Post

[ECC 2026 Summer Project] SplitOpt - Week 4

[ECC 2026 Summer Project] SplitOpt - Week 4

기간: 2026. 08. 03 – 08. 09 (4주차)

내용: 정산 상태 전이 구현, 동시성 처리, 컨트롤러 테스트, 예산 원자적 upsert

파트: 백엔드


3주차에 개정한 명세를 코드로 옮긴 주다. 동시 요청에서 깨지는 지점을 두 곳 고쳤고, 웹 계층 테스트를 붙였다.

1. 설계 확정

주중 회의에서 남아 있던 결정을 정리했다. 내 파트와 직접 관련된 것은 두 가지다.

정산 상태 전이(27) — 상태별로 엔드포인트를 나누지 않고, 단일 PATCH .../settlements/{id}/status에 body로 동작을 받는 방식으로 확정했다.

1
{ "action": "SEND" }   // SEND | CONFIRM | CANCEL

경로를 상태마다 늘리면 상태가 하나 추가될 때 API가 같이 늘어난다. 전이를 값으로 받으면 경로는 하나로 고정된다.

최적화 재실행(24) — 이미 완료된 정산을 보존하고 미정산 잔액만 재계산한다. 여기서 중요한 조건이 붙었다. 재계산에 넘길 잔액은 이미 정산된 금액을 뺀 순잔액이어야 한다. 지출 원장만으로 잔액을 다시 뽑으면 완료된 송금이 반영되지 않아 같은 금액이 다시 청구된다. 3주차에 열어둔 이슈 #6이 바로 이 문제였고, 회의에서 조건이 확정됐다.

2. 정산 상태 전이 구현

개정안대로 3단계를 구현했다.

action전이권한거부
SENDPENDING → SENT보내는 사람(from)이미 SENT/COMPLETED → 409
CONFIRMSENT → COMPLETED받는 사람(to)아직 PENDING → 409
CANCELSENT → PENDING보내는 사람(from)COMPLETED → 409

권한 위반은 403, 상태 전이 위반은 409로 나눴다. 요청자에게 권한이 없는 경우와 현재 상태에서 불가능한 경우는 클라이언트가 다르게 처리해야 하기 때문이다.

전이 규칙 자체는 엔티티에 뒀다. 서비스가 상태를 직접 대입하면 규칙이 서비스마다 흩어진다. 엔티티가 IllegalStateException을 던지고, 서비스는 그것을 409로 변환한다.

내 정산(26)은 개정 응답에 맞춰 보낼 것 / 받을 것 / 완료로 분류해 내려주고, 요약(29)은 NOT_STARTED(한 번도 정산 안 돌린 모임) / IN_PROGRESS / DONE을 구분한다. 프론트가 모임 목록 배지에 쓰는 값이라 “정산 전”과 “진행 중”이 섞이면 안 된다.

3. 동시성 — 상태 전이 lost update

리뷰에서 걸린 문제다. 같은 정산 건에 SEND와 CONFIRM이 동시에 들어오면 두 요청이 같은 SENT 상태를 함께 읽고, 나중에 커밋되는 쪽이 앞선 결과를 덮어쓴다.

상태 전이 조회에 비관적 락을 걸었다.

1
2
3
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Settlement s where s.id = :id and s.group.id = :groupId")
Optional<Settlement> findByIdAndGroup_IdForUpdate(...)

두 번째 요청은 첫 커밋을 기다린 뒤 갱신된 상태를 읽고, 도메인 상태 가드에 걸려 409가 나간다. 락으로 순서를 만들면 나머지는 기존 상태 검증이 처리하므로, 동시성 때문에 별도 검증 로직을 만들 필요가 없다.

4. 동시성 — 예산 최초 설정

3주차에 이슈 #5로 열어뒀던 것을 이번에 닫았다. 예산 설정이 “조회해서 없으면 저장” 방식이었는데, 같은 모임에 첫 설정 요청이 동시에 오면 둘 다 “예산 없음”을 보고 INSERT를 시도해 한쪽이 group_id UNIQUE 위반으로 실패한다.

UNIQUE 제약 위에서 삽입·수정을 한 문장으로 처리하도록 바꿨다.

1
2
3
INSERT INTO budgets (group_id, amount, created_at, updated_at)
VALUES (:groupId, :amount, CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6))
ON DUPLICATE KEY UPDATE amount = :amount, updated_at = CURRENT_TIMESTAMP(6)

이 한 문장을 쓰는 데 판단이 세 개 필요했다.

갱신값을 파라미터로 다시 바인딩 — 보통 ON DUPLICATE KEY UPDATE amount = VALUES(amount)로 쓰지만, VALUES()는 MySQL 8.0.20에서 deprecated다. 권장 대체 문법(AS new ... new.amount)은 테스트에 쓰는 H2가 받지 않는다. 파라미터를 두 번 바인딩하면 운영(MySQL 8)과 테스트(H2 MySQL 모드) 양쪽에서 유효하다.

