본문 바로가기

Loopers/WIL

Week 4. 동시성 제어

동시성 문제는 무엇일까

 

내가 프로시저를 수정하고 있는데 다른 사람이 끼어들어 같은 프로시저를 수정한다 가정했을 때,

수정 완료 후 커밋 시점엔 최종 결과가 어떻게 될까.

 

애초에 하나의 프로시저를 유저 여러명이서 수정할 수 있을까.

 

이런 고민을 하게 만드는 게 동시성 문제가 아닐까 싶다.

 

즉, ' A 트랜잭션이 수행되는 와중에 B가 개입하는 경우. 어떻게 할 것인가'를 고민하는 문제이다.

 

--

 

트랜잭션과 동시성 제어는 면접 단골 질문이라고 한다.

 

개념은 물론이거니와 이걸 어떻게 잘 설명할 수 있을지가 관건이다.

 

기계적으로 개념만 읊을 게 아니라 내 경험 혹은 구체화가 필요하다.

그러기 위해서는 이번주차에 정확한 개념을 잡고 넘어가야한다.

 

--

 

DB 트랜잭션은 안전한 데이터 처리를 위한 데이터베이스의 "실제 기능"이다.

 

@Transactional은 개발자가 이 DB 트랜잭션을 편리하게 가져다 쓰기 위해 코드 위에 얹는 "AOP 기반의 리모컨"이다.

 

AOP는 핵심 기능과 공통 기능을 분리해서 개발하는 방식이다.

트랜잭션 시작·커밋·롤백 같은 공통 관심사를 비즈니스 로직과 분리해서 어노테이션 하나로 처리할 수 있게 해준다.

 

그렇다고 @Transactional을 모든 곳에 붙이면 안 된다.

커넥션 풀 고갈, 불필요한 프록시 오버헤드, 데이터 잠금 범위 확대 같은 문제가 생긴다.

 

트랜잭션을 쓰느냐 안 쓰느냐의 핵심 차이는 결국 하나다.

문제가 생겼을 때 롤백을 할 것인가.

 

그래서 기준은 이렇게 잡는 게 낫다.

  • 원자성이 보장되어야 하는 C·U·D → @Transactional
  • 단순 조회(R)나 단 건 → @Transactional(readOnly = true) 활용
  • 외부 API 호출이나 컨트롤러 계층 → 걸지 않음

 

--

 

어느 곳에 락을 걸고, 걸지 않을 것인지 결정이 필요했다.

 

재고와 쿠폰, 두 도메인에 같은 방식을 쓰는 게 맞을까 고민이 됐다.

 

재고는 모든 유저가 같은 row를 경쟁한다.

인기 상품이라면 동시에 수십 명이 같은 재고 row를 건드릴 수 있다.

충돌 빈도가 높으니 비관적 락(SELECT FOR UPDATE)이 맞다.

미리 락을 걸고 순서대로 처리하는 게 안전하다.

 

반면 쿠폰은 유저별로 row가 분리되어 있다.

한 사람의 쿠폰을 동시에 두 명이 쓰려는 상황은 현실적으로 드물다.

굳이 SELECT 시점부터 락을 잡을 필요가 없다.

 

WHERE status = 'AVAILABLE' 조건 하나로 UPDATE를 원자적으로 날리면,

DB가 알아서 "딱 하나만 성공"을 보장해준다.

그리고 affected rows = 0이면 "이미 사용된 쿠폰"이라고 명확히 해석할 수 있다.

 

재고는 affected rows = 0이 "재고 부족"인지 "row 없음"인지 구분이 안 된다.

 

이 차이가 둘의 전략을 갈랐다.

 

낙관적 락은 충돌을 허용하되 오직 한 명만 성공하게 하는 경우에 적합하다.

비관적 락은 공유 자원 상태를 선점적으로 보호해야 할 때 적합하다.

 

--

 

트랜잭션 전파도 짚고 넘어가야 했다.

 

createOrder 안에서 쿠폰·재고·주문이 모두 하나의 트랜잭션으로 묶인다.

Spring의 기본 전파 설정(REQUIRED)이 그렇게 동작한다.

안에서 호출된 메서드들은 새 트랜잭션을 만들지 않고 바깥 트랜잭션에 합류한다.

 

여기서 함정이 하나 있다.

 

내부 메서드가 RuntimeException을 던지면 Spring이 트랜잭션에 rollbackOnly 플래그를 세팅한다.

바깥에서 그 예외를 try-catch로 잡아도 플래그는 지워지지 않는다.

 

이후 커밋 시점에 UnexpectedRollbackException이 터진다.

이걸 피하려면 내부 메서드가 예외 대신 int를 반환하게 하고,

바깥 메서드가 직접 예외를 던지는 게 낫다.

 

@Modifying 쿼리가 기본적으로 int를 반환하게 설계된 것도,

이 패턴과 맞닿아 있는 게 아닐까 싶다.

 

--

 

다시 처음 질문으로 돌아와서.

 

A 트랜잭션이 수행되는 와중에 B가 개입하는 경우, 어떻게 할 것인가.

 

DB 레벨에서 보면, 격리 수준이 "B가 A의 데이터를 어느 시점에 볼 수 있는가"를 제어한다.

MySQL InnoDB 기본값인 REPEATABLE READ는 트랜잭션 시작 시점 스냅샷을 유지해

Non-Repeatable Read를 막아준다.

 

하지만 Lost Update는 격리 수준으로 막을 수 없다.

락이나 원자적 UPDATE 같은 명시적 제어가 필요하다.

 

코드 레벨에서 보면, 전파 설정이 "B 메서드가 A 트랜잭션 안에서 실행될 때 무슨 일이 생기는가"를 제어한다.

기본값(REQUIRED)은 B를 A에 합류시키고, rollbackOnly 함정도 이 설정에서 나온다.

 

결국 같은 현상인데 레이어가 다르다.

 

DB는 동시성 문제, Spring은 트랜잭션 경계 문제.

 

제어 도구도 다르다. 헷갈리지 않으려면 두 레이어를 구분해서 생각하는 게 중요하다.

 

--

 

면접에서 "트랜잭션 격리 수준이 뭔가요?" 같은 질문이 나왔을 때,

Dirty Read, Non-Repeatable Read, Phantom Read를 각각 구분해서 설명할 수 있어야 한다.

 

격리 수준이 높아질수록 어떤 문제를 막아주는지,

그리고 Lost Update는 왜 격리 수준으로 해결이 안 되는지.

트랜잭션 전파는 또 다른 레이어의 이야기고,

rollbackOnly 같은 함정도 전파 설정과 맞물려 있다.

 

이 셋을 뒤섞지 않고 레이어별로 구분해서 설명할 수 있는 것, 그게 이번 주 목표였다.

'Loopers > WIL' 카테고리의 다른 글

Week 6. 이론과 수치  (0) 2026.06.26
Week 5. 인덱스와 캐시  (0) 2026.06.19
Week 3. 도메인 주도 설계  (0) 2026.05.29
Week 2. 설계는 선택의 연속이다.  (0) 2026.05.22
Week 1. 모든 게 처음이었다.  (0) 2026.05.15