문제점
모임을 만들수 있는 작업을 하면서 간단하게 생각해볼 수 있는 문제를 생각해봤다
일반적인 요청은 그저 모임에 참여를 하면 모임 참여자 테이블에 해당 사용자가 추가되고 정원에 도달하면 모임 상태가 모집 마감으로 변경되어 더 이상 참여를 누를 수 없는 상태로 변한다. 정말 단순한 로직이지만 동시에 해당 요청을 진행하면 문제가 발생한다
예를 들어 정원이 10명인 모임에 ( 아 물론 모임장은 빼야한다 ) 남은 자리는 9개뿐인데 100명이 동시에 참여를 누르면 당연하지면 9명만 들어오고 나머지 91명은 분명히 실패해야한다. 하지만 그 어떤 처리도 하지 않으면 read 타이밍이 겹치는 순간 문제가 발생하는 것이다
그래서 나는 단순한 api 응답이 아닌 동시성을 테스트하고자 하는 것이다
문제의 로직 순서
1. 사용자의 권한 체크
2. 모임을 조회
3. 모임이 모집 중인지 확인한다
4. 기존의 참여 이력이 있는지 확인한다
5. 참여자 row 를 생성한다
6. 참여자 수가 만약 정원이 꽉 차면 해당 모임을 모집 마감 상태로 변경한다
여기서 위험한 지점은 참여 가능 여부를 판단하고 참여자를 저장하는 과정이다
동시 요청 상황에서 여러 트랜잭션이 같은 모임 상태와 같은 참여자 수를 기준으로 판단하면, 각 요청은 모두 참여 가능하다고 판단할 수 있다. 그래서 이 기능은 단순한 insert 문제가 아니다. 같은 모임에 대한 참여 판단과 상태 변경을 하나의 기준으로 통일해야 하는 문제에 가깝다
해결 방안
1. 조건부 update 기반으로 참여 카운터를 관리하는 방식
모임테이블에서 현재 참여자 수를 별도로 두고, 현재 모임자 수를 체크하는 방식으로 count 를 증가시켜 진행하는 것이다.
이 방식을 이용하면 update 가 발생하면서 바로 쿼리가 날아가기 때문에 db 레벨에서 lock 을 걸어 자연스럽게 동시성을 방지할 수 있지만 중요한것은 로직이 그뿐이 아니라 참여자 테이블에 해당 인원 추가, 모임 상태 변경이 함께 움직이고 있는 것이다.
그래서 별도 카운터를 도입하게 되면 오히려 데이터 모델과 동기화 정책까지 변경되어야 해서 오히려 변경이 커진다
2. 낙관적 락 사용
낙관적 락을 사용하는 조건은 충돌이 자주 발생하지 않는 상황이라는 조건을 주로 두고는 한다. 하지만 나는 모임의 동시성 체크는 많은 신청이 모이는 것으로 시나리오를 그리고 있다. 그래서 만약 마지막 자리를 두고 동시 참여가 생기면 잦은 충돌이 발생할 수 있게 되는것이다. 그래서 잦은 충돌이 발생하면 이가 주된 로직이 되면서 재시도 로직까지 설계해야 하기 때문에 이도 좋은 방향이라고 볼 수 없다고 생각
3. 비관적 락 사용
앞서 말했지만 참여 요청을 하고 확정이 되는 조건은 특정 모임의 정원, 상태, 참여자 수를 기준으로 판단을 하게 된다
결국 같은 모임에 대한 참여 요청들은 하나의 기준으로 순서가 정리되어야 한다는 것이다. 그래서 정리 기준이 참여자 테이블을 중심으로 보는게 아니라 모임의 테이블로 잡아서 상태를 보는 순서를 확실히 잡아야 한다는 것이다
그래서 모임을 읽는 read 쿼리가 발생할때 비관적 락을 거는 방식으로 사용했다.
이로 인해 한 순간 집중되는 요청을 처리하는 정합성을 좀 더 확실하게 지킬 수 있다. 물론 성능을 중심으로 개발하는 내용이 아니기 때문에 비관적 락이 비교적 느리지만 정합성을 위한 선택이라고 할 수 있다
1차 검증. JUnit 을 사용한 통합 테스트
먼저 JUnit 을 사용해서 로직 수준의 동시성 테스트를 진행해보려고 한다
이때 사용한 테스트는 단위 테스트가 아닌 boot & JPA & PostgreSQL TestContainers 를 사용한 통합 테스트이다
이는 로직을 확인하는 것이기 때문에 HTTP, Security, Controller 를 거치는 작업은 거치지 않는것이 중요하다
관심사
- 동시에 여러 트랜잭션이 참여 요청을 실행했을 때 정원이 초과되지 않는가
- 모임에 걸린 비관적락이 postgre에서 정확하게 진행이 되는가
- 최종 참여자 수와 모임 상태가 의도대로 진행이 되는가
테스트 시나리오
코드
@Test
void concurrentJoinRequestsDoNotExceedMeetingCapacity() throws Exception {
Meeting meeting = prepareRecruitingMeetingWithMembers();
CountDownLatch ready = new CountDownLatch(CONCURRENT_JOINERS);
CountDownLatch start = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_JOINERS);
try {
List<Future<JoinAttemptResult>> futures = submitJoinAttempts(executor, meeting, ready, start);
assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue();
start.countDown();
List<JoinAttemptResult> results = collectResults(futures);
long successCount = results.stream().filter(JoinAttemptResult::succeeded).count();
long unexpectedFailureCount = results.stream().filter(JoinAttemptResult::unexpectedFailure).count();
long joinedParticipantCount = meetingParticipantRepository.countByMeetingId(meeting.getId());
Meeting updatedMeeting = meetingRepository.findById(meeting.getId()).orElseThrow();
assertThat(unexpectedFailureCount).isZero();
assertThat(successCount).isEqualTo(CAPACITY - 1L);
assertThat(joinedParticipantCount).isEqualTo(CAPACITY - 1L);
assertThat(joinedParticipantCount + 1L).isLessThanOrEqualTo(CAPACITY);
assertThat(updatedMeeting.getStatus()).isEqualTo(MeetingStatus.RECRUITMENT_CLOSED);
} finally {
executor.shutdownNow();
}
}
- 정원 10명짜리 모집 중 모임을 생성한다
- 참여 요청을 보낼 일반 크루원 100명을 생성한다
- 100개의 요청자를 생성한다
- 모든 스레드가 같은 출발선에서 대기하도록 countDownLatch 를 사용한다
- 메인 테스트 스레드가 latch 를 열게되면 100개의 스레드가 거의 동시에 참여 요청을 실행한다
- 각 스레드는 JoinMeetingUseCase.handle()을 호출
- 모든 요청이 끝나면 DB에서 최종 참여자 수와 모임 상태를 다시 조회한다
- ExecutorService : 동시 작업을 위한 스레드 풀 열기
- Future : 작업의 성공/실패 결과를 수집하여 한 번에 확인
결과
- 성공한 참여 요청은 9개여야 한다
- 최종 참여자 수는 9명
- 모임 상태는 모집 마감으로 변경되어야 한다
- 100명중 91명은 실패하는 로직이 자연스러워야 한다
- 예상하지 못한 DB 예외 발생이나 500 계열 서버 에러는 발생하면 안된다
결국 해당 테스트가 통과하면 정원 동시성 문제가 해결되었다고 볼 수 있다
2차 검증. k6 API 테스트
앞서 JUnit 테스트를 통해 모임 참여 로직이 정원 초과를 막는다는 것은 확인을 했다
다음으로는 실제 요청 환경에서 100명이 동시에 같은 모임에 참여 요청을 보냈을때도 원하는 방향으로 해결되는지를 확인해봐야 한다
그리고 응답 시간은 사용자가 느끼는 상황이기 때문에 로컬에서 테스트를 하는건 의미가 없다고 생각했다. 때문에 현재 서비스가 올라가있는 EC2 서버에서 해당 테스트를 진행했다
가볍에 EC2 의 서버 스펙을 간단하게 말해보자. 프리티어 aws ec2 서버이지만 말이다.
인스턴스 | AWS EC2 t3.small
CPU | Intel Xeon Platinum 8259CL (2 vCPU)
메모리 | 약 2GB
구성 | Spring Boot + PostgreSQL 동일 인스턴스 실행
관심사
- 동시에 요청이 몰려도 동시성이 보장되는가
- 동시성을 지키는 과정의 응답 시간은 얼마나 중요할까
테스트 시나리오
테스트 조건은 앞서 JUnit과 같다...
결과

