본문 바로가기

개발기술/설계|소프트웨어패턴

동시성 고려 프로그래밍(stateless, Thread-Safe, DB Lock)

 

 

 

 

1. 필드에 값을 저장하지 마라 (Stateless)

  • 핵심: 내가 만든 클래스(Service, Controller 등)가 '상태를 가지지 않게(Stateless)' 설계하는 것이 기본입니다.
  • 이유: 서버는 수많은 사용자의 요청을 스레드별로 처리하는데, 이들이 똑같은 서비스 객체를 공유하기 때문입니다.
  • 실천: 데이터는 메서드 내부의 지역 변수파라미터로만 전달하세요. 지역 변수는 스레드마다 독립적인 스택(Stack) 영역에 생성되므로 동시성 문제에서 완전히 자유롭습니다.

2. 저장한다면 동시성이 고려된 도구를 써라 (Thread-Safe)

  • 핵심: 어쩔 수 없이 공유 자원(캐시, 공통 설정 등)을 메모리에 들고 있어야 한다면, 일반 자료구조는 절대 금물입니다.
  • 실천: HashMap 대신 ConcurrentHashMap, int 대신 AtomicInteger를 사용하세요. 이것만 바꿔도 코드 레벨의 동시성 버그 90%는 예방할 수 있습니다.

3. DB는 '나만 보고 있는 게 아니다'라고 생각하라 (Lock)

  • 핵심: 내 코드가 DB에서 값을 가져오는 순간과 수정을 요청하는 순간 사이에 다른 놈이 새치기할 수 있다는 것을 항상 의심해야 합니다.
  • 실천: * 충돌이 잦을 것 같으면? → 비관적 락 (강력하게 잠그기)
    • 충돌이 적고 성능이 중요하면? → 낙관적 락 (버전 체크)

 

 

threadsafe 자료구조를 사용이유

 

  • 초기화 시점의 가시성(Visibility): Java 메모리 구조상, 메인 스레드가 init에서 Map에 값을 넣었어도, 다른 스레드(사용자 요청 스레드)가 그 값을 즉시 보지 못할 수도 있는 아주 미세한 문제가 발생할 수 있습니다. (물론 보통은 보이지만, 원칙적으로는 volatile이나 final 같은 키워드 없이 공유하는 건 위험 요소가 있습니다.)

 

  • HashMap vs ConcurrentHashMap: 로컬에 Map을 캐시할 때 동시성 문제 해결.
  • AtomicInteger: 로컬에서 숫자를 집계할 때 데이터 깨짐 방지.
  • volatile / AtomicBoolean: 로컬에 둔 스위치 값이 모든 스레드에 즉시 보이게 보장.

 

 

갱신할 때 왜 thread-safe 해야 하지?"

이게 진짜 이유입니다. 실무에서 캐시는 **"중간에 바뀔 수 있다"**는 점이 핵심입니다.

상황: 관리자가 공지사항을 수정해서 캐시를 업데이트해야 한다면?

HashMap을 쓰고 있는데, 마침 **스레드 A(관리자)**가 데이터를 지우고 새로 넣는(Update) 그 0.0001초 사이에 **스레드 B(사용자)**가 데이터를 읽으려 한다면 어떤 일이 벌어질까요?

  1. 데이터 오염: 사용자가 읽어간 데이터가 null이거나, 리사이징 중인 잘못된 메모리 주소를 참조해 NullPointerException이나 IndexOutOfBoundsException이 발생할 수 있습니다.
  2. 좀비 루프: 아주 드문 케이스지만, Java 8 이전의 HashMap은 데이터 수정과 읽기가 동시에 일어날 때 내부 구조가 꼬여서 무한 루프에 빠져 CPU 점유율을 100% 먹어치우는 현상이 있었습니다.

 

 

 

 

 

질문자님이 생각하신 **"A 아니면 B겠지"**는 데이터가 아주 작고 단순할 때(예: int, boolean)나 가능한 이야기입니다. 하지만 HashMap 같은 복잡한 객체는 수정하는 과정이 매우 길고 복잡하기 때문에, **'수정 중인 엉망진창인 상태'**를 노출하지 않기 위해 thread-safe라는 보호막이 필요한 것입니다.

 


1. 로컬 캐시 (Local Cache)

DB 조회는 비용이 비쌉니다. 그래서 자주 바뀌지 않는 데이터(예: 공지사항 리스트, 공통 코드, 카테고리 목록)를 DB에서 매번 가져오지 않고, 메모리(필드)에 딱 한 번 올려두고 모든 사용자가 돌려보게 할 때 씁니다.

  • 왜 필드인가? 메서드 안의 지역 변수는 메서드가 끝나면 사라지지만, 필드는 서비스가 살아있는 동안 유지되니까요.
  • 왜 Thread-safe 인가? 여러 사용자가 동시에 "공지사항 보여줘!"라고 할 때, 누군가는 캐시를 갱신하고 누군가는 읽어야 하므로 데이터가 꼬이지 않게 ConcurrentHashMap 등을 씁니다.

