[BE 테크스펙] 동행 모집 도메인 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki

동행모집 도메인 테크 스펙

목차

  1. 배경
  2. 목표가 아닌 것
  3. 설계 및 기술 자료
  4. 미확정 사항
  5. 기술 스택
  6. 용어 정의

배경 (Background)

프로젝트 목표 (Objective)

사용자가 직접 출발지·목적지·출발 시각·이동수단·모집 인원을 입력하여 모집글을 작성한다.

지도에서 해당 글을 본 사용자가 참여하면 즉시 같은 채팅방에 모일 수 있도록 한다.

핵심 결과 (Key Results)

  • 등록된 모집글 중 방장 외에 한 명 이상 참여한 비율 측정
  • 게시글 상세 조회에서 채팅 참여로 이어지는 전환율 측정

문제 정의 (Problem)

  • 취향이 맞는 사람을 찾는 커뮤니티는 많지만 실제 동행까지 연결하기는 어렵다.
  • 원하는 이동수단과 모집 인원을 직접 정하고, 참여 인원의 상한이 보장되는 채팅방에서 함께 이동할 사람을 구하기 어렵다.

가설 (Hypothesis)

사용자가 원하는 동행 조건을 직접 작성하여 지도에 게시하고 누구나 참여할 수 있도록 한다.

그러면 택시팟 자동 매칭으로 발견하기 어려운 동행 수요가 모집글 형태로 드러날 것이다.

관련 자료


목표가 아닌 것 (Non-goals)

이번 프로젝트에서 다루지 않는 내용:

  • 채팅 메시지 조회 · 전송 · 읽음: 채팅 도메인 소관입니다. 동행모집은 입장 · 퇴장 시스템 메시지를 만들기만 합니다.
  • 장소 검색 · 역지오코딩: 프론트가 지도 SDK를 직접 호출합니다. API가 없습니다.
  • 모집 취소 · 게시글 삭제: 화면에 진입점이 없어 엔드포인트를 만들지 않습니다. CANCELED는 마지막 참여자가 나갈 때만 생깁니다.
  • 만료된 모집의 자동 정리: 출발 시각이 지나도 채팅방을 닫지 않습니다. 배치가 없습니다.
  • 동행 평가 · 신고: 동행모집은 완주를 확인하는 절차가 없어 평가 대상이 아닙니다.
  • 정산: 택시팟 전용입니다.

설계 및 기술 자료 (Architecture and Technical Documentation)

상태 전이

동행모집의 상태는 한 축입니다. companions.status의 네 값 중 두 개만 씁니다.

   POST /companion-posts
        │
        ▼
   RECRUITING ──────► CANCELED
        │              · 마지막 1명 나가기
        │
        └─ 참여 / 나가기로 current_count만 오르내림

   companion_participants.outcome_status

   PENDING ──► INCOMPLETE   (나가기)
  • IN_PROGRESS · COMPLETED로 가는 전이가 없습니다. 서비스가 동행의 완주 여부를 알 방법이 없으므로 outcome_statusPENDING 아니면 INCOMPLETE입니다.
  • 만료는 저장 상태가 아닙니다. departure_at < NOW()로 계산합니다. 출발 시각이 지나도 RECRUITING으로 남고 채팅방도 그대로 열려 있습니다.
  • 모집 마감도 저장 상태가 아닙니다. current_count = capacity로 계산하므로 한 명이 나가면 자동으로 풀립니다.

데이터베이스 스키마 (ERD)

택시팟·카풀·동행모집은 생성 방법만 다르고 결과물이 같으므로 companions 테이블을 함께 사용한다. 동행모집은 kind = 'COMPANION'으로 구분한다.

주요 테이블

  • companions: 동행 한 건의 종류, 이동수단, 출발지·도착지 좌표, 출발 시각, 정원, 현재 인원, 상태 및 방장 정보
  • companion_participants: 참여자의 동행 ID, 사용자 ID, 참여 결과 및 참여·이탈 시각
  • chat_rooms: 동행 한 건에 대응하는 채팅방

DDL (Data Definition Language)

