Skip to content

hpp-backend-9/hhplus-tdd

Repository files navigation

포인트 관리 시스템

요구사항

  • PATCH /point/{id}/charge : 포인트를 충전한다.
  • PATCH /point/{id}/use : 포인트를 사용한다.
  • GET /point/{id} : 포인트를 조회한다.
  • GET /point/{id}/histories : 포인트 내역을 조회한다.
  • 잔고가 부족할 경우, 포인트 사용은 실패하여야 합니다.
  • 동시에 여러 건의 포인트 충전, 이용 요청이 들어올 경우 순차적으로 처리되어야 합니다.

Step 1

  • 포인트 충전, 사용에 대한 정책 추가 (잔고 부족, 최대 잔고 등)
    • 동시에 여러 요청이 들어오더라도 순서대로 (혹은 한번에 하나의 요청씩만) 제어될 수 있도록 리팩토링
  • 동시성 제어에 대한 통합 테스트 작성

Step 2

  • 동시성 제어 방식에 대한 분석 및 보고서 작성

동시성(Concurrency) 문제

여러 스레드가 동시에 동일한 자원에 접근하면 첫 번째 요청이 반영되기 전에 두 번째 요청이 먼저 반영되면서 데이터 불일치가 발생할 수 있습니다.
이러한 문제는 특히 동일한 사용자에 대해 동시다발적으로 여러 요청이 들어올 때, 잔여 포인트의 부정확한 계산 등으로 이어질 수 있습니다.

동시성 제어 방식

동시성 문제를 해결하는 방법에는 여러 가지가 있습니다.

1. 낙관적 락(Optimistic Locking)

  • 낙관적 락은 데이터에 대한 변경을 시도할 때 충돌이 없을 것이라고 가정하고, 충돌이 발생하면 그때 가서 다시 시도하는 방식입니다.
  • 보통 데이터베이스의 버전 번호를 이용하여 변경 전후의 일관성을 검증합니다.
  • 충돌 가능성이 낮은 경우 유리하며, 스레드 간의 경합이 적을 때 성능상의 이점이 큽니다.
  • 주로 버전 관리자 CAS(Compare-And-Swap) 같은 알고리즘을 통해 일관성을 유지합니다.
  • 예) 특정 데이터에 대한 변경이 많지 않고 읽기 요청이 주로 발생하는 경우.

2. 비관적 락(Pessimistic Locking)

  • 비관적 락은 데이터에 접근하는 동안 항상 충돌이 발생할 것으로 가정하여, 데이터를 읽거나 쓰기 전에 미리 락을 거는 방식입니다.
  • 데이터베이스나 객체 수준에서 락을 걸어 다른 스레드가 해당 자원에 접근하지 못하게 합니다.
  • 보통 데이터베이스 락이나 자바의 synchronized 또는 ReentrantLock으로 구현합니다.
  • 예) 경합이 자주 발생하거나 데이터 무결성이 매우 중요한 경우로 은행 계좌 이체와 같은 중요한 데이터 수정 시.

3. ReentrantLock을 이용한 동기화

  • ReentrantLock은 자바에서 제공하는 락 중 하나로, 한 스레드가 락을 여러 번 획득할 수 있는 재진입 가능한 락입니다.
  • 명시적으로 락을 획득하고 해체할 수 있는 장점이 있으며, 락을 걸어야 할 범위를 유연하게 조정할 수 있습니다.
  • 이번 프로젝트에서 사용자별 ReentrantLock을 ConcurrentHashMap과 함께 사용하여 특정 사용자에 대해 여러 요청이 발생할 때 안전하게 처리될 수 있도록 했습니다.

4. synchronized

  • 자바의 synchronized 키워드는 특정 코드 블록이나 메서드에 대한 락을 거는 방법입니다.
  • 간단하게 사용할 수 있지만, 락 범위를 세밀하게 조정하기 어렵고, 모든 접근을 직렬화하여 성능 저하의 문제가 발생할 수 있습니다.
  • 예) 간단한 동기화가 필요한 경우, 락의 범위를 최소화할 필요가 없는 경우.

