refactor: 장바구니 조회 담기 수 집계 제거(#116) - #117
Merged
Merged
Conversation
매 요청 carts를 세던 집계 쿼리를 없애고 courses.cart_count로 대체한다. 담기 수를 얻으려고 인덱스 엔트리 1,318개를 훑던 일이 이미 조인해 읽던 courses 행의 컬럼 하나로 바뀐다. 요청당 SQL 2 -> 1, 왕복 7 -> 6. 카운터는 담기와 빼기 시점에 원자적 UPDATE로 갱신한다. current_enrollment와 같은 방식이다. 담기 수에는 상한 규칙이 없어 증가에는 조건절을 두지 않고, 감소에는 음수 방지 가드를 둔다. 감소가 0행이면 카운터가 실제와 어긋난 것이므로 CARTED_COURSE_DELETE_CONFLICT로 롤백한다. 카운터 UPDATE는 save/delete보다 먼저 발행한다. carts.course_id가 courses를 FK로 참조해 INSERT가 부모 행에 S 락을 걸므로, 뒤에 UPDATE를 내면 S->X 업그레이드로 데드락이 난다(#90에서 497건 관측). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LMAsC5aH8Te4dGjAH2kZYr
집계 쿼리가 보장하던 불변식을 이제 서비스 코드가 지켜야 하므로 그 책임을 검증한다. 증가, 감소, 왕복 후 원복, 여러 회원 누적, 회원 격리, 카운터가 어긋났을 때의 CARTED_COURSE_DELETE_CONFLICT와 롤백, 조회 응답 반영까지 8개다. 카운터 갱신이 벌크 UPDATE라 영속성 컨텍스트를 거치지 않는다. 테스트가 값을 읽기 전에 flush 후 clear로 컨텍스트를 비운다. CartFixture.createCart가 강의의 담기 수도 올린다. 담기를 리포지토리로 직접 심는 기존 테스트들이 운영 쓰기 경로와 같은 불변식을 지키게 한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LMAsC5aH8Te4dGjAH2kZYr
#106과 같은 규모(carts 68,182 / members 11,247), 같은 부하 조건(VU 30, 2분)에서 개선 전후를 쟀다. 하드웨어 독립: 요청당 SQL 1.9933 -> 0.9931, 리포지토리 호출 2.000 -> 0.999, 집계 쿼리의 읽은행/반환행 204.0763 항목 소멸. 접근 방식과 인덱스, Handler 카운터는 변화 없다. 하드웨어 의존: p95 113.788 -> 73.391ms, p99 233.371 -> 128.149ms, RPS 398.42 -> 625.85, 커넥션 보유 평균 22.5 -> 14.2ms. 이 크기는 왕복 단가가 큰 이 환경의 값이고, 어디서든 같은 것은 구조 변화다. 남긴 것: 요청당 왕복 약 6 중 4.96이 트랜잭션 제어문(83%)이다. 전역 설정 문제라 이 대상의 사이클로 다루지 않았다. 쓰기 경로에 늘어난 UPDATE의 비용과 인기 강의 행의 X 락 직렬화는 읽기 전용 측정이라 확인하지 못했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LMAsC5aH8Te4dGjAH2kZYr
Test Results301 tests 301 ✅ 5s ⏱️ Results for commit 57357a2. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Summary
장바구니 조회가 매 요청
carts를 세던 집계 쿼리를 없애고, 담기 수를courses.cart_count컬럼으로 유지하도록 바꿨습니다. 요청당 SQL이 2건에서 1건으로, DB 왕복이 7회에서 6회로 줄었습니다.Problem
GET /api/v1/carts는 담긴 강의마다 그 강의를 몇 명이 담았는지를 함께 내려줍니다. 이 값을 매 요청carts테이블에서COUNT로 셌습니다.측정해 보니 이 집계가 요청당 DB 시간의 42.6%를 쓰는 최대 항목이었습니다. 인덱스 엔트리 1,242개를 읽어 숫자 6개를 돌려주는 모양이라, 읽은 행 대 반환 행이 204대 1이었습니다.
인덱스 문제가 아닙니다.
carts에는 이미idx_course_id가 있고 InnoDB 보조 인덱스 리프에 PK가 들어 있어COUNT가 테이블 데이터에 닿지 않습니다. 인덱스로는 더 줄일 데가 없고, 강의 하나에 담기가 평균 145건 있으면 그 145개를 다 세야 숫자 하나가 나오는 구조 자체가 비용이었습니다.문제는 이 비용이 인기도에 정비례해 커진다는 점입니다. 측정에 쓴 시드에서 가장 인기 있는 강의는 담기가 370건인데, 실제 수강신청 직전에 한 강의로 수천 명이 몰리면 요청당 읽는 1,242행이 그만큼 불어납니다. 응답시간이 아니라 확장성 쪽 문제라, #106에서 기준선을 잡을 때 위험 신호로만 적어두고 다루지 않았던 축입니다.
Solution
해결 - 담기 수를 세지 않고 컬럼으로 유지
조회 시점에 세는 한 읽는 양을 줄일 방법이 없으므로, 세는 시점을 쓰기 쪽으로 옮겼습니다. 담기와 빼기는 조회만큼 자주 일어날 것으로 보지만 작업량이 대칭이 아닙니다. 조회에서 없어지는 것은 인덱스 엔트리 수백에서 수천 개를 훑는 일이고, 쓰기에서 늘어나는 것은 한 행을 갱신하는 일입니다.
카운터는
courses에 컬럼으로 뒀습니다. 조회 쿼리가 이미courses를 조인해 읽고 있어서, 담기 수가 그 행에 얹혀 따라옵니다. 덕분에 집계 쿼리만 사라지는 게 아니라 DB 왕복도 하나 함께 없어집니다. 별도 테이블로 뺐다면 락은 분리됐겠지만 조회에 조인이나 쿼리가 하나 더 필요해 왕복이 줄지 않습니다.갱신은 담기와 빼기 시점의 원자적 UPDATE입니다. 이 저장소에는
courses.current_enrollment가 이미 같은 방식으로 돌고 있어 선례를 그대로 따랐습니다. 증감식이라 읽은 값이 개입하지 않아 갱신 유실이 원천적으로 불가능합니다. 담기 수에는 정원 같은 상한 규칙이 없으므로 증가에는 조건절을 두지 않았고, 감소에는 음수 방지 가드(cart_count > 0)를 뒀습니다.감소가 0행이면 카운터가 실제 담기 행과 어긋났다는 뜻입니다. 이때는 삭제까지 롤백하고 409를 돌려줍니다. 사용자 입력이 잘못된 게 아니라 서버 데이터가 어긋난 상황이지만, 같은 성격의 선례(
REGISTRATION_CANCEL_CONFLICT)가 409를 쓰고 있어 표기를 맞췄습니다. 재시도나 자동 보정은 이번 범위에서 다루지 않았습니다.카운터 UPDATE는
save,delete보다 먼저 발행해야 합니다.carts.course_id가courses를 FK로 참조하므로cartsINSERT가 부모 강의 행에 S 락을 겁니다. 그 뒤에 UPDATE로 같은 행의 X 락을 요구하면 S에서 X로 올라가는 업그레이드가 되고, 동시 요청에서 데드락이 납니다. 같은 형태로 #90에서 데드락 497건을 관측한 적이 있어 순서를 조건으로 못박았습니다.검토했다가 버린 안이 셋입니다. 더티 체킹은 읽고 고쳐 쓰는 구조라 같은 강의에 동시 담기가 오면 갱신 유실이고, 이는 #90이
current_enrollment에서 이미 걷어낸 방식입니다. 이벤트 발행은AFTER_COMMIT으로 받으면 별도 트랜잭션이라 갱신이 실패해도 롤백할 것이 없어 값이 영구히 어긋나고, 같은 트랜잭션에서 받으면 같은 UPDATE에 간접층만 늘어납니다. Redis 캐싱은 계산을 없애지 못하고 빈도만 줄이면서 무효화 설계와 실시간성 상실을 떠안습니다.주의할 점 - 더티 체킹이 카운터를 덮어쓸 수 있음
Course에@DynamicUpdate가 없어 더티 체킹 UPDATE가 모든 컬럼을 씁니다. 한 트랜잭션 안에서 카운터를 올린 뒤 같은Course엔티티를 수정해 저장하면, 벌크 UPDATE가 영속성 컨텍스트를 거치지 않으므로 낡은cart_count가 방금 올린 값을 덮습니다. 현재 운영 경로에는 이 조합이 없지만(담기는course를 읽고 증가시킬 뿐 변경하지 않고, 빼기는Course를 엔티티로 로드하지 않습니다) 앞으로 그런 코드가 생기면 값이 조용히 리셋됩니다.current_enrollment도 같은 조건에 놓여 있습니다.실행 계획 (회원 907548, 담긴 강의 6개 기준. 담기 수가 학년 평균과 일치하는 회원을 골랐습니다)
담기 수 집계 쿼리
idx_course_id(커버링)Handler_read_nextHandler_read_rnd_next장바구니 조회 쿼리
cartsrefuk_member_course,courseseq_ref PRIMARY,course_schedulesrefidx_course_idHandler_read_key/read_nextHandler_read_rnd_nextcart_count가 계획을 전혀 바꾸지 않았습니다.courses행을 이미 PK로 단건 조회하고 있었으므로 컬럼 하나가 그 행에 얹혀 따라온 것뿐입니다.부하 지표 (VU 30, 2분,
carts68,182건 기준)처리량 증가는 커넥션 보유 시간으로 설명됩니다. 풀 10개 기준 이론 처리량이 444에서 704 RPS로 1.585배인데 실측이 1.571배라 두 측정이 같은 기전 위에 있습니다.
측정 조건과 판정 근거
perf 프로파일, MySQL 8.0 / InnoDB, 커넥션 풀 10, 버퍼 풀 128 MiB에서 쟀습니다. 데이터는
carts68,182건,members11,247명,courses26,439건이고, 학년별 인원과 평균 담기 수를 실제 추정치로 재현해 전교생이 수강신청 직전에 장바구니를 채운 상태를 만들었습니다. 부하는 VU 30으로 ramp-up 30초, 유지 1분, ramp-down 30초이며 캐시는 warm으로 고정했습니다.로컬 측정이라 p95, p99, 처리량은 하드웨어에 의존합니다. 특히 이 환경은 왕복 단가가 커서(macOS Docker Desktop 포트 포워딩, 호스트 CPU 포화 78~90%) 왕복 하나가 빠지면서 남은 왕복까지 싸졌고, 그만큼 상대 이득이 증폭됐습니다. 그래서 개선 판정은 하드웨어에 흔들리지 않는 지표로 했습니다. 요청당 쿼리 수, 읽은 행 대 반환 행, 접근 방식과 인덱스, Handler 카운터입니다. 측정 산출물은
.claude/resources/perf/116/carts/에 있습니다.측정했지만 이번에 다루지 않은 것
요청당 왕복 약 6회 중 4.96회(83%)가 트랜잭션 제어문입니다.
SET autocommit2회와SET SESSION TRANSACTION READ ONLY/READ WRITE쌍,COMMIT이고 DB 시간의 42.4%를 씁니다. 절대량은 개선 전과 같은데 실제 쿼리가 하나로 줄면서 비중이 드러난 것입니다. 모든 엔드포인트에 공통으로 걸리는 설정 변경이라 이 PR의 범위를 벗어나 남겨뒀습니다.쓰기 경로에 늘어난 UPDATE 1건의 비용과 인기 강의 행에 X 락이 몰리는 직렬화 비용은 측정하지 못했습니다. 이번 측정 대상이 읽기 전용이라 그 코드가 실행되지 않았습니다.
POST /carts/{courseId}를 별도 대상으로 재야 드러납니다.Related Issue