[ECC 2026 Summer Project] SplitOpt - Week 3
기간: 2026. 07. 27 – 08. 02 (3주차)
내용: 공통 모듈, 정산 최적화 알고리즘, 정산·예산 API, Flyway·CI, 명세 개정
파트: 백엔드
구현이 시작된 주다. 내 파트(정산 23–29, 예산 38–40)에서 계산 로직과 API를 올렸고, 주말에는 프로토타입 피드백을 받아 명세를 개정했다.
1. 공통 모듈
도메인 작업 전에 세 파트가 공유할 기반을 먼저 잡았다.
ApiResponse— 성공·실패 응답 포맷 통일BusinessException+ErrorCode+GlobalExceptionHandler— 예외를 HTTP 상태로 매핑하는 지점을 한 곳으로BaseEntity—created_at/updated_at감사 컬럼
에러 코드를 enum 한 곳에 모으고 HTTP 상태를 함께 들고 다니게 했다. 서비스는 BusinessException(ErrorCode.INVALID_STATE, "...")만 던지고, 상태 코드 결정은 핸들러가 한다.
2. 정산 최적화 알고리즘
참여자별 잔액을 입력으로 받아 송금 건수를 줄이는 부분이다.
greedy netting 방식으로 구현했다. 잔액을 채권자(+)와 채무자(−)로 나눠 각각 우선순위 큐에 넣고, 매 단계에서 절댓값이 가장 큰 채권자와 채무자를 뽑아 둘 중 작은 금액만큼 상쇄한다. 남은 금액은 다시 큐에 넣는다. 매 단계마다 최소 한 명이 큐에서 빠지므로 결과는 최대 (n−1)건이다.
1
2
3
4
5
잔액: A +120,000 / B 0 / C −40,000 / D −80,000
1단계: A(120,000) ↔ D(80,000) → D→A 80,000, A에 40,000 남음
2단계: A(40,000) ↔ C(40,000) → C→A 40,000, 둘 다 소진
결과: 2건
최소 송금 횟수 문제 자체는 일반적으로 NP-hard이므로 이 greedy는 최적 근사다. 정확한 최소해가 필요하면 부분집합 상쇄 같은 단계를 얹어야 한다. 이 한계는 주석에 남겼다.
입력 전제도 코드로 강제했다. 잔액 총합이 0이 아니면(= 결제 총액 ≠ 부담 총액) 계산을 시작하지 않고 예외를 던진다. 총합이 0이 아닌 입력은 어디선가 데이터가 깨진 것이므로, 그 상태로 송금 목록을 만들면 안 된다.
금액은 전부 BigDecimal로 다뤘다. 돈 계산에 double을 쓰면 원 단위가 어긋난다.
3. 정산·예산 API
알고리즘 위에 API를 올렸다.
| API | 엔드포인트 |
|---|---|
| 25 정산 결과 조회 | GET .../settlements |
| 28 미정산 조회 | GET .../settlements?status=pending |
| 29 정산 완료 여부 | GET .../settlements/summary |
| 38 예산 설정/수정 | PUT .../budget |
| 39 예산 현황 조회 | GET .../budget |
최적화 실행(24)은 서비스 계층까지 만들고 잔액을 파라미터로 받는 형태로 뒀다. 지출 집계와의 연결부를 계산 로직 밖으로 빼면, 최적화와 잔액 계산을 지출 테이블 없이도 단위 테스트로 검증할 수 있다. 연결부는 별도 지점으로 분리해 나중에 한 곳만 채우면 되게 했다.
리뷰에서 걸린 것 두 가지를 그 자리에서 고쳤다.
- 정산 완료 처리가 다른 모임의 정산 건도 건드릴 수 있었다 → 조회를
findByIdAndGroup_Id로 바꿔 모임 범위로 제한 ?status=파라미터에 지원하지 않는 값이 오면 조용히 전체를 반환했다 → 400으로 거부
예산은 DECIMAL(12,2) 범위를 도메인에서도 강제했다. 컨트롤러 검증만 두면 서비스를 직접 호출하는 경로에서 뚫린다.
4. Flyway와 CI
로컬은 H2로 빠르게 개발하고 있었지만, 운영 스키마를 Hibernate 자동 생성에 맡길 수는 없다. Flyway를 도입해 V1 초기 스키마를 작성하고, 운영에서는 ddl-auto=none으로 두고 스키마를 Flyway가 전담하게 했다.
문제는 이 마이그레이션 SQL이 MySQL 전용이라 H2로는 검증이 안 된다는 점이었다. Testcontainers로 실제 MySQL 컨테이너를 띄워 V1을 적용하고, 스키마가 의도대로 만들어졌는지 검증하는 테스트를 붙였다. FK와 CHECK는 잘못된 INSERT가 실제로 거부되는지로 확인했다. 제약을 선언하는 것만으로는 그것이 동작한다는 보장이 없기 때문이다.
GitHub Actions로 main 푸시·PR마다 테스트를 돌리게 했다. 체크아웃 토큰은 persist-credentials: false로 남기지 않았다. 테스트만 돌리는 잡에 자격증명을 git 설정에 남길 이유가 없다.
5. 코드 리뷰 자동화
CodeRabbit을 붙였다. 각자 다른 도메인을 맡고 있어 서로의 코드를 깊게 볼 여유가 적은데, 자동 리뷰가 1차로 걸러주면 사람 리뷰가 설계 판단에 집중할 수 있다.
내 PR에서 네 건이 걸렸고, 그중 이번에 손대지 않기로 한 것은 이슈로 열어 근거를 남겼다.
| 이슈 | 내용 | 판단 |
|---|---|---|
| #5 | 예산 최초 설정 동시성(원자적 upsert) | UNIQUE로 중복 행은 이미 막히므로 순서를 뒤로 |
| #6 | 재정산 시 완료 정산 중복 청구 | 잔액 산출을 순잔액으로 바꾸는 작업과 함께 처리 |
| #7 | 정산 참여자의 모임 소속 검증 | 같은 잔액 산출 경로에서 함께 보장 |
| #8 | 정산 엔드포인트 인증·멤버십 인가 | 인증 인프라가 붙는 시점에 일괄 적용 |
이슈에는 배경·판단·착수 조건을 함께 적었다. 이 기록이 없으면 미뤄둔 항목인지 검토 끝에 불필요하다고 본 항목인지 구분되지 않는다.
6. 프로토타입 피드백과 명세 개정
주말에 프론트 프로토타입을 보고 명세를 개정했다. 엔드포인트 추가·삭제는 없고, 요청·응답·에러·권한 상세를 보강하는 개정이다.
가장 큰 변경은 정산 상태가 2단계에서 3단계로 늘어난 것이다.
1
2
PENDING ──SEND(보내는 사람)──▶ SENT ──CONFIRM(받는 사람)──▶ COMPLETED
└──CANCEL(보내는 사람, 확인 전에만)──▶ PENDING
기존 PENDING/COMPLETED로는 “보낸 사람이 송금했다고 눌렀다”와 “받은 사람이 확인했다”를 구분할 수 없었다. 보낸 쪽 주장만으로 완료가 되면 받는 쪽에서 확인할 방법이 없다. 그래서 SENT를 사이에 넣고, 완료 배지는 COMPLETED(양측 확인) 기준으로 통일했다.
여기에 CANCEL(SENT→PENDING)을 제안했다. 송금 완료를 잘못 누르는 경우를 되돌릴 수단이 필요하되, 상대가 확인한 뒤(COMPLETED)에는 취소할 수 없어야 한다. 취소하면 sent_at도 NULL로 되돌린다.
DB 쪽 영향은 settlements 한 테이블뿐이었다. status CHECK에 SENT를 넣고 sent_at을 추가하는 변경이며, V1을 수정하지 않고 V2 마이그레이션으로 반영하기로 했다. 이미 적용된 마이그레이션을 고치면 이력이 어긋나기 때문이다.
그 외 개정 사항은 이렇다.
- 정산 방법을 균등 분담 / 직접 입력으로 정리(비율 분담 제거) — 두 방식 모두 결과가 참여자별 금액이라 컬럼 추가 없음
- 지출 결제자를 로그인 사용자로 고정(결제자 드롭다운 제거)
- 권한 정책 정리 — 모임 설정은 owner만, 지출 수정은 결제자 본인만, 송금 완료는 from만, 송금 확인은 to만
- 공통 에러 응답에
errors[](field·code) 추가 — 프론트가 입력란 옆에 인라인 표시할 수 있게 - 균등 분담 나머지 원은 결제자 우선 배분(41,000 ÷ 3 → 13,668 / 13,666 / 13,666)
- 정산 요약에
NOT_STARTED구분 — 한 번도 정산을 안 돌린 모임(total=0)과 진행 중을 구분