2. 카운팅 및 집계 (Counting)

실시간으로 서비스 전체의 상태를 추적해야 할 때입니다.

  • 예시: "현재 접속자 수", "오늘 발송된 총 알림 개수", "실시간 급상승 검색어 순위"
  • 왜 필드인가? 모든 스레드(사용자 요청)가 공통된 숫자 하나를 계속 올리거나 내려야 하기 때문입니다.
  • 왜 Thread-safe 인가? int count = 0; 필드에 100명이 동시에 count++를 하면, 동기화 처리가 없을 경우 100이 아니라 80이나 90 같은 엉뚱한 숫자가 나올 수 있습니다. (그래서 AtomicInteger 같은 걸 씁니다.)

3. 공유 설정 및 상태 제어

시스템의 동작 모드를 실시간으로 바꿀 때 씁니다.

  • 예시: "점검 모드 활성화 여부", "외부 API 연동 On/Off 스위치"
  • 왜 필드인가? 관리자가 스위치를 On으로 바꾸면, 그 즉시 접속해 있는 모든 사용자의 로직에 영향이 가야 하기 때문입니다.

 

1. "조각난 데이터"를 읽을 위험 (Atomicity 문제)

우리는 코드 한 줄로 데이터를 바꾸지만, CPU와 메모리 입장에서는 여러 번에 걸쳐 데이터를 쪼개서 옮깁니다.

  • 상황: 아주 큰 데이터를 담고 있는 객체를 HashMap에 넣는 중이라고 가정해 봅시다.
  • 문제: 스레드 1이 데이터를 쓰고 있는데(Write), 그 작업이 50%만 완료된 시점에 스레드 2가 읽기(Read)를 시도하면 어떻게 될까요?
  • 결과: 스레드 2는 A(옛날 것)도 아니고 B(새것)도 아닌, 공사 중인(메모리가 깨진) 데이터를 보게 됩니다. 이 데이터를 참조하면 프로그램이 바로 꺼지거나(Crash) 말도 안 되는 버그가 터집니다.

2. "업데이트 사실을 모름" (Visibility 문제)

CPU는 속도를 높이기 위해 메인 메모리에서 가져온 값을 자기 전용 **캐시(L1, L2 캐시)**에 복사해두고 씁니다.

  • 문제: 스레드 1(관리자)이 메인 메모리의 값을 B로 바꿨는데, 스레드 2(사용자)는 여전히 자기 CPU 캐시에 들어있는 옛날 값 A만 보고 있을 수 있습니다.
  • 결과: "분명히 바꿨는데 왜 반영이 안 되지?" 하는 현상이 발생합니다. ConcurrentHashMap이나 volatile 같은 키워드는 "야, 캐시 보지 말고 메인 메모리 가서 확인해!"라고 강제하는 역할도 합니다.

3. HashMap 내부의 "구조적 붕괴" (Internal Structure)

HashMap은 데이터를 단순히 쌓는 게 아니라, 내부적으로 **배열(Bucket)**과 **연결 리스트(Node)**를 복잡하게 엮어서 관리합니다.

  • 문제: 데이터가 많아지면 HashMap은 내부 배열 크기를 키우는 **'리사이징(Resizing)'**을 합니다. 이때 기존 데이터를 새 배열로 옮기는 이사가 벌어집니다.
  • 최악의 상황: 이 이사가 진행 중일 때 누군가 데이터를 읽으려 하면, 데이터가 들어있는 주소 자체가 순간적으로 증발하거나 엉뚱한 곳을 가리키게 됩니다. 이때 자바에서는 그 유명한 ConcurrentModificationException이 터지거나, 프로그램이 무한 루프에 빠져 멈춰버립니다

1. "가나"만 읽히는 상황 (Dirty Read / Partial Write)

컴퓨터가 가나다라라는 데이터를 메모리에 적을 때, 우리 눈에는 한 번에 적는 것 같지만 실제로는 '가', '나', '다', '라'를 순서대로 메모리 칸에 채우는 과정을 거칩니다.

  • 스레드 A (쓰기): 가... 나... (적는 중)
  • 스레드 B (읽기): 오! 데이터가 있네? (하고 가나만 홀라당 읽어감)
  • 결과: 사용자는 원래 존재하지도 않는 가나라는 깨진 데이터를 받게 됩니다. 만약 이게 결제 금액이나 비밀번호라면 끔찍한 사고가 되겠죠?

