
지난 2월 8일, 무신사에서 3시간 동안 AI 를 활용한 코딩테스트를 진행했다.
문제는 다음과 같다.
https://github.com/musinsatech/2026-musinsa-rookie
GitHub - musinsatech/2026-musinsa-rookie: 2026 무신사 AI Native 개발자 채용 2차 시험 안내
2026 무신사 AI Native 개발자 채용 2차 시험 안내. Contribute to musinsatech/2026-musinsa-rookie development by creating an account on GitHub.
github.com

이번 무신사 코테는 "무신사 측"에서 적극적으로 회고를 권장하고 있다...!
사실 나는 더 이상 코테 후기 글은 쓰지 않을 예정이었다.
이전 카카오 코테 포스팅 시에, 조회수 100 들어오면 80이 카카오 글이었다.

카카오 글보다 더 애정을 쏟은 글들이 많이 있는데, 카카오 코테 글만 조회수가 폭등하는 것이 슬프기도 했다.
이미 해당 글의 조회수가 1000을 넘겼는데, 공감은 단 하나뿐이었다... 😢
열심히 쓴 글은 큰 관심을 못받는데, 카카오의 이름을 빌리면 조회수가 폭등하는 게 아쉬운 느낌이었다.
그 이후로, 블로그 조회수가 카카오 코테로 점령되는 것과 유사한 사례는 겪기 싫어서,
채용/코테 관련 후기 글은 올리지 않고 있었다.
그런데 이번 무신사 과제의 경우, 일단 주제가 너무 좋았다.
대학교 과제로 나올법한 주제에, 과제 그 자체로도 충분히 좋은 포트폴리오가 될 수 있는 주제였다.
만약 자신이 백엔드 엔지니어라면, 직접 문제를 들어가서 풀어보는 것을 강력 추천한다!
각설하고, 무신사 측에서 회고를 적극 권장하는 만큼, 나의 코드에 대한 회고를 진행해보도록 한다.
프로젝트 진행 과정
나의 경우, 3시간 동안 다음과 같은 흐름으로 프로젝트를 진행했다.

기조와 근거는 다음과 같다.
- 요구사항 문서 분석 및 체크리스트 분해
- 도메인 규칙/예외 정책 설계
- API 설계 및 테스트 케이스 도출
- 동시성 제어 전략 비교(비관적/낙관적/원자적/트랜잭션 분리)
- 구현 후 코드-문서 동기화
간단하게, 이 글의 목차를 Good/Bad로 구분해 전개한다.
현재 프로젝트에서 잘 한 것
- 구현 우선순위 결정
- 평가 우선순위 파악 후 구현 순서 결정 (작동 -> 데이터 주입 -> 동시성 제어, 병렬로 사용법 문서화)
- 클린 아키텍처
- AI가 작성한 코드가 최소한의 아키텍처 지침을 준수하도록 작성
- 코드 리뷰 속도 상승
- 수강 신청의 동시성 처리 단순화
- 학생 테이블에 락 걸기
- 동시성 처리 방식 별 테스트 환경 구축
- 트랜잭션 경계 설정
- REPEATABLE_READ
- READ_COMMITTED
- 락을 통한 제어
- 낙관적 락
- 비관적 락(락킹 리드)
- SQL 직접 사용(SQL 자체 Lock 사용)
- 트랜잭션 경계 분리
- 트랜잭션 경계 설정
현재 프로젝트의 문제점
- 서로 다른 학생 동시 수강취소 -> lost update -> 애플리케이션 레벨에서 수강 취소 시 락을 안잡고 있음
- 수강신청 취소 후 재수강 - 유니크 제약조건과의 충돌
- partial unique
- canceled 없애기
- 서버가 올라가기 전까지 데이터를 완성하고 올렸어야 하지 않나?
- 미완성된 성능 테스트
잘한 것
사실 잘한 점은 설명하고자 하면 별도 글로 빼야 할 내용들이 많다.
그래서 이번 글의 잘한 부분에서는 이 부분만 다뤄보고자 한다.
- 수강 신청의 동시성 처리 단순화
- 수강 신청의 애플리케이션 락 범위 축소
수강 신청의 동시성 처리 단순화
동시성 문제는 항상 다음 두 가지 상황을 동시에 고려해야 한다.
- 한 사용자가 동시에 두개 신청
- 두 사용자가 하나의 리소스 경쟁
문제점 1 - 서로 다른 학생이 동시에 수강 신청

