research

Concurrency & Transactions — 실전 예시와 템플릿

Concurrency & Transactions — 실전 예시와 템플릿

이론: 11-concurrency-and-transactions

예시: Academic Life System의 동시성 시나리오

시나리오 1: 수강 신청 — 동시 요청

수강 인원이 30명 제한인 과목에 29명이 등록된 상태. 두 학생이 동시에 신청한다.

해법 — Pessimistic Lock (SELECT FOR UPDATE):

-- 수강 신청 트랜잭션
BEGIN;

-- 정원 확인 (FOR UPDATE로 행 잠금)
SELECT count(*) as enrolled
FROM enrollments 
WHERE course_id = ? 
FOR UPDATE;

-- 정원 체크
-- enrolled < capacity인 경우만 진행

INSERT INTO enrollments (student_id, course_id, semester)
VALUES (?, ?, ?);

COMMIT;

시나리오 2: 학생 정보 수정 — Optimistic Lock

학생이 웹에서 자기 정보를 수정하는 동안, 교직원도 같은 학생 정보를 수정하려 한다.

시나리오 3: LLM 상담 — Long-Running Process

LLM API 호출은 수 초~수십 초 걸릴 수 있다. 시스템 트랜잭션으로 감싸면 안 된다.

핵심: 외부 API 호출을 DB 트랜잭션 안에 넣지 않는다. 트랜잭션을 짧게 유지하고, 비동기 처리를 활용한다.


Active Record에 Optimistic Lock 추가하기

기존 Active Record 패턴에 version 관리를 통합하는 방법:

// Layer Supertype: 모든 Active Record가 상속
class ActiveRecordBase:
    version: int = 1
    isNew: bool = true
    
    save():
        if isNew:
            // INSERT — version 1로 시작
            db.execute(
                "INSERT INTO [table] (..., version) VALUES (..., 1)", 
                fields...)
            isNew = false
        else:
            // UPDATE — version 체크 + 증가
            rowCount = db.execute(
                "UPDATE [table] SET ..., version = version + 1 WHERE [pk] = ? AND version = ?",
                fields..., pk, version)
            
            if rowCount == 0:
                throw ConcurrencyException(
                    "Record was modified by another user. Please reload.")
            version += 1
    
    delete():
        rowCount = db.execute(
            "DELETE FROM [table] WHERE [pk] = ? AND version = ?",
            pk, version)
        
        if rowCount == 0:
            throw ConcurrencyException("Record was modified or deleted.")

// 사용 예시
class Student extends ActiveRecordBase:
    studentId: String
    name: String
    department: String
    
    save():
        if isNew:
            db.execute(
                "INSERT INTO students (student_id, name, department, version) VALUES (?, ?, ?, 1)",
                studentId, name, department)
            isNew = false
        else:
            rowCount = db.execute(
                "UPDATE students SET name=?, department=?, version=version+1 WHERE student_id=? AND version=?",
                name, department, studentId, version)
            if rowCount == 0:
                throw ConcurrencyException("Student record was modified by another user.")
            version += 1

템플릿

동시성 전략 결정 워크시트

## [프로젝트명] 동시성 설계

### 1. 시나리오 분석
| Use Case | 동시 접근 빈도 | 충돌 비용 | 단일/다중 요청 | 추천 전략 |
|----------|-------------|----------|-------------|----------|
| ________ | Low/Mid/High | Low/High | Single/Multi | Opt/Pess |
| ________ | | | | |

### 2. 기본 전략 선택
> **기본 전략**: Optimistic / Pessimistic
> **DB 격리 수준**: Read Committed (기본) / 기타: ___
> **근거**: ___

### 3. Optimistic Lock 적용 대상
| 엔티티 | version 컬럼 | modified_by | modified_at | 비고 |
|--------|------------|------------|------------|------|
| ______ | ✅/❌ | ✅/❌ | ✅/❌ | |

### 4. Pessimistic Lock 적용 대상
| 시나리오 | Lock 대상 | Lock 타입 | 타임아웃 |
|---------|----------|----------|---------|
| _______ | _______ | Read/Write | ___초 |

### 5. Long-Running Process
| 기능 | 외부 호출? | 비동기 처리? | 상태 관리 방법 |
|------|----------|-----------|-------------|
| _____ | Yes/No | Yes/No | polling/webhook/SSE |

Optimistic Lock 구현 체크리스트