2. "가나다라"를 찾으러 갔는데 길이 끊긴 상황 (Structural Corruption)

HashMap은 내부적으로 연결 리스트(Linked List) 구조를 자주 사용합니다. 데이터를 찾으러 갈 때 "가 -> 나 -> 다 -> 라" 순서로 화살표를 따라간다고 생각해보세요.

  • 상황: 데이터를 지우거나 수정하면 이 **화살표(참조)**를 옮겨야 합니다.
  • 문제: 스레드 A가 화살표를 끊고 새로 연결하는 그 찰나에 스레드 B가 들어오면?
  • 결과: 화살표가 공중에 붕 떠버려서 스레드 B는 데이터를 찾지 못하고 NullPointerException을 뱉으며 서버가 에러를 냅니다.

3. "무한 루프"에 빠지는 상황 (Race Condition)

이게 HashMap에서 가장 무서운 점인데, 여러 명이 동시에 수정하다 보면 내부 화살표가 꼬여서 "가 -> 나 -> 가 -> 나..." 식으로 순환 구조가 생길 수 있습니다.

  • 그러면 읽기 스레드는 여기서 영원히 빠져나오지 못하고 CPU를 100% 점유하며 서버를 뻗게 만듭니다.

 

1. 덮어쓰기 (Lost Update)

가장 흔하게 발생하는 문제입니다. 두 스레드가 거의 동시에 같은 곳에 값을 쓰려고 할 때 발생합니다.

  • 스레드 A: 가나다를 쓰려고 준비함.
  • 스레드 B: 라마바를 쓰려고 준비함.
  • 결과: 스레드 A가 쓴 가나다가 순식간에 스레드 B의 라마바로 덮어씌워집니다. A 입장에서는 분명히 값을 저장했는데, 나중에 확인해보니 데이터가 증발한 셈이죠.

2. 끔찍한 혼종 데이터 (Dirty Write)

이게 질문자님이 말씀하신 "망가지는" 상태의 절정입니다. 데이터가 원자적(Atomic)으로 써지지 않으면 이런 일이 벌어집니다.

  • 스레드 A: 가나다를 쓰는 중 (가... 나...)
  • 스레드 B: 같은 칸에 라마바를 쓰는 중 (라...)
  • 결과: 메모리에는 라나다 혹은 가마바 같은, 세상에 존재하지 않는 혼종 데이터가 남게 됩니다.

3. HashMap 내부의 대재앙 (Internal Corruption)

단순히 글자만 바뀌는 게 아니라, HashMap 내부의 **기차 칸(Node)**을 연결하는 과정에서 충돌하면 더 심각해집니다.

  1. 칸 유실: 스레드 두 개가 동시에 새로운 데이터를 추가하려고 빈 칸을 찾았는데, 둘 다 같은 빈 칸을 보고 "여기가 비었네!" 하고 자기 데이터를 집어넣습니다. 그러면 먼저 들어간 데이터는 화살표가 끊겨서 영원히 찾을 수 없는 미궁 속 데이터가 됩니다. (메모리는 먹고 있는데 조회는 안 됨)
  2. 순환 참조 (무한 루프): 데이터를 넣으면서 화살표를 연결하다가, 두 스레드의 작업이 꼬여서 A -> B -> A 형태로 화살표가 연결되어 버리면 서버가 이 데이터를 읽으려 할 때 무한 루프에 빠져 죽어버립니다.

💡 Thread-safe 자료구조는 이걸 어떻게 막나요?

그래서 ConcurrentHashMap 같은 것들은 내부적으로 **'줄 세우기'**를 합니다.

  • 락(Lock) 또는 CAS(Compare-And-Swap): "내가 지금 이 칸을 쓰고 있으니, 너는 잠깐 기다려!"라고 문을 잠그거나, "내가 쓰려고 했던 칸이 그새 바뀌었네? 그럼 다시 계산해서 써야지"라고 확인하는 절차를 거칩니다.

결론: "동시성 고려"의 진짜 의미

결국 면접관이 지적했던 "동시성 고려"의 핵심은 이것입니다.

"여러 명이 동시에 달려들어서 데이터를 읽거나 쓸 때, 데이터가 '반만 써지거나', '덮어씌워지거나', '내부 구조가 꼬여서 서버가 멈추는' 일을 막을 준비가 되어 있는가?"

질문자님은 이제 **"동시성 = 데이터 파손 방지"**라는 아주 중요한 본질을 완벽히 파악하신 거예요. 1년차 개발자가 이 정도 인과관계를 스스로 추론해냈다는 건 정말 대단한 겁니다.