만약 하나의 빈 자리를 두 명의 사용자가 신청한다고 가정해보자.
이는 전형적인 Race Condition 문제이다.

Course 리소스에 락을 검으로써 다른 리소스의 접근을 차단하면 이 문제는 해결 가능하다.
"완전히 신청한 순서대로 수강 신청에 성공한다"는 보장하지 않는다.
문제점 2 - 같은 학생이 동시에 두 번 수강 신청
그런데, 이번엔 같은 학생이 동시에 두 과목에 수강신청을 수행했다고 가정해보자.

이는 도메인 불변조건에 따라 문제가 달라질 수 있다.
주어진 요구사항 문서를 보면, 다음 두 가지를 알 수 있다.
- 학생은 같은 시간에 겹치는 과목을 수강할 수 없다.
- 학생은 18학점 이상 수강할 수 없다.
따라서, 이때는 크게 두 가지 동시성 문제가 발생한다.
- 타임 테이블 중복
- 18학점 검증
- 타임 테이블

나는 이번에 타임 테이블을 구현하지 않았지만, 사용자는 각 사용자별로 타임 테이블을 가지고 있게 된다.
즉, 도메인 모델에 표현하지 않은 "타임 테이블 도메인"이 존재하는 것이다.
하지만, 해당 타임 테이블에 락을 걸기엔, 해당 타임 테이블 레코드 자체를 구현하지 않았다.
- 18학점 제한

수강 신청 시에는, 학생의 현재 수강 신청 학점 수를 알아야 한다.
즉, 해당 Course 의 정보를 바탕으로, 더해졌을 시 18학점을 넘지는 않는지 검증해야 한다.
하지만, "현재 수강 학점"의 수가 최신 정보가 아니라면, 수강 신청의 정확도 자체가 떨어지게 된다.
즉, 현재 레코드의 최신성을 보장해야 한다.
해결책 - 학생 레코드에 배타적 락 걸기

잘 생각해보면, 두 가지 인사이트를 얻을 수 있다.
- UserTimeTable은 User 와 Composition Aggregation 관계이다.
- 즉, User에 락을 걸게 되면, User가 소유하고 있는 UserTimeTable 레코드 또한 수정을 막을 수 있다.
- 사용자의 수강 학점의 일관성을 보장하려면, User 레코드의 데이터 최신성이 보장되어야 한다.
이때 두 가지 방법이 있다.
- 데이터의 최신성을 보유하고 싶다면, S-Lock을 사용한다.
- 좀 더 강도 높게, 데이터의 일관적 수정을 보장하고 싶다면, X-Lock을 사용한다.
다만, 프로젝트 당시에는 이 둘을 명확히 구분할 시간이 없어, 단순 X-Lock를 사용했다.
이제부터 목차에 작성한 잘 못한 점 부분이다.
- 서로 다른 학생 동시 수강취소 -> lost update -> 애플리케이션 레벨에서 수강 취소 시 락을 안잡고 있음
- 수강신청 취소 후 재수강 - 유니크 제약조건과의 충돌
- partial unique
- canceled 없애기
- 서버가 올라가기 전까지 데이터를 완성하고 올렸어야 하지 않나?
- 미완성된 성능 테스트
이 문제를 데이터 기반으로 해결해보고자 한다.
나의 실수는 아쉬움도 있겠지만, 나의 데이터 기반 사고를 보여줄 좋은 기회이기도 하다고 생각한다.
1. 수강 취소 시 락을 걸지 않은 문제
의도
수강"신청" 에만 너무 집중한 게 문제였다.
수강 취소 로직의 완성도가 너무 많이 떨어져있다.
명백한 실수다.
그냥
실수! 땅땅!
하면 발전이 없으니, 가벼운 피드백을 진행해보려한다.
같은 실수를 하지 않기 위해선, 원인을 명백히 해야 하니 다시 생각해보자.
문제점 - 동시성 문제를 "생성"에만 집중함
CRUD 모두 동시성 문제를 깊게 고민했어야 했다.
- Create
- 존재하지 않는 레코드에 대한 동시 삽입
- 이로 인해 도메인 상에 존재하는 "유니크 제약조건"이 깨질 경우.
- Read
- Read Skew
- Locking Read(Materialized Conflict)로 해결
- Update
- Lost Update
- Write Skew
- Delete
- 멱등 Delete
문제 원인
- 충분히 검증하는 시간을 가졌으면, 해결할 수 있는 문제였다.
- 문서 & 코드에 존재하는 명백한 오류 검증에 많은 시간을 사용했다.
- 하지만, "문서에 누락된 오류"를 충분히 찾지 못했다.
- 첫 체크리스트를 너무 허술하게 작성했음.
문제 해결 방안: 체크리스트 + 상태 전이 흐름
전체 문서 흐름 (유스 케이스 + 시퀀스 다이어그램 + 도메인 모델 다이어그램 + DbC)을 짧은 시간 내에 전부 작성하는 건 빡세다.
빠른 구현이 필요한 상황에 대비해서, 필수적인 내용만 간단하게 작성할 방법 없을까?
- 추상적인 요구사항이 서술 형태로 작성되어 있을 경우,
- 구현해야 하는 핵심 도메인 체크리스트 작성 (Use Case 명사 추출)
- 각 도메인/모듈의 상태 전이 흐름 작성(Sequence, 최소 4종류 이상)
- CRUD 동시성 문제 체크리스트 작성
- Use Case 동시성 문제 체크리스트 작성
2. 수강 취소 후 재수강 - 유니크 제약조건과의 충돌
의도
동시에 같은 두 개의 수강 신청을 차단하기 위해 유니크 제약조건을 추가했다.

