1. 문제
2주차의 과제는 다음과 같았다.
* 특강 신청 서비스를 구현해 봅니다.
* 특강 신청 API
* 특강 선택 API
* 특강 신청 완료 목록 조회 API
* 특강 신청 및 신청자 목록 관리를 RDBMS를 이용해 관리할 방법을 고민합니다.
* 아키텍처 준수를 위한 애플리케이션 패키지 설계
* 동일한 신청자는 한 번의 수강 신청만 성공할 수 있습니다.
* 특강은 선착순 30명만 신청 가능합니다.
* 이미 신청자가 30명이 초과되면 이후 신청자는 요청을 실패합니다.
* 다수의 인스턴스로 어플리케이션이 동작하더라도 기능에 문제가 없도록 작성하도록 합니다.
* 동시성 이슈를 고려하여 구현합니다.
저번 주차와 마찬가지로 이번 주차의 과제에서도 동시성 이슈를 고려해 구현해야 한다. 하지만 달라진 점은 2주차부터는 DB를 연동해서 서비스를 구현하는 과제이다.
과제를 발제받았을 때 이번 과제의 핵심이라고 한다면 특강을 신청하는 요청을 선착순으로 성공시켜야 한다는 점과 동일한 아이디의 사용자가 동일한 특강에 대해 한 번만 성공하게 구현해야 하는 점이었다.
2. 시도
우선 과제 프로젝트의 기술 스택은 다음과 같이 구성했다.
* Kotlin: 1.9.25
* Spring Boot: 2.7.18
* JPA: 1.9.24
* DB: H2
ORM으로 JPA를 사용해서 JPA에서 제공해주는 DB Lock을 사용해 동시성 제어를 해야겠다고 생각했던 것 같다. JPA에서 제공하는 DB Lock의 종류는 다음과 같다.
Optimistic Lock (낙관적 락)
@Version
을 이용해 조회했을 때 동일한 Version인지 확인한다.- 트랜잭션이 완료될 때까지 다른 트랜잭션이 데이터를 수정할 수 있지만, 데이터가 실제로 수정될 때 충돌이 있는지 확인해 충돌이 발생하면 롤백하는 방식이다.
Pessimistic Lock (비관적 락)
- DB의 Shared Lock, Exclusive Lock을 이용해 DB 레코드를 제어하는 방식이다.
- 데이터를 읽거나 쓸 때 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 물리적으로 Lock을 거는 방식이다.
두 Lock 중에서 나는 Pessimistic Lock을 사용해 구현하고자 했다. 그 이유는 선착순으로 신청하는 요구 사항같이 민감한 작업에 대해 다중 인스턴스 환경에서 발생할 수 있는 동시성 제어 문제를 비교적 완강하게 막기 위해서이다. 다수의 사용자가 한 특강에 대해 동시에 요청을 시도할 때, 동시에 신청 인원수를 확인하고 저장하는 과정에서 충돌이 발생할 가능성이 있기 때문이다.
또한 즉각적인 일관성이 필요한 작업이기 때문에, 한 트랜잭션이 진행되는 동안 다른 트랜잭션이 데이터에 접근 자체를 못하게 하는 물리적인 Lock으로 충돌을 미리 방지할 수 있기 때문이다.
하지만 처음 Lock을 설정할 때 실수했던 점이 있었다. 이 Lock은 조회를 할 때 한 레코드에 대해서 Lock을 거는 방식이기 때문에 단건 조회에 대해서 Lock을 걸었어야 했다. 하지만 처음 구현할 때는 목록 조회에 대해 Lock을 걸려고 시도했었다. 동시성 제어 테스트를 하는 데 실제로 기대한 값이 나오지 않아 문제를 확인하다보니 이러한 원인을 알게 되었다.
3. 해결
단건 조회로 Lock을 걸어야 한다는 것을 알게 되어, 처음에 설계했던 테이블 구조를 변경해야 했다. 처음에는 특강의 신청 인원 수를 신청 내역이라는 테이블에서 특정 특강 Id로 목록을 조회해서 목록 size로 구하려고 했다. (count()
집계 함수를 사용하려고 처음에 했으나, 집계 함수에도 Lock이 걸리지 않는다는 것을 알았다.)
목록 조회로 Lock이 걸리지 않아, 동시성 제어가 불가능했기 때문에 특강 신청 인원 수에 대한 데이터를 한 테이블의 컬럼에 추가해야 했다. 최종적인 DB 테이블은 다음과 같이 설계했다.
LectureOption
테이블에 신청 인원 수(currentApplicants
)를 추가해서, 특정 특강 Id(lectureId
)로 레코드를 단건 조회할 때 Pessimistic Lock을 걸어 조회해와서 현재 특강의 신청 인원 수가 정원이 초과되지 않았는지 체크할 수 있도록 했다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT lo FROM LectureOption lo WHERE lo.lectureId = :lectureId")
fun findByLectureIdWithLock(@Param("lectureId") lectureId: Long): LectureOption?
신청 인원 수를 체크하는 것과 마찬가지로 동일한 사용자가 동일한 특강을 요청할 때 한 번만 성공하도록 하는 방법 또한 Lock을 이용해 구현했다. 신청 완료 내역인 LectureApplyHistory
테이블에서 특강 Id(lectureId
)와 사용자 Id(userId
)로 조회해서 이미 데이터가 존재하면 신청이 완료되었다고 판단했다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT lah FROM LectureApplyHistory lah WHERE lah.lectureId = :lectureId AND lah.userId = :userId")
fun findByLectureIdAndUserIdWithLock(@Param("lectureId") lectureId: Long, @Param("userId") userId: Long): LectureApplyHistory?
4. 알게된 것
DB Lock을 걸 때는 단건 조회에 대해 한 레코드에 대해서만 Lock이 걸린다는 것을 알게 되었다. 추가로 JPA에서 제공해주는 DB Lock인 Optimistic Lock과 Pessimistic Lock에 대해서도 다시 한 번 되짚어보는 계기가 되었다.
추가로 DB 테이블 설계에 대해서도 다시 생각하게 되었던 것 같다. 이전에는 요구 사항이 생기면 특정 도메인 별로 테이블을 만들고, 거기에 필요한 데이터들을 추가하는 식이었는데, 이번 과제하면서 동일한 도메인에 대해서도 데이터의 특징에 따라 테이블을 정규화할지에 대해서도 고민해봤던 것 같다.
Keep : 현재 만족하고 계속 유지할 부분
요구 사항을 구현하는 능력은 만족스러웠던 것 같다. 또한 누락된 요구 사항이 있는지 체크하기 위해, 과제를 시작하기 전에 구현해야 할 기능들을 체크리스트로 정리해 구현해야 할 기능들을 관리했다. 이러한 점들은 앞으로도 지속해야 할 작업 방식이라고 생각한다.
Problem : 개선이 필요하다고 생각하는 문제점
과제의 피드백을 받고 이번에도 테스트 코드에서 검증하는 부분을 설명에 정확하게 작성하는 것이 아직 더 연습이 필요하다고 생각했다. 실제로 테스트 코드에서 검증하는 내용 중에는 최초 성공
에 대한 검증은 없었는데, 테스트 코드 설명에 최초
라는 단어를 포함시켜 혼동을 줬던 것 같다.
Try : 문제점을 해결하기 위해 시도해야 할 것
테스트 코드에서 무엇을 검증하고 있는지 더 명확하게 이해하고 표현해야겠다고 생각했다. 또한 테스트 코드에서 수행하는 검증이 요구 사항에 필요한 검증인지도 판단해 테스트를 작성하는 부분도 더 연습하며 노력해보도록 하겠다.
2주차까지 진행한 모든 과제들에 대해 모두 PASS를 받았다.😌 그래서 전체 10주차까지의 과제 중 20% 이상을 PASS해서 블루 뱃지를 얻게 되었다.
지금까지의 PASS가 요행이 아니고, 나의 실력이었음을 계속 증명해보이기 위해 다음 과제들도 해쳐나아갈 것이다! 남은 주차도 파이팅하자!!🔥
🤩 다음 수료생 추천 할인 혜택!
혹시라도 항해 플러스에 합류하고 싶은데 비싼 수강료 때문에 망설여진다면…? 🤔
수료생 추천 할인 혜택으로 20만 원을 할인받으실 수 있다는 사실! 💡
결제페이지 → 할인 코드 → 수료생 할인 코드에 tJQjYK 입력하면 추가로 20만 원을 할인받는 혜택 꼭 챙겨가시길 바란다🚀🚀🌟
#추천인: tJQjYK #항해플러스 #항해99