[Spring] 채점 시스템 3차 고도화: DeadLock

thezz9·2025년 7월 26일

ezcode

목록 보기
4/4

개요

이번 글에서는 성능테스트 도중 발생한 DeadLock 문제에 대해, 개념부터 해결 과정까지 정리해보려고 한다.

기능 개발 단계에서 진행한 간단한 테스트에서는 데드락의 위험성이 존재했음에도 불구하고 실제로 발생하지 않았는데, 그 이유 또한 함께 짚어보려 한다.


1. 데드락(Deadlock)이란?

데드락(Deadlock)은 둘 이상의 프로세스나 스레드가 서로가 가진 자원을 기다리며, 무한정 멈춰버린 상태를 말한다.
이 상태에 빠지면 외부 개입 없이는 어떤 작업도 더 이상 진행될 수 없다.

데드락은 다음의 4가지 조건이 모두 만족될 때 발생한다.

  • 상호 배제(Mutual Exclusion): 자원은 한 번에 하나의 프로세스만 사용할 수 있어야 한다.
  • 점유 대기(Hold and Wait): 자원을 점유한 상태에서 다른 자원을 기다리는 상황
  • 비선점(No Preemption): 점유한 자원을 강제로 뺏을 수 없다.
  • 순환 대기(Circular Wait): 프로세스 간 자원을 순환하며 기다리는 상태 (A → B → C → A)

이 중 하나라도 깨지면, 데드락은 원천적으로 방지할 수 있다.

데드락은 보통 멀티스레드 프로그래밍, 데이터베이스 트랜잭션, 분산 시스템, 락 기반의 동시성 제어 상황에서 자주 발생한다.
내 경우에는, 채점 시스템이 병렬로 동작하는 멀티스레드 환경이었기 때문에 데드락이 발생했다.


2. 데드락 발생 지점

제출 이후 수행되는 DB 관련 작업은 다음과 같다.

  • Submission 제출 테이블

    • 제출된 코드의 컴파일 결과, 채점 결과 등 세부 정보를 저장한다.
  • UserProblemResult 문제 해결 여부 테이블

    • 랭킹 산정 등에서 성능 최적화를 위해 사용된다.
    • 하나의 User는 하나의 Problem에 대해 오직 하나의 결과(row) 만 가진다.
  • Problem 문제 테이블

    • 채점 결과에 따라 제출 횟수(totalSubmission) 또는
      정답 횟수(correctSubmission)가 +1씩 증가한다.

이 중 실제로 데드락이 발생하는 지점은 Problem 테이블의 통계 업데이트 과정이다.

하지만 더 정확히 말하면, 단순히 여러 스레드가 동시에 Problem 테이블을 업데이트했기 때문만은 아니다.
문제의 본질은 모든 작업이 Problem 테이블을 외래 키로 참조하고 있다는 점에 있다.

예를 들어, SubmissionUserProblemResult에 데이터를 insert하거나 update할 때, MySQL은 외래 키 무결성 검사를 위해 해당 Problem 레코드에 공유 락(Shared Lock, S Lock)을 건다. 이 락은 단순히 참조 무결성을 확인하는 데 그치지 않고, 트랜잭션이 종료될 때까지 유지된다.

이 상태에서 마지막 단계로 Problem의 제출 수치를 update하면, 해당 row에 대해 배타 락(Exclusive Lock, X Lock)이 필요해진다. 그런데 이미 다른 트랜잭션들이 공유 락을 보유하고 있는 상태라면, 배타 락으로의 락 승격(lock upgrade)이 불가능해지며 대기 상태에 들어간다.

문제는 이 상황이 여러 스레드에서 동시에 벌어지면, 각 트랜잭션이 공유 락을 쥔 채로 서로의 배타 락 획득을 기다리는 순환 대기에 빠진다는 것이다. 즉, 데드락의 4가지 조건이 모두 충족되면서 교착 상태가 발생한다.

결국, 이 현상은 단순한 동시 업데이트 충돌이 아니라, 외래 키 참조로 인해 의도치 않게 잡힌 공유 락이 원인이 되어 발생한 락 업그레이드 충돌이라고 보는 것이 정확하다.


3. 왜 개발 단계에서는 발생하지 않았는가

데드락이 발생할 수 있는 지점, 즉 동시성 문제가 생길 수 있는 위험은 인지하고 있었다.
그렇다면 왜 그때는 데드락이 발생하지 않았을까?

개발 단계에서는 완전한 동시 요청을 보내는 테스트를 하지 않았기 때문이다.
그 당시엔 "여러 스레드가 동시에 돌아가며, 서로 다른 채점 로직을 잘 수행하는가"에만 집중했었다.

무엇보다 개발 환경에서 사용하던 컴파일 서버의 사양이 낮았기 때문에,
요청 대부분이 컴파일 단계에서 병목이 생겨 DB까지 도달하는 속도가 느렸다.

