Skip to content

[Feat] 카테고리 마스터 테이블과 조회 API - #15

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

[Feat] 카테고리 마스터 테이블과 조회 API#15
RootToApex wants to merge 7 commits into
devfrom
feat/category

Conversation

@RootToApex

@RootToApex RootToApex commented Aug 30, 2026

Copy link
Copy Markdown
Member

💡 개요

매물 분류를 담을 categories 마스터 테이블과 조회 API를 추가합니다.
대분류 12 / 중분류 52, 2뎁스 고정입니다.

가격통계의 categorySnapshot이 막혀 있던 선행 조건이 이 PR로 해소됩니다
categories 테이블이 생깁니다. 다만 이 PR은 테이블만 만들고 snapshot을 채우거나 category
code를 마스터와 대조하지는 않으므로 deferred #13은 열어둡니다.

매물(listings) 엔티티는 이 PR 범위 밖이며 다음 PR에서 올립니다.

🛠️ 작업 내용

카테고리 마스터

  • Category 엔티티 — 2뎁스 고정, 중분류의 부모는 반드시 대분류
  • code는 불변 업무 식별자. 모든 참조는 숫자 id가 아니라 code로 합니다
    (시드 id는 환경마다 달라 id로 참조하면 로컬에서만 동작합니다)
  • 물리 삭제 금지. 정의에서 빠진 분류는 active=false로 내립니다

시드

  • CategorySeeder(ApplicationRunner)가 기동 시 code 기준 upsert
  • data.sql을 쓰지 않은 이유: 로컬에서만 돌고 운영(validate + Flyway)에서는
    돌지 않아 두 환경이 갈라집니다
  • 재실행해도 행 수와 id가 유지됩니다 (id가 바뀌면 매물·통계 FK가 끊깁니다)

조회 API

  • GET /api/categories — 비로그인 허용, 활성 분류 전량
  • 평면 리스트이되 대분류별로 묶어서 내립니다
  • 숫자 id는 응답에 넣지 않습니다

테스트 공통화

  • 컨테이너 선언이 테스트마다 복사돼 있어 IntegrationTestSupport로 추출하고,
    기존 ApiContractTest·PriceStatisticsRepositoryTest도 상속으로 옮겼습니다
  • @Testcontainers 대신 static 초기화로 컨테이너를 띄웁니다. 클래스 단위
    수명 관리는 클래스마다 컨테이너를 내렸다 다시 띄워 포트가 바뀌는데,
    캐시된 스프링 컨텍스트가 옛 포트를 붙잡아 연결 거부가 납니다

검증

  • clean build 전체 테스트 65건 통과
  • 실기동 확인: GET /api/categories 200, 64건 (대분류 12 / 중분류 52)
  • 시더 멱등성 실기동 확인: 재기동 시 신규 0건, 행 수 변화 없음

📌 참고

  • 카테고리 실제 목록 값은 이번 PR에서 확정했습니다
  • ETC_OTHER(기타)를 남긴 이유: 분류가 애매한 매물의 등록 경로가 필요하고,
    MVP 이후 상품명 기반 집계가 들어오면 카테고리 축의 한계는 해소됩니다
  • 매물 검색용 FULLTEXT 인덱스는 ddl-auto로 생성되지 않으므로, 검색 API 착수
    시 마이그레이션에 명시적으로 넣어야 합니다

🔁 리뷰 반영 (DGAZA-max)

지적 조치
1. 본문 수치 stale 12/52/64로 갱신, 테스트 건수도 65로
2. 공개 엔드포인트 계약 테스트 없음 ApiContractTest에 GET 200 / 쓰기 비통과 2건 추가
3. 시더 테스트가 DB를 비운 채 끝남 @AfterEach를 삭제 대신 시더 재실행으로 원복
4. code 규약이 문서에만 있음 Category.child에 접두어 검증 + 시더에 code 중복 검증 추가
5. 자식 비활성 테스트 없음 대분류 비활성 시 자식도 내려가는 테스트 추가
6. 검증이 컨테이너를 띄움 code 유일성·접두어를 DB 없는 CategorySeedDataTest로 분리
MySQLContainer deprecated org.testcontainers.mysql로 이동(새 클래스는 제네릭 없음)
시더 로그 "갱신 N건" "대조 N건"으로 정정 — 값이 같으면 아무것도 안 바뀜을 드러냄
doesNotExist()가 null 통과 value(nullValue())로 교체

4-(b)만 반박합니다. "중분류를 다른 대분류로 옮기면 정의에 있는데도 비활성"은 성립하지 않습니다 — code 규약이 {대분류}_{중분류}라 부모가 바뀌면 code 자체가 바뀌고, 옛 code는 비활성 / 새 code는 신규 생성이 의도한 동작입니다. 다만 그 규약이 실질적 안전장치라는 지적이 맞아 Category.child에서 코드로 못 박았습니다.

나머지(6~10)는 deferred 이슈로 등록했습니다.

테스트 클래스마다 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로 맞춘다.
@RootToApex RootToApex added the enhancement New feature or request label Aug 30, 2026
@RootToApex RootToApex self-assigned this Aug 30, 2026
@coderabbitai

