Conversation
* chore(iac): api 서비스 오토스케일링(태스크 2~4, CPU 40% 목표 추적) 추가 EC2 ASG 는 2대 고정, 인스턴스당 2 태스크는 메모리 예약 1536MiB 가 자연 상한. dev·prod 에 -target 으로 선적용 완료(런치 템플릿 AMI 버전만 함께 갱신, 인스턴스 교체 없음). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * chore(iac): api 대상 그룹 slow start 60초 — 콜드 태스크에 트래픽 점진 배분 스케일아웃·그린 배포 직후 새 태스크가 JIT 워밍업 전 상태로 라운드로빈 몫을 다 받아 요청당 6~20초(p95 11s)가 걸리고 클라이언트 타임아웃으로 끊기는 것을 dev 램프 테스트로 확인. dev 에 선적용, prod 는 미적용. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…적) (#246) * docs(spec): KB-464 푸시 알림 데이터 기반 명세 — 기기 토큰·알림 설정·알림 이력·발송 추적 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * docs(plan): KB-464 푸시 알림 데이터 기반 구현 플랜 — research·data-model·quickstart Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * docs(spec): KB-464 테이블명을 notification_ 접두어로 통일 — notification_device·notification_setting·notification·notification_dispatch Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * docs(spec): KB-464 광고성 수신 동의를 marketing_* 로 — 동의 문구 버전 컬럼 추가, 야간 동의 보류 FE 협의(2026-09-07): 동의 스위치는 알림 유형(NUDGE)이 아니라 법적 카테고리(marketing)로 둔다. marketing_consent_version 으로 구 문구 동의자를 구분하고, 야간 별도 동의는 1차 범위 밖(발송 08~21 KST 하드 가드). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * docs(tasks): KB-464 태스크 27개 — 스토리별 Test-First, 마이그레이션 Red/Green 은 api 컨텍스트가 담당 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * docs(spec): KB-464 게스트 동의를 notification_device 타입 컬럼 3개로 — guest_settings JSON 폐기, 승계 규칙 명시 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * test(notification): 푸시 알림 저장 기반 리포지토리 테스트 4개 선작성 (Red) NotificationDevice·NotificationSetting·Notification·NotificationDispatch 의 저장·조회·전이 시나리오를 BehaviorSpec 으로 먼저 고정. 공용 enum·값 객체, 빈 마이그레이션 파일, ArchUnit 허용 맵 등록 포함. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * feat(notification): 푸시 알림 저장 기반 — 엔티티·리포지토리 4종 + Flyway 테이블 4개 - notification_device: 기기 설치 식별자 UNIQUE, 회원 nullable 연결, 게스트 광고성 동의 컬럼 3개 - notification_setting: 회원당 1건, 선호 2개 + 광고성 동의(문구 버전·서버 스탬프 시각) - notification: 알림 이력(회원/기기 수신자, keyset 페이지, 읽음·전체 읽음·미읽음 수) - notification_dispatch: 알림→기기 발송 추적(PENDING→SENT→DELIVERED/FAILED, Expo 티켓 스냅샷) - MarketingConsent 값 객체가 기기·회원 설정의 동의 전이 규칙을 공유 - ModuleBoundaryTest 허용 맵·TestTables.clearAll 등록 common 리포지토리 테스트 28건, api 통합(validate·ArchUnit) 1,241건 통과. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * docs(tasks): KB-464 태스크 27개 완료 체크 — 전체 빌드·로컬 부팅 검증·Jira DoD·위키 반영 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * test(notification): 광고성 동의를 원장(notification_consent)으로 — 테스트 선반영 (Red) 동의 원장 grant/revoke/claim·열린 행 조회·전부 닫기, 기기 토큰 무효화 스탬프·재등록 복구, 회원 설정은 선호 2개만. 기존 marketing 컬럼·inheritFrom 시나리오 제거. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * feat(notification): 광고성 동의를 원장 테이블로 — notification_consent 신설, 중복 컬럼 제거, 기기 토큰 무효화 스탬프 - notification_consent: 행 1개 = 동의 1회의 생애(granted_at·revoked_at), member_id NULL(게스트)/installation_id NULL(동의 기기), CHECK(둘 중 하나 이상), consent_version SMALLINT UNSIGNED. 철회는 revoked_at 스탬프로 증빙 보존, 게스트→회원 인수는 claim. - notification_device·notification_setting 에서 marketing 컬럼 3개 제거(정본 둘 문제·로그아웃 후 옛 동의 부활 경로 해소). - notification_device.token_invalid_at: DeviceNotRegistered 시 삭제 대신 스탬프(소프트삭제+UNIQUE 재등록 불가 블로커 해소), renew 가 복구. - 문자열 버전 비교("v10" < "v2") 제거, findByExpoToken 제거(영수증 정리는 dispatch 의 기기 id 사용). DBA·CTO 에이전트 교차 검토(2026-09-07) 결론. common 29건·api 1,241건 통과. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * docs(spec): KB-464 광고성 동의 원장 설계로 문서 정정 — R4·R5·R7, FR-005·013·014, data-model 5테이블 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * refactor(notification): 알림 유형 NUDGE 를 SCAN_SUGGESTION 으로 — 유형 이름이 의도(스캔 제안)를 드러내게 FE 딥링크 계약 data.type 도 SCAN_SUGGESTION 으로 바뀐다(FE 세션 통보). 법적 카테고리 marketing 은 동의 원장·API 계약에 그대로. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s * fix(notification): 게스트 열린 동의 조회를 목록 반환으로 변경 Codex 리뷰 반영 — findOpenGuestByInstallationId 가 단건(NotificationConsent?) 반환이라 재시도·동시 요청으로 같은 기기에 열린 게스트 동의가 2행 생기면 IncorrectResultSizeDataAccessException 이 나고, 중복은 저절로 풀리지 않아 로그인 시 동의 인수(claim) 경로가 영구히 막힌다. MySQL 은 부분 유니크가 없어 "주체당 열린 행 1개"를 DB 가 강제하지 못하므로 회원 경로와 같이 List 를 돌려주고 인수 시 전부 claim 하도록 계약을 맞춘다. - common: 리포지토리 반환 타입 List<NotificationConsent>, 테스트 assertion 갱신 - specs: data-model·research 의 인수 규칙·동시성 근거 갱신 Refs KB-464 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat(notification): 푸시 토큰 등록 API PUT /api/notifications/tokens (X-API-Version 1.1+, 기기 upsert·회원 연결) KB-465 US1. X-Installation-Id 기준 notification_device upsert(신규 register / 기존 renew + 무효 스탬프 해제), 회원 인증 시 linkMember, 게스트 요청은 기존 연결 유지. 멱등. 1.0 요청은 매핑이 없어 404. 게스트·회원 겸용이라 JWT 보호 경로 미등록 + @AuthMemberIdOrNull(/api/home 선례). 헤더 검증은 컨트롤러 가드로 — 인터페이스에 없는 파라미터 제약을 구현 메서드에 달면 Hibernate Validator 가 500 을 낸다. spec·plan·research·data-model·contracts·quickstart·tasks 포함. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxYdVeNoEX1HTxzpBvZPJU * feat(notification): 게스트 광고성 동의를 토큰 등록 요청의 settings 로 원장에 반영 KB-465 US2. marketing=true+version: 같은 버전 열린 게스트 행 있으면 무변화, 다른 버전은 revoke 후 grantForInstallation. marketing=false: 열린 게스트 행 전부 revoke(행 보존). settings 없음·회원 요청은 원장 무처리. on 이면 버전 필수는 DTO @get:AssertTrue(OrderCreateRequest 선례), 버전은 @positive. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxYdVeNoEX1HTxzpBvZPJU * feat(auth): 로그인·로그아웃·탈퇴 1.1+ 매핑에서 기기-회원 연결·동의 인수·정리 (1.0 매핑 불변) KB-465 US3. AuthController 는 기존 1.0 매핑을 그대로 두고 같은 경로에 version="1.1+" 매핑 3개를 추가한다. AuthService 는 기본 인자(installationId=null, releaseDevices=false)로만 확장해 1.0 호출부가 그대로다. 로그인: 기기 linkMember + 게스트 동의 claim(회원 열린 동의 없음)/revoke(있음). 로그아웃: unlinkMember 만. 탈퇴: 소셜 삭제 → 모든 기기 unlink + closeOpenByMemberId → 회원 소프트 삭제. 기존 AuthControllerTest(1.0) 무수정 Green. 1.0 토큰 API 요청은 404(문서 정정). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxYdVeNoEX1HTxzpBvZPJU * docs(spec): KB-465 tasks 진행 체크(T015 전체 빌드·T016 실기동·T017 위키) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxYdVeNoEX1HTxzpBvZPJU * docs(spec): KB-465 tasks T018 완료 체크(draft PR #247·Jira 경로 정정) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxYdVeNoEX1HTxzpBvZPJU * refactor(auth): 인증 API 버전 분기를 1.1+ 매핑 복제에서 메서드 공유 + X-API-Version 헤더 분기로 교체 login·logout·withdraw 메서드 하나가 @RequestHeader(X-API-Version) 를 받아 ApiVersions.isAtLeast(v, "1.1") 이면 기기 연동 인자를 AuthService 에 넘기고, 아니면 기본 인자(종전 1.0 동작). 1.1+ 전용 메서드 3개 제거. ApiHeaders.API_VERSION 상수 추가(WebConfig·OpenApiConfig 가 참조), ApiVersions 는 SemanticApiVersionParser 로 비교. AuthApi 문서는 기존 3개 오퍼레이션에 1.0/1.1 차이와 헤더 @parameter 를 서술. 테스트 동작 무변경(1.0 23건·1.1 15건 Green). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxYdVeNoEX1HTxzpBvZPJU * fix(notification): Codex 리뷰 반영 — 탈퇴 회원 토큰의 기기 재연결 차단, 동의 버전 상한 65535 registerToken 이 회원 요청이면 memberService.getMember 로 활성 회원을 확인한다(탈퇴 후 미만료 access 토큰으로 PUT 하면 MEMBER-003 400 — 다른 인증 엔드포인트와 동일). marketingConsentVersion 에 @max(65535) 를 달아 SMALLINT UNSIGNED 저장 실패로 기기 등록까지 롤백되던 경로를 요청 경계에서 막는다. 테스트 2건 추가. 로그아웃 헤더-only unlink 와 게스트 PUT 의 회원 기기 토큰 갱신은 spec 가정대로 수용(research R12). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxYdVeNoEX1HTxzpBvZPJU --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat(notification): 알림 설정 두 그룹 재편 기반 — 선호 컬럼 교체·동의 원장 종류 축·게스트 동의 두 종류 (KB-466 Phase 1-2) 활동 푸시(활동/소식 토글 1개)·K-Bap에서 보내는 소식(두 동의 + 식사 시간 알림 토글 1개) 구조에 맞춰 저장 기반과 KB-465 게스트 계약을 이전한다. - notification_setting: helpful·review_reminder → activity·meal_time (기본 TRUE, 새 마이그레이션) - notification_consent: consent_type(MARKETING_PRIVACY / MARKETING_RECEIVE) 추가, 기존 행은 RECEIVE 백필 - NotificationConsentService 신설 — 종류·버전 비교 grant/revoke 규칙을 회원·게스트가 공유 - PUT /notifications/tokens 의 settings: marketingConsentVersion → privacyConsentVersion + receiveConsentVersion - 로그인 동의 인수는 종류별 판단(회원에게 같은 종류 열린 행 있으면 철회, 없으면 인수) - ErrorCode NOTIFICATION-001 MARKETING_CONSENT_REQUIRED meal_time 기본 TRUE + 응답은 "저장값 AND 소식 켜짐"으로 계산해 tri-state 없이 "처음 켤 때 켜짐·껐다 켜면 복원"을 만족한다(research R3). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 * feat(notification): 회원 알림 설정 API GET/PATCH /api/notifications/settings (KB-466 US1-US3) 활동 푸시(activity)·K-Bap에서 보내는 소식(두 동의 + mealTime) 구조의 조회·부분 수정 API. - GET: activity, kbapNews{enabled, mealTime, privacyConsent, receiveConsent}. enabled 는 저장값이 아니라 MARKETING_PRIVACY·MARKETING_RECEIVE 열린 동의가 모두 있을 때. mealTime 응답은 저장값 AND enabled. - PATCH: 처리 순서 activity → kbapNews.enabled(두 버전 필수, 종류별 grant/revoke) → kbapNews.mealTime (소식 꺼짐 상태의 켜기는 NOTIFICATION-001). 빈 본문은 무변화. 설정 행은 실제 변경 때만 lazy 생성. - X-API-Version 1.1+ 만 매핑, JWT 보호 경로는 정확 경로로 등록(게스트 토큰 등록 경로 보호). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 * docs(specs): KB-466 tasks 전부 완료 표시 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 * refactor(notification): 알림 설정 API 를 무버전 매핑으로 — 신규 API 라 X-API-Version 1.0 부터 동작 KB-465 토큰 API 의 1.1+ 게이트는 기존 인증 API 와 묶인 릴리스 마커였고, 처음 만들어지는 설정 API 에는 감출 구 계약이 없어 version 속성을 두지 않는다(research R1 갱신). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 * refactor(notification): NotificationSettingService 가독성 — 로컬 함수·var 클로저·확장 함수 제거, 순차 if 로 평탄화 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 * fix(notification): 설정 PATCH 의 X-Installation-Id 도 공백·36자 초과를 400 으로 거절 — 검증을 ApiHeaders 로 공유 동의 원장의 installation_id 는 VARCHAR(36) 이라 검증 없이 넘기면 flush 에서 500 이 났다(Codex P2). 토큰 등록 컨트롤러의 private 검증을 ApiHeaders.validInstallationId 로 올려 두 컨트롤러가 같이 쓴다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 * refactor(notification): 설정 API 의 kbapNews → news — JSON 필드·DTO·결과 타입·문서 일괄 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 * refactor(notification): NotificationPreferences 값 객체 삭제 — 소비자가 설정 응답 조립 한 곳뿐이라 엔티티 필드를 직접 읽는다 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 * fix(notification): 알림 토글 기본값을 꺼짐으로 — iOS/Android 알림 정책상 허용은 항상 false 로 시작 activity·meal_time DEFAULT FALSE(마이그레이션은 아직 어느 DB 에도 미적용이라 파일 수정). K-Bap 소식 켜기가 식사 시간 알림을 자동으로 켜지 않고, 사용자가 켜 둔 값을 AND enabled 계산으로 복원한다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 * feat(notification): K-Bap 소식 켜기 시 하위 토글(식사 시간 알림)을 전부 켠다 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* test(common): LIKE 이스케이프 검증을 단위 테스트로 이관하고 통합 매트릭스 축소 - LikeWildcardsTest 신설 — %·_·백슬래시 치환과 무와일드카드 통과를 단위로 검증 - 3경로(멤버·콘텐츠 아웃박스·벡터 아웃박스) 컨트롤러 테스트는 ESCAPE 절 배선 증명용 q=% 1건만 유지 (배선은 통합으로만 검증 가능) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ * test(arch): 리포지토리 @query 의 like-without-escape 불변식 가드 추가 전 리포지토리 인터페이스의 @query value·countQuery 를 스캔해 like 절 수보다 escape 절 수가 적으면 실패한다 — KB-416 이 봉합한 구멍(네이티브·JPQL 공통, 파생 쿼리는 대상 외)의 재발을 영구 차단. escape 없는 더미 @query 로 Red 확인 후 제거 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ * refactor(order): totalPriceOf 를 totalPriceLongOf 위임으로 단일화 "널 가격은 0" 규칙 출처를 totalPriceLongOf 하나로 줄인다. Int 연산은 mod 2^32 링이라 단계별 wrap 결과가 정확한 Long 합의 하위 32비트와 비트 동일 — 앱 계약(Int) 무변. 오버플로 구간을 포함한 랜덤 500조합 동치 특성화 테스트를 위임 전 작성해 전·후 그린으로 증명 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ * docs: CLAUDE.md — LIKE 검색 이스케이프 규칙 명문화 (KB-416, 예진 승인) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ * test(arch): LIKE 가드·시나리오 테스트명에 근거·한계 명시 - RepositoryLikeEscapeTest given/then 명에 개수 비교 휴리스틱과 그 한계(정교한 절 매칭 아님) 명시 - 멤버·콘텐츠/벡터 아웃박스 LIKE 시나리오명에 "q=% 는 이스케이프 안 하면 전체 매칭이라 가장 위험" 근거 추가 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test(auth): refresh 회원 가드 회귀 잠금 — 삭제·정지 회원 세션 회전 차단 AuthService.refresh 는 회전 전 getMemberOrNull(ACTIVE) 로 회원을 확인해 부재·탈퇴·정지 세션을 401(AUTH-005)로 막고 jti 는 consume 으로 폐기한다(가드 자체는 2026-07-14 부터 존재). 그 동작을 잠그는 회귀 테스트가 없어 3분기(ACTIVE 200 / DB 삭제 401 / 정지 401)를 추가한다 — 코드 변경 없음(작성 즉시 그린) Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…}.webp (#242) V2026.08.11 이 image_path 를 images/webp/{code}.webp 로 적재했으나 S3 실물은 images/webp/ingredients/{code}.webp 라 CDN 403 → 음식 상세 재료 사진 미표시(온보딩·프로필은 FE 폴백으로 가려짐). 새 마이그레이션으로 데이터만 멱등 정정(8/11 파일 무수정), 시드 헬퍼·경로 단언 2건 동기화 Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
KB-451 §1 — orders 에 place_source·place_external_id·place_name·place_address·place_language 를 nullable 로 추가(스키마만, 백필 없음). 리뷰 embed 와 공유하지 않음(불변식 상이). 엔티티 매핑은 PR② 에서 추가하므로 이 PR 은 batch ddl-auto=validate 에 영향 없음. 마이그레이션 테스트: 5컬럼 nullable 존재·미지정 INSERT 시 전부 NULL Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* docs(specs): KB-467 회원 알림함 API 스펙·플랜·태스크 회원 전용·최근 7일·페이징 없음·읽음 취소 없음·모두 읽기 없음·미읽음 수 없음으로 범위를 확정한 SpecKit 산출물(spec·plan·research·data-model·contracts·quickstart·tasks). 비회원 알림 제거 예정(기획 변경)을 반영. Refs KB-467 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rLEXVjNvk5ziDPWSfXnqf * feat(notification): 회원 알림함 API — 최근 7일 목록·읽음 처리 GET /api/notifications 는 회원 본인의 조회 시각 기준 168시간 이내 알림을 id 역순으로 전부 돌려준다(페이징·종류 필터 없음 — 7일 창이 곧 응답 크기 상한). 항목은 id·title·body·receivedAt(epoch ms, 리뷰 목록·Order.orderedAt 과 같은 변환)·read. type·data 는 저장돼 있지만 싣지 않는다(기획: 제목·본문·수신 시각·읽음 여부면 충분). PATCH /api/notifications/{id}/read 는 멱등이며 취소 없음. 소유 검증은 findByIdAndMemberId 조건으로 하고 타인·부재·소프트삭제를 구분 없이 404 NOTIFICATION-002 로 응답한다. 읽음 처리엔 7일 제한이 없다. 미읽음 수·모두 읽기는 만들지 않는다(홈은 항상 새 알림이 있는 것처럼 표시). 비회원 알림은 기획상 제거 예정이라 회원 전용으로 설계했다 — JWT 필터에 /api/notifications, /api/notifications/* 를 등록하고, 아직 남아 있는 게스트 토큰 등록(PUT /tokens)만 GuestExemption 한 줄로 살려 둔다(게스트 제거 후속에서 삭제). - common: ErrorCode NOTIFICATION-002(404), NotificationJpaRepository 파생 쿼리 2개 - api: NotificationInboxApi/Controller/Service, NotificationResponse, WebConfig 경로 - test: NotificationInboxControllerTest 12 시나리오(@IntegrationTEST, 기존 컨텍스트) Refs KB-467 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rLEXVjNvk5ziDPWSfXnqf * docs(specs): KB-467 tasks 완료 표기 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rLEXVjNvk5ziDPWSfXnqf * refactor(notification): 알림함 엔드포인트를 설정 컨트롤러·서비스에 통합 NotificationInbox{Controller,Api,Service} 를 없애고 기존 설정 컨트롤러·서비스에 getRecentNotifications·markRead 를 합친다. 설정만 담던 NotificationSetting{Controller,Api,Service} 는 알림함까지 품으므로 Notification{Controller,Api,Service} 로 이름을 바꾼다. 테스트는 NotificationInboxTest 로 rename. 동작·계약 변경 없음(테스트 61개 Green). Refs KB-467 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rLEXVjNvk5ziDPWSfXnqf --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* docs(specs): KB-508 Sentry 연동 설계 — 4xx·5xx 전부 수집, 프로젝트·인스턴스 두 층으로 컨테이너 구분 spec·plan·research(R1~R11)·data-model(태그 스키마)·contracts(env·yml·워크플로 jq·terraform)·quickstart. - 캡처 지점: sentry.exception-resolver-order = HIGHEST_PRECEDENCE 로 어드바이스 앞에서 캡처(GlobalExceptionHandler 무수정) - EventProcessor 하나가 MDC requestId·memberId 와 BusinessException 의 status·code 를 태그로, 에러 코드별 fingerprint - Sentry 프로젝트 kbap-api·kbap-batch, SSM API_SENTRY_DSN·BATCH_SENTRY_DSN, release 는 배포 워크플로가 이미지 태그로 주입 - 알림 규칙은 범위 밖(콘솔 설정). 로컬·테스트는 DSN 부재로 비활성 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014PoEXoXHWAbMT8rTdncgZ9 * build(observability): Sentry SDK 도입 + api·batch sentry.* 설정 고정 (KB-508) - io.sentry:sentry-spring-boot-4-starter / sentry-logback 8.55.0 (Boot 4 전용 모듈명은 -4-starter, 설계의 -4-jakarta 아님 — research R2 확정) - api: exception-resolver-order = Integer.MIN_VALUE. GlobalExceptionHandler 가 모든 예외를 삼키므로 Sentry 리졸버가 어드바이스 앞에서 캡처해야 4xx·5xx 이벤트가 생긴다(R1). 어드바이스 무수정. - DSN 은 ${API_SENTRY_DSN:} / ${BATCH_SENTRY_DSN:} — 없으면 자동구성 skip 으로 로컬·테스트 비활성(R6), 별도 enabled 스위치 없음. send-default-pii false, ERROR=이벤트·INFO=breadcrumb(R7·R8). - SentryConfigTest 가 main application.yml 을 직접 읽어 위 값을 고정한다(테스트 클래스패스의 application.yml 은 test 오버라이드라 FileSystemResource 로 main 파일을 읽는다). - 통합 컨텍스트(@IntegrationTEST·@BatchIntegrationTest) 무변경 — DSN 부재라 Sentry 빈 미등록 확인. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * feat(observability): api Sentry 이벤트에 요청 맥락 태그·에러 코드 핑거프린트 (KB-508) SentryRequestContextProcessor(EventProcessor 빈) 하나가 - MDC requestId·memberId → 태그(리졸버 캡처 경로엔 MDC 가 자동 첨부되지 않는다) - BusinessException → http.status·error.code 태그 + fingerprint ["business", code] (4xx 를 전부 받으면 같은 코드가 던진 위치마다 이슈가 갈라지므로 코드로 묶는다 — R3) - Spring ErrorResponse → 그 상태, 그 외 → 500 - throwable 없는 로그 이벤트엔 http.status 를 붙이지 않는다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * feat(batch): 잡 이름을 MDC job 으로 남겨 Sentry 이벤트에 태그 (KB-508) JobNameMdcListener(beforeJob put / afterJob remove)를 두 잡 빌더에 .listener 로 등록. Spring Batch 는 전역 리스너를 자동 적용하지 않고, JobOperator.start 가 다른 스레드에서 돌 수 있어 런처가 아닌 잡 리스너에서 MDC 를 잡는다(R9). 스텝 실패 ERROR 로그가 logback 경로로 이벤트가 되며 MDC job 은 어펜더가 자동 첨부한다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * ci(deploy): SENTRY_RELEASE=이미지 태그 주입 + Sentry DSN SSM 시크릿 등록 (KB-508) - 4개 배포 워크플로 jq: .image 교체 시 environment 의 SENTRY_RELEASE 를 이미지 URI 태그($IMAGE 의 ':' 뒤, api-<sha>/batch-<sha>)로 교체·추가. batch 워크플로엔 tag 출력이 없어 --arg TAG 대신 URI 에서 잘라 네 파일이 같은 식. 샘플 태스크 정의(environment 없음/기존 값 있음)로 SENTRY_RELEASE 정확히 1개 확인. - terraform api_secret_names += API_SENTRY_DSN, batch_secret_names += BATCH_SENTRY_DSN. ECS 는 SSM 파라미터가 없으면 태스크를 기동하지 않으므로 파라미터 생성 → apply → 배포 순서 필수(quickstart). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * docs(observability): Sentry 운영 문서 + KB-508 tasks (KB-508) docs/observability/sentry.md — 구성·태그 규약·배포 순서·후속 알림/노이즈/되돌리기. Grafana 대시보드 문서에 역할 분담(Grafana=얼마나, Sentry=무엇이 어디서) 한 줄. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * chore(sentry): batch DSN 시크릿 보류 + 프로젝트 이름 실제값(kbap-server-dev)으로 문서 정정 (KB-508) Sentry 프로젝트는 서비스별이 아니라 환경별(org skyjs, kbap-server-dev = dev api)로 생성됐다. batch 프로젝트가 아직 없어 batch_secret_names 의 BATCH_SENTRY_DSN 을 뺀다 — 넣어 두면 파라미터 없는 env 의 batch 태스크가 apply 후 기동하지 못한다. yml 의 ${BATCH_SENTRY_DSN:} 은 그대로라 후속은 SSM 파라미터 + 이 한 줄 복구뿐이다. api_secret_names 는 전 env 공통 기본값이므로 prod apply 전에 /kbap/prod/API_SENTRY_DSN 이 있어야 한다는 점을 문서에 명시. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * chore(sentry): dev batch 프로젝트(kbap-batch-dev) 생성에 따라 BATCH_SENTRY_DSN 시크릿 복구 (KB-508) /kbap/dev/BATCH_SENTRY_DSN 등록 완료. batch_secret_names 기본값에 BATCH_SENTRY_DSN 을 되돌리고 문서를 dev 프로젝트 2개(kbap-server-dev·kbap-batch-dev) 기준으로 정정. prod apply 전 prod 파라미터 2개 선행. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * test(observability): Sentry 관련 단위 테스트 3개 제거 (KB-508) SentryConfigTest·SentryRequestContextProcessorTest·JobNameMdcListenerTest 삭제(사용자 결정 2026-09-09). 설정·태그 부착은 dev 배포 후 Sentry 콘솔에서 실이벤트로 확인한다(quickstart 시나리오). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * feat(observability): send-default-pii true — 요청자 IP·헤더·쿠키 수집, Authorization 헤더만 제거 (KB-508) 사용자 결정(2026-09-09): 디버깅 정보를 늘리기 위해 api·batch 모두 send-default-pii=true. 살아 있는 access 토큰이 외부 SaaS 에 저장되지 않도록 SentryRequestContextProcessor 가 event.request.headers 에서 Authorization 만 제거한다. 요청 본문은 여전히 미첨부. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * fix(observability): Codex 리뷰 반영 — http.status 매핑 정합·쿼리 마스킹·batch job 태그 승격 (KB-508) Codex 리뷰(PR #256) 3건 반영: - http.status 를 GlobalExceptionHandler 와 같은 매핑으로: IllegalArgumentException·HttpMessageNotReadableException → 400, 낙관락(cause 체인 포함) → 409, 그 외 500. 종전엔 ErrorResponse 가 아니면 전부 500 이라 5xx 알림에 4xx·409 가 섞였다. - 쿼리스트링을 RequestLoggingFilter 와 같은 maskQuery(q·latitude·longitude → ***) 로 마스킹. send-default-pii 와 무관하게 Sentry 서블릿 통합은 queryString 을 그대로 싣고, q 엔 관리자 회원 검색의 이메일이 들어온다. - batch sentry.context-tags: job — sentry-logback 은 여기 나열된 MDC 키만 태그로 승격하고 나머지는 contexts.MDC 부가 데이터로 넣어 필터·알림 조건에 못 쓴다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * fix(observability): MethodArgumentTypeMismatchException 도 http.status 400 으로 태그 (KB-508) Codex 재리뷰 반영. TypeMismatchException 은 Spring 7 에서도 ErrorResponse 를 구현하지 않아 어드바이스(handleTypeMismatch=400)와 달리 500 으로 태그되던 케이스. 어드바이스 @ExceptionHandler 목록과 1:1. 관리자 ADMIN_TOKEN 쿠키 제거 지적은 기각(사용자 결정: 노출 허용). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * fix(observability): Jakarta OptimisticLockException 도 409 로 태그 (KB-508) Codex 3차 리뷰 반영. 어드바이스 hasOptimisticConflictCause 와 동일하게 Spring·Jakarta 두 타입을 본다. 퍼센트 인코딩된 파라미터 이름 우회 지적은 기각(고의 우회 한정, 로그 마스킹과 동일 동작, 수렴 판단). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu * chore(sentry): 이 브랜치가 추가한 yml·카탈로그 주석 제거 (KB-508) Sentry 설정 근거는 docs/observability/sentry.md 와 specs/kb-508 research 에 있다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018eWEUWhtAx69kZduFxyXAu --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
test(bookmark): 커서 페이지네이션 경계·드레인 종료 보장 회귀 (KB-489) KB-488 홈 프리징(북마크 드레인 무한 페치) 서버측 재현 시도 — GET /bookmarks 는 경계에서도 정상 종료함을 회귀로 못박는다. 서버 코드 변경 없음(무한 루프는 서버 재현 불가 = 클라이언트측, FE #95 가드가 실제 수정). - 경계 N=20(첫 페이지 hasNext=false·nextCursor=null)·N=40(둘째 페이지 20개+hasNext=false)·N=41(드레인) - 드레인 재현: hasNext 를 따라 nextCursor 로 끝까지 페치 → 커서 strictly 감소·비반복·유한 종료·전 항목 중복없이 수집 - 근거: findPage 는 b.id < :cursor strict + order by b.id desc, id 는 IDENTITY 라 동률 없음 Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…이션 (#253) * feat(food): GET /api/foods·/bookmarks 위험도(risk) 서버 필터 + 필터 집합 커서 페이지네이션 KB-491 — 위험도 칩을 클라 필터에서 서버 필터로. risk CSV(SAFE·CAUTION·DANGER·UNKNOWN, OR) 미지정 시 현행, 게스트도 400 아님, 미정의 값은 400(COMMON-002). - overallRiskStatus 는 조회자별(회피성분 ∩ 음식 성분, 성분은 JSON) 계산값이라 순수 SQL 필터 불가 → id 키셋 위에서 배치 페치(100) → 앱에서 overallRisk 계산·필터, PAGE_SIZE+1 매치까지 반복. 커서=반환한 마지막 항목 키(원배치 경계로 전진해 드롭 행도 통과), hasNext=PAGE_SIZE+1 매치 존재 → 빈/얇은 페이지 0 - FoodService.collectRiskFiltered 제네릭 헬퍼(음식 목록=food id 키셋, 북마크=bookmark id 키셋 공용), 비지정 경로는 기존 그대로 - FoodBrowseRequest.risk·BookmarkListRequest.risk + RiskFilterParser(미정의 토큰 COMMON-002), Swagger 갱신. /foods/search 는 비범위 - 테스트: risk 필터(DANGER/OR/미지정/미정의 400)·필터 하 꽉 찬 페이지·정상 커서 드레인(food·bookmark) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ * fix(food): 위험도 필터 희소 스캔 방어 — 불가능 필터 단락 + 요청당 스캔 상한 (Codex P1) 희소 필터가 카탈로그 전량을 순회하던 문제(게스트 risk=DANGER 등): - 불가능 필터 단락: 회피성분 0(게스트·미설정)이면 도달 가능 위험도는 SAFE·UNKNOWN 뿐이라 DANGER/CAUTION 만 요청 시 DB 접근 없이 빈 페이지(hasNext=false) 즉시 반환 - 요청당 스캔 상한 RISK_FILTER_MAX_BATCHES=5(=500행): 상한 도달 시 매치가 PAGE_SIZE 미만이어도 현재 매치 + 마지막 스캔 커서 + hasNext=true 로 반환(얇은/빈 페이지 허용, 다음 요청이 이어서 스캔) — 종료는 hasNext/nextCursor 로만 - Swagger: risk 필터 시 items<PAGE_SIZE 라도 hasNext=true 가능 명시. precompute 는 카탈로그 1만+ 시 검토(PR 본문) - 테스트: 게스트 DANGER 즉시 빈 응답·스캔 상한 밖 매치 얇은 페이지 이어받기 드레인 종료 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ * docs(food): risk 파라미터 스키마 설명 정정 — 얇은 페이지 허용 명시 (Codex P2) FoodBrowseRequest·BookmarkListRequest 의 risk 설명이 "빈/얇은 페이지 없음"을 약속했으나 요청당 스캔 상한 도달 시 items<PAGE_SIZE(0 포함)+hasNext=true 가 가능하다. "종료 판정은 hasNext/nextCursor 로만, items 는 PAGE_SIZE 미만일 수 있음"으로 계약 문구 정정(코드 무변) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…#258) * fix(observability): 매핑 없는 경로 404(NoResourceFoundException) Sentry 미전송 (KB-510) dev api 에 Sentry 가 켜진 뒤 첫 이슈들이 EC2 공인 IP 봇 스캔(GET /, /api/v2/static/not.found)의 404 였다. 존재하지 않는 경로를 두드린 외부 트래픽이라 추적할 원인이 없고 이슈 목록·한도만 소모한다. - api application.yml sentry.ignored-exceptions-for-type 에 NoResourceFoundException 1개 등록. SDK(SentryClient.captureEvent)가 이벤트 프로세서 이전에 정확한 클래스 일치로 버린다 — SentryRequestContextProcessor·GlobalExceptionHandler 무수정, HTTP 404 COMMON-002 응답 무변경. - 4xx 수집 정책(KB-508)은 유지한다. 4xx 전체 drop(PR #257)은 채택하지 않았다. 앱 에러 코드 4xx(BusinessException)·405·415·5xx 는 종전대로 수집. - 자동 테스트 없음(2026-09-09 결정 — 관측 부품 무테스트). 클래스명 오타는 바인딩 실패로 부팅이 멈춘다. - docs/observability/sentry.md "수집되지 않는 것"·노이즈 조정 항목 갱신. 로컬 검증(가짜 DSN + sentry.debug=true): GET /api/no-such-path, GET / → 404, "Event was dropped as the exception class org.springframework.web.servlet.resource.NoResourceFoundException is ignored" GET /api/foods/999999999?lang=ko → 400 FOOD-001, drop 없이 전송 시도 :api:test 108 클래스 1335 테스트 실패 0 (컨텍스트 무변화, DSN 부재로 Sentry 미기동) Refs KB-510 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018oXCJR24JQZZrb8EYiaEgU * docs(specs): KB-510 tasks 진행 상태 갱신 (T006·T007 완료) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018oXCJR24JQZZrb8EYiaEgU --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
feat(order): 주문 항목 응답에 hasPhoto 추가 — 실사진과 기본 대체 이미지 구분 공유 카드(KB-518)가 ready=true 인데 사진만 없는 음식의 기본 대체 이미지를 실사진과 구분하지 못해 카드에 그대로 박혔다. imageRef 는 항상 URL 이 채워지고 URL 문자열로 판별하는 건 금지라, 불리언 필드를 준다. hasPhoto = 그 음식이 READY 이고 자기 imageRef 로 URL 이 만들어질 때만 true 다. 응답의 imageRef 와 같은 계산에서 나오므로 둘이 어긋날 수 없다 — 사진 URL 을 먼저 구하고, 없으면 기본 이미지로 채우면서 그 여부를 그대로 내려보낸다. 목록 응답(thumbnails·imageUrl)은 건드리지 않는다. 필드 추가만이라 하위 호환. 테스트: 실사진 있는 READY true·사진 없는 READY false·준비중 false. Refs KB-572 Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(food): 음식 상세 응답에 이미지 갤러리 images[] 추가
공개 GET /api/foods/{id} 에 images 배열을 더한다. 각 항목은 해석된 url 과
isPrimary 뿐이고, 대표가 항상 첫 번째로 온다(그다음은 sort_order·id 순).
food_image 는 BaseEntity 의 소프트 삭제 제약을 받으므로 살아 있는 행만 내려간다.
필드 추가만이라 하위 호환이고 기존 imageRef 는 그대로 둔다 — 대표 이미지의
정본은 여전히 imageRef 이며, 갤러리 행이 아직 없는 음식은 빈 배열이다.
어드민이 imageRef 를 직접 편집하면 갤러리와 어긋날 수 있는데, 그 동기화는
KB-414 범위라 여기서는 없는 값을 지어내지 않는다.
테스트: 대표+추가 2장의 순서·URL 해석, 갤러리 행이 없을 때 빈 배열,
소프트 삭제된 이미지 제외.
Refs KB-565
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* refactor(food): images 항목에서 isPrimary 제거 — url 하나만 남긴다
대표 이미지는 imageRef 로 이미 내려가고 정렬 규약이 "대표가 항상 첫 번째"라
isPrimary 는 같은 사실을 세 번째로 말하는 중복이다(예진 결정). 겸사겸사
is- 접두 boolean 의 직렬화 함정도 계약에서 사라진다.
응답 항목은 {url} 하나가 되고, 내부 결과 타입도 해석된 URL 목록으로 줄인다.
정렬(대표 우선 → sort_order → id)·빈 배열·소프트 삭제 제외는 그대로다.
Swagger 는 "대표(imageRef 와 같은 URL)가 항상 첫 번째"로 고쳤다.
테스트: images[0].isPrimary 단언을 images[0].url == imageRef 로 바꿨다.
Refs KB-565
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(iac): Alloy remote_write 호스트 handev.site → handev.cloud 홈서버 Prometheus 수신 호스트가 바뀌어 tfvars 예시와 import 블록 생성 스크립트의 기본값을 갱신한다. 실제 적용값인 dev.tfvars·prod.tfvars 는 git 비추적이라 각 환경에서 직접 바꾼 뒤 terraform apply 로 반영한다. 경로(/api/v1/write)와 Cloudflare Access 헤더 구성은 그대로다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sygpc7qkquce8vHzh65jc * chore(iac): Alloy remote_write 주소를 SSM 주입으로 전환 — 저장소에서 호스트 제거 remote_write 수신 주소가 tfvars·예시·스크립트에 하드코딩돼 공개 저장소에 개인 도메인이 남았다. Cloudflare Access 토큰과 같은 경로로 옮겨, 설정은 sys.env 로 읽고 값은 SSM /kbap/<env>/REMOTE_WRITE_URL 에서 ECS secrets 로 주입한다. 주소 변경은 이제 SSM 값 수정 + 서비스 강제 재배포로 끝난다(terraform apply 불필요). 값은 태스크 기동 시 읽으므로 재배포는 여전히 한 번 필요하다. 주의: alloy_secret_names 에 REMOTE_WRITE_URL 이 들어가므로 apply 전에 두 환경 모두 SSM 파라미터를 먼저 등록해야 한다. 없으면 태스크가 기동 전에 실패한다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019sygpc7qkquce8vHzh65jc --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(iac): api 스케일 아웃 — 서비스 CPU 80% + capacity provider 로 인스턴스 증설 목표는 "컨테이너가 자기 몫 CPU 의 80% 를 쓰면 인스턴스 1대 + 컨테이너 1개를 한 단위로 늘린다"이다. 평시 인스턴스당 api 태스크 1개, 나머지 절반은 카나리 그린 자리. - 서비스 타깃 트래킹 40% → 80%, scale-out cooldown 60s → 300s. 2026-09-07 prod 카나리 롤백은 그린 JVM 부팅 버스트가 40% 알람을 울려 desired 3 → 고정 인스턴스에 자리 부족 으로 났다. #244 는 인프라에서만 제거하고 미병합 종료 — 이 커밋이 그 코드를 대체한다. - api ASG 상한을 api_instance_max_count 로 풀고 capacity provider(target_capacity 50) 가 대수를 조정한다. 절반만 채우므로 그린 자리가 상시 보장되고, 모자라면 롤백 대신 인스턴스가 늘어난다. batch 풀은 고정. - CODE_DEPLOY 서비스는 capacity provider 전략을 UpdateService·Terraform 으로 못 바꾼다 (재생성). 배포 appspec 의 CapacityProviderStrategy 가 그린 태스크셋에 건다 — apply 후 api 를 한 번 배포해야 실제로 붙는다. - api_task_cpu 는 512 유지 — t3.medium 2048 유닛에 태스크 2×1024 + Alloy 128 은 안 들어간다. dev plan: 5 add / 5 change / 1 destroy, api 서비스 replacement 없음. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(api): Hikari 풀 10 → 5 — RDS max_connections 60 안에서 스케일 아웃 성립 db.t4g.micro 의 max_connections 는 60 이다. 블루/그린은 그린을 desired 수만큼 전부 띄우므로 최대 스케일(api 4) 중 배포는 풀 10 기준 4×2×10 + batch 10 = 90 으로 상한을 넘는다. 풀 5 면 50. 태스크 CPU 가 0.5 vCPU 라 Hikari 권장식(코어×2+1)으로도 5 는 여유 있다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(iac): capacity provider target_capacity 50 → 100 — 50 은 빈 인스턴스를 같은 수만큼 더 띄운다 ECS managed scaling 은 태스크가 하나라도 있는 인스턴스를 사용 중으로 센다. 50 은 인스턴스마다 절반을 비우는 게 아니라 풀을 두 배로 만든다 — dev apply 직후 api ASG 가 2 → 4대로 늘었다. 100 이면 빈 인스턴스 없이, 태스크를 놓을 자리가 없을 때만 인스턴스가 는다. 평시 그린 자리는 spread(instanceId) + 인스턴스당 2자리가 만든다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * revert(api): Hikari 풀 5 → 10 원복 — 커넥션 수는 건드리지 않는다 풀 크기는 기본값 10 을 유지하기로 했다. RDS max_connections 60 기준 평시 최대 스케일은 4×10 + batch 10 = 50 으로 들어가지만, 3태스크 이상으로 늘어난 상태의 카나리 배포는 블루+그린 공존으로 60 을 넘을 수 있다 — 제약을 api-autoscaling.tf 주석에 남긴다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* refactor(batch): 스캔 제안 잡을 단일 청크 스텝으로 재구성하고 Expo 발송을 순차화 (KB-614) - 태스크릿+@JobScope 버퍼(ScanSuggestionCandidateDto)를 없애고 리더(member_id 커서 100건)·프로세서(이번 슬롯 수신자 제외)·라이터의 청크 스텝 하나로 합쳤다. 대상 전체를 메모리에 올리지 않는다. - 청크 트랜잭션은 실제 트랜잭션 매니저 — 트랜잭션 안 Expo 호출은 사용자 결정으로 감수(specs research R3). - ExpoPushSender 의 스레드 풀·요청 간격 슬롯·close 를 제거하고 100건 요청을 순차로 보낸다. 동시성·간격 설정 키 삭제(api·batch). - 발송 시각과 식사 슬롯 경계를 11:00·17:00 KST 로 당겼다(영수증 확인·재전송 창 확보). - 설정 키 member-chunk-size(500) → chunk-size(100). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(push): Expo 영수증 확인과 기기 단위 재전송 (KB-614, KB-473 흡수) - 발송 이력(notification_dispatch)에 알림 유형 컬럼 추가 — NULL 허용 + 기존 행 백필. 블루/그린 중 구 코드의 INSERT 를 받기 위해 NOT NULL 은 다음 릴리스로 미룬다. - 영수증 잡 2개: 광고성(10분 주기, 11~13시·17~19시 KST)·활동 알림(15분 주기 상시). 본체는 하나이고 대상 유형과 스케줄만 다르다. - 확인 대상은 SENT·접수 후 15분~24시간. 24시간 지난 SENT 는 상태를 바꾸지 않고 미확인 종결로 본다(정리 작업 없음). - 영수증 ok → DELIVERED, 오류 → FAILED(사유). DeviceNotRegistered 는 기기 토큰 무효 처리. - 재전송은 MessageRateExceeded·세부 코드 없는 오류만, 알림당 2회·최초 발송 후 2시간 이내, 그 기기가 지금도 발송 대상일 때. 같은 알림함 행에 발송 이력을 하나 더 붙여 그 기기에만 보낸다. - 영수증 잡은 기존 준비→외부 호출→기록 방식(트랜잭션 밖 Expo 호출)을 따른다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(push): 재등록된 기기의 새 토큰을 옛 토큰 영수증으로 무효 처리하지 않는다 (KB-614) 발송과 영수증 확인 사이(15분+)에 앱이 토큰을 재등록하면 옛 토큰의 DeviceNotRegistered 가 새 토큰까지 죽인다(#259 Codex 지적과 같은 경합). 발송 이력의 토큰 스냅샷과 기기의 현재 토큰이 같을 때만 무효 처리한다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(specs): KB-614 태스크 진행 상태 갱신 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(batch): 영수증 잡도 실제 트랜잭션 매니저를 쓴다 (KB-614) ResourcelessTransactionManager 에서는 청크의 업무 데이터 커밋과 배치 메타 테이블 기록이 서로 다른 트랜잭션이라 원자적이지 않다. 발송 잡과 같이 PlatformTransactionManager 로 통일해 영수증 확정·재전송 이력·스텝 실행 기록을 한 단위로 커밋한다. 트랜잭션 안 Expo 호출은 발송 잡과 같은 결정으로 감수한다(specs research R3). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(batch): 영수증 잡 cron 상수 이름을 시각이 읽히게 바꾼다 (KB-614) MARKETING_CRON·MARKETING_CLOSING_CRON·ACTIVITY_CRON 은 표현식을 읽어야 언제 도는지 알 수 있었다. 이름이 곧 시각이 되게 하고, 어느 잡의 스케줄인지는 스케줄러 사용처가 말한다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(batch): 발송 잡 cron 상수도 시각이 읽히는 이름으로 맞춘다 (KB-614) LUNCH_CRON·DINNER_CRON → DAILY_AT_11_00·DAILY_AT_17_00. 영수증 잡 상수와 같은 규칙이다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(batch): created_at 과 비교할 현재 시각 변환을 Clock.nowInJvmZone 으로 모은다 (KB-614) 엔티티 createdAt 은 @CreationTimestamp 가 JVM 기본 시간대로 찍고 배치 Clock 은 Asia/Seoul 고정이라, LocalDateTime.now(clock) 을 쓰면 JVM 시간대가 서울이 아닌 환경에서 9시간 어긋난다. 같은 변환이 리더·라이터에 복제돼 있던 것을 이름 붙은 확장 함수 하나로 모아 의도를 남긴다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(push): 영수증 조회 포트를 PushReceiptClient 로 이름 바꾼다 (KB-614) 외부 API 를 부르는 포트는 Client 접미사로 통일한다(FoodImageBatchClient·TextEmbeddingClient 와 같은 규칙). 구현체 ExpoPushReceiptClient, 테스트 페이크 FakePushReceiptClient, 명세 문서의 이름도 함께 맞췄다. 동작 변경 없음. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(db): 스캔 제안 대상 리더용 복합 인덱스 추가 (KB-614) 리더 쿼리는 news = 1 AND status = 'ACTIVE' AND member_id > ? ORDER BY member_id LIMIT ? 다. 기존 고유 키 (member_id, installation_id) 는 news·status 를 확인하려고 행을 하나씩 읽어 걸러야 해서 소식을 켠 회원이 드물수록 스캔이 길어진다. (news, status, member_id) 커버링 인덱스로 받친다. 회원 5만 건 실측(MySQL 8.4): 켠 비율 10% 1.7ms → 0.4ms, 0.1% 9.6ms → 0.02ms. 엔티티 @table(indexes) 에도 선언. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(batch): 이미 받은 회원을 리더에서 거르고 배치 패키지를 기능별로 재배치 (KB-614) 발송 대상 필터를 리더로 이동 - "발송 대상" 의 정의가 곧 "소식을 켰고 이번 슬롯에 아직 안 받은 회원" 이라 조회 조건(NOT EXISTS)으로 옮겼다. 프로세서 ScanSuggestionSlotFilter 와 회원별 exists 파생 쿼리를 삭제 — 청크당 100회 나가던 쿼리가 없어진다. - 슬롯 시작 시각은 리더 open() 에서 한 번 정한다. - 대가: 걸러진 수(filterCount)가 스텝 실행 기록에 남지 않는다. 읽은 수 = 실제 발송 대상 수. - 잡 테스트 기대값: 250명 중 40명 기수신이면 읽은 수 210, 묶음 100·100·10. 배치 패키지 재배치(파일 이동, 동작 변경 없음) - notification → notification/suggestion(발송 잡)·notification/recipt(영수증 잡) - trigger → trigger/rest(HTTP 트리거)·trigger/scheduler(스케줄러, 구 schedule 패키지) - observability/JobNameMdcListener → util - 이동한 notification 파일들의 package 선언은 아직 com.kbap.batch.notification 그대로다(후속 정리 대상). JobRepository 팩토리 교체 - Spring Batch 6 에서 forRemoval 로 deprecated 된 JobRepositoryFactoryBean 을 JdbcJobRepositoryFactoryBean 으로 바꿨다. API 동일. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(batch): 패키지 선언을 경로에 맞추고 폴더 오타를 고친다 (KB-614) - 폴더 오타: notification/recipt → receipt, food/vecotr → vector. - 경로와 어긋나 있던 package 선언 11개를 경로 미러링 규약대로 맞췄다 (notification.suggestion·notification.receipt·food.vector). 같은 패키지라 import 없이 쓰던 참조에는 import 를 추가. - 테스트도 main 구조를 따라 옮겼다: ScanSuggestion*Test → notification/suggestion, PushReceiptSyncJobTest → notification/receipt, vector → food/vector. 공용 픽스처(FakePushSender·FakePushReceiptClient·MutableClock)는 notification 에 둔다. - ModuleBoundaryTest 의 어댑터 참조 허용 목록에서 옛 com.kbap.batch.outbox 를 com.kbap.batch.food.content 로 갱신. 동작 변경 없음. 배치 테스트 11개 클래스가 새 패키지로 통과. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(push): 발송 포트를 PushClient 로 이름 바꾼다 (KB-614) 외부 API 를 부르는 포트는 Client 접미사로 통일한다(PushReceiptClient·FoodImageBatchClient·TextEmbeddingClient 와 같은 규칙). - 포트 PushSender → PushClient, 구현체 ExpoPushSender → ExpoPushClient - 테스트 페이크 FakePushSender(api·batch) → FakePushClient, FakePushSenderConfig → FakePushClientConfig - 빈 이름 pushSender → pushClient, 주입 변수 sender → pushClient - 컨벤션 문서와 KB-614 명세 문서의 이름도 맞췄다. 과거 명세(KB-468·KB-471)는 당시 기록이라 두었다. 메서드 send 와 동작은 그대로다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(speckit): assess 확장 설치 — 아이디어 평가 스킬 5종 추가 SpecKit assess 확장(intake → research → define → shape → decide)을 설치했다. - .claude/skills/speckit-assess-* 5종, .specify/extensions/assess 와 레지스트리 - .specify/extensions.yml: installed 목록에 assess 추가, settings.auto_execute_hooks: true. 설치기가 파일을 다시 쓰면서 기존 before_specify 훅 설명 주석이 빠지고 들여쓰기가 바뀌었다(훅 내용은 동일). - .claude/settings.json: 설치기가 한글 문자열을 \uXXXX 이스케이프로 다시 직렬화했다(의미 동일). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(batch): 영수증을 끝내 확인하지 못한 발송을 자정에 FAILED 로 닫는다 (KB-614) 영수증 잡은 접수 후 24시간까지만 조회하므로 그때까지 영수증이 안 나온 발송은 SENT 로 영구히 남았다. 매일 00:00 KST 에 도는 태스크릿 잡(unconfirmedPushDispatchCloseJob)이 접수 후 이틀 지난 SENT 를 벌크 UPDATE 한 번으로 FAILED(ReceiptUnconfirmed) 처리한다. 유형과 무관하게 닫으므로 배포 중 유형 없이 들어간 행도 함께 정리된다. Expo 를 부르지 않고 알림함 행은 지우지 않는다(실제로는 전달됐을 수 있다). 닫은 건수는 스텝 writeCount 와 로그로 남는다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* feat(admin): 음식 이미지 갤러리 — 조회·대표 교체·재생성·선택 배치 제출
어드민이 음식당 여러 이미지를 보고 대표를 고르고 다시 생성시킬 수 있게 한다.
계약은 specs/admin-rebuild/admin-food-images-contract.md.
- GET /api/admin/foods/{id}/images — {foodId, version, contentStatus, items[]}.
대표가 항상 첫 번째, 이후 sortOrder·id 순. 소프트 삭제 제외, 없으면 빈 배열.
version 은 음식 낙관잠금 버전으로 대표 교체 바디에 그대로 되돌려 보낸다.
- PUT /api/admin/foods/{id}/images/{imageId}/primary — 바디 {version}.
대표 플립 + food.image_ref 동기화 + 벡터 UPSERT 예약, 갱신된 갤러리를 돌려준다.
version 불일치 409 FOOD-006(기존 코드 재사용), 그 음식 이미지가 아니면 404,
이미 대표면 아무것도 바꾸지 않고 200(멱등).
- POST /api/admin/foods/{id}/regenerate-image — READY 만 허용(아니면 409
FOOD-011). 이미 진행 중이면 409 IMAGE-004. PENDING_IMAGE 로 내리고 벡터 DELETE
를 예약한 뒤 배치에 제출하며 {foodId, contentStatus, batchItemId} 를 준다.
- POST /api/admin/foods/images — 바디 {foodIds?} 추가(없으면 기존 전체 일괄).
응답은 기존 2필드를 그대로 두고 submittedCount·skippedInProgress 를 더한다 —
ImageBatchesPage 가 옛 필드를 쓰고 있어 교체하면 배포 순간 깨진다.
- 어드민 수정 PUT 의 imageRef 는 현재 값과 같으면 통과, 다르면 400 FOOD-012.
대표 지정 경로를 set-primary 하나로 모아 image_ref == primary 불변식을 한 곳에서
지킨다. 같은 값 통과는 수정 폼이 조회값을 되돌려 보내기 때문이다.
새 에러 코드: FOOD-011·FOOD-012·IMAGE-004·IMAGE-005.
테스트 15건: 갤러리 정렬·빈 배열·무토큰 401, 대표 교체와 image_ref 동기화·멱등·
버전 충돌·타 음식 이미지 404, 재생성 상태 전이·벡터 예약·진행 중 409·READY 아님
409, foodIds 지정 제출·진행 중 건너뛰기, imageRef 동일 통과·변경 400.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 선택 제출을 이미지 후보로 제한하고 빈 배열을 전체 일괄과 구분
Codex P1 2건·P2 1건.
1) foodIds 로 지정한 제출이 findByIdIn 으로 음식을 그냥 가져와, READY·FAILED 처럼
이미지 후보가 아닌 음식까지 유료 생성 API 에 올렸다. 수집기는 완료본을
PENDING_IMAGE 가 아니라는 이유로 버리므로 비용만 나가고 결과가 없다.
findImageCandidatesByIdIn 으로 전체 일괄과 같은 조건(PENDING_IMAGE + 진행 중
항목 없음)을 선택 제출에도 적용한다. 재생성은 상태를 PENDING_IMAGE 로 먼저
내린 뒤 제출하도록 순서를 바꿔 같은 조건을 만족시킨다.
2) {"foodIds": []} 가 orEmpty() 로 생략과 같아져, 빈 선택이 이미지 없는 음식
전체 일괄 제출로 번졌다. null(생략) 과 빈 배열을 구분해 전자는 기존 일괄,
후자는 아무것도 제출하지 않는다.
3) 대표 교체가 서비스의 명시 버전 검사만 갖고 있어, 두 관리자가 같은 version 으로
동시에 교체하면 진 쪽이 커밋 시점 OptimisticLockingFailureException 으로 500 이
됐다. AdminFoodCatalogController 와 같게 FOOD-006 으로 매핑한다.
테스트 3건 추가: 후보 아닌 READY 지정 시 미제출, 빈 배열 미제출, 바디 없으면
기존 일괄 유지.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 재생성의 배치 선점을 외부 호출 전에 커밋하고 멱등 대표 지정의 UPSERT 제거
Codex P1 1건·P2 1건.
1) regenerateImage 에 @transactional 을 걸어 두어, FoodImageBatchSubmitService 의
TransactionTemplate 이 바깥 트랜잭션에 합류했다. 그 결과 배치 선점과 외부
배치 id 가 유료 API 호출 전에 커밋되지 않는다 — 호출 직후 죽으면 우리 쪽에
기록 없이 비용만 나간다. 상태 전이(READY→PENDING_IMAGE)와 벡터 DELETE 예약만
먼저 자체 트랜잭션으로 커밋하고, 제출은 그 바깥에서 자기 트랜잭션으로 돌린다.
진행 중 검사도 전이 전에 같은 트랜잭션에서 수행해 전이만 남는 상태를 줄인다.
2) 이미 대표인 이미지를 다시 지정하면 아무것도 안 바꾸면서 UPSERT 만 쌓였다.
enqueueIfAbsent 는 PENDING 행만 중복 제거하므로, 이전 아웃박스가 처리된 뒤
같은 요청을 반복하면 매번 새 재색인이 큐에 들어간다. 실제로 바뀐 게 있을 때만
예약한다.
남긴 것(Codex P1 "재생성본을 READY 로 되돌려라"): 스펙의 상태표가 의도한 흐름이다 —
batch collect 는 PENDING_IMAGE→PENDING_REVIEW 로 두고 어드민 승인에서 READY 로
복귀한다(반려 시 FAILED, R7 의 반려본 안내가 그 상태를 전제로 한다). 재생성본을
검수 없이 자동 공개하지 않는 것이 설계다.
테스트 1건 추가: 멱등 대표 지정 후 벡터 아웃박스 0건.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 재생성 재시도에 진행 중을 먼저 알리고 대표 교체는 커밋된 version 을 반환
Codex 2건.
- 재생성 성공 직후 음식은 PENDING_IMAGE 에 대기 배치 아이템을 갖는다. 준비 상태 검사가 먼저라
재시도에 FOOD-011(비READY)이 나가 클라이언트가 "진행 중"과 "그냥 비READY"를 구분하지 못했다.
대기 아이템 검사를 앞으로 옮겨 계약대로 IMAGE-004 를 준다. 기존 테스트가 상태를 READY 로
되돌려 놓고 재시도해 이 경로를 가리고 있었으므로 그 조작을 걷어냈다.
- 대표 교체 응답의 version 은 현재도 맞게 나간다 — 갤러리를 만들며 도는 조회가 Hibernate
자동 flush 를 일으켜 @Version 이 이미 올라간다. 다만 우연에 기대는 구조라 galleryOf 앞에
명시적 flush 를 두고, 응답 version 으로 곧바로 다음 교체가 되는지를 테스트로 고정했다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 레거시 폼의 이미지 키 칸을 읽기 전용으로, 일괄 제출 문서를 실제 계약으로
Codex 2건.
- 여전히 서비스되는 /admin/foods/list 편집 폼에 이미지 키 입력이 활성이었다. 이번 가드가 그 수정을
IMAGE_REF_NOT_EDITABLE 로 거절하는데, 폼은 한 번에 보내므로 같이 담긴 무관한 수정까지 통째로
버려진다. 칸을 readonly 로 바꿔 대표 이미지는 갤러리 API 로만 바꾸게 한다.
disabled 가 아니라 readonly 인 이유는 disabled 면 값이 전송되지 않아 가드가 빈 문자열과
현재 키를 비교해 오히려 전부 거절하기 때문이다.
- 일괄 제출 Swagger 가 "요청 바디 없음, 서버가 대상 선정"으로 남아 있었다. 실제로는 foodIds 가
유료 제출 범위를 정하고, 바디 생략·null 이면 후보 전체가 제출된다. 문서를 계약대로 고쳤다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 음식 수정에서 imageRef 생략을 무변경으로 다룬다
Codex P2: imageRef 는 요청에서 선택 필드인데 컨트롤러가 빈 문자열로 바꿔 넘겨, 이미지가 있는
음식은 필드를 생략한 모든 수정이 FOOD-012 로 거절됐다. 공개 스키마대로 호출하는 클라이언트가
무관한 필드 하나를 고치려면 현재 이미지 키를 먼저 조회해 되돌려줘야 했다 — 어드민 음식 수정
전체를 막는 경로다.
UpdateFoodCommand.imageRef 를 nullable 로 바꿔 null 을 무변경으로 읽는다. 값이 오면 현재 키와
같아야 하고 다르면 FOOD-012 다. 대표 이미지 자체는 여전히 갤러리 대표 지정 API 로만 바뀐다.
계약을 required 로 바꾸지 않았다 — 어드민 화면과 레거시 폼을 둘 다 고쳐야 한다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 배치 실패로 이미지 대기에 남은 음식도 재생성할 수 있게
Codex P1: 제출에 성공한 배치가 만료·실패하거나 항목 오류를 내면 수집기가 아이템을 FAILED 로
닫지만 음식은 PENDING_IMAGE 에 남는다. 진행 중 아이템이 없으니 대기 검사는 통과하는데 준비
상태 검사가 FOOD-011 로 막아, 공개돼 있던 음식이 숨겨진 채 색인에서 빠진 상태로 방치됐다.
재생성 허용 조건을 READY 또는 PENDING_IMAGE 로 넓힌다. 진행 중 아이템 검사가 앞에 있으므로
여기까지 오면 활성 아이템이 없는 상태이고, 겹친 제출은 여전히 IMAGE-004 로 막힌다.
종단 실패 때 음식을 이전 상태로 되돌리는 일은 수집기 소유라 별건으로 둔다 — 그쪽이 "숨은 채
방치"의 근본 원인이고, 이 PR 은 운영자가 복구할 길을 여는 데까지다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 재생성이 READY 를 벗어날 때 레거시 발행 시각을 동결한다
Codex P2: publishedAt 이 비어 있는 레거시 READY 음식은 effectivePublishedAt 의 createdAt 폴백에
기대는데, 재생성 전이가 freezePublishedAtIfLegacy 없이 READY 를 벗어났다. 이후 수집이 검수
대기로 보내고 승인이 현재 시각을 찍으면 옛 음식이 새로 나온 것처럼 보인다.
어드민 음식 수정의 READY 이탈 경로가 이미 같은 호출을 하고 있다 — 재생성만 빠져 있었다.
dev 기준 published_at 이 비어 있는 음식 881건 중 READY 는 0건이라 지금 터지는 경우는 없다.
발견이 어려운 조용한 오염이라 선례와 같은 자리에서 막는다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 재생성 재시도를 이미지가 있던 음식으로 좁힌다
Codex P2: 앞 커밋의 PENDING_IMAGE 예외가 첫 이미지를 기다리는 신규 음식까지 받아들였다.
그 음식들은 원래 이 상태이고 진행 중 아이템도 없어서, 재생성 엔드포인트가 READY 전용이라는
계약을 우회해 유료 생성을 곧바로 호출할 수 있었다. 신규 음식의 첫 이미지는 일괄 제출 몫이다.
재시도는 "이미 대표 이미지가 있는데 PENDING_IMAGE 로 남은" 음식으로 좁힌다. 재생성은 대표
이미지가 있는 음식만 대상이라 image_ref 유무가 곧 "재생성하다 실패해 남았는지"의 판별자다
(dev·prod 실측에서도 READY 는 전부 image_ref 보유, PENDING_IMAGE 는 전부 미보유).
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 배치 선점을 상태 전이와 한 트랜잭션에 넣는다
Codex P1: 상태 전이를 먼저 커밋하고 그다음 제출 서비스가 자기 트랜잭션으로 배치를 선점했다.
두 커밋 사이에서 프로세스가 멈추면 음식은 PENDING_IMAGE 에 벡터 삭제까지 예약된 채 남는데
수집기가 회수할 배치 아이템이 없다 — 공개돼 있던 음식이 숨은 채 방치된다.
제출 서비스를 선점(claimOne)과 외부 호출(submitClaimed)로 갈랐다. 재생성은 상태 전이와 선점을
같은 트랜잭션에서 커밋하고, 유료 API 호출만 그 밖에서 한다. 일괄 제출 경로도 같은 두 단계를
쓰므로 동작은 그대로다. 선점이 durable 해진 뒤 외부를 호출한다는 기존 성질도 유지된다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 재생성 선점 경합을 계약대로 IMAGE-004 로 알린다
Codex P2: 두 관리자가 같은 음식을 동시에 재생성하면 둘 다 진행 중 검사를 통과하고, 진 쪽이
선점 유니크 제약이나 낙관적 락에서 깨져 COMMON-00x 로 나갔다. 이중 유료 제출은 제약이 이미
막고 있었고 문제는 "막혔다는 사실을 계약대로 알리지 못한 것"이다.
선점 트랜잭션 밖에서 런타임 예외를 잡되, 경쟁 중인 대기 아이템이 실제로 있을 때만 IMAGE-004 로
번역한다. 없으면 원래 예외를 그대로 올린다 — 확인 없이 번역하면 진짜 무결성 오류를 감춘다.
비즈니스 예외는 먼저 잡아 그대로 통과시킨다.
테스트: 같은 음식에 동시 재생성 두 건 → 200 하나·409 하나.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* docs(admin): 일괄 제출의 외부 호출 실패 응답을 Swagger 에 적는다
계약 에러 코드 일괄 점검에서 남은 한 자리다. 이미지 생성 API 제출이 실패하면 전용 코드 없이
500 이 나간다. 코드를 새로 만들지 않고 문서에만 남긴다 — 선점했던 배치는 실패로 닫히고
다음 호출에 다시 포함되므로 클라이언트가 분기할 동작이 따로 없다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 일괄 제출에서 경합한 음식만 건너뛴다
Codex P2: 대상 목록을 계산한 뒤 다른 요청이 그중 한 건을 선점하면 청크 선점이 통째로 실패해,
경합과 무관한 나머지 음식까지 제출되지 않고 버려졌다. 이번에 넣은 선점 유니크가 만든 경계다.
청크 단위 all-or-nothing 을 없앴다. 선점 전에 진행 중인 음식을 걸러 내고, 그래도 충돌하면
한 번 더 걸러 남은 음식으로 다시 선점한다. 끝까지 실패한 음식만 건너뛰며, 건너뛴 id 는 모두
응답의 skippedInProgress 로 드러나 운영자가 무엇이 빠졌는지 안다.
테스트: 한 건이 선점된 채로 두 건을 제출하면 하나만 제출되고 선점된 id 가 skipped 로 온다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 선점 충돌마다 남은 음식으로 다시 선점한다
Codex P2: 재시도를 한 번만 하니 그 재시도 중에 또 다른 음식이 선점되면 남은 음식 전체를
건너뛰었다. 경합과 무관한 음식이 다음 제출 호출까지 빠진다.
충돌할 때마다 진행 중인 음식을 다시 걸러 내고 남은 음식으로 선점을 재시도한다. 매 충돌마다
경쟁자의 선점이 이미 커밋돼 보이므로 대상이 최소 한 건씩 줄고, 반복 횟수를 청크 크기로 묶어
끝난다. 대상 수가 줄지 않으면 선점 경합이 아닌 다른 무결성 오류라 보고 그 청크를 건너뛴다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 재생성이 삭제를 예약하기 전에 대기 중인 재색인을 취소한다
Codex P2: READY 음식에 벡터 UPSERT 가 대기 중인데 재생성이 그 뒤에 DELETE 를 얹으면, 워커가
밀린 사이 이미지 회수와 승인이 먼저 끝나도 검수 서비스는 "이미 대기 중인 UPSERT 가 있다"고 보아
새로 넣지 않는다. 워커가 옛 UPSERT 다음에 DELETE 를 처리하면 다시 공개된 음식이 벡터 검색에서
영영 빠진다.
소프트 삭제 경로가 이미 하는 대로, READY 에서 숨김으로 내려가는 이 전이에서도 대기 중인
UPSERT 를 취소하고 DELETE 를 넣는다. 승인이 그 뒤에 새 UPSERT 를 예약할 수 있다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 경합이 아닌 무결성 오류는 삼키지 않고 올린다
Codex P2: 선점이 유니크 경합 말고 다른 이유로 깨지면(예: 설정된 모델 값이 컬럼 길이를 넘음)
대상 수가 줄지 않는데, 그 분기가 전 대상을 skippedInProgress 로 적고 200 을 돌려줬다.
아무것도 진행 중이 아닌데 "건너뛴 것뿐"으로 보여 운영 실패가 감춰진다.
경합이 확인되지 않으면 예외를 그대로 올린다. 로그도 error 로 올렸다 — 이 경로는 설계상
선점 경합이 아닌 것만 도달하므로 정상 운영 중에는 나오지 않는다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
* fix(admin): 바꿀 게 없는 대표 지정은 낡은 버전으로도 성공으로 답한다
Codex P2: 응답을 못 받은 클라이언트가 같은 대표 지정을 재시도하면, 첫 요청이 이미 version 을
올려 둔 탓에 FOOD-006 이 나갔다. 그 이미지는 이미 대표이고 계약은 재선택을 멱등 200 으로 약속한다.
버전 검사를 "바꿀 게 있을 때" 로 좁혔다. 대상이 이미 대표이고 food.imageRef 도 그 키면 아무것도
바꾸지 않으므로 낙관적 락을 걸 이유가 없다. 실제로 바꾸는 요청에는 검사가 그대로 유지된다.
Refs KB-414
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* feat(food): 재료 관계 백필·양방향 검증·역조회 이중 쓰기 배포 뒤 기존 음식 전부를 관계 테이블로 채우고 JSON 과 등가인지 검증한다. 관계 테이블이 주는 첫 기능인 재료·분류 역조회도 함께 연다. 위험 판정·상세 읽기는 아직 JSON 이다. - 백필 POST /api/admin/foods/ingredient-backfill?dryRun=true|false (@hidden). 보존 중인 모든 음식(삭제·전 content_status 포함)을 음식 단위 트랜잭션으로 처리한다. JSON 항목에서 0% 를 뺀 집합을 관계로 교체하고 ingredients_assessed 를 갱신한다. Food.replaceIngredients·어드민 수정 서비스를 쓰지 않는다 — 벡터 outbox 를 만들지 않으려고 전용 리포지토리(FoodIngredientBackfillJdbcRepository)로 간다. 원본이 깨진 음식은 건너뛰고 이유와 함께 목록으로 돌려준다(부분 백필 금지). 음식 단위 집합 교체라 재실행이 멱등이다. - 검증 리포트 GET /api/admin/foods/ingredient-backfill-report (@hidden): JSON→관계·관계→JSON 양방향 diff 음식 id 와 assessed 불일치 id. dev·prod 둘 다 0 이어야 읽기 전환을 논할 수 있다. - 역조회: GET /api/admin/foods 에 ingredientCode·categoryCode 필터 추가(응답 shape 무변). 두 필터 모두 IN 서브쿼리라 역방향 인덱스 (ingredient_id, food_id)로 음식 id 를 모은다. - 재료 카탈로그 응답에 categoryCode 가산(분류 없는 포괄 재료는 null). IngredientCategory 엔티티와 Ingredient.categoryId 매핑 추가 — 스키마는 #279 에서 이미 나갔다. - 음식 한 건의 관계 조회는 sort_order 순 21행까지만 주고 넘치면 WARN 을 남긴다. 목록은 id 집합 IN 일괄 로드라 N+1 이 없다. 판정 읽기 전환(5단계) 전까지 호출부는 없다. 동시 실행은 인스턴스 안 플래그로 막고 겹치면 409 FOOD-017 이다. 인스턴스가 여럿이면 동시에 돌 수 있으나 음식 단위 집합 교체가 멱등이라 결과는 같고, 겹친 음식은 실패 목록으로 드러나 재실행으로 수렴한다. 테스트: dryRun 무변경 · 삭제 음식 포함 백필 · 멱등 · 깨진 원본 건너뛰기 · 벡터 outbox 무증가 · 리포트 백필 전후 · 21행 상한 · 역조회 두 필터 · 카탈로그 categoryCode. 전체 그린 — api 1450 / common 546 / batch 37. Refs KB-596 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ * fix(food): 백필이 음식별 트랜잭션 안에서 원본을 다시 읽게 Codex P2: 전체 음식 스냅샷을 떠 놓고 음식별 트랜잭션에서 처리하면, 그 사이 들어온 어드민 수정· 적재를 낡은 JSON 으로 덮어쓰고 성공으로 집계한다. 이행 기간의 쓰기 동결은 사람 간 합의지 코드가 막는 게 아니다 — 조용한 불일치를 없애려는 도구가 같은 함정을 가질 수는 없다. 스냅샷에서는 음식 id 만 뽑고, JSON·assessed 는 각 음식의 트랜잭션 안에서 다시 읽는다. 음식당 조회 한 번이 늘 뿐이다(dev 1,208건). 검증 리포트는 여전히 사후 그물망이다 — stale 쓰기가 남았다면 양방향 diff 로 잡히고 DoD 가 diff 0 을 요구한다. 이번 수정은 그 재실행 비용을 없앤다. Refs KB-596 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ * fix(food): 검증 리포트의 양쪽을 한 스냅샷에서 읽게 Codex P2: sources 와 relations 를 트랜잭션 밖 두 쿼리로 읽어, 그사이 커밋이 들어오면 이중 쓰기가 정상인데도 가짜 diff 가 난다. 이 리포트가 배포 판정용 zero-diff 게이트라 거짓 양성은 곧 백필 헛 재실행이다. 읽기 전용 트랜잭션으로 묶어 REPEATABLE READ 스냅샷 하나에서 양쪽을 읽는다. Refs KB-596 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ * fix(food): 백필이 음식 행을 잠그고 읽는다 Codex P2: 음식별 재조회가 잠금 없는 SELECT 라, 적재가 새 JSON 을 쓰는 사이 이 트랜잭션이 옛 스냅샷으로 관계를 덮고 성공으로 집계할 수 있었다. 새 JSON 에 낡은 관계가 붙는다. 쓰기 트랜잭션 안의 재조회를 SELECT ... FOR UPDATE 로 바꿔 교체·표시가 끝날 때까지 행을 잡는다. dryRun 은 아무것도 쓰지 않으므로 잠그지 않는 조회를 그대로 쓴다 — 점검이 운영 쓰기를 막을 이유가 없다. Refs KB-596 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ * fix(food): 재료를 쓰는 모든 경로가 음식 행을 먼저 잠근다 Codex P2: 백필에 FOR UPDATE 를 넣어 낡은 읽기를 막았더니 락 순서가 역전됐다. 백필은 food 를 먼저 잠그고 관계를 교체하는데, 적재는 관계를 먼저 바꾸고 food 는 Hibernate 가 flush 할 때 잠갔다. 둘이 겹치면 서로를 기다리고 MySQL 이 한쪽을 죽인다. 적재가 음식을 findByIdForUpdate 로 먼저 잠근다. 어드민 수정은 이미 같은 순서였다. 이제 재료를 쓰는 세 경로(적재·어드민 수정·백필)가 모두 "음식 먼저, 관계 나중" 으로 잠근다. flush 시점에 순서를 맡기지 않는 게 핵심이다 — 코드에 안 보이는 순서는 다음 사람이 지킬 수 없다. Refs KB-596 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
chore(db): 콘텐츠 아웃박스 회수 컬럼 추가 — dead_at·last_error 발행된 SENT 행은 응답이 오지 않아도 다시 집히지 않는다. 퍼블리셔가 PENDING 만 읽기 때문이다. prod 에서 173건이 2026-09-02~09-10 에 SENT 로 굳은 채 한 달 가까이 아무도 모르고 지나갔다. 회수 잡(짝 B)이 쓸 컬럼을 먼저 넣는다. 전부 가산이라 구 코드와 공존한다. - dead_at: 재시도 상한에 닿아 포기한 시각. 값이 있으면 회수 대상에서 빠지고 사람이 볼 대상이 된다. - last_error: 왜 굳었는지. 숫자만 남으면 다음 사람이 조사를 처음부터 다시 한다. - INDEX (outbox_status, sent_at): 굳은 행 조회용. outbox_status ENUM 에 DEAD 를 더하지 않았다 — 기존 컬럼 MODIFY 는 가산이 아니다(9/20 원칙). 포기 상태는 dead_at 으로 표시하며 PENDING 과 섞이지 않는다는 요구는 그대로 만족한다. Refs KB-607 Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
로그아웃 상태에서 같은 기기의 회원 문의가 보였다. 조회 조건이 "설치 ID 또는 회원 id" 라 게스트가 그 기기에서 회원이 보낸 문의까지 봤다. iOS 는 설치 ID 가 키체인에 남아 재설치도 살아남으므로, 앱을 지웠다 깔고 비로그인인데 이전 문의가 보였다. 범위를 신원에 따라 가른다 — 로그인 상태면 그 회원의 문의만(기기 무관), 비로그인이면 그 기기의 게스트 문의만(회원 문의 제외). 목록과 상세가 같은 조건을 쓴다. 상세가 넓으면 URL 로 남의 문의를 연다. 게스트로 보낸 문의를 로그인 뒤 이어 보는 것(계정 이전)은 이번 범위가 아니다 — 지금은 안 보이는 것을 수용한다. 그 동작을 검증하던 기존 테스트 둘을 새 계약으로 바꿨고 Swagger 설명도 고쳤다. 테스트 4케이스: 기기 공유 회원 문의 비노출 / 다른 기기 로그인 시 노출 / 기기 게스트 문의 노출 / 회원이 기기 게스트 문의 비노출. 상세 404 포함. OR 로 되돌리는 뮤테이션에서 다섯 건이 빨개진다. Refs KB-615 Claude-Session: https://claude.ai/code/session_01GukKBwfa8dRLMzhK4LoqvJ Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
발송 기록 테이블을 만들 때(V2026.09.07.15.39.45) notification_id 에만 외래키를 걸고 notification_device_id 는 빠졌다. 존재하지 않는 기기 id 가 들어가도 DB 가 막지 못하고, 기기 행이 사라져도 발송 기록이 고아로 남는다. 컬럼은 NULL 을 그대로 허용하고 ON DELETE 는 두지 않는다 — 저장소의 다른 외래키와 같은 방침이다. dev 는 발송 12건 모두 참조가 유효하고 prod 는 아직 이 테이블이 없어, 제약 추가로 기동이 막히는 환경은 없다. Claude-Session: https://claude.ai/code/session_019sygpc7qkquce8vHzh65jc Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9fdd6c846b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| val device = deviceRepository.findByInstallationId(installationId) | ||
| if (device == null) { | ||
| deviceRepository.save(NotificationDevice.register(installationId, token, platform, lang, memberId)) |
There was a problem hiding this comment.
Retire stale owners of an Expo token
When an installation ID changes while Expo returns the same token, this path creates a second active device without invalidating the previous row. PushTargetResolver later selects both rows solely by member and token validity, so the device can receive duplicate pushes; if the new installation logs into a different account, notifications intended for the previous account can still be delivered to that physical device. Registration should ensure that an Expo token has only one current owner or otherwise exclude stale token rows from dispatch.
Useful? React with 👍 / 👎.
| val existingLike = reviewLikeRepository.findByReviewIdAndMemberIdIncludingDeleted(reviewId, memberId) | ||
| reviewLikeRepository.upsertActive(reviewId = reviewId, memberId = memberId) | ||
| val isNewLike = existingLike?.countsAsNewLikeAt(LocalDateTime.now()) ?: true |
There was a problem hiding this comment.
Make like notification detection atomic
When two liked=true requests for the same member and review run concurrently, both can read existingLike == null before either upsert completes. The unique upsert keeps only one like row, but both transactions still compute isNewLike = true and publish ReviewLiked, producing duplicate notifications for an otherwise idempotent operation. Derive event publication atomically from the upsert result or lock the (reviewId, memberId) state before deciding.
Useful? React with 👍 / 👎.
* feat(push): 푸시 문구를 MessageSource 메시지 파일로 이관하고 후보를 랜덤 선택한다 (KB-617) PushTemplates 코틀린 맵 상수를 common/src/main/resources/messages/push_<locale>.properties 10개(ko·zh_Hans·en·ja·zh_Hant·vi·id·th·ru·es)로 옮긴다. 렌더러는 유형·슬롯·언어로 키를 조립해 MessageSource 에서 읽고, 치환·광고 접두·수신거부 안내·절단 규칙은 그대로다. - PushMessageSourceConfig(common.domain.notification): ResourceBundleMessageSource 빈 하나, basename messages/push, UTF-8, fallbackToSystemLocale=false, 기본 파일 없음 — 누락 언어는 폴백 없이 NoSuchMessageException. api 는 루트 스캔, batch 는 PushConfig @import 로 같은 빈. - LanguageCode.locale = Locale.forLanguageTag(code). JDK 21 에서 zh-Hans→push_zh_Hans, zh-Hant→push_zh_Hant, id→push_id 로 해석됨을 확인. - 키 체계 push.<type>[.<slot>].<n>.title|body + push.opt-out. 유형(·슬롯)마다 후보 3개를 두고 렌더러가 pickVariant(기본 Random.nextInt)로 하나를 골라 같은 번호의 제목·본문 쌍을 쓴다. MessageSource 조회는 인자 없이 해 MessageFormat 을 타지 않는다 — {food} 이름 자리표시자와 따옴표가 변형되지 않고 기존 정규식 치환이 그대로 동작한다. - 문구 교체(사용자 결정 2026-09-22): Codex 가 토스·Braze·Airship·OneSignal·Apple 자료를 조사해 어그로형 후보 3개씩 제안 → humanize-korean 윤문 검수(등급 A, 24세트 중 18세트 수정: 주체 뒤집힘·경어법 혼용·목적어 누락·직역투) → Codex 10개 언어 번역. 이모지 후보당 1개. 수신거부 안내는 기존 문구 고정. NEWS 는 {title}/{body} 통과 1개. - 테스트: 유형(·슬롯) × 언어 후보 수 동일·title/body 존재 검증(누락 시 키·언어를 짚어 실패), 선택기 계약, zh 간/번체 구분, 폴백 없음, 이모지 보존. 통합 테스트(dispatch·batch)는 무작위라 정확 문자열 대신 언어 차이·{food} 치환·접두/안내로 검증. - PushTemplates.kt 삭제. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(specs): KB-617 spec·plan·research·data-model — 범위 확장(문구 교체·후보 랜덤 선택) 반영 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
릴리스 범위
main 은 26.09.0(#228)이고, 이번 머지로 develop HEAD
e0bae18c까지 PR 1개(#240) 가 올라간다. 앱 코드 변경 없음 — IaC 만 바뀐다. 파이프라인은 같은 소스로 이미지를 다시 빌드해 카나리 배포하므로 동작 변화는 없다.인프라 (#240)
배포 후 확인
deploy-prod.yml카나리 성공 → Slack ✅,https://prod.kbap.site/api/app-version200aws application-autoscaling describe-scalable-targets --service-namespace ecs --resource-ids service/kbap-prod-ecs-cluster/kbap-prod-ecs-api로 min/max 확인🤖 Generated with Claude Code
https://claude.ai/code/session_017TfKjXnrRmxTY42F15Pr9s