[BE 테크스펙] 동행 모집 도메인 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki
동행모집 도메인 테크 스펙
목차
배경 (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_status는PENDING아니면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 명세:
POST /companion-posts- API 문서
- 권한:
- 로그인한 사용자
- 구현 상세:
-
클래스 배치:
클래스 메서드 CompanionPostControllercreate(CompanionPostCreateRequest request)CompanionPostServicecreate(Long userId, CompanionPostCreateCommand command)CompanionRepositorysave(Companion c)ChatRoomRepositorysave(ChatRoom room)CompanionParticipantRepositorysave(CompanionParticipant p)SystemMessagePortjoin(chatRoomId, userId) -
요청 DTO:
CompanionPostCreateRequest(record)필드 애노테이션 reasonorigin_namedest_name@NotBlank·@Size(max=100)REQUIRED·MAX_LENGTHorigin_latdest_lat@NotNull·@DecimalMin(-90)·@DecimalMax(90)·@Digits(3,6)REQUIRED·OUT_OF_RANGEorigin_lngdest_lng@NotNull·@DecimalMin(-180)·@DecimalMax(180)·@Digits(3,6)REQUIRED·OUT_OF_RANGEdeparture_at@NotNullREQUIRED·INVALID_FORMATtransport_type@NotNull· enum 4종INVALID_ENUMrecruit_count@NotNull·@Min(1)·@Max(9)REQUIRED·OUT_OF_RANGEcontent@Size(max=500)MAX_LENGTH -
처리 로직:
- 형식 검증 → 실패 시
422 - 비즈니스 검증 →
422departure_at이 현재 이전 →DEPARTURE_TIME_PASSED- 출발지 = 도착지 →
SAME_ORIGIN_DEST - 이동수단별 인원 범위 위반 →
RECRUIT_COUNT_OUT_OF_RANGE(TAXI·OWNED_CAR13,9)SUBWAY·BUS1
capacity = recruit_count + 1변환- 한 트랜잭션에서 세 행 생성
companions(kind='COMPANION',creator_id = host_id = me,current_count = 1,status='RECRUITING')chat_roomscompanion_participants(방장,outcome_status='PENDING')
- 입장 시스템 메시지(
SYSTEM_JOIN) 생성 - 응답 -
201 Created+Location: /companion-posts/{id}+data: { id, chat_room_id }
- 형식 검증 → 실패 시
-
capacity변환 이유 - 방장을 제외한 인원을 입력받지만 DB는 총원으로 저장합니다.current_count가 방장을 포함해 1부터 시작하므로 같은 기준이어야current_count < capacity비교가 성립합니다. -
중복 등록을 막지 않습니다. 같은 사람이 같은 경로로 두 개를 모집하는 것을 금지하는 규칙이 없습니다. 택시팟의 동시 참여 제한은 택시팟 전용입니다.
-
content는 공백만 있어도 입력으로 처리합니다. 커뮤니티 글이 공백을 미입력으로 보는 것과 다릅니다.
-
동행모집 게시글 상세 API
- API 명세:
GET /companion-posts/{companion_id}- API 문서
- 권한:
- 비로그인 허용. 토큰이 있는데 유효하지 않으면
401
- 비로그인 허용. 토큰이 있는데 유효하지 않으면
- 구현 상세:
-
클래스 배치:
클래스 메서드 CompanionPostControllerget(Long companionId)CompanionPostServicefind(Long userIdOrNull, Long companionId)CompanionRepositoryfindCompanionPost(Long id)CompanionParticipantRepositoryfindActiveByCompanionId(Long id) -
요청 (Path):
companion_id -
처리 로직:
companions에서id = ? AND kind = 'COMPANION'조회 → 없으면404 POST_NOT_FOUNDstatus = 'CANCELED'이면410 COMPANION_POST_CLOSEDoutcome_status = 'PENDING'인 참여자를users와 조인해 닉네임 · 프로필 이미지 조회joined판정 - 토큰이 없으면false- 응답 -
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을 서버가 계산해 응답에 포함한다. -
410과404를 나눈 이유 - 취소된 모집은 “없었던 것”이 아니라 “있었으나 끝난 것”입니다. -
토큰이 무효하면 조용히 비로그인으로 강등하지 않습니다. 강등하면 클라이언트가 토큰이 죽은 줄 모른 채 화면을 그리고, [채팅 참여하기]를 눌러야 비로소
401을 만납니다.
-
동행모집 채팅 참여하기 API
- API 명세:
POST /companion-posts/{companion_id}/participants- API 문서
- 권한:
- 로그인한 사용자. 승인 절차가 없습니다 → 참여가 곧 채팅방 입장입니다.
- 구현 상세:
-
클래스 배치:
클래스 메서드 CompanionPostControllerjoin(Long companionId)CompanionPostServicejoin(Long userId, Long companionId)CompanionRepositoryfindCompanionPost(Long id)·tryJoin(Long id)(조건부 UPDATE)CompanionParticipantRepositoryfindByCompanionIdAndUserId(...)·save(...)SystemMessagePortjoin(chatRoomId, userId) -
요청: 없음. 참여자는 토큰에서 식별합니다.
-
처리 로직:
-
기존 참여 행 조회 -
outcome_status = 'PENDING'이면409 ALREADY_JOINED -
조건부 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); -
영향받은 행이 0이면 사유를
SELECT로 판별 →409 CAPACITY_FULL/409 DEPARTURE_TIME_PASSED/410 COMPANION_POST_CLOSED -
참여 행 생성. 나갔던 사람이면 기존 행을 재사용 (
outcome_status='PENDING',left_at=NULL,joined_at갱신) -
입장 시스템 메시지(
SYSTEM_JOIN) 생성 -
응답 -
201 Created+Location: /companion-posts/{id}/participants/{participant_id}+data: { chat_room_id }
-
-
정원 검사를
UPDATE안에 두는 이유- 인원을 조회한 뒤 애플리케이션에서 비교하면 동시에 들어온 두 요청이 모두 통과해 정원을 넘깁니다.
- 조건을
UPDATE에 두면 DB가 행 잠금으로 직렬화합니다. - 다른 사용자가 먼저 참여하여 정원이 찬 경우를 대비합니다.
-
409는 거절이 아닙니다. 동행모집에는 방장의 승인 · 거절 단계가 없기 때문에,409는 이미 마감됐거나 시간이 지난 모집이라는 사실을 알려주는 것뿐입니다. -
재참여는 새 행을 만들지 않습니다.
UNIQUE(companion_id, user_id)가 있어 기존 행을 되살립니다. 이력은messages의SYSTEM_JOIN·SYSTEM_LEAVE가 남깁니다. 이 유니크 키가 자연 멱등키 역할을 하므로Idempotency-Key를 두지 않습니다.
-
동행모집 채팅 나가기 API
- API 명세:
DELETE /companion-posts/{companion_id}/participants/me- API 문서
- 권한:
- 해당 동행모집 참여자. 비참여자는
404
- 해당 동행모집 참여자. 비참여자는
- 구현 상세:
-
클래스 배치:
클래스 메서드 CompanionPostControllerleave(Long companionId)CompanionPostServiceleave(Long userId, Long companionId)CompanionRepositorydecrementCount·changeHost·cancelCompanionParticipantRepositorymarkLeft(...)·findEarliestPending(companionId)ChatRoomRepositoryclose(chatRoomId)SystemMessagePortleave(chatRoomId, userId) -
요청 (Path):
companion_id -
처리 로직: 하나의 트랜잭션에서 수행
- 참여 행 조회 - 없거나 이미 나갔으면
404 COMPANION_POST_NOT_FOUND companion_participants-outcome_status = 'INCOMPLETE',left_at = NOW(6)companions.current_count감소 (chk_count가 0 미만은 거부)- 퇴장 시스템 메시지(
SYSTEM_LEAVE) 생성 - 방장이 나간 경우 - 남은
PENDING참여자 중joined_at이 가장 이른 사람에게host_id승계 - 마지막 1명이 나간 경우 -
companions.status = 'CANCELED',chat_rooms.closed_at = NOW(6) - 응답 -
204 No Content
- 참여 행 조회 - 없거나 이미 나갔으면
-
outcome_status는INCOMPLETE입니다. -
방장 승계에는 시스템 메시지를 만들지 않습니다.
-
동행모집은 운행 상태를 가지지 않기 때문에 언제든 나갈 수 있습니다.
-
미확정 사항
현재 범위에서는 만료된 모집을 자동으로 정리하는 배치를 도입하지 않는다. 따라서 출발 시각이 지나도 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)