CREATE TABLE companions (
  id                BIGINT       NOT NULL AUTO_INCREMENT,
  creator_id        BIGINT       NOT NULL,
  host_id           BIGINT       NOT NULL,
  kind              ENUM('TAXI_POT','CARPOOL','COMPANION') NOT NULL,
  transport_type    ENUM('TAXI','OWNED_CAR','SUBWAY','BUS') NOT NULL,
  content           VARCHAR(500) NULL,
  origin_name       VARCHAR(100) NOT NULL,
  origin_lat        DECIMAL(9,6) NOT NULL,
  origin_lng        DECIMAL(9,6) NOT NULL,
  origin_location   POINT SRID 4326 GENERATED ALWAYS AS (ST_SRID(POINT(origin_lat, origin_lng), 4326)) STORED NOT NULL,
  dest_name         VARCHAR(100) NOT NULL,
  dest_lat          DECIMAL(9,6) NOT NULL,
  dest_lng          DECIMAL(9,6) NOT NULL,
  dest_location     POINT SRID 4326 GENERATED ALWAYS AS (ST_SRID(POINT(dest_lat, dest_lng), 4326)) VIRTUAL NOT NULL,
  departure_at      DATETIME(6)  NOT NULL,
  eta_at            DATETIME(6)  NULL,
  capacity          TINYINT UNSIGNED NOT NULL,
  current_count     TINYINT UNSIGNED NOT NULL DEFAULT 1,
  status            ENUM('RECRUITING','IN_PROGRESS','COMPLETED','CANCELED') NOT NULL DEFAULT 'RECRUITING',
  created_at        DATETIME(6)  NOT NULL,
  updated_at        DATETIME(6)  NOT NULL,
  PRIMARY KEY (id),
  SPATIAL INDEX idx_origin (origin_location),
  KEY idx_kind_status_departure (kind, status, departure_at),
  CONSTRAINT fk_companions_creator FOREIGN KEY (creator_id) REFERENCES users (id),
  CONSTRAINT fk_companions_host    FOREIGN KEY (host_id)    REFERENCES users (id),
  CONSTRAINT chk_kind_transport    CHECK ((kind = 'TAXI_POT' AND transport_type = 'TAXI') OR (kind = 'CARPOOL' AND transport_type = 'OWNED_CAR') OR (kind = 'COMPANION')),
  CONSTRAINT chk_capacity          CHECK ((transport_type IN ('TAXI','OWNED_CAR') AND capacity BETWEEN 2 AND 4) OR (transport_type IN ('SUBWAY','BUS') AND capacity BETWEEN 2 AND 10)),
  CONSTRAINT chk_taxipot_capacity  CHECK (kind <> 'TAXI_POT' OR capacity = 4),
  CONSTRAINT chk_count             CHECK (current_count BETWEEN 0 AND capacity)
);

CREATE TABLE companion_participants (
  id                   BIGINT      NOT NULL AUTO_INCREMENT,
  companion_id         BIGINT      NOT NULL,
  user_id              BIGINT      NOT NULL,
  last_read_message_id BIGINT      NULL,
  outcome_status       ENUM('PENDING','COMPLETED','INCOMPLETE') NOT NULL DEFAULT 'PENDING',
  joined_at            DATETIME(6) NOT NULL,
  left_at              DATETIME(6) NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_companion_user     (companion_id, user_id),
  KEY        idx_user_outcome      (user_id, outcome_status),
  KEY        idx_companion_outcome (companion_id, outcome_status),
  CONSTRAINT fk_cp_companion FOREIGN KEY (companion_id) REFERENCES companions (id),
  CONSTRAINT fk_cp_user      FOREIGN KEY (user_id)      REFERENCES users (id),
  CONSTRAINT chk_left CHECK ((outcome_status = 'PENDING' AND left_at IS NULL) OR (outcome_status <> 'PENDING' AND left_at IS NOT NULL))
);

CREATE TABLE chat_rooms (
  id              BIGINT      NOT NULL AUTO_INCREMENT,
  companion_id    BIGINT      NOT NULL,
  last_message_id BIGINT      NULL,
  closed_at       DATETIME(6) NULL,
  created_at      DATETIME(6) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_chat_room_companion (companion_id),
  CONSTRAINT fk_chat_room_companion FOREIGN KEY (companion_id) REFERENCES companions (id)
);