coderabbitai Bot commented Aug 30, 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: 285c89b4-d51b-4dfc-bac9-9929ae9f0c26


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

@DGAZA-max DGAZA-max left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰 요약

PR 브랜치를 그대로 체크아웃해서 확인했습니다. ./gradlew clean test (JDK 17) 전체 통과 — 59건, 실패 0. 실기동해서 카테고리 응답 건수도 확인했습니다.

설계 판단은 대체로 타당하고 근거도 코드에 잘 남아 있습니다. 1번만 정리되면 머지 가능하다고 봅니다.


머지 전에 고쳐야 할 것

1. PR 본문 수치가 마지막 커밋 이전 것입니다

본문 실제 코드(0b00025)
대분류 11 12
중분류 49 52
합계 60 64 (실기동 확인)

마지막 커밋 add: 생활·주방 대분류 추가가 본문에 반영되지 않았습니다. CategorySeedData javadoc은 12/52로 정확히 고쳐져 있어서 본문만 stale입니다. "검증" 절의 59건 통과·실기동 60건도 그 커밋 이전 수치인데, 리뷰어가 근거로 삼는 부분이라 갱신 부탁드립니다.


머지 전 권장

2. 공개 엔드포인트 계약에 테스트가 없습니다

ApiContractTesthealth_isPublic, listings_isWhitelisted처럼 화이트리스트를 한 줄씩 고정해 두는 파일인데 이번 permitAll만 빠졌습니다.

CategoryControllerTeststandaloneSetup + 서비스 목이라 시큐리티도, 실제 ObjectMapper도, DB→응답 경로도 타지 않습니다. 즉 "비로그인 200"의 유일한 근거가 본문의 수동 실기동 확인입니다. ApiContractTest에 2줄이면 됩니다 — 인증 없이 GET 200, POST는 401/405.

3. 시더 테스트가 공유 DB를 비운 채 끝납니다 (순서 의존)

CategorySeederTest@Transactional을 쓰지 않는 이유는 타당합니다. 다만 @AfterEach clear()가 categories를 전부 지우고 끝나고, MySQL 컨테이너는 전체 테스트가 공유합니다(브랜치를 그대로 돌려보니 스프링 컨텍스트는 2개 뜨지만 컨테이너·DB는 하나입니다).

지금은 아무도 안 깨집니다. 하지만 2번을 ApiContractTest에 넣는 순간, JUnit 클래스 실행 순서에 따라 카테고리가 64건일 수도 0건일 수도 있습니다.

@AfterEachclear() 대신 categorySeeder.run(null)로 원복하거나, 시더에 스위치(app.seed.categories.enabled)를 두는 편이 안전합니다. 후자면 CategoryRepositoryTest.clearSeeded() 우회도 같이 사라집니다.

4. 시더가 부모(parent_id)를 교정하지 않습니다

rename(name, sortOrder)만 부르고 parent는 손대지 않습니다. DB에서 DIGITAL_PHONE의 부모를 FASHION으로 옮겨놓고 시더를 재실행해 봤습니다.

before=DIGITAL  after=FASHION  active=true

로그에는 "갱신"으로 잡히는데 아무것도 고쳐지지 않았습니다. "기동 시 정의에 맞춘다"는 계약이 name/sortOrder에서만 성립합니다. 현실적 트리거 둘:

  • (a) CategorySeedData에 같은 code가 두 번 들어가면(복붙) 두 번째는 조용히 무시되고 첫 번째의 부모·순서가 남습니다. code 중복을 막는 검증이 어디에도 없습니다.
  • (b) 중분류를 다른 대분류로 옮기는 정의 변경 시, leaf는 definedCodes에 있는데 옛 부모가 사라져 parentRemoved 분기를 타고 정의에 있는데도 비활성이 됩니다.

(b)는 {대분류}_{중분류} code 규약(= code 불변) 덕에 잘 안 일어납니다. 다만 그 규약이 실질적 안전장치라면 테스트가 아니라 코드로 못 박는 게 맞을 것 같습니다 — Category.child에서 code.startsWith(parent.code + "_") 검증.

최소한 code 전역 유일성과 접두어 규약은 DB 없는 순수 단위 테스트로 고정하면 좋겠습니다. 지금 leafCodeCarriesParentPrefix는 정적 데이터 검증인데 MySQL 컨테이너를 띄웁니다.

5. "대분류를 내리면 자식도 내린다"에 테스트가 없습니다

엔티티·시더·CategorySeedData javadoc에 세 번 강조된 규칙인데, deactivateRemovedparentRemoved 분기를 타는 테스트가 6건 중 하나도 없습니다. 4-(b)와 같은 코드 경로라, 이 테스트가 있었으면 그 문제도 같이 드러났을 것 같습니다.


deferred 이슈로 남기면 좋을 것

6. 시더 vs 관리자 편집 API 충돌SecurityConfig 주석에 "관리자용 카테고리 편집 API는 별도 경로로 만들 예정"이라고 적혀 있는데, 시더는 기동마다 rename(정의값) + activate()를 무조건 겁니다. 관리자가 표시명을 바꾸거나 분류를 내려도 다음 배포에서 원복됩니다. "코드가 원본이면 편집 API를 두지 않는다" / "DB가 원본이면 시더는 최초 1회만" 중 하나를 골라야 합니다.

7. 다중 인스턴스 기동 레이스 — 서버 2대가 동시에 뜨면 둘 다 시더를 돌립니다. uk_categories_code 덕에 데이터는 안 깨지지만 진 쪽은 DataIntegrityViolationException으로 기동 자체가 실패합니다(ApplicationRunner 예외 = 부팅 실패). 지금은 1대라 안 터지지만, application.ymlchat-fanout 주석은 이미 서버 2대를 전제하고 있습니다.

8. V1__init.sql — categories는 엔티티 DDL로만 존재합니다. CLAUDE.md대로 첫 배포 전 V1을 손으로 쓸 때 code의 collation(utf8mb4_0900_ai_ci), uk_categories_code, idx_categories_parent_sort, depth CHECK(1,2)는 엔티티 덤프에 안 나옵니다. 본문 참고에 FULLTEXT 얘기는 있는데 이건 없습니다.

9. 컬럼 길이 불일치price_statistics.categorylength = 30, categories.codelength = 50. 같은 값을 담는데 다릅니다. 현재 최장 code가 17자(APPLIANCE_KITCHEN)라 안 터지지만, 31자짜리가 생기면 카테고리는 저장되고 통계 insert만 조용히 실패합니다. 두 테이블을 code로 잇는 첫 PR이니 여기서 맞추는 게 자연스러운데, 남의 도메인 컬럼이라 가격추세 담당과 합의가 필요합니다.

10. deferred #13은 아직 닫히지 않습니다 — 본문은 "categorySnapshot이 항상 null이던 제약이 이 PR로 풀린다"고 쓰는데, 이 PR은 테이블만 만들고 snapshot을 채우거나 category code를 마스터와 대조하는 코드는 없습니다. "선행 조건 해소"로 적고 이슈는 열어두는 게 정확할 것 같습니다.


사소한 것

  • MySQLContainer deprecatedorg.testcontainers.containersorg.testcontainers.mysql. 기존 코드에서 옮겨온 거지만, 모두가 상속할 공통 부모를 만드는 PR이니 import 한 줄 고치기 좋은 타이밍입니다.
  • 시더 로그 "갱신 N건" — 실제 변경 여부와 무관하게 기존 행 수를 셉니다. 재기동마다 "갱신 64건"이 찍히는데 그게 정상 상태입니다. 4번의 "고친 줄 알았는데 안 고쳐짐"과 겹쳐서 로그가 진단에 쓰이기 어렵습니다. 변경된 것만 세거나 이름을 "확인"으로 바꾸면 좋겠습니다.
  • doesNotExist()는 JSON null을 통과시킵니다parentCode").doesNotExist()는 값이 null이어도 통과합니다. 실제 응답엔 "parentCode": null이 들어갑니다(spring.jackson에 inclusion 설정 없음). 의도가 "대분류엔 parentCode가 없다"면 .value(nullValue())가 정확합니다.

좋았던 부분

  • data.sql 대신 코드 시더를 택한 판단과 그 이유를 남긴 것 — 로컬/운영이 갈라지는 문제를 정확히 짚었습니다
  • id 대신 code를 외부 식별자로 고정하고 응답에서 숫자 id를 뺀 것
  • @Testcontainers 대신 static 초기화를 쓴 이유를 "편해 보인다고 다시 붙이지 말 것"까지 써 둔 것 — 실제로 재발을 막는 주석입니다
  • 물리 삭제 금지 + 재활성 경로까지 테스트로 고정한 것

@RootToApex

RootToApex commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

@DGAZA-max 리뷰 확인했습니다. 반영한 것 아래 정리했습니다.

  1. 본문 수치 — 12/52/64로 갱신
  2. 공개 엔드포인트 계약 — ApiContractTest에 인증 없이 GET 200 / 인증 없는 쓰기 비통과 2건 추가
  3. 시더 테스트가 DB를 비운 채 끝나는 문제 — @AfterEach를 삭제 대신 시더 재실행으로 원복
  4. code 규약 — Category.child에 접두어 검증, 시더에 code 중복 검증 추가
  5. 자식 비활성 테스트 추가
  6. 정적 검증이 컨테이너를 띄우던 문제 — CategorySeedDataTest로 분리해 DB 없이 실행

사소한 것 3건(MySQLContainer 패키지, 시더 로그 "대조"로 정정, value(nullValue()))도 반영했습니다.

4-(b)는 성립하지 않습니다. code가 {대분류}_{중분류}라 부모를 바꾸면 code 자체가 바뀝니다. 옛 code 비활성 / 새 code 생성이 의도한 동작입니다. 다만 규약을 코드로 강제하라는 부분은 반영했습니다.

6~10번은 deferred 이슈로 열었습니다 — #20 #21 #22 #23. #23은 가격추세 컬럼이라 재민님으로 지정했습니다.
deferred #13은 "선행 조건 해소"로 본문 고치고 이슈는 열어뒀습니다.

a19ec30 5b9114e

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.

2 participants