하지만 성능 테스트 단계에서는 이야기가 달랐다.
테스트를 위해 컴파일 서버의 스펙을 상향하자, 컴파일이 빠르게 완료되고 채점 속도도 크게 빨라지면서 결과적으로 DB 접근까지의 시간도 비약적으로 짧아졌다.

이로 인해 DB에 동시에 접근하는 스레드가 급격히 많아졌고, 그제야 데드락 문제가 발생하기 시작한 것이다.

따라서 개발 단계에서는 컴파일 서버의 병목으로 인해 DB 접근이 자연스럽게 순차 처리되면서 데드락이 발생하지 않았고, 미리 데드락 방지 로직을 짜더라도 그것이 실제로 잘 작동하는지 확인하긴 어려운 상황이었다.


4. 데드락을 해결할 수 있는 방법들

데드락을 방지하거나 해결하는 방법은 여러 가지가 있다. 대표적으로는 다음과 같은 전략들이 존재한다.

4.1. 락 순서 강제

모든 트랜잭션이 자원을 고정된 순서로 획득하도록 강제하여,
데드락 발생 조건 중 하나인 순환 대기(Circular Wait)를 원천적으로 방지하는 방식이다.
다만 서비스 규모가 커질수록 흐름마다 락 순서를 일관되게 유지하기 어려우며,
도메인이 복잡해질수록 코드 유지보수 부담이 커진다.

4.2. 낙관적 락 / 비관적 락 사용

  • 낙관적 락: JPA의 @Version 필드를 활용해 커밋 시점에 버전 충돌을 감지한다.
    충돌 발생 시 롤백 후 재시도해야 하며, 이 과정에서 DB 접근 횟수 증가 및 재처리 로직 필요성이 생긴다.

  • 비관적 락: SELECT ... FOR UPDATE처럼 조회 시점에 락을 선점하여 다른 트랜잭션의 접근을 차단한다.
    이 경우 락 경합으로 인한 DB 자원 소모와 성능 저하가 발생할 수 있다.

두 방식 모두 트랜잭션 충돌을 방지하는 데 효과적이지만, 충돌 시 전체 트랜잭션이 롤백되기 때문에, 앞에서 수행한 Submission, UserProblemResult 등의 DB 작업도 함께 되돌아가게 된다.

4.3. 직렬화 트랜잭션 / 격리 수준 조정

DB의 트랜잭션 격리 수준을 SERIALIZABLE로 높여, 트랜잭션끼리 하나씩 순차 실행되도록 제한하는 방식이다.
내부적으로는 DB가 트랜잭션을 큐처럼 직렬화하여 처리하게 되며,
이로 인해 병렬성이 크게 제한되고 전체 처리량이 급감하는 단점이 있다.

4.4. 이벤트 발행 + 트랜잭션 분리

하나의 트랜잭션 안에서 모든 작업을 처리하는 대신, 핵심 로직 종료 후@TransactionalEventListener(phase = AFTER_COMMIT)로 이벤트를 발행하고, 이를 @Transactional(propagation = REQUIRES_NEW)로 완전히 분리된 트랜잭션에서 처리하는 방식이다.
단점은, 후속 트랜잭션이 실패할 경우 일부 데이터 정합성이 깨질 수 있어, 별도의 재처리 로직이 필요하다는 점이다.

나는 이번 문제를 해결하기 위해 이벤트 기반 처리 방식을 채택했다.
이 방법을 선택한 이유는 다음과 같다.

  • DB 부하를 늘리는 방식은 확장성이 떨어진다고 판단했다.
    서버는 scale-out이 비교적 쉬운 반면, DB는 구조적으로 확장에 제약이 있다.
    따라서 복잡한 충돌 처리나 정합성 보장을 DB 레벨에서 감당하기보다는,
    애플리케이션 레벨에서 분산 처리하는 방식이 더 유연하다고 판단했다.

  • 운영 유연성과 장애 대응에도 적합하다.
    제출 횟수나 정답 횟수는 핵심 비즈니스 데이터는 아니기 때문에, 일부 처리 실패가 전체 정합성에 큰 영향을 주지는 않는다.
    이러한 이유로 현재는 별도의 재처리 로직은 없지만, 이벤트 기반 구조는 필요 시 큐잉, 로그 기반 재처리 등으로 복구가 가능해 운영 측면에서도 유리하다.


5. 트랜잭션 분리 구조 적용

데드락 문제를 해결하기 위해, 문제 통계(totalSubmissions, correctSubmissions)를 업데이트하는 로직을 핵심 비즈니스 로직과는 완전히 분리된 트랜잭션으로 처리하도록 구조를 변경했다.

