Continuous Challenge

[항해플러스 7기 백엔드] 1주차 회고 - TDD, 동시성 제어 본문

Study/항해플러스 7기

[항해플러스 7기 백엔드] 1주차 회고 - TDD, 동시성 제어

응굥 2024. 12. 21. 23:16
728x90
728x90

TDD

TDD는 현업에서 사용하지는 않지만 이전에 NEXTSTEP 교육을 통해 학습했던 경험이 있어서 그렇게 부담으로 다가오진 않았다.

그리고 1주차 과제에서 요구하는 것은 '테스트 코드를 먼저 작성하는 TFD 방식 보다는 테스트 코드 자체에 대한 중요성을 강조하여 기능을 구현한 뒤 테스트 코드를 작성하는 TLD 방식으로 코드를 작성해도 된다. 오히려 TFD로 작성하는 경우를 본 적이 드물다.' 고 말씀해 주셔서 조금은 편하게 과제를 진행할 수 있었다.

 

이번 과제에서는 요구 사항 분석정책 설정(최대 금액 등)에 신경 썼다.

구현해야 할 기능, 예외 처리해야 할 부분을 먼저 구성하고 나서 구현을 시작했다.

1주차 과제의 요구사항은 크게 포인트 조회, 충전, 사용, 히스토리 내역 조회 기능을 구현하고 테스트 코드를 작성하는 것이었다.

그리고 추가적으로 동시성 이슈가 발생하지 않도록 동시성 제어를 구현하는 것이었다.

 

동시성 제어

동시성 제어,,

들어는 보았지만 실제로 밀접하게 접할 기회가 없어서 익숙하지 않았던 용어,,와 구현 방식,,

 

동시성 제어에는 여러 방식이 있다.

과제에 동시성 제어에 대한 분석 및 보고서 제출이 있어 공부한 내용을 정리해 깃허브에 올려두었지만 마음에 들지 않아 시간을 내어 다시 한 번 정리해야겠다는 다짐을.. 해본다.

 

내가 사용했던 방식은 ConcurrentHashMapReentrantLock 을 사용한 방식이다.

동시성 제어에는 Java5 부터 출시된 Concurrent 패키지에서 제공하는 함수를 많이 사용하는데 Concurrent 패키지에 존재하는 컬렉션들은 락을 사용할 때 발생하는 성능 저하를 최소한으로 만든다고 한다. 그 중에서도 ConcurrentHashMap은 내부적으로 여러 개의 락을 가지고 해시값을 이용해 락을 분할하여 사용하는 병렬성과 성능을 모두 잡은 컬렉션이다.

ReentrantLock은 Lock의 구현체 명시적인 락 방식으로 lock(), unlock()을 통해 수동으로 락을 잠그고 해제하는 클래스이다. 암묵적인 락으로는 해결할 수 없는 복잡한 상황에서 사용할 수 있어 synchronized 보다 선호하는 방식이다.

사용하게 된 이유는 위와 같이 여러가지 이유가 있지만 멘토링 청강에서 멘토분들이 추천해준 방식이라는 이유가 가장 컸다. (ㅎㅎ)

@Component
public class ServiceLockFactory {
    private final ConcurrentHashMap<Long, ReentrantLock> lockMap = new ConcurrentHashMap<>();

    public ReentrantLock getLock(long id) {
        return lockMap.computeIfAbsent(id, key -> new ReentrantLock());
    }
}

 

포인트 사용을 하는 유저의 id에 대해 Lock 을 개별 관리하기 위한 도구로 ConcurrentHashMap을 사용하였다.

  • getLock() : 해당 id 에 대응하는 ReentrantLock을 반환하며, 해당 id 에 해당하는 락이 없다면 새로운 ReentrantLock을 생성해 lockMap에 추가 후 반환한다.
   /**
     * 포인트 충전
     * @param id 사용자 id
     * @param amount 충전포인트
     * @return 충전 후 포인트
     */
    public UserPoint charge(long id, long amount) {
        long MAX_VALUE = 2_000_000_000L;

        // 충전 금액 검증
        if (amount < 0) {
            throw new PointException(PointErrorCode.CHARGE_AMOUNT_LESS_THAN_ZERO);
        }
        if (amount > MAX_VALUE) {
            throw new PointException(PointErrorCode.CHARGE_AMOUNT_GREATER_THAN_MAX);
        }

        ReentrantLock lock = lockFactory.getLock(id);

        lock.lock();

        try {
            // 포인트 충전
            UserPoint userPoint = pointRepository.selectById(id).orElse(UserPoint.empty(id));
            UserPoint chargedPoint = userPoint.charge(amount);
            UserPoint savedUserPoint = pointRepository.insertOrUpdate(chargedPoint);

            // 히스토리 저장
            PointHistory chargeHistory = PointHistory.createChargeHistory(savedUserPoint.id(), savedUserPoint.point(), savedUserPoint.updateMillis());
            pointHistoryRepository.insert(chargeHistory);

            return savedUserPoint;
        } finally {
            lock.unlock();
        }
    }

 

