Concurrency Control
동시성 제어(Concurrency Control) 방식 분석 보고서
1. 개요
1.1. 문제 정의: 경쟁 상태 (Race Condition)
현대적인 애플리케이션 환경에서는 여러 사용자의 요청이 동시에 처리됩니다. 이때 여러 스레드(Thread)가 동일한 공유 자원(Shared Resource)에 동시에 접근하여 값을 변경하려고 할 때, 접근 순서에 따라 결과가 달라지는 경쟁 상태(Race Condition)가 발생할 수 있습니다.
본 프로젝트의 포인트 충전/사용
기능은 동시성 문제에 매우 취약합니다. 예를 들어, 사용자 A가 1,000 포인트를 가진 상태에서 다음과 같은 상황이 발생할 수 있습니다.
- Thread 1 (사용 요청): 700 포인트 사용을 위해 현재 잔액 1,000을 조회합니다.
- Thread 2 (충전 요청): 500 포인트 충전을 위해 현재 잔액 1,000을 조회합니다.
- Thread 1 (계산 및 저장):
1000 - 700 = 300
을 계산하고, 잔액을 300으로 저장합니다. - Thread 2 (계산 및 저장):
1000 + 500 = 1500
을 계산하고, 잔액을 1500으로 저장합니다.
최종적으로는 800 포인트(1000 - 700 + 500
)가 남아야 하지만, Thread 1의 작업이 무시되고 1500 포인트가 남는 데이터 불일치(Inconsistency) 문제가 발생합니다.
1.2. 해결 목표
이러한 문제를 해결하기 위해 한 번에 하나의 스레드만 공유 자원을 변경하도록 보장하는 동시성 제어 기법이 필요합니다. 본 보고서에서는 대표적인 두 가지 잠금(Locking) 전략인 비관적 락(Pessimistic Lock)과 낙관적 락(Optimistic Lock)에 대해 분석하고, 각 방식의 장단점 및 프로젝트 적용 방안을 제시합니다.
2. 비관적 락 (Pessimistic Lock)
2.1. 개념
“충돌이 빈번하게 발생할 것”이라고 비관적으로 가정하고, 데이터에 접근하는 시점부터 먼저 독점적인 잠금(Lock)을 거는 방식입니다. 트랜잭션이 시작될 때 공유 자원에 락을 걸고, 트랜잭션이 종료될 때 락을 해제합니다. 락이 걸려있는 동안 다른 스레드는 해당 자원에 접근할 수 없으며 대기해야 합니다.
2.2. 구현 방법
A. synchronized
키워드
Java에서 가장 간단하게 비관적 락을 구현하는 방법입니다. 특정 메소드나 코드 블록을 하나의 스레드만 실행하도록 보장합니다.
1
2
3
4
5
6
7
8
// PointService.java
public synchronized UserPoint chargePoint(long userId, long amount) {
// 이 메소드는 한번에 하나의 스레드만 실행 가능
UserPoint currentPoint = userPointTable.selectById(userId);
long updatedPoint = currentPoint.point() + amount;
// ... 로직 생략 ...
return userPointTable.insertOrUpdate(userId, updatedPoint);
}
단점: synchronized
는 성능 저하의 원인이 될 수 있으며, 여러 서버 인스턴스가 동작하는 분산 환경에서는 적용되지 않습니다.
B. 데이터베이스 레벨 락 (SELECT ... FOR UPDATE
)
데이터베이스가 제공하는 잠금 기능을 사용하는 가장 확실한 방법입니다. 데이터를 조회할 때 FOR UPDATE
구문을 추가하면 해당 레코드에 배타적 잠금(exclusive lock)이 걸리고, 트랜잭션이 커밋되거나 롤백될 때까지 다른 트랜잭션은 해당 레코드에 접근할 수 없습니다.
1
2
3
4
5
6
7
8
9
10
11
-- 트랜잭션 시작
BEGIN;
-- userId = 1 인 레코드에 락을 설정
SELECT * FROM user_point WHERE id = 1 FOR UPDATE;
-- 포인트 업데이트 로직 수행
UPDATE user_point SET point = 1500 WHERE id = 1;
-- 트랜잭션 종료 (락 해제)
COMMIT;
JPA에서는 @Lock(LockModeType.PESSIMISTIC_WRITE)
어노테이션으로 간단하게 구현할 수 있습니다.
2.3. 장단점
장점 | 단점 |
---|---|
데이터 무결성을 확실히 보장함 | 성능 저하 발생 가능성이 큼 (처리량 감소) |
구현이 비교적 간단하고 직관적임 | 락을 획득하기 위한 대기 시간 발생 |
충돌이 빈번할 때 효과적임 | 락이 길어지면 시스템 전체 성능에 악영향을 줌 |
교착 상태(Deadlock) 발생 가능성이 있음 |
3. 낙관적 락 (Optimistic Lock)
3.1. 개념
“충돌이 거의 발생하지 않을 것”이라고 낙관적으로 가정하고, 일단 락 없이 작업을 수행한 뒤, 데이터를 수정하는 마지막 시점에 충돌 여부를 검사하는 방식입니다. 충돌이 감지되면 작업을 롤백하고 재시도를 하거나 사용자에게 오류를 알립니다.
3.2. 구현 방법
version
컬럼 활용
데이터 테이블에 version
과 같은 버전 관리 컬럼을 추가하는 것이 일반적인 방법입니다.
- 데이터를 조회할 때
version
정보도 함께 가져옵니다. - 포인트 계산 등 비즈니스 로직을 수행합니다.
- 데이터를 업데이트할 때, 조회 시점의
version
과 현재 데이터베이스의version
이 동일한지 확인합니다.- 동일하면:
point
를 업데이트하고,version
을 1 증가시킵니다. - 다르면: 다른 스레드가 먼저 데이터를 수정한 것이므로, 현재 작업을 실패 처리하고 재시도 로직을 수행합니다.
- 동일하면:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// PointService.java - 재시도 로직 포함
public UserPoint usePoint(long userId, long amount) {
while (true) {
UserPoint currentPoint = userPointTable.selectById(userId); // version 정보 포함
// ... 잔고 확인 로직 ...
long updatedPoint = currentPoint.point() - amount;
// updateIfVersionMatches는 version이 일치할 때만 update하고 성공 여부를 반환한다고 가정
boolean success = userPointTable.updateIfVersionMatches(userId, updatedPoint, currentPoint.version());
if (success) {
// 성공 시 업데이트된 정보 반환
return new UserPoint(userId, updatedPoint, currentPoint.version() + 1);
}
// 실패 시 루프를 통해 재시도
}
}
JPA에서는 엔티티에 @Version
어노테이션만 추가하면, JPA가 업데이트 시 자동으로 버전을 비교하고 충돌 발생 시 예외(ObjectOptimisticLockingFailureException
)를 던져줍니다.
3.3. 장단점
장점 | 단점 |
---|---|
높은 처리량(Throughput) (락으로 인한 대기 없음) | 구현이 비관적 락보다 복잡함 (재시도 로직 필요) |
교착 상태(Deadlock)가 발생하지 않음 | 충돌이 빈번하게 발생하면 재시도 비용이 커져 성능이 저하될 수 있음 |
읽기 작업이 많은 환경에서 매우 효율적임 | 재시도 로직이 너무 길어지면 기아 상태(Starvation)가 발생할 수 있음 |
4. 비교 및 프로젝트 적용 방안
구분 | 비관적 락 (Pessimistic Lock) | 낙관적 락 (Optimistic Lock) |
---|---|---|
핵심 사상 | 충돌을 미리 방지 (선점) | 충돌을 사후에 감지 (후검증) |
성능 (처리량) | 낮음 (대기 시간 발생) | 높음 (락 대기 없음) |
구현 복잡도 | 낮음 (단, Deadlock 고려 필요) | 높음 (재시도 로직 구현 필요) |
적합한 환경 | 충돌이 잦고, 트랜잭션이 짧은 경우 | 충돌이 드물고, 읽기 작업이 많은 경우 |
데이터 일관성 | 강력하게 보장 | 재시도 로직을 통해 보장 |
결론 및 권장 사항
포인트 시스템은 사용자의 자산과 직접적으로 연관되므로 데이터의 정합성이 무엇보다 중요합니다. 또한 한 사용자의 포인트에 대한 동시 접근은 충돌이 발생할 확률이 낮다고 보기 어렵습니다.
따라서 본 과제의 심화 요구사항인 “한 번에 하나의 요청씩만 제어”를 만족시키기 위한 1차적인 선택으로는 비관적 락
방식이 더 적합합니다. 특히 Java의 synchronized
키워드를 PointService
의 충전/사용 메소드에 적용하는 것이 가장 직관적이고 빠르게 동시성 문제를 해결할 수 있는 방법입니다.
만약 이 시스템이 수백만 사용자를 대상으로 하고 최고 수준의 처리량이 요구되는 서비스로 발전한다면, 그 때는 낙관적 락
으로 전환하는 것을 고려해야 합니다. 낙관적 락
은 시스템의 확장성을 높이는 데 더 유리하기 때문입니다.