[Feat] 카테고리 마스터 테이블과 조회 API - #15
Conversation
테스트 클래스마다 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로 맞춘다.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: Comment |
There was a problem hiding this comment.
리뷰 요약
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. 공개 엔드포인트 계약에 테스트가 없습니다
ApiContractTest는 health_isPublic, listings_isWhitelisted처럼 화이트리스트를 한 줄씩 고정해 두는 파일인데 이번 permitAll만 빠졌습니다.
CategoryControllerTest는 standaloneSetup + 서비스 목이라 시큐리티도, 실제 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건일 수도 있습니다.
@AfterEach를 clear() 대신 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에 세 번 강조된 규칙인데, deactivateRemoved의 parentRemoved 분기를 타는 테스트가 6건 중 하나도 없습니다. 4-(b)와 같은 코드 경로라, 이 테스트가 있었으면 그 문제도 같이 드러났을 것 같습니다.
deferred 이슈로 남기면 좋을 것
6. 시더 vs 관리자 편집 API 충돌 — SecurityConfig 주석에 "관리자용 카테고리 편집 API는 별도 경로로 만들 예정"이라고 적혀 있는데, 시더는 기동마다 rename(정의값) + activate()를 무조건 겁니다. 관리자가 표시명을 바꾸거나 분류를 내려도 다음 배포에서 원복됩니다. "코드가 원본이면 편집 API를 두지 않는다" / "DB가 원본이면 시더는 최초 1회만" 중 하나를 골라야 합니다.
7. 다중 인스턴스 기동 레이스 — 서버 2대가 동시에 뜨면 둘 다 시더를 돌립니다. uk_categories_code 덕에 데이터는 안 깨지지만 진 쪽은 DataIntegrityViolationException으로 기동 자체가 실패합니다(ApplicationRunner 예외 = 부팅 실패). 지금은 1대라 안 터지지만, application.yml의 chat-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.category는 length = 30, categories.code는 length = 50. 같은 값을 담는데 다릅니다. 현재 최장 code가 17자(APPLIANCE_KITCHEN)라 안 터지지만, 31자짜리가 생기면 카테고리는 저장되고 통계 insert만 조용히 실패합니다. 두 테이블을 code로 잇는 첫 PR이니 여기서 맞추는 게 자연스러운데, 남의 도메인 컬럼이라 가격추세 담당과 합의가 필요합니다.
10. deferred #13은 아직 닫히지 않습니다 — 본문은 "categorySnapshot이 항상 null이던 제약이 이 PR로 풀린다"고 쓰는데, 이 PR은 테이블만 만들고 snapshot을 채우거나 category code를 마스터와 대조하는 코드는 없습니다. "선행 조건 해소"로 적고 이슈는 열어두는 게 정확할 것 같습니다.
사소한 것
MySQLContainerdeprecated —org.testcontainers.containers→org.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 초기화를 쓴 이유를 "편해 보인다고 다시 붙이지 말 것"까지 써 둔 것 — 실제로 재발을 막는 주석입니다- 물리 삭제 금지 + 재활성 경로까지 테스트로 고정한 것
|
@DGAZA-max 리뷰 확인했습니다. 반영한 것 아래 정리했습니다.
사소한 것 3건( 4-(b)는 성립하지 않습니다. code가 6~10번은 deferred 이슈로 열었습니다 — #20 #21 #22 #23. #23은 가격추세 컬럼이라 재민님으로 지정했습니다.
|
💡 개요
매물 분류를 담을
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 기준 upsertdata.sql을 쓰지 않은 이유: 로컬에서만 돌고 운영(validate + Flyway)에서는돌지 않아 두 환경이 갈라집니다
조회 API
GET /api/categories— 비로그인 허용, 활성 분류 전량id는 응답에 넣지 않습니다테스트 공통화
IntegrationTestSupport로 추출하고,기존
ApiContractTest·PriceStatisticsRepositoryTest도 상속으로 옮겼습니다@Testcontainers대신 static 초기화로 컨테이너를 띄웁니다. 클래스 단위수명 관리는 클래스마다 컨테이너를 내렸다 다시 띄워 포트가 바뀌는데,
캐시된 스프링 컨텍스트가 옛 포트를 붙잡아 연결 거부가 납니다
검증
clean build전체 테스트 65건 통과GET /api/categories200, 64건 (대분류 12 / 중분류 52)📌 참고
ETC_OTHER(기타)를 남긴 이유: 분류가 애매한 매물의 등록 경로가 필요하고,MVP 이후 상품명 기반 집계가 들어오면 카테고리 축의 한계는 해소됩니다
ddl-auto로 생성되지 않으므로, 검색 API 착수시 마이그레이션에 명시적으로 넣어야 합니다
🔁 리뷰 반영 (DGAZA-max)
ApiContractTest에 GET 200 / 쓰기 비통과 2건 추가@AfterEach를 삭제 대신 시더 재실행으로 원복Category.child에 접두어 검증 + 시더에 code 중복 검증 추가CategorySeedDataTest로 분리MySQLContainerdeprecatedorg.testcontainers.mysql로 이동(새 클래스는 제네릭 없음)doesNotExist()가 null 통과value(nullValue())로 교체4-(b)만 반박합니다. "중분류를 다른 대분류로 옮기면 정의에 있는데도 비활성"은 성립하지 않습니다 — code 규약이
{대분류}_{중분류}라 부모가 바뀌면 code 자체가 바뀌고, 옛 code는 비활성 / 새 code는 신규 생성이 의도한 동작입니다. 다만 그 규약이 실질적 안전장치라는 지적이 맞아Category.child에서 코드로 못 박았습니다.나머지(6~10)는 deferred 이슈로 등록했습니다.