Skip to content

항해 플러스 1주차 테스트 코드 적용해보기

Notifications You must be signed in to change notification settings

rueun/hhplus-tdd-java

Repository files navigation

[1주차 과제] point 패키지의 TODO 와 테스트코드를 작성해주세요.

요구사항

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

Default

  • /point 패키지 (디렉토리) 내에 PointService 기본 기능 작성
  • /database 패키지의 구현체는 수정하지 않고, 이를 활용해 기능을 구현
  • 각 기능에 대한 단위 테스트 작성

총 4가지 기본 기능 (포인트 조회, 포인트 충전/사용 내역 조회, 충전, 사용) 을 구현합니다.

Step 1

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

Step 2

  • 동시성 제어 방식에 대한 분석 및 보고서 작성 ( README.md )

동시성 문제 분석 보고서

1. 동시성 문제란?

동시성 문제란 여러 프로세스나 스레드가 동시에 동일한 자원을 접근하여 변경하려 할 때 발생하는 문제를 말합니다. 많은 사람들이 동시에 자원을 접근하는 것 자체를 동시성 문제라고 생각할 수 있지만, 이는 정확한 설명이 아닙니다. 동시성 문제는 자원 접근이 아닌 동일한 자원에 대한 동시 '변경' 시 발생합니다.

예를 들어, 다수의 사용자가 동시에 데이터를 조회하는 경우에는 동시성 문제가 발생하지 않습니다. 그러나 동일한 데이터를 동시에 수정하려고 한다면, 자원의 일관성 문제가 발생할 수 있습니다.

1.1 동시성 문제의 경합 상태와 순서 보장

동시성 문제는 흔히 경합 상태(Race Condition)와 관련이 있습니다. 이는 여러 스레드가 동일한 자원을 변경하려 할 때, 그 순서가 보장되지 않기 때문에 발생합니다.

동시성 문제는 아래와 같은 문제를 일으킵니다:

문제점 설명
경합 상태 여러 스레드가 자원을 경합하며 동시에 접근하려고 할 때, 자원의 변경이 예측 불가능한 순서로 발생할 수 있습니다.
순서 보장 실패 순차적으로 처리가 되지 않아 데이터가 일관성을 잃거나, 사용자가 기대한 결과와 다르게 변경될 수 있습니다.
무한 대기 특정 스레드가 자원을 계속해서 점유하여 다른 스레드가 자원에 접근하지 못하는 상황이 발생할 수 있습니다.
데이터 무결성 문제 동시에 자원이 변경되면 데이터 무결성이 깨질 가능성이 높습니다. 예를 들어 은행 계좌에서 출금을 처리할 때, 동시에 두 개의 스레드가 같은 계좌에서 출금을 시도하면 자원의 무결성이 깨질 수 있습니다.

동시성 제어의 핵심은 동일한 자원을 동시에 변경하지 않도록 제어하는 것 입니다. 락(Lock)을 걸어 스레드가 자원을 순차적으로 처리할 수 있게 하는 방식이 가장 일반적으로 사용됩니다.

이를 쉽게 화장실을 사용하는 상황으로 비유할 수 있습니다. 화장실을 사용하려면 키를 먼저 획득해야 하며, 키를 가진 사람이 나올 때까지는 다른 사람이 기다려야만 화장실을 이용할 수 있습니다.

하지만 락을 사용한다고 해서 락을 얻는 순서가 보장되지는 않습니다.
여러 스레드나 프로세스가 동시에 락을 요청할 때, 어떤 스레드가 먼저 락을 얻을지는 운영 체제의 스케줄링에 의해 결정되며, 이는 예측할 수 없습니다.

이로 인해 먼저 락을 요청한 스레드가 계속해서 대기하는 상황이 발생할 수 있으며, 이러한 무한 대기 상태는 공정성의 부재로 이어집니다. 공정성에 대해서는 아래에서 더 자세히 설명하겠습니다.

3. 해결 방법 분석

3.1 공정성이란?

공정성이란 모든 쓰레드가 자신의 작업을 수행할 기회를 공평하게 갖는 것을 의미합니다.