lockFactory 에서 id에 대응하는 락 또는 새롭게 생성된 락을 ReentrantLock 으로 선언하여 충전에 대한 동시성 이슈를 제어하였다.

 

 

동시성 제어에 대한 테스트에는 ExecutorServiceCountDownLatch를 사용하였다.

 

ExecutorService 역시 Concurrent 패키지에서 제공하는 인터페이스 중 하나로, 스레드 풀의 개념을 제공하며 스레드를 만들고 관리하는 작업을 단순하게 처리할 수 있도록 도와준다. 성능을 향상시키고 스레드 관리에 대한 복잡성을 줄일 수 있다.

CountDownLatch는 ExecutorService와 함께 사용할 수 있는 동기화 유틸리티다. 일정 갯수의 쓰레드가 모두 완료될 때까지 쓰레드를 대기시킬 때 사용한다.

	
    @Test
    void 충전_동시성_제어_테스트() throws InterruptedException {
        // given
        long id = 1L;
        long amount = 100L;

        pointRepository.insertOrUpdate(new UserPoint(id, 0L, System.currentTimeMillis()));

        int threadCount = 30;

        // when
        this.executorService(threadCount, () -> pointService.charge(id, amount));

        // then
        Optional<UserPoint> optionalUserPoint = pointRepository.selectById(id);
        assertThat(optionalUserPoint).isPresent();
        UserPoint userPoint = optionalUserPoint.get();
        assertThat(userPoint.point()).isEqualTo(amount * threadCount);
    }
    
    private void executorService(int threadCount, Runnable task) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                try {
                    task.run();
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executorService.shutdown();
    }

 

여러 테스트에서 반복적으로 사용되는 로직을 별도의 메서드(executorService)로 추출하여 사용하였다.

 

ExecutorService

  • Executors.newFixedThreadPool() : 고정된 쓰레드 수로 ThreadPoolExecutor를 초기화한다. 동시에 실행되는 작업의 수가 지정한 쓰레드 수보다 작거나 같다면 곧바로 실행된다. 그렇지 않다면 차례를 기다리기 위해 큐에 남아 대기한다.
  • submit() : 멀티 쓰레드로 처리할 작업을 예약한다. 
  • shutdown() : 작업을 중지하고 리소스를 정리한다. 새로운 작업은 거부하고 이미 제출된 작업들이 완료될 때까지 기다린다.

CountDownLatch

  • new CountDownLatch(threadCount) : 몇 개의 스레드가 완료되면 다음 쓰레드를 시작할 것인지 정한다.
  • countDown() : 쓰레드가 완료될 때마다 카운트를 감소한다.
  • await() : 카운트가 0이 되면 대기가 풀리고 다음 쓰레드를 실행한다.

장기여행을 다녀오고 나서 정신없이 시작했던 1주차였는데 무사히 과제를 Pass 할 수 있어서 우선 한시름 놓았다,,

1주차 과제를 진행하면서 테스트 코드의 중요성을 제대로 생각해볼 수 있었고, 동시성 제어에 대해서도 경험해볼 수 있어서 좋았다.

앞으로 현업에서 기능 구현을 할 때에도 테스트 코드 작성 기간을 함께 잡아 구현한 기능에 대한 테스트 코드를 작성해볼 생각이다.

(실제로 1주차 과제를 진행하면서 현업에서도 테스트 코드 작성하고 있다!)

동시성 제어에 대해서도 조금 더 깊게 공부해보아야겠다!

 

오늘 시작된 2주차 과제도 무사히 Pass할 수 있기를 바라면서,, 1주차 회고를 마친다...

항해 멘토분들과 운영진분들께 감사를 표하며,, 함께 과제하고 있는 7기 여러분들도 화이팅,,!    

728x90
728x90
Comments