Post

[ECC 2026 Summer Project] SplitOpt - Week 5

[ECC 2026 Summer Project] SplitOpt - Week 5

기간: 2026. 08. 10 – 08. 16 (5주차)

내용: prod 부팅 실패, 인증 연동, 프론트엔드 연동 시작, 응답 계약 정리

파트: 백엔드


프론트엔드 연동이 시작된 주다. 서버 단독으로 호출할 때는 드러나지 않던 문제가 이 시점에 몰려 나왔고, 4주차에 남겨둔 임시 처리도 인증이 붙으면서 걷어냈다.

1. prod 프로파일 부팅 실패

배포 서버가 부팅되지 않았다. 남은 로그는 한 줄이다.

1
Circular depends-on relationship between 'flyway' and 'entityManagerFactory'

팀에서는 Spring Boot 4.1.0과 Flyway의 호환 문제로 보고 버전 다운그레이드가 거론됐다. jar를 직접 실행하며 설정을 하나씩 바꿔 확인한 결과, 원인은 버전이 아니라 application.properties의 spring.jpa.defer-datasource-initialization=true 한 줄이었다.

이 값은 로컬 H2에서 data.sql 실행 순서를 맞추려고 넣은 것인데, application.properties는 로컬 전용 파일이 아니라 모든 프로파일의 베이스라 prod에도 그대로 상속된다.

  • 기본 프로파일: Flyway가 꺼져 있어 Flyway 빈 자체가 없으므로 순환이 성립하지 않는다
  • prod: Flyway가 켜지면서 Hibernate와 Flyway가 서로의 초기화를 기다린다

그래서 로컬에서는 정상이고 prod에서만 부팅이 실패한다. 같은 성격의 누수가 하나 더 있었다. spring.h2.console.enabled=true도 공통 파일에 있어 운영에서 H2 콘솔이 켜진다. prod 설정에 세 값을 명시해 로컬 설정이 prod로 넘어가지 않게 했다.

1
2
3
spring.jpa.defer-datasource-initialization=false
spring.sql.init.mode=never
spring.h2.console.enabled=false

버전 문제로 결론 내고 다운그레이드했다면 원인은 그대로 남고 지원이 끝난 버전을 쓰게 된다. 증상만 보고 의심 가는 구성 요소를 먼저 바꾸는 것보다, 재현 조건을 좁혀 원인을 확정하는 편이 빨랐다.

2. 요청자 식별을 인증 principal로 교체

4주차에 남은 작업으로 적어둔 #15를 닫았다. 인증이 붙기 전까지 요청자를 X-User-Id, X-Participant-Id 헤더로 받고 있었는데, 헤더는 클라이언트가 조작할 수 있어 다른 참여자로 위장해 송금 완료·확인을 누를 수 있다. 로그인이 적용됐으므로 요청자를 인증 principal에서만 얻도록 바꿨다.

걷어내는 과정에서 범위가 더 큰 문제를 확인했다. 모임 하위 API가 경로에서 groupId만 받고, 호출자가 그 모임의 참여자인지 검증하지 않고 있었다. 로그인만 하면 groupId를 아는 누구나 다른 모임의 정산을 조회할 수 있는 상태다.

GroupAccessGuard를 만들어 정산 24~29와 잔액 23, 예산 38~40의 진입점에 걸었다. 없는 모임은 404, 참여자가 아니면 403으로 모임 상세(7)와 같은 규칙을 따른다. 판정 순서는 4주차에 정리한 대로 모임 없음(404)이 멤버 아님(403)보다 앞이다. 순서가 반대면 존재하지 않는 모임에 403이 나가면서 모임의 존재 여부가 노출된다.

내 정산(26)은 비참여자에게 빈 목록 대신 403을 준다. 빈 응답은 다른 모임에 대해 “당신의 정산이 없다”는 사실을 알려주는 응답이 된다.

3. 정산 조회 정렬과 N+1

정산 목록 조회(25·26·28)에 정렬이 없어 DB가 임의 순서로 반환하고 있었다. 최적화 재실행(24)이 PENDING을 삭제 후 재삽입하므로, 같은 내용의 목록이 실행할 때마다 다른 순서로 내려갈 수 있다. 금액 내림차순, 동액은 id 오름차순으로 고정했다.

