한 줄 정의
서버는 언제나 여러 요청을 동시에 처리하므로, 공유 데이터에 대한 접근 순서를 통제하지 않으면 데이터가 조용히 어긋납니다.
쉽게 말하면
사무실 벽에 걸린 화이트보드에 숫자 하나가 적혀 있고, 직원들이 “적힌 숫자를 읽고 → 1을 더해서 → 다시 적는” 일을 한다고 해 봅시다.
혼자 할 때는 아무 문제가 없습니다. 그런데 두 사람이 동시에 보드를 보면 둘 다 5를 읽고, 둘 다 6을 적습니다. 두 번 더했는데 결과는 6입니다. 1이 사라진 겁니다. 이 장이 다루는 모든 사고가 이 한 장면의 변주입니다.
해법도 전부 “보드 앞을 어떻게 통제할 것인가”로 정리됩니다.
| 보드 앞 규칙 | 대응하는 수단 |
|---|---|
| 문을 달고 한 번에 한 명만 들여보낸다 | 잠금(lock) |
| 동시에 최대 5명까지만 들여보낸다 | 세마포어 |
| 읽기만 할 사람은 여럿이 같이 들어가도 된다 | 읽기 쓰기 잠금 |
| ”6으로 바꿔주세요”가 아니라 “1 더해주세요”라고 부탁한다 | 원자적 타입 · 증분 쿼리 |
| 들어가서 내가 읽은 5가 그대로인지 확인하고 고친다 | 비선점(낙관적) 잠금 |
| 보드는 서기 한 명만 만지고, 나머지는 쪽지함에 넣는다 | 단일 스레드 처리 |
왜 중요한가?
동시 실행은 선택 사항이 아닙니다. 요청 1건 처리에 0.1초가 걸리는 서버가 초당 100건을 받는다면 순차 처리 시 초당 처리량은 10으로 떨어집니다. 동시 처리를 포기하는 순간 처리량이 10분의 1이 됩니다. 요청마다 스레드를 할당하든 비동기 IO를 쓰든(이 경우에도 단일 스레드인 경우는 드뭅니다) 결론은 같습니다.
문제는 이 동시성이 터졌을 때 티가 나지 않는다 는 점입니다. 잘못된 코드가 100번 중 3번만 어긋나므로 테스트를 통과하고, 재현이 안 되니 원인 추적도 어렵습니다. 결제 데이터가 꼬여 CS 부서가 민원에 시달리는 동안 코드를 한 줄씩 읽어 내려가며 원인을 찾아야 하는 상황이 실제로 벌어집니다.
그래서 동시성은 버그를 만난 뒤 고치는 대상이 아니라 처음부터 염두에 두고 짜야 하는 전제 입니다.
핵심 내용
경쟁 상태 — 모든 사고의 원형
public class Increaser {
private int count = 0;
public void inc() { count = count + 1; }
public int getCount() { return count; }
}100개 스레드가 각각 100번씩 inc() 를 호출하면 10000이 나와야 하지만, 실제로는 9982나 9973 같은 값이 나옵니다. count = count + 1 이 읽기와 저장, 두 단계로 실행되기 때문입니다.
sequenceDiagram participant T1 as 스레드 1 participant C as count (5) participant T2 as 스레드 2 T1->>C: 값 읽기 → 5 T2->>C: 값 읽기 → 5 T1->>C: 5+1 저장 → 6 T2->>C: 5+1 저장 → 6
두 번 더했는데 7이 아니라 6입니다. 4개 스레드가 이렇게 겹치면 9가 아니라 6이 됩니다.
경쟁 상태(race condition)
여러 스레드가 동시에 공유 자원에 접근할 때, 접근 순서에 따라 결과가 달라지는 상황 을 말합니다. 여기서 공유 자원은
count필드입니다.
싱글톤 객체의 필드가 가장 흔한 함정
경쟁 상태는 카운터 같은 원시 타입에서만 생기는 게 아닙니다. 실무에서 훨씬 자주 만나는 형태는 싱글톤 빈의 인스턴스 필드에 요청별 데이터를 담는 코드 입니다.
public class PayService {
private Long payId; // 요청마다 달라져야 할 값
public PayResp pay(PayRequest req) {
this.payId = genPayId(); // 단계 1
saveTemp(this.payId, req); // 단계 2
PayResp resp = sendPayData(this.payId, …); // 단계 3
applyResponse(resp); // 단계 4 → updatePayData(this.payId, …)
return resp;
}
}스레드 1이 단계 1에서 payId 를 1로 설정한 뒤 단계 4에 도달하기 전에, 스레드 2가 단계 1을 실행해 payId 를 2로 바꿔 버리면 스레드 1은 자기 결제(1)가 아니라 남의 결제(2) 데이터를 수정합니다.
결과는 단순한 값 오류가 아닙니다. 고객 A의 결제 결과는 사라지고, 고객 B의 결제에는 고객 A의 응답이 저장됩니다. 실제로 국내 대형 서비스에서 이와 유사한 실수로 다른 사용자의 메일 목록과 내용이 보이는 보안 사고 가 발생한 적이 있습니다.
스프링 빈은 기본이 싱글톤입니다
스프링을 쓴다면
@Service클래스는 별도 설정이 없는 한 하나의 인스턴스를 모든 요청이 공유합니다. 요청 범위 데이터는 필드가 아니라 메서드 파라미터나 지역 변수 로 다뤄야 합니다.
DB도 예외가 아닙니다. 관리자가 주문을 배송 상태로 바꾸는 동시에 고객이 같은 주문을 취소하면, 주문 서비스가 취소 상태로 변경한 직후 관리 시스템이 다시 배송 상태로 덮어씁니다. 고객은 취소했는데 시스템상으로는 배송이 시작됩니다.
프로세스 수준의 제어 수단
잠금(lock)
공유 자원에 접근하는 스레드를 한 번에 하나로 제한합니다. 흐름은 잠금 획득 → 임계 영역 접근 → 잠금 해제 입니다.
임계 영역(critical section)
동시에 둘 이상의 스레드나 프로세스가 접근하면 안 되는 공유 자원에 접근하는 코드 영역입니다. 공유 자원의 예로는 메모리나 파일이 있습니다.
public class UserSessions {
private Lock lock = new ReentrantLock();
private Map<String, UserSession> sessions = new HashMap<>();
public void addUserSession(UserSession session) {
lock.lock();
try {
sessions.put(session.getSessionId(), session);
} finally {
lock.unlock();
}
}
}HashMap 은 다중 스레드 환경에서 안전하지 않아, 동시에 put() 을 호출하면 데이터가 유실되거나 잘못 저장됩니다. 500개 스레드로 1000개 세션을 넣는 테스트에서 잠금을 빼면 session-769 가 없다는 검증 실패가 나고, 실행할 때마다 누락되는 세션이 바뀝니다. 동시성 문제의 결과가 매번 다르다는 것을 그대로 보여 줍니다.
synchronized와 ReentrantLock
synchronized는 블록이 끝나면 자동으로 잠금을 풀어 주므로 더 간단합니다. 반면ReentrantLock은 잠금 획득 대기 시간 지정 처럼synchronized에 없는 기능을 제공하고, 자바 21에 추가된 가상 스레드는ReentrantLock만 지원합니다(synchronized는 자바 24부터). 둘 중 무엇을 써도 되지만 섞어 쓰지는 말고 한 가지로 통일 합니다.참고로 뮤텍스(mutex)는 mutual exclusion의 줄임말로 잠금과 같은 말입니다. 자바는
Lock, Go는Mutex라는 이름을 씁니다.
세마포어
동시에 실행할 수 있는 스레드 수 를 제한합니다. 외부 서비스에 대한 동시 요청을 최대 5개로 묶고 싶을 때처럼, 배타적 접근이 아니라 접근량 조절이 목적일 때 씁니다.
private Semaphore semaphore = new Semaphore(5);
public String getData() {
semaphore.acquire(); // 퍼밋 획득 (남은 개수 1 감소)
try {
return …; // 최대 5개 스레드만 동시 실행
} finally {
semaphore.release(); // 퍼밋 반환 (1 증가)
}
}허용 가능한 숫자를 자바는 퍼밋(permit), Go는 weight라고 부릅니다. 남은 퍼밋이 0이면 다른 스레드가 반환할 때까지 대기합니다. 동시 접근 스레드가 1개인 것을 이진(binary) 세마포어, 지정한 수만큼 허용하는 것을 계수(counting) 세마포어라고 하며, 퍼밋을 구하고 반환하는 연산은 각각 P 연산(wait), V 연산(signal)이라고 합니다.
읽기 쓰기 잠금
일반 잠금의 약점은 데이터를 변경하지 않아도 동시에 읽을 수 없다 는 점입니다. HashMap 이 바뀌지 않는다면 get() 은 여러 스레드가 동시에 실행해도 문제가 없는데, 같은 잠금을 쓰면 한 번에 한 스레드만 읽게 되어 쓰기 대비 읽기 빈도가 높을 때 읽기 성능이 떨어집니다.
읽기 쓰기 잠금은 이 손해를 없앱니다.
| 규칙 | 내용 |
|---|---|
| 쓰기 잠금 | 한 번에 한 스레드만 획득 |
| 읽기 잠금 | 한 번에 여러 스레드가 획득 |
| 쓰기 중 읽기 | 쓰기 잠금이 해제될 때까지 읽기 잠금 획득 불가 |
| 읽기 중 쓰기 | 모든 읽기 잠금이 해제될 때까지 쓰기 잠금 획득 불가 |
private ReadWriteLock lock = new ReentrantReadWriteLock();
private Lock writeLock = lock.writeLock(); // 변경 메서드에서 사용
private Lock readLock = lock.readLock(); // 조회 메서드에서 사용원자적 타입
잠금은 확실하지만 CPU 효율이 떨어집니다. 잠금을 확보한 하나를 제외한 나머지 스레드가 모두 멈춰서 대기하기 때문입니다.
int, long, boolean 같은 단순 값이라면 잠금 없이 해결할 수 있습니다.
private AtomicInteger count = new AtomicInteger(0);
public void inc() {
count.incrementAndGet(); // 다중 스레드 문제없이 값을 1 증가
}내부적으로 CAS(Compare And Swap) 연산을 사용해 스레드를 멈추지 않고도 안전하게 값을 바꿉니다. 이름 그대로 “비교 후 교체”이며, 여러 스레드가 동시에 호출해도 모두 멈추지 않고 실행되므로 CPU 효율이 좋습니다. 내부 구현은 잠금보다 복잡하지만 쓰는 입장에서는 오히려 더 간단합니다.
동시성 지원 컬렉션
컬렉션에는 두 가지 접근이 있습니다.
| 동기화된 컬렉션 | 동시성 지원 컬렉션 | |
|---|---|---|
| 만드는 법 | Collections.synchronizedMap(map) | new ConcurrentHashMap<>() |
| 방식 | 변경·조회 메서드 전체를 동기화 블록으로 감쌈 | 변경 시 잠금 범위를 최소화 |
| 성능 | 잠금 범위가 넓어 경합에 약함 | 키 해시 분포가 고르고 동시 수정이 많을 때 유리 |
가상 스레드에서는 동기화 컬렉션을 피합니다
자바 23 또는 그 이전 버전 기준으로 가상 스레드를 쓴다면
Collections.synchronizedMap()계열을 쓰면 안 됩니다. 내부적으로synchronized를 사용하는데, 그 버전의 가상 스레드는synchronized를 지원하지 않아 성능 문제가 생깁니다.
불변(immutable) 값이라는 우회로
값이 바뀌지 않으면 여러 스레드가 동시에 접근해도 애초에 문제가 없습니다. 변경이 필요하면 기존 값을 고치는 대신 새 값을 만들어 씁니다. 자바의
CopyOnWriteArrayList가 요소를 추가·삭제할 때마다 새 리스트를 생성해 반환하는 방식이 여기에 해당합니다.
DB 수준의 제어 수단
트랜잭션은 여러 조회·쓰기를 논리적으로 하나의 연산으로 묶어 모두 커밋되거나 모두 롤백되게 합니다. 덕분에 개발자가 직접 고민할 동시성 문제의 범위가 크게 줄어듭니다.
하지만 트랜잭션만으로 모든 동시성 문제가 해결되지는 않습니다. 앞서 본 배송/취소 충돌처럼 여러 트랜잭션이 같은 데이터를 동시에 수정하는 문제는 DB가 제공하는 잠금 기능을 직접 써야 합니다.
선점(비관적) 잠금
데이터에 먼저 접근한 트랜잭션이 잠금을 획득하고, 나머지는 대기합니다.
select * from 테이블 where 조건 for update조건에 해당하는 레코드를 조회하면서 동시에 잠금을 획득합니다. 잠금은 트랜잭션이 종료될 때(커밋이나 롤백) 반환됩니다.
배송 시작과 주문 취소가 동시에 들어오면 먼저 잠금을 얻은 쪽이 이깁니다. 배송이 먼저 커밋되면 취소 트랜잭션은 잠금을 얻은 뒤 조회한 상태가 이미 ‘배송 중’이므로 취소에 실패하고, 취소가 먼저 커밋되면 배송이 실패합니다. 어느 쪽이든 둘 다 성공해서 데이터가 꼬이는 일은 없습니다.
분산 잠금(distributed lock)
여러 프로세스가 동시에 동일한 자원에 접근하지 못하도록 막는 방법입니다. 앞의 잠금과 개념은 같고 프로세스 간에 잠금 처리를 한다 는 점만 다릅니다. 간단한 분산 잠금은 DB의 선점 잠금으로 구현할 수 있습니다. 대부분의 시스템이 DB를 필수로 쓰므로 별도 도구 없이 구성할 수 있다는 게 장점입니다. 트래픽이 많다면 레디스 기반 도구를 고려합니다.
비선점(낙관적) 잠금
명시적으로 잠금을 걸지 않습니다. 조회 시점의 값과 수정 시점의 값이 같은지 비교 하는 방식으로 처리하며, 보통 정수 타입의 version 칼럼을 씁니다.
-- 1. version을 함께 조회
select …, version from 테이블 where id = ?
-- 2. 로직 수행 후, 조회한 version이 그대로인지 확인하며 갱신
update 테이블 set …, version = version + 1
where id = ? and version = [1에서 조회한 값]변경된 행이 0이면 그 사이에 다른 트랜잭션이 값을 바꾼 것이므로 실패로 처리하고 롤백하며, 0보다 크면 커밋합니다.
선점 잠금과 비교했을 때 핵심 이점은 잠금 획득을 위한 대기 과정이 없다 는 것입니다. 실패할 경우 사용자에게 더 빠르게 결과를 응답할 수 있습니다.
왜 비관적이고 낙관적인가
‘비관적’은 실패할 가능성이 높아서 입니다. 다수가 데이터 변경을 시도하면 정상적으로 변경할 가능성이 떨어지니, 아예 한 번에 하나만 접근시키는 배타적 잠금을 씁니다. ‘낙관적’은 반대로 성공할 가능성이 높아서 입니다. 배타적 잠금까지는 쓰지 않고 값 비교로 대응합니다. 실제로 잠금을 쓰는 것은 아니지만 비관적 잠금에 대응하는 용어로 낙관적 잠금이라고 부릅니다.
트랜잭션 안에 외부 연동이 있다면 선점 잠금
주문 취소 과정에서 외부 PG를 호출해 결제까지 취소해야 하는 상황을 생각해 봅시다. 비선점 잠금을 쓰면 결제는 이미 취소됐는데 데이터 변경에 실패해 트랜잭션이 롤백되는 문제가 생길 수 있습니다. 되돌릴 수 없는 외부 작업이 트랜잭션 범위 안에 있다면 선점 잠금을 고려하거나, 트랜잭션 아웃박스 패턴으로 외부 연동을 트랜잭션 밖으로 빼야 합니다.
증분 쿼리 — 잠금을 아예 쓰지 않는 길
참여자 수를 1 증가시키는 코드가 다음처럼 되어 있으면, 두 명이 동시에 참여할 때 joinCount 가 2가 아니라 1만 증가합니다. 화이트보드 예제와 완전히 같은 구조입니다.
Subject subject = jdbcTemplate.queryForObject("select id, joinCount … where id = ?", …);
jdbcTemplate.update("update SUBJECT set joinCount = ? where id = ?",
subject.getJoinCount() + 1, subject.getId());선점 잠금을 쓰면 대기 시간만큼 응답이 느려지고, 비선점 잠금을 쓰면 변경 실패 에러가 자주 발생합니다. 둘 다 쓰지 않는 답이 있습니다.
update SUBJECT set joinCount = joinCount + 1 where id = ?DB가 joinCount = joinCount + 1 을 원자적 연산으로 처리 하고, 동일 데이터에 대한 원자적 연산이 동시에 들어오면 순차적으로 실행하기 때문에 데이터가 누락되지 않습니다. 앞의 AtomicInteger 와 정확히 같은 발상을 DB 레벨에서 쓰는 것입니다.
Note
증분 쿼리는 DB에 따라 원자적 연산이 아닐 수도 있습니다. 사용하는 DB에서 원자적으로 처리되는지 반드시 검증해야 합니다.
잠금 사용 시 주의 사항
반드시 해제한다
잠금을 획득했으면 반드시 해제해야 하고, 세마포어도 퍼밋을 반드시 반환해야 합니다. 그렇지 않으면 대기 중인 스레드가 무한정 기다립니다. 그래서 예제 코드가 전부 try-finally 구조인 것이고, finally 는 항상 실행되므로 잠금이 무조건 해제됩니다. 습관으로 굳혀 두면 나중에 스스로를 구합니다.
대기 시간을 지정한다
임계 영역 실행에 0.5초가 걸리고 1000개 스레드가 동시에 잠금을 시도하면, 마지막 스레드는 499.5초를 기다린 뒤에야 실행됩니다.
boolean acquired = lock.tryLock(5, TimeUnit.SECONDS); // 대기 없이 즉시 판단하려면 tryLock()
if (!acquired) {
throw new RuntimeException("Failed to acquire lock");
}
try { … } finally { lock.unlock(); }계좌 이체를 했는데 몇 분 동안 응답이 없으면 사용자는 크게 불안해집니다. 빠른 실패 응답이 무응답보다 낫습니다.
교착 상태를 피한다
한 작업에서 2개 이상의 자원에 대한 잠금을 획득하는 코드 구조는 교착 상태(deadlock)에 빠지기 쉬운 전형적인 패턴 입니다.
graph LR T1[스레드 1] -->|A 획득| A[자원 A] T1 -.->|B 대기| B[자원 B] T2[스레드 2] -->|B 획득| B T2 -.->|A 대기| A
스레드 1은 자원 B를 기다리느라 자원 A를 놓지 못하고, 스레드 2는 자원 A를 기다리느라 자원 B를 놓지 못합니다. 서로가 획득한 잠금을 무한히 기다리는 상황입니다.
해소 방법은 두 가지입니다. 대기 시간을 제한하면(예: 5초) 교착이 발생하더라도 잠금 획득에 실패하면서 풀립니다. 더 근본적인 방법은 정해진 순서대로 잠금을 획득하는 것 입니다. 두 스레드 모두 자원 A → 자원 B 순으로 시도하면, 한 스레드만 A를 얻고 이어서 B까지 얻으므로 순환 대기가 생기지 않습니다.
라이브락과 기아 상태
라이브락(livelock) 은 좁은 길에서 마주친 두 사람이 서로 비켜 주려고 계속 같은 방향으로 움직이는 상황입니다. 활동을 하고 있지만 실제로는 아무것도 진행되지 않습니다. 아무것도 하지 않고 대기만 하는 교착 상태와는 다릅니다. 우선순위를 두거나 중재자를 두거나 임의의 시간만큼 기다렸다 움직이게 해서 해소합니다.
기아(starvation) 상태 는 우선순위가 낮은 작업이 계속 밀려 실행되지 못하거나, 한 프로세스가 자원을 오래 독점해 다른 프로세스가 접근하지 못하는 상태입니다. 밀린 작업의 우선순위를 높이거나 자원 독점 시간에 제한을 두어 해결합니다.
단일 스레드로 처리하기
동시성 문제의 원인이 “여러 스레드가 동시에 같은 자원에 접근하는 것”이라면, 아예 한 스레드만 접근하게 하면 문제 자체가 생기지 않습니다.
graph LR W1[작업 요청 스레드] --> Q[작업 큐] W2[작업 요청 스레드] --> Q W3[작업 요청 스레드] --> Q Q --> S[상태 관리 스레드]
상태 관리 스레드만 데이터를 조작하고, 나머지 스레드는 작업 큐에 작업을 넣을 뿐 직접 상태에 접근하지 못합니다.
while (running) {
Job job = jobQueue.poll(1, TimeUnit.SECONDS); // 한 스레드만 큐에서 꺼내 실행
if (job == null) continue;
switch (job.getType()) {
case INC:
obj.modifyState(); // 한 스레드만 접근하므로 잠금이 필요 없다
break;
}
}데이터 공유가 필요하면 콜백이나 큐로 복제본 을 넘기거나 불변 값 을 공유합니다. 다른 스레드가 데이터를 수정하지 못하게 막는 것이 목적입니다.
Go 언어의 격언
메모리를 공유하는 방식으로 (고루틴 간에) 소통하지 말고, 통신을 통해 메모리를 공유하라.
Go도 잠금 수단을 제공하지만 채널을 통한 데이터 공유를 권장하며, 이는 동시성 문제를 줄여 줍니다.
단점은 구조가 복잡해진다 는 것입니다. 다만 논블로킹·비동기 IO를 쓰는 경우에는 블로킹 연산을 최소화해야 하므로 단일 스레드 방식이 오히려 적합합니다.
단일 스레드로 바꾸면 느려지지 않을까
성능은 동시에 실행할 작업 개수와 임계 영역의 실행 시간 에 달려 있습니다. 임계 영역이 짧고 동시 스레드가 적으면 잠금 쪽이 유리합니다. 큐나 채널을 처리하는 시간보다 잠금 획득·해제 시간이 더 짧기 때문입니다. 반대로 동시 작업이 많고 임계 영역이 길어지면 큐·채널 방식이 비슷하거나 더 나은 성능을 낼 수 있습니다.
비교 / 트레이드오프
| 수단 | 통과 인원 | 대기 발생 | 적합한 상황 |
|---|---|---|---|
| 잠금 | 1 | 있음 | 임계 영역이 짧고 배타적 접근이 필요할 때 |
| 세마포어 | N (지정) | 있음 | 외부 자원에 대한 동시 요청량을 제한할 때 |
| 읽기 쓰기 잠금 | 읽기 N / 쓰기 1 | 있음 | 쓰기 대비 읽기 빈도가 압도적으로 높을 때 |
| 원자적 타입 | 제한 없음 | 없음(CAS) | int·long·boolean 단일 값 갱신 |
| 동시성 컬렉션 | 제한 없음 | 최소 | 맵·리스트 등 컬렉션 공유 |
| 단일 스레드 | 1 (고정) | 큐 대기 | 동시 작업이 많고 임계 영역이 길 때 |
DB 수준에서는 선택 축이 “대기할 것인가, 실패할 것인가” 로 압축됩니다.
| 선점(비관적) 잠금 | 비선점(낙관적) 잠금 | 증분 쿼리 | |
|---|---|---|---|
| 방식 | for update 로 먼저 잠근다 | version 비교로 나중에 검증한다 | DB의 원자적 연산에 맡긴다 |
| 충돌 시 | 대기 후 순차 처리 | 즉시 실패 · 롤백 | 순차 실행되어 충돌 자체가 없음 |
| 비용 | 대기 시간만큼 응답 지연 | 변경 실패 에러 빈발 | 없음(단, 값 증감에만 적용 가능) |
| 트랜잭션 내 외부 연동 | 적합 | 부적합 — 되돌릴 수 없는 작업 후 롤백 | 해당 없음 |
세 수단은 대체재가 아니라 적용 범위가 다릅니다. 값을 증감하는 것뿐이라면 증분 쿼리로 끝나고, 상태 전이처럼 조회 후 판단이 끼면 잠금이 필요하며, 그 안에 외부 연동이 있으면 선점 잠금으로 좁혀집니다.
내 생각
- 동시성 버그는 “테스트로 못 잡는 버그”라는 점이 본질입니다. 100번 중 3번만 어긋나는 코드는 CI를 통과하고 스테이징도 통과한 뒤 트래픽이 붙은 운영에서만 터지므로, 리뷰 단계에서 “이 필드가 요청별 데이터인가”를 눈으로 잡아내는 게 사실상 유일한 방어선입니다.
- 스프링 개발자에게 가장 위험한 코드는
@Service클래스의 인스턴스 필드입니다.PayService예제는 억지 사례가 아니라 로컬 테스트에서 절대 재현되지 않는 실제 패턴이고, “필드에 요청 데이터를 담지 않는다”는 규칙 하나가 이 장 절반의 사고를 막아 줍니다. - 증분 쿼리는 잠금을 배우기 전에 먼저 떠올려야 할 답입니다. 조회 후 계산해서 저장하는 코드를 한 줄 UPDATE로 바꾸는 것만으로 잠금도 버전 칼럼도 필요 없어지는데, 잠금 개념을 먼저 익히면 오히려 이 단순한 해법이 눈에 잘 들어오지 않습니다.
- 선점이냐 비선점이냐는 성능 문제가 아니라 “무엇이 되돌릴 수 없는가”의 문제입니다. 트랜잭션 안에 결제 취소 같은 외부 호출이 있으면 낙관적 잠금의 롤백이 곧 데이터 불일치가 되므로, 성능 비교보다 되돌릴 수 없는 작업의 위치를 먼저 확인하는 게 순서입니다.
- 분산 환경에서는 프로세스 수준 잠금이 아무 의미가 없다는 점을 늘 의식해야 합니다.
ReentrantLock은 JVM 하나 안에서만 유효하므로 인스턴스를 2대로 늘리는 순간 무력화되고, 그 지점부터 DB 선점 잠금이나 레디스 분산 잠금으로 올라가야 합니다. - 단일 스레드 처리는 회피가 아니라 정당한 설계 선택입니다. 임계 영역이 길고 동시 작업이 많은 구간이라면 잠금보다 성능이 나을 수도 있어, 상태를 한 곳에 모으고 나머지는 큐로 넘기는 구조를 “복잡해서 안 된다”고 미리 배제할 이유는 없습니다.
관련 개념
- Ch05 비동기 연동, 언제 어떻게 써야 할까 — 트랜잭션 아웃박스 패턴, 소비자 멱등성
- Ch03 성능을 좌우하는 DB 설계와 쿼리 — 트랜잭션 범위와 잠금 대기
- Ch04 외부 연동이 문제일 때 살펴봐야 할 것들 — 되돌릴 수 없는 외부 호출과 재시도