Skip to content

[Feat] 매물 등록·목록·상세 API - #16

Open
RootToApex wants to merge 7 commits into
devfrom
feat/listing
Open

[Feat] 매물 등록·목록·상세 API#16
RootToApex wants to merge 7 commits into
devfrom
feat/listing

Conversation

@RootToApex

Copy link
Copy Markdown
Member

💡 개요

매물 도메인 P0 3건 — 등록 / 목록(커서 페이지네이션) / 상세.

#15(카테고리) 위에 쌓여 있습니다. category_id가 필요해 그 브랜치에서 땄고, #15가
머지되면 이 PR의 diff는 매물 부분만 남습니다.

이미지 업로드(LST-2)는 S3 인프라가 정해지지 않아 뺐습니다. 매물 수정·삭제·상태 전이는
다음 PR입니다.

🛠️ 작업 내용

매물 엔티티

  • 상태 머신 DRAFT / PENDING_VERIFICATION / ACTIVE / SOLD / BLOCKED / DELETED
    • 전이표는 규칙을 모으는 장치일 뿐 동시성 방어가 아닙니다. 최종 심판인 조건부 UPDATE는
      상태 전이 API에서 붙입니다
    • 정책이 명시한 전이만 넣었습니다. 차단 → 삭제는 열지 않았습니다 — 제재 건을 지우면
      거래 스냅샷·주문·신고 이력이 고아가 되고 이의제기 근거가 사라집니다
  • 검증 구간은 feature flag(app.verification.enabled, 기본 off)
  • 가격은 DTO·엔티티·DB CHECK 3중으로 막습니다. 정책이 DTO와 DB 제약의 병행을
    요구합니다 — 컨트롤러를 안 거치는 경로로 들어온 값 하나가 가격 통계를 오염시킵니다
  • 조회수는 이 엔티티로 올리지 않습니다 — @Version이 증가해 판매자의 수정이 실패합니다

목록·상세

  • 커서 페이지네이션, size + 1건 조회로 hasNext 판정
  • 노출 조건은 허용 목록입니다 — 거부 목록이면 상태가 추가될 때 자동 공개됩니다
  • 목록과 상세의 기준이 다릅니다: 목록은 ACTIVE만, 상세는 SOLD까지 열립니다.
    거래 당사자가 나중에 확인하고 채팅·신고에서 넘어온 링크가 죽지 않아야 합니다
  • categoryCode에 대분류를 주면 하위 중분류로 펼쳐서 조회합니다
  • size는 조용히 깎지 않고 100 초과 시 400입니다

공통 인프라

  • PublicIdGenerator — ULID. 인덱스 지역성 때문입니다(클래스 주석 참조)
  • Base64CursorCodec — 비어 있던 CursorCodec 구현체. API 명세서 응답 예시 형식을 따랐습니다

검증

  • clean build 94건 통과
  • DB CHECK가 실제 DDL에 반영되는지 네이티브 UPDATE로 확인하는 테스트를 두었습니다
  • 실기동 확인 (명세서 테스트 시나리오)
    • 대분류 code로 등록 → 400 C001 "중분류를 선택해야 합니다"
    • flag OFF 등록 → 즉시 ACTIVE / 가격 999원 → 400 + fieldErrors[price]
    • 미인증 등록 → 401 / 비로그인 목록·상세 → 200