같은 쿼리에서 참여자와 사용자를 fetch join으로 함께 읽도록 했다. 참여자는 표시 이름 없이 생성되어 user.getName()으로 폴백하는데, 둘 다 지연 로딩이라 이름을 만들 때마다 추가 쿼리가 나갔다.

상태 전이(27)의 잠금 조회에는 fetch join을 넣지 않았다. 잠금 조회에 조인을 걸면 조인된 참여자·사용자 행까지 함께 잠겨 무관한 요청이 막히거나 잠금 순서가 엇갈릴 수 있다. 단건이라 지연 로딩 비용도 작다.

4. 검증하지 않는 테스트 수정

정렬 계약을 고정하면서 기존 테스트를 확인한 결과, 동액 타이브레이크를 검증한다고 작성해둔 테스트가 실제로는 그 규칙을 확인하지 않고 있었다.

재실행 테스트의 잔액이 30000 / 20000 / 10000이라 동액이 발생하지 않는다. 금액 내림차순만 맞으면 통과하므로 id 타이브레이크를 제거해도 결과가 같다. 20000원 두 건이 나오는 잔액으로 바꾸고, 정렬 계약을 직접 확인하는 단언으로 교체했다.

같은 테스트에 전제 검증도 빠져 있었다. 재실행 테스트는 “기존 PENDING을 지우고 새 id로 다시 넣는다”를 전제로 정렬을 보는데, 재실행이 no-op이 되면 순서가 흔들릴 일이 없어져 아래 단언이 아무것도 검증하지 못한다. 재실행 전후 id가 겹치지 않는지를 먼저 단언하도록 고쳤다.

4주차에 쓴 “고치기 전 구현으로 되돌려 테스트가 실패하는지 확인하는” 방법을 기존 테스트에 적용해 나온 결과다. 테스트 개수보다 그 테스트가 무엇을 못 잡는지가 판단 기준이 된다.

5. 프론트엔드 연동에서 드러난 문제

연동 첫날 브라우저에서 오는 요청이 전부 차단됐다. CORS 설정이 없었기 때문이다. curl과 Postman은 CORS를 보지 않으므로 서버 쪽 호출에서는 드러나지 않는다.

허용 출처는 코드에 두지 않고 cors.allowed-origins 프로퍼티로 뺐다. 배포 도메인이 바뀌어도 환경변수로 대응하기 위해서다. 허용 헤더는 처음에 Authorization, Content-Type 두 개로 좁혔다가 풀었다. 프론트가 헤더를 추가할 때마다 막히는데, 허용 출처를 이미 명시한 상태에서 헤더까지 좁혀 얻는 이득이 크지 않다.

지출 등록은 브라우저에서 500으로 실패했다. 원인이 둘이고 모두 요청 본문 검증 전에 발생했다.

  • 결제자·요청자를 ?payerId=, ?requesterId= 쿼리 파라미터로 요구했다. 개정안 C-2가 정한 “결제자는 로그인 사용자로 서버가 고정”과 어긋나 프론트가 보내지 않았고, 보낸다 해도 다른 참여자로 위장할 수 있어 권한 규칙이 성립하지 않는다.
  • 날짜를 시각까지 있는 값으로 요구했다. 화면 입력이 날짜뿐이라 프론트는 yyyy-MM-dd를 보내고, 이 값은 파싱에 실패한다.

API를 expenseDate(날짜)로 받고 돌려주며, 저장은 기존 spent_at 컬럼에 그날 00:00으로 한다. 스키마는 그대로다. 함께 읽을 수 없는 본문과 파라미터 바인딩 실패를 400으로 내리는 핸들러를 추가했다. 이전에는 잘못된 요청에도 “서버 오류가 발생했습니다”만 나가 프론트가 원인을 확인할 수 없었다.

6. 응답 계약 정리

