1. 동시성(Concurrency)
여러 작업이 같은 시간대에 겹쳐서 진행되는 상황
예를 들어 쇼핑몰에 재고가 1개 남았는데, 사용자 A의 구매 요청과 사용자 B의 구매 요청이 거의 동시에 들어오는 경우이다.
서버에서는 각 요청을 서로 다른 스레드가 처리할 수 있다.
요청 A → Thread A
요청 B → Thread B
두 스레드가 동시에 같은 상품의 재고를 읽고 수정하게 될 수 있다.
2. 공유 데이터
동시성 문제가 생기려면 중요한 조건이 있다.
여러 실행 흐름이 같은 데이터를 공유한다.
// Product
stock = 10
Thread A, B 둘 다 같은 상품을 본다.
즉 두 요청이 서로 완전히 독립적인 데이터만 다룬다면, 이런 충돌이 일어나지 않을 수 있다.
문제는 `여러 요청 → 같은 상품 → 같은 stock`을 수정할 때 발생한다.
3. Race Condition
여러 실행 흐름이 공유 데이터에 접근하고, 실행 순서에 따라 결과가 달라지는 상황
예를 들어 `stock = 10`에서 A와 B가 동시에 주문한다.
정상적으로 한 요청씩 순서대로 처리하면, 대략 아래와 같이 흘러간다.
A
10 조회 → 9 저장
B
9 조회 → 8 저장
최종 = 8
문제가 없으나, 만약 실행이 겹치게 되면 최종값이 달라진다.
A: 10 조회
B: 10 조회
A: 9 계산
B: 9 계산
A: 9 저장
B: 9 저장
즉, 실행 타이밍에 따라 결과가 달라진다. 이런 상황이 Race Condition이다.
4. Lost Update
두 트랜잭션이 각각 수정했는데, 한쪽 수정 결과가 사실상 사라지는 현상이다.
시간 흐름
// DB 초기 상태
Product
stock = 10
두 사용자가 거의 동시에 상품을 구매한다.
시점 1
A가 조회한다.
Transaction A
SELECT stock → 10
A의 영속성 컨텍스트에는 `Product`가 아래와 같이 생성된다.
Product
stock = 10
시점 2
A가 아직 commit하기 전에 B도 조회한다.
Transaction B
SELECT stock → 10
B의 영속성 컨텍스트에도 `Product`가 아래와 같이 생성된다.
이제 `stock`의 상황은 다음과 같다.
DB
stock = 10
A가 보는 Product
stock = 10
B가 보는 Product
stock = 10
A와 B는 서로 다른 Java 객체를 가지고 있을 수 있지만, 같은 DB 행을 기반으로 만들어진 객체이다.
5. A가 재고를 차감한다.
A가 `product.decreaseStock();`를 호출한다.
A 메모리는 `10 → 9`가 된다.
아직 B가 가지고 있는 객체에는 영향이 없다.
A Product
stock = 9
B Product
stock = 10
A와 B는 각자의 트랜잭션과 영속성 컨텍스트를 사용하고 있기 때문이다.
6. B도 재고를 차감한다.
B도 똑같이 `product.decreaseStock();`를 실행한다.
B가 알고 있는 시작 재고도 10이었다.
따라서 `10 → 9`가 된다.
A Product
stock = 9
B Product
stock = 9
여기서 문제가 보이기 시작한다.
실제로 주문은 A 1개 + B 1개 = 2개인데, 두 객체 모두 자기 입장에서는 `10에서 한 개만 차감 → 9`라고 계산했다.
7. Dirty Checking 실행
A의 트랜잭션이 종료된다.
JPA
처음 stock = 10
현재 stock = 9
→ 변경됨
따라서 SQL이 실행된다.
UPDATE product
SET stock = 9
WHERE id = 1;
8. 그 다음 B도 UPDATE
B도 똑같다.
JPA
처음 stock = 10
현재 stock = 9
UPDATE product
SET stock = 9
WHERE id = 1;
DB는 이미 9인데 다시 9로 변경된다.
최종적으로 `stock = 9`이다.
9. 그런데 정답은 8
* 실제로 일어난 업무
초기 재고 = 10
A 구매 1개
B 구매 1개
10 - 1 - 1 = 8
그런데 DB에는 이미 `9`인 상태이다. A의 수정 결과와 B의 수정 결과 중 하나가 사실상 사라졌다.
그래서 Lost Update라고 부른다.
10. 전체 그림
시간
│
▼
Transaction A Transaction B
SELECT stock = 10
|
| SELECT stock = 10
|
decreaseStock()
10 → 9
|
| decreaseStock()
| 10 → 9
|
UPDATE stock = 9
COMMIT
|
UPDATE stock = 9
COMMIT
최종 stock = 9
하지만 실제 판매는 `2개`이고, 따라서 정상 기대값은 `8`, 실제값은 `9`이다.
11. 그래서 왜 `@Transactional`이 문제를 막지 못했나?
두 메서드가 모두 아래와 같은 형태였다고 해보자.
@Transactional
public void decrease(...) {
...
}
그런데도 문제가 생겼다.
`@Transactional`은 각각의 요청에 대해 아래의 과정을 하나의 작업으로 관리한다.
- `A Transaction` : `SELECT → UPDATE → COMMIT`
- `B Transaction` : `SELECT → UPDATE → COMMIT`
즉 각각의 트랜잭션은 정상이다.
문제는 "A와 B가 같은 값을 동시에 읽고 각각 수정했다는 것"이다.
12. 트랜잭션의 원자성과 동시성 제어는 다른 문제
A 주문 안에서 `쿠폰`, `재고`, `포인트`, `주문`을 모두 성공하거나 실패하게 만드는 것이 트랜잭션이 잘 하는 일이다.
그런데 지금 문제는, `Transaction A` vs `Transaction B` 사이의 충돌이다.
즉 원자성과 동시성 제어는 아예 다른 질문이라고 볼 수 있다.
- 원자성 : 내 트랜잭션 안의 작업들이 같이 성공/실패하는가?
- 동시성 제어 : 다른 트랜잭션과 다른 데이터를 수정할 때 충돌을 어떻게 처리할 것인가?
13.`@Transactional`이면 DB가 알아서 순서대로 처리하지 않나?
많이 하는 오해인데, DB가 트랜잭션을 지원한다고 해서 항상 `A 전체 완료 → B 시작`처럼 완전히 직렬로 실행하는 것은 아니다.
그렇게 한다면 안전할 수는 있지만, 동시 처리 성능이 매우 떨어질 것이다.
실제로 여러 트랜잭션은 동시에 진행될 수 있다.
A ────────────────>
B ────────────────>
그리고 DB의 격리 수준, SQL 종류, 락 등의 조건에 따라 서로 보이는 데이터와 충돌 처리 방식이 달라진다.
중요한 것은 "트랜잭션은 서로 겹쳐 실행될 수 있다"는 것이다.
14. Read → Modify → Write 구조
Lost Update를 이해할 때 아주 중요한 패턴이다.
코드로 예를 들어보자.
Product product = productRepository.findById(productId).orElseThrow();
product.decreaseStock();
실제로는 아래의 흐름대로 진행된다.
Read
DB에서 stock = 10 조회
↓
Modify
Java에서 10 → 9
↓
Write
UPDATE stock = 9
즉 `Read → Modify → Write` 구조이다.
동시에 실행하면 아래와 같이 될 수 있다.
A Read 10
B Read 10
A Modify 9
B Modify 9
A Write 9
B Write 9
이 구조가 앞으로 해결 방법을 비교할 때 매우 중요하다.
15. 그러면 `synchronized` 붙이면 되나?
Java에서 동시성을 보면 바로 `synchronized`가 떠오를 수도 있다.
하지만 지금 문제의 공유 데이터는 `JVM 메모리`가 아니라, `Database`이다.
게다가 서버가 여러 대일 경우,
Server A JVM
Server B JVM
각각의 `synchronized`는 서로 알지 못한다.
16. Race Condition과 Lost Update 관계
| 동시성 | 여러 작업이 겹쳐 실행된다. |
| 공유 데이터 | 여러 작업이 같은 stock을 읽고 수정한다. |
| Race Condition | 실행 순서에 따라 결과가 달라진다. |
| Lost Update | 두 수정 중 하나가 결과에서 유실된다. |
동시 실행 + 공유 데이터
↓
Race Condition 가능
↓
그 결과 중 하나
Lost Update
17. 동시성 문제는 항상 발생하나?
A → Product 1 수정
B → Product 2 수정
같은 데이터를 건드리지 않을 경우, 지금과 같은 충돌은 없다.
또한 `A 실행 완료 → B 실행`처럼 요청이 겹치지 않아도 문제가 없음.
그래서 동시성 문제는 개발자의 로컬 테스트에서는 잘 안보일 수 있다.
`내가 버튼 클릭 → 성공 → 다시 클릭 → 또 성공`은 순차 실행이기 때문이다.
실제 운영에서 요청이 겹칠 때만 나타날 수 있다.
그래서 나중에 동시성 테스트를 일부러 만들어야 하는 이유가 생긴다.
18. 일반적인 테스트로는 왜 발견하기 어려울까?
product.decreaseStock();
assertThat(product.getStock()).isEqualTo(9);
위 코드를 실행하면 한 실행 흐름밖에 없다. `10 → 9`
Service 테스트를 100번 반복해도 순차적으로 실행하면 정상일 수 있다.
우리가 확인하고 싶은 것은 `여러 요청이 같은 시점에 같은 데이터를 읽는 상황`이다.
그래서 `ExecutorService`, `CountDownLatch`를 사용해 이 상황을 일부러 만들게 된다.
19. "조회 시 재고 검사"도 안전하지 않을 수 있다
public void decreaseStock() {
if (stock <= 0) {
throw new IllegalStateException();
}
stock--;
}
`Product`가 위와 같이 잘 작성되어 있다. 재고는 1개이다.
A가 아래와 같이 판단한다.
stock = 1
1 > 0
→ 차감 가능
B도 동시에 동일하게 판단한다.
stock = 1
1 > 0
→ 차감 가능
각자의 객체에서는 규칙을 잘 지켰으나, 두 트랜잭션을 함께 보면 문제가 생긴다.
도메인 규칙이 올바르다는 것과 동시에 실행해도 안전하다는 것은 별개의 문제인 것이다.
20. 예제
1) 초기 재고가 `stock = 5`이고, A와 B가 동시에 상품 한 개씩 구매한다.
아래 흐름이라고 가정한다.
A: stock 조회
B: stock 조회
A: decreaseStock()
B: decreaseStock()
A: UPDATE
B: UPDATE
A가 조회한 값: 5
B가 조회한 값: 5
A가 계산한 값: 4
B가 계산한 값: 4
A UPDATE 후 DB: 4
B UPDATE 후 최종 DB: 4
정상적으로 기대하는 최종 값: 3
2) 다음 문장들이 맞는지 판단한다.
A
`@Transactional`을 사용하면 같은 데이터를 수정하는 여러 요청이 자동으로 하나씩 순서대로 실행된다.
틀림. 동시에 실행될 수도 있음
B
두 트랜잭션이 각각 정상적으로 Commit되어도 전체 비즈니스 결과는 잘못될 수 있다.
맞음. 트랜잭션이 정상적으로 작동해도 두 트랜잭션이 같은 이전 값을 기준으로 수정했다면 전체 비즈니스 결과는 잘못될 수 있다.
C
`Product.decreaseStock()`이 재고 부족 검사를 정확히 하고 있다면 동시성 환경에서도 재고 정합성이 자동으로 보장된다.
틀림. 도메인 규칙이 올바른 것과 동시성과는 별개의 문제이다.
D
Lost Update는 여러 트랜잭션이 같은 이전 값을 기반으로 수정한 뒤, 한쪽 변경 결과가 사실상 덮어써지는 문제다.
맞음
3) 다음 두 문제를 구분해본다.
[상황1]
쿠폰 사용 성공
재고 차감 성공
포인트 차감 성공
주문 저장 실패
[상황2]
A가 재고 10 조회
B가 재고 10 조회
A가 9 저장
B가 9 저장
[상황1]
어떤 문제인가? : 트랜잭션 하나의 내부 문제
@Transactional은 어떤 도움을 줄 수 있는가? : 과정 실패 시 rollback하여 데이터 정합성을 지킨다.
@Transactional만으로 충분한가? : 쿠폰, 재고, 포인트, 주문이 모두 같은 DB 트랜잭션으로 관리되는 작업이라면 충분할 수 있음
[상황2]
어떤 문제인가? : 여러 트랜잭션 사이의 문제
@Transactional은 어떤 도움을 줄 수 있는가? : 각 트랜잭션의 원자성을 지킬 수 있게 한다.
@Transactional만으로 충분한가? : 충분하지 않음
21. 정리
- Race Condition은 여러 실행 흐름이 공유 데이터에 접근할 때 실행 순서에 따라 결과가 달라지는 문제다.
- Lost Update는 여러 트랜잭션이 같은 이전 값을 기반으로 수정하면서 한쪽 변경 결과가 유실되는 현상이다.
- `@Transactional`은 각 트랜잭션 내부를 원자적으로 처리하는 데 도움을 주지만 여러 트랜잭션 사이의 데이터 충돌을 자동으로 모두 해결하지는 않는다.
'Spring' 카테고리의 다른 글
| 비관적 락 / SELECT ... FOR UPDATE / PESSIMISTIC_WRITE (0) | 2026.08.31 |
|---|---|
| ExecutorService / CountDownLatch / 동시성 테스트 (0) | 2026.08.29 |
| JPA 영속성 컨텍스트 / Dirty Checking / Flush (0) | 2026.08.26 |
| 트랜잭션 / ACID / Commit / Rollback / @Transactional (0) | 2026.08.25 |
| [설계] Layered Architecture / Application Service / Repository Interface / DIP (0) | 2026.08.25 |