혹시 이제 "그러면 무조건 안전한 ConcurrentHashMap만 쓰면 되는 거 아냐? 왜 굳이 HashMap이 기본이지?" 같은 의문이 들진 않으신가요? 이 차이점까지 알면 자료구조 마스터가 되실 수 있습니다! 추가로 궁금한 점 있으신가요?

 

 

# Batch 서버 Event 중복 처리 Race Condition 장애 해결기

## 1. 문제 상황

### 발생 시점
JVM OOM 문제 해결 후 Batch 서버 재기동 시 예상치 못한 2차 장애 발생

### 증상
- Batch 서버가 정상적으로 기동되지 않음
- 미처리된 Event log가 대량 적재되어 있었음
- Batch의 처리 속도가 느려 동일 event가 queue에 중복 진입
- Blocking Queue에서 event dropping 발생
- 동일한 event가 중복으로 처리됨

### 아키텍처 구조
```
[Event Log Table (DB)]
        ↓
   [Producer Thread] - event log 조회 및 가져오기
        ↓
  [Blocking Queue] - 메모리 버퍼
        ↓
   [Worker Thread] - event 처리
```

---

## 2. 긴급 조치

### 상황 분석
- Event log 테이블에 처리되지 않은 데이터가 과도하게 누적 (OOM 이후)
- Producer가 빠르게 event를 조회하지만, Worker의 처리 속도가 느림
- 처리되지 않은 event를 Producer가 다시 조회 → Queue에 중복 진입
- Blocking Queue 용량 초과로 event dropping 발생

### 조치 내용
```sql
-- 오래된 event log의 count 감소 처리
UPDATE event_log
SET cnt = cnt - 1
WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)
  AND cnt > 0;
```

**결과:**
- Event 처리 대상 범위 축소
- Batch 서버 정상 기동 성공
- Event log 복구 로직으로 누락 데이터 복구

---

## 3. 근본 원인 분석

### 문제의 핵심: Race Condition

**정상 흐름:**
```
Producer: event A 조회 → Queue 추가 → DB에서 A 제외 처리
Worker: Queue에서 A 꺼내기 → 처리
```

**비정상 흐름 (Race Condition):**
```
시간 T1: Producer가 event A 조회
시간 T2: Producer가 event A를 Queue에 추가 (아직 DB 업데이트 전)
시간 T3: Producer가 다시 event A 조회 (DB에서 아직 제외 안 됨)
시간 T4: Producer가 event A를 Queue에 또 추가
시간 T5: Worker가 event A 처리 (1번째)
시간 T6: Worker가 event A 처리 (2번째) ← 중복 처리!
```

### 원인 정리

1. **원자성(Atomicity) 부재**
   - "조회 → Queue 추가 → DB 업데이트" 과정이 원자적이지 않음
   - 조회와 업데이트 사이에 시간차 존재

2. **멱등성(Idempotency) 미보장**
   - 동일 event를 여러 번 처리해도 결과가 같아야 하는데 그렇지 않음
   - Event 중복 처리 시 통계 데이터 왜곡

3. **비정상 상황에서 문제 발현**
   - 정상: Event log가 적음 → 중복 조회 확률 낮음
   - 비정상: Event log 과다 누적 → 중복 조회 확률 급증

---

## 4. 해결 방안 (실무에서 많이 쓰는 방법)

### 설계 원칙

**가장 방어적인 가정:**
- Producer: Multi Thread (동시에 여러 개가 event 조회)
- Consumer: Multi Thread (동시에 여러 개가 event 처리)

**핵심 원칙:**
> "외부 시스템(DB/Redis)과 접촉하는 **최초 지점**에서 중복을 차단해야,
> 이후 내부에서는 데이터 정합성을 공리로 가정하고 진행할 수 있다."

**두 가지 접근:**
1. **Producer 레벨 방어**: Queue 적재 전 차단 (근본적)
2. **Consumer 레벨 방어**: 처리 시 차단 (보완적)

---

### 방안 1: DB Unique Constraint (Consumer 레벨) ⭐ 필수

**가장 기본적이고 확실한 방법**

#### 구현

```sql
-- Event 처리 이력 테이블
CREATE TABLE event_process_history (
    event_id VARCHAR(255) PRIMARY KEY,
    processed_at DATETIME,
    processed_by VARCHAR(100)
);
```

```java
public void processEvent(Event event) {
    try {
        // 1. 처리 이력 저장 (중복 시 Exception)
        insertProcessHistory(event.getId());

        // 2. 실제 처리
        doBusinessLogic(event);

    } catch (DuplicateKeyException e) {
        // 이미 처리됨, skip
        log.info("Event already processed: {}", event.getId());
    }
}

private void insertProcessHistory(String eventId) {
    jdbcTemplate.update(
        "INSERT INTO event_process_history (event_id, processed_at) VALUES (?, NOW())",
        eventId
    );
}
```

#### 동작 원리

**Q: 두 Thread가 동시에 같은 event 처리하면?**

```
Thread 1: INSERT INTO event_process_history (event_id) VALUES ('event-1')
         → 성공 (먼저 도착)

Thread 2: INSERT INTO event_process_history (event_id) VALUES ('event-1')
         → DuplicateKeyException (이미 존재)
         → catch로 skip
```

**핵심:** DB가 PRIMARY KEY로 자동 Lock을 걸어서 순차 처리합니다.

#### 장점
- ✅ DB 레벨 보장 (100% 확실)
- ✅ 구현 단순
- ✅ 분산 환경에서도 동작
- ✅ 처리 이력 추적 가능
- ✅ 추가 인프라 불필요
- ✅ Producer 구조 변경 불필요

#### 단점
- ❌ Insert 오버헤드
- ❌ 중복 event가 Queue까지 진입 (메모리 낭비)

#### 주의사항: Transaction 분리

❌ **나쁜 예:**
```java
@Transactional
public void processEvent(Event event) {
    insertProcessHistory(event.getId());
    doBusinessLogic(event);  // 여기서 Exception → 전부 rollback!
}
```

✅ **좋은 예:**
```java
public void processEvent(Event event) {
    try {
        // 별도 Transaction으로 처리 이력 저장
        historyService.saveHistory(event.getId());
    } catch (DuplicateKeyException e) {
        return;  // skip
    }

    // 비즈니스 로직 (별도 Transaction)
    businessService.process(event);
}
```

**이유:** 비즈니스 로직이 실패해도 처리 이력은 남아야 재처리를 방지합니다.

---

### 방안 2: SELECT FOR UPDATE SKIP LOCKED (Producer 레벨)

**Queue 적재 전에 중복을 차단하는 방법**

#### 구현

```java
@Transactional
public void loadEvents() {
    // 1. 조회 + Row Lock 획득
    List<Event> events = entityManager.createQuery(
        "SELECT e FROM EventLog e " +
        "WHERE e.status = 'PENDING' " +
        "ORDER BY e.createdAt " +
        "FOR UPDATE SKIP LOCKED",  // 핵심!
        EventLog.class)
        .setMaxResults(100)
        .getResultList();

    // 2. 즉시 상태 변경 (다른 Thread가 못 가져가게)
    for (Event event : events) {
        event.setStatus("QUEUED");
    }

    // 3. Queue에 추가
    for (Event event : events) {
        blockingQueue.offer(event);
    }
}
```

#### 동작 원리

**`FOR UPDATE SKIP LOCKED` 설명:**

```
FOR UPDATE: Row Lock 획득 (다른 Thread 대기)
SKIP LOCKED: Lock된 Row는 건너뜀 (대기 없이)
```

**예시:**
```
Thread 1: SELECT ... FOR UPDATE SKIP LOCKED
         → event 1, 2, 3, 4, 5 조회 + Lock

Thread 2: SELECT ... FOR UPDATE SKIP LOCKED (동시 실행)
         → event 6, 7, 8, 9, 10 조회 + Lock
         (1~5는 Thread 1이 Lock 중이라 SKIP)
```

**결과:**
- Thread 1: event 1~5 처리
- Thread 2: event 6~10 처리
- **겹치지 않음!**

#### 장점
- ✅ Producer 레벨에서 중복 차단 (근본적)
- ✅ DB 레벨 보장 (확실함)
- ✅ Thread간 event 중복 없음
- ✅ Queue에 중복 진입 자체가 불가능

#### 단점
- ❌ DB 종속적 (MySQL 8.0+, PostgreSQL 9.5+, MariaDB 10.6+)
- ❌ Lock 오버헤드
- ❌ 트랜잭션 관리 필요

---

### 방안 비교 및 선택 가이드

| 방안 | 난이도 | 확실성 | 변경점 | 백엔드 필수 지식 |
|------|--------|--------|--------|----------------|
| **Unique Constraint** | ⭐ 쉬움 | ⭐⭐⭐ 매우 확실 | 테이블 추가, try-catch | ✅ 필수 |
| **FOR UPDATE SKIP LOCKED** | ⭐⭐ 보통 | ⭐⭐⭐ 매우 확실 | 쿼리 수정 | ✅ 실무 필수 |

#### 권장 조합 (현재 시스템 기준)

**최소 변경 (필수):**
```
Consumer: Unique Constraint
→ 테이블 1개 추가 + try-catch
```

**최적 (다층 방어):**
```
Producer: FOR UPDATE SKIP LOCKED (쿼리 수정)
Consumer: Unique Constraint (테이블 + try-catch)
→ 변경점 적으면서 안전성 최대
```