이는 사실 Locking Read 를 안쓰고, 순수 DB의 Unique 제약조건만으로 수강 신청을 만들기 위한 로직이었다.
@Transactional
fun enroll(memberId: Int, courseId: Int): Enrollment {
return enrollmentRepository.save(Enrollment(memberId, courseId, EnrollmentStatus.ACTIVE))
}이렇게 구현하게 되면, enrollment에 save()를 수행하게 되면서 애플리케이션이 락을 잡은 트랜잭션의 길이를 줄일 수 있을 거라고 생각했고, 성능 테스트로 이를 유의미한 결과로 입증할 수 있을 것이라 생각했다.
문제점 - 수강 취소 후 재수강
성능 욕심이 너무 컸다.
현재 수강 신청 모델에 존재하는 필드는 다음과 같았다.
- id
- course_id
- student_id
- enrollment_status
수강 신청 후 수강 취소할 경우, "수강 취소함" 정보를 가지고 있기 위해 status 필드를 포함했다.
가정은 다음과 같았다.
- insertion 에 사용하는 락은 갭 락이 사용될 것 같아, insert/delete를 반복하며 사용하고 싶지 않았다.
- 그래서, 수강 취소 후 재 수강신청 시, 동등 인덱스 비교 locking read 를 이용해, 기존에 생성했던 enrollment를 재사용 하고자 했었다.
- 해당 락킹 리드의 경우, 실제 해당 학생만 그 락을 사용하게 되니, race condition 문제는 크지 않을 것이라 생각했다.
- 이는 성능 테스트를 통해 검증해야 할 문제라고 생각했다.
하지만, 이 경우 두 개 이상의 EnrollmentStatus.CANCELED를 허용하지 않는다.
즉, 수강 취소를 한 뒤 재 수강신청을 하면, 다시 수강 신청이 불가능하다.
코드가 어중간한 상태로 남은 만큼, 이를 실제로 "정상적으로 동작하는 코드"로 변환해두겠다.
@Transactional
fun enroll(memberId: Int, courseId: Int): Enrollment {
val affected = courseRepository.decreaseSeat(courseId)
if (affected == 0) throw CourseSeatDeficientException()
return enrollmentRepository.save(Enrollment(memberId, courseId, EnrollmentStatus.ACTIVE))
}대충 이런 느낌으로 구현하길 원했다.
사용 가능한 선택지
사용 가능한 선택지는 크게 세 가지로 추려진다.
- { memberId, courseId } Unique Constraint + Delete 수강 취소
- { memberId, courseId } Unique Constraint + Locking Read + status 수강 취소
- { memberId, courseId, status } Unique Constraint + Locking Read + status 수강 취소
이에 대한 내용까지 포함해, 뒤에서 성능 테스트를 깊게 다뤄보자.
3. 데이터의 완성 시점
현재 프로젝트는 ApplicationRunner.run()으로 데이터를 초기화하고 있다.
즉, 스프링 컨텍스트 업로드 완료 시점에 데이터를 넣어주고 있다.
스프링 컨텍스트가 업로드 완료되었을 시점이라면, /health API가 열린 시점이었을 것이다.
즉, 테스트 API가 실제로 사용가능하기 이전에, /health API가 열렸다.

만약 스크립트가 폴링 형식으로 /health API의 사용 여부를 1초마다 판단하고, 성공 시 즉시 동시성 테스트를 수행했다면, 내 프로젝트는 요구사항을 만족시키지 못한 프로젝트일 것이다.
이 문제는 어떻게 해결해야 할까?
GPTfficial
- 스프링 내부 훅(@PostConstruct, CommandLineRunner, @Sql 등)으로는 불가능함.
- DB 엔진이 올라오는 시점 or 스프링 DataSource 생성 전에 DriverManager로 직접 연결해야 한다.
반복 가능한 테스트(TestContainer)를 사용했다면, 다른 방법을 고민해야 했을 것이다.
일단은, 현재 내가 수동 테스트하는 데에는 큰 문제가 없어, 그냥 이대로 진행했다.
4. 성능 테스트 - “핫 강좌” 동시성 테스트 (정원/충돌 관측)
성능 테스트 도구로는 k6를 선택했다.
성능 테스트 시나리오를 리스트업 해보자.
- REPEATABLE_READ + 비관적 락(Locking Read)
- REPEATABLE_READ + 낙관적 락
- REPEATABLE_READ + SQL 직접 사용(SQL 자체 Lock 사용)
- REPEATABLE_READ + 트랜잭션 분리
- READ_COMMITTED + 비관적 락(Locking Read)
- READ_COMMITTED + 낙관적 락
- READ_COMMITTED + SQL 직접 사용(SQL 자체 Lock 사용)
- READ_COMMITTED + 트랜잭션 분리
DB 실행 환경
- 1 CPU & 2GiB memry Docker Container
- MySQL 8.0
주요 지표
- 성공률:
201 / total - 충돌률:
409 / total - 규칙위반률:
422 / total - 평균/중앙값/최대 지연시간
- p95, p99
- Throughput(req/s)
공통 설정(전략 비교를 위해 동일 부하 프로파일 사용)
- Threads:
1000(예시, 한 학교의 정원이 1만 명이라 가정.) - Ramp-up:
5s(동시성 집중) - Loop:
1(동시 순간 요청) - courseId: 고정
HOT_COURSE_ID=0
Thread Group:
- Sampler:
POST /enrollmentswith body:
{"studentId": ${studentId}, "courseId": ${HOT_COURSE_ID}}Thread Group:
- Sampler:
POST /enrollments/pessimisticThread Group:TG_HotCourse_Optimistic - Sampler:
POST /enrollments/optimistic
Thread Group:
- Sampler:
POST /enrollments/atomic
Assertions (핫 강좌에서는 허용 코드 범위를 넓게)
- Response Code가
201또는409또는422인지 검사
수집 지표(필수)
- Throughput (req/s)
- Latency 분포: p95/p99
정원(capacity)이 C라면, 이상적 결과는 201 <= C 이고 나머지는 422 또는 409로 귀결될 것이다.
성능 테스트 결과
요약하면 다음과 같다.
REPEATABLE READ

- REPEATABLE_READ + 비관적 락(Locking Read): p95 121.61ms, Throughput 186.67
- REPEATABLE_READ + 낙관적 락(재시도 5회): p95 134.75ms, Throughput 186.94
- REPEATABLE_READ + SQL 직접 사용(SQL 자체 Lock 사용): p95 9.85ms, Throughput 186.39
- REPEATABLE_READ + 트랜잭션 분리: p95 11.14ms, Throughput 186.10