📌 확인 부탁드립니다

  • 경로 접두어: 화이트리스트를 /api/v1/listings/api/listings로 맞췄습니다.
    API 명세서와 이미 머지된 시세 조회가 v1 없는 경로라서요.
    /api/v1/auth/*는 인증 도메인 소유라 두었습니다
  • 매물 상태명: PENDING_VERIFICATION. ERD·요구사항 명세서 표기입니다
  • 지역 화이트리스트: 값이 정해지지 않아 형식 검증만 걸었습니다. 목록이 정해지면
    서버 검증을 추가해야 합니다
  • seller_id FK: users가 없어 컬럼으로만 뒀습니다. V1에서 겁니다
  • 목록 응답 필드: 명세에 없는 categoryCode·regionSido를 넣었습니다.
    regionSigungu만으로는 어느 시도인지 알 수 없어서인데, 빼는 게 맞다면 알려주세요

테스트 클래스마다 MySQL·Redis 컨테이너 선언과 DynamicPropertySource 블록이
그대로 복사돼 있어, 도메인이 늘어나는 만큼 같은 블록도 늘어난다.
IntegrationTestSupport로 옮기고 하위 테스트가 상속받게 한다.

컨테이너는 static 초기화 블록에서 직접 띄우고 내리지 않는다. @testcontainers는
컨테이너 수명을 테스트 클래스 단위로 관리해 클래스가 끝날 때마다 내렸다 다시
띄우는데, 그때 매핑 포트가 새로 잡힌다. 스프링 컨텍스트는 클래스 사이에
캐시되므로 사라진 옛 포트를 붙잡고 연결 거부가 난다. 클래스마다 컨테이너를
따로 두면 드러나지 않다가 공유하는 순간 터진다.

ServiceConnection으로 접속 정보를 넘겨 url·계정·포트 수동 주입을 없앤다.
해당 애노테이션이 spring-boot-testcontainers 모듈 소속이라 의존성을 추가한다.
매물 분류를 enum이 아니라 테이블로 둔다. 카테고리는 매물 필터·가격통계·챗봇이
공유하는 축이라 값이 늘거나 표시명이 바뀔 때 배포 없이 반영돼야 한다. 대신 모든
참조는 숫자 id가 아니라 code로 한다 - 시드 id는 환경마다 달라서 id로 참조하면
로컬에서 되던 것이 운영에서 깨진다.

시드는 기동 시 code 기준 upsert로 반영한다. 로컬은 create-drop이라 재기동마다
테이블이 비고, 카테고리가 비면 매물 등록이 전부 실패한다. data.sql은 로컬에서만
돌고 운영(validate + Flyway)에서는 돌지 않아 두 환경이 갈라진다. 정의에서 빠진
분류는 삭제하지 않고 비활성으로 내린다 - 매물·통계가 참조 중이라 지우면 고아가
된다.

응답은 평면 리스트이되 대분류별로 묶어 내린다. depth와 sortOrder만으로 정렬하면
각 대분류의 첫 자식들이 한 덩어리가 되어 형제가 흩어진다.

가격통계의 categorySnapshot이 categories 부재로 항상 null이던 제약이 이 테이블로
풀린다.
DIGITAL_MOBILE을 쓰고 있었으나 카테고리 마스터의 스마트폰 code는
DIGITAL_PHONE이다. code는 매물·통계가 분류를 참조하는 불변 식별자라 정의와
어긋나면 존재하지 않는 분류를 가리키게 된다. categorySnapshot 예시의 부모도
실제 대분류인 DIGITAL로 맞춘다.
public_id에 ULID를 쓴다. 이 값은 UNIQUE 세컨더리 인덱스인데 난수를 넣으면 INSERT마다
인덱스 곳곳에 흩어져 꽂혀 페이지 분할이 잦아진다. ULID는 앞 48비트가 시각이라 사전순이
곧 시간순이고 새 값이 인덱스 끝에 붙는다. 대가로 생성 시각이 값에 드러나므로, 시각이
어차피 공개되는 대상에만 쓴다.

직접 구현하지 않고 라이브러리를 쓴다 - 비트 배치와 Crockford Base32, 같은 밀리초 내
단조 증가 보장을 손으로 짜면 틀리기 쉽다.

CursorCodec은 인터페이스만 있고 구현체가 없었다. 주석이 남긴 세 후보(암호화 토큰 /
Redis 발급 이력 / 값 인코딩) 중 마지막을 택한다. API 명세서의 응답 예시가 Base64로 감싼
JSON이라 그 계약을 따른다. 커서는 비밀이 아니지만 신뢰하지도 않는다 - 조회는 언제나
공개 조건을 다시 걸고 커서는 시작 위치만 정한다.
상태 머신은 EnumMap 전이표로 규칙을 한 곳에 모은다. 다만 이건 동시성 방어가 아니다 -
서버 두 대가 동시에 다른 전이를 시도하면 각자 자기 메모리에서 검증해 둘 다 통과한다.
최종 심판은 조건부 UPDATE이고, 그건 상태 전이 API에서 붙인다.

검증 구간은 feature flag로 둔다. 검증 도메인이 나중에 붙기 때문에 초기 상태를
하드코딩하면 그때 등록 코드를 다시 써야 한다. 기본은 꺼둔다 - 켜면 등록이 검증 대기에서
멈춰 아무도 매물을 공개할 수 없다.

목록은 커서 페이지네이션이다. OFFSET은 앞의 n건을 세고 버려 뒤로 갈수록 느려지고,
조회 중 새 매물이 들어오면 항목이 밀려 중복·누락이 생긴다. 노출 조건은 허용 목록으로
짠다 - 거부 목록이면 나중에 상태가 추가될 때 검토 없이 자동 공개된다.

가격은 DTO와 엔티티 양쪽에서 막는다. DTO만 믿으면 이벤트 수신·배치처럼 컨트롤러를 안
거치는 경로로 잘못된 값이 들어오고, 그 한 건이 가격 통계를 오염시킨다.

seller_id에 FK를 걸지 않는다 - users는 인증 도메인 소유인데 아직 엔티티가 없다. 여기서
먼저 정의하면 남의 도메인 스키마를 선점하게 된다. V1 마이그레이션에서 건다.

보안 화이트리스트의 매물 경로를 /api/v1/listings에서 /api/listings로 맞춘다. API
명세서와 이미 머지된 시세 조회가 v1 없는 경로를 쓰고 있어 접두어가 갈려 있었다.
전이표를 정책이 명시한 것만 남긴다. 차단된 매물을 삭제로 내릴 수 있게 열어뒀는데, 제재
건을 지우면 거래 스냅샷·주문·신고 이력이 고아가 되고 이의제기 시 근거가 사라진다. 초안과
검증 대기에서 바로 삭제하는 전이도 정책에 없어 닫는다 - "그 외 전이 금지"가 원칙이라
있으면 편할 것 같은 경로를 미리 열어두지 않는다.

검증 대기에서 차단으로 가는 전이를 추가한다. 판정 점수가 차단 구간이면 공개되지 못하고
차단돼야 하는데 그 경로가 없었다.

목록과 상세의 노출 기준을 나눈다. 하나로 묶어 ACTIVE만 열었더니 팔린 매물의 상세가 404가
됐다. 거래 당사자가 나중에 무엇을 샀는지 확인하고 채팅·신고에서 넘어온 링크가 죽지 않아야
하므로, 상세는 SOLD까지 열고 차단·삭제만 없는 것으로 취급한다.

가격 제약을 DB에도 건다. 정책이 DTO 검증과 DB CHECK의 병행을 요구한다 - 컨트롤러를 안
거치는 경로로 들어온 값 하나가 가격 통계 전체를 오염시키기 때문이다. Hibernate의 @check는
7에서 deprecated라 Jakarta Persistence 3.2 표준을 쓴다. 애노테이션이 실제 DDL에 반영되는지
네이티브 UPDATE로 확인하는 테스트를 함께 둔다.

상세 응답의 카테고리·지역을 명세서대로 중첩 객체로 바꾼다.
@RootToApex RootToApex added the enhancement New feature or request label Aug 31, 2026
@RootToApex RootToApex self-assigned this Aug 31, 2026
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 66fa7fd6-6b1a-46e5-8a67-09d64f363c29


Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant