과제 제출을 위해 수강 신청 시스템을 구현한 프로젝트입니다.
크리에이터는 강의를 등록하고 모집 상태를 관리할 수 있으며, 유저는 모집 중인 강의에 수강 신청, 결제 확정, 취소를 수행할 수 있습니다. 정원 초과 신청과 중복 신청을 방지하고, 동시에 여러 사용자가 마지막 자리에 신청하는 상황을 고려해 수강 인원 카운트를 별도 엔티티로 관리합니다.
- Java 21
- Spring Boot 3.5.0
- Spring Data JPA
- QueryDSL 7.1
- Spring Retry
- H2
- Gradle
- JUnit 5
- Lombok
- JDK 21
./gradlew bootRunWindows PowerShell에서는 다음 명령을 사용할 수 있습니다.
.\gradlew.bat bootRun애플리케이션은 기본적으로 http://localhost:8080에서 실행됩니다.
H2 데이터베이스는 인메모리 기반으로 실행되며 접속 정보는 아래와 같습니다.
- H2 Console:
http://localhost:8080/h2-console - JDBC URL:
jdbc:h2:mem:liveclass;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE - User Name:
sa - Password: 빈 값
- 인증/인가 시스템은 과제 범위 외로 확인하여, 요청 헤더
X-Member-Id를 현재 사용자 식별자로 사용했습니다. - 회원 가입 API는 구현하지 않았습니다. 대신 애플리케이션 실행 시, 회원 데이터를 5명이 저장되도록하여 테스트 시 활용할 수 있도록 하였습니다.
- 정원이 차는 타이밍 :
CONFIRMED전환 시점을 기준으로 잡을 경우 정원이 이 때 차도록한다면, 마감임박이나 인기 강의의 경우 높아질 수 있는 동시성 상황에서 사용성에 대한 우려가 예상되었습니다.- 예를 들어 결제 화면까지 갔던 99명이 마지막에 정원 마감으로 거부된다면 사용자는 서비스에 대해 큰 불편함을 느낄 것이라고 생각되었습니다.
- 또한 결제 PG에 진입한 후 거절된다면 환불 처리도 필요할 수 있어, 결제 확정 처리 기능 하나가 너무 무거운 책임을 가지게된다고 느꼈고 신청완료 단계인
PENDING상태에 정원을 점유하도록 상황을 가정했습니다.
- 유저는 강의 별로 수강신청을 한번씩만 가능한 것으로 요구사항을 이해했으며, 취소 후에는 재신청이 가능하다고 가정하였습니다.
현재 신청 인원은 강의의 고정 속성이라기보다 수강 신청/취소에 따라 자주 바뀌는 파생 상태라고 판단했습니다. 따라서 최대 정원처럼 강의 자체의 정책 값은 course 테이블에 두고, 실시간으로 증감되는 현재신청수는 course_enroll_count 테이블로 분리했습니다.
이를 통해 추가적으로 어떤 동시성 상황에서 충돌하였는지 의미도 보다 명확하게 인지가 가능해졌습니다.
수강 신청 과정에서 확인해야 하는 값은 크게 두 가지입니다.
| 구분 | 관리 테이블 | 의미 |
|---|---|---|
| 모집 상태 | course | 지금 신청 가능한 강의인가 |
| 현재 신청 수 | course_enroll_count | 아직 남은 자리가 있는가 |
만약 현재 신청 수를 course에 함께 둔다면, 마지막 자리 신청처럼 단순히 좌석 수만 경쟁하는 상황 에서도 course 테이블의 version이 계속 변경됩니다. 그러면 강의 상태, 제목, 설명, 가격, 기간 같은 강의 정보 변경과 좌석 수 변경이 모두 같은 버전 충돌 범위에 묶이게 됩니다.
반대로 현재 신청 수를 별도 테이블로 분리하면 충돌을 다음처럼 해석할 수 있습니다.
- Course 버전 충돌: 모집 상태나 강의 정책이 바뀐 상황
- CourseEnrollCount 버전 충돌: 남은 좌석 수를 여러 사용자가 동시에 점유하려는 상황
특히 도메인 별로 충돌/재시도 횟수를 별도로 관리하여 이후에 인기 강의 혹은 고경합 강의로 판단하여 동시성 제어 방식을 변경하는 등의 확장을 할 때, 보다 명료하게 도메인에 대한 정보를 얻을 수 있어 적절한 판단에 도움이 될 수도 있을 것입니다.
수강신청 시작 시점에 OPEN이었지만, 신청완료 시점에 CLOSED로 강의의 상태가 변경될 경우 마감된 강의에 수강신청이
완료될 수 있는 문제가 예상되었습니다. 따라서 크게 3가지 방안 고려 끝에 명시적 낙관적 락 처리 방식을 사용하기로 결정했습니다.
수강신청 로직이 시작되고 강의가 OPEN임을 확인 후, 수강신청이 완료되기 전까지 해당 강의에 Row Lock을 거는 비관적 락을 거는 방식을 고려해보았습니다. 하지만 이렇게되면 일반 유저가 수강신청을 할 때마다 해당 강의 데이터에 Lock이 걸려 크리에이터가 강의 정보를 수정하는데 지연이 발생할 수 있었고, 이는 크리에이터 사용자의 입장에서 서비스를 이용하는데 충분히 불편을 초래할 수 있을 것이라는 생각이 들었습니다.
Course의 상태가 Open임을 체크하여 삽입하는 커스텀 Insert 쿼리를 작성하는 방법입니다. 수강신청 데이터 저장완료 시점에 Insert가 Open임을 원자적으로 보장가능하며, Course에 Lock을 거는 시간이 방안1에 비해 줄어든다는 장점이 있습니다.
하지만 엔티티로 관리되지않아 영속성 컨텍스트를 생략하고 DB에 저장하기 때문에 아래와 같은 문제들이 존재합니다. 때문에 이를 우회하여 JDBC 기술을 통해 Insert문에 대한 결과값을 반환받는 방식도 고려해보았습니다.
- QueryDsl의 삽입쿼리가 반환 데이터가 아닌 결과영향 행수를 반환하는 문제
- Auditing이 동작하지 않는 문제
- Version이 초기화되지 않는 문제
entityManager.lock(course, LockModeType.OPTIMISTIC);수강신청 시, 낙관적 락을 명시적으로 사용해서 Course의 버전체크를 강제하는 방법입니다. 위 두 비관적 Lock 방식에 비해 Lock을 잡지않아 성능을 확보할 수 있는 이점이 있습니다. 수강신청 도중에, 강의의 상태를 CLOSED로 바뀌는 빈도가 적으므로 이 방식이 가장 합리적이라고 판단하여 사용하기로 결정했습니다.
기존에는 수강신청 로직에 대상 강의에 동일한 유저가 신청했던 데이터 중, 취소 상태가 아닌 것이 있는지 체크하는 로직이 존재했습니다. 만약 서로 다른 두 트랜잭션이 해당 로직을 동시에 통과할 경우, 수강신청 데이터가 중복되어 생성될 수 있는 문제가 예상되었습니다.
또한 대기열 등록과 수강신청 중 하나만 허용가능하게끔 하려면 애플리케이션 로직에서 검증한다해도 결국 DB 삽입 시점에 Race Condition이 발생할 수 있었습니다. 이에 따라 결국 DB 레벨에서 원자적인 처리가 필요하다고 생각되어 두 등록 로직이 함께 바라보는 별도 테이블을 관리해야겠다고 판단하여 아래와 같이 처리하기로 결정했습니다.
- 별도
course_reservation테이블을 두어, 한 사용자가 한 강의에 대해 수강신청 또는 대기열 중 하나만 등록이 보장될 수 있도록 하였습니다.course_reservation테이블은(course_id, member_id)UNIQUE 제약을 가집니다.- 수강신청·대기등록 시 동일 트랜잭션 안에서
course_reservation을 삽입하고, 취소 시 제거합니다. - 동시 등록은 이 테이블의 DB UNIQUE 제약으로 원자적으로 방어가 가능해집니다.
- enrollment·waitlist 양쪽 모두에 대한 중복(예: 대기 중인데 수강신청, enrollment 중인데 대기등록)도 DB 레벨에서 차단됩니다.
- 취소 시 reservation 데이터를 제거하므로 재신청·재대기가 가능합니다.
대기열 기능 구현 시 거쳤던 주요 고민과정을 아래와 같이 정리해보았습니다.
- 특성의 상이함
- Enrollment의 주요 관심사 : 한 수강생의 신청 상태 관리
- 결제 상태
- 취소 가능 여부
- 수강 상태
- 대기열 데이터의 주요 관심사 : 정원 초과 상황에서의 순번 관리
- 순번
- 승급 (빈 자리 채우기)
- 재정렬
- Enrollment의 주요 관심사 : 한 수강생의 신청 상태 관리
- 순번, 만료기한 등 대기열 관리만을 위한 데이터 필요/추가의 가능성
위 이유를 근거로 대기열을 별도 테이블로 관리하기로 결정했습니다.
사용자 입장에서 예상과 달리 정원이 가득찼을 때, 대기열에 등록할지 의사를 확인하는 등 그 상황을 명확히 인지할 수 있도록 하는 것이 UX 측면에서 더 좋을 것이라고 생각되어 별도 API로 분리하기로 결정했습니다.
아래와 같은 근거로 비동기 구조를 적용하기로 했습니다.
동기처리
수강 취소 시점에 대기열에 있던 유저 한명을 신청자로 승격하는 동기적인 구조를 고려해볼 수 있을 것입니다. 하지만 다음과 같은 문제가 예상되었습니다.
- 환불 등이 포함되면 수강취소는 본래 무겁다는 특성
- 승격 처리도 좌석 확인, 다음 대기자 조회, 대기열 재정렬 등이 포함되므로 가벼운 기능이라고 볼 수 없음
- 수강취소를 요청한 유저와 대기상태인 유저가 서로 다른데도 불구하고 영향을 주어 응답시간이 길어질 수 있음
- 승격 처리가 실패해도 수강 취소를 요청했던 유저는 다른 유저이므로 성공해야하지만 장애가 전파되어 함께 실패하게됨
- 대기자의 승격이 수강취소가되자마자 즉시 처리 되어야하는 것은 아니라고 판단됨
비동기 구조
- 취소요청은 승격처리와 상관없이 완료되면 바로 응답하여 응답시간이 짧아질 수 있음
- 장애 전파 문제가 해결되어 실패의 격리가 가능해짐
- 재처리 등의 설계를 독립적으로 사용 가능해짐
비동기 처리 방식은 크게 3가지를 고려했습니다.
@Async 를 활용하면 비동기 구조를 빠르게 적용할 수 있을 것입니다. 그러나 메모리 기반으로 동작되기 때문에 이벤트 처리 도중 서버가 내려가거나 장애가 발생할 경우 이벤트가 유실될 수 있다는 문제가 예상되었습니다. 대기열 상태인 유저의 경우 계속 기다리고 있으므로 이는 서비스 사용성에도 악영향을 줄 것으로 생각되었습니다.
인프라가 추가되므로 본 과제의 범위를 벗어난다고 생각되어 배제하기로 하였습니다.
수강취소 시점에 트랜잭션을 이용하여 취소 이벤트를 원자적으로 발행하는 방식입니다. 브로커 도입은 제약 상 어려우므로 스케줄러를 활용해 대기열 데이터를 Polling하여 1명을 승격처리합니다. DB에서 이벤트를 영속적으로 관리하므로 서버 장애에 대응할 수 있고, 스케줄러에 할당 스레드를 늘려 처리량도 늘릴 수 있어 확장성도 꽤 확보할 수 있습니다.
주기적인 Polling으로 인해 불필요한 DB 조회가 발생할 수 있지만 현 제약 상황에서 나름 합리적인 방안이라고 판단하여 해당 방식을 사용하기로 결정했습니다. 이벤트는 추후 다른 이벤트들도 추가될 수 있으므로 이벤트 종류를 type 컬럼을 두어 관리하도록 하였습니다.
현재신청인원 카운터 테이블을 별도로 분리하였지만, 수강신청과 강의의 상태 사이의 Race Condition 해결을 위해 명시적으로 낙관적 Lock을 잡는 방식을 택했었습니다. 이로인해 인기 강의의 경우 수강신청 빈도수가 높을 것이기에 크리에이터가 강의를 수정하는데 충돌률이 제법 높을 것으로 예상되어 적절한 해결책을 선택하였는지 의문이 남습니다. 수강신청과 무관하게 크리에이터가 쾌적히 강의를 수정 할 수 있는 더 적합한 방법이 있을지 좀 더 고민해보고 싶다는 생각이 들었습니다.
또한 아직 강의/수강신청시스템에 대한 이해도가 높지 않기에 낙관적 락 방식으로 동시성 제어를 해도 정말 괜찮은 것인지, 실제 서비스가 운용된다면 경합도는 얼마나될 지 궁금해졌습니다.
낙관적 락 방식을 택했지만, 충돌/재시도 횟수 등의 데이터를 관리하여 모니터링해나간다면, 보다 나은 방식을 선택하고 개선하는데 도움이 될 것입니다. 하지만 이에 대한 처리를 하지 못한 상태로, 해당 부분 아쉬움이 남습니다.
현재 스케줄러로 발행된 이벤트를 Polling하는 방식으로 소비하고 있습니다. 이는 해당 이벤트가 없을 때도 불필요하게 DB에 접근해 조회하고 있어 개선이 필요합니다.
또한 스레드 할당 관련 설정을 추가적으로 하지 않아 1개로 처리되는 형태인데, 요구 처리량이 높아지는 등 상황에 따라 스레드를 늘리거나 스케줄러를 위한 별도 시스템으로 분리, 혹은 이베트 브로커의 도입 등 확장이 필요할 수 있을 것입니다.
현재 신청인원을 점유하는 수강신청 상태의 기준은 PENDING으로 잡았었습니다. 이를 통해 결제 등의 무거운 기능과 좌석 경쟁과 같은 신청 경합을 분리할 수 있었습니다.
하지만 PENDING 상태가 만료 없이 유지된다면, 결제를 완료하지 않고 이탈해도 정원을 계속 점유하게됩니다. 이는 매출을 일으킬
가능성이 더 높은 다른 사용자의 신청 기회를 막을 수 있어 회전률을 높이려면 만료 기능이 필요할 수도 있겠다라는 생각이 들었습니다.
- 직접 작성한 설계문서(도메인 모델 등)를 기반으로 AI가 더 잘 활용할 수 있도록 추가적인 설계문서를 작성하는데 먼저 AI를 활용했습니다.
- 전반적인 비즈니스 로직 구현, 테스트 코드를 작성하는데 AI를 활용하였지만 아키텍처, 코드 작성 규칙, 컨벤션 등 규칙 문서를 구체적으로 작성하여 의도한대로 작성될 수 있게끔 하였습니다.
- 또한 매 기능마다 작성된 내용을 검토하여 과하거나 잘못 설계ㆍ구현된 코드들은 직접 수정하는 등 제어가 가능한 범위에서 AI를 활용하였습니다.
README.md,API 명세서,테이블 명세서등 문서 작업 진행 시에도 AI를 적극 활용하여 생산성을 높이고자 했습니다.
- API 명세서 : [Link 클릭]
- 테이블 명세서 : [Link 클릭]
전체 테스트는 다음 명령으로 실행합니다.
./gradlew testWindows PowerShell에서는 다음 명령을 사용할 수 있습니다.
.\gradlew.bat test테스트 완료 후 콘솔에 출력되는 HTML Report 링크를 통해 테스트 결과를 확인하실 수 있습니다.
===== Test Report =====
HTML Report: file://{프로젝트 루트 경로}/build/reports/tests/test/index.html