๑ `⌃´ ๑

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`은 각 트랜잭션 내부를 원자적으로 처리하는 데 도움을 주지만 여러 트랜잭션 사이의 데이터 충돌을 자동으로 모두 해결하지는 않는다.

 

🎵 Playlist
loading...
00:00 / 00:00