공정한 락(Fair Lock)

  • 여러 스레드가 락을 요청할 때, 락을 먼저 요청한 스레드가 먼저 Lock 을 얻을 수 있도록 보장하는 방식입니다.
  • 즉, 락을 기다리는 스레드가 많은 경우, 가장 오래 기다린 스레드(먼저 락을 요청한 스레드)가 락을 획득하게 되어 락을 요청한 순서가 보장됩니다.
  • 쉽게 말하보면 경쟁 상태일 때 가장 오랫동안 기다린 스레드에게 Lock 을 제공하는 것입니다.
  • 수강신청, 은행 대기열, 티켓팅 등 요청 순서대로 처리해야 하는 경우에 사용됩니다.

비공정한 락(Unfair Lock)

  • Lock 을 요청한 순서와 상관없이 스레드가 Lock 을 얻을 수 있습니다.
  • 때로는 스레드가 Lock 을 기다리는 도중에 다른 스레드가 더 빨리 Lock 을 얻을 수 있음을 의미합니다.
  • 성능 면에서 유리할 수 있지만, 특정 스레드가 Lock 을 얻지 못하고 계속 기다리는 상황이 발생할 수 있습니다(기아 상태, starvation).

정리하자면 락을 요청한 순서대로 락을 얻는 것을 보장하는 것이 공정성입니다.

기아 상태(Starvation)

  • 다른 쓰레드들에게 우선순위가 밀려 자원을 계속해서 할당받지 못하는 스레드가 존재하는 상황을 starvation(기아 상태)라 부릅니다.
  • 이러한 기아 상태를 해결하기 위해 공정성이 필요합니다.

3.2 synchronized

  • synchronized는 기본적인 동시성 제어 방법으로, 특정 메소드나 블록에 대해 한 번에 하나의 스레드만 접근할 수 있도록 보장 합니다.
  • 즉, 한 스레드가 해당 메소드나 블록을 실행 중일 때, 다른 스레드는 대기해야 합니다.
  • synchronized는 공정성을 지원하지 않습니다. 즉, 먼저 요청한 스레드가 먼저 락을 얻는 것을 보장하지 않습니다.
장점 단점
구현이 간단하며, 자원에 대한 동시 접근을 막아 Thread-Safe 하게 보장할 수 있습니다. - 한 번에 한 스레드만 자원을 사용할 수 있기 때문에 성능 저하를 일으킬 수 있습니다.
- 다수의 요청을 처리하는 시스템에서는 이로 인해 병목 현상이 발생할 수 있습니다.
- 여러 스레드가 동시에 요청하지 않아도 락이 걸려 있기 때문에, 불필요하게 대기 상태에 빠질 수 있습니다.

3.3 ConcurrentHashMap

  • ConcurrentHashMap은 멀티스레딩 환경에서 동시성을 보장하는 컬렉션 중 하나입니다.
  • 내부적으로 특정한 부분만 락을 걸어 성능 저하를 최소화하며, 다수의 스레드가 동시에 읽고 쓸 수 있습니다.
  • 자체적으로 동시성 문제를 어느 정도 해결해주지만, 읽기 작업에는 락을 걸지 않기 때문 같은 사용자에 대한 여러 개의 충전 요청이 동시에 들어오는 경우 동시성 문제가 발생할 수 있습니다. 이를 해결하기 위해서는 추가적인 락 메커니즘을 사용해야 합니다.
장점 단점
성능이 우수하며, synchronized와 달리 전체 맵에 락을 거는 대신 특정 버킷에만 락을 걸기 때문에 동시성 문제가 적습니다. 읽기 작업에서는 락을 걸지 않아 성능이 더욱 향상됩니다.
- 고도로 세밀한 락 제어가 필요한 경우에는 한계가 있을 수 있습니다.
- 특정 자원에 대해 락을 걸어야 하는 경우, 추가적인 락 메커니즘을 사용해야 합니다.
- 동시에 여러 스레드가 같은 객체에 접근하여 잘못된 결과를 초래할 수 있습니다.

아래와 같은 코드가 있습니다.

private final ConcurrentHashMap<Long, UserPoint> userPointHashMap = new ConcurrentHashMap<>();

public UserPoint charge(final ChargeUserPointCommand command) {
    return userPointHashMap.compute(command.getUserId(), (key, existingUserPoint) -> {
        final UserPoint userPoint = userPointRepository.findByUserId(command.getUserId());
        final UserPoint chargedPoint = userPoint.charge(command.getAmount());
        ... 생략
        return chargedPoint;
    });
}