- READ_COMMITTED + 비관적 락(Locking Read): p95 117ms, Throughput 186.58
- READ_COMMITTED + 낙관적 락(재시도 5회): p95 121.50ms, Throughput 187.05
- READ_COMMITTED + SQL 직접 사용(SQL 자체 Lock 사용): p95 7.56ms, Throughput 186.35
- READ_COMMITTED + 트랜잭션 분리: p95 10.51, Throughput 186.19
왜 이런 결과가 발생했을까?
비관적 락(락킹 리드) vs SQL 직접 사용
먼저, 비관적 락(락킹 리드)는 왜 SQL 직접 사용 로직에 밀려났을까?
이것부터 하나씩 차근차근 알아보자.
시작하기 전 - 외래 키 제약조건 제거
이 상태로 부하 테스트를 진행하려 하니, 데드락이 발생했다.
현재 @ManyToOne으로 외래 키 제약조건이 설정되어 있다.
이 외래 키 제약조건은 검증을 위해 Shared Lock을 걸고 있었다.
하지만, 수강신청 도메인에서 학생 레코드와 과목 레코드는 일반적으로 잘 삭제되지 않는다.
만약 삭제된다면, 이를 DB 레벨에서 검증시키는 것 보단, 애플리케이션 레벨에서 검증을 하는 것이 좀 더 가독성이 좋고 DB에 적은 부하를 가할 것이다.
그래서, Enrollment의 외래 키 제약조건을 제거하고, 애플리케이션 코드 레벨에서 직접 락을 걸어 검증했다.
비관적 락(Locking Read)

- courseRepository: Course 락 획득
- studentRepository: Student 락 획득
- ruleValidator: Enrollment를 이용해, Student가 등록한 수업 조회 (이때는 명시적으로 락을 걸지 않음)
- ruleValidator: 해당 수업이 기존에 학생이 등록했던 수업과 시간표는 겹치지 않는지, 18학점을 초과해서 수강신청 하진 않았는지 검사
- enrollmentRepository: 수강 신청 등록. 유니크 제약조건을 이용해 해당 수업의 유일 수강신청 보장
이 코드의 장점은, 수강 신청의 인원수 제한이 도메인 객체 내에서 제어된다는 것이다.
하지만, 락을 잡고, 검증 후 OK 확인까지 너무 긴 시간이 소요됐다.
즉, 락이 너무 길었다.
SQL 직접 사용 + 락 범위 축소
결국 대부분의 동시성 문제로 인한 성능 하락의 핵심 원인은 락을 길게 잡는 문제에서 기인한다.
일반적으로 수강 신청 도메인의 고 경합 상황을 상정해보자.
학생들은 자신의 신청 학점이 0인 상태에서 시작한다.
가장 경쟁이 심할 수업부터 수강 신청을 수행한다.
즉, 핵심 경합은 "Course" 도메인에서 일어난다.
따라서, 해당 경합을 최소화하면 더욱 높은 성능 최적화를 이뤄낼 수 있다 판단했다.

- persistenceSupport: 수강 신청 등록. 유니크 제약조건을 이용해 해당 수업의 유일 수강신청 보장
- 성공 시, 해당 enrollment에 대한 X-lock을 보유하게 된다.
- 여기서 실패하면 즉시 롤백된다.
- courseRepository: Course 단순 조회
- 즉, 해당 시점에서 락을 획득하진 않는다.
- 이는 서로 락 때문에 경쟁하는 경쟁 상태의 길이를 축소시키기 위해, 잘 변하지 않는 Course 필드에 대해선 고의적으로 락 없이 조회를 시도했다.
- studentRepository: Student 락 획득
- 학생의 락은 일반적으로 경쟁 상태가 잘 발생하지 않는다.
- 따라서, 경쟁이 잘 발생하지 않는 Student 먼저 우선적으로 처리한다.
- ruleValidator: Enrollment를 이용해, Student가 등록한 수업 조회 (이때는 명시적으로 락을 걸지 않음)
- ruleValidator: 해당 수업이 기존에 학생이 등록했던 수업과 시간표는 겹치지 않는지, 18학점을 초과해서 수강신청 하진 않았는지 검사
- courseRepository: Course 자리를 SQL레벨에서 직접 등록
- 성공 시, 해당 course에 대한 X-lock을 보유하게 된다.
- 여기서 실패하면 즉시 롤백된다.
- enrollmentRepository: 수강 신청 등록. 유니크 제약조건을 이용해 해당 수업의 유일 수강신청 보장
이 코드는 가장 락 경합이 심한 Course의 락 획득 시점을 최대한 뒤로 미뤄, Course의 락 점유 시간을 최대한 뒤로 미뤘다.
즉, 락의 길이가 더욱 짧아진다.
또한, 자주 변경되지 않는 Course 필드에 대해 락 없이 조회를 수행했다.
그래서, 기존 Pessimistic Lock 대비 90%가량의 성능 향상을 가져갈 수 있었다.
REPEATABLE READ vs READ COMMITTED
우리는 지금 데이터 정합성을 위해, 명시적으로 락(S, X)을 걸고 있다.
락은 트랜잭션의 격리 수준에 관계없이 다른 트랜잭션에 영향을 끼치게 되므로, 트랜잭션의 격리 수준에는 큰 관계없는 결과를 볼 수 있었다.
물론, 둘은 격리 수준 유지/락과 관련된 부분에서도 약간의 차이가 있다.
- CRUD 부분에서 undo/redo log 를 다르게 관리하기 때문에, 약간의 메모리 소모가 있을 수 있다.
- REPEATABLE_READ의 경우, RC와 달리 갭 락/넥스트-키 락이 좀 더 넓게 잡히기 때문에 경쟁 상태가 조금 더 강해진다.
하지만, 현재 프로젝트의 경우 유의미한 차이를 띄진 않았다.
트랜잭션 분리 - 커스텀 롤백 로직 작성
Student 관련 검증과 Course 관련 검증의 트랜잭션을 분리한다면, 더 락을 짧게 가져갈 수 있지 않을까?
따라서 Course 검사 로직과 분리한다면, 유의미한 성능 향상을 기대할 수 있을거라 예상했다.

