
LMS 서비스를 운영하던 중 한 가지 문제를 발견했다. 동일한 사용자가 같은 과제에 대해 "제출" 버튼을 빠르게 두 번 누르면, 과제가 두 번 저장되는 현상이 생기는 것이다. 코드를 들여다보니 원인은 단순했다.
@Transactional
public SubmissionResponse submitAssignment(...) {
// 중복 체크
validationUtils.validateDuplicateSubmission(userId, planId);
// 저장
submissionRepository.save(submission);
}

중복 체크를 통과한 뒤 저장하는 구조 자체는 자연스럽다. 문제는 요청 A와 B가 거의 동시에 들어올 때다. 둘 다 1번을 통과한 뒤 둘 다 2번을 실행한다.
전형적인 race condition이다.
이 글에서는 이 문제를 JPA의 비관적 락으로 해결하는 과정을 정리하고, 같은 종류의 문제를 이전에 Redis 분산 락으로 해결했던 경험과 비교해 두 방식이 언제 어떻게 다른지 분석해본다.
경쟁 상태(Race Condition) 란 여러 요청(또는 스레드, 프로세스)이 공유 자원에 거의 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 현상을 말한다. 이름 그대로 "누가 먼저 도착하느냐의 경주(race)"가 프로그램의 정확성을 좌우하게 되는 상황이다.
race condition이 발생하는 전형적인 패턴은 Check-Then-Act다. 어떤 조건을 검사한 뒤(Check) 그 결과를 바탕으로 동작하는(Act) 구조에서, 검사와 동작 사이에 다른 요청이 끼어들면 검사 결과가 무효가 된다.
과제 제출 코드로 돌아가보자.
validationUtils.validateDuplicateSubmission(userId, planId); // Check
submissionRepository.save(submission); // Act
요청 하나만 있을 때는 아무 문제가 없다. 이미 제출한 사용자가 다시 요청하면 Check 단계에서 예외가 터지고, 처음 제출하는 사용자는 Check를 통과해 Act로 넘어간다.
그런데 요청 A와 B가 거의 동시에 들어오면 이야기가 달라진다. A가 Check를 통과하는 순간, 아직 A의 Act(INSERT)는 실행되기 전이다. 바로 그 찰나에 B도 Check를 실행하면 B 역시 "제출 기록이 없다"는 결과를 받게 된다. 그 뒤 A와 B가 각자의 Act를 실행하면 두 건의 INSERT가 모두 성공한다. 두 요청 모두 자기 기준에서는 올바르게 동작했지만, 결과적으로는 중복 row가 생긴다. 이것이 전형적인 race condition이다.
race condition의 위험한 점은 단일 스레드로 코드를 읽으면 버그가 전혀 보이지 않는다는 것이다. 위 두 줄은 누가 봐도 "제출 여부를 확인하고 저장한다"는 올바른 로직이다. 문제는 이 두 줄이 원자적으로 실행되지 않는다는 점인데, 이는 코드의 문법이 아니라 실행 모델의 특성이다.
그래서 race condition은 테스트로도 잘 잡히지 않는다. 단위 테스트는 보통 한 스레드로 순차 실행하므로 틈이 생기지 않는다. 실제 운영 환경에서 트래픽이 몰릴 때만 드물게 재현되고, 재현이 어려우니 원인 파악도 까다롭다.
race condition을 없애는 방법은 크게 세 가지다.
INSERT ... ON DUPLICATE KEY 같은 원자적 구문을 사용한다. 단순한 경우에 효과적이다.이 글에서 다루는 비관적 락은 세 번째 접근이다. "검사와 실행 사이의 틈"을 락으로 메워서, 한 요청이 Check와 Act를 끝낼 때까지 다른 요청이 아예 들어오지 못하게 막는다.
동시성 제어는 크게 두 가지 철학으로 나뉜다.