- 총 요청 수 : 100
- 5xx 에러 : 0건
- 실패 응답 비율 : 91%
- 평균 응답 시간 : 1.85초
- p90 응답 시간 : 2.17초
- p95 응답 시간 : 2.19초
- 최대 응답 시간 : 2.21초
- TPS : 44
동시성만 놓고 보자면 역시 잘 지켜졌다고 볼 수 있다. 하지만 역시 성능보다는 정합성을 먼저 보았기 때문에 응답 시간이 아쉬웠다.
전체적으로 100명의 동시 요청 처리가 약 2초 수준으로 끝나는 것으로 볼 수 있었다.
물론 비관적 락의 특성이기 때문이라고 생각한다
초기에 말했듯이 현재 구조에서는 같은 모임에 대한 참여 요청은 모임 테이블의 read 를 기준으로 하나로 통일하기 때문에 동시성을 막는데는 효과적이지만 하나의 트랜잭션이 뒤의 모든 트랜잭션의 대기를 발생시키때문에 성능적으로 문제가 발생할 수 있는것이라고 예상한다.
향후 개선 방향
사실 현재 서비스가 100명이 동시에 모임을 요청할 것이라는 예상은 전혀 하고 있지 않다. 하지만 추후에 트래픽이 증가하거나 특정 모임에 요청이 자주 집중된다면 당연히 개선 방향을 모색해야한다
- 모집 마감 상태에 대한 빠른 실패 전략
- 굳이 모든 로직을 다 타게 하는게 아니라 빠른 return 을 목표로 하는 것도 괜찮을거 같다
- 정원 임박/마감 상태를 캐싱 또는 read model로 빠르게 확인
- 이또한 빠른 return 을 목표로 할 수 있을거 같다
- 참여 요청 대기열 도입
- 총 몇 명만 가능하고 뒤는 그냥 트랜잭션 얻기도 전에 싹다 return 하는 방식으로 이건 순서도 같이 지킬 수 있을것 같다
- (희망) EC2 사양 업그레이드하기
- 역시 프리티어인 t3.small 의 ec2 cpu가 형편없어 로직 처리가 오래걸리는 것이 가장 큰 영향을 차지하는 것이라고 생각한다 나중에...돈 생기면 올려보는것도...나쁘지 않을거 같다
세가지 정도로 생각을 해봤지만 결국 빠른 return 을 해서 lock을 물고 있는 시간을 최대한 줄이는 방식으로 생각이 계속 들게 된다.
그리고 나중에 돈을 벌게 되면 서버를 아예 scale up 해보는것도 너무 좋을거 같다