Skip to content

feat: 연차 사용 내역 삭제 API · 잔여가 총 연차를 넘지 못하게 한다 - #268

Open
sevineleven wants to merge 6 commits into
devfrom
feat/265-leave-usage-delete
Open

feat: 연차 사용 내역 삭제 API · 잔여가 총 연차를 넘지 못하게 한다#268
sevineleven wants to merge 6 commits into
devfrom
feat/265-leave-usage-delete

Conversation

@sevineleven

@sevineleven sevineleven commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Situation

  • "내 연차" 화면에 사용 내역을 지우는 길이 없었다. POST /api/v1/leaves/me/usages 로 쌓기만 할 수 있고 DELETE 는 404 였다.
  • 그래서 프론트는 음수 days 를 새로 등록해 상쇄하는 방식으로 취소를 흉내내고 있었다. 그 우회가 실제로 데이터를 망가뜨렸다.
동작 원장(사용 합) 화면의 잔여
총 15일, 2일 사용 +2 13
취소 1회 (-2 등록) 0 15 (정상)
취소 2회 (-2 또 등록) -2 17
  • 네트워크 재시도나 사용자의 중복 탭만으로 없던 연차가 생긴다. 상쇄 등록은 취소가 아니라 새 기록이라, 같은 요청이 두 번 들어오면 그만큼 더 상쇄된다.