주요 설계 결정

  • 제목 컬럼은 저장하지 않으며 출발지 → 목적지 형식으로 서버가 조립한다.
  • chk_kind_transport - 이동수단 4종을 자유롭게 고를 수 있는 것은 동행모집뿐입니다. 택시팟은 TAXI, 카풀은 OWNED_CAR로 고정됩니다.
  • chk_capacity - 총원 기준입니다. 택시 · 자차 24명, 지하철 · 버스 210명. 화면이 입력받는 값은 방장을 제외한 인원이므로 API 경계에서 +1 합니다.
  • eta_at은 동행모집에서 쓰지 않습니다. 도착 감지가 없습니다.
  • dest_location은 동행모집에서 읽지 않습니다. 택시팟의 도착 감지 전용입니다.
  • chat_rooms는 게시글 등록 트랜잭션에서 함께 생성한다. 방장 혼자인 시점에도 채팅방은 이미 존재한다.

API 명세 (API Specifications)

  • 목차:
    • 동행모집 게시글 등록 API
    • 동행모집 게시글 상세 API
    • 동행모집 채팅 참여하기 API
    • 동행모집 채팅 나가기 API
  • 공통:
    • 조회(상세)는 비로그인도 허용합니다. 나머지는 Authorization: Bearer {access_token} 필수.
    • 택시팟 · 카풀의 companion_id를 넣으면 전부 404입니다. 한 companion_id는 정확히 한 컬렉션 URI에만 속합니다.