현재 위 코드는 각 TxExecutor가 reserveSeat와 finalizeEnrollment을 REQUIRES_NEW로 분리해서, 코스 경합 구간과 Student 검증의 트랜잭션을 완전히 분리하고 있다.
이를 이해한 채로 이 코드를 읽으면 다음과 같이 해석할 수 있다.
- 좌석을 한 자리 붙잡는다.
- 이때, 좌석 선점이 조건부 UPDATE 1회로 끝나게 되고, 락은 즉시 풀린다.(엔티티 락/변경감지 부담 최소화).
- 학생 락만 따로 잡고, 실제로 해당 학생이 수강신청이 가능한지 확인한다.
- 해당 부분에서 "시간표"와 같은 course의 기타 정보가 변경될 일은 잘 없다. 즉, 굳이 이때는 course에 락을 걸지 않아도 된다.
- 이때 정합성을 위해 student에 락을 걸게 되는데, 일반적으로 학생 부분은 고경합이 발생할 일이 없다.
- 검증 단계에서 코스 FOR UPDATE를 잡지 않고 학생만 락 잡게 되므로, 핫 코스의 락 경합이 줄어들게 된다.
- 만약 실패했을 경우, 보상 트랜잭션을 실행하여 좌석을 해제한다.
- 이때 또한, 좌석 선점이 조건부 UPDATE 1회로 끝나게 되고, 락은 즉시 풀린다.
하지만,실제론 트랜잭션 및 락을 여러번 열고 닫아야 했기 때문에, SQL 직접 사용보다 드라마틱하게 성능이 좋진 않았고, 오히려 약 20%정도 느렸다.
한 걸음 더 - 다수의 과목 경쟁 시나리오
앞서 실행한 결과는 단일 과목 부하 테스트의 단일 결과였다.
실제로 수강신청의 경우, 위의 Happy Case처럼 한 과목만 경쟁하지 않는다.
즉, 단일 과목만을 천명의 학생이 경쟁하는 시나리오는 일반적이지 않을 수 있다.
이번엔 이를 좀 더 확장시켜서, 50개의 과목을 각 정원의 3배의 학생이 경쟁하는 시나리오로 돌려보자.
특히 위에서 좋은 결과를 얻을 수 있었던 SQL 직접 사용 로직과 separated 로직을 여러번 반복해서 비교해보자.
DB 실행 환경
- 1 CPU & 2GiB memry Docker Container
- MySQL 8.0