Task

  • 삭제 API 를 여는 것은 절반이다. 진짜 문제는 "잔여는 총 연차를 넘을 수 없다" 는 규칙이 도메인 어디에도 없었다는 것이다. 삭제를 만들어도 그 규칙이 없으면 다른 경로로 같은 값이 다시 나온다.
  • 그 규칙을 어디가 지킬 것인가 가 이번 PR 의 중심 결정이었다. 서비스에 조건문을 하나 더 두는 것과, 값객체가 스스로 보장하는 것은 다르다.
  • 곁가지 판단 셋: 음수 등록을 계속 받을 것인가(→ 당분간 받는다, 음수 days 등록을 400 LEAVE-013 으로 거절 (앱이 삭제 API 로 갈아탄 뒤) #276 로 분리), 남의 내역·없는 내역을 어떻게 답할 것인가, 코스 확정으로 생긴 내역을 손으로 지우게 둘 것인가.

Action

불변식을 값객체로 내렸다

  • 연차 현황(총·쓴·남은)을 만드는 값객체가 사용 합을 0 아래로 내려가지 않게 잘라서 현황을 계산한다. 잔여는 그 값에서 파생하므로 총 연차를 넘는 잔여가 나올 수 없다.
  • 서비스에 조건문을 두지 않은 이유: 그 서비스를 거치지 않는 경로가 하나 생기는 순간 다시 샌다. 현황을 만드는 길이 값객체의 팩토리 하나뿐이면 어디서 만들든 같은 규칙을 탄다.
  • 아래쪽은 여전히 열어둔다. 초과 사용으로 잔여가 음수가 되는 것은 그대로다(결정 [결정] leave 연차 계산·검색반경·여행상한 스펙 확정 #38). 두 방향은 뜻이 다르다 — 초과 사용은 사용자가 경고를 보고 확인한 사실이고, 총을 넘는 잔여는 있을 수 없는 값이다.
  • 자른 사실을 삼키지 않는다. 원장 합이 음수면 값객체가 그 사실을 드러내고 서비스가 warn 을 남긴다. 조용히 정상으로 보이면 옛 데이터가 남아 있다는 걸 아무도 모른다.

되돌리기를 등록에서 삭제로 옮겼다

  • DELETE /api/v1/leaves/me/usages/{usageId} — 200 과 함께 갱신된 내 연차 전체(총·쓴·남은 + 내역 목록)를 준다. 화면이 한 번의 왕복으로 다시 그린다.
  • 음수 등록 거절은 이 PR 에서 뺐다 → 음수 days 등록을 400 LEAVE-013 으로 거절 (앱이 삭제 API 로 갈아탄 뒤) #276. 처음엔 함께 넣었는데(LEAVE-013), 리뷰에서 배포 순서에 창(window)이 생긴다는 것이 드러났다. 프론트는 이 PR 이 배포된 뒤에야 삭제로 갈아탈 수 있어 순서가 백엔드 → 프론트로 고정인데, 그 사이 앱의 "취소" 가 400 을 받아 사용자가 취소를 아예 못 한다.
  • 미뤄도 이 PR 의 본질은 남는다. 사용자 눈에 보이는 "잔여가 총을 넘는" 증상은 위의 clamp 가 막고, 틀어진 장부는 새 삭제 API 로 정리된다. 남는 것은 원장에 음수 행이 더 쌓이는 것뿐인데, 그것도 clamp 로 가려지고 삭제로 정리된다.
  • LEAVE-013 엔트리는 지우지 않고 남겼다. 에러코드 번호가 append-only 라 재사용·재배치가 금지이고 음수 days 등록을 400 LEAVE-013 으로 거절 (앱이 삭제 API 로 갈아탄 뒤) #276 이 그 코드를 그대로 쓴다. 지금 아무도 던지지 않아 죽은 코드로 보이므로 javadoc 에 그 사정을 박아뒀다.

이미 쌓인 음수 데이터

  • 마이그레이션으로 지우지 않는다. 사용자의 장부를 서버가 임의로 손대는 셈이고, 그 행들은 이제 사용자가 화면에서 직접 지울 수 있다.
  • 정리되기 전까지는 위의 clamp 가 화면을 정상으로 지킨다. 잔여는 총을 넘지 않고, 그 소유자의 조회마다 warn 이 남는다.

지울 수 있는 것과 없는 것

대상 응답 이유
내 수동 내역 200 + 갱신된 내 연차 사용자가 남긴 기록이라 사용자가 지운다
없는 내역 404 LEAVE-012
남의 내역 404 LEAVE-012 403 으로 나누면 번호를 넣어보며 "이 번호는 있다" 를 알아낼 수 있다. 코스 조회와 같은 규칙
코스 확정 내역 409 LEAVE-014 아래
  • 코스 확정으로 생긴 내역은 막았다. 그 행은 차감량이자 확정 표식이라, 연차 화면에서 지우면 "코스는 확정인데 연차는 안 깎인" 상태가 남는다. 코스 삭제·날짜 변경도 그 행이 있다는 전제로 돈다. 되돌리는 길은 이미 있다 — 코스의 차감 취소가 코스와 연차를 한 덩어리로 되돌린다.
  • 이건 404 로 감추지 않는다. 자기 내역이 화면에 보이는데 "없다" 고 답하면 사용자는 버그로 읽는다. 이유를 알려줘야 코스 화면으로 갈 수 있다.
  • 소유 확인은 조회 쿼리 조건에 함께 건다. id 로 읽고 나중에 소유자를 비교하는 모양이면, 그 비교를 빠뜨린 코드 한 줄이 곧 남의 내역을 지우는 길이 된다.
  • 지울 수 있는지의 판단은 서비스가 아니라 내역 자신이 답한다. 수정 API 가 붙어도 같은 규칙을 다시 쓰지 않는다.

이번에 넣지 않은 것 — 수정(PATCH)

장점 비용
이번에 함께 넣는다 프론트가 요청한 계약을 한 번에 채운다 부분 수정 계약(안 보낸 필드 vs null)을 새로 정해야 한다. days 수정은 코스가 들고 있는 차감량과 어긋날 수 있어 분기가 는다
뺀다 (채택) 삭제 + 재등록으로 같은 결과가 난다. 이 PR 이 닫으려는 버그에 검토를 모은다 프론트가 두 번 호출한다 (프론트도 급하지 않다고 했다)

Result

  • 프론트는 취소를 삭제 한 번으로 하고, 그 응답만으로 화면 전체를 다시 그린다. 취소 뒤 GET /me 를 다시 부르지 않아도 된다.
  • 검증은 두 층으로 잠갔다. 단위 — 원장 합이 음수(-0.5 ~ -99)여도 잔여가 총을 넘지 않고, 잔여 = 총 / 잔여 0 / 초과 사용의 경계를 함께 본다. 통합 — 같은 내역을 두 번 지우면 두 번째가 404 이고 잔여는 총 그대로다(재현 시나리오 그 자체).
  • 클라이언트 계약은 바뀌지 않는다. 음수 days 등록은 지금처럼 201 로 계속 받는다 — 앱이 삭제로 갈아탈 때까지 그 경로가 열려 있어야 한다. 거절은 음수 days 등록을 400 LEAVE-013 으로 거절 (앱이 삭제 API 로 갈아탄 뒤) #276 이 앱 전환 뒤에 닫는다. 통합 테스트가 음수 등록이 다시 201 로 통과하는 것을 단언해, 되돌리다 반쯤 남는 것을 막는다.
  • 응답의 usages[].days 는 상쇄 등록이면 음수다(옛 행도, 음수 days 등록을 400 LEAVE-013 으로 거절 (앱이 삭제 API 로 갈아탄 뒤) #276 전까지 새로 들어오는 것도). 그 행도 삭제 대상이다 — 사용자가 목록에서 지워 장부를 정리한다.

작업을 마치기 전 자문 셋

  1. 운영에서 버티는가 — 스키마 변경도 부팅 적재도 없다. 쿼리 하나가 늘었는데 기본 키와 소유 키로 한 행을 집는 조회다.
  2. 외부 API 한도 — 해당 없음. 외부 호출이 없는 경로다.
  3. 코스의 완성도 — 코스 자체와는 무관한 데이터 정합성 수정이다. 다만 코스 확정 내역을 지우지 못하게 막아, "코스는 확정인데 연차는 안 깎인" 어긋남이 생길 길을 하나 닫았다.

연관 이슈

- DELETE /api/v1/leaves/me/usages/{usageId} 를 열고, 응답으로 갱신된 내 연차 전체를
  내려준다. 화면이 삭제 후 목록·잔여를 한 번의 왕복으로 다시 그린다
- 삭제가 없어 프론트가 음수 days 를 등록해 상쇄하고 있었는데, 같은 취소가 두 번 들어오면
  사용 합이 음수로 내려가 잔여가 총 연차를 넘었다(15일 중 2일 쓰고 취소 2회 → 잔여 17)
- 그 불변식을 LeaveSummary 가 갖게 했다. 서비스에 if 를 두면 그 서비스를 안 거치는 경로가
  생기는 순간 다시 새므로, 사용 합을 0 아래로 못 내려가게 하는 팩토리로 값객체가 보장한다.
  자른 사실은 isLedgerNegative 로 드러내 호출자가 warn 을 남긴다(조용한 실패 금지)
- 음수 등록 자체를 막는다(LEAVE-013). 삭제가 생기면 상쇄 등록은 필요 없고, 그 등록은
  재시도·중복 탭에 상한이 없어 장부가 그만큼 틀어진다. 0.5 단위 위반(LEAVE-010)과
  코드를 가른 이유는 화면이 "삭제로 취소하세요" 를 안내해야 하기 때문
- 이미 쌓인 음수 행은 마이그레이션으로 건드리지 않는다. 남의 장부를 서버가 지우는 셈이고,
  이제 사용자가 그 행을 직접 지울 수 있다. 그때까지는 위 clamp 가 화면을 정상으로 지킨다
- 코스 확정 내역(courseId 있음)은 삭제를 409(LEAVE-014)로 막는다. 그 행은 차감량이자 확정
  표식이라 지우면 "코스는 확정인데 연차는 안 깎인" 상태가 남고, 코스 삭제·날짜 변경도 그
  행을 전제로 돈다. 404 로 감추지 않는 이유는 자기 내역이 화면에 보이기 때문 — 이유를
  알려줘야 코스 화면으로 갈 수 있다
- 없는 내역과 남의 내역은 같은 404(LEAVE-012). 나눠 답하면 번호를 넣어보며 존재 여부를
  알아낼 수 있어 코스 조회와 같은 규칙을 따른다. 조회 자체도 소유자를 쿼리 조건에 함께 건다
- 수정(PATCH)은 이번에 넣지 않는다. 삭제 + 재등록으로 같은 결과를 낼 수 있고 프론트도 급하지
  않다고 했다. days 수정은 코스 차감량과 어긋날 수 있어 계약을 따로 정해야 한다 (#267)
- "취소 2회 → 잔여 17" 을 두 층에서 잠근다. 단위로는 원장 합이 음수여도 잔여가 총을 넘지
  않는 것, 통합으로는 같은 내역을 두 번 지우면 두 번째가 404 이고 잔여가 총 그대로인 것
- LeaveSummary 는 경계값을 표로 망라했다 — 잔여 = 총(사용 0), 잔여 0(딱 맞게 씀),
  초과 사용(음수 유지), 음수 원장(-0.5 ~ -99). 위쪽만 막고 아래쪽은 여는 비대칭이 핵심이라
  두 방향을 함께 둔다
- 음수 등록 400 은 code 뿐 아니라 detail 까지 단언한다. 이 문구가 "삭제로 취소하세요" 를
  사용자에게 전하는 유일한 통로라, 바뀌면 화면 안내가 조용히 사라진다
- 코스 확정 내역 삭제 409 는 코스 통합 테스트에 둔다. 차감 내역을 실제로 만들어야 하는데
  그 준비가 이미 그쪽에 있고, 거절 뒤에도 차감이 남아 있는지까지 확인한다
- 기존 "취소하면 되돌아온다" 테스트는 음수 등록으로 검증하고 있어 삭제 기반으로 옮겼다
@sevineleven sevineleven added the feat 새 기능 (외부에 보이는 변화) label Aug 13, 2026
@sevineleven sevineleven self-assigned this Aug 13, 2026
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@sevineleven, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 110 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4797c62b-29a5-4507-b67f-3ded4656e118

📥 Commits

Reviewing files that changed from the base of the PR and between c20ff40 and 063f4c5.

📒 Files selected for processing (19)
  • src/main/java/com/offway/core/leave/controller/LeaveApi.java
  • src/main/java/com/offway/core/leave/controller/LeaveController.java
  • src/main/java/com/offway/core/leave/controller/dto/AddLeaveUsageRequest.java
  • src/main/java/com/offway/core/leave/controller/dto/MyLeaveResponse.java
  • src/main/java/com/offway/core/leave/domain/LeaveDays.java
  • src/main/java/com/offway/core/leave/domain/LeaveErrorCode.java
  • src/main/java/com/offway/core/leave/domain/LeaveException.java
  • src/main/java/com/offway/core/leave/domain/LeaveSummary.java
  • src/main/java/com/offway/core/leave/domain/LeaveUsage.java
  • src/main/java/com/offway/core/leave/repository/LeaveUsageJpaRepository.java
  • src/main/java/com/offway/core/leave/repository/LeaveUsageRepository.java
  • src/main/java/com/offway/core/leave/repository/LeaveUsageRepositoryImpl.java
  • src/main/java/com/offway/core/leave/service/MyLeaveService.java
  • src/main/java/com/offway/core/leave/service/dto/AddLeaveUsage.java
  • src/test/java/com/offway/core/itinerary/controller/CourseLeaveDeductionIntegrationTest.java
  • src/test/java/com/offway/core/leave/controller/MyLeaveIntegrationTest.java
  • src/test/java/com/offway/core/leave/domain/LeaveDaysTest.java
  • src/test/java/com/offway/core/leave/domain/LeaveSummaryTest.java
  • src/test/java/com/offway/core/leave/domain/LeaveUsageTest.java

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

- 현황을 만드는 경로가 둘인데(내 연차 조회·홈 배지) warn 이 한쪽에만 있었다.
  remainingDaysOrNull 이 LeaveSummary.of 를 직접 불러, 원장 합이 음수인 소유자가
  홈만 열면 clamp 가 조용히 일어났다. 더 자주 불리는 쪽이 그쪽이라 방향이 반대였다.
- 두 경로를 private summaryOf(guestId, totalDays) 하나로 모아 자르는 곳과 알리는 곳을
  일치시켰다. 홈 배지는 총 연차를 이미 손에 들고 있어 조회를 한 번 아끼는데,
  그 최적화가 로그를 건너뛰는 이유가 되지 않게 총 연차를 인자로 받는 오버로드를 뒀다.
- 새 삭제 엔드포인트에만 401 이 붙어 있어, 나머지 다섯 개가 공개로 읽혔다.
  SecurityConfig 는 anyRequest().authenticated() 라 여섯 개 모두 401 도달 가능하다.
  한 곳만 적으면 없는 쪽이 permitAll 이라는 뜻이 되므로 전부에 붙이고,
  인터페이스 주석에 근거(#122 인증 게이트)를 남겼다.
- LeaveUsage 의 usedOn·days 필드 주석이 "증감 · 취소는 음수" 로 남아 있었다.
  클래스 주석만 고치고 필드를 놓친 자리다. 하이드레이션이 생성자를 거치지 않아
  옛 음수 행은 그대로 읽힌다는 사실도 함께 적었다.
- MyLeaveResponse.usedDays 를 "내역 합" 이라 설명하던 것을 고쳤다. 이제 0 으로
  잘리므로 옛 음수 행이 있으면 usages 를 더한 값과 다르다 — 클라이언트가 목록으로
  검산하면 서버와 어긋난다.
- "마이그레이션으로 지우지 않고 사용자가 직접 지운다" 는 이 PR 의 결정이 테스트로
  잠기지 않아, 그 행들이 실제로 어떻게 보이는지 아무도 확인하지 않은 상태였다.
- API 로는 더 이상 음수를 넣을 수 없으므로 JdbcTemplate 으로 도메인을 우회해 심는다.
  옛 데이터의 실제 모습이 그렇다 — 하이드레이션은 생성자를 거치지 않는다.
- 한 시나리오로 셋을 잠근다. 원장 -2 여도 잔여가 17 이 아니라 15 인 것(clamp 가
  단위 테스트가 아니라 실데이터에서 도는 것), 그래도 음수 행이 목록에 그대로
  나가는 것(감추지 않기로 한 사양), 지우고 나면 usedDays 2·잔여 13 으로 장부가
  맞아떨어지는 것.
- clamp 를 제거해 돌려 이 테스트가 실제로 깨지는 것을 확인했다.
@sevineleven

Copy link
Copy Markdown
Contributor Author

리뷰 — PR #268 (CodeRabbit 레이트리밋 대체)

본문의 주장을 코드로 짚어가며 봤다. 결론: 설계는 맞다. 다만 머지 전에 사람이 정해야 할 것이 하나 있고(배포 순서), 자잘한 지적 넷은 이 브랜치에 직접 고쳐 올렸다.

검증한 것

항목 결과
./gradlew cleanTest test (Testcontainers MySQL) 1357 tests / 0 failures / 21 skipped — BUILD SUCCESSFUL
컨벤션 훅 (convention-check.sh, 변경 파일 전수) 차단 0
origin/dev 와의 간격 벌어지지 않음 (rebase·머지 불필요)
소유권 우회 경로 findByIdAndGuestId 로 쿼리 조건에 결합 — id 로 읽고 나중에 비교하는 모양이 없다
에러코드 append-only LEAVE-012/013/014 추가만. 번호 재사용·재배치·결번 메움 없음
트랜잭션 경계 deleteUsage@Transactional, 외부 호출 없음. 삭제 → myLeave 재조회가 한 트랜잭션
N+1 삭제 경로 4쿼리(내역 1 + 삭제 + 잔액 + 합계·목록). 루프 없음
⑤ 삭제 응답이 갱신된 MyLeaveResponse 전체인가 그렇다. ApiResponseBody.ok(MyLeaveResponse.from(...)) — 총·쓴·남은 + 내역 목록

좋았던 것 (근거 있는 것만)

  • 불변식을 값객체로 내린 것이 실제로 값객체 안에 있다. LeaveSummary.of 가 자르고, 추가로 compact constructor 가 usedDays < 0IllegalArgumentException 으로 막는다. record 는 canonical constructor 를 감출 수 없으므로 "팩토리 하나뿐" 은 강제할 수 없는데, 그 구멍을 불변식 검증으로 실제로 메웠다. 본문 서술과 코드가 어긋나지 않는다.
  • 도메인이 DTO 를 우회해도 스스로 방어한다. 음수 거절이 AddLeaveUsageRequest.toCommand()LeaveUsage.requireDays() 양쪽에 있다. forCourse 를 서비스가 직접 부르는 경로가 이미 있으므로 이 이중화는 과잉이 아니다.
  • requireDayscourseId == null 일 때만 음수를 가른다. 코스 차감은 애초에 음수가 들어올 길이 없어 코드를 갈라도 소득이 없다는 판단이 맞다. moveTocourseId 를 넘겨 같은 규칙을 탄다.
  • ③ 코스 차감 내역 409 는 판단 기준에 맞다. 자기 내역이 목록에 보이는 상태에서 삭제를 누르는 것은 "멀쩡한 클라이언트가 정상 요청으로 닿는" 자리다 → 계약 → 커스텀 예외. 404 로 감추지 않은 것도 맞다: 없는 내역·남의 내역과 달리 여기서 감출 존재 여부가 없다(이미 본인 목록에 있다). detail 이 "코스에서 차감을 취소해 주세요" 로 다음 행동을 지정하고, 테스트가 그 문구까지 단언한다.
  • 없는 내역과 남의 내역을 같은 404 로 답하고, 그 규칙을 코스 조회 선례에 맞춘 것.

지적

🔴 [High · 머지 전 사람이 결정할 것] ① 음수 등록 400 은 배포 순서에 창(window)을 만든다 — 본문의 대응이 성립하지 않았다

본문 Result 에 이렇게 적혀 있었다.

프론트가 상쇄 등록을 쓰던 자리를 삭제로 바꾼 뒤 배포해야 한다.

이 순서는 불가능하다. 프론트가 상쇄 등록을 삭제로 바꾸려면 DELETE /me/usages/{id} 가 먼저 떠 있어야 하고, 그건 이 PR 이 배포돼야 생긴다. 따라서 순서는 백엔드 → 프론트로 고정이고, 그 사이 구간에서

  • 앱의 "취소" 버튼은 POST /me/usages 에 음수를 보낸다 → 400 LEAVE-013
  • 지금 배포된 앱은 그 코드를 모른다 → 일반 오류로 보이거나 조용히 실패한다
  • 그 구간 동안 사용자는 취소를 아예 못 한다 (삭제는 앱에 아직 없으므로)

이 PR 에서 가장 큰 실무 위험이고, 코드로는 해결되지 않는다. 선택지는 둘이다.

  • ⓐ 프론트 배포와 붙여 내보낸다 — 창을 분 단위로 줄인다. 프론트 일정에 묶인다.
  • LEAVE-013 거절만 후속으로 미룬다 — 삭제 API 와 clamp 는 지금 내보내고, 음수 거절은 프론트가 삭제로 갈아탄 뒤 별도 PR 로 닫는다. 이 PR 의 본질은 그대로 남는다: "잔여가 총을 넘는" 사용자 눈에 보이는 증상은 LeaveSummary 의 clamp 가 막고, 상쇄로 틀어진 장부는 새 삭제 API 로 정리된다. 미루는 동안 남는 손해는 원장이 더 틀어질 수 있다는 것뿐인데, 그건 이미 clamp 로 가려지고 삭제로 정리 가능하다.

기술적으로는 ⓑ 가 위험/이득 비가 낫다고 본다. 다만 이건 릴리스 조율 판단이라 리뷰어가 대신 정하지 않았다. 본문 해당 bullet 만 표적 수정해 순서가 백엔드→프론트로 고정이라는 사실과 두 선택지를 적어뒀다(다른 서술은 건드리지 않았다).

🟡 [Medium · 고쳤다 → ce09c10] ② clamp 경고가 두 경로 중 한쪽에만 있었다 — 더 자주 불리는 쪽이 조용했다

본문은 "그 소유자의 조회마다 warn 이 남는다" 고 했는데, 코드상 그렇지 않았다.

  • MyLeaveService.summaryOf()isLedgerNegative() 로 warn ✅ (죽은 코드 아님. 호출부 확인했다)
  • MyLeaveService.remainingDaysOrNull()LeaveSummary.of(...)직접 불러 clamp 만 하고 로그 없음

홈 배지가 「내 연차」 화면보다 자주 불리므로, 깨진 데이터를 가진 사용자가 홈만 열면 clamp 가 조용히 일어났다. §조용한 실패를 만들지 않는다 위반이다.

→ 두 경로를 private LeaveSummary summaryOf(String guestId, double totalDays) 하나로 모아 자르는 곳과 알리는 곳을 일치시켰다. 홈 배지가 총 연차를 이미 손에 들고 조회를 한 번 아끼는 최적화는 유지했다(그래서 오버로드).

clamp 자체가 진짜 문제를 가리는가에 대한 판단: 가리지 않는다고 본다. 원장이 음수라는 것은 데이터가 깨졌다는 신호가 맞지만, 여기서 500 을 던지면 피해자가 화면을 못 여는 것으로 벌을 받는다. clamp + warn + 사용자가 직접 정리할 수 있는 삭제 API, 이 셋이 함께 있으면 "드러남" 은 충족된다. 다만 warn 이 소유자를 안 싣는 것은 그대로 두는 게 맞다(§로깅) — 규모만 알면 되고, 대상은 SELECT guest_id, SUM(days) FROM leave_usage GROUP BY guest_id HAVING SUM(days) < 0 로 언제든 찾을 수 있다.

🟡 [Medium · 고쳤다 → 89d22ae] ④ "이미 쌓인 음수 행을 사용자가 지울 수 있다" 가 테스트로 잠기지 않았다

본문의 핵심 결정("마이그레이션으로 지우지 않는다")을 떠받치는 전제인데 검증이 없었다. 로직상으로는 requireManuallyDeletable()courseId 만 보므로 지워지는 게 맞지만, 주장이 사양이면 테스트가 있어야 한다.

이미_쌓인_음수_행은_목록에_보이고_사용자가_지워_정리할_수_있다() 를 추가했다. 이제 API 로 음수를 못 넣으므로 JdbcTemplate 으로 도메인을 우회해 심는다 — 하이드레이션이 생성자를 거치지 않으니 그게 옛 데이터의 실제 모습이다. 한 시나리오가 셋을 잠근다.

  1. 원장 +2, -2, -2 → 잔여가 17 이 아니라 15 (clamp 가 단위 테스트가 아니라 실데이터·실응답에서 돈다)
  2. 그래도 음수 행 2건이 목록에 그대로 나간다 — 감추지 않기로 한 사양이라 명시적으로 단언했다
  3. 지우면 usedDays 2 / 잔여 13 으로 장부가 실제로 맞아떨어진다

자명하게 통과하지 않는지 확인했다: LeaveSummary.ofMath.max 와 불변식 검증을 빼고 돌려 이 테스트가 FAILED 하는 것을 봤다.

참고로 ④ 의 사용자 경험은 이렇다 — 정리 전까지 목록에는 -2 행이 보이는데 usedDays 는 0 이다. 장부가 눈으로는 안 맞는다. 이건 "지우지 않는다" 를 고른 대가이고, 사용자가 그 행을 지우면 해소된다. 받아들일 만하다고 본다.

🟢 [Low · 고쳤다 → 5568400] @ApiResponse 401 이 새 엔드포인트에만 붙었다

SecurityConfiganyRequest().authenticated() 라(#122) LeaveApi여섯 개 모두 401 이 도달 가능하다. 삭제에만 붙이면 나머지 다섯이 permitAll 이라는 뜻이 되어 오히려 오해를 만든다(api-convention §응답 전수 문서화 ②). 전부에 붙이고 인터페이스 주석에 근거를 남겼다. 이 PR 이 만든 비대칭이라 여기서 정리했다.

🟢 [Low · 고쳤다 → 5568400] 낡은 주석 둘

  • LeaveUsageusedOn·days 필드 주석이 "증감 · 사용은 양수, 취소는 음수" 로 남아 있었다. 클래스 주석만 고치고 필드를 놓친 자리다.
  • MyLeaveResponse.usedDays"내역 합" 이라 설명하는데, 이제 0 으로 잘리므로 옛 음수 행이 있으면 usages 를 더한 값과 다르다. 클라이언트가 목록으로 검산하면 서버와 어긋난다 — 계약 문서로 나가는 자리라 고쳤다.

⚪ [Nit · 고치지 않았다] LEAVE-010 메시지가 낡았다

"연차 증감은 0.5일 단위여야 하고 0일은 기록할 수 없습니다." — 이제 "증감" 이 아니고, 99 초과도 이 코드로 나간다. 다만 detail 은 사용자 대면 문구라 바꾸면 화면에 그대로 반영되고, 이 PR 자신이 LEAVE-013 detail 을 단언하는 테스트를 넣은 만큼 문구 변경은 프론트와 합의할 사안으로 봤다. 리뷰어가 임의로 바꾸지 않았다.


남는 것

머지 전에 ① 만 정하면 된다. ⓑ(음수 거절을 후속으로 분리)를 고르면 LeaveDays.isReversal 호출부 두 곳(AddLeaveUsageRequest.toCommand, LeaveUsage.requireDays)만 걷어내면 되고 나머지는 그대로 간다 — 원하면 그 작업도 이 브랜치에 올리겠다.

머지는 하지 않았다.

@sevineleven

Copy link
Copy Markdown
Contributor Author

대응 커밋 — 위 리뷰의 지적을 이 브랜치에 직접 고쳐 올렸다

커밋 지적 무엇을
ce09c10 fix: 🟡 ② clamp 경고가 한 경로에만 remainingDaysOrNullLeaveSummary.of 를 직접 불러 홈 배지 조회에서 clamp 가 조용히 일어나던 것. summaryOf(guestId, totalDays) 로 모아 자르는 곳과 알리는 곳을 일치시켰다
5568400 docs: 🟢 401 비대칭 · 낡은 주석 LeaveApi 여섯 엔드포인트 전부에 401 문서화(근거는 인터페이스 주석에) · LeaveUsage.usedOn/days 필드 주석 · MyLeaveResponse.usedDays 의 "내역 합" 서술
89d22ae test: 🟡 ④ 음수 행 삭제가 미검증 이미_쌓인_음수_행은_목록에_보이고_사용자가_지워_정리할_수_있다()JdbcTemplate 으로 옛 데이터를 심어 clamp·노출·정리 셋을 한 시나리오로 잠금

본문 표적 수정 1건 — Result 의 "프론트가 … 바꾼 뒤 배포해야 한다" bullet 만 고쳤다. 그 순서가 불가능하다는 사실(백엔드→프론트 고정)과 선택지 ⓐ/ⓑ 를 적었다. 나머지 서술은 그대로 뒀다.

고치지 않은 것 — ① 배포 순서(사람이 결정할 릴리스 조율 사안), LEAVE-010 문구(사용자 대면 문구라 프론트와 합의 대상).

재검증

  • ./gradlew cleanTest test1357 tests / 0 failures / 21 skipped, BUILD SUCCESSFUL (신규 1건 포함)
  • 컨벤션 훅 변경 파일 전수 → 차단 0
  • 새 테스트가 자명하게 통과하지 않는지 확인: LeaveSummary.ofMath.max + 불변식 검증을 제거하고 돌려 FAILED 되는 것을 봄

- 거절을 이 PR 에 함께 넣으면 배포 순서에 창(window)이 생긴다. 프론트는 삭제 API 가
  배포된 뒤에야 갈아탈 수 있어 순서가 백엔드 → 프론트로 고정인데, 그 사이 앱의 "취소" 는
  400 LEAVE-013 을 받는다. 지금 앱은 그 코드를 모르고 삭제도 안 붙였으므로 그 구간 동안
  사용자가 취소를 아예 못 한다. 리뷰에서 드러나 거절만 #276 으로 떼어냈다.
- 미뤄도 이 PR 의 본질은 남는다. 사용자 눈에 보이는 "잔여가 총을 넘는" 증상은
  LeaveSummary 의 clamp 가 막고, 틀어진 장부는 새 삭제 API 로 정리된다. 남는 것은
  원장에 음수 행이 더 쌓이는 것뿐인데 그건 clamp 로 가려지고 삭제로 정리된다.
- 되돌린 범위: isValidUsage 를 음수 허용으로 복구, isReversal 제거, 요청 DTO·도메인의
  throw 두 곳 제거, 관련 문서·테스트. 삭제 API·clamp·409·404·warn 일원화는 그대로 둔다.
- LEAVE-013 엔트리와 LeaveException 팩토리는 지우지 않았다. 에러코드 번호가
  append-only 라 재사용·재배치가 금지이고, #276 이 그 코드를 그대로 쓴다. 지금 아무도
  던지지 않아 죽은 코드로 보이므로 양쪽 javadoc 에 "#276 이 호출부를 만든다" 를 박았다.
- 거절 테스트는 지우지 않고 뒤집었다. 음수 등록이 다시 201 로 통과하는 것을 단언한다 —
  되돌리다 반쯤 남기는 것을 막는 자리다. 재현 시나리오 테스트도 JdbcTemplate 우회 대신
  실제 앱 경로(상쇄 등록 두 번)로 바꿔, 원장 -2 에서 잔여가 17 이 아니라 15 인 것을 본다.
@sevineleven

Copy link
Copy Markdown
Contributor Author

ⓑ 채택 — 음수 등록 거절을 이 PR 에서 걷어내 #276 으로 분리했다

앞선 리뷰의 🔴 ① 에 대한 결정이다. 음수 days 등록은 이 PR 에서 계속 201 로 받는다.

거절을 여기 두면 배포 순서에 창(window)이 생긴다. 프론트는 이 PR 이 배포된 뒤에야 삭제 API 로 갈아탈 수 있어 순서가 백엔드 → 프론트로 고정인데, 그 사이 앱의 "취소" 가 400 LEAVE-013 을 받는다. 지금 앱은 그 코드를 모르고 삭제도 아직 안 붙였으므로 그 구간 동안 사용자는 취소를 아예 못 한다.

미뤄도 이 PR 의 본질은 그대로다.

  • 사용자 눈에 보이는 "잔여가 총을 넘는" 증상 → LeaveSummary 의 clamp 가 막는다 (이 PR)
  • 상쇄로 틀어진 장부 정리 수단 → 새 삭제 API (이 PR)
  • 남는 것은 원장에 음수 행이 더 쌓이는 것뿐인데, 그것도 clamp 로 가려지고 삭제로 정리된다

걷어낸 범위 — 063f4c5

되돌린 것 그대로 둔 것
LeaveDays.isValidUsage 를 양수 전용으로 좁힌 것 → 음수 허용 복구 DELETE /me/usages/{id} 삭제 API 전체
LeaveDays.isReversal 도입 LeaveSummary clamp + 불변식 + ledgerDays
AddLeaveUsageRequest.toCommand 의 throw 코스 확정 내역 삭제 차단 409 LEAVE-014
LeaveUsage.requireDays 의 throw 없는/남의 내역 404 LEAVE-012
거절 관련 문서(@ApiResponse 400 설명, schema, javadoc) 리뷰로 고친 warn 일원화·401 전수 문서화·주석 정정

LEAVE-013 엔트리와 LeaveException 팩토리는 지우지 않았다. 에러코드 번호가 append-only 라 재사용·재배치가 금지고, #276 이 그 코드를 그대로 쓴다. 지금 아무도 던지지 않아 죽은 코드로 보이므로 양쪽 javadoc 에 "#276 이 호출부를 만든다" 를 박아 의도를 남겼다.

반쯤 남지 않았는지 어떻게 확인했나

되돌리다 절반만 남는 것이 제일 나쁘므로 세 겹으로 봤다.

  1. 거절 테스트를 지우지 않고 뒤집었다음수_등록은_아직_받는다()-1 등록이 201 로 통과하고 응답에 days: -1.0 이 실리는 것을 단언한다. 거절이 어딘가에 남아 있으면 이 테스트가 깨진다.
  2. dev 와 코드 레벨로 대조했다LeaveDays·AddLeaveUsageRequest.toCommand·LeaveUsage.requireDays 의 실행 코드가 dev 와 동일함을 확인했다(LeaveDays 는 리터럴 0NONE 상수로 바뀐 것만 차이 — 의미 동일).
  3. 전수 grepisReversal / leaveUsageReversalNotAllowed 호출부가 src 전체에 0건.

재현 시나리오 테스트도 JdbcTemplate 우회 대신 실제 앱 경로(상쇄 등록 두 번)로 바꿨다. 원장 +2, -2, -2 에서 잔여가 17 이 아니라 15 이고, 음수 행 2건이 목록에 그대로 보이며, 지우면 usedDays 2 / 잔여 13 으로 맞아떨어지는 것을 한 시나리오로 잠근다.

재검증

  • ./gradlew cleanTest test1347 tests / 0 failures / 21 skipped, BUILD SUCCESSFUL
    • 1357 → 1347 은 isReversal 파라미터 테스트 10건이 빠진 수와 정확히 일치한다
  • 컨벤션 훅 변경 파일 전수 → 차단 0

후속

본문도 바뀐 범위에 맞춰 해당 bullet 들만 표적 수정했다. 머지는 하지 않았다.

@sevineleven

sevineleven commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

후속 PR 번호: #277 (이슈 #276).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat 새 기능 (외부에 보이는 변화)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

연차 사용 내역 삭제 API 와 "잔여가 총 연차를 넘는" 버그 차단

1 participant