5. ConcurrentHashMap

  • ConcurrentHashMap은 자바에서 제공하는 안전한 해시맵으로, 내부적으로 효율적인 동시성 제어를 위해 부분적인 락을 사용합니다.
  • 맵 전체가 아닌 맵의 일부(버킷)에 대해 락을 걸어 여러 스레드가 병렬로 다른 키에 접근할 수 있도록 설계 되었으며, 이를 통해 높은 동시성을 보장합니다.
  • Java 8 이후에는 CAS(Compare-And-Swap)와 스트라이핑 기법을 사용하여 성능을 더 최적화했습니다. 즉, 일부 낙관적 락의 특성과 비관적 락의 특성을 혼합하여 사용합니다.
  • ConcurrentHashMap을 사용함으로써 여러 스레드가 동시에 데이터를 읽고 쓸 수 있지만, 내부적으로 충돌이 일어나지 않도록 부분적으로만 락을 걸어 성능과 안전성을 동시에 유지할 수 있습니다.
  • 예) 특정 키에 대한 쓰기 작업이 ㅁ낳지 않으며 동시에 여러 읽기 작업이 이루어지는 경우.

프로젝트에서 사용한 동시성 제어 구현 방법

이번 포인트 관리 시스템에서는 ConcurrentHashMapReentrantLock을 조합하여 사용자별로 동시성 제어를 수행했습니다.

  • ConcurrentHashMap을 사용하여 각 사용자 ID에 대해 개별적인 락(ReentrantLock)을 생성하고 관리했습니다.
  • computeIfAbsent() 메서드를 사용하여 특정 사용자에 대해 처음 락이 필요할 때만 ReentrantLock을 생성함으로써 락의 수를 최적화했습니다.
  • 이를 통해 각 사용자의 포인트 충전 및 사용 요청이 순차적으로 처리되도록 보장하여 데이터 무결성을 유지했습니다.
import java.util.concurrent.locks.ReentrantLock;

public class PointService {
    private final ConcurrentHashMap<Long, ReentrantLock> userLocks = new ConcurrentHashMap<>();

    private ReentrantLock getUserLock(long userId) {
        return userLocks.computeIfAbsent(userId, k -> new ReentrantLock());
    }

    public UserPoint charge(long id, long amount) {
        ReentrantLock lock = getUserLock(id);
        lock.lock();
        try {
            // 포인트 충전 로직
        } finally {
            lock.unlock();
        }
    }

    public UserPoint use(long id, long amount) {
        ReentrantLock lock = getUserLock(id);
        lock.lock();
        try {
            // 포인트 사용 로직
        } finally {
            lock.unlock();
        }
    }
}

동시성 테스트

다음과 같은 동시성 시나리오를 주어 테스트를 진행했습니다.

  1. 포인트 충전 및 사용 요청이 동시에 들어오는 경우
  2. 여러 사용자에 대해 포인트 충전 및 사용 요청이 동시에 들어오는 경우
  3. 잔여 포인트 보다 큰 금액 사용 요청이 들어오는 경우

결론

ConcurrentHashMapReentrantLock을 조합한 사용자별 동시성 제어 방식은 포인트 관리 시스템의 데이터 일관성을 보장하고, 사용자별로 독립적인 요청 처리를 가능하게 합니다. 이를 통해 특정 사용자에 대한 다중 요청이 순차적으로 처리될 수 있도록 하여 동시성 문제를 효과적으로 해결했습니다. 이번 개선된 구조는 낙관적 락비관적 락의 특성을 고려하여 사용자별 락을 세밀하게 적용함으로써 성능과 데이터 일관성을 모두 유지하는 것을 목표로 했습니다.

About

Test Driven Development

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages