[BE 테크스펙] 카풀 도메인 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki

배경

  • 프로젝트 목표 (Objective): 필수정보 입력만으로 카풀 등록이 간편해지고, 간단한 탑승 신청부터 수락까지 편리한 매칭 성사(요청 수락)까지의 전환율을 높인다.

    • 핵심 결과 (Key Result) 1: 차량 정보·동승 선호도 등 필수 사전조건을 한 번만 등록하면, 이후 카풀 등록·요청 과정에서 재입력 없이 이어서 진행할 수 있다.
    • 핵심 결과 (Key Result) 2: 카풀 요청부터 수락·거절까지의 상태 전이가 지연이나 오류 없이 정확하게 반영되어, 탑승자가 자신의 요청 결과를 즉시 확인할 수 있다.
  • 문제 정의 (Problem):

    • 카풀이 등록은 되어도 실제 요청-수락으로 이어지지 않아 매칭 성사율이 낮을 수 있다.
    • 등록 전제조건(차량 정보, 동승 선호도 등록)이 있어, 이 단계에서 이탈하는 사용자가 있을 수 있다.
    • host가 요청에 응답하지 않고 방치하는 경우 탑승자가 다른 카풀을 알아보지 못한 채 대기하게 되어 이탈로 이어질 수 있다.
  • 가설 (Hypothesis): 요청·수락 흐름의 상태 전이를 명확히 하고(멱등성·동시성 보장으로 안정적인 처리), 사전 정보(차량·동승 선호도) 검증을 통해 신뢰도를 높이면 매칭 성사율이 상승할 것이다.

  • 관련 자료:


목표가 아닌 것 (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_at
  • companion_participants: 수락 시 참여자로 등록되는 테이블
  • owned_cars : 차량 정보(카풀 등록 전제조건)
  • ride_preferences: 동승 선호도(카풀 등록 전제조건)

API 명세 (API Specifications)


카풀 등록 API

  • 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): 출발지(지역/위도/경도), 도착지(지역/위도/경도), 출발 시각(년/월/일/시/분), 모집인원(방장 제외)

    1. 사전조건 검증. 아래 a,b는 DTO의 @Valid로 검증이 불가한 부분이니 Service 레이어에서 별도로 처리한다.
      1. 차량 정보(owned_cars) 등록 여부 → 미등록 시 422(CAR_REGISTRATION_REQUIRED)
      2. 동승 선호도(ride_preferences) 등록 여부 → 미등록 시 422(RIDE_PREFERENCE_REQUIRED)
    2. departure_at: 현재 이후 ~ 1개월 이내만 허용
    3. origin_name == dest_name 이면 422(SAME_ORIGIN_DEST) , 필드 간 상호 검증이므로 Service 레이어에서 처리한다.
    4. recruit_count 1~3 범위 검증, 방장 제외한 모집인원수, 카풀도 최대 4명(방장포함)으로 강제 제한
  • companions insert(kind='CARPOOL') + 채팅방(chat_rooms) 자동 생성 + 응답에 chat_room_id 포함

  • companions insert(kind='CARPOOL') 시에,

    • compaions 테이블 status 상태 초기화: RECURITING
    • 생성시각 표시
    • 생성한 유저의 id를 creator_id로 연관관계 매핑
  • 트랜잭션 관리: companions insert와 chat_rooms insert는 같은 트랜잭션으로 묶는다. 카풀만 생성되고 채팅방 생성이 실패하는 상황을 방지하기 위함임.


(지도) 주변 카풀/택시 핀 조회 API

  • 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)가 있는 요청:
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 명세:

  • 권한: 로그인한 사용자

  • 구현 상세:

    • 응답 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 명세:

  • 권한: 로그인한 사용자

  • 구현 상세:

    • 응답 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 명세:

  • 권한: 로그인한 사용자

  • 구현 상세:

    • 운행 시작 전에만 나가기 버튼이 화면에 활성화 된다. 운행 시작 후 나가기 요청이 오면 409 (ALREADY_STARTED)
    • 운행 시작 전에 방장이라면, 운전자가 만든 모임이므로 나갈 수 없다 403(HOST_CANNOT_LEAVE)
    • companions 테이블의 current_count를 -1 한다. 이때 어플리케이션 단이 아니라 DB 단에서 SET current_count = current_count - 1 WHERE current_count > 0 조건도 넣어서, 혹시 모를 음수 방지도 해둔다.
    • companion_participants 테이블의 퇴장 시각 left_at, 완주 여부 상태 outcome_status으로 PENDING 유지
    • ENUM 기존 SYSTEM_LEAVE 타입 재사용: '000님이 퇴장하였습니다.'
  • 트랜잭션 관리: ompanion_participants 업데이트(left_at, outcome_status)와 companions.current_count 감소, 그리고 시스템 메시지 발송(messages insert)까지 하나의 트랜잭션으로 묶는다. 이 셋이 같은 트랜잭션으로 묶여야 "나가기는 처리됐는데 카운트만 안 줄었다" 같은 불일치를 막는다.


동승 요청 보내기 API

  • 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 명세:

  • 권한: 로그인한 사용자

  • 구현 상세:

    • 응답 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와 일치하는 요청들
  • 최신순 정렬, 커서 페이지네이션: 최신순 정렬(id DESC), 커서는 마지막 항목의 id만 담음({"last_id": 88}을 base64 인코딩) — 종합점수 정렬이 아니라 단순 id 정렬이라 이전 채팅 메시지 커서와 동일한 패턴

카풀 요청 상세조회 API

  • 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 명세:

  • 권한: 해당 카풀의 host만 — 그 외 403(CARPOOL_HOST_ONLY)

  • 구현 상세:

    • 요청 DTO의 status@NotNull + @Pattern(regexp = "ACCEPTED|REJECTED")@Valid 검증
    • PENDING 상태에서만 전이 가능 — 이미 처리됐으면 409(REQUEST_ALREADY_HANDLED)
    • 수락(ACCEPTED):
      • companions.current_countUPDATE ... WHERE current_count < capacity 조건부 UPDATE로 증가 — 영향받은 행 0개면 409(CAPACITY_FULL). 특정 행만 지목하는 UPDATE라 InnoDB가 행 단위 락만 걸어 다른 카풀에는 영향 없음(동시성 제어)
      • companion_requests.status 갱신, companion_participants insert
      • notifications insert(CARPOOL_REQUEST_ACCEPTED, 동기)
    • 거절(REJECTED): companion_requests.status만 갱신, 알림 없음 - Non-goals에 명시된 팀 결정(status 값을 프론트가 표시), notifications insert 자체를 하지 않음
    • 트랜잭션 관리: 같은 트랜잭션 안에서 notifications insert(CARPOOL_REQUEST_ACCEPTED, 동기) → 트랜잭션 커밋 후 별도 비동기로 push 발송 트리거

기술 스택 (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회, 예상 소요 시간 조회