감사 컬럼을 SQL에서 직접 채움 — 네이티브 쿼리는 JPA 감사(AuditingEntityListener)를 우회한다. created_at을 INSERT 경로에서만 설정하고 수정 경로에서는 건드리지 않아야, 예산을 수정할 때 최초 설정 시각이 지워지지 않는다.

검증 위치 — 엔티티 생성을 거치지 않으니 금액 검증(0 이상, DECIMAL(12,2) 범위)이 통째로 빠진다. 엔티티의 검증 메서드를 공개해 서비스에서 먼저 호출했다. 규칙의 출처는 여전히 엔티티 한 곳이다.

5. 회귀 테스트가 실제로 잡는지 확인

동시성 테스트는 통과해도 아무것도 증명하지 못할 수 있다. 스레드가 실제로 겹치지 않았는데 통과한 건지, 버그를 잡을 수 있는데 지금은 없어서 통과한 건지 구분이 안 된다.

그래서 서비스를 옛 구현으로 되돌려 테스트를 돌렸다. 동시 최초 설정 테스트만 실패하고, 실패 원인이 이슈에 적어둔 것과 정확히 일치했다.

1
2
Unique index or primary key violation: PUBLIC.BUDGETS(GROUP_ID) VALUES (3)
insert into budgets (amount,created_at,group_id,updated_at,id) values (?,?,?,?,default)

나머지 세 개는 옛 구현에서도 통과했다. 원자성만 겨냥한 테스트라 의도한 결과다. 이 확인을 거친 뒤에 테스트를 근거로 삼았다.

테스트 작성에서 걸린 문제도 있었다. 동시성을 재현해야 하므로 테스트 트랜잭션 롤백을 쓸 수 없고(다른 스레드에서 픽스처가 안 보인다), 그러면 정리를 직접 해야 한다. 처음엔 정리를 네이티브 SQL로 했는데 groups가 예약어라 백틱 인용이 H2에서 다르게 해석돼 tearDown이 깨졌다. 정리가 안 되니 다음 테스트가 이메일 중복으로 연쇄 실패했다. JPA 삭제로 바꿔 해결했다.

6. 컨트롤러 웹 계층 테스트

서비스·알고리즘 단위 테스트는 3주차에 붙였지만, HTTP 경계는 비어 있었다. 상태 코드 매핑, 파라미터 바인딩, @Valid, JSON 직렬화는 서비스 테스트로 검증되지 않는 층이다.

정산 12개, 예산 5개를 붙였다. 200/400/403/404/409가 실제로 그 상태로 나가는지, 지원하지 않는 status 값이 400으로 거부되는지, 필수 헤더가 없을 때 어떻게 되는지를 확인했다. 단언도 “200이면 통과”가 아니라 응답 본문의 값까지 보게 구체화했다.

현재 테스트는 63개다.

7. 남은 작업

내 파트에서 아직 열지 않은 항목과 착수 조건을 정리했다.

남은 작업착수 조건
23 개인별 잔액, 24 최적화 실행 엔드포인트지출 집계 연결
39 사용액·잔여, 40 예산 초과 예측지출 집계 연결
#6 재정산 중복 청구 방지, #7 참여자 소속 검증위 잔액 산출 경로에서 함께
#15 요청자 식별 헤더 제거인증 적용
#8 그룹 멤버십 인가인증 적용 + 멤버십 조회 수단

이 중 #15는 내가 만든 임시 처리를 걷어내는 일이다. 인증이 붙기 전까지 요청자를 X-User-Id / X-Participant-Id 헤더로 받고 있는데, 헤더는 클라이언트가 조작할 수 있으니 다른 사람으로 위장할 수 있다. 리뷰에서도 IDOR로 지적됐다. 지금은 개발용으로 API가 열려 있어 실질 노출은 없지만, 인증이 들어오면 반드시 걷어내야 한다. 임시 처리라는 걸 주석과 이슈 양쪽에 남겨뒀다.

하나 더 정리해둔 것은, 존재하지 않는 모임 ID로 요청하면 FK 위반이 그대로 올라와 500이 난다는 점이다. 404로 바꾸려면 모임 존재 확인이 필요한데, 멤버십 인가(#8)를 붙일 때 모임 조회가 들어온다. 같은 조회를 두 번 만들지 않으려고 그 이슈 범위에 넣었다. 인가 순서도 함께 적었다 — 모임 없음(404)을 멤버 아님(403)보다 먼저 판정해야 한다. 순서가 반대면 존재하지 않는 모임에 403이 나가면서 “권한만 없으면 그 모임은 있다”는 정보가 새어나간다.

This post is licensed under CC BY 4.0 by the author.