동행모집 게시글 등록 API

  • API 명세:
  • 권한:
    • 로그인한 사용자
  • 구현 상세:
    • 클래스 배치:

      클래스 메서드
      CompanionPostController create(CompanionPostCreateRequest request)
      CompanionPostService create(Long userId, CompanionPostCreateCommand command)
      CompanionRepository save(Companion c)
      ChatRoomRepository save(ChatRoom room)
      CompanionParticipantRepository save(CompanionParticipant p)
      SystemMessagePort join(chatRoomId, userId)
    • 요청 DTO: CompanionPostCreateRequest(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 REQUIRED · INVALID_FORMAT
      transport_type @NotNull · enum 4종 INVALID_ENUM
      recruit_count @NotNull · @Min(1) · @Max(9) REQUIRED · OUT_OF_RANGE
      content @Size(max=500) MAX_LENGTH
    • 처리 로직:

      1. 형식 검증 → 실패 시 422
      2. 비즈니스 검증 → 422
        1. departure_at이 현재 이전 → DEPARTURE_TIME_PASSED
        2. 출발지 = 도착지 → SAME_ORIGIN_DEST
        3. 이동수단별 인원 범위 위반 → RECRUIT_COUNT_OUT_OF_RANGE (TAXI · OWNED_CAR 13, SUBWAY · BUS 19)
      3. capacity = recruit_count + 1 변환
      4. 한 트랜잭션에서 세 행 생성
        1. companions (kind='COMPANION', creator_id = host_id = me, current_count = 1, status='RECRUITING')
        2. chat_rooms
        3. companion_participants (방장, outcome_status='PENDING')
      5. 입장 시스템 메시지(SYSTEM_JOIN) 생성
      6. 응답 - 201 Created + Location: /companion-posts/{id} + data: { id, chat_room_id }
    • capacity 변환 이유 - 방장을 제외한 인원을 입력받지만 DB는 총원으로 저장합니다. current_count가 방장을 포함해 1부터 시작하므로 같은 기준이어야 current_count < capacity 비교가 성립합니다.

    • 중복 등록을 막지 않습니다. 같은 사람이 같은 경로로 두 개를 모집하는 것을 금지하는 규칙이 없습니다. 택시팟의 동시 참여 제한은 택시팟 전용입니다.

    • content는 공백만 있어도 입력으로 처리합니다. 커뮤니티 글이 공백을 미입력으로 보는 것과 다릅니다.

동행모집 게시글 상세 API

  • API 명세:
  • 권한:
    • 비로그인 허용. 토큰이 있는데 유효하지 않으면 401
  • 구현 상세:
    • 클래스 배치:

      클래스 메서드
      CompanionPostController get(Long companionId)
      CompanionPostService find(Long userIdOrNull, Long companionId)
      CompanionRepository findCompanionPost(Long id)
      CompanionParticipantRepository findActiveByCompanionId(Long id)
    • 요청 (Path): companion_id

    • 처리 로직:

      1. companions에서 id = ? AND kind = 'COMPANION' 조회 → 없으면 404 POST_NOT_FOUND
      2. status = 'CANCELED'이면 410 COMPANION_POST_CLOSED
      3. outcome_status = 'PENDING'인 참여자를 users와 조인해 닉네임 · 프로필 이미지 조회
      4. joined 판정 - 토큰이 없으면 false
      5. 응답 - 200 + data: { id, title, content, transport_type, origin_name, dest_name, departure_at, is_expired, current_count, capacity, is_full, author, participants[], chat_room_id, joined }
    • is_expired · is_full을 서버가 계산해 응답에 포함한다.

    • 410404를 나눈 이유 - 취소된 모집은 “없었던 것”이 아니라 “있었으나 끝난 것”입니다.

    • 토큰이 무효하면 조용히 비로그인으로 강등하지 않습니다. 강등하면 클라이언트가 토큰이 죽은 줄 모른 채 화면을 그리고, [채팅 참여하기]를 눌러야 비로소 401을 만납니다.

동행모집 채팅 참여하기 API

  • API 명세:
    • POST /companion-posts/{companion_id}/participants
    • API 문서
  • 권한:
    • 로그인한 사용자. 승인 절차가 없습니다 → 참여가 곧 채팅방 입장입니다.
  • 구현 상세:
    • 클래스 배치:

      클래스 메서드
      CompanionPostController join(Long companionId)
      CompanionPostService join(Long userId, Long companionId)
      CompanionRepository findCompanionPost(Long id) · tryJoin(Long id) (조건부 UPDATE)
      CompanionParticipantRepository findByCompanionIdAndUserId(...) · save(...)
      SystemMessagePort join(chatRoomId, userId)
    • 요청: 없음. 참여자는 토큰에서 식별합니다.

    • 처리 로직:

      1. 기존 참여 행 조회 - outcome_status = 'PENDING'이면 409 ALREADY_JOINED

      2. 조건부 UPDATE - 정원 · 상태 · 출발 시각을 한 문장에서 검사하며 증가

        UPDATE companions SET current_count = current_count + 1
         WHERE id = ? AND kind = 'COMPANION' AND status = 'RECRUITING'
           AND current_count < capacity AND departure_at > NOW(6);
        
      3. 영향받은 행이 0이면 사유를 SELECT로 판별 → 409 CAPACITY_FULL / 409 DEPARTURE_TIME_PASSED / 410 COMPANION_POST_CLOSED

      4. 참여 행 생성. 나갔던 사람이면 기존 행을 재사용 (outcome_status='PENDING', left_at=NULL, joined_at 갱신)

      5. 입장 시스템 메시지(SYSTEM_JOIN) 생성

      6. 응답 - 201 Created + Location: /companion-posts/{id}/participants/{participant_id} + data: { chat_room_id }

    • 정원 검사를 UPDATE 안에 두는 이유

      • 인원을 조회한 뒤 애플리케이션에서 비교하면 동시에 들어온 두 요청이 모두 통과해 정원을 넘깁니다.
      • 조건을 UPDATE에 두면 DB가 행 잠금으로 직렬화합니다.
      • 다른 사용자가 먼저 참여하여 정원이 찬 경우를 대비합니다.
    • 409는 거절이 아닙니다. 동행모집에는 방장의 승인 · 거절 단계가 없기 때문에, 409는 이미 마감됐거나 시간이 지난 모집이라는 사실을 알려주는 것뿐입니다.

    • 재참여는 새 행을 만들지 않습니다. UNIQUE(companion_id, user_id)가 있어 기존 행을 되살립니다. 이력은 messagesSYSTEM_JOIN · SYSTEM_LEAVE가 남깁니다. 이 유니크 키가 자연 멱등키 역할을 하므로 Idempotency-Key를 두지 않습니다.

동행모집 채팅 나가기 API

  • API 명세:
    • DELETE /companion-posts/{companion_id}/participants/me
    • API 문서
  • 권한:
    • 해당 동행모집 참여자. 비참여자는 404
  • 구현 상세:
    • 클래스 배치:

      클래스 메서드
      CompanionPostController leave(Long companionId)
      CompanionPostService leave(Long userId, Long companionId)
      CompanionRepository decrementCount · changeHost · cancel
      CompanionParticipantRepository markLeft(...) · findEarliestPending(companionId)
      ChatRoomRepository close(chatRoomId)
      SystemMessagePort leave(chatRoomId, userId)
    • 요청 (Path): companion_id

    • 처리 로직: 하나의 트랜잭션에서 수행

      1. 참여 행 조회 - 없거나 이미 나갔으면 404 COMPANION_POST_NOT_FOUND
      2. companion_participants - outcome_status = 'INCOMPLETE', left_at = NOW(6)
      3. companions.current_count 감소 (chk_count가 0 미만은 거부)
      4. 퇴장 시스템 메시지(SYSTEM_LEAVE) 생성
      5. 방장이 나간 경우 - 남은 PENDING 참여자 중 joined_at이 가장 이른 사람에게 host_id 승계
      6. 마지막 1명이 나간 경우 - companions.status = 'CANCELED', chat_rooms.closed_at = NOW(6)
      7. 응답 - 204 No Content
    • outcome_statusINCOMPLETE입니다.

    • 방장 승계에는 시스템 메시지를 만들지 않습니다.

    • 동행모집은 운행 상태를 가지지 않기 때문에 언제든 나갈 수 있습니다.


미확정 사항

현재 범위에서는 만료된 모집을 자동으로 정리하는 배치를 도입하지 않는다. 따라서 출발 시각이 지나도 RECRUITING 상태가 유지되고 채팅방도 닫히지 않는다.

후속 작업에서는 다음 정책을 결정해야 한다.

  • 만료된 모집을 정리하는 Scheduler를 도입할지 여부
  • 만료 시 채팅방을 닫을지 여부
  • 완주 여부를 판정할 수 없는 상황에서 COMPLETED 상태를 사용할 수 있는지 여부

기술 스택 (Technology Stack)

  • Backend: Java 25 / Spring Boot
  • Database: MySQL 8 - DECIMAL(9,6) 좌표, POINT SRID 4326 생성 컬럼
  • Scheduler: 미확정. 현재 범위에서는 사용하지 않으며 만료 정책 확정 후 검토
  • Realtime: 입장 · 퇴장 시스템 메시지만 생성. 전송은 채팅 도메인의 WebSocket

용어 정의 (Glossary)

  • 동행모집: 사용자가 직접 조건을 적어 올리고 누구나 참여할 수 있는 동행. companions.kind = 'COMPANION'
  • 방장 (host_id): 현재 방장. 나가면 가장 먼저 들어온 참여자에게 승계됨. 최초 작성자(creator_id)와는 별개
  • recruit_count / capacity: 요청은 방장을 제외한 모집 인원(recruit_count), 저장은 방장을 포함한 총원(capacity). API 경계에서 +1
  • 제목: 저장하지 않는 값. 출발지 → 목적지로 서버가 조립
  • 모집 마감: 저장 상태가 아니라 current_count = capacity로 계산되는 값. 한 명이 나가면 자동으로 해제됨
  • 만료: 저장 상태가 아니라 departure_at < NOW()로 계산되는 값. 상태는 RECRUITING으로 남음
  • outcome_status: 참여의 결말. 동행모집에서는 PENDING(참여 중) / INCOMPLETE(나감) 두 값만 쓰임
  • 조건부 UPDATE: WHERE에 현재 상태를 포함한 갱신. 영향 행이 0이면 실패로 판정해 정원 초과와 중복 처리를 막음
  • 시스템 메시지: 서버가 채팅방에 만드는 메시지. 동행모집은 SYSTEM_JOIN · SYSTEM_LEAVE 두 종류만 사용
  • 오류 코드:
    • POST_NOT_FOUND: 없는 id · kind 불일치 (404)
    • COMPANION_POST_NOT_FOUND: 참여 중인 동행모집이 아님 (404)
    • COMPANION_POST_CLOSED: 취소된 모집 (410)
    • ALREADY_JOINED: 이미 참여 중 (409)
    • CAPACITY_FULL: 정원이 찼음 (409)
    • DEPARTURE_TIME_PASSED: 출발 시각이 지난 모집에 참여 시도 (409) · 과거 시각으로 등록 시도 (422)
    • SAME_ORIGIN_DEST · RECRUIT_COUNT_OUT_OF_RANGE: 등록 검증 실패 (422)