feat(admin): 제품 성분·기능성 검수 보완, 설문 근거 관리 - #6
Merged
Conversation
## 특성 읽기·삭제·쓰기 세 곳에 property_key='PURITY' 가 박혀 있어 순도 외에는 아무것도 담을 수 없었다. 실제로 필요한 것은 "이 제품의 히알루론산은 저분자", "아줄렌 순도 99%" 처럼 성분·제품마다 다른 축이다. property_definition 에 정의된 어떤 키든 받도록 일반화한다 — 수치형은 min/max, 범주형은 valueText. ## 화면 - 워크플로 1단계에 한글 이름 입력(Mongo products.name_ko). 전성분과 같은 폼이라 저장이 한 번이다. 비워 두면 유지한다 — 지우려는 의도와 구분할 수 없다. - 상세에 "성분별 보완" 섹션 신설. productIngredients 를 컨트롤러가 모델에 넣고 있었는데 템플릿이 렌더링하지 않아 행별 편집 UI 가 아예 없었다. 사전 연결 배지, 미연결 건수, 함량, 특성 입력을 한 자리에서 처리한다. ## 포트 ProductIngredientPort + JDBC/API 두 어댑터. 전환은 설정 한 줄이다. admin.product-ingredient.source = jdbc(기본) | api API 어댑터의 propertyDefinitions() 는 일부러 UnsupportedOperationException 을 던진다. V2 에 정의 API 가 없어 빈 목록을 주면 화면에 특성 칸이 안 뜨는데, 그것이 "고장"이 아니라 "입력할 게 없음"으로 보여 원인을 찾기 어렵다. ## 곁들여 nameKo 추가가 워킹트리에 미완성으로 있어 테스트 컴파일이 깨져 있었다. 인자를 맞췄다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
성분 → 함량 → 한글 이름 → 기능성 순서로 나눈다. 함량은 성분이 있어야 채울 수 있고, 기능성 확인은 성분을 보고 판단한다. 한글 이름은 성분과 무관하지만 기능성보다 가벼워 앞에 둔다. - 성분 행 편집 UI 를 fragments/ingredient-rows 조각으로 뺐다. 상세 화면과 워크플로 2단계가 같은 조각을 쓴다 — 한쪽만 고쳐 입력 규칙이 갈리는 것을 막는다. - 함량·특성은 행마다 따로 저장한다. 성분 30개를 한 폼으로 묶으면 하나만 고쳐도 전부 다시 보내야 하고, 중간에 틀리면 어디가 틀렸는지 찾기 어렵다. - 성분을 찾지 못한 제품은 채울 함량이 없으므로 함량 단계를 건너뛴다. - 단계 표시(step-trail)로 지금 어디이고 몇 개가 남았는지 보인다.⚠️ List.of() 는 불변 리스트라 contains(null) 이 false 가 아니라 NPE 다. step 파라미터는 대개 없으므로 null 검사가 반드시 앞에 와야 한다(테스트로 확인). 한글 이름 저장은 1단계에서 3단계로 옮겼다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
푸시 문의 대응용이다. "푸시가 안 온다", "지운 기기로 계속 온다"는 죽은 토큰이 원인인 경우가 많은데 확인하고 끊을 자리가 없었다. - 사용자 ID 로만 조회한다. 토큰 역조회를 두지 않는 이유: 어드민이 토큰을 알 방법이 없고, 입력받는 화면을 두면 그 값이 로그·브라우저 기록에 남는다. - 토큰은 앞 6자 + 뒤 4자만 보여 준다. 전문이 있으면 그것만으로 발송할 수 있다. - userId 없이 들어오면 조회하지 않는다 — 전체 기기 목록은 볼 이유가 없고 양도 많다. - 해제는 확인 모달을 거친다(되돌리려면 사용자가 앱에서 알림을 다시 켜야 한다). NotificationDevicePort 로 감싸 둔다 — ProductIngredientPort 와 같은 이유다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
## 계약 테스트 두 어댑터를 실제로 나란히 돌리려면 Postgres 와 product-service 가 둘 다 떠 있어야 한다. 그런데 실제로 깨지는 지점은 대부분 그게 아니라 필드 이름 어긋남이다 — V2 가 응답 필드를 바꾸면 어댑터에서 조용히 null 이 되고 화면에는 빈 칸으로만 보인다. HTTP 를 흉내 내는 것만으로 그 어긋남을 잡는다. 실제로 잡았다: V2 응답에 inciName/displayNameKo 를 추가했는데 어댑터가 버리고 있었다. ## 타임아웃 설정 위치 어댑터가 requestFactory() 를 부르면 MockRestServiceServer 가 붙여 둔 팩토리를 덮어써서 흉내 내려던 요청이 진짜로 나간다(실측). 타임아웃은 AdminHttpClientConfig 로 옮기고 어댑터는 baseUrl 만 정한다. 🔴 타임아웃 자체를 빼면 안 된다 — 기본값은 무한 대기라 호출 대상이 멈추면 어드민 화면도 함께 멈춘다. ## 계층 NotificationDeviceController 가 포트를 직접 잡고 있었다. 다른 도메인은 전부 서비스를 거치는데 여기만 어긋났다. NotificationDeviceService 를 넣는다 — 지금은 넘기기만 하지만 사용자 ID 검증이나 해제 사유 기록 같은 규칙이 생기면 그 자리가 필요하다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
성분 보완 탭에서 성분과 함량을, 기능성 확인 탭에서 한글 이름과 기능성을 입력한다. 4단계 마법사로 쪼갰던 것을 되돌린다 — 두 탭은 근거 자료가 달라서 나뉜 것이지, 입력 항목마다 나누면 저장 버튼만 늘고 어드민이 같은 제품을 네 번 열게 된다. 성분을 저장하면 같은 탭에 남아 방금 저장된 목록에 함량을 채우게 한다. 화면을 옮기면 맥락이 끊긴다. 성분을 찾지 못한 경우에만 큐로 돌아간다(채울 함량이 없다). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
어드민이 제품을 등록할 때는 이미 상세 페이지를 보고 있어서 한글 이름을 알고 있다. 검수 단계까지 미루면 같은 제품을 다시 열어야 한다. 함량은 등록 폼에서 받지 않는다 — 성분 행이 저장돼야 각 행에 붙일 수 있기 때문이다. 대신 성분을 함께 넣은 경우 등록 직후 성분 보완 탭으로 보내 그 자리에서 채우게 한다. 결과적으로 기능성만 별도 검수로 남는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
기능성 스크리닝의 ClaudeProductNameResolver 가 com.anthropic.* 를 쓰는데 build.gradle.kts 에 의존성이 없어 컴파일 단계에서 8건이 깨졌다.⚠️ LLM 을 끄는 것은 admin.functional-screening.llm.enabled=false 로 하는 것이지 의존성을 빼는 것이 아니다 — @ConditionalOnProperty 는 런타임 스위치라 클래스 자체는 항상 컴파일된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
설문이 "근거 기반"이어야 한다는 요구. 지금은 문항과 선택지만 있어서 "이 문항을 왜 묻는지"가 사람 머릿속에만 있다 — 문항을 고치거나 뺄 때 판단할 자료가 없고, 사용자에게 설명할 근거도 없다. 🔴 1:N 이다. 문항 하나가 근거 하나만 가질 이유가 없다 — 논문 하나로 시작해도 나중에 가이드라인·임상 자료가 붙는다. 1:1 로 만들면 두 번째 근거가 생기는 순간 스키마를 다시 바꾼다. 🔴 rationale 을 필수로 받는다. 링크만 모으면 "왜 묻는지"는 여전히 사람 머릿속에 남는다. 링크를 열지 않고도 판단할 수 있어야 한다. - url 은 nullable(내부 자료). (question_key, url) 유일 인덱스는 url is not null 조건부라 링크 없는 자료는 제목이 같아도 별개로 남는다 — 의도한 동작이다. - source_type 은 신뢰도 표시용이라 알 수 없는 값을 거부하지 않고 ARTICLE 로 흘린다. 근거를 못 남기는 쪽이 더 나쁘다. 마이그레이션은 스키마 주인 두 곳에 넣는다 — api-server V15, skin-service V10. 실제 Postgres 16 에 V1~V15 를 적용해 통과와 삽입을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
## 500 SurveyAdminController 에 return "survey" 가 다섯 군데 있는데(정상 렌더 하나 + 검증 실패 네 군데) 근거 목록을 GET 한 곳에만 넣었다. 그래서 중복 문항 키나 빈 값으로 추가하면 템플릿이 null 을 참조해 터졌고, 그 오류를 쓰려다 HttpMessageNotWritableException 으로 500 이 됐다. 정상 흐름은 멀쩡해서 눈으로도 테스트로도 안 걸린다. populateSurveyModel() 로 묶어 손으로 채우는 곳을 없앤다. ## UI 문항마다 표·수정 폼·추가 폼을 펼쳐 두니 정작 자주 쓰는 선택지 편집이 스크롤 아래로 밀렸다. 접기로 한 번 줄였지만 중첩 details 라 여전히 번잡했다. 이 페이지가 이미 쓰는 관례로 맞춘다 — 목록은 선택지와 같은 표, 추가는 상단 툴바 버튼에서 여는 드로어(대상 문항 라디오 + 입력). 문항 카드 안에 추가 폼을 두지 않으니 같은 폼이 문항 수만큼 반복되지 않는다. 실패하면 드로어를 다시 열어 입력이 어디로 갔는지 알 수 있게 한다. 엔드포인트도 드로어에 맞춰 POST /admin/survey/evidence 로 옮겼다(questionKey 는 본문). 접기용으로 넣었던 CSS 는 지웠다 — 죽은 규칙을 남기지 않는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
표에 textarea 를 넣으니 근거 하나가 서너 줄을 먹어, 정작 자주 쓰는 선택지 편집이 스크롤 아래로 밀렸다. 접힌 상태는 한 줄이고 펼쳐야 편집 칸이 나온다. 접힌 줄에도 이유를 미리 보여 준다(넘치면 말줄임) — 펼치지 않고도 무엇에 대한 근거인지 알 수 있어야 목록으로서 쓸모가 있다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
판정에 임계값을 통과한 후보만 넘기고 있었다. 유사도는 표기가 크게 다른 제품을 통째로 떨어뜨려서, "토리든 다이브인 저분자 히알루론산 세럼"은 그 브랜드 등록 2건과 모두 0.5 미만이라 판정이 아예 호출되지 않았고 화면에는 "후보 없음"만 떴다. 어드민이 직접 판단할 근거가 화면에 하나도 남지 않는다. 이제 점수는 정렬에만 쓰고, 브랜드 등록 목록을 점수순으로 넉넉히(기본 20건) 넘겨 고르는 일을 판정에 맡긴다. 실제로 "Beauty of Joseon 릴리프 선"이 유사도 0.36 으로 꼴찌였던 '조선미녀맑은쌀선크림'에 걸렸다 — 유사도만 봤으면 못 찾았을 건이다. 함께 고친 것들: - 브랜드 별칭을 첫 성공에서 멈추지 않고 전부 조회해 합친다. 표기가 갈리는 브랜드가 있고, 한 표기로 몇 건 나왔다고 나머지를 안 보면 정작 맞는 제품을 놓친다. - 이름을 일부만 적어도 후보가 남는다. "달바 워터풀"은 등록명과 유사도 0.59 라 잘렸는데, 정작 우리가 적은 이름은 등록명에 통째로 들어 있다. coverage 로 그런 건을 건지되 자동 확정에는 쓰지 않는다 — 같은 계열 다섯 건이 모두 1.0 이라 고를 근거가 못 된다. - 브랜드가 이름 앞에 두 번 붙던 문제. "달바" + "달바 워터풀" 이 "달바달바워터풀" 로 조회돼 한 건도 안 나왔다. - 브랜드 표기 프롬프트를 실제로 갈리는 축으로 바꿨다. 법인 표기는 넣지 않는다 — 등록명 19만 건 중 "(주)" 4건, "주식회사" 5건뿐이라 붙이면 오히려 0건이 된다. - Mongo 한글 이름 필드를 name_ko → name_kr 로. 같은 컬렉션에 쓰는 product-service 도 함께 바꿔야 하고, 배포 전에 기존 문서에 $rename 을 돌려야 한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01146dyT7CQcWMzkCFpkPPj4
## 확정 판정이 골랐다고 확정하지 않는다. 이름이 완전히 같은 경우를 빼면 확정은 사람 몫이다 — 모델이 고른 근거가 아무리 그럴듯해도 등록명이 다르다는 건 다른 제품일 수 있다는 뜻이고, 기능성은 틀렸을 때 되돌리는 비용이 한 번 더 클릭하는 것보다 크다. 대신 고른 것을 맨 앞에 세우고 유형까지 채워 둬 맞으면 저장만 누르면 되게 한다. 점수 하나로도 확정되지 않는다 — 숫자 토큰 일치·업체명 연결·기능성 유형 도출을 모두 통과해야 한다. 실측에서 "닥터디퍼런트 311 모이스처라이저"가 등록명 "닥터디퍼런트131 모이스처라이저"와 0.94 였는데 숫자 규칙에서 걸린다. ## 화면 전에는 .is-selected 클래스만 붙이고 스타일을 안 만들어서 모델이 고른 것이 화면에 전혀 드러나지 않았다. 후보가 여덟 줄 깔린 표에서 어느 것을 보라는 건지 알 수 없으면 자동 조회를 돌린 의미가 없다. 배경·좌측 바에 더해 "AI 지목" 칩으로 글자로도 남긴다 — 클래스만으로는 스타일이 빠지면 아무것도 안 보인다. 설정값(coverage-threshold·judge-pool-size·max-brand-aliases·max-search-terms)에 각각 무엇을 조절하는지 주석을 붙였다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
전에는 성분이 하나라도 있으면 통째로 건너뛰었다. 그래서 시드 목록에 성분을 추가해도 영영 들어가지 않는다 — 코드에는 있는데 DB 에는 없어서 "왜 없지"를 찾는 데 시간을 쓴다. 항상 upsert 로 바꾸지 않는 이유: save() 는 별칭·효능을 지우고 다시 넣어서, 어드민이 화면에서 고친 값을 재기동이 되돌린다. 시드의 목적은 기본 성분을 깔아 두는 것이지 코드 값을 강제하는 것이 아니다. 🔴 saveIfAbsent 는 id 와 inci_name 을 둘 다 본다. id 로만 보면 어드민이 같은 성분을 다른 id 로 만들어 둔 경우 삽입이 uq_ingredient_inci_name(대소문자 무시) 위반으로 터지고, 시드가 ApplicationRunner 라 그대로 기동 실패가 된다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
왜
제품 검수(성분·함량·기능성)와 설문을 어드민에서 끝까지 다룰 수 있게 한다. 지금은 성분을 텍스트로만 적을 수 있고, 함량·특성을 넣을 자리가 없으며, 설문 문항을 왜 묻는지는 사람 머릿속에만 있다.
제품 성분
특성을 임의 키로 확장. 읽기·삭제·쓰기 세 곳에
property_key='PURITY'가 박혀 있어 순도 외에는 아무것도 담을 수 없었다. 실제로 필요한 것은 "이 제품의 히알루론산은 저분자", "아줄렌 순도 99%" 처럼 성분·제품마다 다른 축이다.property_definition에 정의된 어떤 키든 받는다 — 수치형은 min/max, 범주형은valueText.행별 편집 UI 신설.
productIngredients를 컨트롤러가 모델에 넣고 있었는데 템플릿이 렌더링하지 않아 편집 화면이 아예 없었다. 사전 연결 배지·미연결 건수·함량·특성을 한 자리에서 처리한다.검수는 기존 2탭 유지. 성분 보완(성분+함량), 기능성 확인(한글 이름+기능성). 4단계 마법사로 쪼갰다가 되돌렸다 — 두 탭은 근거 자료가 달라 나뉜 것이지, 입력 항목마다 나누면 저장 버튼만 늘고 같은 제품을 네 번 열게 된다.
신규 등록에서 한글 이름·전성분을 미리 받는다. 어드민이 등록할 때는 이미 상세 페이지를 보고 있어 한글 이름을 안다. 성분을 함께 넣으면 등록 직후 함량 화면으로 이어진다.
기능성 자동 판정
🔴 판정이 골랐다고 확정하지 않는다. 이름이 완전히 같은 경우를 빼면 확정은 사람 몫이다 — 모델이 고른 근거가 그럴듯해도 등록명이 다르다는 건 다른 제품일 수 있다는 뜻이고, 기능성은 틀렸을 때 되돌리는 비용이 한 번 더 클릭하는 것보다 크다. 대신 고른 것을 맨 앞에 세우고 유형까지 채워 둔다.
점수 하나로도 확정되지 않는다(숫자 토큰·업체명·유형 도출을 모두 통과해야 한다). 실측에서 "닥터디퍼런트 311 모이스처라이저"가 등록명 "닥터디퍼런트131모이스처라이저"와 0.94 였는데 숫자 규칙에서 걸린다.
.is-selected클래스만 붙이고 스타일이 없어 모델이 고른 것이 화면에 전혀 안 드러났다. 후보가 여덟 줄 깔린 표에서 어느 것을 보라는 건지 알 수 없으면 자동 조회를 돌린 의미가 없다. 배경·좌측 바에 더해 "AI 지목" 칩으로 글자로도 남긴다.설문 근거
문항을 왜 묻는지가 사람 머릿속에만 있어 문항을 고치거나 뺄 때 판단할 자료가 없었다. 문항당 근거 여러 개(1:N)를 붙인다.
🔴
rationale이 필수다. 링크만 모으면 "왜 묻는지"는 여전히 사람 머릿속에 남는다 — 링크를 열지 않고도 판단할 수 있어야 한다.목록은 한 줄로 접고(이유는 접힌 줄에서 말줄임으로 미리 보임), 추가는 상단 툴바 드로어에서 한다. 문항 카드마다 추가 폼을 두면 같은 폼이 문항 수만큼 반복돼 정작 자주 쓰는 선택지 편집이 스크롤 아래로 밀린다.
고친 것
com.anthropic.*를 쓰는데build.gradle.kts에 SDK 의존성이 없어 컴파일이 8건 깨졌다.admin.functional-screening.llm.enabled=false이지 의존성을 빼는 것이 아니다.return "survey"가 다섯 군데인데 근거 목록을 GET 한 곳에만 넣어, 중복 키·빈 값 경로에서만 템플릿이 터졌다. 정상 흐름은 멀쩡해 눈으로도 테스트로도 안 걸린다.populateSurveyModel()로 묶었다.데이터 접근
ProductIngredientPort+ JDBC/API 두 어댑터. 지금은 PostgreSQL 직접, 나중에 V2 product-service 로 옮길 때 설정 한 줄로 바뀐다(admin.product-ingredient.source=jdbc|api).API 어댑터의 응답 매핑을 계약 테스트로 고정했다 — V2 가 응답 필드를 바꾸면 어댑터에서 조용히 null 이 되고 화면에는 빈 칸으로만 보인다. 실제로 이 테스트가
inciName/displayNameKo를 버리던 것을 잡았다.🔴 배포 순서
설문 근거 화면이
survey_question_evidence테이블을 읽는다. 서버가 먼저 나가야 한다 — 어드민이 먼저 나가면relation ... does not exist로 500 이 난다(로컬에서 실제로 겪었다)./admin/notifications/devices는 화면·포트·어댑터가 있지만 호출 대상이 없다 — V2 의notification-service에서 어드민 API 를 걷어냈다. 그 API 가 갈 자리를 정하기 전까지 조회를 누르면 실패한다.테스트
96개 통과.
🤖 Generated with Claude Code
https://claude.ai/code/session_012c2dcyGHngmupvKjQY1PbT