[BE 테크스펙] 커뮤니티 도메인 - 100-hours-a-week/KTB4-3rd-wiki GitHub Wiki

목차

  1. 배경
  2. 목표가 아닌 것
  3. 설계 및 기술 자료
  4. 기술 스택

배경 (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_at
    • community_comments: id, post_id, author_id, content(500자), created_at
    • 기타: comment_countcommunity_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)
);

주요 설계 결정

  • locationSTORED인 이유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 명세:
  • 권한:
    • 로그인한 사용자
  • 구현 상세:
    • 요청 (Request Body): 제목, 내용, 등록 위치(위도/경도)

    • 요청 예시:

      {
        "title": "판교역 근처 카페 추천",
        "content": "조용히 작업하기 좋은 카페가 있을까요?",
        "lat": 37.3945,
        "lng": 127.1112
      }
      
    • 클래스 배치:

      클래스 메서드
      CommunityPostController create(CommunityPostCreateRequest request)
      CommunityPostService create(Long userId, CommunityPostCreateCommand command)
      CommunityPostRepository save(CommunityPost post)
    • 처리 로직:

      1. 데이터 유효성 검증: title(필수, 최대 30자, 공백만 입력 시 미입력 처리), content(최대 500자, 동일 규칙), lat/lng(위도 -9090, 경도 -180180) 검증
      2. community_posts insert: locationlat/lng로부터 DB 생성 컬럼이라 애플리케이션에서 별도 계산 불필요
      3. 201 + Location 헤더로 생성된 리소스 위치 반환

게시글 상세 조회 API

  • API 명세:
  • 권한: 불필요 (비로그인 조회 허용)
  • 구현 상세:
    • 클래스 배치:

      클래스 메서드
      CommunityPostController get(Long postId)
      CommunityPostService find(Long postId)
      CommunityPostRepository findActiveById(Long id)
    • 요청 (Path): post_id

    • 처리 로직:

      1. community_posts에서 id = ?인 행 조회
      2. 없으면 404 POST_NOT_FOUND
      3. deleted_at IS NOT NULL이면 410 POST_GONE
      4. 응답 — 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 문서
  • 권한: 불필요 (비로그인 조회 허용)

  • 구현 상세:

    • 클래스 배치:

      클래스 메서드
      HomeFeedController getMapPins(MapPinSearchRequest request)
      HomeFeedService findPins(MapPinSearchCommand command)
      CommunityPostRepository findPinsInViewport(Polygon viewport)
      CompanionFeedQueryPort findPinsInViewport(Polygon viewport) — 카풀/택시팟/동행모집 소유 도메인이 구현
      CongestionFeedQueryPort findPinsInViewport(Polygon viewport) — 혼잡도 도메인이 구현.

      지도 핀은 커뮤니티 게시글뿐 아니라 매칭(동행모집)까지 함께 보여주는 화면이라, 다른 도메인 데이터가 필요하다. CompanionFeedQueryPort처럼 이 도메인이 필요로 하는 인터페이스(포트)만 정의해두고, 실제 구현은 해당 데이터를 소유한 도메인이 맡는 구조로 의존 방향을 정리한다.

    • 요청 DTO: MapPinSearchRequest(record)

      필드 애노테이션 reason
      sw_lat ne_lat @NotNull · @DecimalMin(-90) · @DecimalMax(90) REQUIRED · OUT_OF_RANGE
      sw_lng ne_lng @NotNull · @DecimalMin(-180) · @DecimalMax(180) REQUIRED · OUT_OF_RANGE
    • 처리 로직:

      1. 형식 검증 → 실패 시 422

      2. 커뮤니티 게시글 핀 조회 — 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
              );
        
      3. 매칭 핀 조회: CompanionFeedQueryPort.findPinsInViewport(...)에 위임

      4. 두 결과를 type(COMMUNITY/COMPANION)으로 구분해 합산

      5. 합산 결과가 500건을 초과하면 빈 배열과 limit_exceeded: true 반환

      6. 응답 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)

처리 로직:

  1. post_id로 게시글 존재 여부와 deleted_at IS NULL 조건을 확인한다. 삭제된 글이면 409 POST_GONE을 반환한다.
  2. content가 최대 500자인지, 공백만 입력되지 않았는지 검증한다.
  3. community_comments를 추가하고 community_posts.comment_count를 원자적으로 1 증가시킨다.
  4. 커밋 성공 후 알림 도메인에 comment_created 이벤트를 발행한다.
  5. 응답에 갱신된 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)

처리 로직:

  1. post_id로 게시글 존재 여부를 확인한다.
    • 게시글이 없으면 404 POST_NOT_FOUND를 반환한다.
    • 삭제된 게시글이면 410 POST_GONE을 반환한다. 댓글 작성 API가 409를 반환하는 것과 구분한다.
  2. community_comments를 작성자(users)와 조인하여 DTO Projection으로 조회한다. 이를 통해 N+1 문제를 방지한다.
  3. idx_post_created (post_id, id) 인덱스로 post_id 동등 조건과 id 정렬을 함께 처리한다.
  4. id 내림차순으로 최신 댓글부터 정렬하고, 커서 기반 페이지네이션으로 10건씩 반환한다.

기술 스택 (Technology Stack)

  • Backend: Java 25 / Spring Boot
  • Database: MySQL 8 — DECIMAL(9,6) 좌표, POINT SRID 4326 생성 컬럼, ST_Distance_Sphere
  • Scheduler: Spring @Scheduled (지도 핀 삭제)
  • 외부 연동: 작성 필요