크루팀의 완벽한 ‘솝케팅’을 위한 여정
Intro.
3,000여명의 회원들이 존재하는 SOPT를 수료하셨다면, 누구나 ‘모임’ 기능을 통해 스터디나 친목 모임을 열고, 자유롭게 참여할 수 있어요. 그 중에서도 SOPT의 활동 기수 분들은 ‘모임’ 기능을 통해 솝커톤, 네트워킹 데이 등의 행사에 신청하는데요.
보통 100여명의 대규모로 진행되는 SOPT의 행사들은, 인기가 많아 선착순으로 빠르게 마감되는데요. SOPT에서는 행사 신청이 치열하기 때문에 ‘솝케팅’이라는 은어로 부르기도 해요.
때문에 큰 행사들을 앞두고 있는 기간동안, ‘모임’ 기능을 관리하는 SOPT makers의 크루 팀은 긴장 상태에 있습니다. 오늘은 그중에서도, 행사 신청 기간동안 순간적으로 트래픽이 몰리는 ‘모임’ 기능을 정상적으로 동작하게 하기 위해 크루 팀의 BE들이 노력한 내용들을 공유하고자 해요.
안녕하세요!
저는 SOPT makers의 크루팀에서 36, 37기 BE 개발을 하고 있는 김효준이라고 합니다.
SOPT에는 여러 가지 모임들이 존재하지만, 그 중에서도 솝커톤과 네트워킹 행사는 SOPT 활동의 ‘꽃’이라고 말할 수 있는데요. 오늘은 지난 36기 솝커톤 신청에서 겪었던 “똑같은 사람이 2명” 이슈를 어떻게 해결했는지 공유해보려고 해요.
SOPT 활동 기수들은 행사 신청 기간이 되면, ‘솝케팅’을 위해 아래와 같은 모습으로 간절히 기다리겠죠?
저희 크루팀도, 그런 여러분들 뒤에서 서버가 터지지 않길, 이슈가 발생하질 않길 기도하고 있어요. (ㅎㅎ)
그런데… 지난 36기 솝커톤 신청에서 이슈가 발생했어요.
바로 똑같은 사람 2명이 신청 현황에 뜨는 이슈가 있었던 건데요.
전문적인 용어를 사용해 보면 아래와 같은 이유예요.
Race Condition 발생!
사용자가 여러 기기에서 동시에 모임을 신청할 때, ‘신청 여부 확인’과 ‘신청 정보 저장’ 사이에 Race Condition이 발생해서 중복 신청 데이터가 쌓이는 문제
SOPT 행사는 인기가 많은만큼 신청에서 이슈가 발생했을 때의 빠른 대처가 중요한데요,
크루 팀 모두 해당 이슈를 모니터링하다가 로그를 보며 대응할 수 있었어요.
문제 상황을 간단히 요약하자면, 다음과 같아요.
참여하고 싶은 마음이 간절한 SOPT인은 휴대폰, 컴퓨터 두 기기를 가지고 동시에 모임 신청을 눌렀어요.
이때, 두 기기에서 요청을 보낸 API 간격은 약 0.000802초 밖에 차이가 나지 않았어요. (이는 DB에 들어가는 시간보다 더 빨랐어요.)
DB에 저장되기 직전 거의 동시에 들어온 요청이었기에 1명이지만, 2명의 유저처럼 저장되었어요.
이 상황을 시퀀스 다이어그램으로 보면 다음과 같은데요.
0.000802초라는 극소의 시간 차이로 발생한 Race Condition을 해결하기 위해, 크루 팀은 여러 기술적 해결책을 면밀히 검토했어요.
먼저 우리가 충족해야 할 핵심 요구사항을 정리하면 다음과 같아요.
환경적 제약: SOPT 모임 서버는 현재 단일 인스턴스 환경에서 운영 중입니다.
성능과 정합성의 균형: 서로 다른 사용자의 요청은 병렬로 처리하여 성능을 보장해야 하고,
동일 사용자의 중복 요청은 순차적으로 처리하여 데이터 정합성을 보장해야 합니다.
이 두 가지 요구사항을 기준으로 여러 동시성 제어 방법을 평가했어요.
JPA의 @Version 어노테이션을 활용한 버전 기반 동시성 제어 방식이에요.
동작 원리:
엔티티를 조회할 때 버전 정보를 함께 가져옵니다.
수정 시도 시 버전을 비교하여, 버전이 달라졌다면 다른 트랜잭션이 수정한 것으로 판단합니다.
충돌 발생 시 OptimisticLockException을 발생시키고 재시도합니다.
기각 사유:
낙관적 락은 기존 데이터를 수정하는 상황에 적합한 메커니즘입니다. 그러나 모임 신청은 새로운 데이터를 생성하는 작업입니다.
더 중요한 것은, 이미 첫 번째 요청이 성공한 상황에서 두 번째 요청을 재시도하는 것은 비즈니스 로직상 의미가 없습니다. 사용자에게는 “이미 신청이 완료되었습니다”라고 명확히 알려주는 것이 올바른 처리 방식입니다.
JPA의 @Lock 어노테이션을 활용한 데이터베이스 레벨의 행 잠금 방식이에요.
동작 원리:
데이터를 읽을 때 해당 행에 배타적 잠금을 걸어, 다른 트랜잭션의 접근을 차단합니다.
트랜잭션이 완료될 때까지 다른 요청은 대기하게 됩니다.
기각 사유:
비관적 락의 가장 큰 문제는 같은 모임에 대한 모든 사용자의 요청이 순차적으로 처리된다는 점입니다.
예를 들어 120명이 동시에 같은 모임에 신청한다고 가정해봅시다. 각 요청의 처리 시간이 200ms라면:
User 001: 0ms에 시작 → 200ms에 완료
User 002: 200ms 대기 후 시작 → 400ms에 완료
User 003: 400ms 대기 후 시작 → 600ms에 완료
…
User 120: 23,800ms 대기 후 시작 → 24,000ms에 완료
마지막 사용자는 약 24초를 기다려야 합니다. 서로 다른 사용자(User A와 User B)까지 줄을 서서 기다리게 만드는 것은 불필요한 성능 저하입니다.
이는 “서로 다른 사용자의 요청은 병렬로 처리한다”는 핵심 요구사항에 부합하지 않습니다.
Redis의 SETNX 명령어나 Redisson 라이브러리를 활용한 분산 환경 동시성 제어 방식이에요.
동작 원리:
Redis에 특정 키로 값을 설정하려 시도합니다(SETNX).
키가 존재하지 않으면 설정에 성공하고 락을 획득한 것으로 간주합니다.
키가 이미 존재하면 다른 인스턴스가 락을 보유 중인 것으로 판단합니다.
기각 사유:
분산 락은 여러 서버 인스턴스가 동시에 실행되는 환경을 위해 설계된 솔루션입니다.
현재 SOPT는 단일 인스턴스 환경에서 운영 중이므로, 분산 락을 도입하는 것은 과도한 복잡성을 추가하는 오버엔지니어링입니다. 또한 Redis 서버 자체가 새로운 장애 지점이 되며, 네트워크 I/O로 인한 불필요한 지연이 발생합니다.
아키텍처 설계 원칙: “현재 시스템의 요구사항과 환경에 맞는 가장 단순하면서도 효과적인 방법을 선택한다”
단일 인스턴스 환경에서 사용자별 세밀한 동시성 제어를 위해, Java 표준 라이브러리의 ReentrantLock
과 ConcurrentHashMap을 조합한 방식을 선택했어요.
환경 적합성: 단일 인스턴스 환경에서 JVM 내부 동기화를 활용하여 외부 의존성 없이 구현 가능합니다.
세밀한 제어: 사용자 ID별로 독립적인 락을 관리하여, 같은 사용자의 요청만 순차 처리하고 다른 사용자는 병렬 처리할 수 있습니다.
명확한 의도 표현: tryLock(timeout)을 통해 락 획득 실패 시 즉시 사용자에게 응답할 수 있습니다.
뛰어난 성능: 순수 JVM 메모리 레벨에서 동기화를 처리하기 때문에, DB 락이나 분산 락처럼 네트워크 I/O나 DB 커넥션을 점유하는 다른 대안들과 비교해 훨씬 응답 속도가 빠릅니다.
여기서 날카로운 질문이 나올 수 있는데요.
“synchronized로도 사용자별 락을 걸 수 있지 않나요?"
맞습니다!
실제로 ConcurrentHashMap과 synchronized를 조합하면 기능적으로 동작해요.
그럼에도 불구하고 ReentrantLock을 선택한 3가지 결정적 이유가 있어요.
synchronized는 락 객체의 내부 상태를 확인할 방법이 없습니다. 따라서 한번 생성된 락 객체는 Map에 영원히 남게 되어 메모리 누수가 발생해요.
이에 대한 해결책은 아래와 같아요.
isLocked(): 락이 현재 잠겨있는지 확인
hasQueuedThreads(): 대기 중인 스레드가 있는지 확인
이 정보를 바탕으로 “아무도 사용하지 않는 락 객체”를 안전하게 제거할 수 있습니다.
synchronized는 락을 획득할 때까지 무한정 대기합니다.
이에 대한 해결책은 아래와 같아요.
5초 안에 락을 획득하지 못하면 즉시 예외를 던져, 사용자에게 명확한 피드백을 제공할 수 있습니다.
락의 획득과 해제가 코드에 명확하게 드러나 가독성과 안정성이 높습니다.
향후 추가 요구사항에 유연하게 대응할 수 있습니다.
사용자별 락을 관리하기 위해 Map이 필요한데, 왜 일반 HashMap이 아닌 ConcurrentHashMap을 사용해야 할까요?
락을 관리하는 자료구조 자체가 동시성 버그를 일으킨다면, 락의 의미가 완전히 사라지기 때문입니다.
일반 HashMap으로 락을 관리하면 락 객체 자체가 중복 생성되는 심각한 문제가 발생해요.
이 코드는 Check-Then-Act 패턴으로, 원자적이지 않습니다. 세 개의 독립적인 연산으로 구성되어 있고, 각 연산 사이에 다른 스레드가 끼어들 수 있기 때문이에요.
핵심 문제:
Thread 1은 Lock_A 객체를 잠그고 있다고 생각합니다
Thread 2는 Lock_B 객체를 잠그고 있다고 생각합니다
실제로는 서로 다른 객체이므로 둘 다 동시에 임계 영역에 진입합니다
결과: 락이 전혀 작동하지 않고, 똑같은 사람이 2명 이슈 재발!
ConcurrentHashMap의 computeIfAbsent 메서드는 Check-Then-Act를 하나의 원자적 연산으로 만듭니다.
return userLocks.computeIfAbsent(userId, id -> new LockWrapper());
이 한 줄의 코드는 다음 세 단계를 분리 불가능한 하나의 연산으로 수행해요.
키 존재 여부 확인
없으면 값 생성 원자적 연산 (atomic)
맵에 저장하고 반환
왜 원자적일까요?
ConcurrentHashMap은 내부적으로 버킷 단위 잠금을 사용해요. 해당 버킷(인덱스)에 대해서만 락을 획득하여 “확인-생성-저장”을 한 번에 처리하고, 작업이 끝나면 락을 자동으로 해제합니다.
ConcurrentHashMap이 빠른 이유는 두 가지 핵심 기술 때문입니다.
CAS는 CPU가 제공하는 원자적 명령어입니다 (x86의 CMPXCHG). 빈 버킷에 첫 노드를 삽입할 때는 락 없이 CAS만으로 처리하고, 충돌 시에만 해당 버킷에 synchronized를 사용해요.
Hashtable과 달리 ConcurrentHashMap은 버킷별로 독립적인 락을 사용합니다.
이는 모든 연산이 직렬화돼요.
서로 다른 버킷은 병렬 접근이 가능해요.
Thread 1: put(key1, “A”) // hash → 버킷 0 잠금
Thread 2: put(key2, “B”) // hash → 버킷 5 잠금 (동시 실행 가능!)
Thread 3: get(key3) // hash → 버킷 0 대기 (Thread 1 사용 중)
Thread 4: put(key4, “D”) // hash → 버킷 12 잠금 (동시 실행!)
Java 7 이전: 16개의 세그먼트로 분할, 각 세그먼트마다 독립적인 락
Java 8 이후: 노드 레벨 락으로 진화, CAS와 synchronized를 혼합하여 더욱 세밀한 동시성 제어
이 구조 덕분에 동시성이 높은 환경에서 Hashtable 대비 10배 이상의 처리량 향상을 달성할 수 있어요.
결론적으로, 이렇게 한 이유는 다음과 같아요.
ConcurrentHashMap: 락 객체의 원자적 생성과 일관성 보장
ReentrantLock: 메모리 최적화, 타임아웃, 확장 가능성
동시성 제어를 위한 락을 관리하는 자료구조 자체가 동시성 안전해야 하고, 락 자체도 고급 제어 기능을 제공해야 전체 시스템이 올바르게 작동하는 것이죠.
1) 제네릭 메서드
Supplier<T>를 사용하여 비즈니스 로직을 주입받습니다. 이를 통해 락 관리 로직과 비즈니스 로직을 분리할 수 있습니다.
2) 타임아웃 설정
tryLock(timeout)을 사용하여 무한 대기를 방지하고, 락 획득에 실패하면 즉시 사용자에게 응답합니다.
3) 예외 안전성
finally 블록에서 락 해제를 보장하여, 예외 발생 시에도 데드락이 발생하지 않도록 합니다.
4) 메모리 최적화
usageCount를 통해 사용 빈도가 낮은 락 객체를 자동으로 메모리에서 제거합니다. 이를 통해 장기간 운영 시 메모리 누수를 방지할 수 있습니다.
해결책을 만들었다고 끝이 아니겠죠.
모임을 하는데 0.000802초와 같은 격차로 인해 신청하는 상황은 저희 손으로 하기 쉽지 않기 때문에, 안정성을 위한 테스트도 진행했어요.
그래서 저희는 CountDownLatch라는 도구를 사용해서 여러 스레드가 정확히 동시에 모임 신청을 하는 상황을 시뮬레이션했습니다.
CountDownLatch는 여러 스레드가 특정 지점에서 동기화될 수 있도록 하는 동기화 유틸리티입니다. 육상 경기에 비유하면:
� 육상 경기 비유:
준비 단계 (readyLatch)
└─ 모든 선수가 출발선에 도착할 때까지 대기
출발 신호 (startLatch)
└─ 심판의 총성과 동시에 모든 선수가 출발
완료 대기 (finishLatch)
└─ 모든 선수가 결승선을 통과할 때까지 대기
참고: readyLatch.await() 후 Thread.sleep(5000) 을 추가한 이유는 Spring의 트랜잭션 프록시와 JPA 영속성 컨텍스트가 완전히 초기화될 시간을 주기 위함이에요.
통합 테스트 환경에서 스레드가 생성되자마자 비즈니스 로직을 실행하면, 트랜잭션 컨텍스트가 제대로 설정되지 않아 예상치 못한 결과가 발생할 수 있어요.
성공 요청: 1건
실패 요청: 4건 (예외: “이미 지원한 모임입니다”)
데이터베이스: 1건의 신청 데이터만 존재
성공 요청: 5건 (모두 성공!)
실패 요청: 0건
데이터베이스: 5건의 신청 데이터 존재 (각 사용자별 1건씩)
테스트도 끝!
이제 다음 해커톤이나 네트워킹 행사에서 SOPT인 여러분들이 아무리 빨리 연타로 신청 버튼을 눌러도 안전할 거라는 믿음을 가질 수 있겠어요! ㅎㅎ
테스트를 통해 다음과 같은 내용도 확인했어요:
에러 메시지: 실패한 요청들이 명확한 에러 메시지 받음
DB 정합성: 실제로 신청 데이터가 1개만 저장되는지 확인
이제 정말 안심이 되네요!
다음 행사에서는 중복 신청 이슈 없이 깔끔하게 진행될 것 같습니다.
동시성 이슈 없이, 무사히 마무리 될 수 있었답니다!
이번 동시성 문제 해결 과정을 통해 아래와 같은 경험들을 쌓을 수 있었어요.
적정 기술 선택의 중요성: 복잡한 기술이 항상 답은 아닙니다. 현재 환경(단일 인스턴스)과 요구사항(사용자별 동시성 제어)에 맞는 가장 단순하면서도 효과적인 방법을 찾는 것이 최선이에요.
CountDownLatch를 활용한 동시성 테스트로 실제 상황을 시뮬레이션하고 검증할 수 있었어요.
0.000802초라는 아주 작은 시간 차이에서 시작된 문제였지만, 이를 해결하는 과정에서 동시성 제어에 대한 깊은 이해를 얻을 수 있었어요.
앞으로도 크루 팀의 BE들은 SOPT인들이 안심하고 모임 신청을 할 수 있도록, 더 나은 기술적 해결책을 지속적으로 고민하고 개선해 나가겠습니다.
makers 팀에게 제안하고 싶으신 것이 있다면, 카카오톡 채널 @SOPT makers로 전달해 주세요.
written by. 김효준 | 36, 37기 | Crew팀 | BE
supported by. 이동훈 | 35-37기 | Crew팀 | BE
edited by. 이재현 | 35-37기 | APP팀, 37기 팀장 | iOS