---

### 백엔드 엔지니어가 꼭 알아야 하는 이유

#### 1. Unique Constraint
- **모든 시스템에서 사용**
  - 주문 ID 중복 방지
  - 결제 트랜잭션 중복 방지
  - 이메일 중복 가입 방지
- **기본 중의 기본**
- 1년차도 반드시 알아야 함

#### 2. FOR UPDATE SKIP LOCKED
- **Job Queue / Batch 처리의 표준**
  - Spring Batch, Celery, Sidekiq 등에서 사용
  - Worker Pool 환경에서 필수
- **실무 2-3년차 필수 지식**
- 주문 처리, 예약 시스템 등에서 활용

---

## 5. 테스트 계획 (실제 적용 시)

### 재현 시나리오
1. Event log 대량 적재 (1000건+)
2. Batch 서버 기동
3. 중복 처리 발생 여부 확인

### 검증 항목
- [ ] Event 처리 이력 중복 없음
- [ ] 통계 데이터 정확성
- [ ] 성능 저하 허용 범위 내

---

## 6. 회고

### 배운 점

1. **긴급 조치 vs 근본 해결**
   - 긴급 조치(cnt -1)로 서비스는 복구
   - 하지만 근본 원인(race condition)은 여전히 존재
   - 두 가지를 모두 해야 완전한 해결

2. **정상 상황에서 드러나지 않는 동시성 문제**
   - Event log 처리량이 적을 때는 문제가 나타나지 않음
   - 부하 상황(대량 적재)에서 race condition 발현
   - 비정상 시나리오에 대한 테스트의 중요성

3. **Producer-Consumer 패턴의 적용 조건**
   - Producer와 Consumer의 속도 차이가 클 때 유효
   - Worker가 Multi Thread일 때 처리량 증가
   - 단, 동시성 제어는 필수 고려사항

4. **방어적 설계 원칙**
   - 외부 시스템 접촉 최초 지점에서 중복 차단
   - 이후 내부에서는 데이터 정합성을 신뢰
   - Defense in Depth (다층 방어)

5. **동시성 제어의 중요성**
   - 원자성, 멱등성은 Multi Thread 환경에서 필수
   - "잘 안 일어나는 문제"도 운영 환경에서는 발생 가능
   - DB가 제공하는 기본 기능 활용 (Unique Constraint, FOR UPDATE)

### 다음 액션

- [ ] DB Unique Constraint 적용 (필수)
- [ ] Producer Multi Thread 환경이면 FOR UPDATE SKIP LOCKED 검토
- [ ] 부하 테스트로 검증
- [ ] 모니터링 강화 (중복 처리 감지)

---

## 기술 스택

- Java 17
- Spring Boot 3.2
- Spring Batch
- MariaDB / MySQL
- Redis
- BlockingQueue (java.util.concurrent)

---

## 참고 자료

- MySQL 공식 문서: SELECT FOR UPDATE
- Spring Data JPA: Locking
- Redis 공식 문서: List Commands

---

**작성일:** 2025-11-09
**태그:** #운영장애 #RaceCondition #동시성제어 #멱등성 #Batch


========================================

1. Unique Constraint (DB 제약 조건)

핵심 개념
- DB 테이블에 중복 값 저장 불가 제약
- PRIMARY KEY, UNIQUE KEY

언제 쓰나?
- 회원 이메일 중복 방지
- 주문 ID 중복 방지
- 결제 트랜잭션 ID 중복 방지
- Event 처리 중복 방지

코드 예시

CREATE TABLE users (
    email VARCHAR(255) PRIMARY KEY  -- 중복 불가
);

CREATE TABLE event_history (
    event_id VARCHAR(255) UNIQUE  -- 중복 불가
);

try {
    // 중복 시 Exception 발생
    insertUser(email);
} catch (DuplicateKeyException e) {
    // 이미 존재
}

동작 원리
- DB가 INSERT 시 자동으로 중복 체크
- 중복이면 Exception
- 별도 Lock 코드 불필요

장점
✅ 간단
✅ 확실함 (DB 레벨 보장)
✅ 분산 환경 OK

단점
❌ Insert 오버헤드

면접 질문

Q: "회원 가입 시 이메일 중복을 어떻게 방지하나요?"

A: "DB에 email 컬럼을 UNIQUE 제약으로 설정하고, DuplicateKeyException 처리합니다."

========================================

2. Pessimistic Lock (비관적 락) - SELECT FOR UPDATE

핵심 개념
- "충돌 날 것 같으니 미리 Lock 걸자"
- 데이터 읽을 때 Lock 획득
- Transaction 끝날 때까지 다른 Thread 대기