public UserPoint use(final UseUserPointCommand command) {
    return userPointHashMap.compute(command.getUserId(), (key, existingUserPoint) -> {
        final UserPoint userPoint = userPointRepository.findByUserId(command.getUserId());
        final UserPoint usedPoint = userPoint.use(command.getAmount());
                ... 생략
        return usedPoint;
    });
}
  • compute 메서드는 락을 걸고 동작하기 때문에 userPointHashMap 이라는 Map 자체의 동시성 문제는 해결되지만, 문제는 내부적으로 UserPoint 객체에 대한 동시 접근에서 발생할 수 있습니다.
  • charge.charge(command.getAmount())와 같은 메서드 호출이 병렬로 실행될 경우, 각 스레드가 동일한 시점에서 같은 userPoint 값을 읽어와서 서로 다른 값으로 계산한 후 업데이트할 수 있습니다.
  • 이런 문제를 lost update(갱신 손실)라고 하며, 동시성 처리에서 흔히 발생하는 문제입니다.
  • 결국 ConcurrentHashMap 만으로는 동시성 문제를 완전히 해결할 수 없고, 추가적인 락 메커니즘을 사용해야 합니다.

3.4 ReentrantLock

  • ReentrantLocksynchronized 키워드와 유사한 기능을 제공하지만 보다 정교한 락 제어가 가능합니다.
  • 락 획득 시 대기 시간 제한, 인터럽트 가능 락, 공정성 보장 등의 추가 기능을 사용할 수 있습니다.
  • ReentrantLock 이 공정 모드로 설정되면, 락을 요청하는 스레드들은 내부적으로 대기 큐에 들어가고, 이 큐의 순서대로 락을 획득하게 됩니다. 따라서 먼저 요청한 스레드가 먼저 락을 획득할 수 있어 공정성을 보장할 수 있습니다.
장점 단점
- 락을 획득하는 순서를 공정하게 설정할 수 있으며, 스레드가 일정 시간 동안 락을 획득하지 못할 경우 타임아웃을 설정할 수 있습니다.
- 락의 상태를 모니터링할 수 있어 더욱 유연한 제어가 가능합니다.
- 개발자가 직접 락을 명시적으로 획득하고 해제해야 하므로, 코드 복잡도가 증가할 수 있습니다.
- 락을 해제하지 못하고 그대로 유지하는 실수는 자원 고갈을 야기할 수 있습니다.

4. 채택한 방법과 이유

4.1 채택한 방법

제가 채택한 방법은 ConcurrentHashMapReentrantLock을 결합하여 유저별로 락을 제어하는 방식 입니다.

유저별로 락을 제어하는 것이 가장 효율적이라고 생각해, 두 가지를 조합한 방식으로 각 유저별로 락을 생성하고, 동일한 유저에 대한 자원을 변경할 때만 락을 걸 수 있습니다.

해당 방법은 각 유저의 자원에만 락을 걸기 때문에 성능 저하가 최소화 된다는 장점을 갖습니다. 즉, 특정 자원에 대해서만 동시성 제어가 이루어지며, 다른 유저들의 자원 접근에는 영향을 주지 않습니다. 하지만 유저별로 락을 관리해야 하므로 락 관리에 대한 코드가 추가로 필요하고, 락을 획득하고 해제하는 로직이 복잡해질 수도 있다는 단점이 있습니다.

4.2 채택한 이유

그럼에도 불구하고 해당 방법을 선택한 이유는 아래와 같습니다.

  1. 성능 및 동시성 제어: ConcurrentHashMap을 이용하여 전체 자원에 대해 락을 걸지 않고, 유저별로 개별 자원에만 락을 걸 수 있기 때문에, 불필요한 스레드 대기나 성능 저하를 방지할 수 있습니다.
  2. 유연성: ReentrantLock을 사용하여 락을 세밀하게 제어할 수 있으며, 필요할 경우 공정성 보장 등의 기능을 활용할 수 있습니다.

두 가지 조합을 통해 각 유저에 대해 락을 제어하고, 동시에 여러 요청이 들어왔을 때도 공정하게 처리할 수 있습니다. 또한, ConcurrentHashMap만 사용할 경우 발생할 수 있는 lost update 문제를 ReentrantLock 를 사용하여 해결할 수 있습니다.

About

항해 플러스 1주차 테스트 코드 적용해보기

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages