수강신청 시스템, 플로우차트/ DB락/테스트

smj_716·2025년 7월 2일

한이음 드림업

목록 보기
4/9

✍️ 설계보다 코드가 먼저?

이번에 수강신청 시스템을 구현하면서 가장 먼저 한 일은 코드를 짜는 것이 아니라 로직을 플로우차트로 그려보는 것이었다.

이 차트를 먼저 만들고 나니 구현이 훨씬 쉬워졌다.
✔️ 무엇을 언제 검사해야 하는지, 예외는 어디에서 발생할 수 있는지, 그리고 트랜잭션의 시작과 끝을 어디에 둘 것인지가 명확해졌다.
✔️ 특히 락 타임아웃, DB 좌석 확인, 트랜잭션 경계처럼 헷갈릴 수 있는 부분도 미리 체크할 수 있어서 실수 없이 구현할 수 있었다.


🔐 DB 비관적 락

📌 수강신청의 본질: 경쟁과 충돌

수강신청은 단순히 "누가 먼저 클릭했느냐"의 싸움이 아니다. 수십 명, 수백 명이 같은 강의를 동시에 신청하기 때문에 시스템 내부에서는 굉장한 충돌이 발생한다.

이런 상황에서 가장 중요한 건 바로 정합성(일관성)이다. 강의 정원이 40명인데 동시에 41명이 들어오면 누군가는 잘못 등록되는 것이다.
이걸 막기 위해 DB 수준에서의 동시성 제어가 필요했다.

그래서 선택한 방법이 바로 DB 비관적 락 (Pessimistic Lock)이다.

💡 왜 락이 필요한가?
예를 들어, A와 B가 동시에 같은 강의를 신청한다고 하자. 두 요청이 거의 같은 시점에 DB에서 "현재 정원이 39명"인 걸 확인하고, 동시에 1명을 추가해 41명이 되면 어떻게 될까? 시스템은 데이터 무결성 오류를 일으키게 된다.

이 문제를 방지하려면 동시에 같은 데이터를 읽는 순간부터 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 막아야 한다.

📢 낙관적인 락(Optimistic Lock)을 쓰지 않은 이유

  • 낙관적 락은 데이터를 읽을 땐 그냥 읽고, 쓰기 시점에 버전 정보를 비교해서 다른 트랜잭션이 변경했는지 확인한다. 충돌이 감지되면 예외를 던지거나, 재시도 로직이 필요하다.
  • 수강신청에서 낙관적 락을 사용하면 충돌이 자주 발생하고, 그때마다 예외 처리나 재시도 로직을 추가로 구현해야 한다.
  • 즉, 락은 가볍지만 로직은 더 복잡해진다.

반면, 비관적 락(Pessimistic Lock)충돌 자체를 원천 차단하는 방식이다. 한 사용자가 특정 강의 데이터를 조회하고 수정하려 할 때, 다른 사용자가 해당 데이터를 동시에 수정하지 못하도록 DB가 직접 막아준다.

수강신청처럼 정합성이 매우 중요한 작업에서는 충돌을 감지하고 복구하는 것보다 애초에 충돌이 발생하지 않도록 막는게 훨씬 안전하고 단순하다. 그래서 이번 구현에서는 비관적 락을 선택하게 됐다.

🔽 비관적 락 적용
JPA에서 제공하는 @Lock 애너테이션을 통해 PESSIMISTIC_WRITE 락을 걸었다. 아래는 실제 사용한 코드다.

🔺CourseReader.java

public Optional<Course> readWithPessimisticLock(Long courseId) {
    return courseRepository.findWithPessimisticLock(courseId);
}

🔺CourseRepository.java

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Course c WHERE c.id = :courseId")
Optional<Course> findWithPessimisticLock(@Param("courseId") Long courseId);

이 코드는 해당 강의에 대해 트랜잭션이 종료될 때까지 다른 트랜잭션이 이 강의 데이터를 수정하거나 읽지 못하게 막는 역할을 한다.

PESSIMISTIC_WRITE 락은 쿼리 실행 시점에 DB 레벨에서 exclusive lock을 걸고 트랜잭션이 끝나기 전까지 다른 요청은 대기 상태로 들어간다.
만약 락을 기다리는 시간(timeout)이 초과되면 예외가 발생한다.


🧨 테스트 코드

이 락이 실제로 동시성 문제를 잘 막아주는지 확인하기 위해 100명이 동시에 40명 정원의 강의를 신청하는 시나리오를 테스트 코드로 작성했다.

assertEquals(40, successCount.get(), "정원이 40명이므로 40명만 성공해야 함");
assertEquals(60, failCount.get(), "100명 중 60명이 실패해야함");
assertEquals(40, course.getParticipant(), "Course의 participant 필드도 40이어야 함");

이 테스트를 통해 동시에 요청을 보내도 정확히 40명까지만 수강에 성공하고 나머지는 모두 실패하는 걸 확인할 수 있었다.

또한 System.out.println("실패한 studentId: ...") 로그를 통해 실제로 어떤 사용자들이 실패했는지도 체크할 수 있어 디버깅에 도움이 됐다.


➡️ 추후에는 Redis 락으로?

현재는 DB 락만으로도 코드를 구현했지만, 우리 서비스는 만명까지 테스트하는 것을 목표로 두고 있기 때문에 수강 인원이 수천 명을 넘기 시작하면 또 다른 문제가 생길것이다.

DB는 락을 처리하면서 성능 저하가 올 수 있고 락 대기 시간이 길어지면 타임아웃이 잦아질 수 있다.

그래서 장기적으로는 Redis 기반의 분산 락을 도입하는 것을 고려하고 있다.

이 방법을 쓰면 DB가 아닌 메모리 기반에서 빠르게 락을 제어할 수 있고 분산 서버 환경에서도 동일한 락을 공유할 수 있다.

물론 도입 자체는 단순하지 않지만 장기적으로 봤을 때 확장성 면에서는 더 유리할것이다.


💡 느낀 점

  • 설계 없이는 절대 안정적인 시스템을 만들 수 없다.
  • 무작정 코드를 짜기보다는 플로우차트를 먼저 그려보자.
  • DB 락은 강력한 도구다. 그러나 상황에 따라 다르게 선택해야 한다.
  • 실전에서는 테스트 코드만으로는 부족할 수 있다. 시뮬레이션을 통한 예외 확인도 병행해야 한다. -> JMeter 도입

0개의 댓글