언제 쓰나?
- 충돌 확률 높을 때
- 재고 차감 (동시 주문 많음)
- 좌석 예약 (동시 예약 많음)
- Job Queue (여러 Worker가 같은 Job 가져감)
- Batch Event 처리

코드 예시

일반 SELECT (문제 있음)

@Transactional
public void reserve(Long seatId) {
    Seat seat = em.find(Seat.class, seatId);
    // Thread 2도 같은 seat 조회 가능 → 문제!
    seat.setStatus("RESERVED");
}

SELECT FOR UPDATE (해결)

@Transactional
public void reserve(Long seatId) {
    Seat seat = em.createQuery(
        "SELECT s FROM Seat s WHERE s.id = :id FOR UPDATE",
        Seat.class)
        .setParameter("id", seatId)
        .getSingleResult();

    // Thread 2는 대기 (Thread 1 끝날 때까지)
    seat.setStatus("RESERVED");
}

FOR UPDATE SKIP LOCKED (중요!)

Job Queue 처리에 필수

@Transactional
public List<Job> getJobs() {
    return em.createQuery(
        "SELECT j FROM Job j WHERE status = 'PENDING' " +
        "FOR UPDATE SKIP LOCKED",
        Job.class)
        .setMaxResults(10)
        .getResultList();
}

// Thread 1: Job 1~10 Lock
// Thread 2: Job 11~20 Lock (1~10은 SKIP)
// → 겹치지 않음!

동작 원리
1. SELECT FOR UPDATE 실행
2. DB가 해당 Row에 Lock 걸음
3. 다른 Thread는 대기 (또는 SKIP LOCKED면 건너뜀)
4. Transaction 끝나면 Lock 해제

장점
✅ DB 레벨 보장 (확실함)
✅ Thread간 충돌 방지
✅ SKIP LOCKED로 대기 없이 처리

단점
❌ DB 종속적 (MySQL 8.0+, PostgreSQL 9.5+, MariaDB 10.6+)
❌ Lock 오버헤드

면접 질문

Q: "재고 차감 시 동시성 문제를 어떻게 해결하나요?"

A: "SELECT FOR UPDATE로 재고 Row에 Lock을 걸어, 한 번에 한 Thread만 차감하도록 합니다."

Q: "여러 Worker가 Job을 가져갈 때 중복을 어떻게 방지하나요?"

A: "SELECT FOR UPDATE SKIP LOCKED로 각 Worker가 서로 다른 Job을 가져가게 합니다."

========================================

3. Optimistic Lock (낙관적 락) - @Version

핵심 개념
- "충돌 안 날 것 같으니 Lock 안 걸고, 나중에 확인하자"
- 읽을 때 Lock 안 걸음
- 업데이트할 때 Version 비교

언제 쓰나?
- 충돌 확률 낮을 때
- 읽기 많고 쓰기 적음
- 게시글 수정 (동시 수정 드뭄)
- 프로필 업데이트 (혼자 수정)
- 장바구니 수정

코드 예시

Entity 설정

@Entity
public class Post {
    @Id
    private Long id;

    private String content;

    @Version  // 핵심!
    private Long version;
}

사용

// Thread 1
@Transactional
public void updatePost(Long postId, String newContent) {
    Post post = postRepo.findById(postId).get();  // version = 1
    post.setContent(newContent);
    postRepo.save(post);  // version = 2로 업데이트
}

// Thread 2 (거의 동시)
@Transactional
public void updatePost(Long postId, String newContent) {
    Post post = postRepo.findById(postId).get();  // version = 1 (같음)
    post.setContent(newContent);
    postRepo.save(post);
    // → OptimisticLockException! (version이 2로 바뀜)
}

동작 원리

JPA가 자동 생성하는 SQL:

UPDATE post
SET content = ?, version = version + 1
WHERE id = ? AND version = 1;

-- 0 row updated → OptimisticLockException

재시도 처리

@Transactional
public void updatePost(Long postId, String newContent) {
    int retryCount = 0;

    while (retryCount < 3) {
        try {
            Post post = postRepo.findById(postId).get();
            post.setContent(newContent);
            postRepo.save(post);
            return;  // 성공

        } catch (OptimisticLockException e) {
            retryCount++;
            // 재시도
        }
    }

    throw new RuntimeException("Update failed after 3 retries");
}

장점
✅ Lock 안 걸어서 성능 좋음
✅ 읽기 많은 환경에 적합
✅ 구현 단순 (@Version만 추가)

단점
❌ 충돌 시 Exception (재시도 필요)
❌ 충돌 많으면 비효율

면접 질문

Q: "게시글 수정 시 동시성 문제를 어떻게 해결하나요?"

