[BE 테크스펙] 택시팟 매칭 도메인 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki
배경 (Background)
-
프로젝트 목표 (Objective): 출발지 · 도착지 · 출발 시각이 같은 사용자를 서버가 자동으로 묶어 택시비를 나눠 낼 수 있게 한다. 사용자는 조건만 입력하고, 팟을 찾고 만드는 일은 서버가 한다.
- 핵심 결과 (Key Result) 1: 매칭 시작 요청 중 2명 이상이 모여 운행 시작까지 도달한 비율 측정
- 핵심 결과 (Key Result) 2: 운행 시작 → 정산 완료 전환율 90% 이상
-
문제 정의 (Problem):
- 택시를 모집할 때 자신의 니즈에 맞는 다른 사용자를 찾는 과정은 매우 번거롭고 어려운 일입니다.
- 모르는 사람과의 택시 동행이 이루어진다고 하더라도, 사람들의 신원을 알지 못하기 때문에 선결제·정산에 대한 두려움이 앞섭니다.
-
가설 (Hypothesis): 출발지·목적지·출발 시간이 같은, 신원이 확보된 사용자들을 매칭시켜주고, 그 과정에서 택시비 기록, 송금 인증을 서버에서 관여한다면 사용자들은 모르는 사람들과의 택시팟에 참여할 것이고, 지속적인 참여를 위해 부정한 행동을 하지 않을 것이다.
-
관련 자료:
목표가 아닌 것 (Non-goals) (Optional)
이번 프로젝트에서 다루지 않는 내용:
- 채팅 메시지 조회 · 전송 · 읽음: 채팅 도메인 소관입니다. 택시팟은 시스템 메시지를 만들기만 하고, 메시지 타입 정의와 전송 계약은 채팅 문서를 따릅니다.
- 장소 검색 · 위치 미세 조정: 프론트가 지도 SDK를 직접 호출합니다. API가 없습니다.
- 매칭 조건 고도화 (허용 반경 · 시간창): 초기 구현은 출발지 · 도착지 · 출발 시각의 완전 일치입니다.
- 동승자 위치 기반 도착 감지: 방장의 위치만 받고, 다른 참여자들의 위치 정보는 활용하지 않습니다.
- 정원이 찼을 때 출발 시각 전 운행 시작 허용 여부: 현재는 출발 시간이 도달한 이후에만 운행 시작 트리거를 제공하고, 추후 사용자의 불편의 원인이 되는 시점에 수정합니다.
설계 및 기술 자료 (Architecture and Technical Documentation)
상태 전이
택시팟의 상태는 두 축입니다. 하나로 읽으면 화면이 맞지 않습니다.
① companions.status — 동행 1건의 생명주기
POST /taxi-pots
│
▼
RECRUITING ◄──── 정원 미달 복귀 (참여자 나가기)
│ │
│ └──────────────► CANCELED
│ · 마지막 1명 나가기
│ · 출발 시각 +12h 배치
│
│ PATCH {status:'IN_PROGRESS'} · 방장 · 2명 이상 · 출발 시각 이후
▼
IN_PROGRESS
│ PATCH {status:'COMPLETED'} · 방장
▼
COMPLETED ──► 정산 도메인
② companion_participants.outcome_status — 참여 1건의 결말
PENDING ──► INCOMPLETE (나가기 · 자동 취소)
└────► COMPLETED (정산 완료)
생성 ─────────────── departure_at ─────────────── +12h
│ │ │
│ 모집만 가능 │ 운행 시작 가능 │ 배치 자동취소
│ (시작 불가) │ (2명 이상일 때) │ → CANCELED
화면 ↔︎ 저장 상태
| 화면 | 판정식 |
|---|---|
TAXI-006 매칭 대기 |
status='RECRUITING' AND current_count < capacity |
TAXI-007 매칭 완료 |
status='RECRUITING' AND current_count = capacity |
RIDE-001 운행 시작 확인 카드 |
status='RECRUITING' AND current_count >= 2 AND departure_at <= NOW() + 방장만 |
| 운행 중 | status='IN_PROGRESS' |
PAY-001 운행 종료 확인 카드 |
서버가 도착 감지로 시스템 메시지 생성. 버튼은 방장만 활성 |
PAY-001 COMPLETED_PENDING_METER |
status='COMPLETED' AND settlements 행 없음 |
| 그 이후 | settlements.status |
- 모집 마감은 저장 상태가 아닙니다.
current_count = capacity로 계산하므로 한 명이 나가면 다시 모집 중인 상태로 돌아갑니다.
데이터베이스 스키마 (ERD)
-
택시팟 · 카풀 · 동행모집은 만드는 방법만 다르고 결과물이 같으므로
companions한 테이블을 씁니다. 택시팟은kind = 'TAXI_POT'입니다. -
주요 테이블:
companions: 동행 1건 (종류, 출발지 · 도착지 좌표, 출발 시각, 정원, 현재 인원, 상태, 방장)companion_participants: 참여자 (동행 ID, 사용자 ID, 완주 여부, 참여 · 종료 시각)chat_rooms: 동행 1건에 대한 채팅방settlements: 동행 1건에 대한 정산
API 명세 (API Specifications)
-
목차:
- 진행 중인 택시팟 조회 API
- 택시팟 매칭 시작 API
- 택시팟 상세 조회 API
- 운행 상태 전이 API
- 매칭 나가기 API
- 자동 취소 (배치 - 엔드포인트 아님)
- 도착 자동 감지 (WebSocket - 엔드포인트 아님)
- 정산 시작 API
- 정산 상태 조회 API
- 미터기 사진 교체 API
- 정산 진행 API
- 송금 인증 제출 API
- 송금 인증 사진 조회 API
- 정산 타임아웃 (배치 - 엔드포인트 아님)
-
공통:
- 택시팟 매칭은 회원에게만 제공하는 서비스
- 전부
Authorization: Bearer {access_token}필수
진행 중인 택시팟 조회 API
-
API 명세:
GET /users/me/current-taxi-pot- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=224571766#gid=224571766
-
권한 :
- 로그인한 사용자 (본인)
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
TaxiPotController |
getMyCurrent() |
TaxiPotService |
findMyCurrent(Long userId) |
CompanionParticipantRepository |
findCurrentTaxiPot(Long userId) |
- 처리 로직:
companion_participants에서user_id = me AND outcome_status = 'PENDING'인 행을companions와 조인해kind = 'TAXI_POT'인 것 1건 조회idx_user_outcome으로 사용자의PENDING행만 좁힌 뒤kind확인
- 있으면
200 + data: { id, chat_room_id, status, current_count, capacity } - 없으면
200 + data: null- 클라이언트는null이면 모달 없이 진행, 값이 있으면 매칭 제한 모달 표시
- 용도:
TAXI-001매칭 제한 모달 분기. 한 사용자는 동시에 하나의 택시팟에만 참여 가능 - 정산 중(
status='COMPLETED')인 팟도PENDING참여자가 있으므로 반환됨. 정산 완료 때까지 새 매칭을 막는 것은 의도된 동작
택시팟 매칭 시작 API
-
API 명세:
-
권한 :
- 로그인 + 정산 계좌 등록자
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
TaxiPotController |
start(TaxiPotStartRequest request) |
TaxiPotService |
start(Long userId, TaxiPotStartCommand command) |
BankAccountQueryPort |
hasBankAccount(Long userId) |
CompanionParticipantRepository |
existsPendingTaxiPot(Long userId) (FOR UPDATE) |
CompanionRepository |
findMatchable(...) · tryJoin(Long id) · save(Companion c) |
ChatRoomRepository |
save(ChatRoom room) |
SystemMessagePort |
enter(chatRoomId, userId) |
- 요청 (Request Body):
origin_name·origin_lat·origin_lng·dest_name·dest_lat·dest_lng·departure_at전부 필수 - 요청 DTO:
TaxiPotStartRequest(record)
| 필드 | 애노테이션 | reason |
|---|---|---|
origin_name dest_name |
@NotBlank · @Size(max=100) |
REQUIRED · MAX_LENGTH |
origin_lat dest_lat |
@NotNull · @DecimalMin(-90) · @DecimalMax(90) · @Digits(3,6) |
REQUIRED · OUT_OF_RANGE |
origin_lng dest_lng |
@NotNull · @DecimalMin(-180) · @DecimalMax(180) · @Digits(3,6) |
REQUIRED · OUT_OF_RANGE |
departure_at |
@NotNull · RFC 3339 |
REQUIRED · INVALID_FORMAT |
capacity와transport_type은 받지 않습니다. 택시팟은 4인 ·TAXI고정입니다.- 처리 로직:
- 형식 검증 → 실패 시
422 - 비즈니스 검증 →
422departure_at이 현재 이전 →DEPARTURE_TIME_PASSEDdeparture_at이 현재 + 3시간 초과 →DEPARTURE_TIME_TOO_FAR- 출발지 = 도착지 →
SAME_ORIGIN_DEST - 정산 계좌 미등록 →
BANK_ACCOUNT_REQUIRED
- 중복 참여 확인 -
PENDING인 택시팟 참여가 있으면409 MATCH_ALREADY_IN_PROGRESSFOR UPDATE로 잠가 검사와 생성 사이에 다른 요청이 끼어들지 못하게 함
- 매칭 - 조건이 맞는
RECRUITING팟이 있으면 합류, 없으면 새로 만듦- 조건:
kind='TAXI_POT' AND status='RECRUITING' AND current_count < capacity AND 출발지 좌표 · 도착지 좌표 · departure_at 완전 일치 - 합류:
UPDATE companions SET current_count = current_count + 1 WHERE id = ? AND current_count < capacity AND status = 'RECRUITING'→ 합류할 매칭이 없으면 다음 후보 또는 신규 생성 - 신규:
companions(creator_id = host_id = me,capacity = 4,current_count = 1) +chat_rooms+companion_participants생성
- 조건:
- 입장 시스템 메시지 생성
- 응답 -
201 Created+Location: /taxi-pots/{id}+data: { id, chat_room_id, status, current_count, capacity }
- 형식 검증 → 실패 시
- 합류든 신규든
201입니다 - 어느 경우든companion_participants행이 생기고Location으로 접근 가능한 URI를 알려줍니다. 409를 받으면 클라이언트는 진행 중인 택시팟 조회 API로 id를 얻어TAXI-006으로 이동합니다.
택시팟 상세 조회 API
-
API 명세:
GET /taxi-pots/{companion_id}- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=224571766#gid=224571766
-
권한 :
- 해당 택시팟 비참여자 · 없는 id ·
kind가TAXI_POT이 아니면 전부404 NOT_FOUND
- 해당 택시팟 비참여자 · 없는 id ·
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
TaxiPotController |
get(Long taxiPotId) |
TaxiPotService |
find(Long userId, Long taxiPotId) |
CompanionRepository |
findTaxiPotForParticipant(Long id, Long userId) |
- 요청 (Path):
taxi_pot_id - 처리 로직:
companions를companion_participants와 조인해id = ? AND kind = 'TAXI_POT' AND 참여자 = me조회- 없으면
404 NOT_FOUND - 응답 -
200 + data: { id, chat_room_id, status, origin_name, dest_name, departure_at, current_count, capacity, host_id }
운행 상태 변경 API
-
API 명세:
PATCH /taxi-pots/{companion_id}- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=224571766#gid=224571766
-
권한 :
- 해당 택시팟의 방장. 참여자이지만 방장이 아니면
403 HOST_ONLY, 비참여자는404
- 해당 택시팟의 방장. 참여자이지만 방장이 아니면
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
TaxiPotController |
transition(Long taxiPotId, TaxiPotTransitionRequest request) |
TaxiPotService |
transition(Long userId, Long taxiPotId, CompanionStatus target) |
CompanionRepository |
findTaxiPotForParticipant · startRide(...) · completeRide(...) (조건부 UPDATE) |
SystemMessagePort |
rideStarted(chatRoomId) · meterRequested(chatRoomId) |
- 요청 (Path):
companion_id - 요청 (Request Body):
status-IN_PROGRESS또는COMPLETED - 전이 조건:
| 전이 | 화면 | 조건 |
|---|---|---|
RECRUITING → IN_PROGRESS |
RIDE-001 운행 시작 |
방장 · current_count >= 2 · departure_at <= NOW() |
IN_PROGRESS → COMPLETED |
PAY-001 운행 종료 확인 |
방장 |
- 처리 로직:
- 형식 검증 -
status가 허용 값이 아니면400 - 참여 확인
- 비참여자 · 없는 id →
404 - 참여자인데 방장이 아님 →
403 HOST_ONLY
- 비참여자 · 없는 id →
- 조건부 UPDATE - 한 문장으로 전이와 중복 방지를 함께 처리
- 형식 검증 -
UPDATE companions SET status = 'IN_PROGRESS'
WHERE id = ? AND kind = 'TAXI_POT' AND status = 'RECRUITING' AND host_id = ?
AND current_count >= 2 AND departure_at <= NOW(6);
1. 영향받은 행이 0이면 사유를 `SELECT`로 판별 → `409 INVALID_STATE_TRANSITION` / `409 NOT_ENOUGH_PARTICIPANTS` / `409 DEPARTURE_NOT_REACHED`
4. 시스템 메시지 생성 (같은 트랜잭션)
- `→ IN_PROGRESS`는 `SYSTEM_RIDE_STARTED`
- `→ COMPLETED`는 `SYSTEM_METER_REQUESTED`
5. 응답 - `200 + data: { id, chat_room_id, status, origin_name, dest_name, departure_at, current_count, capacity, host_id }`
- 정원 충족은 조건이 아닙니다. 택시는 4명이 아니어도 운행합니다. 혼자만 아니면 되고, 출발 시각이 지났으면 됩니다.
NOT_ENOUGH_PARTICIPANTS와DEPARTURE_NOT_REACHED를 나눈 이유 - 혼자일 땐 기다리는 것 말고 할 게 없지만, 시간 전이면 기다리면 버튼이 켜집니다.- 상태 전이가 한 번만 성공하므로 시스템 메시지도 한 번만 생깁니다.
COMPLETED전이는 정산 행을 만들지 않습니다. 정산은 미터기 사진 전송 시점에 생성됩니다.
매칭 나가기 API
-
API 명세:
DELETE /taxi-pots/{companion_id}/participants/me- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=224571766#gid=224571766
-
권한 :
- 해당 택시팟 참여자. 비참여자는
404
- 해당 택시팟 참여자. 비참여자는
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
TaxiPotController |
leave(Long taxiPotId) |
TaxiPotService |
leave(Long userId, Long taxiPotId) |
CompanionRepository |
findTaxiPotForParticipant · decrementCount · changeHost · cancel |
CompanionParticipantRepository |
markLeft(...) · findEarliestPending(companionId) |
SettlementQueryPort |
existsUnsettled(companionId) |
ChatRoomRepository |
close(chatRoomId) |
SystemMessagePort |
leave · hostChanged · canceled |
- 요청 (Path):
companion_id - 처리 로직:
- 참여 확인 - 없으면
404 NOT_FOUND - 차단 조건 →
409status = 'IN_PROGRESS'→RIDE_IN_PROGRESSstatus = 'COMPLETED'이고 정산 미완료 →SETTLEMENT_IN_PROGRESS
- 참여 종료 -
outcome_status = 'INCOMPLETE',left_at = NOW(6)- 이미
COMPLETED인 행은 덮어쓰지 않음. 정산 완료 후 나가는 사람은 완주
- 이미
- 택시팟 현재 인원 - 1 →
current_count - 1 - 방장이 나갔으면 남은
PENDING참여자 중joined_at이 가장 이른 사람에게 승계 +SYSTEM_HOST_CHANGED - 마지막 1명이 나갔으면
status = 'CANCELED'+chat_rooms.closed_at SYSTEM_LEAVE메시지- 응답 -
204 No Content
- 참여 확인 - 없으면
- 정원이 찬 뒤 나가면 다시 모집 가능한 상태가 됩니다. 모집 마감을 상태로 저장하지 않기 때문입니다.
자동 취소 (배치)
-
트리거: 스케줄러 (주기 10분,
TaxiPotAutoCancelScheduler) -
대상:
kind = 'TAXI_POT' AND status = 'RECRUITING' AND departure_at < NOW(6) - INTERVAL 12 HOUR -
처리 로직 (팟 1건당 하나의 트랜잭션):
companions.status = 'CANCELED',current_count = 0companion_participants—PENDING행 전부INCOMPLETE,left_at = NOW(6)chat_rooms.closed_at = NOW(6)SYSTEM_CANCELED메시지
-
엔드포인트가 아니지만 명세에 적는 이유 — 클라이언트가 관측하는 변화입니다. 진행 중인 택시팟 조회가
data: null로 바뀌고, 채팅 목록에서 방이 사라집니다. -
RECRUITING만 취소합니다.IN_PROGRESS·COMPLETED는 정산이 걸려 있어 건드리지 않습니다. -
출발 시각 기준인 이유 — "이미 지나간 약속"이라는 사실이 기준이 되어야 합니다. 생성 시각 기준이면 3시간 뒤 출발과 10분 뒤 출발이 같은 수명을 갖습니다.
-
TAXI-006[4] "자동 취소는 없음"과 어긋납니다. 그 문구는 모집 중 서비스가 임의로 매칭을 끊지 않는다는 뜻으로 읽었고, 화면 문서 정정 대상입니다.
도착 자동 감지
운행 종료 확인 카드는 두 경로로 생성되며, 먼저 도착한 쪽이 만듭니다. 이미 카드가 있으면 만들지 않습니다.
| 경로 | 트리거 | 역할 |
|---|---|---|
| ① 위치 감지 | 방장이 보낸 좌표가 도착지 반경 500m 안에서 10초 유지 | 더 일찍 띄우는 최적화 |
| ② ETA 도달 | 배치가 NOW() >= eta_at인 팟을 찾음 |
기본. 위치 정보가 안 와도 종료 확인 카드가 뜨게 하는 트리거 |
① 위치 감지 (WebSocket)
- 채널: 채팅 WebSocket에 메시지 타입 1개 추가. 새 REST 엔드포인트 없음
- 발신 조건:
companions.status = 'IN_PROGRESS'이고 발신자가host_id일 때만. 10초 주기 - 클래스 배치:
| 클래스 | 메서드 |
|---|---|
LocationMessageHandler |
onHostLocation(chatRoomId, userId, lat, lng) (채팅 WS 핸들러) |
ArrivalDetector |
check(companionId, lat, lng) |
SystemMessagePort |
arrivalConfirmRequested(chatRoomId) |
-
처리 로직:
- 발신자가 방장이고 팟이
IN_PROGRESS인지 확인. 아니면 무시 ST_Distance_Sphere(dest_location, 클라이언트로부터 수신한 현재 좌표) <= 500판정- 500m 이내가 10초 연속 유지되면 카드 생성
- 좌표는 저장하지 않음. 판정 후 버림
- 발신자가 방장이고 팟이
-
방장만 보내는 이유 - 결제자라 도착 시점에 화면을 보고 있을 가능성이 높고, 발신자가 1명이면 관리 용이
② ETA 폴백
-
eta_at채우는 시점:→ IN_PROGRESS상태 변경 커밋 후 비동기. 운행당 1회POSThttps://apis-navi.kakaomobility.com/v1/directionsAuthorization: KakaoAK {REST_API_KEY}- 성공 → eta_at = NOW(6) + duration
- 실패 → eta_at = NOW(6) + 60분 (상한 폴백)
- 운행 상태 변경 트랜잭션에서는 eta_at을 채우지 않습니다.
- 호출이 실패해도 상한 폴백으로 값을 채웁니다.
-
클래스 배치:
| 클래스 | 메서드 |
|---|---|
EtaRefresher |
refresh(Long companionId) - afterCommit에서 비동기 호출. 실패 시 상한값 기록 |
KakaoNaviClient |
estimateDuration(origin, dest) |
TaxiPotArrivalScheduler |
sweep() - 자동 취소 배치와 같은 스케줄러 (10분 주기) |
- 배치 조건:
SELECT id FROM companions
WHERE kind = 'TAXI_POT' AND status = 'IN_PROGRESS' AND NOW(6) >= eta_at;
- 길찾기 호출을 트랜잭션 밖에 두는 이유 - 카카오모빌리티 장애가 운행 시작을 롤백시키면 안 됩니다. 유저 도메인에서 카카오 unlink를 트랜잭션 밖으로 뺀 것과 같은 판단입니다.
- 재시도 큐를 두지 않습니다. 실패하면 상한값이 들어가고, 그 전에 방장이 도착하면 위치 감지가 먼저 카드를 띄웁니다.
- 값이 부정확해도 되는 이유 -
eta_at은 "카드를 언제 띄울까"의 기준일 뿐이고 확정은 사람이 합니다. 일찍 뜨면 방장이 무시하면 되고 카드는 남아 있습니다. 늦게 뜨는 쪽이 나쁜데 그건 위치 감지가 커버합니다. - 카카오 모빌리티의 길찾기 REST API를 호출하여, 추정 시간을 가져옵니다.
카드 생성 공통 규칙
SystemMessagePort.existsArrivalConfirm(chatRoomId)로 중복 확인 → 있으면 생성하지 않음- "운행이 종료 됐나요?" 시스템 메시지 생성 - 전원에게 보이고 버튼은 방장만 활성 (
PAY-001[3]) - 상태 전이는 사람이 합니다. 카드는 띄울 뿐이고
→ COMPLETED는 방장이 버튼을 눌러 운행 상태 전이 API를 호출해야 일어납니다.
- 방장이 앱을 백그라운드로 두면 ①이 동작하지 않습니다. 웹의
geolocation한계이며 ②가 그 구멍을 메웁니다. - 카드가 떠도 방장이 누르지 않으면
IN_PROGRESS로 남습니다. 자동 취소 배치는RECRUITING만 대상입니다. "사람이 안 누르면 진행이 멈춘다"는 정산 종료 보장과 같은 문제이며 정산 도메인에서 함께 다룹니다. - 약관에 위치정보 항목을 추가해야 합니다. 서버가 개인위치정보를 처리하므로 이용약관 명시 · 동의가 필요합니다.
정산
운행이 끝난 뒤 방장이 선결제한 택시비를 동승자에게 청구하고, 송금을 인증받아 마무리하는 과정입니다. companions.status = 'COMPLETED'가 된 뒤에만 시작됩니다.
AI가 두 번 개입합니다. 성격이 달라 저장 위치도 다릅니다.
| 대상 | 저장 위치 | 실패 시 | |
|---|---|---|---|
| ① 미터기 OCR | 방장이 올린 미터기 사진에서 총액 추출 | settlements.meter_analysis_status |
방장이 수동 입력 |
| ② 송금 캡처 OCR | 동승자가 올린 캡처의 금액 · 수취인 · 위조 대조 | settlement_participants.analysis_status · is_forged |
동승자가 재제출 |
미터기 사진 업로드 API
-
API 명세:
POST /settlements- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=224571766#gid=224571766
-
권한 :
- 해당 택시팟의 방장. 참여자이지만 방장이 아니면
403 HOST_ONLY, 비참여자는404
- 해당 택시팟의 방장. 참여자이지만 방장이 아니면
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
SettlementController |
start(SettlementStartRequest request) |
SettlementService |
start(Long userId, Long companionId, String meterImageUrl) |
CompanionRepository |
findTaxiPotForParticipant |
UserRepository |
findById |
SettlementRepository |
save(Settlement s) |
MeterAnalysisPort |
requestAnalysis(settlementId, meterImageUrl) |
- 요청 (Request Body):
companion_id(필수),meter_image_url(필수) - 처리 로직:
- 형식 검증 → 실패 시
422 - 권한 · 상태 확인
- 비참여자 · 없는 id →
404, 방장 아님 →403 HOST_ONLY companions.status <> 'COMPLETED'→409 RIDE_NOT_COMPLETED- 이미 정산 행이 있으면 →
409 SETTLEMENT_ALREADY_STARTED
- 비참여자 · 없는 id →
- 정산 행 생성 (하나의 트랜잭션)
payee_id = host_id,payee_bank_name·payee_account_no를users에서 복사 (스냅샷)participant_count = companions.current_count(스냅샷)status='PENDING'·meter_analysis_status='ANALYZING'·analysis_started_at=NOW(6)·total_amount=NULL
- 커밋 후 AI 분석 요청 -
MeterAnalysisPort.requestAnalysis(...) - 응답 -
202 Accepted+Location: /settlements/{id}+data: { id, meter_analysis_status, analysis_started_at }
- 형식 검증 → 실패 시
- 성공 응답이
202인 이유 - 총액은 아직 없습니다. 접수만 하고 완료는 정산 상태 조회로 확인하는 비동기 패턴 - 분석 상태를 담을 행이 분석 시작 시점에 필요하기 때문에 총액이 없는 상태로 행을 만듭니다.
chk_amount_required가 "금액 없이 청구 불가"를 스키마로 막고 있어PENDING단계에서만NULL이 허용됩니다. participant_count를 이 시점에 찍어도 안전한 이유 -companions.status='COMPLETED'이면 매칭 나가기가409 SETTLEMENT_IN_PROGRESS로 막혀 인원이 변하지 않습니다.- AI 호출을 커밋 밖에 두는 이유 - AI 서버 장애가 정산 시작을 롤백시키면 안 됩니다. 실패하면
meter_analysis_status='FAILED'로 갱신되고 화면이 수동 입력을 유도합니다.
미터기 금액 인식 상태 조회 API
-
API 명세:
GET /settlements/{settlement_id}/analysis-status- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=549851087#gid=549851087
-
권한 :
- 해당 정산의 참여자. 그 외
404 NOT_FOUND
- 해당 정산의 참여자. 그 외
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
SettlementController |
getAnalysisStatus(Long settlementId) |
SettlementService |
findAnalysisStatus(Long userId, Long settlementId) |
SettlementRepository |
findForParticipant(Long id, Long userId) |
- 요청 (Path):
settlement_id - 처리 로직:
- 참여 확인 - 없으면
404 NOT_FOUND - 응답 -
200 + data: { group_settlement_status, meter_analysis_status, total_amount }
- 참여 확인 - 없으면
- 폴링 전용 엔드포인트입니다. 미터기 인식은 비동기라 클라이언트가 주기적으로 조회합니다. 새로고침하거나 화면을 이탈했다 돌아와도 서버 상태로 복원됩니다.
정산 금액 수동 입력 API
-
API 명세:
PUT /settlements/{settlement_id}/amount- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=549851087#gid=549851087
-
권한 :
- 해당 정산의 방장
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
SettlementController |
putAmount(Long settlementId, AmountRequest request) |
SettlementService |
inputAmount(Long userId, Long settlementId, int totalAmount) |
SettlementRepository |
requestPayment(...) (조건부 UPDATE) |
SettlementParticipantRepository |
saveAll(...) |
NotificationPort |
settlementRequested(...) |
SystemMessagePort |
paymentRequested(chatRoomId) |
- 요청 (Request Body):
total_amount(필수) - 숫자만, 최대 6자리, 공백 · 소수점 · 음수 불가 - 처리 로직:
- 형식 검증 → 실패 시
422 - 조건부 UPDATE -
WHERE status='PENDING'. 영향 행 0이면409 ALREADY_REQUESTEDtotal_amount기록 +meter_analysis_status='MANUAL'+status='REQUESTED'
settlement_participantsN - 1행 생성 (같은 트랜잭션) -analysis_status='PENDING'- 동승자 인당 =
total_amount / participant_count의 내림 - 방장 몫 =
total_amount − (인당 × (participant_count − 1))- 나머지를 방장이 흡수합니다
- 동승자 인당 =
SYSTEM_PAYMENT_REQUESTED메시지 - "방장이 결제를 완료했어요"SETTLEMENT_REQUESTED알림 발송 - 이 호출의 부수 효과로notifications행을 만듭니다. 채팅방 밖에 있는 동승자도 청구 사실을 알아야 합니다- 응답 -
200 + data: { participant_count, amount_per_person, group_settlement_status, meter_analysis_status }
- 형식 검증 → 실패 시
- 쓰이는 시점 - 미터기 인식이 지연되어 방장이 기다리지 않기로 했거나(10초 이후 [수동 입력하기] 버튼 클릭) 실패한 경우
- 인식이 성공한 경우는 이 API를 거치지 않습니다.
MeterRecognitionPort의 콜백이 같은 로직(2~5)을meter_analysis_status='PASSED'로 수행합니다. participant_count를 여기서 다시 세지 않습니다 - 정산 생성 시점의 스냅샷을 그대로 씁니다.
송금 인증사진 제출 API
-
API 명세:
POST /settlements/{settlement_id}/transfer-proof- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=549851087#gid=549851087
-
권한 :
- 해당 정산의 동승자 본인. 방장이 호출하면
403 PAYEE_CANNOT_SUBMIT
- 해당 정산의 동승자 본인. 방장이 호출하면
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
TransferProofController |
submit(Long settlementId, TransferProofRequest request) |
TransferProofService |
submit(Long userId, Long settlementId, String imageUrl) |
SettlementParticipantRepository |
findBySettlementIdAndUserId · markAnalyzing |
TransferAnalysisPort |
request(participantId, imageUrl) |
- 요청 (Request Body):
transfer_image_url(필수) - 처리 로직:
- 참여 확인 -
settlement_participants행이 없으면404 NOT_FOUND - 재제출 가능 여부 확인 → 불가하면
409 RETRY_LIMIT_EXCEEDED transfer_image_url기록 +analysis_status='ANALYZING'- 커밋 후 AI 검증 요청 -
TransferAnalysisPort.request(...) - 응답 -
202 Accepted+data: { participant_count, group_settlement_status, member_analysis_status, transferred_at, is_forged }
- 참여 확인 -
- 재제출은 1회만 허용합니다 -
transferred_at이NULL인지로 판단합니다. - AI 검증 결과 반영 - 별도 API 호출 없이 서버가 자동으로 전환합니다
| 결과 | 처리 |
|---|---|
| 금액 · 수취인 일치 | analysis_status='PASSED' · transfer_completed=TRUE · transferred_at=NOW(6) + SYSTEM_TRANSFER_DONE 메시지 |
| 금액 불일치 | analysis_status='FAILED' - "송금 금액이 정산 금액과 일치하지 않아요" |
| 송금자 · 수취인 불일치 | analysis_status='FAILED' - "본인이 송금한 내역만 인증할 수 있어요" |
| 위조 탐지 | analysis_status='PASSED' · is_forged=TRUE - 재제출 1회 허용 |
| OCR 실패 · 서버 오류 | analysis_status='FAILED' |
POST인 이유 - 같은 사용자가 여러 번 보낼 수 있는 요청이 아니라 1회 한정 재시도가 있는 제출이라, 멱등한 교체(PUT)보다 제출 행위(POST)가 계약에 맞습니다. 중복은 2의 상태 검사가 막습니다.
내 송금 상태 조회 API
-
API 명세:
GET /settlements/{settlement_id}/participants/me- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=549851087#gid=549851087
-
권한 :
- 해당 정산의 동승자 본인
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
TransferProofController |
getMine(Long settlementId) |
TransferProofService |
findMine(Long userId, Long settlementId) |
SettlementParticipantRepository |
findBySettlementIdAndUserId |
- 요청 (Path):
settlement_id - 처리 로직:
- 참여 확인 - 없으면
404 NOT_FOUND - 응답 -
200 + data: { participant_count, group_settlement_status, member_analysis_status, is_forged, transferred_at }
- 참여 확인 - 없으면
member_analysis_status를 그대로 내립니다.
| 값 | 의미 |
|---|---|
PENDING |
아직 인증 사진을 올리지 않음 |
ANALYZING |
업로드 완료, AI 위조 판단 진행 중 |
PASSED |
AI 분석 성공 |
FAILED |
AI 분석 실패 |
- 클라이언트는 이 응답의
transferred_at과is_forged로 재제출 버튼 활성 여부를 판정합니다.
송금 인증사진 조회 API
-
API 명세:
GET /settlements/{settlement_id}/participants/{user_id}/transfer-proof- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=549851087#gid=549851087
-
권한 :
- 방장 또는 당사자만.
- 같은 정산의 다른 동승자는
403 FORBIDDEN, 정산 비참여자는404 NOT_FOUND
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
TransferProofController |
get(Long settlementId, Long userId) |
TransferProofService |
find(Long requesterId, Long settlementId, Long targetUserId) |
SettlementRepository |
findForParticipant(Long id, Long requesterId) |
SettlementParticipantRepository |
findBySettlementIdAndUserId |
ImageAccessPort |
issueTemporaryUrl(String storedUrl) |
- 요청 (Path):
settlement_id,user_id - 처리 로직:
- 정산 참여 확인 - 요청자가 그 정산의 참여자가 아니면
404 NOT_FOUND - 열람 권한 확인 - 요청자가 방장도 당사자도 아니면
403 FORBIDDEN - 대상 참여자 행 조회 - 없으면
404 NOT_FOUND - 이미지 판정
transfer_image_url이NULL이거나 만료 · 손상 →200 + data: null- 있으면 만료형 접근 URL을 발급해 응답
- 응답 -
200 + data: { image_url, transferred_at, member_analysis_status, is_forged }
- 정산 참여 확인 - 요청자가 그 정산의 참여자가 아니면
403과404를 나눈 기준 - 같은 정산의 동승자는 그 정산의 존재를 이미 압니다. 감출 것이 없으므로 "볼 권한이 없다"는403이 정직합니다. 정산 자체의 비참여자에게는 존재를 감춰404를 줍니다.- 읽기 전용입니다. 다운로드를 제공하지 않으며,
image_url은 만료형 접근 URL이라 외부로 새어도 시간이 지나면 못 엽니다.
정산 완료 처리 API
-
API 명세:
POST /settlements/{settlement_id}/completion- API 문서 링크: https://docs.google.com/spreadsheets/d/1WLFU62fWcBZwKfZSKxbjSNSo9kyhKXRecp1C25Pqkh4/edit?gid=549851087#gid=549851087
-
권한 :
- 해당 정산의 방장
-
구현 상세 :
- 클래스 배치:
| 클래스 | 메서드 |
|---|---|
SettlementController |
complete(Long settlementId) |
SettlementService |
complete(Long userId, Long settlementId) |
SettlementRepository |
complete(...) (조건부 UPDATE) |
SettlementParticipantRepository |
completeAll(Long settlementId) |
CompanionParticipantRepository |
completeAll(Long companionId, LocalDateTime now) |
ChatRoomRepository |
close(chatRoomId) |
SystemMessagePort |
settlementCompleted(chatRoomId) |
- 요청: 본문 없음
- 처리 로직:
- 조건부 UPDATE -
WHERE status='REQUESTED'→status='COMPLETED',completed_at=NOW(6)- 영향 행 0이면
409 INVALID_STATE_TRANSITION
- 영향 행 0이면
- 마무리 (같은 트랜잭션)
transfer_completed=FALSE인 동승자 행을 전부TRUE로 확정companion_participants의PENDING행을 전부outcome_status='COMPLETED',left_at=NOW(6)chat_rooms.closed_at=NOW(6)- 방장 정산 완료 확인 시 채팅방에서 모든 사용자 나가기 처리SYSTEM_SETTLEMENT_COMPLETED메시지
- 응답 -
204 No Content
- 조건부 UPDATE -
- 동승자의 송금 상태를 조건으로 걸지 않습니다.
PASSED인데is_forged=TRUE거나 재시도를 다 썼는데도FAILED인 참여자가 있어도, 방장의 완료 처리가 조건 없이 그룹을COMPLETED로 넘깁니다. 그 참여자의member_analysis_status는 멈춘 값 그대로 남아 기록이 됩니다. 204인 이유 - 클라이언트가 이 응답으로 그릴 것이 없습니다. 화면은 곧바로 평가 모달이 뜨고, 상태 변화는 채팅 시스템 메시지로 전원에게 전달됩니다.
- 정산 타임아웃 (배치) - 정산이 방치될 때의 해제 보장이 지금은 없는 상태라 논의 필요
기술 스택 (Technology Stack)
- Backend: Java 25 / Spring Boot
- Database: MySQL 8 —
DECIMAL(9,6)좌표,POINT SRID 4326생성 컬럼,ST_Distance_Sphere - Scheduler: Spring
@Scheduled(자동 취소 · ETA 폴백) - Realtime: 채팅 WebSocket 재사용 (도착 감지)
- 외부 연동: 카카오모빌리티 길찾기 API (
apis-navi.kakaomobility.com/v1/directions)- 운행 시작 시 1회, 예상 소요 시간 조회