Concurrency & 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