5.1. 메인 트랜잭션: 채점 결과 확정 + 이벤트 발행

채점이 완료되면 SubmissionContext를 기반으로 결과를 확정하고,
이후 다양한 후속 이벤트를 발행하게 된다.

@Transactional
public void finalizeAndPublish(SubmissionContext ctx) {
    SubmissionResult submissionResult = submissionDomainService.finalizeSubmission(ctx);

    publishFinalResult(ctx);
    publishProblemSolve(submissionResult);

    // 문제 통계 반영 이벤트 발행
    publishProblemCountAdjustment(ctx, submissionResult);
}

여기서 publishProblemCountAdjustment()는 통계 업데이트에 필요한 핵심 정보들이 담긴 이벤트 객체를 생성해 발행하는 역할을 한다.


5.2. AFTER_COMMIT: 트랜잭션 종료 후 이벤트 수신

이벤트는 트랜잭션 커밋 이후에 실행되도록 AFTER_COMMIT 설정이 되어 있다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProblemCountAdjustment(ProblemCountAdjustmentEvent event) {
    problemService.problemCountAdjustment(event.problemId(), event.isSolved());
}

이 설정 덕분에, 메인 로직이 정상적으로 커밋된 이후에만 통계 업데이트가 실행되며,
만약 앞의 채점 로직에서 문제가 생긴다면 이 로직은 아예 실행되지 않는다.


5.3. REQUIRES_NEW: 완전히 분리된 트랜잭션으로 통계 반영

실제 통계는 REQUIRES_NEW 트랜잭션으로 처리되므로,
기존 트랜잭션과 격리된 환경에서 안전하게 실행된다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void problemCountAdjustment(Long problemId, boolean isSolved) {
    int correctInc = isSolved ? 1 : 0;
    problemDomainService.problemCountAdjustment(problemId, correctInc);
}

이 메서드는 엔티티를 조회하지 않고 직접 쿼리를 실행하여, 문제의 제출 횟수와 정답 횟수를 증가시킨다.

@Modifying(clearAutomatically = true)
@Query("""
    UPDATE Problem p
    SET p.totalSubmissions = p.totalSubmissions + 1,
        p.correctSubmissions = p.correctSubmissions + :correctInc
    WHERE p.id = :problemId
""")
void incrementCount(@Param("problemId") Long problemId, @Param("correctInc") int correctInc);

이 구조는 핵심 로직 커밋 이후, @TransactionalEventListener(AFTER_COMMIT)를 통해 이벤트가 실행된다.
각 이벤트는 REQUIRES_NEW 트랜잭션으로 분리되어 처리되기 때문에, 락이 걸리는 시점이 자연스럽게 분산된다.

즉, 채점 결과 확정 → 커밋 → 이후에 통계 업데이트가 실행되므로, 여러 스레드가 동시에 접근해도 통계 업데이트의 타이밍 자체가 분산되어 락 충돌 없이 자연스럽게 순차 처리되는 효과를 기대할 수 있다

또한, problemId만 전달해 직접 쿼리로 수정하기 때문에,
JPA의 더티 체킹이나 엔티티 락 없이도 안전하게 업데이트할 수 있다.

결과적으로, 트랜잭션 분리와 실행 시점 분산을 통해
락 충돌을 최소화하고 데드락을 구조적으로 예방하는 설계로 리팩토링할 수 있었다.

실제 성능 테스트에서 구조 개선 효과는 확실하게 드러났다.
기존에는 10명의 동시 요청만으로도 데드락이 반복적으로 발생했지만,
구조 개선 이후 80명 동시 요청 수준까지 테스트해도 단 한 차례의 데드락도 발생하지 않았다.


6. 마무리

이번 경험을 통해 단순한 예매 시스템처럼 1명만 점유하면 되는 구조가 아닌,
다중 스레드가 공유 자원에 접근할 때 발생하는 락 경합과 트랜잭션 충돌을 실제로 분석하고 대응하는 구조를 설계해볼 수 있었다.

Ezcode 프로젝트를 진행하면서 문제를 분석하고 구조를 설계하는 나만의 사고 흐름이 생겼다는 점에서 큰 성장이 있었다고 느낀다.

이제는 본격적으로 취업 준비에 집중할 시기이기에 기능 고도화는 잠시 멈추려고 한다.
현재 구조로도 안정적으로 잘 돌아가길 바란다...

profile
개발 취준생

3개의 댓글

comment-user-thumbnail
2025년 9월 4일

안녕하세요. 좋은 글 잘 봤습니다.
한가지 궁금한 점이 생겨서 댓글남깁니다.

incrementCount에서 DB에 update를 날릴 때 +1 등의 연산이 동시에 이루어진다면 데이터의 정합성이 틀어질 수 있을 것 같은데 이 부분도 고려된건지 궁금합니다!

2개의 답글