동시성 문제는 무엇일까
내가 프로시저를 수정하고 있는데 다른 사람이 끼어들어 같은 프로시저를 수정한다 가정했을 때,
수정 완료 후 커밋 시점엔 최종 결과가 어떻게 될까.
애초에 하나의 프로시저를 유저 여러명이서 수정할 수 있을까.
이런 고민을 하게 만드는 게 동시성 문제가 아닐까 싶다.
즉, ' 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 |