[BE 테크스펙] 카풀 도메인 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki
배경
-
프로젝트 목표 (Objective): 필수정보 입력만으로 카풀 등록이 간편해지고, 간단한 탑승 신청부터 수락까지 편리한 매칭 성사(요청 수락)까지의 전환율을 높인다.
- 핵심 결과 (Key Result) 1: 차량 정보·동승 선호도 등 필수 사전조건을 한 번만 등록하면, 이후 카풀 등록·요청 과정에서 재입력 없이 이어서 진행할 수 있다.
- 핵심 결과 (Key Result) 2: 카풀 요청부터 수락·거절까지의 상태 전이가 지연이나 오류 없이 정확하게 반영되어, 탑승자가 자신의 요청 결과를 즉시 확인할 수 있다.
-
문제 정의 (Problem):
- 카풀이 등록은 되어도 실제 요청-수락으로 이어지지 않아 매칭 성사율이 낮을 수 있다.
- 등록 전제조건(차량 정보, 동승 선호도 등록)이 있어, 이 단계에서 이탈하는 사용자가 있을 수 있다.
- host가 요청에 응답하지 않고 방치하는 경우 탑승자가 다른 카풀을 알아보지 못한 채 대기하게 되어 이탈로 이어질 수 있다.
-
가설 (Hypothesis): 요청·수락 흐름의 상태 전이를 명확히 하고(멱등성·동시성 보장으로 안정적인 처리), 사전 정보(차량·동승 선호도) 검증을 통해 신뢰도를 높이면 매칭 성사율이 상승할 것이다.
-
관련 자료:
- 기능 정의서/화면 기획: Figma
- ERD 링크: ERDCloud
- 테이블 정의서: GitHub Wiki #46
- 프로젝트 요구사항 정의서: GitHub Wiki #30
- API 명세서: 구글 시트, API 명세서 깃허브 문서
목표가 아닌 것 (Non-goals) (Optional)
이번 프로젝트에서 다루지 않는 내용:
- 실제 정산 로직 넣지 않는다: 실제 정산 로직은 팀프로젝트 범위에서 제외한다.
- 카풀 요청 거절 시 별도 알림을 보내지 않는다: status=REJECTED 값을 프론트에서 화면에 표시하는 방식으로 대체한다. notifications.type ENUM에 REJECTED 계열 값 추가 불필요하다.
설계 및 기술 자료 (Architecture and Technical Documentation)
데이터베이스 스키마 (ERD)
companions: kind='CARPOOL'로 생성되는 행 공용 테이블(택시팟/동행모집과 공유)companion_requests: id, companion_id, requester_id, content, status(PENDING/ACCEPTED/REJECTED), created_atcompanion_participants: 수락 시 참여자로 등록되는 테이블owned_cars: 차량 정보(카풀 등록 전제조건)ride_preferences: 동승 선호도(카풀 등록 전제조건)
API 명세 (API Specifications)
- 목차:
카풀 등록 API
-
API 명세:
POST /carpools- API 문서 링크
-
권한 :
- 로그인한 사용자
-
구현상세:
- json
{
"origin_name": "판교역",
"origin_lat": 37.394500,
"origin_lng": 127.111200,
"dest_name": "강남역",
"dest_lat": 37.497900,
"dest_lng": 127.027600,
"departure_at": "2026-09-08T08:30:00.000Z",
"recruit_count": 3
}
-
요청 (Request Body): 출발지(지역/위도/경도), 도착지(지역/위도/경도), 출발 시각(년/월/일/시/분), 모집인원(방장 제외)
- 사전조건 검증. 아래 a,b는 DTO의 @Valid로 검증이 불가한 부분이니 Service 레이어에서 별도로 처리한다.
- 차량 정보(
owned_cars) 등록 여부 → 미등록 시 422(CAR_REGISTRATION_REQUIRED) - 동승 선호도(
ride_preferences) 등록 여부 → 미등록 시 422(RIDE_PREFERENCE_REQUIRED)
- 차량 정보(
departure_at: 현재 이후 ~ 1개월 이내만 허용origin_name==dest_name이면 422(SAME_ORIGIN_DEST) , 필드 간 상호 검증이므로 Service 레이어에서 처리한다.recruit_count1~3 범위 검증, 방장 제외한 모집인원수, 카풀도 최대 4명(방장포함)으로 강제 제한
- 사전조건 검증. 아래 a,b는 DTO의 @Valid로 검증이 불가한 부분이니 Service 레이어에서 별도로 처리한다.
-
companionsinsert(kind='CARPOOL') + 채팅방(chat_rooms) 자동 생성 + 응답에chat_room_id포함 -
companionsinsert(kind='CARPOOL') 시에,- compaions 테이블 status 상태 초기화: RECURITING
- 생성시각 표시
- 생성한 유저의 id를 creator_id로 연관관계 매핑
-
트랜잭션 관리:
companionsinsert와chat_roomsinsert는 같은 트랜잭션으로 묶는다. 카풀만 생성되고 채팅방 생성이 실패하는 상황을 방지하기 위함임.
(지도) 주변 카풀/택시 핀 조회 API
-
API 명세:
GET /carpool-pins?sw_lat={}&sw_lng={}&ne_lat={}&ne_lng={}- API 문서 링크
-
권한: 로그인한 사용자
-
구현상세:
companions.origin_location에 걸린 MySQL SPATIAL INDEX(MBRContains)로 뷰포트 범위 안의 후보를 빠르게 좁힌 뒤,kind='CARPOOL'(등호),status='RECRUITING'(등호),departure_at(범위) 조건으로 추가 필터링한다.- 출발 시간이 지난 카풀은 핀에서 제거하되, 기능정의서 규칙대로 출발 시간 10분 후부터 제거한다.
- 조회 결과가 500건 초과 시 빈 배열 +
limit_exceeded: true반환 - 공간 쿼리 구현 방식: MySQL 공간 인덱스
MBRContains(g1, g2)이용하여 정밀도는 조금 떨어져도 빠르게 조회하도록 한다.
-
compaions 테이블에서 미리 만들어둔 인덱스: 뷰포트 좌표에 포함 된 카풀팟/택시팟 매칭 핀들을 인덱싱해둔다. 메인 접근 경로 이외에도 departure_at 비교, 모집중 상태를 필터로 처리 해야한다.
SELECT c.*
FROM companions c
WHERE c.kind = 'CARPOOL' -- ① 등호 조건 (필터)
AND c.status = 'RECRUITING' -- ② 등호 조건 (필터)
AND MBRContains( -- ③ 공간 인덱스 (메인 접근 경로)
ST_GeomFromText(CONCAT('POLYGON((', ...)),
c.origin_location
)
AND c.departure_at > NOW() - INTERVAL 10 MINUTE -- ④ 범위 조건 (필터)
LIMIT 501;
카풀 핀 목록 조회 API
-
API 명세:
GET /carpools?lat={}&lng={}&sw_lat={}&sw_lng={}&ne_lat={}&ne_lng={}&cursor={}- API 문서 링크
-
권한: 로그인한 사용자
-
구현 상세:
- 기능정의서 6번 [게시글 정렬 기준]
- 기본 정렬은 거리 점수와 최신성 점수를 합산한 종합 점수 순으로 표시한다
- 최신성 점수: 작성 후 24시간 이내 게시글에 높은 점수를 부여한다
- 거리 점수: 현재 위치에서 가까울수록 높은 점수를 부여한다
- 위치 정보가 없으면 거리 점수를 제외하고 최신순으로 노출한다
- 점수가 동일하면 작성일시가 최신인 게시글을 우선한다
- 위 '(지도) 주변 카풀/택시 핀 조회 API'와 함께 홈 화면 진입 시 같이 호출되는 API다.
- 미리 compaions 테이블에서 만들어둔 인덱스에서 필요에 맞게 사용자 위치 기준 거리를 계산하거나 최신 글 기준으로 필터링한다.
- 위치 정보(
lat/lng)가 있는 요청:
- 위치 정보(
- 기능정의서 6번 [게시글 정렬 기준]
SELECT c.*,
ST_Distance_Sphere(c.origin_location, ST_GeomFromText(CONCAT('POINT(', :userLng, ' ', :userLat, ')'), 4326)) AS distance_m,
(:maxDistance - ST_Distance_Sphere(c.origin_location, ST_GeomFromText(CONCAT('POINT(', :userLng, ' ', :userLat, ')'), 4326))) AS distance_score,
CASE WHEN c.created_at > NOW() - INTERVAL 24 HOUR THEN :recencyBonus ELSE 0 END AS recency_score
FROM companions c
WHERE c.kind = 'CARPOOL'
AND MBRContains(
ST_GeomFromText(CONCAT('POLYGON((', :swLng, ' ', :swLat, ',', :neLng, ' ', :swLat, ',', :neLng, ' ', :neLat, ',', :swLng, ' ', :neLat, ',', :swLng, ' ', :swLat, '))'), 4326),
c.origin_location
)
-- 커서 조건은 (distance_score+recency_score, id) 튜플 비교로 추가
ORDER BY (distance_score + recency_score) DESC, c.created_at DESC
LIMIT ?;
- 위치 정보(`lat`/`lng`)가 없는 요청: `distance_score` 계산을 빼고 `ORDER BY created_at DESC`만 쓰는 별도 분기가 필요하다. 이건 애플리케이션 레벨에서 `lat`/`lng` null 여부로 쿼리 자체를 분기하는 방식으로 구현한다.
-
(홈)핀 조회와 다르게 모집완료, 모집중이더라도 바텀시트 목록에서는 보여준다.
-
(지도) 핀 조회와 달리, 모집완료(
COMPLETED)든 모집중(RECRUITING)이든 상태와 무관하게 목록에 노출한다. 핀은status/departure_at로 필터링되지만, 이 목록은 그런 필터 없이 뷰포트 안의 카풀 전부를 보여줌 -
커서 기반 페이지네이션: 정렬 기준이
id가 아니라 계산된 종합점수라, 커서에는 마지막 항목의 점수값과id(동점자 tie-break용)를 함께 담아야 함 -
compaions 테이블에서 미리 만들어둔 인덱스: 뷰포트 필터는 핀 조회와 동일하게
companions.origin_location의 SPATIAL INDEX(MBRContains)로 후보를 좁힌 뒤, 그 후보들에 대해 요청마다 사용자 위치(lat/lng) 기준 거리를 계산해 종합점수를 산출한다 → 거리순 정렬 자체는 사전에 만들어둘 수 없고(사용자 위치가 요청마다 다르므로) 매 요청 시 계산됨
카풀 상세 조회 API
-
API 명세:
GET /carpools/{companion_id}- API 문서 링크
-
권한: 로그인한 사용자
-
구현 상세:
- 응답 body
{
"message": "조회에 성공했습니다",
"data": {
"id": 51,
"status": "RECRUITING",
"host": {
"id": 7,
"nickname": "우림",
"profile_image_url": null,
"companion_count": 3,
"last_companion_at": "2026-08-20T09:00:00.000Z",
"ride_preference": {
"pref_conversation": "LIGHT",
"pref_smoking": "NO_SMOKING",
"pref_music": "SOUND_OK",
"pref_pet": "SMALL_OK"
}
},
"origin_name": "판교역",
"dest_name": "강남역",
"departure_at": "2026-09-08T08:30:00.000Z",
"current_count": 2,
"capacity": 4,
"is_full": false,
"my_request": {
"id": 88,
"status": "PENDING"
}
}
}
- 필요한 데이터들은 DTO Projection으로 가져온다.
- companions 테이블에서 출발지, 도착지, 출발시간, 현재인원, 모집인원, 모집상태, 방장 id를 가져온다.
- user 테이블에서 방장의 닉네임과 프로필이미지를 가져온다.
- ride_preferences에서 4가지 카테고리를 가져온다.
- 요청자 본인의 기존 요청이 있으면
my_request(id, status) 필드 포함 status,current_count: 캐시를 거치지 않고 위 JOIN 쿼리로 매 요청 조회- 존재하지 않으면 404
CARPOOL_NOT_FOUND
카풀 운행 종료 API
-
API 명세:
PATCH /carpools/{companion_id}- API 문서 링크
-
권한: 로그인한 사용자
-
구현 상세:
- 응답 body
{
"message": "운행을 종료했습니다",
"data": {
"id": 51,
"status": "COMPLETED",
"completed_at": "2026-09-08T09:12:00.000Z",
"ratees": [
{
"id": 9,
"nickname": "루디"
},
{
"id": 4,
"nickname": "테오"
}
]
}
}
-
운행 종료 권한 확인: Authentication으로 확인한 ~이 compaions의 host_id와 일치하지 않으면 403(HOST_ONLY)
-
companions 테이블의 status 상태 변경: IN_PROGRESS → COMPELETED
- CANCELED는 방장이 매칭팟을 열었는데 출발 시간에 1시간이 지난 이후에도 운행 시작을 안 한 경우다. CANCELED는 서버가 자동으로 CANCELED한다. 운행 시작을 한 이상 COMPLETED로 무조건 귀결된다.
-
companions 테이블의 current_count는 변경 안해도 된다. 이는 모집중일때만 유효한 값이다.
-
companions 테이블의 최근 수정시각 updated_at 갱신
-
companion_participants 테이블의 퇴장 시각 left_at, 완주 여부 상태 outcome_status를 PENDING → COMPLETED로 변경
-
chat_rooms에서 closed_at 갱신
-
응답 body에 참여인원을 ratees List 컬렉션으로 담는다.
-
트랜잭션 관리: 카풀 운행 종료 했을 때 companions 테이블, chat_rooms 테이블, companion_participants 테이블 업데이트를 하나의 트랜잭션 안에서 관리한다. 운행은 종료됐는데 채팅방이 남아있음을 방지하기 위함이다.
카풀 나가기 API
-
API 명세:
DELETE /carpools/{companion_id}/participants/me- API 문서 링크
-
권한: 로그인한 사용자
-
구현 상세:
- 운행 시작 전에만 나가기 버튼이 화면에 활성화 된다. 운행 시작 후 나가기 요청이 오면 409 (ALREADY_STARTED)
- 운행 시작 전에 방장이라면, 운전자가 만든 모임이므로 나갈 수 없다 403(HOST_CANNOT_LEAVE)
- companions 테이블의 current_count를 -1 한다. 이때 어플리케이션 단이 아니라 DB 단에서
SET current_count = current_count - 1WHERE current_count > 0조건도 넣어서, 혹시 모를 음수 방지도 해둔다. - companion_participants 테이블의 퇴장 시각 left_at, 완주 여부 상태 outcome_status으로 PENDING 유지
- ENUM 기존
SYSTEM_LEAVE타입 재사용: '000님이 퇴장하였습니다.'
-
트랜잭션 관리:
ompanion_participants업데이트(left_at,outcome_status)와companions.current_count감소, 그리고 시스템 메시지 발송(messagesinsert)까지 하나의 트랜잭션으로 묶는다. 이 셋이 같은 트랜잭션으로 묶여야 "나가기는 처리됐는데 카운트만 안 줄었다" 같은 불일치를 막는다.
동승 요청 보내기 API
-
API 명세:
POST /carpools/{companion_id}/join-requests- API 문서 링크
-
권한 :
- 로그인한 사용자
-
구현상세:
- 요청 body
{
"content": "판교역에서 같이 가고 싶습니다!"
}
- 응답 body(200일때)
{
"message": "카풀 요청이 등록되었습니다",
"data": {
"id": 88,
"carpool_id": 51,
"status": "PENDING",
"created_at": "2026-09-06T21:10:00.000Z"
}
}
-
DTO에서 요청 메시지 content의 길이제한은 @Valid로 유효성 검사한다.
-
본인 카풀에는 요청 불가 422 (OWN_CARPOOL)
-
동시 이중 생성 방지: 이미 동일한 companion_id에 대해
PENDING요청이 있으면 409 (REQUEST_ALREADY_PENDING) -
companions 테이블에서 정원을 실시간 검사한다. (
current_count/capacity) 조회하고, 초과하면 409(CAPACITY_FULL) -
companions 테이블에서 실시간으로 모집 상태(
status) 조회 —RECRUITING이 아니면(IN_PROGRESS/COMPLETED/CANCELED) 409(CARPOOL_CLOSED) -
재요청은 허용하되 누적 3회로 제한 → 초과 시 (REQUEST_LIMIT_EXCEEDED)
-
companion_request 테이블 insert(
id,content,status=PENDING,created_at) -
위의 예외를 모두 통과하면, notification 테이블에 insert 한다(어떤거를 insert?)
-
트랜잭션 관리: companion_request 테이블과 notifications 테이블의 로직은 모두 하나의 트랜잭션에서 관리한다.
-
동시성 제어: 이미 PENDING 요청 있으면 409(위에 언급함), 검증과 insert 사이에 race condition이 있어서
(companion_id, requester_id)+status='PENDING'조합에 DB 유니크 제약(생성 컬럼 방식)을 걸어야 한다. -
멱등성 키 관리: (일단 생각하지 않는다. 이후에 개선 작업으로 남긴다.)
-
트랜잭션 커밋 후 비동기로 push 발송을 트리거하여 알림을 보낸다.
내 카풀 요청 목록 조회 API
-
API 명세:
GET /users/me/carpool-requests?direction={}&cursor={}- API 문서 링크
-
권한: 로그인한 사용자
-
구현 상세:
- 응답 body(200만)
{
"message": "조회에 성공했습니다",
"data": {
"direction": "SENT",
"items": [
{
"id": 88,
"carpool_id": 51,
"status": "ACCEPTED",
"counterpart": {
"id": 7,
"nickname": "우림",
"profile_image_url": null
},
"origin_name": "판교역",
"dest_name": "강남역",
"departure_at": "2026-09-08T08:30:00.000Z",
"chat_room_id": 620,
"created_at": "2026-09-06T21:10:00.000Z"
},
{
"id": 84,
"carpool_id": 44,
"status": "EXPIRED",
"counterpart": {
"id": 12,
"nickname": "제이",
"profile_image_url": null
},
"origin_name": "서현역",
"dest_name": "역삼역",
"departure_at": "2026-09-05T08:00:00.000Z",
"created_at": "2026-09-04T19:40:00.000Z"
}
],
"next_cursor": "v1.eyJxIjoiYTNmOSIsImQiOjMyMCwiaSI6NTF9"
}
}
- @RequestParam으로 direction, cursor를 받는다.
direction(SENT/RECEIVED) 기준 분기하여 DB를 조회한다.SENT:companion_requests.requester_id가 인증된 사용자 id와 일치하는 요청들RECEIVED: 요청이 걸린companions.host_id가 인증된 사용자 id와 일치하는 요청들
- 최신순 정렬, 커서 페이지네이션: 최신순 정렬(
idDESC), 커서는 마지막 항목의id만 담음({"last_id": 88}을 base64 인코딩) — 종합점수 정렬이 아니라 단순id정렬이라 이전 채팅 메시지 커서와 동일한 패턴
카풀 요청 상세조회 API
-
API 명세:
GET /carpools/{companion_id}/join-requests/{request_id}- API 문서 링크
-
권한: 해당 카풀의 host만 — 그 외 403(
CARPOOL_HOST_ONLY) -
구현 상세:
- 응답 body
카풀 상세 조회 body
{
"message": "조회에 성공했습니다",
"data": {
"id": 51,
"status": "RECRUITING",
"host": {
"id": 7,
"nickname": "우림",
"profile_image_url": null,
"companion_count": 3,
"last_companion_at": "2026-08-20T09:00:00.000Z",
"ride_preference": {
"pref_conversation": "LIGHT",
"pref_smoking": "NO_SMOKING",
"pref_music": "SOUND_OK",
"pref_pet": "SMALL_OK"
}
},
"origin_name": "판교역",
"dest_name": "강남역",
"departure_at": "2026-09-08T08:30:00.000Z",
"current_count": 2,
"capacity": 4,
"is_full": false,
"my_request": {
"id": 88,
"status": "PENDING"
}
}
}
카풀 요청 상세 조회 body
{
"message": "조회에 성공했습니다",
"data": {
"id": 88,
"carpool_id": 51,
"status": "PENDING",
"content": "판교역에서 같이 가고 싶습니다!",
"requester": {
"id": 9,
"nickname": "루디",
"profile_image_url": "https://cdn.moyeota.app/p/9.jpg",
"companion_count": 2,
"last_companion_at": "2026-08-11T08:20:00.000Z",
"ride_preference": {
"pref_conversation": "QUIET",
"pref_smoking": "NO_SMOKING",
"pref_music": "SILENT",
"pref_pet": "NOT_ALLOWED"
}
},
"created_at": "2026-09-06T21:10:00.000Z"
}
}
companion_requests를 기준으로companions(존재 확인 +host_id대조)users(requester),ride_preferences(requester)를 DTO Projection으로 한 번에 조회 (N+1 방지)- 쿼리
SELECT cr.id, cr.companion_id, cr.status, cr.content, cr.created_at,
u.id AS requester_id, u.nickname, u.profile_image_url,
rp.pref_conversation, rp.pref_smoking, rp.pref_music, rp.pref_pet,
c.host_id
FROM companion_requests cr
JOIN companions c ON c.id = cr.companion_id
JOIN users u ON u.id = cr.requester_id
LEFT JOIN ride_preferences rp ON rp.user_id = cr.requester_id
WHERE cr.id = :requestId AND cr.companion_id = :companionId;
-
조회된
c.host_id가 인증된 사용자 id와 다르면 403(CARPOOL_HOST_ONLY) -
존재하지 않으면 404(
CARPOOL_REQUEST_NOT_FOUND) -
캐싱: (아직 논의가 안 된 부분)
카풀 요청 수락 및 거절 API
-
API 명세:
PATCH /carpools/{companion_id}/join-requests/{request_id}- API 문서 링크
-
권한: 해당 카풀의 host만 — 그 외 403(
CARPOOL_HOST_ONLY) -
구현 상세:
- 요청 DTO의
status는@NotNull+@Pattern(regexp = "ACCEPTED|REJECTED")로@Valid검증 PENDING상태에서만 전이 가능 — 이미 처리됐으면 409(REQUEST_ALREADY_HANDLED)- 수락(
ACCEPTED):companions.current_count를UPDATE ... WHERE current_count < capacity조건부 UPDATE로 증가 — 영향받은 행 0개면 409(CAPACITY_FULL). 특정 행만 지목하는 UPDATE라 InnoDB가 행 단위 락만 걸어 다른 카풀에는 영향 없음(동시성 제어)companion_requests.status갱신,companion_participantsinsertnotificationsinsert(CARPOOL_REQUEST_ACCEPTED, 동기)
- 거절(
REJECTED):companion_requests.status만 갱신, 알림 없음 - Non-goals에 명시된 팀 결정(status 값을 프론트가 표시),notificationsinsert 자체를 하지 않음 - 트랜잭션 관리: 같은 트랜잭션 안에서
notificationsinsert(CARPOOL_REQUEST_ACCEPTED, 동기) → 트랜잭션 커밋 후 별도 비동기로 push 발송 트리거
- 요청 DTO의
기술 스택 (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회, 예상 소요 시간 조회