프론트가 개정안 기준으로 연동을 마친 상태라 필드명이 어긋나는 지점이 여러 개 나왔다.

  • 모임 목록(6): memberCount → participantCount, totalExpense → totalAmount, settledStatus → settlementStatus
  • 정산: id → settlementId
  • 잔액: paid / owed → paidAmount / burdenAmount (참여자별 정산 현황(16)이 이미 쓰는 이름)

정산·잔액 목록은 배열을 그대로 내려주고 있어 화면이 필요한 건수 요약을 담을 자리가 없었다. {settlements, transactionCount, completedCount, pendingCount} 형태로 감쌌다. 서비스 반환 타입은 그대로 두고 컨트롤러에서만 감쌌는데, 참여자별 정산 현황(16)과 모임 목록이 이 서비스 메서드를 직접 호출하기 때문이다.

이름을 정할 때 기준이 갈린 항목도 있었다. 명세 세부 페이지는 정산의 from/to를 fromUserId로 적지만, 3주차 회의록 스키마와 모임 상세(7)의 각주는 group_participants.id로 확정했다. 세부 페이지가 더 낡은 세대라고 판단해 participantId를 유지했다. 문서가 여러 벌 있으면 어느 것이 최신인지부터 확인해야 한다.

7. 환경별 스키마 차이로 생긴 버그

지출 수정이 운영에서만 500으로 실패했다. 부담자를 그대로 두고 금액만 바꾸는 경우가 여기 해당해, 가장 흔한 수정이 막혀 있었다.

기존 부담 내역을 지우고 새로 저장하는 코드인데, Hibernate는 한 번의 flush에서 INSERT를 DELETE보다 먼저 실행한다. 새 INSERT가 아직 지워지지 않은 기존 행과 부딪혀 uk_share_expense_participant 제약을 위반한다. 삭제 직후 flush해 DELETE를 먼저 내보내는 것으로 고쳤다. 벌크 삭제(JPQL delete)로 바꾸는 방법도 있으나 영속성 컨텍스트를 우회해, 같은 트랜잭션에 남은 ExpenseShare가 이미 지워진 Expense를 가리키는 상태를 만들어 모임 삭제 경로가 깨진다.

로컬과 CI에서 잡히지 않은 이유는 스키마가 환경마다 달라서다. 운영 MySQL은 Flyway V1로 만들어져 이 제약이 있지만, 로컬·테스트 H2는 엔티티로 스키마를 만드는데 ExpenseShare만 제약 선언이 빠져 있었다. 선언을 추가해 세 환경의 스키마를 맞췄다.

같은 성격으로, 예산 저장이 MySQL 문법(ON DUPLICATE KEY UPDATE)을 쓰는데 로컬 H2에만 MODE=MySQL이 빠져 있어 로컬에서 예산 기능이 동작하지 않았다. 테스트 프로파일에는 이미 있던 설정이다. 두 건 모두 환경별 스키마 차이에서 나온 문제이므로, 상시 검증 수단은 6주차 작업으로 넘겼다.

8. 예산 단위 구현

명세 38·39·40이 정의한 budgetType(TOTAL / PER_PERSON)이 구현돼 있지 않았다. 프론트가 PER_PERSON을 보내도 Jackson이 모르는 필드로 버리고 금액을 그대로 모임 전체 예산으로 썼다. 1인당 50만원짜리 2인 모임의 총예산이 100만원이 아니라 50만원으로 잡힌다.

budgets에 budget_type을 추가하고(기존 행은 TOTAL) 요청·응답에 반영했다. PER_PERSON이면 총예산 = 금액 × 활성 참여자 수이며, 사용률·잔여·초과 판정은 모두 총예산 기준이다. budgetType을 보내지 않으면 TOTAL로 보므로 이전 요청 형태도 그대로 동작한다.

초과 예측(40)을 명세 필드에 맞추는 과정에서 테스트가 계산 순서 문제를 잡았다. 예상 총액을 “반올림한 하루 평균 × 전체 일수”로 구하면, 기간이 끝나 더 쓸 일이 없는데도 예상 총액이 사용액과 어긋난다. 반올림을 마지막에 한 번만 하도록 바꿨다.

현재 테스트는 245개다.

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