낙관적 락(Optimistic Lock): "충돌은 거의 일어나지 않을 것"이라 가정한다. 일단 작업을 진행하고, 커밋 시점에 version 컬럼을 비교해 충돌을 감지한다. 충돌 시 예외를 던지거나 재시도한다.
비관적 락(Pessimistic Lock): "충돌이 반드시 일어날 것"이라 가정한다. 데이터를 읽는 순간부터 다른 트랜잭션의 접근을 아예 차단한다.
비관적 락은 물리적으로 동시 접근을 직렬화한다. 줄을 서서 한 명씩 들어가는 것과 같다.
공짜가 아니다. 락을 잡고 있는 동안 다른 요청은 대기해야 하므로 처리량이 떨어지고, 잘못 설계하면 데드락이 생기며, DB 커넥션을 오래 붙잡고 있어 커넥션 풀을 고갈시킬 수 있다. 그래서 락 점유 시간을 최대한 짧게 유지하는 것이 핵심 원칙이다.
먼저 Plan을 락과 함께 조회하는 메서드를 추가한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Plan p WHERE p.planId = :planId")
Optional<Plan> findByIdWithPessimisticLock(@Param("planId") Long planId);
@Lock(LockModeType.PESSIMISTIC_WRITE)는 하이버네이트에게 "이 쿼리를 실행할 때 SQL에 FOR UPDATE를 붙여라"라고 지시한다. 결과적으로 실제 DB에 나가는 SQL은 다음과 같다.
SELECT * FROM plan WHERE plan_id = ? FOR UPDATE
FOR UPDATE가 붙은 순간 해당 Plan row에는 배타 락이 걸린다. 같은 row를 FOR UPDATE로 읽으려는 다른 트랜잭션은 현재 트랜잭션이 커밋되거나 롤백될 때까지 DB 레벨에서 대기한다.
JPA의 락 모드는 세 가지가 있다.
PESSIMISTIC_READ:SELECT ... FOR SHARE— 읽기는 공유하지만 쓰기는 차단PESSIMISTIC_WRITE:SELECT ... FOR UPDATE— 읽기/쓰기 모두 차단PESSIMISTIC_FORCE_INCREMENT: 위에 더해 version 컬럼을 강제로 증가
중복 제출 방지에는 배타적 접근이 필요하므로 PESSIMISTIC_WRITE가 적절하다.
서비스 메서드에서 락 획득을 중복 체크보다 앞에 배치한다.
@Transactional
public SubmissionResponse submitAssignment(Long groupId, Long planId, ...) {
User user = validationUtils.validateMenteeAccess(groupId, studentNumber);
Plan plan = validationUtils.validatePlan(planId);
// Plan 행에 비관적 락 획득 - 동시 제출 요청을 직렬화하여 중복 제출 race condition 방지
planRepository.findByIdWithPessimisticLock(planId)
.orElseThrow(() -> new RestApiException(ErrorCode.PLAN_NOT_FOUND));
// 과제 제출 중복 여부 체크
validationUtils.validateDuplicateSubmission(user.getUserId(), planId);
// 저장
AssignmentSubmission submission = ...;
return SubmissionResponse.from(submissionRepository.save(submission));
}
이 코드에서 주목해야 할 점이 세 가지 있다.
(1) 락으로 가져온 Plan을 변수에 받지 않는다
findByIdWithPessimisticLock의 반환값을 변수에 담지 않고 .orElseThrow만 호출한 뒤 버린다. 이것은 의도적인 설계다. 이 쿼리의 목적은 Plan의 필드를 읽는 것이 아니라 FOR UPDATE로 락을 거는 부수효과이기 때문이다. Plan의 실제 데이터는 이미 앞 줄의 validatePlan이 가져왔다. 비관적 락 패턴을 처음 보면 어색하게 느껴지는데, "SELECT가 목적이 아니라 락이 목적"이라는 관점에서 보면 자연스럽다.
(2) 왜 Plan에 락을 거는가
중복이 생기는 단위는 "같은 Plan + 같은 User"다. 그런데 락을 AssignmentSubmission에 걸 수는 없다. 아직 존재하지 않는 row이기 때문이다(지금 막 INSERT 하려는 중). 그래서 이미 존재하는 부모 엔티티인 Plan row를 락의 게이트키퍼로 사용한다. 이는 비관적 락 설계의 자주 쓰이는 패턴이다. "새로 만들 자식이 충돌할 수 있다면, 공통된 부모 row를 잠근다."
(3) 순서가 곧 의미다
락 획득 → 중복 체크 → save. 이 순서가 뒤집히면 락의 의미가 사라진다. 만약 중복 체크를 먼저 하면 그건 락 잡기 전의 상태를 읽은 것이라 의미가 없다. 락을 먼저 잡아야 "이 시점 이후로 같은 Plan에 접근하는 모든 요청이 내 뒤에 줄 선다"가 보장되고, 그때부터 하는 중복 체크만 신뢰할 수 있다.
요청 A와 B가 거의 동시에 들어온다고 하자.
findByIdWithPessimisticLock(planId) 호출 → Plan row 락 획득FOR UPDATE는 MVCC 환경에서도 스냅샷이 아닌 최신 커밋 상태를 읽도록 강제하므로, B는 A의 결과를 반드시 본다. 이것이 비관적 락이 만드는 직렬화의 본질이다.
위 코드 어디에도 unlock() 같은 호출이 없다. @Transactional 메서드가 끝나면 정상 커밋이든 예외 롤백이든 DB가 트랜잭션을 종료하면서 잡고 있던 모든 락을 함께 해제한다. 즉 락의 생명주기 = 트랜잭션의 생명주기다. 개발자는 락 해제를 신경 쓸 필요가 없고, 중간에 예외가 터져도 락이 남지 않는다. 이 점은 뒤에서 볼 Redis 분산 락과 가장 크게 대비되는 지점이다.
락이 완벽하게 동작해도 과거 데이터에 이미 중복이 쌓여 있을 수 있다. 락은 지금 이후의 중복만 막기 때문이다. 그래서 통계 API도 함께 손봐야 한다.
// Before: 단순 COUNT - 중복 제출이 있으면 한 사람이 여러 번 카운트됨
int submittedCount = submissionRepository
.countByPlanPlanIdAndStatus(planId, SUBMITTED);
// After: 고유 제출자 수 - 설령 중복이 섞여 있어도 인원 수로 환산됨
int submittedCount = submissionRepository
.countDistinctSubmittersByPlanIdAndStatus(planId, SUBMITTED);
실제 쿼리는 다음과 같다.
@Query("SELECT COUNT(DISTINCT a.submitter.userId) FROM AssignmentSubmission a " +
"WHERE a.plan.planId = :planId AND a.status = :status")
int countDistinctSubmittersByPlanIdAndStatus(
@Param("planId") Long planId,
@Param("status") SubmissionStatus status);
COUNT(*)을 COUNT(DISTINCT submitter_id)로 바꾸면 row 수가 아니라 사람 수를 센다는 불변식이 생긴다. 한 사람이 두 번 제출했더라도 통계에서는 한 명으로 카운트된다.
미제출자 계산도 함께 정비했다.
@Query("SELECT COUNT(DISTINCT a.submitter.userId) FROM AssignmentSubmission a " +
"WHERE a.plan.planId = :planId " +
"AND a.submitter.userId IN (" +
" SELECT gm.user.userId FROM GroupMember gm " +
" WHERE gm.studyGroup.studyId = :groupId " +
" AND gm.role = com.mjsec.lms.studygroup.domain.type.GroupMemberRole.MENTEE" +
")")
int countDistinctMenteeSubmittersByPlanIdAndGroupId(...);
notSubmittedCount = totalMentees - submittedMentees로 계산할 때, 제출자 쪽이 "현재 그룹에 속한 멘티"로 한정되지 않으면 탈퇴한 사용자나 역할이 바뀐 사용자 때문에 미제출자 수가 음수로 나올 수도 있다. 그래서 분모(totalMentees)와 분자(submittedMentees)의 기준을 "현재 이 그룹의 멘티" 로 일치시킨 것이다.
정리하면 두 개의 방어선을 깔았다.
시간축 양방향으로 방어선을 친 셈이다.
락을 적용할 때는 테스트로 불변식을 명시해두는 것이 중요하다. 코드만 보고는 "락 획득 → 중복 체크" 순서가 왜 중요한지 알기 어려워서, 리팩토링 중에 순서가 뒤집히는 실수가 일어나기 쉽기 때문이다.
@Test
@DisplayName("비관적 락 획득이 반드시 중복 체크보다 먼저 수행된다")
void lock_acquired_before_duplicate_check() {
// ... stubbing ...
service.submitAssignment(GROUP_ID, PLAN_ID, STUDENT_NUMBER, dto, IP_ADDRESS);
InOrder inOrder = inOrder(planRepository, validationUtils);
inOrder.verify(planRepository).findByIdWithPessimisticLock(PLAN_ID);
inOrder.verify(validationUtils).validateDuplicateSubmission(USER_ID, PLAN_ID);
}
Mockito의 InOrder는 "A가 B보다 먼저 호출되었는가"를 검증한다. 이 테스트가 있으면 나중에 누가 코드를 리팩토링하다가 순서를 바꿔도 CI가 막아준다. 일종의 가드레일이다.
@Test
@DisplayName("락 획득 실패 시 중복 체크와 저장이 수행되지 않는다")
void no_duplicate_check_or_save_when_lock_fails() {
when(planRepository.findByIdWithPessimisticLock(PLAN_ID))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> service.submitAssignment(...))
.isInstanceOf(RestApiException.class);
verify(validationUtils, never()).validateDuplicateSubmission(anyLong(), anyLong());
verify(submissionRepository, never()).save(any());
}
락 획득 단계에서 Plan을 못 찾으면 뒤의 중복 체크와 save가 절대 호출되지 않아야 한다. 트랜잭션 롤백이 어차피 부작용을 되돌리기는 하지만, 서비스 레이어에서 부작용 있는 메서드가 호출되는 것 자체를 막는 더 엄격한 계약이다.
이 단위 테스트들은 @Mock으로 Repository를 모킹하므로 실제 DB 락이 걸리는지까지는 검증하지 못한다. "서비스 레이어에서 락 메서드를 올바른 순서로 호출하는가"까지만 확인한다. 실제 락이 DB 수준에서 동작하는지는 별도의 동시성 통합 테스트(AssignmentSubmissionConcurrencyTest)에서 CountDownLatch와 ExecutorService로 5개 요청을 동시에 던져 1개만 성공하는지 검증한다.
단위 테스트는 "계약"을, 동시성 테스트는 "실측"을 담당하는 역할 분담이다.
예전에 CTF 플랫폼에서는 같은 종류의 중복 제출 문제를 Redis 분산 락으로 해결했다. 두 방식을 비교해보면 비관적 락의 성격이 더 선명해진다.
Redis 분산 락은 보통 Redisson의 RLock 같은 라이브러리로 구현한다. 원리는 SET key value NX PX ttl 같은 원자적 명령으로 "키 선점 = 락 획득"을 구현하고, 여기에 pub/sub 기반 대기, 재진입, watchdog 자동 연장을 얹는 방식이다.
unlock() 해야 하고 finally 블록이 필수다. 더 까다로운 문제는 AOP 순서다. 락을 @Transactional 안에서 얻으면 트랜잭션 커밋 전에 락이 풀릴 수 있어, 다음 요청이 아직 커밋되지 않은 상태를 보고 중복 체크를 통과해버릴 수 있다. 그래서 락은 반드시 트랜잭션 바깥에서 감싸야 한다.두 방식은 우열이 있는 게 아니라 적합한 맥락이 다르다. 판단 기준은 다음 세 가지 정도다.
충돌의 규모와 빈도
트랜잭션의 길이
분산 환경의 복잡도
LMS의 과제 제출은 한 사용자가 자신의 과제를 두 번 클릭하는 수준의 충돌이다. 충돌 범위가 매우 좁고(사용자별 Plan 단위) 빈도도 낮다.
반면 CTF 플랫폼은 동일 문제에 수백 명이 동시에 플래그를 제출한다. 충돌이 극심하고 DB 락을 잡으면 커넥션 풀이 터진다. 같은 팀이 두 프로젝트에서 서로 다른 선택을 한 이유가 여기에 있다.
비관적 락은 개념 자체는 단순하지만, 실제로 적용할 때는 세심하게 고려해야 할 지점이 많다. 락을 어느 엔티티에 걸 것인가, 락과 검증의 순서를 어떻게 할 것인가, 트랜잭션 범위와 어떻게 맞출 것인가, 기존에 쌓인 데이터는 어떻게 보정할 것인가, 그리고 그 계약을 테스트로 어떻게 못 박을 것인가.
락은 단순한 어노테이션 한 줄이지만, 그 한 줄이 제대로 동작하게 하려면 서비스 레이어의 호출 순서, 집계 쿼리의 재설계, 단위 테스트와 동시성 테스트의 역할 분담까지 모두 맞아떨어져야 한다.
Redis 분산 락이 더 "고급"인 것도, JPA 비관적 락이 더 "기본"인 것도 아니다. 해결하려는 문제의 규모, 트랜잭션의 성격, 인프라의 복잡도에 맞춰 가장 단순하게 작동하는 도구를 고르는 것이 가장 좋은 선택이다.