Post

Concurrency Control

Concurrency Control

동시성 제어(Concurrency Control) 방식 분석 보고서

1. 개요

1.1. 문제 정의: 경쟁 상태 (Race Condition)

현대적인 애플리케이션 환경에서는 여러 사용자의 요청이 동시에 처리됩니다. 이때 여러 스레드(Thread)가 동일한 공유 자원(Shared Resource)에 동시에 접근하여 값을 변경하려고 할 때, 접근 순서에 따라 결과가 달라지는 경쟁 상태(Race Condition)가 발생할 수 있습니다.

본 프로젝트의 포인트 충전/사용 기능은 동시성 문제에 매우 취약합니다. 예를 들어, 사용자 A가 1,000 포인트를 가진 상태에서 다음과 같은 상황이 발생할 수 있습니다.

  1. Thread 1 (사용 요청): 700 포인트 사용을 위해 현재 잔액 1,000을 조회합니다.
  2. Thread 2 (충전 요청): 500 포인트 충전을 위해 현재 잔액 1,000을 조회합니다.
  3. Thread 1 (계산 및 저장): 1000 - 700 = 300을 계산하고, 잔액을 300으로 저장합니다.
  4. 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과 같은 버전 관리 컬럼을 추가하는 것이 일반적인 방법입니다.

  1. 데이터를 조회할 때 version 정보도 함께 가져옵니다.
  2. 포인트 계산 등 비즈니스 로직을 수행합니다.
  3. 데이터를 업데이트할 때, 조회 시점의 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의 충전/사용 메소드에 적용하는 것이 가장 직관적이고 빠르게 동시성 문제를 해결할 수 있는 방법입니다.

만약 이 시스템이 수백만 사용자를 대상으로 하고 최고 수준의 처리량이 요구되는 서비스로 발전한다면, 그 때는 낙관적 락으로 전환하는 것을 고려해야 합니다. 낙관적 락은 시스템의 확장성을 높이는 데 더 유리하기 때문입니다.

This post is licensed under CC BY 4.0 by the author.