## Optimistic Lock 구현

### DB 스키마 변경
- [ ] 대상 테이블에 `version INT DEFAULT 1` 컬럼 추가
- [ ] (선택) `modified_by VARCHAR(50)` 컬럼 추가
- [ ] (선택) `modified_at TIMESTAMP` 컬럼 추가

### 코드 변경
- [ ] Layer Supertype에 version 필드 추가
- [ ] 조회 시 version 값을 함께 로드
- [ ] UPDATE WHERE에 `AND version = ?` 조건 추가
- [ ] UPDATE 후 row count 체크 → 0이면 ConcurrencyException
- [ ] DELETE WHERE에도 `AND version = ?` 조건 추가
- [ ] 웹 폼에 version을 hidden field로 포함

### 사용자 경험
- [ ] 충돌 시 사용자에게 의미 있는 메시지 표시
- [ ] "다시 로드" 옵션 제공
- [ ] (고급) 충돌 내용 비교(diff) 표시

트랜잭션 설계 템플릿

// ============================================
// [기능명] 트랜잭션 설계
// ============================================

// 원칙: 트랜잭션은 최대한 짧게.
// 외부 API 호출은 트랜잭션 밖에서.

function [기능명]([params]):
    
    // --- Phase 1: 검증 (트랜잭션 1, 짧게) ---
    tx1 = beginTransaction()
    try:
        [data] = [mapper].find([id])           // 필요한 데이터 조회
        validate([data], [params])              // 비즈니스 규칙 검증
        tx1.commit()
    catch:
        tx1.rollback()
        throw
    
    // --- Phase 2: 외부 호출 (트랜잭션 밖) ---
    [externalResult] = [gateway].call([data])   // 외부 API 호출
    
    // --- Phase 3: 결과 저장 (트랜잭션 2, 짧게) ---
    tx2 = beginTransaction()
    try:
        // Optimistic Lock으로 데이터 변경 여부 확인
        [fresh] = [mapper].find([id])
        if [fresh].version != [data].version:
            tx2.rollback()
            throw ConcurrencyException("Data changed during processing")
        
        [fresh].applyResult([externalResult])
        [mapper].update([fresh])
        tx2.commit()
    catch:
        tx2.rollback()
        throw

수강 신청 동시성 구현 예시

-- DDL: enrollments 테이블에 동시성 대비
CREATE TABLE enrollments (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    student_id VARCHAR(20) NOT NULL,
    course_id VARCHAR(20) NOT NULL,
    semester VARCHAR(10) NOT NULL,
    grade VARCHAR(5),
    grade_point REAL,
    version INT DEFAULT 1,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (student_id) REFERENCES students(student_id),
    FOREIGN KEY (course_id) REFERENCES courses(course_id),
    UNIQUE(student_id, course_id, semester)
);

-- 수강 정원 관리 테이블
CREATE TABLE course_sections (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    course_id VARCHAR(20) NOT NULL,
    semester VARCHAR(10) NOT NULL,
    capacity INT NOT NULL DEFAULT 30,
    enrolled INT NOT NULL DEFAULT 0,
    version INT DEFAULT 1,
    FOREIGN KEY (course_id) REFERENCES courses(course_id),
    UNIQUE(course_id, semester)
);
// 수강 신청 — Pessimistic Lock 방식
function enrollStudent(studentId, courseId, semester):
    tx = beginTransaction()
    try:
        // FOR UPDATE로 행 잠금
        section = db.queryForUpdate(
            "SELECT * FROM course_sections WHERE course_id=? AND semester=?",
            courseId, semester)
        
        if section.enrolled >= section.capacity:
            throw CapacityExceededException("정원이 마감되었습니다")
        
        // 중복 신청 체크
        existing = db.query(
            "SELECT 1 FROM enrollments WHERE student_id=? AND course_id=? AND semester=?",
            studentId, courseId, semester)
        if existing:
            throw DuplicateEnrollmentException("이미 수강 신청한 과목입니다")
        
        // 수강 등록
        db.execute(
            "INSERT INTO enrollments (student_id, course_id, semester) VALUES (?, ?, ?)",
            studentId, courseId, semester)
        
        // 인원 수 증가
        db.execute(
            "UPDATE course_sections SET enrolled = enrolled + 1 WHERE id = ?",
            section.id)
        
        tx.commit()
    catch:
        tx.rollback()
        throw