[BE 테크스펙] 커뮤니티 도메인 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki
목차
배경 (Background)
프로젝트 목표 (Objective)
가까운 거리의 매칭팟을 지도 핀으로 찾고, 사용자들이 교통 현황을 공유할 수 있는 커뮤니티를 제공한다.
핵심 결과 (Key Results)
- 동행을 구하는 사용자에게 커뮤니티 공간을 제공하고 교통 현황을 공유할 수 있도록 한다.
- 지도에서 직관적으로 매칭팟을 찾도록 돕는다.
문제 정의 (Problem)
- 카풀·택시팟 매칭 기능만으로는 매칭이 성사되기 전까지 사용자가 앱에 머무를 유인이 적어 재방문율이 낮을 수 있다.
- 동행 상대를 구하기 전에 지역 생활 정보인 맛집·주변 시설 등을 통해 다른 사용자와 자연스럽게 상호작용하고 신뢰를 쌓을 채널이 부족하다.
가설 (Hypothesis)
지역 기반 자유게시판을 제공하면 사용자가 매칭 목적 외에도 앱에 재방문할 이유가 생긴다.
또한 커뮤니티에서 쌓인 상호작용이 카풀·동행에 대한 신뢰로 이어져 매칭 전환율도 함께 상승할 것이다.
관련 자료
목표가 아닌 것 (Non-goals)
이번 프로젝트에서 다루지 않는 내용:
- 실제 정산 로직 넣지 않는다: 실제 정산 로직은 팀프로젝트 범위에서 제외한다. 대신 은행앱 이체 인증 사진을 AI에게 위조판단을 함으로써 정산여부를 따진다.
설계 및 기술 자료 (Architecture and Technical Documentation)
게시글 상태
community_posts는 상태 머신을 갖지 않는다.deleted_at으로 soft-delete 여부만 구분한다.
데이터베이스 스키마 (ERD)
- 도메인별 관련 ERD:
community_posts: id, author_id, title(30자), content(500자), lat/lng, location(POINT, 생성 컬럼), comment_count, created_at, updated_at, deleted_atcommunity_comments: id, post_id, author_id, content(500자), created_at- 기타:
comment_count는community_posts에 비정규화되어 있다. 그 이유는 댓글 작성 시 원자적으로 함께 증가시켜야 정합성이 깨지지 않기 때문이다.
DDL
CREATE TABLE community_posts (
id BIGINT NOT NULL AUTO_INCREMENT,
author_id BIGINT NOT NULL,
title VARCHAR(30) NOT NULL,
content VARCHAR(500) NOT NULL,
lat DECIMAL(9,6) NOT NULL,
lng DECIMAL(9,6) NOT NULL,
location POINT SRID 4326 GENERATED ALWAYS AS (ST_SRID(POINT(lat, lng), 4326)) STORED NOT NULL,
comment_count INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
deleted_at DATETIME(6) NULL,
PRIMARY KEY (id),
SPATIAL INDEX idx_location (location),
KEY idx_author (author_id),
CONSTRAINT fk_post_author FOREIGN KEY (author_id) REFERENCES users (id)
);
CREATE TABLE community_comments (
id BIGINT NOT NULL AUTO_INCREMENT,
post_id BIGINT NOT NULL,
author_id BIGINT NOT NULL,
content VARCHAR(500) NOT NULL,
created_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
KEY idx_post_created (post_id, id),
CONSTRAINT fk_comment_post FOREIGN KEY (post_id) REFERENCES community_posts (id),
CONSTRAINT fk_comment_author FOREIGN KEY (author_id) REFERENCES users (id)
);
주요 설계 결정
location이STORED인 이유 —map-pins/nearby-posts에서 SPATIAL INDEX로 뷰포트 조회를 해야 하고, 공간 인덱스는 실제 저장된 값이 있어야 걸린다(VIRTUAL은 인덱스 불가).- 좌표를
DECIMAL(9,6)으로 두는 이유 — 택시팟 도메인과 동일:FLOAT/DOUBLE의 부동소수점 문제 회피. idx_post_created (post_id, id)복합 인덱스인 이유 — 댓글 목록 조회가WHERE post_id = ? ORDER BY id ...형태라,post_id등호 조건과id정렬을 하나의 인덱스로 동시에 처리하기 위함.comment_count가 비정규화된 이유 — 댓글 작성 시 원자적으로 함께 증가시켜야 정합성이 깨지지 않고, 게시글 목록/상세 조회 시 별도COUNT(*)쿼리(N+1 유발)를 피하기 위함.
API 명세 (API Specifications)
- 목차:
- 커뮤니티 도메인
- 게시글 등록 API
- 게시글 상세 조회 API
- 게시글 목록 조회 API
- 댓글 작성 API
- 댓글 목록 조회 API
- 커뮤니티 도메인
커뮤니티 API
게시글 등록 API
- API 명세:
POST /community-posts- API 문서
- 권한:
- 로그인한 사용자
- 구현 상세:
-
요청 (Request Body): 제목, 내용, 등록 위치(위도/경도)
-
요청 예시:
{ "title": "판교역 근처 카페 추천", "content": "조용히 작업하기 좋은 카페가 있을까요?", "lat": 37.3945, "lng": 127.1112 } -
클래스 배치:
클래스 메서드 CommunityPostControllercreate(CommunityPostCreateRequest request)CommunityPostServicecreate(Long userId, CommunityPostCreateCommand command)CommunityPostRepositorysave(CommunityPost post) -
처리 로직:
- 데이터 유효성 검증:
title(필수, 최대 30자, 공백만 입력 시 미입력 처리),content(최대 500자, 동일 규칙),lat/lng(위도 -9090, 경도 -180180) 검증 community_postsinsert:location은lat/lng로부터 DB 생성 컬럼이라 애플리케이션에서 별도 계산 불필요- 201 +
Location헤더로 생성된 리소스 위치 반환
- 데이터 유효성 검증:
-
게시글 상세 조회 API
- API 명세:
GET /community-posts/{post_id}- API 문서
- 권한: 불필요 (비로그인 조회 허용)
- 구현 상세:
-
클래스 배치:
클래스 메서드 CommunityPostControllerget(Long postId)CommunityPostServicefind(Long postId)CommunityPostRepositoryfindActiveById(Long id) -
요청 (Path):
post_id -
처리 로직:
community_posts에서id = ?인 행 조회- 없으면
404 POST_NOT_FOUND deleted_at IS NOT NULL이면410 POST_GONE- 응답 —
200 + data: { id, author: { id, nickname, profile_image_url }, title, content, lat, lng, comment_count, created_at, updated_at }
-
comment_count는 비정규화 컬럼을 그대로 읽는다(별도COUNT쿼리 없음) -
댓글 목록은 이 응답에 포함하지 않는다 — 댓글 조회가 실패해도 게시글 본문은 정상 노출되도록, 댓글 영역은 별도 API(아래 댓글 목록 조회)로 분리해 독립적으로 재시도 가능하게 설계
-
게시글 목록 조회 API — 지도 핀
-
API 명세:
GET /map-pins?sw_lat={}&sw_lng={}&ne_lat={}&ne_lng={}- 쿼리 파라미터:
sw_lat,sw_lng,ne_lat,ne_lng(뷰포트 남서·북동 좌표, 전부 필수) - API 문서
-
권한: 불필요 (비로그인 조회 허용)
-
구현 상세:
-
클래스 배치:
클래스 메서드 HomeFeedControllergetMapPins(MapPinSearchRequest request)HomeFeedServicefindPins(MapPinSearchCommand command)CommunityPostRepositoryfindPinsInViewport(Polygon viewport)CompanionFeedQueryPortfindPinsInViewport(Polygon viewport)— 카풀/택시팟/동행모집 소유 도메인이 구현CongestionFeedQueryPortfindPinsInViewport(Polygon viewport)— 혼잡도 도메인이 구현.지도 핀은 커뮤니티 게시글뿐 아니라 매칭(동행모집)까지 함께 보여주는 화면이라, 다른 도메인 데이터가 필요하다.
CompanionFeedQueryPort처럼 이 도메인이 필요로 하는 인터페이스(포트)만 정의해두고, 실제 구현은 해당 데이터를 소유한 도메인이 맡는 구조로 의존 방향을 정리한다. -
요청 DTO:
MapPinSearchRequest(record)필드 애노테이션 reasonsw_latne_lat@NotNull·@DecimalMin(-90)·@DecimalMax(90)REQUIRED·OUT_OF_RANGEsw_lngne_lng@NotNull·@DecimalMin(-180)·@DecimalMax(180)REQUIRED·OUT_OF_RANGE -
처리 로직:
-
형식 검증 → 실패 시
422 -
커뮤니티 게시글 핀 조회 —
community_posts.location의 SPATIAL INDEX(MBRContains)로 뷰포트 안 후보를 좁히고,deleted_at IS NULL로 필터SELECT cp.id, cp.title, cp.lat, cp.lng FROM community_posts cp WHERE cp.deleted_at IS NULL AND MBRContains( ST_GeomFromText( CONCAT( 'POLYGON((', :swLat, ' ', :swLng, ',', :neLat, ' ', :swLng, ',', :neLat, ' ', :neLng, ',', :swLat, ' ', :neLng, ',', :swLat, ' ', :swLng, '))' ), 4326 ), cp.location ); -
매칭 핀 조회:
CompanionFeedQueryPort.findPinsInViewport(...)에 위임 -
두 결과를
type(COMMUNITY/COMPANION)으로 구분해 합산 -
합산 결과가 500건을 초과하면 빈 배열과
limit_exceeded: true반환 -
응답 DTO:
200 + data: { items: [{ type, id, lat, lng, title? }], limit_exceeded }
-
-
-
트랜잭션 관리: 해당 없음(읽기 전용, 포트 호출도 읽기 전용)
댓글 작성 API
- API 명세:
POST /community-posts/{post_id}/comments- API 문서
- 권한:
- 로그인한 사용자
- 구현 상세:
-
요청 (Request Body): 내용
-
요청 예시:
{ "content": "저도 궁금해요!" }
-
클래스 배치:
| 클래스 | 메서드 |
|---|---|
CommunityCommentController |
create(Long postId, CommentCreateRequest request) |
CommunityCommentService |
create(Long userId, Long postId, CommentCreateCommand command) |
CommunityPostRepository |
findActiveById(Long id) · incrementCommentCount(Long id) |
CommunityCommentRepository |
save(CommunityComment comment) |
NotificationPort |
commentCreated(Long postAuthorId, Long postId, Long commentId) |
처리 로직:
post_id로 게시글 존재 여부와deleted_at IS NULL조건을 확인한다. 삭제된 글이면409 POST_GONE을 반환한다.content가 최대 500자인지, 공백만 입력되지 않았는지 검증한다.community_comments를 추가하고community_posts.comment_count를 원자적으로 1 증가시킨다.- 커밋 성공 후 알림 도메인에
comment_created이벤트를 발행한다. - 응답에 갱신된
comment_count를 함께 반환한다.
- 트랜잭션 관리: 댓글 작성과 알림 테이블 추가까지 같은 트랜잭션을 사용한다.
- 동시성 고려사항: 값을 애플리케이션에서 읽어 1을 더한 뒤 저장하지 않는다. DB에서
UPDATE community_posts SET comment_count = comment_count + 1형태로 처리하여 Race Condition을 방지한다.
댓글 목록 조회 API
- API 명세:
GET /community-posts/{post_id}/comments- API 문서
- 권한:
- 로그인한 사용자
- 구현 상세:
- 요청: 없음
클래스 배치:
| 클래스 | 메서드 |
|---|---|
CommunityCommentController |
list(Long postId, CommentListRequest request) |
CommunityCommentService |
findList(Long postId, CommentListCommand command) |
CommunityPostRepository |
existsActiveById(Long id) · existsById(Long id) |
CommunityCommentRepository |
findByPostIdWithCursor(Long postId, Long cursor, int size) |
처리 로직:
post_id로 게시글 존재 여부를 확인한다.- 게시글이 없으면
404 POST_NOT_FOUND를 반환한다. - 삭제된 게시글이면
410 POST_GONE을 반환한다. 댓글 작성 API가409를 반환하는 것과 구분한다.
- 게시글이 없으면
community_comments를 작성자(users)와 조인하여 DTO Projection으로 조회한다. 이를 통해 N+1 문제를 방지한다.idx_post_created (post_id, id)인덱스로post_id동등 조건과id정렬을 함께 처리한다.id내림차순으로 최신 댓글부터 정렬하고, 커서 기반 페이지네이션으로 10건씩 반환한다.
기술 스택 (Technology Stack)
- Backend: Java 25 / Spring Boot
- Database: MySQL 8 —
DECIMAL(9,6)좌표,POINT SRID 4326생성 컬럼,ST_Distance_Sphere - Scheduler: Spring
@Scheduled(지도 핀 삭제) - 외부 연동: 작성 필요