A: "@Version으로 Optimistic Lock을 사용합니다. 충돌 확률이 낮아서 성능이 좋습니다."

Q: "Pessimistic Lock과 Optimistic Lock 차이가 뭔가요?"

A:
- Pessimistic: 미리 Lock 걸음, 충돌 많을 때 사용
- Optimistic: 나중에 확인, 충돌 적을 때 사용

========================================

비교표 (면접용)

방법                    | Lock 시점  | 충돌 확률 | 성능   | 사용 예                    | 필수 레벨
Unique Constraint      | INSERT 시  | -         | 빠름   | 중복 방지 (이메일, ID)     | 1년차 필수
Pessimistic Lock       | SELECT 시  | 높음      | 느림   | 재고, 좌석, Job Queue      | 2-3년차 필수
Optimistic Lock        | UPDATE 시  | 낮음      | 빠름   | 게시글, 프로필             | 2-3년차 필수

========================================

실전 선택 가이드 (면접 답변용)

Q: "어떤 상황에 어떤 Lock을 쓰나요?"

1. 중복 방지 → Unique Constraint
- 회원 이메일
- 주문 ID
- Event 처리 이력
- 가장 기본적, 무조건 써야 함

2. 충돌 많음 (동시 쓰기) → Pessimistic Lock
- 재고 차감 (동시 주문)
- 좌석 예약 (동시 예약)
- Job Queue (여러 Worker)
- Batch Event 처리

3. 충돌 적음 (읽기 많음) → Optimistic Lock
- 게시글 수정
- 프로필 업데이트
- 장바구니

========================================

실무 적용 예시

예시 1: 온라인 쇼핑몰 재고 관리

문제: 동시에 10명이 마지막 1개 상품 주문

해결:

@Transactional
public void orderProduct(Long productId, int quantity) {
    // 1. Pessimistic Lock으로 재고 조회
    Product product = em.createQuery(
        "SELECT p FROM Product p WHERE p.id = :id FOR UPDATE",
        Product.class)
        .setParameter("id", productId)
        .getSingleResult();

    // 2. 재고 확인
    if (product.getStock() < quantity) {
        throw new OutOfStockException();
    }

    // 3. 재고 차감
    product.setStock(product.getStock() - quantity);

    // 4. 주문 생성 (Unique Constraint)
    Order order = new Order(generateOrderId(), productId, quantity);
    orderRepo.save(order);  // order_id UNIQUE
}

예시 2: 영화 좌석 예약

문제: 동시에 여러 명이 같은 좌석 예약

해결:

@Transactional
public void reserveSeat(Long seatId, Long userId) {
    // Pessimistic Lock
    Seat seat = em.createQuery(
        "SELECT s FROM Seat s WHERE s.id = :id FOR UPDATE",
        Seat.class)
        .setParameter("id", seatId)
        .getSingleResult();

    if (!"AVAILABLE".equals(seat.getStatus())) {
        throw new AlreadyReservedException();
    }

    seat.setStatus("RESERVED");
    seat.setUserId(userId);
}

예시 3: 게시글 수정

문제: 동시 수정은 드물지만 가끔 발생

해결:

@Entity
public class Post {
    @Id
    private Long id;
    private String content;

    @Version  // Optimistic Lock
    private Long version;
}

@Transactional
public void updatePost(Long postId, String newContent) {
    try {
        Post post = postRepo.findById(postId).get();
        post.setContent(newContent);
        postRepo.save(post);

    } catch (OptimisticLockException e) {
        // 재시도 또는 사용자에게 알림
        throw new ConcurrentModificationException("다른 사용자가 수정 중입니다");
    }
}

========================================

암기용 핵심 (면접 전 복습)

1. Unique Constraint
- 중복 불가 제약
- DB가 자동 체크
- 주문 ID, 이메일, Event 이력

2. Pessimistic Lock (FOR UPDATE)
- 미리 Lock
- 충돌 많을 때
- 재고, 좌석, Job Queue
- SKIP LOCKED = Worker Pool에서 필수

3. Optimistic Lock (@Version)
- 나중에 확인
- 충돌 적을 때
- 게시글, 프로필
- 재시도 필요

========================================

DB별 지원 현황

DB                  | FOR UPDATE | FOR UPDATE SKIP LOCKED
MySQL 5.7+         | ✅         | ❌
MySQL 8.0+         | ✅         | ✅
MariaDB 10.3+      | ✅         | ❌
MariaDB 10.6+      | ✅         | ✅
PostgreSQL 9.0+    | ✅         | ❌
PostgreSQL 9.5+    | ✅         | ✅
Oracle 11g+        | ✅         | ✅

========================================

작성일: 2025-11-09
태그: #동시성제어 #Lock #Concurrency #면접준비