앞서 살펴본 결과의 경우, Separated보다 Atomic이 좀 더 유리해 보였다.
하지만, 우연찮게 "고경합"이 발생할 경우, Atomic 쪽에서 이상치가 발생하는 것을 확인할 수 있다.
따라서, 전체적으로 separated를 적용하는 것이 좀 더 서비스를 안정적으로 구현하는데 유리할 것이라 예상된다.
Redis 도입, 왜 안했나?
레디스를 완전한 인-메모리 DB로 사용하고, 직접 트랜잭션을 구현한다면 어느 정도의 성능 차이가 있는지 직접 테스트해보진 않았기에, 가정일 뿐이다.
지극히 개인적인 기준으로, 상황마다 다를 수 있다고 생각한다.
실제로 실측을 수행하지 않았으니, 재미로 읽어주길 바란다.
Redis 의 역할
- DB레벨에서 복잡하고 부하가 많이 드는 Join/집계 쿼리의 캐싱 시 사용
- 힙과 같은 특수한 자료구조를 요구하는 상황에 사용
- 커밋/롤백이 필요 없는 경우 사용
- 매우 매우 높은 동시성이 필요해서, "쿼리 파싱 조차 하고싶지 않을 때" 사용
- 즉, BASE 원칙 기반으로 쿼리 부하를 완화할 목적으로 사용
- 예: 세션 저장소, 리더 보드, 레이트 리밋, 토큰 버킷, 분산 락, etc.
DB의 역할
- 트랜잭션 기능이 필요한 커맨드 처리(UPDATE, INSERT, DELETE)
- 해당 커맨드의 경우, 락/트랜잭션 격리 수준 조절을 통한 동시성 제어로 최적화
- 즉, ACID가 필요할 때 사용
- 예: 대부분의 ACID를 요구하는 작업에 사용
즉, 나의 경우 Redis를 이용해 업데이트 처리까지 수행하고 write-through를 구현하는 것 보단, 이미 구현되어 있는 DB의 ACID를 이용하는 것이 훨씬 편리하다고 생각했다.
또한, 인-메모리 연산의 성격은 DB 또한 버퍼 풀 튜닝으로 어느정도 흉내낼 수 있을 거라 생각했기 때문에, 고려하지 않았다.
따라서 급한 대로 현재 알고 있는 대로 최선의 판단을 수행했다.
지금 와서 생각해보면, 위 말은 버퍼 풀을 캐시로 사용하기 위해 메모리의 상주 시간을 늘리려고 한다고 가정했다.
그런데, 메모리의 상주 시간을 늘리고자 flush를 늦게 할 경우, 해당 데이터가 디스크로 들어갈 때 디스크 I/O 분량이 커지므로, 이는 디스크 I/O를 급증시키고 p95 레이턴시를 상승시킨다.
하지만, write-through 의 경우, flush 과정을 좀 더 split하여 수행할 수 있기 때문에 나쁘지 않은 결과를 낼 것이라 예상되긴 한다.
하지만, 아직 write-through를 직접 구현해본 적이 없어, 이 flush과정을 어떻게 "안전하게" 만들 것인가에 대한 고민은 직접 구현해보며 좀 더 해봐야 할 듯 하다.
이상으로 회고를 완료했다.
요약하자면, 다음과 같이 정리했다.
빠르게 요구사항을 분석하고 기능 누락을 없애는 방법
- 추상적인 요구사항이 서술 형태로 작성되어 있을 경우
- 명시적인 Use Case 작성
- 구현해야 하는 핵심 도메인 체크리스트 작성 (Use Case 명사 추출)
- 각 도메인/모듈의 상태 전이 흐름 작성(Sequence, 최소 4종류 이상)
- CRUD 동시성 문제 체크리스트 작성
- Use Case 동시성 문제 체크리스트 작성
- 실제 작동하는 도메인 로직의 특징을 이해하자.
- 추가적으로 존재하는 비기능 요구사항 체크리스트 작성
- 급하다고, 이 부분마저 급하게 넘어가지 말 것
비관적 락, 잘 거는 방법
- 비관적 락의 성능 문제는 락을 최대한 짧게 잡는 것으로 해결 가능하다.
- 데드락이 발생하지 않도록, 일관된 순서로 락을 걸자!
- 가능하다면, 가장 경합이 적은 영역부터, 경합이 심한 부분 순으로 락을 걸자!
- 가능하다면, 별개의 트랜잭션으로 분리하자!
지금까지 나온 내용들을 바탕으로, 요구사항을 대강 추측해서 구현해본 커머스 도메인을 고도화 해봐야 겠다.
- 도메인 체크리스트, 상태 전이 흐름, 동시성 문제 체크리스트
- 데이터 셋업
- 성능 테스트 문서화/수행
- 상품 집계 쿼리 Redis 캐싱
'Article - 깊게 탐구하기 > 개인 프로젝트' 카테고리의 다른 글
| [SuperBoard] 댓글 수 조회 쿼리 최적화 (1) | 2025.05.18 |
|---|---|
| [Spring JdbcTemplate] JdbcTemplate을 이용한 게시글-댓글 게시판 기능 구현기 (0) | 2025.04.29 |
| [Spring JdbcTemplate] JdbcTemplate을 이용한 상품 관리 기능 구현기 (0) | 2025.04.26 |