Compare commits
435
Commits
test
..
7844ea74d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7844ea74d7 | ||
|
|
e04fa67d1d | ||
|
|
bd1ec54d36 | ||
|
|
4442fa6d91 | ||
|
|
795c25159f | ||
|
|
cdc7cb8612 | ||
|
|
efefef51f4 | ||
|
|
e2b9eb8bf3 | ||
|
|
1aa7a89f7b | ||
|
|
45aa26f1cb | ||
|
|
626f02b45e | ||
|
|
540c5cb317 | ||
|
|
3aa6a35adb | ||
|
|
81c0d2586c | ||
|
|
965bb068fc | ||
|
|
d082e0b745 | ||
|
|
c8b83272a3 | ||
|
|
1939fdcb33 | ||
|
|
f521a240c2 | ||
|
|
0c35624dfb | ||
|
|
5c24addf31 | ||
|
|
8671c8efc7 | ||
|
|
16c17f4bfa | ||
|
|
0e821fae1b | ||
|
|
6a10eff15f | ||
|
|
fea329e637 | ||
|
|
681e4a4036 | ||
|
|
c23f574162 | ||
|
|
c884d7d6c9 | ||
|
|
116e8cbca3 | ||
|
|
c8187ba147 | ||
|
|
676bd0b79e | ||
|
|
7522f06bf3 | ||
|
|
a9d2d1ab48 | ||
|
|
e0e371cdc9 | ||
|
|
b49344d0e9 | ||
|
|
5cc152307a | ||
|
|
1fd3d41d7e | ||
|
|
c1b9dd730d | ||
|
|
d40cd32c50 | ||
|
|
0289607fd9 | ||
|
|
1bec644372 | ||
|
|
f8a6d1b221 | ||
|
|
ec769a1307 | ||
|
|
8e4fb0d313 | ||
|
|
cc3a620642 | ||
|
|
be0884e974 | ||
|
|
34456395fd | ||
|
|
26ddeb9591 | ||
|
|
cd535a628c | ||
|
|
de32b537f4 | ||
|
|
9c271fc1f6 | ||
|
|
2ddbfbccd6 | ||
|
|
80786deb72 | ||
|
|
8ca2e185ac | ||
|
|
484711ad1b | ||
|
|
e80ceca0c5 | ||
|
|
33293a6533 | ||
|
|
f0c1d4e32a | ||
|
|
6cd319ec76 | ||
|
|
6557ec2aed | ||
|
|
f2f8a34319 | ||
|
|
c50ac6ed2c | ||
|
|
11b9c349d1 | ||
|
|
ef9f8d65e1 | ||
|
|
299f2100e9 | ||
|
|
fd5c794480 | ||
|
|
68197de095 | ||
|
|
587f3d6b58 | ||
|
|
9b6167d46d | ||
|
|
008ee3b4e5 | ||
|
|
3a57ad23bb | ||
|
|
729552335a | ||
|
|
02ae507c87 | ||
|
|
5818abf69d | ||
|
|
ee403915f0 | ||
|
|
1a660088de | ||
|
|
5196c80ca8 | ||
|
|
c9c09c2998 | ||
|
|
3ea33c4c7b | ||
|
|
451a1aa4f2 | ||
|
|
90555fd34f | ||
|
|
0dc430b098 | ||
|
|
1f2103c7fa | ||
|
|
062c17c51e | ||
|
|
de169b79a1 | ||
|
|
aa24de0a5a | ||
|
|
e5937d573a | ||
|
|
6da86e12bd | ||
|
|
9049022a74 | ||
|
|
7b6f3a7a5f | ||
|
|
53e9678efa | ||
|
|
e4f547fa92 | ||
|
|
b69756ef81 | ||
|
|
1a3a9149a2 | ||
|
|
ce120a6d5d | ||
|
|
08b5fd23ab | ||
|
|
eb18e2d009 | ||
|
|
a27852ed44 | ||
|
|
c7925c1706 | ||
|
|
be59bd7e89 | ||
|
|
51ce143fc2 | ||
|
|
89eb11f808 | ||
|
|
30d89987a4 | ||
|
|
7959d3e5ed | ||
|
|
1e29573ef7 | ||
|
|
cc2f533dc6 | ||
|
|
32b0c19f9d | ||
|
|
9af2d768e8 | ||
|
|
5677824cde | ||
|
|
e8f1bc09f9 | ||
|
|
d1a936d55b | ||
|
|
dc97eaa835 | ||
|
|
dcbe57806c | ||
|
|
b14438cc15 | ||
|
|
b27d3bd5c6 | ||
|
|
03ebc9cfe9 | ||
|
|
24841b9850 | ||
|
|
d35a3d1a8c | ||
|
|
60c4e0b528 | ||
|
|
84f33d1bc2 | ||
|
|
c4e1709b99 | ||
|
|
e7a5fd5819 | ||
|
|
4bde03643c | ||
|
|
1bc52b56af | ||
|
|
9c33fd93f7 | ||
|
|
3c087bc275 | ||
|
|
8ad13c289e | ||
|
|
7577f48a09 | ||
|
|
0251906964 | ||
|
|
2723a5f134 | ||
|
|
c3c60605fd | ||
|
|
238f704b22 | ||
|
|
5639d8ac8e | ||
|
|
9aac591591 | ||
|
|
ffa8e5aebb | ||
|
|
cbbfe014cc | ||
|
|
83028f7817 | ||
|
|
70d1795557 | ||
|
|
8c6c681424 | ||
|
|
50bc9f4ff3 | ||
|
|
f00ea03fad | ||
|
|
f22e7b9ad1 | ||
|
|
c7ec95f4bb | ||
|
|
229e7a8ccc | ||
|
|
3c616474ff | ||
|
|
56eb6b3ce3 | ||
|
|
545836d43c | ||
|
|
219f83dec0 | ||
|
|
a76a841238 | ||
|
|
c26680de84 | ||
|
|
8fffad9d3a | ||
|
|
f4f0f203a2 | ||
|
|
b7196f5a0c | ||
|
|
5d33a18890 | ||
|
|
96186a1a50 | ||
|
|
bc8bc479d1 | ||
|
|
47595b1291 | ||
|
|
01a88964df | ||
|
|
3a2b77379f | ||
|
|
dc4e5f75cd | ||
|
|
d0178d551c | ||
|
|
827333108d | ||
|
|
587b90bd27 | ||
|
|
4dc20c5e90 | ||
|
|
ac25782f2b | ||
|
|
20437d56e7 | ||
|
|
f0b412828a | ||
|
|
367faac5c3 | ||
|
|
84deaaa970 | ||
|
|
a2b39466c2 | ||
|
|
03586c4005 | ||
|
|
6ea69e1510 | ||
|
|
553c6dc539 | ||
|
|
6cc22f5b6d | ||
|
|
9103d67cc1 | ||
|
|
25083fb0e4 | ||
|
|
d2dc045255 | ||
|
|
b8621dfbb0 | ||
|
|
93633940dd | ||
|
|
b6f5325351 | ||
|
|
7c32c08f1f | ||
|
|
1d268da08d | ||
|
|
797666ae0d | ||
|
|
dcf470997e | ||
|
|
0974d1dbf8 | ||
|
|
12a35db6cd | ||
|
|
9abbb05ad8 | ||
|
|
1ecaf69b0b | ||
|
|
e334d1e5d9 | ||
|
|
b735e861d0 | ||
|
|
4eb433d372 | ||
|
|
2416ae61f3 | ||
|
|
01fb336985 | ||
|
|
b6af88a732 | ||
|
|
58a2a17d6d | ||
|
|
79f5a0f520 | ||
|
|
7f6c0f7f04 | ||
|
|
f658df4dca | ||
|
|
9d43b8e23a | ||
|
|
4270aef79b | ||
|
|
1c0dc82d44 | ||
|
|
c1e325aadf | ||
|
|
cec87da69d | ||
|
|
f68f24cb2c | ||
|
|
ed094347fc | ||
|
|
b8afdffbe1 | ||
|
|
f6ba79f31c | ||
|
|
5f3b1663d2 | ||
|
|
66e786b4bb | ||
|
|
f671114574 | ||
|
|
ce37060d94 | ||
|
|
7d19a4d184 | ||
|
|
22f28a2f8a | ||
|
|
ceef9ca979 | ||
|
|
efe8f4f939 | ||
|
|
ba692a1195 | ||
|
|
d732bad042 | ||
|
|
4c935c3bee | ||
|
|
c160dd791f | ||
|
|
23cd1b4601 | ||
|
|
031fc8ba1b | ||
|
|
c6853289ad | ||
|
|
2497bb69bc | ||
|
|
a58a67e0a2 | ||
|
|
4315fe12a5 | ||
|
|
42f10a8899 | ||
|
|
1e4b47f989 | ||
|
|
ff255dbfae | ||
|
|
dbe9b72feb | ||
|
|
95a714b391 | ||
|
|
28f58c7f56 | ||
|
|
8bd46d8f21 | ||
|
|
e1bb8e54ed | ||
|
|
1de705b063 | ||
|
|
f6926ad356 | ||
|
|
2cdbbb1b37 | ||
|
|
4dce8c8f03 | ||
|
|
97a5bace6f | ||
|
|
d4d51ec48f | ||
|
|
fb91398462 | ||
|
|
105dadd798 | ||
|
|
2abf2837d3 | ||
|
|
422aa67af6 | ||
|
|
7fffab6985 | ||
|
|
5a4be3d2c1 | ||
|
|
f39a7681db | ||
|
|
c60a7580ba | ||
|
|
97edb56edc | ||
|
|
6ebca8d22b | ||
|
|
95371ad934 | ||
|
|
2c176825fd | ||
|
|
fae7de48d3 | ||
|
|
b8230646a2 | ||
|
|
43279541dd | ||
|
|
b4791977c1 | ||
|
|
ef917ecc25 | ||
|
|
a93faad951 | ||
|
|
fd001d24d3 | ||
|
|
7aa5884797 | ||
|
|
5b237a1547 | ||
|
|
2e37990d87 | ||
|
|
dd07d724a8 | ||
|
|
03ce8618e7 | ||
|
|
db1a7a7fd6 | ||
|
|
36a82d7f53 | ||
|
|
3a34401113 | ||
|
|
9927268330 | ||
|
|
c45c97e29d | ||
|
|
c64a315226 | ||
|
|
a4cafca6ab | ||
|
|
46284a0660 | ||
|
|
05df86e15a | ||
|
|
8b433027e2 | ||
|
|
5bd4ff7610 | ||
|
|
d693c397ea | ||
|
|
1d8d1ec9a5 | ||
|
|
5e491f11ee | ||
|
|
7cedea06ac | ||
|
|
2e5f750e50 | ||
|
|
20289cad10 | ||
|
|
e0d64c31c7 | ||
|
|
8c1b95dc97 | ||
|
|
fb5641343e | ||
|
|
87765941eb | ||
|
|
1809862c16 | ||
|
|
300f784f7d | ||
|
|
67a045eae6 | ||
|
|
2a79903a28 | ||
|
|
d3222ce083 | ||
|
|
406a421742 | ||
|
|
10bf728faf | ||
|
|
607617747c | ||
|
|
f0a69eb1a2 | ||
|
|
6b307a6e17 | ||
|
|
08d08a934a | ||
|
|
c500c12668 | ||
|
|
62060adeba | ||
|
|
b2fc75edb8 | ||
|
|
a999dd2085 | ||
|
|
49f95ab100 | ||
|
|
1a84d5b30c | ||
|
|
3b65050632 | ||
|
|
d0df31674c | ||
|
|
1fe88402e2 | ||
|
|
67097696e6 | ||
|
|
8e7e77067a | ||
|
|
9899390b61 | ||
|
|
80c476a908 | ||
|
|
59da1d6e49 | ||
|
|
5aef7dac33 | ||
|
|
faf7aa06b6 | ||
|
|
38ef6e5583 | ||
|
|
c0b15b5d94 | ||
|
|
2cfc067ea1 | ||
|
|
a91db4f956 | ||
|
|
8a09780a02 | ||
|
|
45e8ec6505 | ||
|
|
4554b85914 | ||
|
|
8aa79c4a9c | ||
|
|
c8d3210b57 | ||
|
|
2282a49563 | ||
|
|
b82fdfb2c8 | ||
|
|
2d17eac199 | ||
|
|
e482bc3aad | ||
|
|
ec022b74d1 | ||
|
|
dc42c09ce3 | ||
|
|
046a34d2a4 | ||
|
|
9ff6ec1888 | ||
|
|
d2950106ec | ||
|
|
962f800d2e | ||
|
|
962107e507 | ||
|
|
039bd11963 | ||
|
|
5c250ea4ae | ||
|
|
e3405bcec6 | ||
|
|
0fd1c2235f | ||
|
|
b20c29b022 | ||
|
|
12d5dcd298 | ||
|
|
2c305dc6c6 | ||
|
|
62f76f7433 | ||
|
|
858ce524f9 | ||
|
|
3795fb4a40 | ||
|
|
0c01aeec50 | ||
|
|
892206744d | ||
|
|
9e2c1474db | ||
|
|
16328f73d9 | ||
|
|
e0d4f53cf4 | ||
|
|
e09a59c5b4 | ||
|
|
049e654535 | ||
|
|
c927dc4ecd | ||
|
|
fe4ecd0ad8 | ||
|
|
78d476fe80 | ||
|
|
a11c8465d5 | ||
|
|
366304a9b7 | ||
|
|
4356663688 | ||
|
|
26b55e6fcf | ||
|
|
0d743f7204 | ||
|
|
6cbe113b3e | ||
|
|
6409b69d6c | ||
|
|
c5164c76fc | ||
|
|
baade8e138 | ||
|
|
b848d6b4e0 | ||
|
|
d8139d2ab0 | ||
|
|
e96d8f7469 | ||
|
|
2acffd8afc | ||
|
|
3c8e72073c | ||
|
|
724d7a9d9b | ||
|
|
2da3b0db78 | ||
|
|
685ad7afaf | ||
|
|
264cf75964 | ||
|
|
c773dbc7b5 | ||
|
|
37cbc64f52 | ||
|
|
cb1dde17bb | ||
|
|
c29988acf4 | ||
|
|
eadbf56dae | ||
|
|
4b3b455135 | ||
|
|
e6ac177396 | ||
|
|
3d0e29003f | ||
|
|
78b9b00f77 | ||
|
|
0ee7faa551 | ||
|
|
e5fdced681 | ||
|
|
afb99fef64 | ||
|
|
7dfaa36024 | ||
|
|
0496f665aa | ||
|
|
0d19e1be74 | ||
|
|
4aff0111aa | ||
|
|
63b3ba2bb2 | ||
|
|
7444b41f60 | ||
|
|
8e90dbc8b6 | ||
|
|
9f70722521 | ||
|
|
52fae596fa | ||
|
|
ccb67957bc | ||
|
|
fb82538d0d | ||
|
|
72ee39612e | ||
|
|
51fd5408dc | ||
|
|
3fae40fbef | ||
|
|
0745890af0 | ||
|
|
4abe1730a7 | ||
|
|
626f0e6989 | ||
|
|
9f42d9d173 | ||
|
|
f90a93c4bc | ||
|
|
8000ad6c6a | ||
|
|
1f1f1bea1a | ||
|
|
d95460c7cd | ||
|
|
a3d93d4b08 | ||
|
|
07a92af982 | ||
|
|
f4618877d4 | ||
|
|
2b914fd222 | ||
|
|
109e42a5a3 | ||
|
|
fa515ad39c | ||
|
|
f09673a795 | ||
|
|
f71536c614 | ||
|
|
7bdddc7ae8 | ||
|
|
aa8926a624 | ||
|
|
be71e59be2 | ||
|
|
4d7753378f | ||
|
|
60257c4ef4 | ||
|
|
1e0b79bf62 | ||
|
|
6883434d0d | ||
|
|
eda2193e64 | ||
|
|
99bf829c88 | ||
|
|
5feafe1b48 | ||
|
|
c9292b7d04 | ||
|
|
ae7e1a91c1 | ||
|
|
3e1887e0d1 | ||
|
|
474646db47 | ||
|
|
56f7b6c449 | ||
|
|
76b2b5f7e3 | ||
|
|
e918d809eb | ||
|
|
7af059e543 | ||
|
|
897726e1ec | ||
|
|
8b98a2dd07 | ||
|
|
cca75420f0 | ||
|
|
86c627ed1d | ||
|
|
d55514e3a7 |
+10
-4
@@ -27,7 +27,6 @@ repositories {
|
||||
|
||||
dependencies {
|
||||
implementation("org.redisson:redisson-spring-data-27:3.19.2")
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||
implementation("org.springframework.boot:spring-boot-starter-aop")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||
implementation("org.springframework.boot:spring-boot-starter-data-redis")
|
||||
@@ -100,10 +99,17 @@ tasks.withType<KotlinCompile> {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
maxHeapSize = "4g"
|
||||
jvmArgs("-Dfile.encoding=UTF-8")
|
||||
|
||||
val springContextCacheMaxSize = (project.findProperty("test.springContextCacheMaxSize") as String?) ?: "1"
|
||||
maxHeapSize = (project.findProperty("testMaxHeap") as String?) ?: "1536m"
|
||||
maxParallelForks = 1
|
||||
|
||||
jvmArgs(
|
||||
"-Dfile.encoding=UTF-8",
|
||||
"-Dspring.test.context.cache.maxSize=$springContextCacheMaxSize"
|
||||
)
|
||||
}
|
||||
|
||||
tasks.getByName<Jar>("jar") {
|
||||
|
||||
@@ -29,9 +29,6 @@
|
||||
- 저장소에는 DB migration 디렉터리가 없으므로 신규 스냅샷/조회 이력 엔티티 추가 시 운영 DB DDL 반영은 배포 절차에서 별도 수행한다. 코드 구현 task에는 JPA 엔티티/리포지토리와 통합 테스트를 포함하고, Phase 7 완료 후 신규 엔티티 테이블 생성 SQL을 문서 산출물로 작성한다.
|
||||
- 조회 구현은 JPA/QueryDSL 우선, native SQL 제한 사용의 하이브리드 전략으로 진행한다. 단순 조회/상세 조립/대상 활성 조건은 JPA 또는 QueryDSL로 표현하고, CTE/window function/`union all`/DB-side exact scoring처럼 SQL 고급 기능이 필요한 추천 산정에만 native SQL을 사용한다. native SQL 사용 시에는 H2 MySQL mode와 Kotlin 정책 산식 parity를 포함한 repository 통합 테스트를 반드시 둔다.
|
||||
- 이번 범위에서는 기존 홈/콘텐츠 홈/라이브/AI 캐릭터 API의 공개 스키마를 변경하지 않고, 앱 다국어 문구 번역, ML 개인화, A/B 테스트 플랫폼, 관리자 화면, 추천 결과 수동 편집 기능은 구현하지 않는다. 응답 enum은 앱 다국어 처리를 위해 안정적인 영문 code로 유지한다.
|
||||
- 방금 활동한 크리에이터 item은 활동을 등록한 `Member.id`를 non-null `creatorId`로 항상 제공한다.
|
||||
- `LIVE` 활동의 `targetId`는 `live_room.is_active = true`이면 `live_room.id`, `false`이면 `null`로 제공한다.
|
||||
- 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입은 추가하지 않고 기존 활동 시간·정렬과 비 LIVE `targetId` 의미를 유지한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -262,11 +259,9 @@
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- RED: 데뷔 후 30일 이내 추천 점수순, 최근 데뷔 크리에이터 노출 정보의 프로필 이미지/닉네임, 첫 오디오 콘텐츠 3번째 이내 활성 콘텐츠만 인정, 최신성 점수 구간별 정렬, 예약 공개 콘텐츠 제외 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`
|
||||
- GREEN: 데뷔일 계산, 최근 7일/30일 집계, `release_date` 기준 최신성 점수, 동점 랜덤 정렬을 구현한다. 최근 데뷔 크리에이터의 라이브 기준 데뷔 판정은 종료된 라이브도 유지되는 `live_room.channel_name` 존재 여부를 기준으로 하며, 종료 시 `false`가 되는 `live_room.is_active`는 조건으로 사용하지 않는다.
|
||||
- GREEN: 데뷔일 계산, 최근 7일/30일 집계, `release_date` 기준 최신성 점수, 동점 랜덤 정렬을 구현한다.
|
||||
- REFACTOR: 데뷔일 계산은 `CreatorDebutPolicy`, 산식은 `RecommendationScorePolicy`만 호출하도록 중복 제거한다.
|
||||
- 기대 결과: 앞선 비활성 콘텐츠가 3개 이상이면 이후 활성 콘텐츠가 제외된다.
|
||||
- 검증 기록:
|
||||
- 2026-07-22: 종료된 라이브도 `channel_name`이 있으면 최근 데뷔 크리에이터의 라이브 데뷔로 인정하도록 `DefaultHomeRecommendationQueryRepositoryTest.shouldIncludeEndedLiveWithChannelNameInRecentDebutCreators`를 추가했다. RED에서 기존 SQL의 `lr.is_active = true` 조건 때문에 실패했고, GREEN에서 `findRecentDebutCreators`의 live 데뷔 branch가 `channel_name` 기준만 사용하도록 수정해 focused test가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
|
||||
- [x] **Task 3.3: AI 캐릭터/응원/인기 커뮤니티 스냅샷 조회 구현**
|
||||
- Files:
|
||||
@@ -298,30 +293,6 @@
|
||||
- 2026-07-09: GREEN에서 `HomeRecommendationFacade.HOME_AI_CHARACTER_LIMIT`와 `HomeRecommendationQueryService.DEFAULT_AI_CHARACTER_LIMIT`를 20으로 변경하고, 홈 통합 응답이 AI 캐릭터를 최대 20개 반환하는 controller 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-07-09: 회귀 검증으로 `./gradlew ktlintCheck`와 `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`로 통과했다. 두 명령은 최초 sandbox 실행에서 Gradle wrapper의 `~/.gradle` lock 파일 접근 권한 문제로 실패했고, 권한 상승 재실행으로 통과했다. `./gradlew :app:ktlintCheck`는 단일 루트 프로젝트에 `app` 프로젝트가 없어 실패했으며, 저장소 기준 명령인 `./gradlew ktlintCheck`로 대체 검증했다.
|
||||
|
||||
- [x] **Task 9.2: 홈 첫 화면 AI 캐릭터 부족분 랜덤 보충**
|
||||
- Files:
|
||||
- Modify: `docs/20260529_메인_홈_추천_API/prd.md`
|
||||
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 스냅샷 상세 조립 결과가 요청 limit 미만이면 스냅샷 target id를 제외하고 랜덤 AI 캐릭터 id를 부족분만큼 조회한 뒤 기존 상세 조회를 재사용하는 service 테스트와, 랜덤 id 조회가 활성 `ChatCharacter` 및 활성 CREATOR/AI_CHARACTER `creatorMember`만 반환하고 제외 id를 빼는 repository 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest \
|
||||
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest
|
||||
```
|
||||
- GREEN: 홈 통합 조회에서 `backfillRandom = true`로 호출하고, `HomeRecommendationQueryService.findAiCharacterRecommendations`에서 보충이 명시적으로 켜져 있으며 상세 결과가 `limit`보다 적을 때만 `HomeRecommendationQueryPort.findRandomAiCharacterRecommendationIds(excludeCharacterIds, limit)`로 부족분 id를 먼저 뽑은 뒤 기존 `findAiCharacterRecommendationDetails(...)`로 상세와 채팅 수를 조회한다. `excludeCharacterIds`는 최신 스냅샷 target id 전체를 사용해 stale 스냅샷 후보를 다시 뽑지 않는다.
|
||||
- REFACTOR: 전체보기 paging 조회는 랜덤 보충을 적용하지 않고, AI 캐릭터 추천 점수 산식/스냅샷 생성/공개 API schema는 변경하지 않는다.
|
||||
- 기대 결과: `GET /api/v2/home/recommendations`의 `aiCharacters`는 가능한 경우 20개까지 채워지고, 랜덤 후보가 부족하면 중복 없이 가능한 AI 캐릭터만 반환한다.
|
||||
- 검증 기록:
|
||||
- 2026-07-22: RED에서 `HomeRecommendationQueryPort.findRandomAiCharacterRecommendationIds(...)` 계약을 추가하고 service/repository 테스트를 작성했으며, 기존 구현은 `DefaultHomeRecommendationQueryRepository`가 신규 port 메서드를 구현하지 않아 `compileKotlin`에서 실패했다.
|
||||
- 2026-07-22: GREEN에서 홈 통합 조회에만 `backfillRandom = true`를 전달하고, AI 캐릭터 스냅샷 상세 결과가 limit보다 적을 때 랜덤 AI 캐릭터 id를 먼저 조회한 뒤 기존 상세 조회로 상세와 채팅 수를 재사용하도록 구현했다. 빈 스냅샷 refresh 완료 marker와 전체보기 paging 조회는 랜덤 보충하지 않도록 기존 계약을 유지했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest` 실행 결과 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-07-22: 리뷰 지적에 따라 전체보기 첫 페이지가 `offset == 0`으로 랜덤 보충되는 문제를 `backfillRandom` 명시 플래그로 보정했다. 재검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`를 실행해 모두 `BUILD SUCCESSFUL` 또는 무출력 통과를 확인했다.
|
||||
|
||||
### Phase 4: 콘텐츠 조회 이력 기록
|
||||
|
||||
- [x] **Task 4.1: 콘텐츠 조회 이력 엔티티/서비스 작성**
|
||||
@@ -694,185 +665,12 @@
|
||||
|
||||
---
|
||||
|
||||
### Phase 11: 최근 활동 라이브 이동 대상 분기
|
||||
|
||||
**Phase 결과:** `recentlyActiveCreators[]`가 모든 활동의 `creatorId`를 제공하고, 진행 중 라이브는 라이브 방으로, 종료된 라이브는 크리에이터 채널로 이동할 수 있는 식별자 계약을 제공한다.
|
||||
|
||||
**선행조건:** Phase 10 완료와 `docs/20260529_메인_홈_추천_API/prd.md` Feature D의 2026-07-30 확정 계약.
|
||||
|
||||
**Phase 완료 조건:** `P11-T1`, `P11-T2`, `P11-GATE`의 체크박스와 완료 증거가 모두 충족되고 검증 결과가 이 문서의 Verification Log에 누적된다.
|
||||
|
||||
**리뷰 후속 조건(2026-07-30):** 완료된 `P11-T1`, `P11-T2`, `P11-GATE`는 되돌리지 않고, `REV-P11-001`의 신규 회귀 Goal `P11-R1`을 완료한 뒤 Phase 11 리뷰를 종료한다.
|
||||
|
||||
#### Task 11.1: 최근 활동 조회 record와 LIVE target id 분기
|
||||
|
||||
**Goal 실행 `P11-T1`:** 최근 활동 조회 결과가 크리에이터 id를 항상 포함하고 라이브 활성 상태에 따라 라이브 방 id 또는 `null`을 반환한다.
|
||||
|
||||
- **시작 조건:** Phase 10 완료와 PRD Feature D Response Contract 확정.
|
||||
- **완료 증거:** 아래 체크박스 완료, repository focused test 통과, 내부 record와 native query row 매핑 일치.
|
||||
- **범위 밖:** 공개 API DTO와 facade 매핑, 신규 활동 타입·이동 타입·상태 필드 추가.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `LiveRoom.isActive`, `LiveRoom.id`, `LiveRoom.member.id`, 기존 `findRecentlyActiveCreators(limit, memberId, includeAdultActivities)`.
|
||||
- Produces:
|
||||
|
||||
```kotlin
|
||||
data class RecentlyActiveCreatorRecord(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String?,
|
||||
val activityType: CreatorActivityType,
|
||||
val activityAt: LocalDateTime,
|
||||
val targetId: Long?
|
||||
)
|
||||
```
|
||||
|
||||
- [x] **RED:** `shouldFindOneLatestActivityPerCreatorWithActivityType`에서 모든 활동의 `creatorId`를 검증하고 진행 중 LIVE의 `targetId`가 `live_room.id`인지 검증한다. `shouldIncludeInactiveLiveWithChannelNameInRecentlyActiveCreators`에서는 종료된 LIVE의 `creatorId`와 `targetId = null`을 검증한다.
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 `creatorId` 미구현 컴파일 실패 또는 진행 중 LIVE `targetId`의 `null` assertion 실패를 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType \
|
||||
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldIncludeInactiveLiveWithChannelNameInRecentlyActiveCreators
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `RecentlyActiveCreatorRecord`에 non-null `creatorId`를 추가한다. native SQL outer select에 `ranked.creator_id`를 포함하고 LIVE branch의 `target_id`를 아래 식으로 변경한 뒤 row index를 새 select 순서에 맞춘다. `HomeRecommendationQueryServiceTest`의 기존 record fixture에는 해당 크리에이터 id만 추가한다.
|
||||
|
||||
```sql
|
||||
case when lr.is_active = true then lr.id else null end as target_id
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** RED focused test를 다시 실행해 진행 중 LIVE는 `targetId = live_room.id`, 종료된 LIVE는 `targetId = null`, 모든 활동은 올바른 `creatorId`를 반환하는지 확인한다.
|
||||
- [x] **REFACTOR:** 비 LIVE 활동의 `targetId`, `activityAt`, 크리에이터별 최신 활동 선정, 차단·성인·비활성 회원 제외 조건을 변경하지 않았는지 repository 테스트 클래스 전체로 회귀 확인하고 결과를 Progress에 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest \
|
||||
--tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest
|
||||
```
|
||||
|
||||
#### Task 11.2: 최근 활동 공개 응답에 creatorId 노출
|
||||
|
||||
**Goal 실행 `P11-T2`:** 홈 통합 API가 내부 최근 활동 record의 `creatorId`와 상태별 `targetId`를 최종 JSON에 그대로 노출한다.
|
||||
|
||||
- **시작 조건:** `P11-T1` 완료.
|
||||
- **완료 증거:** 아래 체크박스 완료, 홈 통합 API 통합 테스트 통과, additive schema와 상태별 JSON 계약 확인.
|
||||
- **범위 밖:** 앱 네비게이션 코드 구현, 라이브 입장 실패 처리, 기존 endpoint URL과 다른 추천 item 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/recommendation/HomeRecommendationResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `P11-T1`의 `RecentlyActiveCreatorRecord.creatorId`와 상태별 nullable `targetId`.
|
||||
- Produces:
|
||||
|
||||
```kotlin
|
||||
data class HomeActiveCreatorItem(
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileImage: String,
|
||||
val activityType: String,
|
||||
val activityAt: String,
|
||||
val targetId: Long?
|
||||
)
|
||||
```
|
||||
|
||||
- [x] **RED:** `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`를 추가한다. 서로 다른 크리에이터의 진행 중 LIVE와 종료된 LIVE를 저장하고 홈 통합 API 응답에서 두 item의 `creatorId`, 진행 중 LIVE의 `targetId = live_room.id`, 종료된 LIVE의 명시적 `targetId = null`을 검증한다.
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 `creatorId` JSON path 미존재로 실패하는지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `HomeActiveCreatorItem`에 non-null `creatorId`를 추가하고 `HomeRecommendationFacade.RecentlyActiveCreatorRecord.toItem()`에서 `creatorId = creatorId`를 매핑한다. controller 테스트의 `saveLiveRoom` fixture는 종료된 라이브를 만들 수 있도록 `isActive: Boolean = true`만 추가한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 최종 JSON의 진행 중/종료 LIVE 이동 식별자 계약을 확인한다.
|
||||
- [x] **REFACTOR:** `isOnAir`, `targetType`, 종료 전용 활동 타입을 추가하지 않고 기존 프로필 이미지·활동 타입·UTC 시간 변환을 유지한다. 홈 API 테스트 클래스 전체를 실행해 공개 응답 회귀를 확인하고 결과를 Progress에 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
|
||||
```
|
||||
|
||||
#### Phase 11 Gate
|
||||
|
||||
**Goal 실행 `P11-GATE`:** Phase 11의 내부 조회·공개 응답 계약과 변경 범위 품질을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P11-T1`, `P11-T2` 완료.
|
||||
- **완료 증거:** 아래 focused/영향 범위 회귀, lint, 문서 검증이 모두 통과하고 실제 결과가 Verification Log에 기록됨.
|
||||
- **범위 밖:** Gate 통과를 위한 테스트 삭제·완화, 전체 라이브/추천 구조 리팩터링, 신규 dependency 추가.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest \
|
||||
--tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest \
|
||||
--tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
|
||||
./gradlew ktlintCheck
|
||||
./gradlew tasks --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 모든 명령이 0 exit code로 통과하고, 진행 중 LIVE는 라이브 방 id, 종료된 LIVE는 `null`, 모든 최근 활동 item은 non-null 크리에이터 id를 반환하며 비 LIVE 활동 계약은 유지된다.
|
||||
|
||||
- 전체 `./gradlew test`는 공통 인증·설정·다중 도메인을 변경하지 않는 국소 조회/DTO 변경이므로 기본 생략한다. focused/영향 범위 회귀에서 범위를 판단할 수 없는 실패가 발생하면 전체 테스트로 확장하고 결과를 기록한다.
|
||||
|
||||
**기존 구현 실행 순서:** `P11-T1 → P11-T2 → P11-GATE`
|
||||
|
||||
#### Task 11.3: 종료 LIVE targetId의 명시적 null JSON 계약 검증 보강
|
||||
|
||||
**Goal 실행 `P11-R1`:** `REV-P11-001`에 따라 종료 LIVE 응답이 `targetId` 필드를 생략하지 않고 명시적 `null`로 제공하는 계약을 회귀 테스트로 고정한다.
|
||||
|
||||
- **시작 조건:** `docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md`의 `REV-P11-001` 확정.
|
||||
- **완료 증거:** JSON path 존재와 null 값을 각각 검증하는 focused test, 홈 API 테스트 회귀, lint·문서 검증, review/Verification Log 기록.
|
||||
- **범위 밖:** production DTO·facade·query 변경, nullable 정책 변경, 신규 Jackson 전역 설정.
|
||||
- **TDD 예외 사유:** 현재 production 구현은 nullable DTO와 기본 Jackson 설정으로 명시적 null을 직렬화하며, 확정 항목은 누락과 null을 구분하지 못하는 기존 assertion의 판별력 공백이다. production 동작을 변경하지 않는 테스트 보강이므로 별도 실패 구현을 만들지 않는다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
- Modify: `docs/20260529_메인_홈_추천_API/plan-task.md`
|
||||
- Modify: `docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md`
|
||||
|
||||
- [x] 종료 LIVE의 `$.data.recentlyActiveCreators[1].targetId`에 `hasJsonPath()`를 추가하고 기존 `doesNotExist()`를 함께 사용해 필드 존재와 null 값을 모두 검증한다.
|
||||
- [x] 아래 focused test를 실행해 명시적 null JSON 계약이 통과하는지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators
|
||||
```
|
||||
|
||||
- [x] 홈 API 테스트 클래스와 Phase 11 문서 검증을 실행한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
|
||||
./gradlew ktlintCheck
|
||||
./gradlew tasks --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
- [x] 실제 실행 결과를 이 문서 Verification Log와 `phase-11-review.md`의 수정 후 검증 기록에 누적하고 `REV-P11-001`을 `수정 완료`로 갱신한다.
|
||||
|
||||
**후속 실행 순서:** `P11-R1`
|
||||
|
||||
---
|
||||
|
||||
## PRD Coverage Check
|
||||
|
||||
- Feature A: Phase 3, Phase 6, Phase 7에서 통합 조회, limit, 인증/비회원, 팔로우 제외, 콘텐츠 조회 이력, 본인인증 여부, 차단 필터, 스냅샷 빈 배열 처리를 검증한다.
|
||||
- Feature B: Task 3.1, Task 6.3에서 라이브 최신순/전체보기/비활성 회원 제외와 크리에이터 닉네임/프로필 이미지/라이브 번호 노출 필드를 검증한다.
|
||||
- Feature C: Task 3.1과 Task 7.7에서 기존 콘텐츠 홈 배너 재활용, orders 정렬, 동일 orders 랜덤 정렬, 활성 배너/콘텐츠 조건, `EVENT`/`CREATOR`/`SERIES` 대상 비활성 제외, `CREATOR`/`SERIES` 대상 양방향 차단 제외, `LINK` 배너의 자체 활성 상태 기준 노출, 앱 이동 필드 유지를 검증한다.
|
||||
- Feature D: Task 1.3, Task 3.1, Task 10.1, Task 11.1, Task 11.2, Task 11.3에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 id/프로필 이미지/닉네임, UTC 시간, `COMMUNITY` 활동의 `creator_community.id`, 진행 중 LIVE의 `live_room.id`, 종료된 LIVE의 명시적 nullable 이동 대상과 크리에이터 채널 fallback 식별자를 검증한다.
|
||||
- Feature D: Task 1.3, Task 3.1, Task 10.1에서 활동 타입 영문 enum, 최신 활동 1개, 크리에이터 프로필 이미지/닉네임, UTC 시간, 이동 대상 id nullable, `COMMUNITY` 활동의 `creator_community.id` 이동 대상 id를 검증한다.
|
||||
- Feature E: Task 1.1, Task 1.2, Task 3.2, Task 6.3에서 데뷔일/점수/동점 랜덤 정렬/프로필 이미지와 닉네임 노출/전체보기를 검증한다.
|
||||
- Feature F: Task 1.1, Task 3.2, Task 6.3, Task 9.1에서 첫 오디오 콘텐츠 판정, 최신성 점수 구간, 예약 공개 제외, native query Boolean 계산 컬럼 매핑을 검증한다.
|
||||
- Feature G: Task 1.1, Task 2.2, Task 2.6, Task 2.7, Task 2.8, Task 2.9, Task 3.3, Task 6.3, Task 8.1, Task 8.2에서 AI 캐릭터 점수, 캐릭터 생성일 기준 신규 부스트, 스냅샷, AI 채팅 집계 범위, DB-side exact scoring, 응답 필드, 오리지널 작품명 조건, 전체보기, AI 캐릭터에 대응하는 `creatorId` 노출을 검증한다.
|
||||
@@ -881,17 +679,12 @@ git diff --check
|
||||
- Feature J: Task 1.1, Task 2.2, Task 2.3.1, Task 2.4, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 5.1, Task 5.2에서 최근 응원 점수/스냅샷 조회, 스냅샷 일 배치 클러스터 단일 실행, 8명 limit, 크리에이터 프로필 이미지/닉네임 노출, `CHANNEL_DONATION` 기준 후원 금액/후원 수, 팬 Talk 수, 최근 7일 집계, 데뷔일 기준 신규 부스트, DB-side exact scoring, 해당 섹션의 동시 팔로우를 검증한다.
|
||||
- Feature K: Task 1.1, Task 2.2, Task 2.5, Task 2.8, Task 2.9, Task 3.3, Task 7.1에서 인기 커뮤니티 점수/조건/홈 통합 응답 노출 필드(크리에이터 프로필 이미지, 닉네임, UTC 시간, 좋아요 수, 댓글 수, 내용)/댓글 불가 게시글 댓글 수 0점 계산, 데뷔일 기준 신규 부스트, 최근 7일 집계, DB-side exact scoring을 검증한다.
|
||||
- Metrics: Task 7.2에서 메인 홈 API 성공률/응답 시간, 섹션별 빈 응답 비율, 전체보기 API 조회 수, 추천 섹션별 클릭률, 동시 팔로우 요청/성공 수, 콘텐츠 조회 이력 기록 성공률, 일 배치 집계 성공/실패 수와 스냅샷 생성 소요 시간의 로그 또는 metric 기록 지점을 검증한다.
|
||||
- Technical Constraints/Non-Goals: Phase 1~7, Phase 9, Phase 11에서 `v2.api.home`/`v2.recommendation` 패키지 경계, `port.out` 의존 방향, 신규 v2 endpoint 분리, additive `creatorId`, 기존 필드 유지, 서버 다국어 번역/ML 개인화/A-B 테스트/관리자 화면/수동 편집 제외 조건을 검증한다. 응답 enum 영문 code 안정성은 Task 1.3과 Task 3.1에서, `RecommendationSnapshotPort`의 persistence entity 노출 정리는 Task 2.4에서, 점수 기반 스냅샷의 `RecommendationScoreSpec` 공유 산식과 candidate pre-limit 금지는 Task 2.9에서, JPA/QueryDSL 우선 및 native SQL 제한 사용 전략은 Task 2.9와 Task 3.1에서, native query Boolean 계산 컬럼 매핑은 Task 9.1에서, 신규 엔티티 테이블 생성 SQL 문서화는 Task 7.4에서, 최근 활동의 신규 상태·이동 타입 미추가는 Task 11.2에서 검증한다.
|
||||
- Technical Constraints/Non-Goals: Phase 1~7과 Phase 9에서 `v2.api.home`/`v2.recommendation` 패키지 경계, `port.out` 의존 방향, 신규 v2 endpoint 분리, 기존 공개 스키마 유지, 서버 다국어 번역/ML 개인화/A-B 테스트/관리자 화면/수동 편집 제외 조건을 검증한다. 응답 enum 영문 code 안정성은 Task 1.3과 Task 3.1에서, `RecommendationSnapshotPort`의 persistence entity 노출 정리는 Task 2.4에서, 점수 기반 스냅샷의 `RecommendationScoreSpec` 공유 산식과 candidate pre-limit 금지는 Task 2.9에서, JPA/QueryDSL 우선 및 native SQL 제한 사용 전략은 Task 2.9와 Task 3.1에서, native query Boolean 계산 컬럼 매핑은 Task 9.1에서, 신규 엔티티 테이블 생성 SQL 문서화는 Task 7.4에서 검증한다.
|
||||
|
||||
---
|
||||
|
||||
## Verification Log
|
||||
|
||||
- 2026-07-30: Phase 11의 `P11-R1` 반영 상태를 Gradle 재실행 없이 2차 정적 리뷰했다. PRD Feature D와 native query/row mapping, `RecentlyActiveCreatorRecord`, facade, `HomeActiveCreatorItem`, repository/API 테스트를 다시 대조했고, 모든 활동의 non-null `creatorId`, 진행 중 LIVE의 `live_room.id`, 종료 LIVE의 명시적 null, 비 LIVE `targetId` 유지가 일치함을 확인했다. record·DTO 생성자와 조회 호출 지점, null 제외 설정, 변경 Kotlin 라인 길이, `git diff --check`를 점검했으며 신규 확정 발견 사항은 없었다. 기존 `REV-P11-001`은 수정 완료 상태를 유지하고 추가 회귀 Task/Goal은 만들지 않았다. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았으며 상세 결과는 `reviews/phase-11-review.md`의 2차 정적 재점검 절에 기록했다.
|
||||
- 2026-07-30: P11-R1을 완료했다. `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`의 종료 LIVE `targetId` assertion에 `hasJsonPath()`를 추가하고 기존 `doesNotExist()`를 유지해 JSON path 존재와 명시적 null 값을 함께 검증하도록 보강했다. focused test는 최초 120000ms timeout에 도달해 240000ms로 재실행했고 `BUILD SUCCESSFUL`로 통과했다. 추가 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 무출력 통과했다. production DTO·facade·query는 변경하지 않았고, `REV-P11-001`은 수정 완료로 갱신했다.
|
||||
- 2026-07-30: Phase 11 구현을 테스트 재실행 없이 정적 리뷰했다. record/native query의 `creatorId` select와 row index, LIVE `targetId` 분기, DTO/facade 매핑은 PRD Feature D와 일치했다. `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`의 종료 LIVE assertion이 `doesNotExist()`만 사용해 JSON path 누락과 null을 구분하지 못하는 테스트 공백 `REV-P11-001`을 Low로 확정하고, 완료된 Task와 Gate를 되돌리지 않은 채 신규 회귀 Task 11.3/Goal `P11-R1`을 추가했다. 사용자 지시에 따라 Gradle 컴파일과 테스트는 실행하지 않았으며, 상세 근거는 `docs/20260529_메인_홈_추천_API/reviews/phase-11-review.md`에 기록했다.
|
||||
- 2026-07-30: Phase 11 구현을 완료했다. P11-T1 RED에서 `DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType`, `shouldIncludeInactiveLiveWithChannelNameInRecentlyActiveCreators` focused 실행이 `RecentlyActiveCreatorRecord.creatorId` 미구현으로 `compileTestKotlin` 실패하는 것을 확인했다. GREEN에서 내부 record에 non-null `creatorId`를 추가하고 최근 활동 native SQL outer select/row mapping에 `ranked.creator_id`를 포함했으며, LIVE `target_id`를 `case when lr.is_active = true then lr.id else null end`로 변경했다. focused 재실행과 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest`가 `BUILD SUCCESSFUL`로 통과했다. P11-T2 RED에서는 `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`가 `$.data.recentlyActiveCreators[0].creatorId` `PathNotFoundException`으로 실패했고, GREEN에서 `HomeActiveCreatorItem.creatorId`와 facade 매핑을 추가해 focused test와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`가 `BUILD SUCCESSFUL`로 통과했다. P11-GATE로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`가 모두 통과했다. 추가 광역 회귀로 `./gradlew test`를 실행했으나 300000ms와 600000ms timeout에 각각 도달해 완료 결과를 얻지 못했고, 동일 timeout 2회 후 추가 재시도하지 않았다.
|
||||
- 2026-07-30: 사용자 승인에 따라 방금 활동한 크리에이터의 모든 item에 non-null `creatorId`를 추가하고, 진행 중 LIVE는 `targetId = live_room.id`, 종료된 LIVE는 `targetId = null`로 분기하는 요구사항을 PRD Feature D와 plan-task Phase 11에 반영했다. `P11-T1`은 내부 record/native query, `P11-T2`는 공개 DTO/facade/JSON, `P11-GATE`는 focused 회귀와 lint를 각각 소유하도록 분리했다. 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입과 기존 정렬 변경은 범위에서 제외했다. 문서 자체 검토로 Phase 11의 PRD coverage, 타입·테스트명·파일 경로 일치, placeholder와 상충 문구 부재를 확인했고, `git diff --check`와 `./gradlew tasks --all`이 통과했다. 이 단계에서는 제품 코드를 변경하거나 테스트를 실행하지 않았다.
|
||||
- 2026-07-10: 사용자 피드백에 따라 홈 추천 최근 활동 크리에이터의 `COMMUNITY` 활동 `targetId`를 기존 크리에이터 id에서 `creator_community.id`로 변경했다. PRD Feature D와 plan-task Phase 10을 보강했고, RED/GREEN으로 repository 테스트를 갱신했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldFindOneLatestActivityPerCreatorWithActivityType`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`을 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.
|
||||
- 2026-06-27: Phase 9 코드 리뷰 및 검증을 진행했다. 변경 범위가 첫 오디오 콘텐츠 native query row 매핑의 Boolean 변환 보정과 운영 회귀 테스트/문서 보강에 한정되어 있는지 확인했고, `isPointAvailable`, `isAdult`, `isOriginalSeries`가 `Boolean` 또는 `Number(0/1)` 모두에서 명시적으로 Boolean으로 변환되는지 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest.shouldMapNumericNativeBooleanFromFirstAudioContentRows`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `git diff --check --cached`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.
|
||||
- 2026-06-23: Phase 8 코드 리뷰 및 검증을 진행했다. 변경 범위가 `creatorId` additive schema 추가에 한정되어 있는지 확인했고, `HomeAiCharacterRecommendationRecord.creatorId` → `HomeAiCharacterItem.creatorId` 매핑, `ChatCharacter.creatorMember` inner join과 활성/CREATOR/AI_CHARACTER 필터, 홈 통합/AI 캐릭터 전체보기 JSON 응답 검증 테스트를 점검했다. 리뷰 결과 수정이 필요한 결함은 발견하지 못했다. 검증으로 `./gradlew test --rerun-tasks --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`, `./gradlew test`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 통과를 확인했다. `ktlintCheck`와 `tasks --all`은 sandbox의 `~/.gradle` lock 파일 접근 제한으로 최초 실패해 권한 상승으로 재실행했다.
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
- 시간 응답은 UTC 기준으로 내려주고 앱에서 표시 포맷과 다국어를 처리한다.
|
||||
- 장르 기반 크리에이터 추천을 위해 콘텐츠 조회 이력 기록 방식을 도입한다.
|
||||
- 여러 크리에이터를 동시에 팔로우하는 API를 제공한다.
|
||||
- 방금 활동한 크리에이터의 라이브가 진행 중이면 라이브로, 종료됐으면 해당 크리에이터 채널로 이동할 수 있는 식별자를 제공한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -34,8 +33,6 @@
|
||||
- 추천 산식의 머신러닝 모델화, 개인화 가중치 학습, A/B 테스트 플랫폼은 이번 범위에 포함하지 않는다.
|
||||
- 관리자 화면 신규 개발은 포함하지 않는다.
|
||||
- 추천 결과 수동 편집 기능은 포함하지 않는다.
|
||||
- 방금 활동한 크리에이터 응답에 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입을 추가하지 않는다.
|
||||
- 라이브 종료 시각을 새로 저장하거나 방금 활동한 크리에이터의 기존 활동 시간·정렬 기준을 변경하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
@@ -49,7 +46,7 @@
|
||||
## 6. User Stories
|
||||
- 사용자는 메인 홈 진입 시 라이브 중인 방송 20개를 최신순으로 보고 싶다.
|
||||
- 사용자는 홈 배너를 최대 20개까지 정해진 노출 순서대로 보고 싶다.
|
||||
- 사용자는 방금 활동한 크리에이터와 활동 영역을 확인하고 해당 콘텐츠/커뮤니티로 이동하며, 라이브 활동은 진행 중이면 라이브로, 종료됐으면 해당 크리에이터 채널로 이동하고 싶다.
|
||||
- 사용자는 방금 활동한 크리에이터와 활동 영역을 확인하고 해당 콘텐츠/커뮤니티로 이동하고 싶다.
|
||||
- 사용자는 최근 데뷔한 크리에이터를 추천 점수순으로 보고 전체 리스트도 확인하고 싶다.
|
||||
- 사용자는 신규 크리에이터가 올린 첫 번째 오디오 콘텐츠를 발견하고 전체보기로 더 탐색하고 싶다.
|
||||
- 사용자는 AI 캐릭터를 추천 점수순으로 보고 채팅 화면으로 이동하고 싶다.
|
||||
@@ -118,36 +115,15 @@
|
||||
- 활동 타입 후보는 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY`로 한다.
|
||||
- 오디오는 콘텐츠를 업로드한 경우를 의미한다.
|
||||
- 커뮤니티는 커뮤니티 게시글을 등록한 경우를 의미한다.
|
||||
- 라이브는 `live_room.channel_name`이 존재하고 빈 값이 아닌 진행 중 또는 종료된 라이브를 의미한다.
|
||||
- 라이브는 라이브 진행 후 종료한 경우를 의미한다.
|
||||
- 라이브 다시듣기는 콘텐츠 업로드 시 `다시듣기` 테마로 올린 경우를 의미한다.
|
||||
- 노출 정보는 크리에이터 id, 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다.
|
||||
- `creatorId`는 모든 활동 item에 non-null로 제공하며, 활동을 등록한 `Member.id`를 사용한다.
|
||||
- 라이브 활동은 `live_room.is_active = true`이면 `targetId`로 `live_room.id`를 내려주고, `live_room.is_active = false`이면 `targetId`를 `null`로 내려준다.
|
||||
- 노출 정보는 크리에이터 프로필 이미지, 닉네임, 활동 타입, UTC 기반 활동 시간, 이동 대상 id를 포함한다.
|
||||
- 라이브 활동은 별도 이동 대상 id가 필요하지 않다.
|
||||
- 라이브 외 활동은 오디오/라이브 다시듣기 콘텐츠 id를 내려주며, 커뮤니티 활동은 `creator_community.id`를 내려준다.
|
||||
- 앱 클라이언트는 `activityType = LIVE`이면서 `targetId != null`이면 라이브로 이동하고, `targetId = null`이면 `creatorId`를 사용해 크리에이터 채널로 이동한다.
|
||||
- 크리에이터당 최신 활동 1개만 노출한다.
|
||||
- 라이브의 활동 시간은 기존과 같이 `live_room.begin_date_time`을 사용하며, 기존 최신 활동 선정과 정렬 기준을 변경하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- `다시듣기` 콘텐츠는 `AUDIO`가 아니라 `LIVE_REPLAY`로 분류한다.
|
||||
- 응답 조회 후 라이브 입장 전에 방송이 종료되어 라이브 진입에 실패하면 앱 클라이언트는 같은 item의 `creatorId`를 사용해 크리에이터 채널로 이동할 수 있다.
|
||||
|
||||
#### Response Contract
|
||||
|
||||
| 활동 상태 | `activityType` | `creatorId` | `targetId` | 이동 대상 |
|
||||
|---|---|---|---|---|
|
||||
| 진행 중 라이브 | `LIVE` | 크리에이터 `Member.id` | `live_room.id` | 라이브 |
|
||||
| 종료된 라이브 | `LIVE` | 크리에이터 `Member.id` | `null` | 크리에이터 채널 |
|
||||
| 오디오 | `AUDIO` | 크리에이터 `Member.id` | `content.id` | 오디오 콘텐츠 |
|
||||
| 라이브 다시듣기 | `LIVE_REPLAY` | 크리에이터 `Member.id` | `content.id` | 라이브 다시듣기 콘텐츠 |
|
||||
| 커뮤니티 | `COMMUNITY` | 크리에이터 `Member.id` | `creator_community.id` | 커뮤니티 게시글 |
|
||||
|
||||
#### Acceptance Criteria
|
||||
- 모든 `recentlyActiveCreators[]` item은 non-null `creatorId`를 반환한다.
|
||||
- 진행 중 라이브 item은 `activityType = LIVE`, `targetId = live_room.id`를 반환한다.
|
||||
- 종료된 라이브 item은 `activityType = LIVE`, `targetId = null`을 반환한다.
|
||||
- 오디오, 라이브 다시듣기, 커뮤니티의 `activityType`과 `targetId` 의미는 변경하지 않는다.
|
||||
- `creatorId` 추가는 additive schema 변경으로 처리하고, `isOnAir`, `targetType`, 신규 활동 타입은 추가하지 않는다.
|
||||
|
||||
### Feature E. 최근 데뷔한 크리에이터
|
||||
|
||||
@@ -156,7 +132,6 @@
|
||||
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
||||
- 데뷔일은 콘텐츠를 처음 공개한 날과 라이브를 한 날 중 빠른 날짜로 계산한다.
|
||||
- 데뷔일 계산 로직은 기존 `ExplorerService.getCreatorDetail`의 `debutDateTime` 계산 방식과 동일하게 맞춘다.
|
||||
- 라이브 기준 데뷔 판정은 `live_room.channel_name`이 존재하고 빈 값이 아닌 라이브를 사용한다. 종료된 라이브는 `live_room.is_active = false`가 되므로 최근 데뷔 판정에서 `is_active`는 조건으로 사용하지 않는다.
|
||||
- 데뷔 후 30일 이내 크리에이터만 대상으로 한다.
|
||||
- 추천 점수는 `((팔로우 증가량 * 0.35) + (콘텐츠 활동 점수 * 0.3) + (소통 점수 * 0.2)) * 신규 부스트`로 계산한다.
|
||||
- 팔로우 증가량은 최근 7일간 신규 팔로우한 유저 수로 계산한다.
|
||||
@@ -188,7 +163,6 @@
|
||||
#### Requirements
|
||||
- AI 캐릭터 리스트를 조회한다.
|
||||
- 홈 첫 화면은 20개를 조회한다.
|
||||
- 홈 첫 화면은 최신 스냅샷 상세 조회 결과가 20개 미만이면 이미 조회한 스냅샷 캐릭터를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다.
|
||||
- 전체 리스트 API는 페이징으로 조회할 수 있어야 한다.
|
||||
- 노출 정보는 캐릭터 id, AI 캐릭터에 대응하는 creator id, 캐릭터 이름, 캐릭터 소개, 프로필 이미지, 작품명, 사용자들이 친 전체 채팅 수를 포함한다.
|
||||
- AI 캐릭터에 대응하는 creator id는 `ChatCharacter.creatorMember.id`이며, 해당 Member는 `role = CREATOR`, `memberKind = AI_CHARACTER`인 내부 크리에이터 Member다.
|
||||
@@ -207,8 +181,6 @@
|
||||
#### Edge Cases
|
||||
- 비활성 또는 노출 제한 캐릭터는 제외한다.
|
||||
- 활성 `ChatCharacter`에 `creatorMember`가 없거나 연결된 Member가 비활성/비 CREATOR/비 AI_CHARACTER이면 해당 AI 캐릭터는 홈 추천 응답에서 제외한다.
|
||||
- 랜덤 보충 후보가 부족하면 중복 없이 조회 가능한 AI 캐릭터만 내려준다.
|
||||
- 전체 리스트 API의 paging 조회는 페이지별 랜덤 보충을 적용하지 않는다.
|
||||
|
||||
### Feature H. 장르의 크리에이터
|
||||
|
||||
@@ -291,8 +263,6 @@
|
||||
- Controller는 `adapter.in.web`, application service/use case는 `application`, repository/cache/scheduler 구현은 `adapter.out.*`, application이 외부 조회/저장 구현에 의존하는 계약은 `port.out`에 둔다.
|
||||
- `port.in`은 여러 adapter에서 같은 use case를 재사용하거나 진입 계약을 명확히 해야 할 때만 둔다.
|
||||
- 홈 추천 AI 캐릭터 응답의 `creatorId` 추가는 기존 `characterId` 의미를 변경하지 않는 additive schema 변경으로만 처리한다.
|
||||
- 방금 활동한 크리에이터 응답의 `creatorId` 추가는 기존 필드를 제거하거나 이름을 변경하지 않는 additive schema 변경으로 처리한다.
|
||||
- 방금 활동한 크리에이터의 `LIVE` `targetId`는 `live_room.is_active`에 따라 `live_room.id` 또는 `null`로 결정하고, 다른 활동 타입의 `targetId` 의미는 유지한다.
|
||||
- 정책, 점수 계산, 노출 조건, 스냅샷 모델처럼 인프라 의존이 없는 코드는 `domain`에 둔다.
|
||||
- `kr.co.vividnext.sodalive.v2` 외부 코드는 엔티티만 재활용하고, Controller/Service/Repository/DTO는 신규 작성한다.
|
||||
- 기존 엔티티 후보는 `Member`, `LiveRoom`, `AudioContent`, `AudioContentBanner`, `CreatorFollowing`, `CreatorCommunity`, `CreatorCommunityLike`, `CreatorCommunityComment`, `CreatorCheers`, `ChannelDonationMessage`, `AudioContentComment`, `AudioContentLike`, `ChatCharacter` 등이다.
|
||||
@@ -332,7 +302,6 @@
|
||||
- 실제 데뷔일을 계산할 첫 공개 콘텐츠와 첫 라이브가 모두 없는 크리에이터는 Phase 2 스냅샷 후보에서 제외한다.
|
||||
- Phase 2 점수 기반 스냅샷은 DB-side exact scoring으로 계산한다. service는 기준 시각 계산과 snapshot replace만 담당하고, 최종 점수 산식/정렬/limit은 repository query에서 처리한다.
|
||||
- 조회 구현은 JPA/QueryDSL 우선, native SQL 제한 사용의 하이브리드 전략으로 진행한다. native SQL은 SQL 고급 기능이 필요한 추천/랭킹/스냅샷 산정에 한정하고, 단순 상세 조회와 대상 활성 조건은 가능하면 QueryDSL/JPA 조건으로 표현한다.
|
||||
- 2026-07-30: 방금 활동한 크리에이터 item은 `creatorId`를 항상 제공한다. `LIVE`의 `targetId`는 진행 중이면 `live_room.id`, 종료됐으면 `null`로 제공하며, 종료된 라이브는 `creatorId`로 크리에이터 채널에 이동한다. 별도 `isOnAir`, `targetType`, 종료 전용 활동 타입은 추가하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
# Phase 11 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 11 / Task 11.1~11.2 / `P11-GATE` |
|
||||
| 기준 commit 또는 working tree | `b30447f0` 기준 working tree 변경 |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `docs/20260529_메인_홈_추천_API/prd.md`, `docs/20260529_메인_홈_추천_API/plan-task.md` |
|
||||
| 리뷰 상태 | 수정 검증 완료 / 2차 정적 재점검 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- Phase 11 구현이 PRD Feature D의 `creatorId`와 상태별 LIVE `targetId` 계약을 충족하는지 정적으로 확인한다.
|
||||
- 완료된 Task·Gate 기록과 실제 코드·테스트 변경이 일치하는지 확인한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: `RecentlyActiveCreatorRecord`, 최근 활동 native query/row mapping, `HomeActiveCreatorItem`, facade 변환
|
||||
- 테스트: 최근 활동 repository 테스트, 홈 통합 API JSON 테스트, service fixture
|
||||
- 문서: PRD Feature D, plan-task Phase 11과 Verification Log
|
||||
- 수동 검증: working tree diff, 호출 흐름, Spring JSON path matcher 의미 대조
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Phase 1~10과 Phase 11 외 기능
|
||||
- 앱 클라이언트 네비게이션 및 라이브 입장 실패 fallback 구현
|
||||
- 사용자 지시에 따른 Gradle 컴파일·테스트 재실행
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 핵심 흐름 불능, 보안·데이터 손실 또는 완료 판정을 무효화하는 문제 |
|
||||
| High | 확정 요구사항·공개 API 계약 위반 또는 주요 회귀 |
|
||||
| Medium | 제한된 조건의 기능·복구 문제 |
|
||||
| Low | 테스트 판별력, 유지보수성 또는 문서 정합성 문제 |
|
||||
|
||||
| 상태 | 의미 |
|
||||
|---|---|
|
||||
| 후보 | 근거 확인 전 |
|
||||
| 확정 | 코드·테스트·문서 근거로 확인됨 |
|
||||
| 오탐 | 요구사항 또는 코드 근거상 문제 아님 |
|
||||
| 보류 | 외부 결정·환경 필요 |
|
||||
| 수정 완료 | 수정과 검증 완료 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: PRD Feature D Requirements, Response Contract, Acceptance Criteria
|
||||
- 계획: `P11-T1`, `P11-T2`, `P11-GATE`
|
||||
- 내부 조회: `DefaultHomeRecommendationQueryRepository.kt:130-208`
|
||||
- 내부 record: `HomeRecommendationQueryPort.kt:105-112`
|
||||
- 공개 응답: `HomeRecommendationResponse.kt:38-45`
|
||||
- facade: `HomeRecommendationFacade.kt:247-254`
|
||||
- repository 테스트: `DefaultHomeRecommendationQueryRepositoryTest.kt:405-467`
|
||||
- API 테스트: `HomeRecommendationControllerTest.kt:527-545`
|
||||
|
||||
### 실행 환경
|
||||
|
||||
```text
|
||||
검토 방식: working tree 정적 리뷰
|
||||
기준 commit: b30447f0
|
||||
컴파일·테스트: 사용자 지시에 따라 실행하지 않음
|
||||
민감정보: 조회·기록하지 않음
|
||||
```
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `git status --short`, `git diff --name-status` | 성공 | Phase 11 계획의 코드·테스트 7개 파일과 문서 변경 확인 |
|
||||
| Phase 11 관련 `git diff` 및 호출 흐름 대조 | 성공 | query select/row index, record/DTO/facade 필드가 계약과 일치 |
|
||||
| Spring 5.3.29 `JsonPathResultMatchers` 로컬 source jar 확인 | 성공 | `doesNotExist()`가 누락 path와 null 값 모두 허용함을 확인 |
|
||||
| Jackson null 제외 설정 검색 | 성공 | 대상 DTO와 전역 설정에 `NON_NULL` 적용이 없음을 확인 |
|
||||
| Gradle 컴파일·테스트 | 미실행 | 사용자가 현재 통과 상태를 제공하고 재실행을 금지함 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P11-001` | Low | 수정 완료 | 종료 LIVE 테스트가 targetId 누락과 null을 구분하지 못함 | Task 11.3 | `P11-R1` |
|
||||
|
||||
구현 코드에서 확정된 기능 결함은 발견하지 않았다.
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-P11-001 — 종료 LIVE 테스트가 targetId 누락과 null을 구분하지 못함
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** PRD Feature D Acceptance Criteria
|
||||
- **관련 계약:** 종료된 LIVE는 `targetId = null`
|
||||
- **소유 Task:** Task 11.3 / `P11-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`는 종료 LIVE의 `targetId`를 `doesNotExist()`로 검증한다. Spring 5.3.29에서 이 matcher는 JSON path가 없을 때와 값이 null일 때 모두 통과하므로, 필드를 생략하는 회귀를 탐지하지 못한다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `HomeRecommendationControllerTest.kt:545`
|
||||
- 문서: PRD Feature D의 Response Contract와 Acceptance Criteria
|
||||
- 라이브러리: Spring Test 5.3.29 `JsonPathResultMatchers.doesNotExist()` 및 `JsonPathExpectationsHelper.doesNotExist(...)`
|
||||
|
||||
**재현 또는 검증 절차**
|
||||
|
||||
1. 종료 LIVE JSON assertion이 `doesNotExist()`만 사용하는지 확인했다.
|
||||
2. 로컬 Spring Test 5.3.29 source jar에서 `doesNotExist()` 구현을 확인했다.
|
||||
3. 해당 구현은 path 평가 실패를 정상 반환하고, path가 있으면 값이 null일 때 성공한다.
|
||||
4. 따라서 현재 assertion은 `targetId` 누락과 명시적 null을 구분하지 않는다.
|
||||
|
||||
**영향**
|
||||
|
||||
현재 production 구현은 nullable DTO를 사용하고 null 제외 설정이 없어 계약과 일치하는 구조다. 그러나 이후 Jackson null 제외 설정이나 DTO annotation이 추가되어 `targetId`가 생략돼도 이 테스트는 통과하므로 공개 응답 계약 회귀를 차단하지 못한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
같은 JSON path에 `hasJsonPath()`를 추가하고 기존 `doesNotExist()`와 함께 검증한다. production 코드는 변경하지 않는다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — Spring 5.3.29 matcher 구현과 PRD 계약을 대조해 Low 확정.
|
||||
- 2026-07-30 — `hasJsonPath()`와 `doesNotExist()` 조합으로 JSON path 존재와 null 값을 모두 검증하도록 수정하고 focused/API 회귀/lint/문서 검증을 완료했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
### 신규 회귀 수정 Task
|
||||
|
||||
- Task 11.3: 종료 LIVE `targetId`의 명시적 null JSON 계약 검증 보강
|
||||
- Goal: `P11-R1`
|
||||
- 변경 범위: `HomeRecommendationControllerTest`, plan-task, 이 review 문서
|
||||
- production 코드 변경: 없음
|
||||
|
||||
### create_goal objective 초안
|
||||
|
||||
```text
|
||||
[P11-R1]의 확정 review 항목 REV-P11-001을 수정하고 회귀를 방지한다.
|
||||
plan-task.md의 Task 11.3만 수행한다.
|
||||
종료 LIVE targetId의 JSON path 존재와 null 값을 함께 검증하고 focused test, 홈 API 회귀, lint, 문서 검증과 기록이 모두 끝나기 전에는 complete로 표시하지 않는다.
|
||||
production DTO·facade·query 변경과 관련 없는 리팩터링은 범위 밖이다.
|
||||
```
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 11 문서·코드·테스트 diff 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P11-001` Low 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 11.3 / `P11-R1` 추가 |
|
||||
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 항목 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, Gradle 미실행 사유 명시 |
|
||||
|
||||
**최종 결론:** 수정 완료
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
## 9. 수정 후 검증 기록
|
||||
|
||||
- 2026-07-30: `HomeRecommendationControllerTest.shouldExposeNavigationIdsForRecentlyActiveLiveCreators`에 종료 LIVE `targetId` `hasJsonPath()` assertion을 추가해 명시적 null JSON 계약을 고정했다. focused test는 120000ms timeout 후 240000ms로 재실행해 `BUILD SUCCESSFUL`로 통과했다. 이어서 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`를 실행했고 모두 `BUILD SUCCESSFUL` 또는 무출력 통과했다. production 코드는 변경하지 않았다.
|
||||
|
||||
## 10. 2차 정적 재점검 — 2026-07-30
|
||||
|
||||
### 범위와 방법
|
||||
|
||||
- 기준: `b30447f0` 기준 현재 working tree와 `P11-R1` 반영 상태
|
||||
- 문서: PRD Feature D, plan-task Phase 11·PRD Coverage Check·Verification Log
|
||||
- 코드 흐름: native query/row mapping → `RecentlyActiveCreatorRecord` → facade → `HomeActiveCreatorItem`
|
||||
- 테스트: repository의 전체 활동 타입·LIVE 상태 분기와 홈 API JSON 계약 assertion
|
||||
- 제외: 사용자 지시에 따라 Gradle 컴파일·테스트 재실행
|
||||
|
||||
### 정적 검증 결과
|
||||
|
||||
| 검증 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| `git diff`로 Phase 11 변경 전체 대조 | 충족 | `creatorId` select/row index/DTO/facade 매핑과 LIVE `targetId` 분기가 PRD 계약과 일치 |
|
||||
| `rg`로 record·DTO 생성자와 조회 호출 지점 확인 | 충족 | 변경 타입의 모든 생성·매핑 지점이 신규 non-null `creatorId`를 반영 |
|
||||
| nullable JSON 설정과 assertion 대조 | 충족 | 대상 DTO·전역 설정에 null 제외가 없고 `hasJsonPath()` + `doesNotExist()`가 명시적 null 계약을 고정 |
|
||||
| 비 LIVE 회귀와 LIVE 상태 분기 테스트 대조 | 충족 | AUDIO·LIVE_REPLAY·COMMUNITY의 기존 `targetId`, 진행 중/종료 LIVE, 모든 활동의 `creatorId`를 검증 |
|
||||
| 변경 Kotlin 라인 길이와 `git diff --check` | 충족 | 130자 초과 신규 Kotlin 라인 없음, whitespace 오류 없음 |
|
||||
| 과설계·범위 확장 점검 | 충족 | 신규 dependency·상태 타입·추상화 없이 기존 record/DTO/query만 최소 변경 |
|
||||
|
||||
### 발견 사항과 종료 판정
|
||||
|
||||
- 신규 후보·확정·보류 항목 없음.
|
||||
- 기존 `REV-P11-001`은 Task 11.3 / `P11-R1`에서 수정 완료 상태를 유지한다.
|
||||
- 추가로 `plan-task.md`에 전환할 회귀 수정 Task/Goal 없음.
|
||||
- **최종 결론:** Phase 11 2차 정적 리뷰 완료, 확정 발견 사항 없음.
|
||||
@@ -21,30 +21,10 @@
|
||||
- 스케줄 성인 노출 정책: repository query에서 조회자의 성인 노출 정책을 먼저 반영하고, service 최종 조합에서도 내부 스케줄 후보의 `isAdult`로 한 번 더 보정한다. 공개 스케줄 응답에는 `isAdult`를 노출하지 않는다.
|
||||
- 현재 라이브와 예약 라이브 스케줄은 기존 라이브 목록과 동일하게 성별 제한(`LiveRoom.genderRestriction`)과 크리에이터 입장 제한(`LiveRoom.isAvailableJoinCreator`)을 반영한다. application service는 조회자의 `Auth.gender`가 있으면 이를 우선하고, 없으면 `Member.gender`를 사용하는 `effectiveViewerGender`를 산출해 query port에 넘긴다.
|
||||
- 신규 오디오 콘텐츠와 오디오 목록은 중복 노출하지 않는다. `latestAudioContent`로 내려간 가장 최신 콘텐츠를 오디오 목록에서 제외한다.
|
||||
- `latestAudioContent`는 상단 고정 여부와 관계없이 공개 시각 최신순을 유지한다.
|
||||
- `audioContents`는 활성 `PinContent`를 `updatedAt desc`로 먼저 배치하고, 나머지는 `AudioContent.releaseDate desc`, `AudioContent.id desc`로 배치한 뒤 최대 9개를 내려준다.
|
||||
- 크리에이터별 활성 오디오 상단 고정 한도는 9개다. 10번째 고정은 기존처럼 `PinContent.updatedAt`이 가장 오래된 활성 고정을 교체한다.
|
||||
- 채널 후원 홈 섹션은 기존 채널 후원 목록과 동일하게 이번 달 기준 최신순 8개를 내려준다. 응답 메시지는 기본 문구를 조합하지 않고 후원자가 입력한 추가 메시지만 내려준다.
|
||||
- 오리지널 시리즈 여부는 `Series.isOriginal == true`로 판단한다.
|
||||
- 화보와 상단 탭별 전체보기 API는 이번 범위에서 제외한다.
|
||||
|
||||
### 0.1 2026-07-30 후속 변경 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1~2 | 완료 | `3/3` | 없음 | 기존 검증 기록 유지 |
|
||||
| 3 | 완료 | `17/17` | 없음 | 없음 |
|
||||
| 4 | 완료 | `5/5` | 없음 | 없음 |
|
||||
| 5 | 완료 | `3/3` | 없음 | 없음 |
|
||||
| 6 | 완료 | `4/4` | 없음 | 없음 |
|
||||
| 7 | 완료 | `5/5` | 없음 | 없음 |
|
||||
|
||||
- 동시에 하나의 미완료 Goal만 진행한다.
|
||||
- 완료된 Task 체크박스와 기존 검증 기록은 되돌리지 않는다.
|
||||
- 2026-07-30 1차 정적 리뷰 후속 실행 순서 `P7-R1` → `P4-R1` → `P5-R1` → `P6-R1` 완료.
|
||||
- 2026-07-30 2차 정적 리뷰 후속 실행 순서 `P3-R1` → `P4-R2` → `P4-R3` → `P7-R2` 완료.
|
||||
- 2026-07-31 3차 정적 리뷰 후속 실행 순서 `P7-R3` 완료.
|
||||
|
||||
---
|
||||
|
||||
## 1. 파일 구조 계획
|
||||
@@ -74,7 +54,6 @@
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||
|
||||
### 문서 산출물
|
||||
- Modify: `docs/20260612_크리에이터_채널_홈_API/prd.md`
|
||||
- Modify: `docs/20260612_크리에이터_채널_홈_API/plan-task.md`
|
||||
|
||||
---
|
||||
@@ -536,20 +515,6 @@ data class CreatorChannelSnsResponse(
|
||||
- REFACTOR: 좋아요/댓글/구매 여부 조회를 `leftJoin` 하나로 합치지 않고, 현재의 id 목록 기반 bulk 조회 구조를 유지한다.
|
||||
- 기대 결과: 구매자는 삭제된 유료 게시글도 기존 전체보기 의미와 동일하게 접근할 수 있고, 비구매자는 삭제된 게시글을 조회하지 못한다.
|
||||
|
||||
- [x] **Task 3.17: 홈 채널 후원자의 삭제 닉네임 prefix 제거**
|
||||
|
||||
**Goal 실행 `P3-R1`:** 홈 채널 후원도 기존 채널 후원 목록과 동일하게 삭제 회원 닉네임의 `deleted_` prefix를 공개 응답에서 제거한다.
|
||||
|
||||
- **시작 조건:** `REV-P3-001` 확정, Task 3.1~3.16 완료.
|
||||
- **완료 증거:** 삭제 회원 후원자 RED/GREEN, service focused test, 기존 후원 조회 회귀, 검증 기록 누적.
|
||||
- **범위 밖:** 비밀 후원 노출 정책, 후원 메시지, projection 컬럼, 공개 응답 스키마 변경.
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||
- [x] **RED:** `nickname = "deleted_donor"`인 후원 record가 홈 domain에서 `nickname = "donor"`로 조립되는 테스트를 추가한다.
|
||||
- [x] **GREEN:** 기존 채널 후원 목록과 전용 v2 후원 탭이 사용하는 `removeDeletedNicknamePrefix()`를 홈 후원 domain 변환 경계에도 적용한다.
|
||||
- [x] **REFACTOR/GATE:** 닉네임 변환 외 repository 조회·후원 메시지·JSON 계약은 변경하지 않고 후원 관련 focused test 결과를 누적한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: application service 조립
|
||||
@@ -581,50 +546,6 @@ data class CreatorChannelSnsResponse(
|
||||
- REFACTOR: 차단 예외 메시지 조합에 `SodaMessageSource`가 필요하면 기존 `ExplorerService.getCreatorDetail` 패턴을 따른다.
|
||||
- 기대 결과: 신규 API 접근 정책이 구버전 채널 정책과 맞는다.
|
||||
|
||||
- [x] **Task 4.3: 조회자 콘텐츠 선호 조회를 1회로 통합**
|
||||
|
||||
**Goal 실행 `P4-R1`:** 홈 조회에서 한 번 가져온 `ViewerContentPreference`로 성인 노출 여부와 콘텐츠 타입을 모두 결정한다.
|
||||
|
||||
- **시작 조건:** `REV-P4-001` 확정, Task 4.1~4.2 완료.
|
||||
- **완료 증거:** service RED/GREEN, focused test, Phase 4 직접 영향 회귀, 검증 기록 누적.
|
||||
- **범위 밖:** 선호 기본값·국가별 성인 판정·쿼리 필터 정책 변경.
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||
- [x] **RED:** `getStoredPreference(viewer)`가 반환한 `isAdult`가 모든 성인 필터 인자에 전달되고, service가 `canViewAdultContent(viewer)`를 별도 호출하지 않는 것을 검증한다.
|
||||
- [x] **GREEN:** 이미 조회한 `preference.isAdult`를 `canViewAdultContent`로 사용해 중복 `REQUIRES_NEW` 조회를 제거한다.
|
||||
- [x] **REFACTOR/GATE:** 서비스 조립·성인 필터·`contentType` 회귀를 확인하고 `plan-task.md` 검증 기록에 결과를 누적한다.
|
||||
|
||||
- [x] **Task 4.4: 라이브 크리에이터 입장 제한에 조회자 role 전달**
|
||||
|
||||
**Goal 실행 `P4-R2`:** 조회 대상과의 동일인 여부가 아니라 조회자의 `MemberRole.CREATOR` 여부로 현재/예약 라이브의 크리에이터 입장 제한을 적용한다.
|
||||
|
||||
- **시작 조건:** `REV-P4-002` 확정, `P3-R1` 완료.
|
||||
- **완료 증거:** 다른 크리에이터 조회 RED/GREEN, 현재 라이브·예약 스케줄 service 회귀, 검증 기록 누적.
|
||||
- **범위 밖:** 성별·성인 필터, 자기 라이브 예외, query port의 공개 API 스키마 변경.
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||
- [x] **RED:** `viewer.role == CREATOR`이고 `viewer.id != creatorId`인 경우 `findCurrentLive`와 `findSchedules`에 `isViewerCreator = true`가 전달되는 테스트를 추가한다. 일반 회원은 `false`, 대상 본인인 크리에이터는 `true`인 기존 의미도 함께 고정한다.
|
||||
- [x] **GREEN:** `isViewerCreator`를 `viewer.role == MemberRole.CREATOR`로 계산해 기존 라이브 목록의 `isAvailableJoinCreator` 정책과 정렬한다.
|
||||
- [x] **REFACTOR/GATE:** repository의 자기 라이브 예외 조건은 유지하고 service focused test 결과를 누적한다.
|
||||
|
||||
- [x] **Task 4.5: 홈 조회 기본 시각을 UTC로 고정**
|
||||
|
||||
**Goal 실행 `P4-R3`:** JVM 기본 timezone과 무관하게 홈 조회의 공개/예약 경계와 KST 월 범위 계산에 UTC `LocalDateTime`을 전달한다.
|
||||
|
||||
- **시작 조건:** `REV-P4-003` 확정, `P4-R2` 완료.
|
||||
- **완료 증거:** 비 UTC JVM timezone RED/GREEN, facade→service `now` 전달 회귀, 검증 기록 누적.
|
||||
- **범위 밖:** DB 컬럼 타입, API 시간 문자열 형식, 클라이언트 timezone 파라미터 추가.
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/application/CreatorChannelHomeFacade.kt`
|
||||
- Modify if needed: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryServiceTest.kt`
|
||||
- Test if needed: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`
|
||||
- [x] **RED:** JVM 기본 timezone을 `Asia/Seoul`로 바꿔도 인자 없는 홈 조회가 UTC 기준 `now`를 service/query port에 전달하는 테스트를 추가하고, 테스트 종료 시 원 timezone을 복구한다.
|
||||
- [x] **GREEN:** 기본 `now` 생성 지점을 `LocalDateTime.now(ZoneOffset.UTC)` 또는 같은 의미의 UTC clock으로 고정한다. 명시적으로 전달된 `now`는 그대로 사용한다.
|
||||
- [x] **REFACTOR/GATE:** 별도 시간 추상화가 필요하지 않으면 추가하지 않고, 공개/예약 비교와 KST 월 경계의 직접 영향 회귀만 확인한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: web API와 응답 계약
|
||||
@@ -667,21 +588,6 @@ data class CreatorChannelSnsResponse(
|
||||
- REFACTOR: nullable 섹션은 단건이면 `null`, 목록이면 빈 배열로 일관되게 내려준다.
|
||||
- 기대 결과: 클라이언트가 사용할 JSON 스키마가 테스트로 고정된다.
|
||||
|
||||
- [x] **Task 5.3: 빈 홈 응답의 null/빈 배열 JSON 계약 고정**
|
||||
|
||||
**Goal 실행 `P5-R1`:** 데이터가 없는 홈 응답에서 단건 섹션은 `null`, 목록 섹션은 빈 배열로 직렬화되는 공개 계약을 회귀 테스트로 고정한다.
|
||||
|
||||
- **시작 조건:** `REV-P5-001` 확정, `P4-R1` 완료.
|
||||
- **완료 증거:** MockMvc RED/GREEN, controller focused test, 검증 기록 누적.
|
||||
- **범위 밖:** 공개 필드 이름·endpoint·`ApiResponse` 구조 변경.
|
||||
- Files:
|
||||
- Modify if needed: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`
|
||||
- **TDD 예외 사유:** 확정 항목은 실행 결함이 아니라 빈 응답 회귀 증거 부재이므로, 신규 계약 테스트가 첫 실행에서 바로 통과할 수 있다.
|
||||
- [x] **계약 검증:** `currentLive`, `latestAudioContent`, `fanTalk.latestFanTalk`이 명시적 `null`이고, `channelDonations`, `notices`, `schedules`, `audioContents`, `series`, `communities`가 빈 배열인 fixture를 응답해 각 JSON 경로의 존재와 값을 검증한다.
|
||||
- [x] **GREEN:** 최초 계약 검증이 실제 직렬화 문제를 드러낼 때만 DTO mapping/annotation을 최소 수정한다.
|
||||
- [x] **REFACTOR/GATE:** 채워진 응답 계약 테스트와 빈 응답 계약 테스트를 같이 실행하고 결과를 누적한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: 통합 회귀와 문서 갱신
|
||||
@@ -730,141 +636,6 @@ data class CreatorChannelSnsResponse(
|
||||
- REFACTOR: 실패한 검증이 있으면 해당 phase/task로 돌아가 plan-task 체크박스를 완료 처리하지 않는다.
|
||||
- 기대 결과: 구현 완료 시 어떤 검증으로 완료 판단했는지 문서에 남는다.
|
||||
|
||||
- [x] **Task 6.4: 단일 HTTP 요청 기준 홈 전체 조립 통합 회귀 보강**
|
||||
|
||||
**Goal 실행 `P6-R1`:** 실제 controller→facade→service→repository/공용 서비스 경로가 한 번의 홈 API 요청에서 전체 섹션을 조립하는지 검증한다.
|
||||
|
||||
- **시작 조건:** `REV-P6-001` 확정, `P5-R1` 완료.
|
||||
- **완료 증거:** 통합 시나리오 검증, 실제 bean 경로의 전체 섹션 JSON assertion, Phase 6 직접 영향 회귀, 검증 기록 누적.
|
||||
- **범위 밖:** 홈 응답 스키마·조회 정책 변경, 테스트 편의용 운영 API 추가.
|
||||
- Files:
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`
|
||||
- **TDD 예외 사유:** 확정 항목은 계층 연결의 실행 결함이 아니라 단일 요청 통합 증거 부재이므로, 신규 E2E가 첫 실행에서 바로 통과할 수 있다.
|
||||
- [x] **통합 검증:** 현재 리포지토리 테스트의 각 조회 호출과 mock facade controller 테스트로는 증명되지 않는 단일 요청 시나리오를 작성한다. 최소한 creator/current live/latest audio/donation/notice/schedule/audio list/series/community/fan Talk/activity/SNS를 한 fixture에 구성하고 실제 bean과 JSON을 검증한다.
|
||||
- [x] **GREEN:** 통합 테스트가 드러낸 mapping·bean wiring·쿼리 누락만 최소 수정한다. 최초 통합 검증이 바로 통과하면 생산 코드는 변경하지 않는다.
|
||||
- [x] **REFACTOR/GATE:** fixture helper는 해당 테스트 범위에만 두고, 새 E2E와 기존 service/repository/controller focused test를 함께 실행해 결과를 누적한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: 오디오 상단 고정 한도와 홈 목록 정렬 보정
|
||||
|
||||
**Phase 결과:** 크리에이터가 오디오 콘텐츠를 최대 9개까지 상단 고정할 수 있고, 홈 `audioContents`에서 고정 콘텐츠가 최근 고정순으로 먼저 노출된다.
|
||||
|
||||
**선행조건:** PRD Feature D/H와 `DEC-001` 확정.
|
||||
|
||||
**Phase 완료 조건:** 기존 `P7-T1`, `P7-T2`, `P7-GATE`, `P7-R1`, `P7-R2` 완료 이력을 유지하고, 3차 리뷰 후속 `P7-R3`의 실제 검증 결과까지 누적.
|
||||
|
||||
- [x] **Task 7.1: 오디오 상단 고정 최대 개수를 9개로 보정**
|
||||
|
||||
**Goal 실행 `P7-T1`:** 상세 응답의 고정 가능 여부와 상단 고정 등록이 같은 9개 상한을 사용하도록 고정한다.
|
||||
|
||||
- **시작 조건:** PRD Feature H의 고정 상한과 초과 교체 정책 확정.
|
||||
- **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, `AudioContentServiceTest` focused test 통과, 검증 기록 누적.
|
||||
- **범위 밖:** 고정/해제 endpoint, 오류 키, `PinContent` 테이블 스키마 변경.
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Interfaces:
|
||||
- Consumes: `PinContentRepository.getPinContentList(memberId, active)`의 기존 오래된 고정 우선 반환 순서.
|
||||
- Produces: `MAX_PIN_CONTENT_COUNT = 9`, `isAvailablePin == (activePinCount < 9)`, 10번째 고정 시 가장 오래된 활성 고정 교체 동작.
|
||||
- [x] **RED:** 활성 고정이 8개일 때 상세 `isAvailablePin == true`, 9개일 때 `false`임을 검증하고, 9개 상태의 새 고정이 리포지토리 목록의 가장 오래된 항목을 재사용하는 테스트를 추가한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`을 실행해 기존 3개 상한 때문에 8개 상태의 `isAvailablePin` 또는 교체 대상 assertion이 실패함을 확인한다.
|
||||
- [x] **GREEN:** `AudioContentService`의 상세 고정 가능 판정과 `pinToTheTop`이 공통 `MAX_PIN_CONTENT_COUNT = 9`를 사용하도록 최소 수정한다.
|
||||
- [x] **GREEN 확인:** 동일 `AudioContentServiceTest` 명령을 다시 실행해 통과를 확인한다.
|
||||
- [x] **REFACTOR:** 상한 숫자 중복만 제거하고 고정 초과 시 최근 고정을 거부하는 새 예외·설정·DB 제약은 추가하지 않는다. focused test와 `./gradlew ktlintCheck --no-daemon`를 재실행한다.
|
||||
|
||||
- [x] **Task 7.2: 홈 오디오 목록을 활성 고정 우선으로 정렬**
|
||||
|
||||
**Goal 실행 `P7-T2`:** `latestAudioContent`의 최신 공개 정책을 유지하면서 `audioContents`만 활성 고정 우선순으로 반환한다.
|
||||
|
||||
- **시작 조건:** `P7-T1` 완료.
|
||||
- **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, `DefaultCreatorChannelHomeQueryRepositoryTest` focused test 통과, 검증 기록 누적.
|
||||
- **범위 밖:** `latestAudioContent` 선정 정책, `audioContents` 최대 9개, 공개 응답 DTO/스키마 변경.
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||
- Interfaces:
|
||||
- Consumes: `PinContent.member`, `PinContent.content`, `PinContent.isActive`, `PinContent.updatedAt` 및 기존 `findLatestAudioContent`/`findAudioContents` port 계약.
|
||||
- Produces: `findAudioContents`의 활성 고정 우선 정렬과 `findLatestAudioContent`의 기존 `releaseDate desc`, `id desc` 정렬 유지.
|
||||
- [x] **RED:** 최신 공개 콘텐츠, 최근에 고정한 이전 공개 콘텐츠, 먼저 고정한 이전 공개 콘텐츠, 비활성 고정, 일반 콘텐츠 fixture를 구성해 다음을 한 테스트에서 검증한다.
|
||||
- `latestAudioContent`는 상단 고정 여부와 관계없이 실제 최신 공개 콘텐츠다.
|
||||
- `audioContents`는 `PinContent.member.id == creatorId && isActive == true`인 콘텐츠를 `PinContent.updatedAt desc`로 먼저 반환한다.
|
||||
- 비활성 고정은 일반 콘텐츠로 취급하고, 고정 이후 나머지는 `releaseDate desc`, `id desc`다.
|
||||
- `latestAudioContent`는 `audioContents`에 중복되지 않고, 고정 우선 정렬 후 최대 9개만 반환한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`을 실행해 기존 최신순 전용 정렬 때문에 고정 우선 기대 순서 assertion이 실패함을 확인한다.
|
||||
- [x] **GREEN:** `findAudioContentRows`의 최신 단건/목록 용도를 구분해 목록 조회에만 크리에이터의 활성 `PinContent`를 left join하고 `PinContent` 존재 여부 desc, `PinContent.updatedAt desc`, `AudioContent.releaseDate desc`, `AudioContent.id desc`를 적용한다.
|
||||
- [x] **GREEN 확인:** 동일 repository focused test를 다시 실행해 통과를 확인한다.
|
||||
- [x] **REFACTOR:** 공개 조건·projection·bulk 조립은 기존 구조를 유지하고, 정렬 구분을 위한 최소 변경만 남긴다. repository focused test, `CreatorChannelHomeQueryServiceTest`, `./gradlew ktlintCheck --no-daemon`를 실행한다.
|
||||
|
||||
#### Phase 7 Gate
|
||||
|
||||
**Goal 실행 `P7-GATE`:** 고정 한도와 홈 오디오 정렬의 확정 요구사항, 직접 영향 회귀, 문서 정합성을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P7-T1`, `P7-T2` 완료.
|
||||
- **완료 증거:** 아래 명령 전체 성공과 검증 기록 누적.
|
||||
- **범위 밖:** 전체 회귀 실패와 무관한 기존 문제 수정.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon
|
||||
./gradlew ktlintCheck --no-daemon
|
||||
./gradlew tasks --all --no-daemon
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 활성 고정 상한 9개, 10번째 고정의 최고령 고정 교체, `latestAudioContent` 최신순 유지, `audioContents` 활성 고정 우선/일반 최신순/최대 9개가 검증되고 Kotlin 포맷과 문서 명령이 성공한다.
|
||||
|
||||
전체 `./gradlew test`는 공개 API 스키마·공통 인증·예외·설정을 변경하지 않고 두 focused test 범위로 직접 영향을 판정할 수 있으므로 기본 생략한다. Gate 실행 중 targeted test로 영향 범위를 판단할 수 없는 실패가 발생하면 전체 회귀로 확장한다.
|
||||
|
||||
**실행 순서:** `P7-T1` → `P7-T2` → `P7-GATE`(기존 완료) → `P7-R1`(기존 완료) → `P7-R2`(기존 완료) → `P7-R3`
|
||||
|
||||
- [x] **Task 7.3: 동시 상단 고정에서도 크리에이터별 활성 9개 상한 보장**
|
||||
|
||||
**Goal 실행 `P7-R1`:** 같은 크리에이터의 상단 고정 변경을 직렬화해 동시 요청에서도 활성 고정과 콘텐츠 중복이 생기지 않고 9개 상한을 지킨다.
|
||||
|
||||
- **시작 조건:** `REV-P7-001` 확정, 기존 `P7-GATE` 완료.
|
||||
- **완료 증거:** 동시성 RED/GREEN, 홈 목록 9개 상한 assertion, Phase 7 Gate 재실행, 검증 기록 누적.
|
||||
- **범위 밖:** endpoint·오류 키·노출 정렬 계약·DB DDL 변경.
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberRepository.kt` if needed
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||
- [x] **RED:** `AiCharacterAdminCommunityPostConcurrencyTest` 패턴을 참고해 실제 transaction 2개를 사용한다. 활성 고정 8개 상태에서 같은 크리에이터의 서로 다른 콘텐츠 고정 요청을 동시 실행해 최종 활성 고정 수가 9를 넘지 않고 콘텐츠가 중복되지 않아야 함을 재현한다. 홈 오디오 fixture도 9개를 초과해 반환 개수 9를 명시적으로 검증한다.
|
||||
- [x] **GREEN:** 기존 `MemberRepository.findByIdForUpdate` 패턴 등 최소의 크리에이터 단위 pessimistic lock을 고정 상태 조회 전에 적용한다. 같은 콘텐츠의 재요청·9개 초과 교체 의미는 유지한다.
|
||||
- [x] **REFACTOR/GATE:** lock 범위를 고정 변경 transaction에만 두고, `AudioContentServiceTest`, home repository/service focused test, `ktlintCheck`, `git diff --check`의 결과를 누적한다.
|
||||
|
||||
- [x] **Task 7.4: 상단 고정 transaction의 첫 DB 조회에서 크리에이터 lock 획득**
|
||||
|
||||
**Goal 실행 `P7-R2`:** MySQL InnoDB 기본 `REPEATABLE READ`에서도 고정 상태를 읽기 전에 크리에이터 lock을 획득해 대기 transaction이 최신 고정 상태를 기준으로 판단하게 한다.
|
||||
|
||||
- **시작 조건:** `REV-P7-002` 확정, `P4-R3` 완료.
|
||||
- **완료 증거:** DB 호출 순서 RED/GREEN, 기존 동시성·9개 상한 회귀, Phase 7 직접 영향 Gate, 검증 기록 누적.
|
||||
- **범위 밖:** DB 격리수준 설정, `PinContent` DDL/unique constraint, 새 테스트 의존성, endpoint·오류 키 변경.
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt`
|
||||
- [x] **RED:** `memberRepository.findByIdForUpdate`가 `repository.findByIdAndCreatorId`, `findByContentIdAndMemberId`, `getPinContentList`보다 먼저 호출되는지를 하나의 `inOrder` 검증으로 고정한다. 현재 구현은 콘텐츠 일반 조회가 먼저라 실패해야 한다.
|
||||
- [x] **GREEN:** `pinToTheTop`의 첫 DB 접근에서 크리에이터 member row를 잠그고, 이후 콘텐츠와 활성 고정 상태를 조회한다. 필요하면 반환된 locked member를 후속 저장에 사용한다.
|
||||
- [x] **REFACTOR/GATE:** 기존 동시 요청 결과 검증과 10번째 교체·비활성 재활성화 회귀를 유지하고, 격리수준 변경이나 추가 lock/DDL 없이 최소 호출 순서 변경만 남긴다.
|
||||
|
||||
- [x] **Task 7.5: 상단 고정 해제도 크리에이터 lock으로 직렬화**
|
||||
|
||||
**Goal 실행 `P7-R3`:** 같은 크리에이터의 고정과 해제가 동일한 member row lock을 공유하게 해, 최고령 `PinContent` 행 재사용과 이전 콘텐츠 해제가 겹쳐 새 고정이 소실되지 않도록 한다.
|
||||
|
||||
- **시작 조건:** `REV-P7-003` 확정, `P7-R2` 완료.
|
||||
- **완료 증거:** 해제 lock 순서 RED/GREEN, 기존 동시 고정·10번째 교체·비활성 재활성화 회귀, Phase 7 직접 영향 Gate, 검증 기록 누적.
|
||||
- **범위 밖:** `PinContent` DDL/unique constraint, endpoint·오류 키·홈 정렬 계약, 고정 행 재사용 정책 변경.
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Test if needed: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentPinConcurrencyTest.kt`
|
||||
- [x] **RED:** `unpinAtTheTop`이 `memberRepository.findByIdForUpdate(member.id)`로 크리에이터 lock을 획득한 뒤 `findByContentIdAndMemberId`를 호출하는 순서를 `inOrder`로 먼저 고정한다. 현재 구현은 lock 호출이 없어 실패해야 한다.
|
||||
- [x] **GREEN:** `unpinAtTheTop`의 첫 DB 접근에서 `pinToTheTop`과 같은 member row lock을 획득하고, 잠긴 creator id로 해제할 `PinContent`를 조회해 비활성화한다.
|
||||
- [x] **REFACTOR/GATE:** 별도 lock·격리수준·DDL을 추가하지 않고 공용 member lock만 재사용한다. 해제 focused test와 기존 `AudioContentServiceTest`, `AudioContentPinConcurrencyTest`, 홈 repository/service 회귀, `ktlintCheck`, `git diff --check` 결과를 누적한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 중 주의사항
|
||||
@@ -874,7 +645,6 @@ git diff --check
|
||||
- 공개 시간은 UTC ISO-8601 문자열로 내려주고, 앱 표시 포맷은 서버에서 조합하지 않는다.
|
||||
- 목록 섹션은 데이터가 없으면 빈 배열, 단건 섹션은 없으면 `null`로 내려준다.
|
||||
- 신규 API 공개 스키마 변경은 이 문서의 task 범위 안에서만 수행한다.
|
||||
- Phase 7은 공개 응답 DTO를 변경하지 않고, `PinContent` 고정 상한과 홈 `audioContents` 조회 순서만 보정한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -933,27 +703,3 @@ git diff --check
|
||||
- 2026-06-13: Phase 6 Task 6.1 통합 시나리오 검증 - `DefaultCreatorChannelHomeQueryRepositoryTest`에 현실적인 단일 크리에이터 fixture로 creator/currentLive/latestAudioContent/channelDonations/notices/schedules/audioContents/series/communities/fanTalk/activity/sns 후보 조회를 모두 검증하는 `shouldFindCreatorChannelHomeIntegratedSections`를 추가했다. 기존 구현에서 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests '*shouldFindCreatorChannelHomeIntegratedSections' --no-daemon` 통과. MockMvc 응답 표면은 `CreatorChannelHomeControllerTest`에 schedule 내부 `isAdult`와 channelDonation 내부 `donationId`/`memberId`/`isSecret` 비노출 assertion을 보강했고, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 통과.
|
||||
- 2026-06-13: Phase 6 Task 6.2 추천 페이지 enum rename 회귀 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --no-daemon` 통과. `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin` 결과 없음.
|
||||
- 2026-06-13: Phase 6 Task 6.3 전체 검증 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.common.domain.CreatorActivityTypeTest --tests kr.co.vividnext.sodalive.v2.creator.channel.domain.CreatorChannelHomeQueryPolicyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check`, `./gradlew test --no-daemon` 통과. 병렬 Gradle 실행 중 `build/snapshot/kotlin/kaptGenerateStubsTestKotlin` 삭제 경합이 한 번 발생했으나 동일 repository 테스트를 단독 재실행해 통과를 확인했다.
|
||||
- 2026-07-30: Phase 7 후속 변경 문서화 - 활성 오디오 상단 고정 한도 9개, 10번째 고정의 최고령 고정 교체, `latestAudioContent` 최신순 유지, `audioContents` 활성 고정 `PinContent.updatedAt desc` 우선·일반 콘텐츠 `releaseDate desc`, `id desc`·최대 9개 정책을 PRD Feature D/H와 `DEC-001`에 확정했다. 기존 완료 Task는 유지하고 `P7-T1` → `P7-T2` → `P7-GATE` TDD 계획을 추가했다. `git diff --check` 통과. `./gradlew tasks --all --no-daemon`은 최초 샌드박스의 `~/.gradle` lock 파일 접근 제한으로 실패했고, 승인된 Gradle 캐시 접근으로 동일 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. 사용자 요청이 관련 문서 반영이므로 생산 코드와 테스트는 아직 변경·실행하지 않았다.
|
||||
- 2026-07-30: Phase 7 Task 7.1 RED 확인 - `AudioContentServiceTest`에 활성 고정 8개/9개 상세 `isAvailablePin` 계약과 10번째 고정의 최고령 활성 고정 재사용 테스트를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon` 실행 시 기존 3개 상한 때문에 `shouldExposeAvailablePinByNineActivePinLimit`가 실패하는 것을 확인했다.
|
||||
- 2026-07-30: Phase 7 Task 7.1 GREEN/REFACTOR 확인 - `AudioContentService`의 상세 고정 가능 판정과 `pinToTheTop` 교체 기준을 `MAX_PIN_CONTENT_COUNT = 9`로 통일했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과.
|
||||
- 2026-07-30: Phase 7 Task 7.2 RED 확인 - `DefaultCreatorChannelHomeQueryRepositoryTest`에 최신 오디오, 최근/이전 활성 고정, 비활성 고정, 일반 오디오 fixture를 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 실행 시 기존 최신순 전용 정렬 때문에 `shouldSortHomeAudioContentsByActivePinBeforeReleaseDate`가 실패하는 것을 확인했다.
|
||||
- 2026-07-30: Phase 7 Task 7.2 GREEN/REFACTOR 확인 - `findAudioContentRows`를 최신 단건과 목록 용도로 구분하고, 목록 조회에만 활성 `PinContent` left join 및 `pinContent.isActive desc`, `pinContent.updatedAt desc`, `AudioContent.releaseDate desc`, `AudioContent.id desc` 정렬을 적용했다. `pinContent.id.isNotNull.desc()`는 HQL syntax 오류를 내 기존 repository 패턴인 `pinContent.isActive.desc()`로 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon` 통과.
|
||||
- 2026-07-30: Phase 7 Gate 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `./gradlew tasks --all --no-daemon`, `git diff --check` 모두 통과.
|
||||
- 2026-07-30: Phase 7 reviewer gate 보정 RED/GREEN 확인 - 리뷰어가 `pinToTheTop`이 비활성 `PinContent`까지 포함한 전체 목록 기준으로 동작하면 `9 active + inactive` 상태에서 활성 고정이 10개가 될 수 있음을 차단 이슈로 지적했다. `shouldKeepNineActivePinsWhenReactivatingInactivePin` 테스트 추가 직후 `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --no-daemon`에서 해당 테스트 실패를 확인했고, `pinToTheTop`이 `getPinContentList(memberId, active = true)` 기준으로 최고령 활성 고정을 내린 뒤 비활성 기존 고정을 재활성화하도록 보정했다. 보정 후 같은 `AudioContentServiceTest` 통과.
|
||||
- 2026-07-30: Phase 7 reviewer gate 보정 후 Gate 재확인 - `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `./gradlew tasks --all --no-daemon`, `git diff --check` 통과.
|
||||
- 2026-07-30: Phase 7 reviewer gate 최종 확인 - 보정 delta 재검토 결과 차단 findings 없음.
|
||||
- 2026-07-30: Phase 1~7 정적 리뷰 - `docs/20260612_크리에이터_채널_홈_API/reviews/phase-1-review.md`~`phase-7-review.md`를 작성했다. Phase 1~3은 확정 발견 사항 없음으로 판정했고, `REV-P4-001`(선호 중복 조회), `REV-P5-001`(빈 응답 계약 테스트 누락), `REV-P6-001`(단일 요청 통합 증거 누락), `REV-P7-001`(동시 고정 상한/중복 경쟁)을 확정해 Task 4.3·5.3·6.4·7.3과 `P4-R1`·`P5-R1`·`P6-R1`·`P7-R1`로 전환했다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았고, `rg`·`sed`·`git diff`·`git log`·`git status`를 사용한 정적 검토와 `git diff --check` 성공만 기록한다.
|
||||
- 2026-07-30: Phase 7 Task 7.3 RED/GREEN 확인 - `AudioContentServiceTest`에 `MemberRepository.findByIdForUpdate`가 `PinContent` 조회 전에 호출되는 순서를 먼저 고정했고, `AudioContentPinConcurrencyTest`에 활성 고정 8개 상태의 동시 고정 요청 최종 활성 9개/콘텐츠 중복 없음 통합 검증을 추가했다. RED는 production service 생성자에 lock 의존성이 없어 컴파일 실패하는 것으로 확인했고, `AudioContentService.pinToTheTop`이 고정 상태 조회 전 `MemberRepository.findByIdForUpdate(member.id!!)`를 호출하도록 보정했다. `DefaultCreatorChannelHomeQueryRepositoryTest`에는 활성 고정 후보가 9개를 넘어도 `findAudioContents(..., limit = 9)`가 9개만 반환하는 assertion을 추가했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentPinConcurrencyTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon` 통과.
|
||||
- 2026-07-30: Phase 4 Task 4.3 RED/GREEN 확인 - `CreatorChannelHomeQueryServiceTest`에 `getStoredPreference(viewer)` 결과의 `isAdult`가 service/커뮤니티 성인 필터에 전달되고 `canViewAdultContent(viewer)`가 별도 호출되지 않는 테스트를 추가했다. RED는 `NeverWantedButInvoked`로 확인했고, service가 `preference.isAdult`를 재사용하도록 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon` 통과.
|
||||
- 2026-07-30: Phase 5 Task 5.3 계약 검증 - `CreatorChannelHomeControllerTest`에 빈 홈 응답 fixture를 추가해 `currentLive`, `latestAudioContent`, `fanTalk.latestFanTalk`은 명시적 `null`, `channelDonations`, `notices`, `schedules`, `audioContents`, `series`, `communities`는 빈 배열로 직렬화됨을 검증했다. 문서상 TDD 예외 항목이며 생산 DTO 변경 없이 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --no-daemon` 통과.
|
||||
- 2026-07-30: Phase 6 Task 6.4 통합 검증 - `CreatorChannelHomeEndToEndTest`를 추가해 실제 Spring bean과 DB fixture로 인증된 `GET /api/v2/creator-channels/{creatorId}/home` 단일 요청이 creator/current live/latest audio/donation/notice/schedule/audio list/series/community/fan Talk/activity/SNS 대표 필드를 조립하는지 검증했다. fixture 필수 series genre와 `audioContentCount` 기대값을 실제 정책에 맞게 보정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest --no-daemon` 통과.
|
||||
- 2026-07-30: 리뷰 후속 통합 Gate 확인 - `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentPinConcurrencyTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과.
|
||||
- 2026-07-30: Phase 1~7 2차 정적 리뷰 - 기존 Phase별 리뷰 문서에 2차 리뷰 결과를 추가했다. Phase 1·2·5·6은 신규 확정 발견 사항 없음으로 판정했다. `REV-P3-001`(삭제 회원 후원자 닉네임 prefix), `REV-P4-002`(다른 크리에이터 조회 시 라이브 입장 제한 누락), `REV-P4-003`(기본 `now`의 JVM timezone 의존), `REV-P7-002`(member lock 전 일반 조회로 인한 MySQL snapshot 위험)를 확정해 Task 3.17·4.4·4.5·7.4와 `P3-R1`·`P4-R2`·`P4-R3`·`P7-R2`로 전환했다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았으며 코드·테스트·문서와 MySQL 공식 격리수준 문서를 정적으로 대조했다. `git diff --check`, 리뷰 문서별 2차 섹션 단일 존재, finding ID와 미완료 Task 연결, trailing whitespace 부재를 정적 확인했다.
|
||||
- 2026-07-30: Phase 3 Task 3.17 RED/GREEN 확인 - `CreatorChannelHomeQueryServiceTest`에 삭제 회원 후원자 `deleted_donor`가 홈 domain에서 `donor`로 조립되는 테스트를 추가했다. RED는 `AssertionFailedError`로 확인했고, `CreatorChannelDonationRecord.toDomain()`에서 `removeDeletedNicknamePrefix()`를 적용한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests '*shouldRemoveDeletedNicknamePrefixFromChannelDonation' --no-daemon` 통과.
|
||||
- 2026-07-30: Phase 4 Task 4.4 RED/GREEN 확인 - `viewer.role == CREATOR`이고 `viewer.id != creatorId`인 조회자의 `isViewerCreator` 전달 테스트를 추가해 RED를 확인했다. 이후 `CreatorChannelHomeQueryService`의 `isViewerCreator` 계산을 `viewer.role == MemberRole.CREATOR`로 보정하고 일반 회원 false, 대상 본인 크리에이터 true 회귀와 함께 `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --tests '*shouldPassViewerCreatorFlagWhenViewerIsDifferentCreator' --tests '*shouldPassNonCreatorFlagWhenViewerIsUser' --tests '*shouldPassViewerCreatorFlagToLivePolicyQueries' --no-daemon` 통과.
|
||||
- 2026-07-30: Phase 4 Task 4.5 RED/GREEN 확인 - `CreatorChannelHomeFacadeTest`에 JVM 기본 timezone을 `Asia/Seoul`로 바꾼 상태에서 인자 없는 홈 조회가 UTC 기준 `now`를 service에 전달하는 테스트를 추가했다. RED는 UTC 범위 assertion 실패로 확인했고, facade와 service의 기본 `now`를 `LocalDateTime.now(ZoneOffset.UTC)`로 고정한 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.application.CreatorChannelHomeFacadeTest --no-daemon` 통과.
|
||||
- 2026-07-30: Phase 7 Task 7.4 RED/GREEN 확인 - `AudioContentServiceTest`의 10번째 고정 테스트에 `memberRepository.findByIdForUpdate`가 `repository.findByIdAndCreatorId`와 `PinContent` 조회보다 먼저 호출되는 `inOrder` 검증을 추가했다. RED는 `VerificationInOrderFailure`로 확인했고, `AudioContentService.pinToTheTop`이 첫 DB 접근에서 locked creator를 가져와 이후 콘텐츠/고정 조회와 저장에 사용하도록 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests '*shouldReuseOldestActivePinWhenPinningTenthContent' --tests '*shouldKeepNineActivePinsWhenReactivatingInactivePin' --no-daemon` 통과.
|
||||
- 2026-07-31: 2차 리뷰 후속 최종 Gate 확인 - Reviewer gate에서 `P7-R2` 순서 테스트가 `getPinContentList` 호출까지 고정하지 않은 blocker를 확인해 같은 `inOrder`에 추가했고, 재리뷰 PASS를 받았다. 전체 `./gradlew test --no-daemon`은 첫 실행에서 UTC 기본 `now` 변경 영향으로 `CreatorChannelHomeEndToEndTest`의 local-time fixture current live가 `null`이 되어 실패했고, fixture 기준 시각을 `LocalDateTime.now(ZoneOffset.UTC)`로 보정했다. 보정 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest --no-daemon`, `./gradlew test --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과.
|
||||
- 2026-07-31: Phase 1~7 3차 정적 리뷰 - 기존 Phase별 리뷰 문서에 3차 결과를 누적했다. Phase 1~6은 신규 확정 발견 사항 없음으로 판정했고, `REV-P7-003`(고정 해제가 creator lock을 공유하지 않아 최고령 `PinContent` 행 재사용과 겹치면 새 고정이 소실될 수 있음)을 확정해 Task 7.5 / `P7-R3`으로 전환했다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았다. `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin`과 문서 trailing whitespace 검색은 결과 없음, finding ID와 미완료 Task/Goal 연결 확인, `git diff --check -- docs/20260612_크리에이터_채널_홈_API/plan-task.md` 통과.
|
||||
- 2026-07-31: Phase 7 Task 7.5 RED/GREEN 확인 - `AudioContentServiceTest`에 `unpinAtTheTop`이 `memberRepository.findByIdForUpdate`로 크리에이터 lock을 획득한 뒤 `findByContentIdAndMemberId`를 호출하는 `inOrder` 테스트를 추가했다. RED는 `WantedButNotInvoked`로 확인했고, `AudioContentService.unpinAtTheTop`이 첫 DB 접근에서 creator member row lock을 획득한 뒤 잠긴 creator id로 `PinContent`를 조회하도록 최소 보정했다. `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests '*shouldLockCreatorBeforeFindingPinWhenUnpinningContent' --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.content.AudioContentServiceTest --tests kr.co.vividnext.sodalive.content.AudioContentPinConcurrencyTest --no-daemon`, `./gradlew test --tests kr.co.vividnext.sodalive.v2.creator.channel.home.adapter.out.persistence.DefaultCreatorChannelHomeQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest --no-daemon`, `./gradlew ktlintCheck --no-daemon`, `git diff --check` 통과. 사용자 지시에 따라 전체 테스트는 실행하지 않았다.
|
||||
- 2026-07-31: Phase 1~7 4차 정적 리뷰 - 기존 Phase별 리뷰 문서에 4차 결과를 각각 누적했다. PRD·계획·구조 정렬 후속 계약과 현재 domain/port/repository/service/facade/controller, 기존 테스트를 정적 대조한 결과 모든 Phase에서 신규 확정 발견 사항이 없어 후속 Task/Goal은 추가하지 않았다. 사용자 지시에 따라 컴파일·테스트·ktlint은 실행하지 않았다. `rg`로 이전 enum 잔존 여부와 Phase별 4차 절의 단일 존재를 확인했고, trailing whitespace 검색과 `git diff --check`가 통과했다. 문서 가이드에 따른 `./gradlew tasks --all --no-daemon`은 최초 샌드박스의 Gradle lock 접근 제한으로 실패했으나 승인된 캐시 접근으로 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
- 공지, 커뮤니티 게시글은 홈 노출에 필요한 게시글 요약 필드를 제공한다.
|
||||
- 채널 후원은 최신순 8개를 내려준다.
|
||||
- 오디오 콘텐츠는 최근 업로드 기준 최대 9개를 내려주고, 예약 업로드 전 콘텐츠는 일반 오디오 목록에는 포함하지 않는다.
|
||||
- 크리에이터가 상단 고정한 오디오 콘텐츠는 최대 9개까지 관리하고, 홈 오디오 목록에서 일반 콘텐츠보다 먼저 노출한다.
|
||||
- 시리즈는 최대 8개를 내려주고, 해당 시리즈에 속한 콘텐츠의 최신 공개일 기준으로 정렬한다.
|
||||
- 팬 Talk는 가장 최근에 남긴 팬 Talk 1개와 전체 팬 Talk 개수를 함께 내려준다.
|
||||
- 활동 지수와 SNS는 `ExplorerService.getCreatorDetail`의 계산/필드 의미를 기준으로 확장한다.
|
||||
@@ -182,13 +181,6 @@
|
||||
#### Requirements
|
||||
- 최근 업로드된 오디오 콘텐츠를 최대 9개 내려준다.
|
||||
- 신규 오디오 콘텐츠 영역과 오디오 목록 영역의 첫 번째 항목이 겹치지 않도록, 오디오 목록에서는 Feature D의 `latestAudioContent`로 내려간 가장 최신 콘텐츠를 제외한다.
|
||||
- `latestAudioContent`는 상단 고정 여부와 관계없이 기존처럼 공개 시각 기준 최신 콘텐츠를 내려준다.
|
||||
- `audioContents`는 `PinContent.member.id == creatorId && PinContent.isActive == true`인 콘텐츠를 일반 콘텐츠보다 먼저 내려준다.
|
||||
- 상단 고정 콘텐츠 사이의 정렬은 `PinContent.updatedAt desc`다.
|
||||
- 고정되지 않은 콘텐츠 사이의 정렬은 기존 `AudioContent.releaseDate desc`, `AudioContent.id desc`를 유지한다.
|
||||
- 상단 고정 우선순위를 적용한 뒤 전체 `audioContents`를 최대 9개로 제한한다.
|
||||
- 크리에이터별 활성 상단 고정 콘텐츠는 최대 9개다. 9개가 활성인 상태에서 새 콘텐츠를 고정하면 기존 동작처럼 `PinContent.updatedAt`이 가장 오래된 활성 고정을 교체한다.
|
||||
- 오디오 상세의 `isAvailablePin`은 요청자가 해당 콘텐츠의 크리에이터이고 활성 고정 개수가 9개 미만일 때만 `true`다.
|
||||
- 예약 업로드 전 콘텐츠는 포함하지 않는다.
|
||||
- `releaseDate == null`인 오디오 콘텐츠는 목록, 최신 콘텐츠, 첫 콘텐츠 판정에서 제외한다.
|
||||
- 응답에는 다음 값을 포함한다.
|
||||
@@ -209,8 +201,6 @@
|
||||
#### Edge Cases
|
||||
- 시리즈에 속하지 않은 콘텐츠는 시리즈 관련 필드를 `null`로 내려준다.
|
||||
- 오디오 콘텐츠가 없으면 빈 배열을 내려준다.
|
||||
- `latestAudioContent`가 상단 고정 콘텐츠여도 `audioContents`에 중복 노출하지 않는다.
|
||||
- 비활성 `PinContent`는 고정 우선 정렬에 사용하지 않는다.
|
||||
|
||||
### Feature I. 시리즈
|
||||
|
||||
@@ -342,11 +332,3 @@
|
||||
|
||||
## 11. Open Questions
|
||||
- 없음.
|
||||
|
||||
---
|
||||
|
||||
## 12. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 범위 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-07-30 | `DEC-001` | 확정 | 오디오 상단 고정 한도를 3개에서 9개로 늘리고, `audioContents`에서 활성 고정을 `PinContent.updatedAt desc`로 먼저 노출한 뒤 일반 콘텐츠를 기존 최신순으로 노출한다. `latestAudioContent`의 최신 공개 정책과 공개 API 스키마는 유지한다. | 2026-07-30 `deep-interview` 확정 결과와 기존 `PinContent` 교체 동작 | Feature D, Feature H, `P7-T1`, `P7-T2`, `P7-GATE` |
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
# Phase 1 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / Task 1.1 |
|
||||
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- `RecommendedActivityType`의 공용 `CreatorActivityType` 이동과 추천 기능 회귀 범위를 코드·테스트·검증 기록과 정적 대조했다.
|
||||
- 후속 패키지 정렬 문서로 승인된 경로 변경은 결함에서 제외했다.
|
||||
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았고, 기존 성공 기록은 참고 증거로만 사용했다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- `CreatorActivityType.kt`는 `LIVE`, `AUDIO`, `COMMUNITY`, `LIVE_REPLAY`와 각 name 기반 `code`를 제공한다.
|
||||
- 추천 service/port/repository와 대응 테스트의 import와 type은 공용 enum으로 정렬되어 있다.
|
||||
- `rg -n "RecommendedActivityType" src/main/kotlin src/test/kotlin`으로 이전 타입 잔존 여부를 정적 확인했다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 1 코드·테스트·문서 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
- **대상:** 1차 리뷰 후속 변경이 공용 `CreatorActivityType`과 추천 기능에 만든 영향.
|
||||
- **방법:** 현재 working tree의 import/type 사용처와 추천 회귀 테스트 코드를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 신규 확정 발견 사항 없음. Phase 1 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 8. 3차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 현재 working tree의 `CreatorActivityType` 정의와 추천·크리에이터 채널 홈 사용처.
|
||||
- **방법:** enum 값·`code`, import/type 사용처, 대응 테스트와 `plan-task.md` Task 1.1을 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 신규 확정 발견 사항 없음. Phase 1 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 9. 4차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 공용 `CreatorActivityType` 정의와 추천·홈 스케줄의 현재 사용 경계.
|
||||
- **방법:** enum 값·`code`, 추천 service/port/repository 및 홈 domain/DTO import, 이전 `RecommendedActivityType` 잔존 여부를 Task 1.1과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 신규 확정 발견 사항 없음. Phase 1 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Phase 2 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 2 / Task 2.1~2.2 |
|
||||
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, 후속 홈 API 구조 정렬 문서, `docs/agent-guides/*.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- 홈 domain/response가 PRD의 13개 상위 섹션을 유지하는지, 순수 정책이 스케줄 제한·정렬·성인 보정·최신 오디오 중복 제거를 보장하는지 정적 검토했다.
|
||||
- 최초 오디오 판정을 repository 계층으로 이동한 후속 정렬은 해당 문서와 현재 repository 테스트를 함께 대조했다.
|
||||
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- `CreatorChannelHome.kt`, `CreatorChannelHomeResponse.kt`, `CreatorChannelHomeQueryPolicy.kt`
|
||||
- `CreatorChannelHomeQueryPolicyTest.kt`, `CreatorChannelHomeQueryServiceTest.kt`, `DefaultCreatorChannelHomeQueryRepositoryTest.kt`
|
||||
- 최신 오디오 제외와 스케줄 경계값·동시각 LIVE 우선·성인 노출 정책의 테스트 존재를 `rg`로 확인했다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 2 모델·정책·테스트 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
- **대상:** 1차 리뷰 후속 변경 이후 domain/response/policy의 정렬·제한·null/빈 목록 계약.
|
||||
- **방법:** 모델 변환과 policy 호출부, 대응 단위 테스트를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 신규 확정 발견 사항 없음. Phase 2 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 8. 3차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 홈 13개 상위 섹션 domain/response와 스케줄·최신 오디오 제외 순수 정책.
|
||||
- **방법:** `CreatorChannelHome`, `CreatorChannelHomeResponse`, `CreatorChannelHomeQueryPolicy`와 service/controller 테스트를 PRD Feature A~N 및 Task 2.1~2.2와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 신규 확정 발견 사항 없음. Phase 2 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 9. 4차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 홈 domain/response 13개 섹션과 스케줄 제한·동시각 정렬·성인 보정·최신 오디오 제외 정책.
|
||||
- **방법:** domain/response factory, `CreatorChannelHomeQueryPolicy`, service 호출부와 대응 단위·응답 계약 테스트를 Task 2.1~2.2와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 신규 확정 발견 사항 없음. Phase 2 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,88 +0,0 @@
|
||||
# Phase 3 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 3 / Task 3.1~3.16 |
|
||||
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, 커뮤니티 좋아요·홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- 현재 query port/persistence adapter와 공용 커뮤니티 조회 경계가 creator, 차단, 라이브, 예약, 오디오, 후원, 시리즈, 팬 Talk, 활동, SNS 정책을 PRD와 같은 의미로 구현하는지 정적 검토했다.
|
||||
- 후속 문서로 승인된 `isOwned`/`isRented`, `isLiked`, 패키지 분리는 원 계획과의 단순 차이로 결함 판정하지 않았다.
|
||||
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- `CreatorChannelHomeQueryPort.kt`, `DefaultCreatorChannelHomeQueryRepository.kt`
|
||||
- `DefaultCreatorChannelHomeQueryRepositoryTest.kt`의 공개 시각/null, 성인, 성별·크리에이터 입장, 구매·비밀 후원, 삭제된 유료 게시물, 시리즈 경계, 데뷔일, KST 월 경계 테스트
|
||||
- 주요 쿼리의 대량 조립이 id 목록 기반 bulk 조회를 유지하고, 추가 N+1을 만들지 않는지 확인했다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 3 코드·테스트·후속 계약 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
### 7.1 리뷰 범위와 방법
|
||||
|
||||
- 1차 리뷰 후속 변경이 반영된 홈 후원 record/domain 변환을 PRD의 “기존 채널 후원 목록과 동일” 계약과 대조했다.
|
||||
- 기존 `ChannelDonationService`와 전용 v2 후원 탭의 삭제 회원 닉네임 처리도 함께 확인했다.
|
||||
- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
|
||||
### 7.2 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P3-001` | Low | 완료 | 홈 후원에 삭제 회원 닉네임 prefix가 노출된다 | Task 3.17 | `P3-R1` |
|
||||
|
||||
#### REV-P3-001 — 홈 후원에 삭제 회원 닉네임 prefix가 노출된다
|
||||
|
||||
- **관련 요구사항:** PRD Feature E, Task 3.5·3.15
|
||||
- **관찰:** 홈 repository는 후원자의 저장 닉네임을 그대로 projection하고, `CreatorChannelHomeQueryService.toDomain()`도 그대로 복사한다. 기존 `ChannelDonationService`와 `CreatorChannelDonationQueryService`는 `removeDeletedNicknamePrefix()`를 적용한다.
|
||||
- **영향:** 탈퇴한 후원자의 내부 저장 형식인 `deleted_...`가 홈 API에서만 공개되어 기존 채널 후원 목록과 표시 의미가 달라진다.
|
||||
- **권장 조치:** 홈 후원 domain 변환 경계에 기존 extension을 적용하고 삭제 회원 후원자 회귀 테스트를 추가한다.
|
||||
- **판정 기록:** 코드 경로 3개를 정적 대조해 확정했다.
|
||||
- **완료 기록:** 2026-07-30 — `CreatorChannelHomeQueryServiceTest` RED/GREEN으로 확인하고 service 변환 경계에 `removeDeletedNicknamePrefix()`를 적용했다.
|
||||
|
||||
### 7.3 plan·goal 전환과 종료 판정
|
||||
|
||||
- `plan-task.md` Phase 3에 Task 3.17 / `P3-R1`을 추가했다.
|
||||
- **최종 결론:** Low 1건 수정 완료.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 8. 3차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 현재 query port/persistence adapter와 공용 커뮤니티 조회 경계의 creator, 라이브·스케줄, 오디오, 후원, 공지·커뮤니티, 시리즈, 팬 Talk, 활동, SNS 정책.
|
||||
- **방법:** projection·bulk 조립, 공개/예약·성인·차단 조건, KST 월 경계, 삭제 회원 닉네임 보정과 repository/service 테스트를 PRD 및 Task 3.1~3.17과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** `REV-P3-001` 수정 반영을 포함해 신규 확정 발견 사항 없음. Phase 3 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 9. 4차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** query port/persistence adapter와 공용 커뮤니티 service의 creator·라이브·스케줄·오디오·후원·게시글·시리즈·팬 Talk·활동·SNS 조회 정책.
|
||||
- **방법:** projection/bulk 조회, 공개·예약·성인·성별·차단·구매·KST 월 경계 조건과 repository/service 테스트를 PRD Feature A~N 및 Task 3.1~3.17과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 기존 `REV-P3-001` 보정이 유지되며 신규 확정 발견 사항 없음. Phase 3 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,122 +0,0 @@
|
||||
# Phase 4 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 4 / Task 4.1~4.2 |
|
||||
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, 홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- service의 접근 검증, 조회자 context, 섹션 조립, 최종 정책 보정을 PRD와 대조했다.
|
||||
- `CreatorChannelHomeQueryService.kt`, `MemberContentPreferenceService.kt`, 대응 service 테스트를 포함했다.
|
||||
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- `CreatorChannelHomeQueryService.kt:70-71`은 `getStoredPreference(viewer)` 후 `canViewAdultContent(viewer)`를 이어서 호출한다.
|
||||
- `MemberContentPreferenceService.kt:144-157`에서 `canViewAdultContent`ub294 `getStoredPreference(member).isAdult`를 다시 호출한다.
|
||||
- 두 호출은 같은 요청에서 같은 `ViewerContentPreference`를 사용할 수 있으며, `getStoredPreference`는 `REQUIRES_NEW` transaction이다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P4-001` | Low | 완료 | 홈 조회가 콘텐츠 선호를 같은 요청에서 두 번 조회한다 | Task 4.3 | `P4-R1` |
|
||||
|
||||
### REV-P4-001 — 홈 조회가 콘텐츠 선호를 같은 요청에서 두 번 조회한다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** Phase 4 조회자 성인 노출·콘텐츠 타입 context 조립
|
||||
- **소유 Task:** Task 4.3 / `P4-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
service가 이미 받은 `preference.isAdult`를 사용할 수 있지만 `canViewAdultContent(viewer)`를 다시 호출한다. 실제 bean에서는 이 메서드가 `getStoredPreference` 전체 경로를 반복한다.
|
||||
|
||||
**영향**
|
||||
|
||||
공개 응답은 바뀌지 않지만, 홈 조회마다 독립 transaction·선호·국가 context 조회가 중복된다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
`preference.isAdult`를 성인 필터에 재사용하고, service 테스트에서 별도 `canViewAdultContent` 호출이 없음을 고정한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — 두 method의 정적 호출 관계와 transaction annotation으로 확정.
|
||||
- 2026-07-30 — `CreatorChannelHomeQueryServiceTest` RED/GREEN으로 `preference.isAdult` 재사용과 `canViewAdultContent(viewer)` 미호출을 검증하고 완료.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 4에 Task 4.3 / `P4-R1`을 추가했다.
|
||||
- 실행 objective: `REV-P4-001`을 수정하고 service 조립 회귀를 방지한다.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | service·선호 service·테스트 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 4.3 / `P4-R1` |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
### 7.1 리뷰 범위와 방법
|
||||
|
||||
- service/facade의 조회자 context 조립과 기본 `now` 생성 지점을 기존 라이브 목록, 홈 Following, UTC 응답·KST 월 경계 계약과 대조했다.
|
||||
- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
|
||||
### 7.2 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P4-002` | Medium | 완료 | 다른 크리에이터가 조회하면 입장 제한이 적용되지 않는다 | Task 4.4 | `P4-R2` |
|
||||
| `REV-P4-003` | Medium | 완료 | 홈 기본 시각이 JVM timezone에 따라 달라진다 | Task 4.5 | `P4-R3` |
|
||||
|
||||
#### REV-P4-002 — 다른 크리에이터가 조회하면 입장 제한이 적용되지 않는다
|
||||
|
||||
- **관련 요구사항:** PRD Feature B/F의 기존 라이브 목록 동일 정책, Task 4.1
|
||||
- **관찰:** service는 `isViewerCreator = viewerId == creatorId`로 계산한다. 반면 기존 라이브 목록과 `HomeFollowingQueryService`는 조회자의 `MemberRole.CREATOR` 여부를 사용하며, repository는 이 값이 true일 때 `isAvailableJoinCreator` 또는 자기 라이브 예외를 적용한다.
|
||||
- **영향:** 크리에이터가 다른 크리에이터 채널을 볼 때 `isAvailableJoinCreator == false`인 현재/예약 라이브가 홈에 노출될 수 있다.
|
||||
- **권장 조치:** 조회자의 role로 flag를 산출하고 다른 크리에이터 조회 회귀 테스트를 추가한다.
|
||||
- **완료 기록:** 2026-07-30 — 다른 크리에이터/일반 회원/대상 본인 focused test로 `viewer.role == MemberRole.CREATOR` 계산을 검증했다.
|
||||
|
||||
#### REV-P4-003 — 홈 기본 시각이 JVM timezone에 따라 달라진다
|
||||
|
||||
- **관련 요구사항:** UTC 시간 계약, KST 기준 이번 달 후원, Task 4.1·6.4
|
||||
- **관찰:** facade와 service의 기본값은 `LocalDateTime.now()`인 반면 repository는 입력 `now`를 UTC로 간주해 KST 월 경계로 변환하고 DTO도 UTC offset을 붙인다. JVM timezone을 고정하는 설정은 확인되지 않았고, 인접 `HomeFollowingQueryService`는 UTC clock을 명시한다.
|
||||
- **영향:** JVM이 UTC가 아니면 공개/예약 콘텐츠 경계와 이번 달 후원 범위가 timezone offset만큼 이동하고, 응답 문자열도 실제 instant와 다를 수 있다. 현재 E2E fixture도 같은 `LocalDateTime.now()`를 사용해 이 조건을 드러내지 못한다.
|
||||
- **권장 조치:** 인자 없는 조회의 `now`를 UTC로 생성하고 비 UTC JVM timezone 회귀 테스트를 추가한다.
|
||||
- **완료 기록:** 2026-07-30 — facade 기본 호출을 `Asia/Seoul` JVM timezone에서 검증하고 facade/service 기본 `now`를 UTC로 고정했다.
|
||||
|
||||
### 7.3 plan·goal 전환과 종료 판정
|
||||
|
||||
- `plan-task.md` Phase 4에 Task 4.4 / `P4-R2`, Task 4.5 / `P4-R3`을 추가했다.
|
||||
- **최종 결론:** Medium 2건 수정 완료.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 8. 3차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 접근 검증 순서, 콘텐츠 선호 단일 조회, 조회자 role/effective gender 전달, facade·service 기본 UTC 시각과 전체 섹션 조립.
|
||||
- **방법:** `CreatorChannelHomeQueryService`, `CreatorChannelHomeFacade`와 대응 service/facade 테스트를 Task 4.1~4.5 및 기존 라이브 목록 정책과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** `REV-P4-001`~`REV-P4-003` 수정 반영을 포함해 신규 확정 발견 사항 없음. Phase 4 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 9. 4차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 접근 예외 순서, 콘텐츠 선호 단일 조회, 조회자 role/effective gender, UTC `now` 전달과 전체 섹션 조립.
|
||||
- **방법:** query service/facade의 실제 호출 흐름과 service/facade 테스트를 기존 라이브 목록 정책 및 Task 4.1~4.5와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 기존 `REV-P4-001`~`REV-P4-003` 보정이 유지되며 신규 확정 발견 사항 없음. Phase 4 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,92 +0,0 @@
|
||||
# Phase 5 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 5 / Task 5.1~5.2 |
|
||||
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, 홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- endpoint·인증·`ApiResponse`·DTO mapping·JSON 필드 계약을 controller/DTO 테스트와 정적 대조했다.
|
||||
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- `CreatorChannelHomeControllerTest.kt:90-148`은 facade가 반환한 모든 섹션이 채워진 응답의 최상위·boolean·비노출 필드를 검증한다.
|
||||
- `plan-task.md` Task 5.2 REFACTOR와 주의사항은 단건이 없으면 `null`, 목록이 없으면 빈 배열을 내려주도록 명시한다.
|
||||
- 현재 controller 테스트에는 모든 nullable/목록 섹션이 빈 fixture의 JSON 직렬화 assertion이 없다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P5-001` | Low | 완료 | 빈 홈 응답의 null/빈 배열 계약이 테스트로 고정되지 않았다 | Task 5.3 | `P5-R1` |
|
||||
|
||||
### REV-P5-001 — 빈 홈 응답의 null/빈 배열 계약이 테스트로 고정되지 않았다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** Task 5.2 REFACTOR, 구현 중 주의사항의 빈 섹션 계약
|
||||
- **소유 Task:** Task 5.3 / `P5-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
DTO 타입은 nullability와 `List` 구조를 갖추고 있지만, 실제 Jackson 응답에서 null 필드가 존재하고 목록이 `[]`로 나가는지 검증하는 빈 응답 시나리오가 없다.
|
||||
|
||||
**영향**
|
||||
|
||||
현재 실행 결함을 확인한 것은 아니지만, Jackson 설정·DTO annotation·mapping 변경 시 클라이언트의 빈 화면 계약이 회귀해도 감지하지 못한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
MockMvc에 최소 빈 fixture 한 개를 추가해 `currentLive`, `latestAudioContent`, `fanTalk.latestFanTalk`과 목록 6개의 JSON 값을 명시적으로 고정한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — 계획의 명시적 계약과 현재 controller 테스트 범위를 대조해 확정.
|
||||
- 2026-07-30 — `CreatorChannelHomeControllerTest`에 빈 홈 응답 null/빈 배열 JSON 계약 테스트를 추가하고 focused test 통과로 완료.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 5에 Task 5.3 / `P5-R1`을 추가했다.
|
||||
- RED가 바로 통과하면 생산 DTO는 변경하지 않고 회귀 테스트만 남기도록 범위를 제한했다.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | controller·DTO·테스트 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P5-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 5.3 / `P5-R1` |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
- **대상:** 빈 홈 응답 보강 이후 controller 인증·endpoint·JSON 표면 계약.
|
||||
- **방법:** controller, response factory, 채워진/빈 응답 MockMvc assertion을 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 신규 확정 발견 사항 없음. Phase 5 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 8. 3차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 인증 controller, facade 응답 변환, Boolean 필드명, 내부 필드 비노출과 null/빈 배열 JSON 계약.
|
||||
- **방법:** controller/response DTO와 채워진·빈 응답 MockMvc assertion을 Task 5.1~5.3과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 신규 확정 발견 사항 없음. Phase 5 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 9. 4차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 인증 endpoint, `ApiResponse.ok(...)`, facade 응답 변환, Boolean 이름과 null/빈 배열 JSON 계약.
|
||||
- **방법:** controller/response DTO, 채워진·빈 응답 MockMvc assertion과 구조 정렬 후 공용 오디오 응답 필드를 Task 5.1~5.3과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 기존 `REV-P5-001` 보정이 유지되며 신규 확정 발견 사항 없음. Phase 5 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,94 +0,0 @@
|
||||
# Phase 6 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 6 / Task 6.1~6.3 |
|
||||
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, 홈 API 구조 정렬 후속 문서, `docs/agent-guides/*.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- Task 6.1이 요구한 “PRD의 홈 전체 섹션이 한 요청에서 조립” 증거와 실제 테스트 계층/빈 경로를 대조했다.
|
||||
- 추천 enum 회귀와 기존 검증 기록의 존재도 정적 확인했다.
|
||||
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- `CreatorChannelHomeControllerTest.kt:46-52`는 `@WebMvcTest`와 mock `CreatorChannelHomeFacade`를 사용한다.
|
||||
- `DefaultCreatorChannelHomeQueryRepositoryTest.kt:178-275`의 `shouldFindCreatorChannelHomeIntegratedSections`는 동일 fixture로 repository method를 각각 직접 호출한다.
|
||||
- 해당 repository 통합 시나리오는 후속 구조 정렬에서 공용 커뮤니티 service로 이동한 notices/communities를 포함하지 않는다.
|
||||
- `CreatorChannelHomeEndToEndTest` 또는 같은 역할의 실제 홈 endpoint 통합 테스트는 존재하지 않는다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P6-001` | Low | 완료 | 한 HTTP 요청의 홈 전체 조립을 증명하는 통합 테스트가 없다 | Task 6.4 | `P6-R1` |
|
||||
|
||||
### REV-P6-001 — 한 HTTP 요청의 홈 전체 조립을 증명하는 통합 테스트가 없다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** Task 6.1 기대 결과
|
||||
- **소유 Task:** Task 6.4 / `P6-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
현재 증거는 “mock facade가 만든 domain을 controller가 JSON으로 변환”하는 테스트와 “한 fixture에서 repository method를 각각 호출”하는 테스트로 분리되어 있다. 따라서 controller→facade→service→repository/공용 서비스→JSON이 한 요청에서 연결되는지는 직접 증명되지 않는다.
|
||||
|
||||
**영향**
|
||||
|
||||
개별 계층 테스트가 통과해도 bean wiring, facade의 커뮤니티 조회 호출, 시간/context 전달, 전체 mapping 중 누락을 한 번에 검출하지 못한다. 이는 실행 결함 확정이 아니라 Task 6.1 완료 증거의 공백이다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
실제 Spring bean과 DB fixture를 사용해 인증된 단일 홈 요청을 보내고, 13개 상위 섹션의 대표 필드를 검증하는 통합 테스트를 추가한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — 테스트 annotation, mock 경계, repository 호출 범위, E2E 파일 부재를 정적 대조해 확정.
|
||||
- 2026-07-30 — `CreatorChannelHomeEndToEndTest`를 추가해 실제 bean 단일 HTTP 요청으로 전체 대표 섹션 JSON을 검증하고 완료.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 6에 Task 6.4 / `P6-R1`을 추가했다.
|
||||
- 통합 테스트가 바로 통과하면 생산 코드는 변경하지 않도록 범위를 제한했다.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | controller·repository 통합 증거 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 6.4 / `P6-R1` |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
- **대상:** 신규 `CreatorChannelHomeEndToEndTest`가 실제 bean 경로와 13개 섹션의 대표 응답을 연결하는지 확인했다.
|
||||
- **방법:** fixture 생성 시각, HTTP 호출, JSON assertion과 기존 focused test 경계를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** Phase 6 자체의 신규 확정 발견 사항은 없다. E2E가 JVM 기본 timezone과 같은 `LocalDateTime.now()`를 사용해 UTC 오류를 가릴 수 있는 점은 원인 소유 Phase 4의 `REV-P4-003` / Task 4.5로 전환했으며 중복 Task를 만들지 않았다.
|
||||
- **남은 항목:** Phase 6 없음. Task 4.5 완료 후 E2E 직접 영향만 재확인한다.
|
||||
|
||||
## 8. 3차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 실제 controller→facade→service→repository/공용 community 경로의 단일 HTTP 요청 통합 증거와 추천 enum 회귀 범위.
|
||||
- **방법:** `CreatorChannelHomeEndToEndTest`의 UTC fixture·대표 13개 섹션 assertion, repository/service/controller 테스트 경계와 Task 6.1~6.4를 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** `REV-P6-001`과 Phase 4 UTC 후속 수정 반영을 포함해 신규 확정 발견 사항 없음. Phase 6 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 9. 4차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 단일 인증 HTTP 요청의 실제 bean 경로, UTC fixture, 홈 13개 섹션 대표 assertion과 추천 enum 회귀 증거.
|
||||
- **방법:** E2E·repository·service·controller 테스트의 계층 경계와 현재 생산 코드 wiring을 Task 6.1~6.4와 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 기존 `REV-P6-001` 및 UTC 보정이 유지되며 신규 확정 발견 사항 없음. Phase 6 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,172 +0,0 @@
|
||||
# Phase 7 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 7 / Task 7.1~7.2 / P7-GATE |
|
||||
| 기준 commit 또는 working tree | `f1c2e6c5` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- 활성 고정 9개 상한, 10번째 고정의 최고령 교체, 비활성 재활성화, 홈 오디오 고정 우선 정렬, 최신 오디오 제외을 코드·테스트·문서와 정적 대조했다.
|
||||
- 사용자 지시에 따라 컴파일·테스트는 실행하지 않았고, Phase 7의 기존 Gate 성공 기록은 참고 증거로만 사용했다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- `AudioContentService.kt:1230-1261`은 transaction 안에서 현재 고정→활성 목록 순으로 읽고 추가·재활성화하지만 크리에이터 단위 lock을 취하지 않는다.
|
||||
- `PinContent.kt:11-19`에는 `(member_id, content_id)` unique constraint가 없고, `PinContentRepository.kt:15-43`의 조회에도 pessimistic lock이 없다.
|
||||
- `MemberRepository.kt:35-37`에는 이미 회원 행을 잠그는 `findByIdForUpdate` 패턴이 있다.
|
||||
- `AudioContentServiceTest.kt:454-530`은 8/9개, 10번째 교체, 비활성 재활성화를 순차 mock 요청으로 검증하며 동시 요청은 다루지 않는다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P7-001` | Medium | 완료 | 동시 고정 요청이 활성 9개 상한을 넘거나 중복 행을 만들 수 있다 | Task 7.3 | `P7-R1` |
|
||||
|
||||
### REV-P7-001 — 동시 고정 요청이 활성 9개 상한을 넘거나 중복 행을 만들 수 있다
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** PRD Feature D/H, `DEC-001`, Task 7.1
|
||||
- **소유 Task:** Task 7.3 / `P7-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
활성 고정이 8개일 때 같은 크리에이터의 두 transaction이 동시에 목록을 읽으면 둘 다 `size < 9`로 판단해 서로 다른 행을 추가할 수 있다. 같은 콘텐츠에 대한 두 요청도 둘 다 `findByContentIdAndMemberId == null`을 관찰한 후 중복 행을 추가할 수 있다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 활성 고정 8개를 준비한다.
|
||||
2. transaction A/B가 각각 서로 다른 콘텐츠에 대해 기존 고정이 없음과 활성 목록 8개를 읽는다.
|
||||
3. A/B가 각각 새 `PinContent`를 저장하면 최종 활성 수는 10개가 된다.
|
||||
4. 현재 코드·lock·constraint 중 이 interleaving을 차단하는 장치가 없다.
|
||||
|
||||
**영향**
|
||||
|
||||
동시 요청이라는 제한 조건에서 크리에이터별 활성 고정 9개 계약이 깨지고, 홈 정렬 join에 중복 행이 생기면 응답 개수·순서도 오염될 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
고정 상태를 읽기 전 크리에이터 행을 pessimistic write lock으로 직렬화하고, 새 DDL 없이 동시성 통합 테스트로 9개 상한과 콘텐츠 유일성을 고정한다. 홈 repository 테스트에는 9개 초과 fixture에서 반환 개수가 9임을 명시적으로 추가한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — transaction 내 읽기-판단-쓰기 순서, lock 부재, unique constraint 부재를 정적 대조해 확정.
|
||||
- 2026-07-30 — `AudioContentService.pinToTheTop`에 creator member row lock을 추가하고 `AudioContentPinConcurrencyTest`/`AudioContentServiceTest` 통과로 완료. Reviewer gate에서 요구한 홈 목록 9개 상한 assertion은 `DefaultCreatorChannelHomeQueryRepositoryTest`에 추가해 통과 확인.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 7에 Task 7.3 / `P7-R1`을 추가했다.
|
||||
- 기존 Task 7.1~7.2과 P7-GATE의 완료 이력은 유지하고 리뷰 후속 goal만 추가했다.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 고정 service·entity·repository·테스트 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P7-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 7.3 / `P7-R1` |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검토만 실행, 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
### 7.1 리뷰 범위와 방법
|
||||
|
||||
- Task 7.3의 member row lock 호출 순서와 동시성 테스트가 MySQL 운영 격리수준에서도 직렬화를 보장하는지 정적 검토했다.
|
||||
- MySQL 공식 InnoDB `REPEATABLE READ`의 consistent read/locking read 의미를 근거로 대조했다.
|
||||
- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
|
||||
### 7.2 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P7-002` | Medium | 완료 | 크리에이터 lock 전에 일반 조회가 snapshot을 만들 수 있다 | Task 7.4 | `P7-R2` |
|
||||
|
||||
#### REV-P7-002 — 크리에이터 lock 전에 일반 조회가 snapshot을 만들 수 있다
|
||||
|
||||
- **관련 요구사항:** PRD Feature D/H, Task 7.3
|
||||
- **관찰:** `pinToTheTop`은 `repository.findByIdAndCreatorId` 일반 조회 후 `memberRepository.findByIdForUpdate`를 호출하고, 그 뒤 `PinContent`를 일반 조회한다. MySQL InnoDB 기본 `REPEATABLE READ`에서는 첫 consistent read가 snapshot을 정하고 locking read는 최신 행을 읽으므로 두 방식을 섞으면 후속 일반 조회가 lock 대기 전 snapshot을 계속 사용할 수 있다. [MySQL 8.0 Reference Manual](https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html)
|
||||
- **영향:** 두 번째 transaction이 member lock을 기다린 뒤에도 첫 transaction의 최신 고정 변경을 보지 못해 9개 상한이나 콘텐츠 중복 방지 판단이 stale 상태를 기준으로 수행될 수 있다.
|
||||
- **검증 공백:** 현재 단위 테스트는 member lock이 `PinContent` 조회보다 빠른지만 확인하고 콘텐츠 일반 조회는 순서 검증에 포함하지 않는다. 동시성 테스트의 시작 latch도 두 transaction의 고정 목록 읽기 시점을 강제하지 않아 이 interleaving을 보장하지 않는다.
|
||||
- **권장 조치:** transaction의 첫 DB 접근에서 member row lock을 잡고 모든 일반 조회를 그 뒤로 옮기며, 호출 순서를 단위 테스트로 고정한다.
|
||||
- **완료 기록:** 2026-07-30 — `AudioContentServiceTest`의 `inOrder` RED/GREEN으로 lock이 콘텐츠/고정 조회보다 먼저 호출됨을 고정했다.
|
||||
|
||||
### 7.3 plan·goal 전환과 종료 판정
|
||||
|
||||
- `plan-task.md` Phase 7에 Task 7.4 / `P7-R2`를 추가했다.
|
||||
- **최종 결론:** Medium 1건 수정 완료.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 8. 3차 리뷰 — 2026-07-31
|
||||
|
||||
### 8.1 리뷰 범위와 방법
|
||||
|
||||
- Task 7.3~7.4 후속 구현이 같은 크리에이터의 모든 상단 고정 변경을 실제로 직렬화하는지 `pinToTheTop`, `unpinAtTheTop`, member lock, `PinContent` 행 재사용 흐름을 정적 검토했다.
|
||||
- 기존 단위·동시성 테스트가 고정과 해제의 경쟁을 포함하는지도 함께 확인했다.
|
||||
- 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
|
||||
### 8.2 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P7-003` | Medium | 수정 완료 | 고정 해제가 creator lock을 공유하지 않아 새 고정을 소실할 수 있다 | Task 7.5 | `P7-R3` |
|
||||
|
||||
#### REV-P7-003 — 고정 해제가 creator lock을 공유하지 않아 새 고정을 소실할 수 있다
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** PRD Feature H, Task 7.1·7.3·7.4
|
||||
- **소유 Task:** Task 7.5 / `P7-R3`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`pinToTheTop`은 첫 DB 접근에서 `MemberRepository.findByIdForUpdate`로 크리에이터를 잠근 뒤, 활성 고정이 9개이면 가장 오래된 `PinContent` 행의 `content`를 새 콘텐츠로 바꿔 재사용한다. 반면 `unpinAtTheTop`은 같은 member lock 없이 이전 콘텐츠로 `PinContent`를 조회하고 `isActive = false`로 변경한다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 활성 고정 9개에서 가장 오래된 고정 콘텐츠를 A, 새 고정 콘텐츠를 B로 둔다.
|
||||
2. 해제 transaction이 A의 `PinContent`를 먼저 읽은 뒤 commit 전 대기한다.
|
||||
3. 고정 transaction이 member lock을 얻고 같은 행을 B의 활성 고정으로 재사용해 commit한다.
|
||||
4. 해제 transaction이 늦게 commit하면 이전 A를 가리키던 stale entity update가 재사용된 행을 다시 비활성화하거나 이전 상태로 덮을 수 있다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `AudioContentService.pinToTheTop`은 member lock과 최고령 `PinContent` 행 재사용을 수행한다.
|
||||
- 코드: `AudioContentService.unpinAtTheTop`은 member lock 없이 `PinContent`를 조회·비활성화한다.
|
||||
- 테스트: `AudioContentPinConcurrencyTest`는 고정 요청 2개의 경쟁만 검증하고 고정/해제 경쟁은 다루지 않는다.
|
||||
|
||||
**영향**
|
||||
|
||||
겹친 두 요청의 순서에 따라 성공한 새 고정 B가 홈 `audioContents`에서 사라지거나 재사용 행 상태가 요청 완료 순서와 다르게 남을 수 있다. 활성 9개 상한 자체를 초과하지는 않지만 상단 고정 상태의 일관성이 깨진다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
`unpinAtTheTop`도 첫 DB 접근에서 `pinToTheTop`과 같은 member row lock을 획득한 뒤 해제 대상을 조회한다. 최소 회귀 테스트로 member lock이 `PinContent` 조회보다 먼저 호출되는 순서를 고정하고 기존 동시 고정·10번째 교체 테스트를 함께 유지한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — 고정 행 재사용, 해제의 lock 부재, 현재 동시성 테스트 범위를 정적 대조해 확정했다.
|
||||
- 2026-07-31 — `unpinAtTheTop`의 creator lock 선행 획득을 RED/GREEN으로 보정하고, Phase 7 직접 영향 단위 테스트와 `ktlintCheck`, `git diff --check` 통과를 확인했다.
|
||||
|
||||
### 8.3 plan·goal 전환과 종료 판정
|
||||
|
||||
- `plan-task.md` Phase 7에 Task 7.5 / `P7-R3`을 추가했다.
|
||||
- **최종 결론:** Medium 1건 수정 완료.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
## 9. 4차 리뷰 — 2026-07-31
|
||||
|
||||
- **대상:** 활성 고정 9개 상한, 10번째·비활성 재고정, 홈 고정 우선 정렬, 최신 오디오 제외와 고정/해제 creator lock 직렬화.
|
||||
- **방법:** `AudioContentService`, member pessimistic lock, `PinContent` 조회 순서, 홈 repository 정렬과 단위·동시성·repository 테스트를 Task 7.1~7.5 및 기존 `REV-P7-001`~`REV-P7-003`과 정적 대조했다. 사용자 지시에 따라 테스트·컴파일·ktlint은 실행하지 않았다.
|
||||
- **결과:** 기존 동시성 보정이 유지되며 신규 확정 발견 사항 없음. Phase 7 후속 Task를 추가하지 않는다.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -34,9 +34,8 @@
|
||||
- New & Hot 점수: 최신성 35%, 상세 조회수 35%, 좋아요 15%, 댓글 수 15%. 상세 조회수는 `creator_content_view_history`의 `content_id`별 count를 사용한다.
|
||||
- 추천 오디오 점수: 상세 조회수 45%, 좋아요 25%, 댓글 수 20%, 최신성 10%.
|
||||
- 최근 댓글 많은 오디오 점수: 댓글 수 80%, 댓글 최신성 20%.
|
||||
- 최근 댓글 많은 오디오 스냅샷 저장 정책: 같은 크리에이터의 오디오 후보가 여러 개이면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다.
|
||||
- 조회수/좋아요/댓글 수는 후보 내 정규화 없이 원본 count를 그대로 사용한다.
|
||||
- 무료 오디오와 포인트 오디오는 가격 조건으로 분리하며, 무료/추천 또는 포인트/추천 섹션 사이의 중복만 허용한다.
|
||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 콘텐츠가 중복 노출될 수 있다.
|
||||
- `isOriginalSeries`는 시리즈 미소속 오디오이면 `false`로 내려준다.
|
||||
- 전체보기/페이징 API, 관리자 화면, 수동 편집 기능은 이번 범위에 포함하지 않는다.
|
||||
|
||||
@@ -357,7 +356,7 @@ interface AudioRecommendationQueryPort {
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/audio/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 15개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 15개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다. 이 문장은 2026-06-23 당시 기준이며 2026-07-31 후속 요구사항 정정에서 두 섹션을 가격 조건으로 분리한다.
|
||||
- RED: 무료 오디오는 `price = 0` 공개 오디오 중 최대 15개, 포인트 오디오는 `isPointAvailable = true` 공개 오디오 중 최대 15개를 반환하고 두 섹션 간 중복을 제거하지 않는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.audio.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: `findFreeAudios`, `findPointAudios`를 구현하고 DB 랜덤 정렬은 기존 repository 관례에 맞춰 `Expressions.numberTemplate(Double::class.java, "function('rand')")` 또는 동일 프로젝트에서 쓰는 랜덤 정렬 방식을 사용한다.
|
||||
- REFACTOR: 무료/포인트 조회가 같은 공통 projection 함수를 사용하게 정리한다.
|
||||
@@ -609,32 +608,9 @@ interface AudioRecommendationQueryPort {
|
||||
- `git diff --check`: 출력 없음.
|
||||
- `./gradlew ktlintCheck`: sandbox 환경에서는 Gradle wrapper lock 파일 접근 제한으로 실패했으나, 승인 후 sandbox 밖에서 재실행해 `BUILD SUCCESSFUL` 확인.
|
||||
|
||||
### Phase 9: 최근 댓글 많은 오디오 크리에이터 중복 저장 정책
|
||||
|
||||
- [x] **Task 9.1: 같은 크리에이터의 최근 댓글 많은 오디오 후보는 최고 점수 1개만 저장**
|
||||
- 작성일: 2026-07-12
|
||||
- Files:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 같은 크리에이터의 오디오 2개가 최근 댓글 많은 오디오 스냅샷 후보에 들어올 때, `AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots` 또는 `DefaultAudioRecommendationQueryRepository.findMostCommentedSnapshots` 결과에 더 높은 최근 댓글 많은 오디오 점수의 1개만 남는 repository/service 테스트를 먼저 작성한다.
|
||||
- 실패 확인:
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
|
||||
- Run: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- GREEN: repository 후보 산정 SQL 또는 service 후보 선택 단계에서 `creatorId`별 최고 점수 후보만 남기고 `RecommendationSnapshotPort.replaceSnapshots(...)`에는 중복 제거가 끝난 후보만 전달한다.
|
||||
- REFACTOR: 점수 동률과 `randomTieBreaker` 정렬 정책이 기존 `score desc`, `randomTieBreaker asc` 규칙을 유지하는지 회귀 테스트로 확인하고, `MOST_COMMENTED_LIMIT` 최대 5개 정책과 SAFE/ALL visibility 조건을 변경하지 않는다.
|
||||
- 기대 결과: `MOST_COMMENTED_AUDIO_SAFE/ALL` 스냅샷에는 같은 크리에이터의 오디오가 최대 1개만 저장되고, 공개 API 응답 스키마는 변경되지 않는다.
|
||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
|
||||
- 2026-07-12 문서화 기록: 이번 변경은 PRD/plan-task 문서만 갱신했으며 source code와 test code는 아직 변경하지 않았다. 따라서 위 RED/GREEN/REFACTOR 구현 task는 미완료 상태로 남긴다.
|
||||
- 2026-07-12 구현 검증 기록:
|
||||
- RED: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest.shouldKeepOnlyHighestScoredMostCommentedSnapshotPerCreator'` 실행 결과 `expected: <[2, 3]> but was: <[2, 1, 3]>`로 실패해 같은 크리에이터의 낮은 점수 후보가 함께 반환되는 기존 동작을 확인했다.
|
||||
- GREEN: `DefaultAudioRecommendationQueryRepository.findMostCommentedSnapshots(...)`가 `creatorId`별 `row_number()`로 최고 점수 후보 1개만 남긴 뒤 최종 limit을 적용하도록 수정했다. 같은 명령 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 회귀: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`와 `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 모두 `BUILD SUCCESSFUL`로 통과했다.
|
||||
|
||||
### Phase 8: 회귀 검증과 문서 기록
|
||||
|
||||
- [x] **Task 8.1: 전체 관련 테스트와 ktlint 실행**
|
||||
- [ ] **Task 8.1: 전체 관련 테스트와 ktlint 실행**
|
||||
- Files:
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
||||
@@ -647,14 +623,8 @@ interface AudioRecommendationQueryPort {
|
||||
- Run: `./gradlew ktlintCheck`
|
||||
- 기대 결과: 모든 관련 테스트와 ktlint가 `BUILD SUCCESSFUL`이다.
|
||||
- 검증 기록: 구현 완료 시 실행 명령, 결과, 실패 시 원인과 수정 내용을 이 task 아래에 한국어로 누적 기록한다.
|
||||
- 2026-07-12 Phase 9 이후 검증 기록:
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*'`: `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.*'`: `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`: `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest`: `BUILD SUCCESSFUL`.
|
||||
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
|
||||
|
||||
- [x] **Task 8.2: 문서/스키마 영향 최종 확인**
|
||||
- [ ] **Task 8.2: 문서/스키마 영향 최종 확인**
|
||||
- Files:
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
||||
- Verify: `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`
|
||||
@@ -668,12 +638,6 @@ interface AudioRecommendationQueryPort {
|
||||
- Run: `./gradlew tasks --all`
|
||||
- 기대 결과: 공개 API endpoint와 응답 필드명이 문서/코드/테스트에서 일치하고, 신규 DB 테이블 DDL이 필요하지 않으며, 코드의 최종 패키지 구조가 `content.recommendation` 기준이고, v2 성인 콘텐츠 조회 정책 계산 경로가 service 메서드로 통일됐음이 확인된다.
|
||||
- 검증 기록: 구현 완료 시 문서와 코드 검색 결과를 이 task 아래에 한국어로 누적 기록한다.
|
||||
- 2026-07-12 Phase 9 이후 검증 기록:
|
||||
- `rg -n "GET /api/v2/audio/recommendations|AudioRecommendationsResponse|NEW_AND_HOT_AUDIO_SAFE|RECOMMENDED_AUDIO_ALL" docs src/main/kotlin src/test/kotlin`로 endpoint, DTO, section enum 참조가 문서/코드/테스트에 남아 있음을 확인했다.
|
||||
- `rg -n "api\.audio\.recommendation|v2\.audio\.recommendation|api/audio/recommendation|v2/audio/recommendation" src/main/kotlin src/test/kotlin`로 production/test의 최종 추천 API 패키지가 `content.recommendation` 기준임을 확인했다.
|
||||
- `rg -n "isAdultVisibleByPolicy|getStoredPreference\([^\n]*\)\.isAdult" src/main/kotlin/kr/co/vividnext/sodalive/v2 src/test/kotlin/kr/co/vividnext/sodalive/v2` 실행 결과 출력 없음으로 v2 경로의 구 성인 콘텐츠 정책 직접 사용이 없음을 확인했다.
|
||||
- `rg -n "isAdultVisibleByPolicy|resolveCountryCodeByPolicy" src/main/kotlin src/test/kotlin` 실행 결과 기존 비-v2 콘텐츠/테스트 경로의 레거시 사용만 남아 있음을 확인했다.
|
||||
- `./gradlew tasks --all`: `BUILD SUCCESSFUL`.
|
||||
|
||||
---
|
||||
|
||||
@@ -718,13 +682,3 @@ interface AudioRecommendationQueryPort {
|
||||
- 2026-06-25 후속 보정: `DefaultAudioRecommendationQueryRepositoryTest.shouldFindNewAndHotSnapshotsWithVisibility`의 score 비교 실패 원인은 repository native SQL의 `timestampdiff(day, c.release_date, :snapshotAt)` 최신성 계산이 DB 날짜 경계 기준에 의존해 `AudioRecommendationScorePolicy`의 24시간 경과 기준 `ChronoUnit.DAYS` 계산과 어긋날 수 있는 점으로 확인했다. `DefaultAudioRecommendationQueryRepository`의 New & Hot/추천 오디오 공개일 최신성 계산을 `floor(timestampdiff(hour, c.release_date, :snapshotAt) / 24)`로 변경해 Kotlin 정책과 일치시켰고, `SAFE` 성인 콘텐츠 제외 조건은 기존 `(:includeAdult = true or c.is_adult = false)` 구현이 올바른 것으로 확인했다. 검증은 `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest.shouldFindNewAndHotSnapshotsWithVisibility'`, `./gradlew test --rerun-tasks --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.domain.AudioRecommendationScorePolicyTest'`, `./gradlew ktlintCheck` 모두 `BUILD SUCCESSFUL`로 완료했다.
|
||||
- 2026-07-12 후속 정책 변경: 콘텐츠 추천 탭의 `latestAudios`, `freeAudios`, `pointAudios` 기본 노출 수를 각각 15개로 확정했다. PRD/plan-task의 섹션별 기본 노출 수와 관련 task 설명을 15개 정책으로 갱신했고, service 테스트는 `AudioRecommendationQueryService` 상수 기준으로 limit 전달을 검증하도록 정리했다. 검증은 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`, `./gradlew --no-daemon ktlintCheck`, `./gradlew --no-daemon tasks --all` 모두 `BUILD SUCCESSFUL`로 완료했다. 단, `tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인 후 재실행했다.
|
||||
- 2026-07-12 후속 정책 변경: 콘텐츠 추천 탭의 `recommendedAudios`와 `RECOMMENDED_AUDIO_SAFE/ALL` 스냅샷 후보 저장 수를 최대 20개로 확정했다. PRD의 섹션별 기본 노출 수와 추천 오디오 요구사항, plan-task의 개요와 Task 4.3 후보 산정 설명을 20개 정책으로 갱신했다. 해당 개수는 정책값으로 이후 변경될 수 있으므로 테스트는 `assertEquals(20, RECOMMENDED_AUDIO_LIMIT)`처럼 숫자 자체를 고정하지 않고, 기존 service/refresh 테스트에서 `RECOMMENDED_AUDIO_LIMIT`가 snapshot 조회와 후보 산정 port 호출에 전달되는 흐름을 검증하는 방식으로 유지했다. 검증은 이전 개수 정책 문구 잔존 검색 결과 없음, `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` `BUILD SUCCESSFUL`, `git diff --check` 출력 없음, `./gradlew --no-daemon tasks --all` `BUILD SUCCESSFUL`로 완료했다. 단, `tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패해 승인 후 재실행했다.
|
||||
- 2026-07-12 문서 전용 후속 정책 기록: `mostCommentedAudios` 스냅샷 저장 시 같은 크리에이터의 오디오 후보는 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장하도록 PRD Feature H와 조회 정책, plan-task 확정 정책과 Phase 9 후속 구현 task를 갱신했다. 이번 작업은 문서만 작성했으므로 source code와 test code는 변경하지 않았고, 관련 RED/GREEN/REFACTOR 구현은 Task 9.1 미완료 상태로 남겼다.
|
||||
|
||||
- 2026-07-12 Phase 9 선행 구현 및 Phase 8 회귀 검증: 최근 댓글 많은 오디오 스냅샷 후보에서 같은 크리에이터의 여러 오디오 중 최고 점수 1개만 남기도록 repository dedupe를 구현했다. RED에서 같은 크리에이터 낮은 점수 후보가 함께 반환되는 실패를 확인했고, GREEN 후 Phase 9 focused test, Phase 8 관련 test/ktlint/tasks 검증이 모두 `BUILD SUCCESSFUL`로 통과했다.
|
||||
|
||||
## 2026-07-31 후속 요구사항 정정
|
||||
|
||||
- 기존 완료 Task와 검증 기록은 당시 구현 기준의 이력으로 보존한다.
|
||||
- 포인트 오디오를 `isPointAvailable == true`만으로 조회하던 계약은 `isPointAvailable == true && price > 0`으로 정정한다.
|
||||
- 무료 오디오의 공개 응답 `isPointAvailable`은 저장값이 true여도 false로 보정한다.
|
||||
- 후속 RED/GREEN/REFACTOR와 완료 증거는 `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`의 `P1-T1`, `P2-T1`에서 추적한다.
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
- `mostCommentedAudios`: 최대 5개
|
||||
- `recommendedAudios`: 최대 20개
|
||||
- 특정 섹션 데이터가 부족하면 가능한 개수만 내려주고 전체 API는 성공 처리한다.
|
||||
- 무료 오디오와 포인트 오디오는 가격 조건상 중복 노출하지 않는다. 무료/추천 또는 포인트/추천 섹션 사이의 중복은 허용한다.
|
||||
- 무료/포인트/추천 오디오 섹션 사이에는 같은 오디오가 중복 노출될 수 있다.
|
||||
|
||||
#### Edge Cases
|
||||
- 한 섹션 조회 실패가 전체 API 실패로 이어질지는 구현 계획 단계에서 기존 v2 통합 조회 API의 로깅/실패 정책과 비교해 결정한다.
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
#### Requirements
|
||||
- 포인트 사용 가능 오디오 중 랜덤으로 최대 15개 조회한다.
|
||||
- 포인트 오디오는 `isPointAvailable = true`이면서 `price > 0`인 공개 오디오로 정의한다.
|
||||
- 포인트 오디오는 `isPointAvailable = true`인 공개 오디오로 정의한다.
|
||||
- Response는 공통 오디오 카드 응답을 사용한다.
|
||||
|
||||
### Feature H. 최근 댓글이 많은 오디오
|
||||
@@ -146,7 +146,6 @@
|
||||
- 최근 7일 댓글 데이터를 기반으로 최종 점수를 산출한다.
|
||||
- 데이터가 없으면 섹션을 표시하지 않도록 빈 배열로 내려준다.
|
||||
- 최대 5개를 표시한다.
|
||||
- 스냅샷 저장 후보에 같은 크리에이터의 오디오가 여러 개 있으면 최근 댓글 많은 오디오 점수가 가장 높은 1개만 저장한다.
|
||||
- 오디오별 가장 최신 댓글 1개의 본문과 글쓴이 프로필 이미지를 함께 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
@@ -285,9 +284,7 @@ data class CommentedAudioResponse(
|
||||
- 최신성 점수의 일수는 날짜 경계가 아니라 시간까지 포함한 24시간 경과 일수 기준으로 계산한다.
|
||||
- New & Hot lazy 보강은 스냅샷 row가 없을 때 Redis marker 기준 KST 날짜별 1회만 시도하고, 보강 후 후보가 0개인 정상 상황에서는 같은 날짜의 다음 조회가 전체 refresh를 반복하지 않는다.
|
||||
- 공통 오디오 카드 응답의 `isOriginalSeries`는 시리즈 미소속 오디오이면 클라이언트 편의를 위해 `false`로 내려준다.
|
||||
- 무료 오디오와 포인트 오디오는 각각 `price == 0`, `isPointAvailable == true && price > 0` 조건으로 분리한다.
|
||||
- 무료/추천 또는 포인트/추천처럼 그 밖의 추천 섹션 간 중복은 서버에서 제거하지 않는다.
|
||||
- `mostCommentedAudios` 스냅샷 저장은 같은 크리에이터의 오디오를 최대 1개만 포함한다. 같은 크리에이터 후보가 여러 개이면 `findMostCommentedSnapshots`의 최근 댓글 많은 오디오 점수가 가장 높은 후보를 남기고, `AudioRecommendationSnapshotRefreshService.replaceMostCommentedSnapshots`는 그 결과만 저장한다.
|
||||
- 무료/포인트/추천 오디오처럼 서로 다른 추천 섹션에 같은 콘텐츠가 동시에 포함되어도 서버에서 중복 제거하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
@@ -303,14 +300,3 @@ data class CommentedAudioResponse(
|
||||
|
||||
## 12. Open Questions
|
||||
- 없음
|
||||
|
||||
---
|
||||
|
||||
## 13. 후속 요구사항 정정
|
||||
|
||||
| 날짜 | 상태 | 정정 내용 | 기준 문서 |
|
||||
|---|---|---|---|
|
||||
| 2026-07-31 | 확정 | 무료 콘텐츠와 포인트 결제 가능 콘텐츠를 분리하고 `pointAudios`에서 `price == 0`을 제외한다 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
|
||||
|
||||
- 공통 `AudioCardResponse.isPointAvailable`은 저장값을 그대로 노출하지 않고 `storedIsPointAvailable && price > 0`으로 응답한다.
|
||||
- 이 정정은 기존 완료 기록을 삭제하지 않으며 후속 구현과 검증은 새 통합 `plan-task.md`에서 추적한다.
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- `type=ORIGINAL`에는 `dayOfWeek`를 적용하지 않는다.
|
||||
- `type=AUDIO`는 `price > 0`인 유료 공개 오디오만 조회한다.
|
||||
- `type=FREE`는 `price == 0`인 무료 공개 오디오만 조회하며 `type=AUDIO` 결과와 겹치지 않는다.
|
||||
- `type=POINT`는 `isPointAvailable == true && price > 0` 조건을 사용하고 목록과 count에 동일하게 적용한다.
|
||||
- `type=POINT`는 `isPointAvailable == true` 조건을 유지하고 `type=AUDIO`의 유료 조건을 상속하지 않는다.
|
||||
- 전체 응답은 `totalCount`, `audios`, `series`, `sort`, `dayOfWeek`, `page`, `size`, `hasNext`를 포함한다.
|
||||
- `AUDIO`, `FREE`, `POINT`는 `audios`만 채우고 `series`는 빈 배열로 내려준다.
|
||||
- `SERIES`, `ORIGINAL`은 `series`만 채우고 `audios`는 빈 배열로 내려준다.
|
||||
@@ -493,7 +493,7 @@ interface MainContentAllQueryPort {
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
||||
- RED: 공개 오디오만 조회하고 비회원은 성인 오디오를 제외하며 차단 관계 크리에이터의 오디오를 제외하는 repository 테스트를 작성한다.
|
||||
- RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다. 이 문장은 2026-06-25 당시 기준이며 2026-07-31 후속 요구사항 정정에서 유료 조건을 추가한다.
|
||||
- RED: `FREE` 조회는 `price == 0`, `POINT` 조회는 `isPointAvailable == true` 필터가 적용되는 테스트를 작성한다.
|
||||
- RED: `LATEST`, `POPULAR`, `PRICE_HIGH`, `PRICE_LOW` 정렬 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
|
||||
- GREEN: `DefaultAudioRecommendationQueryRepository.audioRows(...)`, `DefaultCreatorChannelAudioQueryRepository.findAudioContentRows(...)` 패턴을 참고해 audio count/list를 구현한다.
|
||||
@@ -587,7 +587,7 @@ interface MainContentAllQueryPort {
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
|
||||
- RED: repository 테스트에 유료 오디오와 무료 오디오 fixture를 함께 만들고, `onlyPaid=true`인 `countAudios(...)`와 `findAudios(...)`가 `price == 0` 오디오를 제외하는 실패 테스트를 작성한다.
|
||||
- RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다. 이 문장은 2026-07-10 당시 기준이며 2026-07-31 후속 요구사항 정정에서 유료 조건을 추가한다.
|
||||
- RED: `onlyFree=true`는 기존처럼 `price == 0`만 반환하고, `onlyPointAvailable=true`는 `isPointAvailable == true` 조건을 유지하는 회귀 테스트를 함께 확인한다.
|
||||
- RED: E2E 테스트에서 `GET /api/v2/audio/contents?type=AUDIO`와 type 미지정 기본 조회가 무료 오디오를 반환하지 않는 실패 테스트를 작성한다.
|
||||
- 실패 확인:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest`
|
||||
@@ -667,11 +667,3 @@ interface MainContentAllQueryPort {
|
||||
- GREEN: `./gradlew test` 성공.
|
||||
- GREEN: `./gradlew ktlintCheck` 성공.
|
||||
- GREEN: `git diff --check` 성공.
|
||||
|
||||
## 2026-07-31 후속 요구사항 정정
|
||||
|
||||
- 기존 완료 Task와 검증 기록은 당시 구현 기준의 이력으로 보존한다.
|
||||
- `type=POINT`가 `isPointAvailable == true`만 사용하던 계약은 `isPointAvailable == true && price > 0`으로 정정한다.
|
||||
- POINT 목록, count와 `hasNext` 후보는 같은 보정 조건을 사용하고 무료 오디오는 제외한다.
|
||||
- 무료 오디오의 공개 응답 `isPointAvailable`은 저장값이 true여도 false로 보정한다.
|
||||
- 후속 RED/GREEN/REFACTOR와 완료 증거는 `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`의 `P1-T1`, `P2-T1`에서 추적한다.
|
||||
|
||||
@@ -176,9 +176,9 @@
|
||||
|
||||
#### Requirements
|
||||
- `type=POINT`는 차단 관계가 아닌 모든 크리에이터의 포인트 사용 가능 오디오 콘텐츠를 조회한다.
|
||||
- 포인트 오디오는 `isPointAvailable == true`이면서 `price > 0`인 공개 오디오로 정의한다.
|
||||
- 포인트 오디오는 `isPointAvailable == true`인 공개 오디오로 정의한다.
|
||||
- 공개/차단/성인 콘텐츠 정책과 정렬, 페이징, 전체 개수 산정 방식은 오디오 공통 정책을 따른다.
|
||||
- `type=POINT`는 `isPointAvailable == true && price > 0` 조건을 사용하고 목록과 전체 개수에 동일하게 적용한다.
|
||||
- `type=POINT`는 `type=AUDIO`의 유료 조건을 상속하지 않고 `isPointAvailable == true` 조건만 추가한다.
|
||||
- 응답 목록은 `audios`에 내려주고 `series`는 빈 배열로 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
@@ -317,7 +317,7 @@ data class MainContentSeriesResponse(
|
||||
|
||||
### 구현 주의사항
|
||||
- `type=AUDIO`는 `price > 0` 조건을 적용하고, `type=FREE`는 `price == 0` 조건을 적용해 두 구분의 결과가 겹치지 않게 한다.
|
||||
- `type=POINT`는 `isPointAvailable == true && price > 0` 조건을 사용하며, 목록과 count가 같은 조건 함수를 공유한다.
|
||||
- `type=POINT`는 `isPointAvailable == true` 조건을 유지하며, `AUDIO` 전용 유료 필터를 암묵적으로 재사용하지 않는다.
|
||||
- 기존 추천 탭의 무료/포인트 오디오는 랜덤 조회지만, 전체 탭은 사용자가 선택한 `sort` 기준으로 조회한다.
|
||||
- 기존 legacy 요일별 시리즈 API는 `dayOfWeek` query parameter로 `SeriesPublishedDaysOfWeek` enum을 받으므로 v2 전체 탭도 같은 parameter 이름과 enum 값을 사용한다.
|
||||
- 기존 v2 채널 오디오/시리즈 탭처럼 invalid parameter fallback을 유지하려면 controller에서는 `dayOfWeek: String?`으로 받고 policy/service 경계에서 `SeriesPublishedDaysOfWeek`로 보정한다.
|
||||
@@ -339,14 +339,3 @@ data class MainContentSeriesResponse(
|
||||
|
||||
## 12. Open Questions
|
||||
- 없음. endpoint는 기존 메인 콘텐츠 v2 endpoint 축에 맞춰 `GET /api/v2/audio/contents`로 확정한다.
|
||||
|
||||
---
|
||||
|
||||
## 13. 후속 요구사항 정정
|
||||
|
||||
| 날짜 | 상태 | 정정 내용 | 기준 문서 |
|
||||
|---|---|---|---|
|
||||
| 2026-07-31 | 확정 | `type=POINT`에서 무료 콘텐츠를 제외하고 목록·`totalCount`·`hasNext` 후보에 같은 조건을 적용한다 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
|
||||
|
||||
- `MainContentAudioResponse.isPointAvailable`은 `storedIsPointAvailable && price > 0`으로 응답한다.
|
||||
- 이 정정은 기존 완료 기록을 삭제하지 않으며 후속 구현과 검증은 새 통합 `plan-task.md`에서 추적한다.
|
||||
|
||||
@@ -10,23 +10,6 @@
|
||||
|
||||
---
|
||||
|
||||
## 현재 후속 작업 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1~2 | 완료 | 기존 Task 전체 | 없음 | 없음 |
|
||||
| 3 | 완료 | 기존 `6/6`, 회귀 `1/1` | 없음 | 없음 |
|
||||
| 4 | 완료 | 기존 `5/5`, 회귀 `2/2` | 없음 | 없음 |
|
||||
| 5~5.5 | 완료 | 기존 Task 전체 | 없음 | 없음 |
|
||||
| 6 | 완료 | 기존 `2/2`, 회귀 `2/2` | 없음 | 없음 |
|
||||
| 7 | 완료 | 기존 `1/1`, 회귀 `2/2` | 없음 | 없음 |
|
||||
|
||||
- 2026-07-30 1차 Phase별 리뷰의 확정 항목은 `P3-R1` → `P3-R-GATE` → `P7-R1` → `P7-R2` → `P7-R-GATE` 순서로 실행 완료했다.
|
||||
- 2026-07-30 2차 정적 리뷰의 후속 순서 `P4-R1` → `P4-R-GATE` → `P6-R1` → `P6-R-GATE`는 실행 완료했다.
|
||||
- 2026-07-30 3차 정적 리뷰의 후속 순서 `P4-R2` → `P4-R2-GATE` → `P6-R2` → `P6-R2-GATE`는 실행 완료했다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 확정 사항
|
||||
|
||||
- API endpoint: `GET /api/v2/home/following`
|
||||
@@ -35,7 +18,7 @@
|
||||
- 응답 wrapper: `ApiResponse.ok(...)`
|
||||
- `SecurityConfig`에 `GET /api/v2/home/following` permitAll 설정을 추가한다.
|
||||
- 섹션별 기본 노출 수:
|
||||
- `followingCreators`: 오래된 팔로우순 20개
|
||||
- `followingCreators`: 최신 팔로우순 20개
|
||||
- `onAirLives`: 팔로잉 크리에이터의 현재 진행 중인 라이브 최신순 10개
|
||||
- `recentChats`: DM/AI 채팅 최신순 10개
|
||||
- `monthlySchedules`: 이번 달 오늘 이후 일정 중 오늘과 가까운 순 3개
|
||||
@@ -47,11 +30,6 @@
|
||||
- 최근 소식 상세 값은 타입별 nullable nested DTO로 내려준다. `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다.
|
||||
- `CREATOR_RANKING`은 `creatorRanking.rank`, `creatorRanking.creatorId`, `creatorRanking.nickname`, `creatorRanking.profileImageUrl`을 사용한다. `rankChange`, `isNew`는 사용하지 않는다.
|
||||
- `CONTENT_RANKING`은 `contentRanking.rank`, `contentRanking.contentId`, `contentRanking.contentImageUrl`, `contentRanking.title`을 사용한다.
|
||||
- `CREATOR_RANKING`은 현재 시점에 공개된 최신 `WEEKLY`, `DONE` 크리에이터 랭킹 job 기준 배치만 최근 소식에 표시한다. 신규 배치 공개 전에는 직전 공개 배치를 유지하고, 공개 후에는 이전 배치를 표시하지 않는다.
|
||||
- 최신 공개 배치에 포함되지 않은 팔로잉 크리에이터의 과거 `CREATOR_RANKING`은 보충하지 않는다.
|
||||
- `CONTENT_RANKING`은 같은 `contentId`의 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 최신 항목 하나만 표시한다.
|
||||
- 랭킹 배치 필터와 콘텐츠 중복 제거를 먼저 적용한 뒤 전체 최근 소식 최대 30개를 조회하며, 제외 후 30개 미만이어도 과거·중복 랭킹으로 보충하지 않는다.
|
||||
- `CONTENT_RANKING` inbox 발행과 콘텐츠 랭킹 스냅샷 연동은 이번 보완 범위에 포함하지 않는다.
|
||||
- `AUDIO_CONTENT`, `PHOTO_CONTENT`는 각각 `audioContent`/`photoContent`에 `contentId`, `contentImageUrl`, `title`, `creatorProfileImageUrl`, `creatorNickname`을 담고, 공개 시각은 최상위 `visibleFromAtUtc`를 사용한다.
|
||||
- `COMMUNITY_POST`는 `communityPost`에 `postId`, `creatorProfileImage`, `creatorNickname`, nullable `imageUrl`, `content`, UTC `createdAt`, `likeCount`, `commentCount`를 담는다.
|
||||
- `COMMUNITY_POST` 최근 소식은 무료 커뮤니티 게시글만 발행한다. 유료 커뮤니티 게시글은 inbox row를 생성하지 않는다.
|
||||
@@ -112,11 +90,6 @@
|
||||
- Keep: `docs/20260625_메인_홈_팔로잉_탭_API/create-home-following-news-inbox-table.sql`
|
||||
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
|
||||
|
||||
### Phase 7 후속 보완
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
|
||||
|
||||
---
|
||||
|
||||
## 2. Response data class 초안
|
||||
@@ -532,7 +505,7 @@ data class HomeFollowingNewsInboxRecord(
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingQueryRepository.kt`
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
||||
- RED: 활성 팔로우/활성 크리에이터만 오래된 팔로우순 20개 조회하는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 테스트를 작성한다.
|
||||
- RED: 활성 팔로우/활성 크리에이터만 최신 팔로우순 20개 조회하는 `@DataJpaTest(properties = ["spring.cache.type=none"])` 테스트를 작성한다.
|
||||
- RED: 차단 관계 크리에이터가 제외되는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행, repository 미구현 실패 확인.
|
||||
- GREEN: `creator_following`, `member`, `block_member` 조건을 QueryDSL로 최소 구현한다.
|
||||
@@ -592,48 +565,6 @@ data class HomeFollowingNewsInboxRecord(
|
||||
- 통과 확인: 같은 단일 테스트 명령 실행, PASS 확인.
|
||||
- REFACTOR: mock 기반 race 테스트와 통합 테스트의 책임을 분리해, mock은 분기 검증만 하고 통합 테스트는 실제 Hibernate 세션/트랜잭션 유효성을 검증하도록 정리한다.
|
||||
|
||||
- [x] **Task 3.7: 라이브 성별·크리에이터 입장 제한 회귀 수정**
|
||||
|
||||
**Goal 실행 `P3-R1`:** `REV-P3-001`에 따라 On Air와 라이브 스케줄에서 기존 라이브 입장 제한을 동일하게 적용한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-3-review.md`의 `REV-P3-001` 확정, 기존 Task 3.1~3.6 완료.
|
||||
- **완료 증거:** 제한 불일치 재현 테스트의 RED 확인, 최소 구현 후 focused test와 E2E 통과, 검증 기록 누적.
|
||||
- **범위 밖:** 공개 응답 스키마 변경, 오디오 스케줄 정책 변경, 라이브 입장 정책 자체의 재정의.
|
||||
- **Files:**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
|
||||
- [x] **RED:** 회원 본인인증 성별을 우선한 effective gender와 크리에이터 회원 여부가 query port에 전달되는 테스트를 추가하고 실패를 확인한다.
|
||||
- [x] **RED:** 성별 제한이 맞지 않는 라이브와 `isAvailableJoinCreator=false`인 타 크리에이터 라이브가 On Air 및 라이브 스케줄에서 제외되는 repository 테스트를 추가하고 실패를 확인한다.
|
||||
- [x] **GREEN:** 기존 `LiveRoomQueryRepositoryImpl`/`DefaultCreatorChannelHomeQueryRepository`의
|
||||
`genderRestriction` 및 크리에이터 입장 제한 조건을 재사용 가능한 최소 QueryDSL 조건으로 적용한다.
|
||||
- [x] **GREEN 확인:** 아래 focused test를 실행해 신규 회귀와 기존 섹션 조회가 통과하는지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
|
||||
```
|
||||
|
||||
- [x] **REFACTOR:** live 조건만 정리하고 공개 DTO와 최근 소식 로직은 변경하지 않는다.
|
||||
|
||||
#### Phase 3 리뷰 회귀 Gate
|
||||
|
||||
**Goal 실행 `P3-R-GATE`:** Phase 3의 라이브 입장 정책 수정과 팔로잉 탭 조립 회귀를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P3-R1` 완료.
|
||||
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-3-review.md`에 누적됨.
|
||||
- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest"
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
|
||||
./gradlew --no-daemon ktlintCheck
|
||||
```
|
||||
|
||||
### Phase 4: 최근 소식 Publish Service와 기존 이벤트 연결
|
||||
|
||||
- [x] **Task 4.1: sourceKey 생성 정책 구현**
|
||||
@@ -700,96 +631,6 @@ data class HomeFollowingNewsInboxRecord(
|
||||
- 통과 확인: 위 두 단일 테스트 명령 재실행, PASS 확인.
|
||||
- REFACTOR: 결제/수정/관리자 저장 중 실제 공개 이벤트가 아닌 경로에서 중복 발행하지 않도록 sourceKey unique와 호출 지점을 함께 점검한다.
|
||||
|
||||
- [x] **Task 4.6: 언팔로우·재팔로우와 최근 소식 발행 동시성 보장**
|
||||
|
||||
**Goal 실행 `P4-R1`:** `REV-P4-001`에 따라 follower 판정부터 inbox insert까지의 경계를 팔로우 상태 변경과 직렬화해, 언팔로우 이전 이벤트가 재팔로우 후 새 소식으로 노출되지 않게 한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-4-review.md`의 `REV-P4-001` 확정, 기존 Task 4.1~4.5 완료.
|
||||
- **완료 증거:** 두 트랜잭션을 제어한 회귀 테스트의 RED 확인, 최소 구현 후 focused test 통과, 실제 결과를 `## 6. 검증 기록`과 `reviews/phase-4-review.md`에 누적.
|
||||
- **범위 밖:** 외부 MQ/outbox/worker 도입, inbox 공개 API·DDL 변경, 팔로우 알림 정책 변경, 관련 없는 publish 경로 리팩터링.
|
||||
- **Files:**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/port/out/HomeFollowingNewsInboxPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxJpaRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapter.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowingRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/HomeFollowingNewsInboxPersistenceAdapterTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingNewsPublishServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
|
||||
- [x] **RED:** publish가 active follower를 읽은 뒤 insert하기 전에 같은 회원이 언팔로우하는 순서를 두 트랜잭션과 latch로 고정한다. 언팔로우 완료 후 publish insert가 늦게 완료되고 다시 팔로우하면 언팔로우 이전 이벤트가 조회되는 현재 실패를 재현한다.
|
||||
- [x] **GREEN:** follower 판정과 inbox insert를 하나의 트랜잭션 경계로 묶고 해당 `creator_following` row를 팔로우/언팔로우 상태 변경과 동일한 잠금 순서로 직렬화한다. publish가 먼저 완료되면 뒤이은 언팔로우가 새 row까지 비활성화하고, 언팔로우가 먼저 완료되면 publish가 해당 회원 row를 생성하지 않아야 한다.
|
||||
- [x] **GREEN:** 재팔로우는 기존 비활성 inbox를 복구하지 않으며, 재팔로우 이후 발생한 새 이벤트만 active row로 생성되는 기존 정책을 유지한다.
|
||||
- [x] **GREEN 확인:** 아래 focused test를 실행해 동시성 회귀와 기존 중복 방지·발행·언팔로우 테스트가 모두 통과하는지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
|
||||
```
|
||||
|
||||
- [x] **REFACTOR:** H2/MySQL에서 검증 가능한 기존 JPA 경로와 공개 port 범위를 유지하고, 동시성 보장에 필요하지 않은 계층·설정·DDL을 추가하지 않는다.
|
||||
|
||||
#### Phase 4 Review Gate
|
||||
|
||||
**Goal 실행 `P4-R-GATE`:** `P4-R1`의 동시성 보장과 기존 최근 소식 발행·조회 계약을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P4-R1` 완료.
|
||||
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-4-review.md`에 누적됨.
|
||||
- **범위 밖:** 테스트 삭제·완화, 공개 API·DDL 확장, 이번 회귀와 무관한 코드 수정.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
|
||||
./gradlew --no-daemon ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** publish와 언팔로우의 완료 순서와 무관하게 최종 언팔로우 상태에서는 해당 creator의 active inbox가 없고, 재팔로우 후에는 재팔로우 이후 이벤트만 노출된다.
|
||||
|
||||
- 전체 `./gradlew test`는 follower fan-out과 팔로우 상태 변경 경계만 보완하는 국소 수정이므로 기본 Gate에서 생략한다. focused test 또는 E2E가 공유 트랜잭션 경계의 회귀를 충분히 판정하지 못하면 전체 회귀로 확장하고 근거와 결과를 기록한다.
|
||||
|
||||
- [x] **Task 4.7: 통합 팔로우 API의 `isActive=false` 경로에서 inbox 비활성화**
|
||||
|
||||
**Goal 실행 `P4-R2`:** `REV-P4-002`에 따라 `creatorFollow(..., isActive=false)`가 새 active 팔로우를 만들지 않고, 기존 관계의 active inbox를 비활성화해 재팔로우 후 과거 소식이 다시 노출되지 않게 한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-4-review.md`의 `REV-P4-002` 확정, `P4-R1`과 `P4-R-GATE` 완료.
|
||||
- **완료 증거:** 통합 팔로우 API 경로의 회귀 테스트 RED 확인, 최소 구현 후 `MemberServiceTest` 통과, Phase 4 회귀 Gate와 검증 기록 누적.
|
||||
- **범위 밖:** `CreatorFollowRequest`·controller 공개 스키마 변경, 알림 설정 정책 변경, 외부 MQ/outbox 도입, 전용 `creatorUnFollow(...)` 경로 리팩터링.
|
||||
- **Files:**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/MemberServiceTest.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/MemberController.kt`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/member/following/CreatorFollowRequest.kt`
|
||||
- **Interfaces:**
|
||||
- Consumes: `MemberService.creatorFollow(creatorId: Long, isNotify: Boolean, isActive: Boolean, memberId: Long)`, `HomeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(memberId: Long, creatorId: Long)`.
|
||||
- Produces: 기존 public method·request 계약을 바꾸지 않고 `isActive=false`일 때 전용 언팔로우 경로와 같은 inbox 최종 상태.
|
||||
- [x] **RED:** 관계가 없는 회원이 `creatorFollow(..., isActive=false)`를 호출해도 새 active 팔로우가 생성되지 않는지 검증한다.
|
||||
- [x] **RED:** active 팔로우와 active inbox를 준비한 뒤 `creatorFollow(..., isActive=false)`를 호출하고 관계와 inbox가 모두 inactive인지 검증한다. 이어 `creatorFollow(..., isActive=true)`를 호출해 기존 inbox가 inactive로 유지되는지 검증한다.
|
||||
- [x] **RED 확인:** 아래 단일 테스트를 실행해 `creatorFollow(..., isActive=false)` 직후 inbox가 여전히 active인 assertion 실패를 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
|
||||
```
|
||||
|
||||
- [x] **GREEN:** 관계가 없고 `isActive=false`면 전용 `creatorUnFollow(...)`와 동일하게 새 active 관계를 만들지 않는다. 기존 `creator_following` row에 `isActive=false`를 반영하는 같은 트랜잭션에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다. `isActive=true`인 알림 변경·재팔로우 경로에서는 기존 비활성 inbox를 복구하지 않는다.
|
||||
- [x] **GREEN 확인:** 같은 `MemberServiceTest` 명령을 재실행해 전용 언팔로우와 통합 팔로우 API 경로의 회귀가 모두 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 두 공개 method의 계약과 비관적 잠금 순서를 유지하고, 공통화가 한 번만 쓰이는 추상화나 신규 계층은 추가하지 않는다.
|
||||
|
||||
#### Phase 4 3차 리뷰 회귀 Gate
|
||||
|
||||
**Goal 실행 `P4-R2-GATE`:** `P4-R2`의 통합 언팔로우 경로와 기존 publish·언팔로우 동시성 계약을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P4-R2` 완료.
|
||||
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-4-review.md`에 누적됨.
|
||||
- **범위 밖:** 테스트 삭제·완화, 공개 API·DDL 변경, 이번 회귀와 무관한 코드 수정.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"
|
||||
./gradlew --no-daemon ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** 전용 언팔로우와 `creatorFollow(..., isActive=false)` 어느 경로를 사용해도 새 active 팔로우가 생기지 않고 기존 active inbox가 비활성화되며, 재팔로우는 과거 inbox를 복구하지 않는다.
|
||||
|
||||
### Phase 5: Facade 통합, 최근 대화 재사용, API End-to-End
|
||||
|
||||
- [x] **Task 5.1: HomeFollowingFacade 통합**
|
||||
@@ -931,178 +772,6 @@ data class HomeFollowingNewsInboxRecord(
|
||||
- 기대 결과: 두 명령 모두 `BUILD SUCCESSFUL`
|
||||
- 검증 결과 기록: 각 task 완료 시 실행 명령, 결과, 실패 시 원인과 후속 조치를 이 문서의 해당 task 아래에 한국어로 누적 기록한다.
|
||||
|
||||
- [x] **Task 6.3: `newsId` 공개 계약을 현재 구현과 동기화**
|
||||
|
||||
**Goal 실행 `P6-R1`:** `REV-P6-001`에 따라 `newsId`가 `home_following_news_inbox.id`의 10진 문자열임을 PRD에 명시하고, `scheduleId`의 `{TYPE}:{targetId}` 계약과 분리한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-6-review.md`의 `REV-P6-001` 확정, 현재 repository/test/E2E의 `newsId` 동작 확인.
|
||||
- **완료 증거:** PRD 식별자 정책과 최근 소식 정렬·동률 해소 설명이 현재 구현과 일치하고, 대체 검증 결과를 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적.
|
||||
- **범위 밖:** 공개 응답 필드 추가·삭제, runtime 코드·테스트·DDL 변경, 기존 `newsId` 값 형식 변경.
|
||||
- **Files:**
|
||||
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
|
||||
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
|
||||
- **TDD 예외 사유:** 현재 공개 동작을 변경하지 않는 문서 계약 정합성 보완이며 신규 production behavior가 없다.
|
||||
- [x] `prd.md`의 공통 식별자 설명에서 `scheduleId`만 `{TYPE}:{targetId}` 형식으로 유지하고, `newsId`는 inbox PK의 10진 문자열이며 같은 노출 시각의 정렬·`CONTENT_RANKING` 동률 해소에 사용한다고 명시한다.
|
||||
- [x] 아래 검색으로 PRD, repository, repository test, E2E의 `newsId` 형식을 정적으로 대조한다.
|
||||
|
||||
```bash
|
||||
rg -n "newsId|scheduleId|home_following_news_inbox\\.id" docs/20260625_메인_홈_팔로잉_탭_API/prd.md src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt
|
||||
git diff --check
|
||||
```
|
||||
|
||||
- [x] 문서 명령 유효성만 확인하는 `./gradlew tasks --all`을 실행해 `BUILD SUCCESSFUL`을 확인한다. 컴파일과 테스트는 실행하지 않는다.
|
||||
|
||||
#### Phase 6 Review Gate
|
||||
|
||||
**Goal 실행 `P6-R-GATE`:** `P6-R1`의 식별자 계약 정합성과 공개 동작 무변경을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P6-R1` 완료.
|
||||
- **완료 증거:** 정적 검색과 `git diff --check`, `./gradlew tasks --all` 결과가 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적됨.
|
||||
- **범위 밖:** 컴파일·테스트 재실행, runtime 코드·테스트·DDL 변경.
|
||||
|
||||
**Expected:** `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 inbox PK 10진 문자열로 문서와 현재 구현이 일치하며 API 응답 동작은 변경되지 않는다.
|
||||
|
||||
- [x] **Task 6.4: 크리에이터 랭킹 최신 공개 배치 기준 문서 동기화**
|
||||
|
||||
**Goal 실행 `P6-R2`:** `REV-P6-002`에 따라 PRD의 최신 `CREATOR_RANKING` 배치 판정 기준을 현재 구현의 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 정책과 일치시킨다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-6-review.md`의 `REV-P6-002` 확정, `P7-R1`과 `P7-R-GATE` 완료.
|
||||
- **완료 증거:** PRD Feature F와 기술 제약의 최신 배치 설명이 Task 7.2 및 repository 조건과 일치하고, 정적 검색 결과를 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적.
|
||||
- **범위 밖:** runtime 조회 조건·테스트·DDL·공개 응답 변경, 랭킹 집계·점수·공개 시각 정책 변경.
|
||||
- **Files:**
|
||||
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`
|
||||
- Modify: `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`
|
||||
- Verify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
||||
- **TDD 예외 사유:** 현재 공개 동작을 바꾸지 않는 문서 정합성 보완이며 신규 production behavior가 없다.
|
||||
- [x] `prd.md`의 두 최신 공개 배치 설명을 적용 가능한 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 시각 우선으로 수정한다.
|
||||
- [x] 적용 가능한 `DONE` job이 전혀 없는 legacy/backfill 데이터에서만 최신 공개 snapshot 시각을 fallback으로 사용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시한다.
|
||||
- [x] 아래 검색으로 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트를 정적으로 대조한다.
|
||||
|
||||
```bash
|
||||
rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" docs/20260625_메인_홈_팔로잉_탭_API/prd.md docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt
|
||||
git diff --check
|
||||
```
|
||||
|
||||
- [x] 문서 명령 유효성만 확인하는 `./gradlew tasks --all`을 실행해 `BUILD SUCCESSFUL`을 확인한다. 컴파일과 테스트는 실행하지 않는다.
|
||||
|
||||
#### Phase 6 3차 리뷰 회귀 Gate
|
||||
|
||||
**Goal 실행 `P6-R2-GATE`:** `P6-R2`의 최신 공개 배치 문서 정합성과 runtime 무변경을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P6-R2` 완료.
|
||||
- **완료 증거:** 정적 검색과 `git diff --check`, `./gradlew tasks --all` 결과가 `## 6. 검증 기록`과 `reviews/phase-6-review.md`에 누적됨.
|
||||
- **범위 밖:** 컴파일·테스트 실행, runtime 코드·테스트·DDL 변경.
|
||||
|
||||
**Expected:** PRD가 최신 완료 job의 빈 배치를 포함한 현재 판정과 일치하고, 적용 가능한 `DONE` job이 없는 경우에만 snapshot fallback을 허용한다.
|
||||
|
||||
### Phase 7: 최근 소식 랭킹 조회 정책 보완
|
||||
|
||||
**Phase 결과:** 최근 소식에서 크리에이터 랭킹은 최신 공개 배치만, 콘텐츠 랭킹은 동일 콘텐츠의 최신 소식 하나만 표시된다.
|
||||
|
||||
**선행조건:** Phase 1~6 완료와 2026-07-30 확정 요구사항 반영.
|
||||
|
||||
**Phase 완료 조건:** `P7-T1`과 `P7-GATE` 완료, focused test·직접 영향 회귀·문서 검증 기록 누적.
|
||||
|
||||
- [x] **Task 7.1: 최신 크리에이터 랭킹 배치 필터와 콘텐츠 랭킹 중복 제거**
|
||||
|
||||
**Goal 실행 `P7-T1`:** `findRecentNews(...)`가 랭킹 정책을 최대 30개 제한 전에 적용해 최신 크리에이터 배치와 콘텐츠별 최신 랭킹 소식만 반환한다.
|
||||
|
||||
- **시작 조건:** PRD Feature F와 Decision Log의 2026-07-30 결정 확인.
|
||||
- **완료 증거:** 아래 TDD 체크박스 전체 완료, focused test 통과, 실제 결과를 `## 6. 검증 기록`에 누적.
|
||||
- **범위 밖:** `CONTENT_RANKING` inbox 발행, 콘텐츠 랭킹 스냅샷 연동, 공개 API 스키마·DDL 변경, 다른 최근 소식 타입 리팩터링.
|
||||
- **Files:**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
||||
- **Interfaces:**
|
||||
- Consumes: `creator_ranking_snapshot.ranking_type`, `visible_from_at`, `home_following_news_inbox.news_type`, `target_id`, `visible_from_at_utc`, `id`.
|
||||
- Produces: 기존 `HomeFollowingQueryPort.findRecentNews(memberId, canViewAdultContent, nowUtc, limit)` 계약을 변경하지 않은 필터링 결과.
|
||||
- [x] **RED:** `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews` 테스트에 직전·최신 공개 크리에이터 랭킹 스냅샷과 inbox를 저장한다. 신규 배치 공개 전에는 직전 배치가 조회되고, 공개 후에는 최신 배치만 조회되며, 최신 배치에 없는 크리에이터의 과거 순위는 제외되는지 검증한다.
|
||||
- [x] **RED:** `shouldFindLatestContentRankingNewsPerContentBeforeLimit` 테스트에 동일 `contentId`의 서로 다른 `visibleFromAtUtc` row, 같은 시각의 서로 다른 `newsId` row, 다른 콘텐츠 row를 저장한다. `visibleFromAtUtc desc`, `newsId desc` 기준 최신 row 하나만 남고 중복 제거 후 `limit`까지 다른 고유 소식이 채워지는지 검증한다.
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 과거 크리에이터 배치 노출, `CONTENT_RANKING` 미조립 또는 동일 콘텐츠 중복 노출 때문에 assertion이 실패하는지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `DefaultHomeFollowingQueryRepository.findRecentNews(...)`에 `creator_ranking_snapshot`의 `rankingType = WEEKLY`, `visibleFromAtUtc <= nowUtc` 중 최신 공개 시각과 일치하는 `CREATOR_RANKING`만 허용하는 조건을 추가한다. 공개 스냅샷이 없으면 `CREATOR_RANKING`을 반환하지 않는다.
|
||||
- [x] **GREEN:** `CONTENT_RANKING`을 활성 오디오 콘텐츠 target과 조인·조립하고, 동일 회원·동일 `targetId`의 노출 가능한 더 최신 row가 존재하지 않는 항목만 남기는 조건을 추가한다. 최신 비교는 `visibleFromAtUtc`, 동률이면 inbox `id`를 사용하며 이 조건을 전체 `limit`보다 먼저 적용한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test 명령을 다시 실행해 두 회귀 테스트와 기존 repository 테스트가 모두 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 이번 Task가 추가한 QueryDSL alias와 조건 함수만 정리하고, 공개 port/DTO·DDL은 변경하지 않는다. 아래 직접 영향 회귀와 lint를 실행해 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
|
||||
./gradlew --no-daemon ktlintCheck
|
||||
```
|
||||
|
||||
#### Phase 7 Gate
|
||||
|
||||
**Goal 실행 `P7-GATE`:** Phase 7의 최신 배치·콘텐츠 중복 제거 정책과 기존 팔로잉 탭 API 회귀를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P7-T1` 완료.
|
||||
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`에 누적됨.
|
||||
- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화, `CONTENT_RANKING` 발행 기능 추가.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
|
||||
./gradlew --no-daemon ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** 과거 크리에이터 랭킹 배치와 동일 콘텐츠의 중복 랭킹이 최근 소식에 포함되지 않고, 기존 팔로잉 탭 응답 계약과 다른 소식 타입 회귀가 없다.
|
||||
|
||||
- 전체 `./gradlew test`는 조회 repository 한 파일과 해당 테스트만 변경하는 국소 보완이므로 기본 Gate에서 생략한다. focused test 또는 E2E에서 공유 경계 회귀를 판단할 수 없는 실패가 발생하면 전체 회귀로 확장하고 근거와 결과를 기록한다.
|
||||
|
||||
- [x] **Task 7.2: 빈 최신 크리에이터 랭킹 배치에서 과거 소식 제외**
|
||||
|
||||
**Goal 실행 `P7-R1`:** `REV-P7-001`에 따라 최신 완료 배치의 결과가 0건이어도 이전 배치의 크리에이터 랭킹 소식을 노출하지 않는다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-7-review.md`의 `REV-P7-001` 확정, 기존 `P7-GATE` 완료.
|
||||
- **완료 증거:** 빈 최신 배치 재현 테스트의 RED 확인, 최소 구현 후 repository/E2E 회귀 통과, 검증 기록 누적.
|
||||
- **범위 밖:** 랭킹 집계·점수 정책 변경, `CONTENT_RANKING` 발행, 신규 테이블/공개 API 변경.
|
||||
- **Files:**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
|
||||
- [x] **RED:** 직전 공개 스냅샷/inbox와 더 최신 `WEEKLY`, `DONE` snapshot job을 저장하되 최신 배치 스냅샷은 0건으로 두고, 과거 `CREATOR_RANKING`이 제외되어야 하는 테스트를 추가해 실패를 확인한다.
|
||||
- [x] **GREEN:** 최신 공개 배치 식별은 결과 row가 없어도 남는 `creator_ranking_snapshot_job`의 `WEEKLY`, `DONE`,
|
||||
`visibleFromAtUtc <= nowUtc` 최신 시각을 기준으로 한다. 기존 snapshot만 있고 적용 가능한 job 이력이 전혀 없는 데이터의 호환 fallback이 필요하면 그 경우로만 제한하며, 빈 `DONE` 배치에서는 과거 snapshot으로 fallback하지 않는다.
|
||||
- [x] **GREEN 확인:** repository focused test를 재실행해 최신 빈 배치와 기존 신규 공개 전/후 정책이 모두 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 최신 배치 조건 helper와 필요한 QueryDSL alias만 정리하고 port/DTO/DDL은 변경하지 않는다.
|
||||
|
||||
- [x] **Task 7.3: 노출 가능한 콘텐츠 랭킹 row만 최신 중복 제거 기준으로 사용**
|
||||
|
||||
**Goal 실행 `P7-R2`:** `REV-P7-002`에 따라 더 최신이지만 노출 불가능한 `CONTENT_RANKING` row가 이전의 노출 가능한 row를 가리지 않도록 한다.
|
||||
|
||||
- **시작 조건:** `P7-R1` 완료와 `reviews/phase-7-review.md`의 `REV-P7-002` 확정.
|
||||
- **완료 증거:** `rank=null` 또는 회원에게 노출 불가한 최신 row 재현 테스트의 RED 확인, 최소 구현 후 focused test 통과, 검증 기록 누적.
|
||||
- **범위 밖:** 콘텐츠 랭킹 발행/스냅샷 연동, 콘텐츠 동일성 기준 변경, 공개 응답 스키마 변경.
|
||||
- **Files:**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepositoryTest.kt`
|
||||
- [x] **RED:** 같은 `memberId/targetId`에서 이전 row는 노출 가능하고 더 최신 row는 `rank=null`인 fixture를 추가해 이전 row가 유지되어야 하는 테스트를 작성하고 실패를 확인한다.
|
||||
- [x] **RED:** 비성인 회원에게 더 최신 inbox row만 `isAdult=true`인 경우에도 이전의 노출 가능한 row가 유지되는 테스트를 작성하고 실패를 확인한다.
|
||||
- [x] **GREEN:** `latestContentRankingNewsCondition(...)`의 newer-row 판정에 `rank is not null`, 회원별 inbox 성인 조건 등 row마다 달라질 수 있는 외부 조회와 동일한 노출 조건을 적용한다.
|
||||
- [x] **GREEN 확인:** repository focused test를 재실행해 노출 불가 newer row와 기존 최신 시각/id tie-break 회귀가 모두 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 중복 제거 조건만 최소 수정하고 다른 최근 소식 타입의 조인·조립은 변경하지 않는다.
|
||||
|
||||
#### Phase 7 리뷰 회귀 Gate
|
||||
|
||||
**Goal 실행 `P7-R-GATE`:** Phase 7 리뷰에서 확정된 빈 배치와 노출 가능 row 기준을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P7-R1`, `P7-R2` 완료.
|
||||
- **완료 증거:** 아래 명령이 모두 `BUILD SUCCESSFUL`이고 결과가 `## 6. 검증 기록`과 `reviews/phase-7-review.md`에 누적됨.
|
||||
- **범위 밖:** 전체 회귀 실패와 무관한 코드 수정, 테스트 삭제·완화, 신규 발행 기능.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"
|
||||
./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
|
||||
./gradlew --no-daemon ktlintCheck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 구현 순서 요약
|
||||
@@ -1114,11 +783,6 @@ data class HomeFollowingNewsInboxRecord(
|
||||
5. publish service를 만들고 언팔로우/랭킹/콘텐츠/커뮤니티 이벤트에 연결한다.
|
||||
6. `FollowingNewsResponse`를 타입별 nested DTO 계약으로 전환하고 무료 커뮤니티 게시글만 `COMMUNITY_POST` 최근 소식을 발행하도록 보강한다.
|
||||
7. End-to-End 테스트와 전체 회귀 검증을 수행한다.
|
||||
8. 후속 회귀 수정으로 `CREATOR_RANKING` 최신 공개 배치 필터와 `CONTENT_RANKING` 콘텐츠별 최신 소식 중복 제거를 적용한다.
|
||||
9. Phase 3 리뷰 회귀 수정으로 On Air와 라이브 스케줄에 기존 라이브 입장 제한을 적용한다.
|
||||
10. Phase 7 리뷰 회귀 수정으로 빈 최신 랭킹 배치와 노출 불가 콘텐츠 랭킹 newer row를 처리한다.
|
||||
11. Phase 4 리뷰 회귀 수정으로 publish와 팔로우 상태 변경의 동시성 경계를 보장한다.
|
||||
12. Phase 6 리뷰 후속 문서 수정으로 `newsId` 식별자 계약을 현재 구현과 동기화한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -1195,102 +859,3 @@ data class HomeFollowingNewsInboxRecord(
|
||||
- 리뷰 보완 후 Phase 5.5 집중 회귀 명령 `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.dto.HomeFollowingTabResponseTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest" --tests "kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 보완 후 `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 응답 단순화: `HomeFollowingContentNews`/`FollowingContentNewsResponse`의 `releaseDate`를 제거하고 콘텐츠 공개 시각은 최상위 `visibleFromAtUtc`를 사용하도록 계약과 테스트를 갱신했다.
|
||||
|
||||
- 2026-07-12 팔로잉 크리에이터 정렬 변경 영향 확인:
|
||||
- `DefaultHomeFollowingQueryRepository.findFollowingCreators`의 정렬을 `creatorFollowing.createdAt.desc()`에서 `creatorFollowing.createdAt.asc()`로 변경한 계약에 맞춰 PRD/계획 문서의 `followingCreators` 정렬 설명을 오래된 팔로우순으로 갱신했다.
|
||||
- 직접 검색 결과 정렬 계약을 명시한 위치는 `docs/20260625_메인_홈_팔로잉_탭_API/prd.md`, `docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`, `DefaultHomeFollowingQueryRepositoryTest.shouldFindActiveFollowingCreatorsByOldestFollowOrder`였고, 호출부는 `HomeFollowingQueryService`가 반환 순서를 그대로 조립하는 구조로 확인했다.
|
||||
- `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- `./gradlew --no-daemon tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
|
||||
- 2026-07-30 Phase 7 요구사항·계획 문서 반영:
|
||||
- PRD에 `CREATOR_RANKING` 최신 공개 `WEEKLY` 배치 한정, 신규 배치 공개 전 직전 배치 유지, 최신 배치에 없는 크리에이터의 과거 순위 미보충 정책을 추가했다.
|
||||
- PRD에 `CONTENT_RANKING`의 동일 `contentId`별 최신 항목 한 건 조회와 발행 기능 제외 정책을 추가했다.
|
||||
- `plan-task.md`에 미완료 회귀 수정 Goal `P7-T1`과 Phase Gate `P7-GATE`를 추가하고 RED → GREEN → REFACTOR, focused test, 직접 영향 E2E, lint 검증 명령을 연결했다.
|
||||
- `rg -n -S "최신 공개|과거.*랭킹|동일.*contentId|CONTENT_RANKING.*발행|P7-T1|P7-GATE|Task 7\\.1|Phase 7" docs/20260625_메인_홈_팔로잉_탭_API/prd.md docs/20260625_메인_홈_팔로잉_탭_API/plan-task.md`로 두 정책과 Goal 연결을 확인했다.
|
||||
- `git diff --check` 실행 결과 오류가 없었다.
|
||||
- `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle 캐시 접근 제한으로 실패했으며, 동일 명령을 승인된 권한으로 재실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 이번 요청은 문서 반영만 수행했으므로 `P7-T1` 구현 체크박스와 Phase 7 상태는 대기로 유지했다.
|
||||
|
||||
- 2026-07-30 Phase 7 구현 검증:
|
||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews`, `shouldFindLatestContentRankingNewsPerContentBeforeLimit` assertion 실패로 `BUILD FAILED`.
|
||||
- `DefaultHomeFollowingQueryRepository.findRecentNews(...)`에 최신 공개 `WEEKLY` 크리에이터 랭킹 배치 필터, `CONTENT_RANKING` 활성 오디오 target 조립, 동일 contentId 최신 row 조건을 추가했다. 공개 port/DTO/DDL은 변경하지 않았다.
|
||||
- GREEN 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 직접 영향 E2E 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 최초 실행은 E2E fixture에 최신 공개 `creator_ranking_snapshot`이 없어 `BUILD FAILED`; fixture 보강 후 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- Gate lint 확인: `./gradlew --no-daemon ktlintCheck` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
|
||||
- 2026-07-30 Phase 1~7 정적 코드 리뷰:
|
||||
- 사용자 요청에 따라 컴파일과 테스트는 재실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 검증 기록을 정적으로 대조했다.
|
||||
- 문서 명령 유효성 확인을 위한 `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle cache 접근 제한으로 실패했고,
|
||||
승인된 권한으로 같은 명령을 재실행해 `BUILD SUCCESSFUL`을 확인했다. 이 명령은 컴파일과 테스트를 실행하지 않는다.
|
||||
- Phase별 판정은 `docs/20260625_메인_홈_팔로잉_탭_API/reviews/phase-1-review.md`부터
|
||||
`phase-7-review.md`까지 기록했으며, Phase 5.5는 `phase-5.5-review.md`로 분리했다.
|
||||
- Phase 3의 라이브 입장 제한 누락 `REV-P3-001`을 확정하고 `P3-R1`, `P3-R-GATE`를 추가했다.
|
||||
- Phase 7의 빈 최신 크리에이터 랭킹 배치 판정 `REV-P7-001`과 노출 불가 콘텐츠 랭킹 newer row 판정
|
||||
`REV-P7-002`를 확정하고 `P7-R1`, `P7-R2`, `P7-R-GATE`를 추가했다.
|
||||
- Phase 1, 2, 4, 5, 5.5, 6은 이번 정적 리뷰 범위에서 확정 발견 사항이 없다.
|
||||
|
||||
- 2026-07-30 Phase 3·7 리뷰 보완 구현 검증:
|
||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 `findOnAirLives`/`findMonthlySchedules`의 effective gender·creator 여부 인자 미반영 컴파일 오류로 `BUILD FAILED`.
|
||||
- `HomeFollowingQueryService`에서 본인인증 성별 우선 effective gender와 크리에이터 여부를 port로 전달하고, `DefaultHomeFollowingQueryRepository`의 On Air·라이브 스케줄에 `genderRestriction`/`isAvailableJoinCreator` 조건을 적용했다.
|
||||
- `CREATOR_RANKING` 최신 배치 식별을 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE` 공개 시각 기준으로 보강해 최신 빈 배치에서 과거 랭킹을 보충하지 않도록 했다.
|
||||
- `CONTENT_RANKING` 중복 제거의 newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건을 추가해 노출 불가 row가 이전 노출 가능 row를 가리지 않도록 했다.
|
||||
- GREEN 확인: 위 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- E2E 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- Gate lint 확인: `./gradlew --no-daemon ktlintCheck` 최초 실행은 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트: Oracle reviewer가 P3-R1, P7-R1, P7-R2, 공개 API/DDL 비확장, 테스트/문서 기록을 검토했고 Critical/Important blocker 없음으로 승인했다.
|
||||
|
||||
- 2026-07-30 Phase 1~7 2차 정적 코드 리뷰:
|
||||
- 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 검증 기록을 다시 대조했다.
|
||||
- Phase 3의 `P3-R1`과 Phase 7의 `P7-R1`·`P7-R2` 반영 코드 및 기존 Gate 성공 기록을 정적으로 재확인했다.
|
||||
- Phase 4에서 active follower 조회와 inbox insert 사이에 언팔로우가 완료되면 뒤늦게 active inbox가 생성되고, 이후 재팔로우 시 언팔로우 이전 이벤트가 노출될 수 있는 `REV-P4-001`을 확정했다.
|
||||
- Phase 6에서 PRD는 `newsId`를 `{TYPE}:{targetId}`로 설명하지만 repository와 테스트는 inbox PK의 10진 문자열을 사용하는 `REV-P6-001`을 확정했다.
|
||||
- 후속 작업으로 Task 4.6 / `P4-R1` / `P4-R-GATE`와 Task 6.3 / `P6-R1` / `P6-R-GATE`를 추가했다.
|
||||
- Phase 1, 2, 3, 5, 5.5, 7에서는 이번 2차 정적 리뷰의 신규 확정 발견 사항이 없다.
|
||||
- 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 `git diff --check`로만 점검한다.
|
||||
|
||||
- 2026-07-30 Phase 4·6 리뷰 보완 구현 검증:
|
||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest.shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed"` 실행 결과 stale active inbox assertion 실패로 `BUILD FAILED`.
|
||||
- `creator_following` active follower 조회에 `for update`를 적용하고, `MemberService.creatorFollow(...)`/`creatorUnFollow(...)`가 같은 row를 `PESSIMISTIC_WRITE`로 조회하도록 보강했다. 공개 API·port·DDL은 변경하지 않았다.
|
||||
- GREEN 확인: 위 동시성 단일 테스트 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R1 focused test `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest" --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R-GATE E2E `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R-GATE lint `./gradlew --no-daemon ktlintCheck` 최초 실행은 테스트 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행한다.
|
||||
- P4-R-GATE lint 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P6-R1 문서 보완으로 PRD에서 `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며 정렬·동률 해소에 사용하는 계약으로 분리했다.
|
||||
- P6-R-GATE 정적 검색 `rg -n "newsId|scheduleId|home_following_news_inbox\.id" ...` 실행 결과 PRD, repository, repository test, E2E가 `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 inbox PK 문자열 계약으로 일치함을 확인했다.
|
||||
- P6-R-GATE `git diff --check` 실행 결과 오류 없음.
|
||||
- P6-R-GATE `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트: Oracle reviewer가 P4 동시성 보완과 P6 문서 계약 동기화를 검토했고 Critical/Important/Minor blocker 없음으로 승인했다.
|
||||
|
||||
- 2026-07-30 Phase 1~7 3차 정적 코드 리뷰:
|
||||
- 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, production/test 코드, 기존 리뷰·검증 기록을 Phase별로 다시 대조했다.
|
||||
- Phase 4에서 공개 `POST /member/creator/follow`가 `isActive=false`를 전달해도 관계가 없으면 새 active 팔로우를 만들고, 기존 관계에서는 inbox 비활성화를 호출하지 않아 재팔로우 시 과거 소식이 다시 노출될 수 있는 `REV-P4-002`를 확정했다.
|
||||
- Phase 6에서 PRD의 최신 `CREATOR_RANKING` 배치 설명이 snapshot만 기준으로 적혀 있어, `P7-R1`로 반영된 최신 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 동작과 불일치하는 `REV-P6-002`를 확정했다.
|
||||
- 후속 작업으로 Task 4.7 / `P4-R2` / `P4-R2-GATE`와 Task 6.4 / `P6-R2` / `P6-R2-GATE`를 추가했다.
|
||||
- Phase 1, 2, 3, 5, 5.5, 7에서는 이번 3차 정적 리뷰의 신규 확정 발견 사항이 없다.
|
||||
- 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 `git diff --check`로만 점검한다.
|
||||
|
||||
- 2026-07-30 Phase 4 3차 리뷰 보완 구현 검증:
|
||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `creatorFollow(..., isActive=false)` 신규 회귀 2건이 assertion 실패로 `BUILD FAILED`.
|
||||
- 관계가 없고 `isActive=false`인 통합 팔로우 요청은 새 active 관계를 만들지 않고, 기존 관계를 inactive로 바꾸는 경로에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출하도록 `MemberService.creatorFollow(...)`만 최소 수정했다.
|
||||
- GREEN 확인: 같은 `MemberServiceTest` 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R2-GATE 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R2-GATE lint `./gradlew --no-daemon ktlintCheck` 최초 실행은 신규 테스트 줄 길이 위반으로 `BUILD FAILED`; 포맷 정리 후 `MemberServiceTest`와 `ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
|
||||
|
||||
- 2026-07-30 Phase 6 3차 리뷰 보완 문서 검증:
|
||||
- PRD Feature F와 최근 소식 Inbox 기술 제약의 `CREATOR_RANKING` 최신 공개 배치 설명을 현재 구현의 `creator_ranking_snapshot_job` 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 우선 기준과 일치하도록 갱신했다.
|
||||
- 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot` fallback을 허용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시했다.
|
||||
- P6-R2-GATE 정적 검색 `rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" ...` 실행 결과 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트의 기준을 대조했다. 검색 결과의 과거 Task 7.1 snapshot 설명은 Task 7.2에서 superseded된 완료 기록으로 확인했다.
|
||||
- P6-R2-GATE `git diff --check` 실행 결과 오류 없음.
|
||||
- P6-R2-GATE `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트: Oracle reviewer가 P4-R2 통합 언팔로우 보완과 P6-R2 문서 동기화를 검토했고 Critical/Important/Minor blocker 없음으로 승인했다.
|
||||
|
||||
- 2026-07-30 Phase 1~7 4차 정적 코드 리뷰:
|
||||
- 사용자 요청에 따라 컴파일과 테스트를 실행하지 않고 PRD, 구현 계획, DDL, production/test 코드와 기존 리뷰 후속 구현을 Phase별로 다시 대조했다.
|
||||
- Phase 1은 공개 endpoint·비회원 응답·DTO, Phase 2는 inbox 저장·중복·잠금, Phase 3은 섹션별 조회·라이브 제한을 확인했다.
|
||||
- Phase 4는 after-commit 발행·팔로우 상태 동시성·통합 inactive 경로, Phase 5와 5.5는 facade/E2E·nested payload·무료 커뮤니티 정책을 확인했다.
|
||||
- Phase 6은 문서·식별자·latest batch 계약, Phase 7은 최신 빈 배치와 노출 가능한 콘텐츠 랭킹 중복 제거를 확인했다.
|
||||
- 각 Phase의 4차 판정은 `reviews/phase-1-review.md`부터 `phase-7-review.md`까지 누적했으며 Phase 5.5는 별도 보고서에 기록했다.
|
||||
- 이번 차수의 신규 확정 발견 사항이 없어 신규 회귀 수정 Task/Goal은 추가하지 않았고 기존 Phase 완료 판정을 유지한다.
|
||||
- 이번 리뷰에서는 Gradle 명령을 실행하지 않았으며, 문서 변경은 정적 검색과 `git diff --check`로만 점검한다.
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
- 최근 소식은 랭킹, 커뮤니티 게시글 업로드, 콘텐츠 업로드가 섞인 피드라 매 요청마다 팔로잉한 모든 크리에이터의 모든 원천 데이터를 크게 조인하면 응답 지연과 DB 부하가 커질 수 있다.
|
||||
- 최근 소식은 전체 후보를 매번 조회하는 모델보다, 팔로우 중인 크리에이터의 이벤트가 발생할 때 각 follower의 우체통에 소식 row를 넣는 사용자별 Inbox Feed 모델이 요구사항에 더 맞다.
|
||||
- 따라서 공개 API 조립 계층과 도메인 조회 계층을 분리하고, 최근 소식은 사용자별 inbox row를 최신순으로 읽는 구조가 필요하다.
|
||||
- 현재 최근 소식 조회는 노출 가능한 과거 `CREATOR_RANKING` inbox도 함께 조회하므로, 전체 소식이 30개 미만이면 직전 공개 배치보다 오래된 크리에이터 순위가 표시될 수 있다.
|
||||
- 향후 `CONTENT_RANKING` inbox가 적재되면 같은 콘텐츠가 여러 랭킹 소식에 포함될 수 있으므로, 조회 시 동일 콘텐츠의 중복 노출을 방지하는 정책이 필요하다.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,13 +19,11 @@
|
||||
- 메인 홈 팔로잉 탭 조회 API를 `kr.co.vividnext.sodalive.v2` 하위 신규 코드로 제공한다.
|
||||
- 기존 패턴과 동일하게 API 조립 계층과 도메인 조회 계층을 분리한다.
|
||||
- 비로그인 사용자도 API 호출은 허용하되, 로그인 유도 화면을 그릴 수 있는 응답을 제공한다.
|
||||
- 사용자가 팔로우한 크리에이터 목록을 오래된 팔로우순 20개 응답한다.
|
||||
- 사용자가 팔로우한 크리에이터 목록을 최신 팔로우순 20개 응답한다.
|
||||
- 사용자가 팔로우한 크리에이터의 현재 진행 중인 라이브를 최신순 10개 응답한다.
|
||||
- DM/AI 채팅방 중 최신 대화순 10개를 응답한다.
|
||||
- 사용자가 팔로우한 크리에이터들의 이번 달 오늘 이후 스케줄을 오늘과 가까운 순으로 최대 3개 응답한다.
|
||||
- 사용자가 팔로우한 크리에이터들의 최근 소식을 최신 노출 가능 시각순 최대 30개 응답한다.
|
||||
- `CREATOR_RANKING` 최근 소식은 현재 시점에 공개된 최신 크리에이터 랭킹 배치만 응답한다.
|
||||
- `CONTENT_RANKING` 최근 소식은 같은 콘텐츠의 노출 가능한 inbox가 여러 개여도 가장 최신 항목 하나만 응답한다.
|
||||
- 최근 소식은 팔로우 중인 크리에이터의 이벤트 발생 시점에 사용자별 inbox row를 생성하고, 조회 시 열람 가능 시각/활성 여부/차단/성인 노출 조건을 적용한다.
|
||||
- 새로 팔로우한 사용자는 과거 소식을 받지 않는다.
|
||||
- 언팔로우하면 해당 크리에이터가 보낸 기존 inbox row를 비활성화한다.
|
||||
@@ -46,7 +42,6 @@
|
||||
- 최근 소식의 운영자 수동 고정/숨김 기능은 포함하지 않는다.
|
||||
- 최근 소식 발송용 외부 MQ, outbox table, 별도 worker, cursor/retry dashboard는 이번 범위에 포함하지 않는다.
|
||||
- 화보 업로드 기능 자체 구현은 포함하지 않는다. 단, 향후 콘텐츠 타입 확장을 고려한 응답 타입은 정의한다.
|
||||
- `CONTENT_RANKING` inbox 발행과 콘텐츠 랭킹 스냅샷 연동은 이번 보완 범위에 포함하지 않는다.
|
||||
- 전체보기/페이징 API는 이번 요구사항에 포함하지 않는다.
|
||||
|
||||
---
|
||||
@@ -60,12 +55,11 @@
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 내가 팔로우한 크리에이터 목록을 오래 팔로우한 순서로 보고 싶다.
|
||||
- 사용자는 내가 팔로우한 크리에이터 목록을 최근 팔로우한 순서로 보고 싶다.
|
||||
- 사용자는 팔로우한 크리에이터가 지금 진행 중인 라이브를 바로 확인하고 싶다.
|
||||
- 사용자는 최근 DM/AI 채팅방으로 빠르게 이동하고 싶다.
|
||||
- 사용자는 팔로우한 크리에이터의 이번 달 예정 라이브/콘텐츠 일정을 가까운 일정부터 보고 싶다.
|
||||
- 사용자는 팔로우한 크리에이터의 최신 공개 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다.
|
||||
- 사용자는 과거 크리에이터 랭킹 배치나 같은 콘텐츠의 중복 랭킹 소식 없이 최근 소식을 보고 싶다.
|
||||
- 사용자는 팔로우한 크리에이터의 이번 주 랭킹 순위, 커뮤니티 게시글, 콘텐츠 업로드 소식을 최신순으로 보고 싶다.
|
||||
- 앱 클라이언트는 소식 item의 타입별 터치 액션을 명확한 target id로 처리하고 싶다.
|
||||
|
||||
---
|
||||
@@ -96,7 +90,7 @@
|
||||
### Feature B. 팔로잉 크리에이터
|
||||
|
||||
#### Requirements
|
||||
- 사용자가 팔로우한 활성 크리에이터를 오래된 팔로우순으로 최대 20개 조회한다.
|
||||
- 사용자가 팔로우한 활성 크리에이터를 최신 팔로우순으로 최대 20개 조회한다.
|
||||
- 팔로잉 기준은 `creator_following.member_id = 요청 회원 id`, `creator_following.is_active = true`다.
|
||||
- 크리에이터는 `member.role = CREATOR`, `member.is_active = true`인 대상만 노출한다.
|
||||
- 응답 필드는 `creatorId`, `creatorNickname`, `creatorProfileImageUrl`을 포함한다.
|
||||
@@ -180,13 +174,6 @@
|
||||
- inbox row에는 소식 타입, 발생 시각, 열람 가능 시각, 수신 회원 id, 크리에이터 id, target id, 표시용 제목/본문/이미지 path, 랭킹 순위 값 등 응답 생성에 필요한 최소 정보를 저장한다.
|
||||
- API 조회는 `memberId = 요청 회원 id`, `isActive = true`, `visibleFromAtUtc <= nowUtc`인 inbox row를 최신순으로 조회한다.
|
||||
- 조회 정렬은 `visibleFromAtUtc desc`, `newsId desc`를 기본으로 한다.
|
||||
- `CREATOR_RANKING`은 적용 가능한 `creator_ranking_snapshot_job` 중 `rankingType = WEEKLY`, `status = DONE`, `visibleFromAtUtc <= nowUtc`를 만족하는 최신 완료 job의 `visibleFromAtUtc`와 같은 inbox row만 조회한다.
|
||||
- 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot`의 `WEEKLY`, `visibleFromAtUtc <= nowUtc` 최신 공개 시각을 fallback으로 사용한다.
|
||||
- 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다.
|
||||
- 다음 랭킹 배치가 공개되기 전에는 직전 공개 배치를 최신 배치로 유지하고, 새 배치가 공개된 뒤에는 이전 배치의 `CREATOR_RANKING` inbox를 조회하지 않는다.
|
||||
- 사용자가 팔로우한 크리에이터가 최신 공개 배치에 없으면 해당 크리에이터의 과거 `CREATOR_RANKING` inbox로 대체하지 않는다.
|
||||
- `CONTENT_RANKING`은 같은 회원과 같은 `targetId`를 콘텐츠 동일성 기준으로 사용하고, 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 첫 항목 하나만 조회한다.
|
||||
- 랭킹 배치 필터와 콘텐츠 중복 제거는 전체 최대 30개 제한보다 먼저 적용한다.
|
||||
- 조회 시 원천 target의 비활성/삭제 여부, 차단 관계, 성인 노출 가능 여부를 최종 확인한다.
|
||||
- `FollowingNewsResponse` 최상위 응답 필드는 `newsId`, `type`, `visibleFromAtUtc`만 공통으로 포함한다.
|
||||
- 타입별 세부 값은 nullable nested DTO로 내려주며, `type`과 일치하는 nested DTO만 non-null이고 나머지는 `null`이다.
|
||||
@@ -203,16 +190,12 @@
|
||||
- 즉시 공개 콘텐츠는 `visibleFromAtUtc = releaseDate`로 저장할 수 있다.
|
||||
- 크리에이터 랭킹 소식은 크리에이터 랭킹 스냅샷 생성 시 inbox row를 생성할 수 있으나, `visibleFromAtUtc`는 랭킹 스냅샷의 `visibleFromAtUtc`를 그대로 사용한다.
|
||||
- 크리에이터 랭킹 스냅샷이 월요일 01:00 KST에 생성되고 월요일 09:00 KST에 화면 반영되는 경우, `CREATOR_RANKING` inbox row도 월요일 09:00 KST 전에는 API에 노출되지 않아야 한다.
|
||||
- 월요일 신규 랭킹 공개 전에는 직전 공개 배치의 `CREATOR_RANKING` 소식을 표시하고, 신규 배치 공개 시점부터는 신규 배치 소식만 표시한다.
|
||||
- 최근 소식에서 순위 변화와 신규 진입 여부는 사용하지 않는다. 랭킹 타입은 nested DTO의 `rank`만 내려준다.
|
||||
|
||||
#### Edge Cases
|
||||
- inbox row가 없거나 필터링 후 결과가 없으면 빈 배열을 내려준다.
|
||||
- inbox 적재 실패 시 API 조회에서 실시간 fallback 집계를 무조건 수행하지 않는다.
|
||||
- 랭킹 소식의 순위 값이 없거나 오래된 경우 해당 item은 생성하지 않는다.
|
||||
- 최신 공개 크리에이터 랭킹 배치에 해당하는 inbox가 없으면 과거 배치로 보충하지 않고 `CREATOR_RANKING` 소식을 가능한 개수만 응답한다.
|
||||
- 같은 콘텐츠의 `CONTENT_RANKING` row가 여러 개이고 `visibleFromAtUtc`가 같으면 `newsId`가 큰 row 하나만 응답한다.
|
||||
- 과거 크리에이터 랭킹과 중복 콘텐츠 랭킹을 제외한 뒤 최근 소식이 30개 미만이어도 과거·중복 랭킹으로 보충하지 않는다.
|
||||
- 같은 회원, 같은 소식 타입, 같은 `sourceKey`에 대해 중복 inbox row를 생성하지 않는다.
|
||||
- 언팔로우와 inbox 적재가 동시에 발생하면, 최종적으로 언팔로우 상태인 크리에이터의 새 소식은 노출하지 않는다.
|
||||
- 타입별 이미지가 없으면 해당 nested DTO의 이미지 URL 필드는 `null`로 내려준다.
|
||||
@@ -345,8 +328,7 @@ enum class FollowingNewsType {
|
||||
```
|
||||
|
||||
- `ChatRoomListItemResponse`는 기존 `v2.chat.dto` 응답 DTO를 직접 재사용한다.
|
||||
- `scheduleId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 사용한다.
|
||||
- `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며, 최근 소식의 `visibleFromAtUtc desc`, `newsId desc` 정렬과 `CONTENT_RANKING` 동률 해소에 사용한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다.
|
||||
- `scheduleId`와 `newsId`는 서로 다른 원천 타입의 id 충돌을 피하기 위해 `{TYPE}:{targetId}` 형식의 문자열을 기본안으로 한다. 최근 소식의 이동 대상 id는 타입별 nested DTO 안의 id 필드를 사용한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -388,9 +370,7 @@ enum class FollowingNewsType {
|
||||
- publish service는 `publishContentUploaded(...)`, `publishFreeCommunityPostCreated(...)`, `publishCreatorRankingVisible(...)`처럼 이벤트별 명시적 메서드를 제공한다. 유료 커뮤니티 게시글은 publish service 호출 대상이 아니다.
|
||||
- 운영 규모가 커지면 publish service 내부에서 outbox row 저장 또는 비동기 worker 위임으로 전환할 수 있도록 호출부 계약을 작게 유지한다.
|
||||
- `CREATOR_RANKING` 타입은 크리에이터 랭킹 소식만 포함한다.
|
||||
- `CREATOR_RANKING` 조회의 최신 공개 배치는 적용 가능한 `creator_ranking_snapshot_job`의 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 조건으로 우선 판정한다. 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot`의 최신 공개 시각을 fallback으로 사용하며, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다.
|
||||
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값을 유지하되, 이번 보완에서는 발행 기능을 추가하지 않고 기존 또는 향후 적재된 inbox의 조회 정책만 정의한다.
|
||||
- `CONTENT_RANKING`은 `targetId`별 최신 노출 가능 row 하나만 남기고, `visibleFromAtUtc`가 같으면 `newsId` 내림차순으로 하나를 선택한다.
|
||||
- `CONTENT_RANKING` 타입은 향후 콘텐츠 랭킹 소식용으로 enum과 table 값만 예약하고, 이번 범위에서는 생성하지 않는다.
|
||||
- 언팔로우 시 해당 회원과 크리에이터의 활성 inbox row를 비활성화한다.
|
||||
- 재팔로우 시 비활성화된 기존 inbox row는 복구하지 않는다.
|
||||
- 현재 `creator_following`에는 재팔로우 시점이 명확히 남지 않으므로, 조회 조건으로 재팔로우 시점을 추론하지 않는다.
|
||||
@@ -415,10 +395,3 @@ enum class FollowingNewsType {
|
||||
## 12. Open Questions
|
||||
- 현재 PRD 기준의 미결정 요구사항은 없다.
|
||||
- 구현 계획 단계에서는 기존 라이브 조회 코드의 진행 중 판단 조건과 스케줄 `isOnAir` 판단 조건을 같은 조건으로 추출할지 검토한다.
|
||||
|
||||
---
|
||||
|
||||
## 13. Decision Log
|
||||
|
||||
- 2026-07-30: `CREATOR_RANKING` 최근 소식은 KST 달력 주간이 아니라 현재 시점의 최신 공개 `WEEKLY`, `DONE` 크리에이터 랭킹 job을 기준으로 한다. 신규 배치 공개 전에는 직전 공개 배치를 유지하고, 공개 후에는 이전 배치를 노출하지 않으며, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다.
|
||||
- 2026-07-30: `CONTENT_RANKING` 발행 기능은 이번 보완 범위에서 제외한다. 조회 시 동일 `contentId`의 노출 가능한 row 중 `visibleFromAtUtc desc`, `newsId desc` 기준 최신 항목 하나만 응답한다.
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Phase 1 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / Task 1.1~1.2 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/*.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- DTO/domain 모델, 비로그인 응답, controller, Security `permitAll`이 공개 계약과 일치하는지 정적으로 대조했다.
|
||||
- `HomeFollowingTabResponse.kt`, `HomeFollowingController.kt`, `HomeFollowingFacade.kt`, `SecurityConfig.kt`와 대응 테스트를 포함했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. `plan-task.md`의 기존 성공 기록은 참고 증거로만 사용했다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- `HomeFollowingTabResponse.loginRequired()`는 로그인 필요 상태와 다섯 개 빈 배열을 생성한다.
|
||||
- controller는 nullable 인증 회원을 facade에 전달하고 `ApiResponse.ok(...)`로 감싼다.
|
||||
- `SecurityConfig`는 `GET /api/v2/home/following`을 `permitAll`로 허용한다.
|
||||
- DTO 테스트와 controller 테스트는 비회원/인증 회원 계약 및 nested 최근 소식 변환을 다룬다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 1 코드·테스트·문서 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 기존 기록 분리 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- controller, facade, DTO, Security permitAll과 비로그인 빈 응답 계약을 현재 working tree 기준으로 다시 대조했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 1 완료 판정을 유지한다.
|
||||
|
||||
## 8. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 공개 endpoint, nullable 인증 principal, `ApiResponse.ok(...)`, 로그인 필요 빈 응답과 nested DTO 변환을 현재 working tree에서 다시 대조했다.
|
||||
- controller·facade·DTO 테스트가 비회원/인증 회원 분기와 공개 응답 계약을 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 1 완료 판정을 유지한다.
|
||||
@@ -1,59 +0,0 @@
|
||||
# Phase 2 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 2 / Task 2.1~2.2 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `create-home-following-news-inbox-table.sql` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- inbox entity/JPA repository/adapter와 MySQL DDL의 컬럼·유니크 키·인덱스·비활성화 정책을 정적으로 대조했다.
|
||||
- 중복 충돌 retry, 활성 follower 조회, 테스트 격리와 기존 통합 테스트 범위를 확인했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- entity와 DDL은 `member_id/news_type/source_key` 유니크 정책, timestamp, 길이, 활성 상태 컬럼이 일치한다.
|
||||
- adapter는 입력 중복 제거 후 기존 수신 회원을 일괄 조회하고 `saveAll`/`flush`하며, unique 충돌 시 새 트랜잭션으로 한 번 재시도한다.
|
||||
- 언팔로우 비활성화 쿼리와 활성 follower 조회 쿼리는 계획의 키 조건을 사용한다.
|
||||
- 통합 테스트는 실제 unique 충돌 후 트랜잭션 사용 가능 여부, 비활성화, 활성 follower 조회를 포함한다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | entity/repository/adapter/DDL/test 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- inbox DDL, Entity, JPA adapter의 중복 방지·retry·비활성화 계약을 현재 working tree 기준으로 다시 대조했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 2 완료 판정을 유지한다.
|
||||
|
||||
## 8. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- DDL·Entity의 컬럼 길이, timestamp, unique/index 정책과 adapter의 중복 제거·충돌 재시도·비활성화 동작을 다시 대조했다.
|
||||
- 활성 follower 조회의 잠금이 Phase 4 팔로우 상태 변경 경계와 연결되고 관련 persistence 테스트가 이를 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 2 완료 판정을 유지한다.
|
||||
@@ -1,120 +0,0 @@
|
||||
# Phase 3 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 3 / Task 3.1~3.6 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md` Feature B~F, `plan-task.md` Phase 3 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- 팔로잉 크리에이터, On Air, 월간 스케줄, 최근 소식 repository와 query service를 기존 접근 정책까지 포함해 정적으로 대조했다.
|
||||
- `DefaultHomeFollowingQueryRepository.kt`, `HomeFollowingQueryPort.kt`, `HomeFollowingQueryService.kt`와 대응 테스트를 검토했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P3-001` | High | 확정 | On Air와 라이브 스케줄이 기존 라이브 입장 제한을 적용하지 않는다 | Task 3.7 | `P3-R1` |
|
||||
|
||||
## 4. 발견 사항 상세
|
||||
|
||||
### REV-P3-001 — On Air와 라이브 스케줄이 기존 라이브 입장 제한을 적용하지 않는다
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** PRD Feature C의 성별·크리에이터 입장 제한, Feature E의 기존 채널 스케줄 정책 재사용
|
||||
- **소유 Task:** 신규 Task 3.7 / `P3-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`HomeFollowingQueryService`는 회원 id와 성인 콘텐츠 허용 여부만 port에 전달한다.
|
||||
`DefaultHomeFollowingQueryRepository.findOnAirLives(...)`와 live schedule 조회는 활성/채널/성인/차단 조건만 적용하며
|
||||
`live_room.gender_restriction`과 크리에이터 회원의 `is_available_join_creator` 조건을 적용하지 않는다.
|
||||
|
||||
반면 기존 `LiveRoomQueryRepositoryImpl.getLiveRoomListNow(...)`와
|
||||
`DefaultCreatorChannelHomeQueryRepository.findCurrentLive/findSchedules(...)`는 effective gender와 크리에이터 입장 제한을 적용한다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `HomeFollowingQueryService.kt:19-30`
|
||||
- 코드: `DefaultHomeFollowingQueryRepository.kt:70-99`, `242-273`
|
||||
- 비교 코드: `LiveRoomRepository.kt:97-115`
|
||||
- 비교 코드: `DefaultCreatorChannelHomeQueryRepository.kt:136-145`, `225-233`
|
||||
- 테스트 공백: `DefaultHomeFollowingQueryRepositoryTest`의 On Air 테스트는 활성/성인/정렬만 검증한다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 남성 회원이 여성 전용 라이브를 연 팔로잉 크리에이터를 조회한다고 가정한다.
|
||||
2. 라이브를 `isActive=true`, non-empty `channelName`, 비성인으로 두면 현재 팔로잉 조회 조건을 모두 통과한다.
|
||||
3. 실제 라이브 입장 정책은 성별 불일치로 입장을 거부하지만 팔로잉 탭 On Air에는 노출된다.
|
||||
4. 크리에이터 회원이 `isAvailableJoinCreator=false`인 타 크리에이터 라이브를 조회하는 경우도 동일하게 노출된다.
|
||||
|
||||
**영향**
|
||||
|
||||
팔로잉 탭에 터치해도 입장할 수 없는 라이브 또는 스케줄이 노출되어 기존 라이브 접근 정책과 API 결과가 불일치한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
회원의 본인인증 성별을 우선한 effective gender와 크리에이터 회원 여부를 query service에서 전달하고, 기존 QueryDSL 조건을
|
||||
On Air와 live schedule에 최소 적용한다. 성별 불일치와 크리에이터 입장 불가 회귀 테스트를 먼저 추가한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — PRD와 기존 라이브 조회 구현을 대조해 확정했다. 런타임 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 3에 Task 3.7, `P3-R1`, `P3-R-GATE`를 추가했다.
|
||||
- 기존 Task 3.1~3.6의 완료 체크와 검증 기록은 유지했다.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 3 repository/service/test 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P3-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 3.7 / `P3-R1` |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 필요.
|
||||
|
||||
**남은 항목:** `P3-R1` 구현 후 `P3-R-GATE`와 이 문서의 수정 후 검증 기록을 수행한다.
|
||||
|
||||
## 7. 수정 후 검증 기록
|
||||
|
||||
- 2026-07-30 — RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 effective gender·creator 여부 port 인자 미반영 컴파일 오류로 `BUILD FAILED`.
|
||||
- 2026-07-30 — `HomeFollowingQueryService`에서 본인인증 성별 우선 effective gender와 크리에이터 회원 여부를 `HomeFollowingQueryPort`에 전달하고, On Air와 라이브 스케줄 QueryDSL에 `genderRestriction` 및 `isAvailableJoinCreator` 조건을 적용했다.
|
||||
- 2026-07-30 — GREEN 확인: 같은 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 2026-07-30 — Gate 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"`와 `./gradlew --no-daemon ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
|
||||
- 2026-07-30 — 리뷰 게이트: Oracle reviewer가 Critical/Important blocker 없음으로 승인했다.
|
||||
|
||||
## 8. 후속 종료 판정 — 2026-07-30
|
||||
|
||||
- `HomeFollowingQueryService`가 본인인증 성별 우선 effective gender와 크리에이터 여부를 On Air·스케줄 조회에 전달하는 현재 코드를 정적으로 재확인했다.
|
||||
- `DefaultHomeFollowingQueryRepository`가 On Air와 라이브 스케줄에 `genderRestriction` 및 `isAvailableJoinCreator` 조건을 적용하는 현재 코드를 정적으로 재확인했다.
|
||||
- 이번 후속 판정에서는 컴파일과 테스트를 재실행하지 않았고, 위 `## 7. 수정 후 검증 기록`의 기존 성공 결과를 근거로 삼았다.
|
||||
|
||||
**최종 결론:** `P3-R1` 및 `P3-R-GATE` 수정 검증 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 9. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 팔로잉 크리에이터, On Air, 월간 스케줄, 최근 소식 조회 조건과 `P3-R1`의 성별·크리에이터 입장 제한 반영을 현재 working tree에서 다시 추적했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 3 완료 판정을 유지한다.
|
||||
|
||||
## 10. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 팔로잉 크리에이터·On Air·월간 스케줄·최근 소식의 활성/role/차단/성인/시간 범위/정렬/limit 조건을 PRD와 다시 대조했다.
|
||||
- `P3-R1`의 effective gender와 크리에이터 입장 제한이 service→port→On Air·라이브 스케줄 query에 동일하게 전달되는지 확인했다.
|
||||
- repository/service 테스트가 KST 월간 경계, 동률 정렬, 원천 target 활성 상태와 라이브 접근 제한을 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 3 완료 판정을 유지한다.
|
||||
@@ -1,203 +0,0 @@
|
||||
# Phase 4 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 4 / Task 4.1~4.5 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md` Feature F, `plan-task.md` Phase 4 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- source key, follower fan-out, 언팔로우 비활성화, 랭킹/오디오/무료 커뮤니티 발행 연결을 정적으로 추적했다.
|
||||
- 원 트랜잭션 commit 이후 발행, 발행 실패 격리, 공개 시각과 중복 방지 키를 확인했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- source key는 타입과 원천 id/기간으로 안정적으로 생성된다.
|
||||
- publish service는 활성 follower에게만 record를 만들고 DB 컬럼 길이에 맞춰 title/body를 제한한다.
|
||||
- 언팔로우는 기존 inbox를 비활성화하며 재팔로우가 이를 복구하지 않는다.
|
||||
- 랭킹, 즉시/예약 오디오, 무료 커뮤니티 생성 경로는 commit 이후 publish service를 호출하고 실패를 원 처리와 격리한다.
|
||||
- 관련 단위·서비스 테스트는 발행 성공, 예약 공개, 유료 미발행, 발행 실패 격리를 포함한다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 발행 경로와 호출부 정적 추적 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
### 리뷰 범위
|
||||
|
||||
- active follower 조회, inbox insert의 트랜잭션 경계와 언팔로우·재팔로우 상태 변경을 함께 정적으로 추적했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
|
||||
### REV-P4-001 — follower 판정 후 언팔로우가 완료되면 stale active inbox가 생성될 수 있음
|
||||
|
||||
**심각도:** Medium
|
||||
|
||||
**상태:** 확정
|
||||
|
||||
**근거**
|
||||
|
||||
- `HomeFollowingNewsPublishService.publishToFollowers(...)`는 active follower id 목록을 먼저 읽고 이후 별도 호출로 inbox row를 insert한다.
|
||||
- `HomeFollowingNewsInboxJpaRepository.findActiveFollowerIds(...)`는 `creator_following`을 잠그지 않는 조회다.
|
||||
- `HomeFollowingNewsInboxPersistenceAdapter.insertIgnoreAll(...)`의 실제 insert는 `REQUIRES_NEW` 경로에서 실행될 수 있어 follower 조회와 하나의 직렬화 경계를 공유하지 않는다.
|
||||
- `MemberService.creatorUnFollow(...)`는 언팔로우 시점에 존재하는 active inbox만 비활성화하고, `creatorFollow(...)`의 재팔로우는 기존 관계 row를 다시 active로 바꾼다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. publish 트랜잭션 A가 active follower인 회원을 읽고 insert 전에 멈춘다.
|
||||
2. 트랜잭션 B가 같은 회원의 언팔로우와 현재 inbox 비활성화를 완료한다.
|
||||
3. A가 기존 follower snapshot으로 active inbox를 뒤늦게 insert한다.
|
||||
4. 언팔로우 중에는 조회의 active-following 조건으로 숨겨지지만, 회원이 다시 팔로우하면 해당 언팔로우 이전 이벤트가 노출 가능해진다.
|
||||
|
||||
**영향**
|
||||
|
||||
재팔로우 시 기존 비활성 row를 복구하지 않고 재팔로우 이후의 새 이벤트만 제공한다는 정책을 우회해, 언팔로우 이전 이벤트가 최근 소식으로 나타날 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
follower 판정부터 inbox insert까지를 하나의 트랜잭션 경계로 묶고, 해당 `creator_following` row를 팔로우·언팔로우 상태 변경과 같은 순서로 잠근다. publish가 먼저 끝나면 뒤이은 언팔로우가 새 row까지 비활성화하고, 언팔로우가 먼저 끝나면 publish가 row를 생성하지 않는 두 순서를 동시성 테스트로 고정한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — publish/follow/unfollow 호출 흐름과 트랜잭션 경계를 대조해 확정했다. 런타임 테스트는 실행하지 않았다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 4에 Task 4.6, `P4-R1`, `P4-R-GATE`를 추가했다.
|
||||
- 기존 Task 4.1~4.5의 완료 체크와 검증 기록은 유지했다.
|
||||
|
||||
### 2차 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | publish/follow/unfollow 트랜잭션 경계 정적 추적 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 4.6 / `P4-R1` |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 필요.
|
||||
|
||||
**남은 항목:** 없음. `P4-R1`과 `P4-R-GATE` 수정 후 검증은 아래 기록에 누적했다.
|
||||
|
||||
## 8. 수정 후 검증 — 2026-07-30
|
||||
|
||||
- RED 확인: `HomeFollowingNewsInboxPersistenceAdapterTest.shouldNotExposeNewsCreatedBeforeUnfollowAfterRefollowWhenPublishIsDelayed` 실행 결과 stale active inbox assertion 실패로 `BUILD FAILED`.
|
||||
- follower 조회와 inbox insert 경계에서 `creator_following` row를 잠그도록 active follower 조회에 `for update`를 적용했고, `creatorFollow(...)`/`creatorUnFollow(...)`도 같은 row를 `PESSIMISTIC_WRITE`로 조회하도록 변경했다.
|
||||
- GREEN 확인: 동시성 단일 테스트 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R1 focused test `HomeFollowingNewsInboxPersistenceAdapterTest`, `HomeFollowingNewsPublishServiceTest`, `MemberServiceTest` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R-GATE E2E `HomeFollowingEndToEndTest` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R-GATE lint `ktlintCheck` 최초 실행은 테스트 import 정렬 위반으로 `BUILD FAILED`; import 정렬 수정 후 재실행한다.
|
||||
- P4-R-GATE lint `ktlintCheck` 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
|
||||
|
||||
**수정 후 결론:** `REV-P4-001` 보완 완료.
|
||||
|
||||
## 9. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
### 리뷰 범위
|
||||
|
||||
- 공개 팔로우·언팔로우 controller 경로, `MemberService` 상태 전이, inbox 비활성화와 재팔로우 조회 결과를 함께 정적으로 추적했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
|
||||
### REV-P4-002 — 통합 팔로우 API의 `isActive=false` 경로가 기존 inbox를 비활성화하지 않음
|
||||
|
||||
**심각도:** High
|
||||
|
||||
**상태:** 확정
|
||||
|
||||
**관련 요구사항:** PRD Feature F의 언팔로우 시 기존 inbox 비활성화, 재팔로우 시 기존 inbox 미복구
|
||||
|
||||
**소유 Task:** Task 4.7 / `P4-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`POST /member/creator/follow`는 request의 `isActive=false`를 `MemberService.creatorFollow(...)`에 전달해 팔로우·언팔로우를 함께 처리한다. 관계 row가 없으면 전달된 `isActive`를 반영하지 않은 기본 active 관계를 만들고, 기존 관계 row가 있으면 `creatorFollowing.isActive=false`만 반영한 채 inbox 비활성화를 호출하지 않는다. 이후 같은 API로 다시 활성화하면 기존 inbox row가 계속 active이므로 active-following 조회 조건을 다시 만족해 언팔로우 이전 소식이 노출될 수 있다.
|
||||
|
||||
**근거**
|
||||
|
||||
- `MemberController.creatorFollow(...)`는 nullable `request.isActive`를 기본값과 함께 service에 전달한다.
|
||||
- `CreatorFollowRequest`는 `isActive`를 공개 request 필드로 정의한다.
|
||||
- `MemberService.creatorFollow(...)`의 신규 관계 분기는 기본값이 `isActive=true`인 `CreatorFollowing()`을 저장하고, 기존 관계 분기는 `isNotify`와 `isActive`만 변경한다.
|
||||
- `MemberService.creatorUnFollow(...)`만 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출한다.
|
||||
- `MemberServiceTest`는 전용 `creatorUnFollow(...)` 경로만 검증하며 `creatorFollow(..., isActive=false)` 경로는 검증하지 않는다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 관계가 없는 회원이 `POST /member/creator/follow`에 `isActive=false`를 보내면 기본 active 관계가 새로 생성된다.
|
||||
2. 별도로 active 팔로우와 active inbox row가 있는 회원이 같은 요청을 보내면 관계 row만 inactive가 되고 inbox는 active로 남는다.
|
||||
3. 같은 API에 `isActive=true`를 보내 재팔로우한다.
|
||||
4. 기존 active inbox가 다시 active-following 조건을 만족해 최근 소식 조회 후보가 된다.
|
||||
|
||||
**영향**
|
||||
|
||||
관계가 없는 회원은 언팔로우 요청으로 오히려 active follower가 될 수 있다. 기존 follower는 언팔로우 경로에 따라 과거 소식 보존 상태가 달라지고, 통합 언팔로우 경로에서 재팔로우 이후 과거 소식 미복구 계약을 위반한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
관계가 없고 `isActive=false`면 전용 언팔로우 경로와 동일하게 새 active 관계를 만들지 않는다. 기존 관계를 `isActive=false`로 반영하는 같은 트랜잭션에서는 inbox를 비활성화한다. `isActive=true`인 알림 변경·재팔로우는 기존 비활성 inbox를 복구하지 않도록 유지하고, 두 경우를 `MemberServiceTest`로 고정한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — request/controller/service의 공개 `isActive=false` 흐름과 PRD 재팔로우 정책을 정적으로 대조해 확정했다. 런타임 테스트는 실행하지 않았다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 4에 Task 4.7, `P4-R2`, `P4-R2-GATE`를 추가했다.
|
||||
- 기존 Task 4.1~4.6의 완료 체크와 검증 기록은 유지했다.
|
||||
|
||||
### 3차 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 두 공개 상태 변경 경로와 조회 조건 정적 추적 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P4-002` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 4.7 / `P4-R2` |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 정적 근거 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 필요.
|
||||
|
||||
**남은 항목:** 없음. `P4-R2`와 `P4-R2-GATE` 수정 후 검증은 아래 기록에 누적했다.
|
||||
|
||||
## 10. 3차 수정 후 검증 — 2026-07-30
|
||||
|
||||
- RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.member.MemberServiceTest"` 실행 결과 `creatorFollow(..., isActive=false)` 신규 회귀 2건이 assertion 실패로 `BUILD FAILED`.
|
||||
- 관계가 없고 `isActive=false`인 통합 팔로우 요청은 새 active 관계를 만들지 않고, 기존 관계를 inactive로 바꾸는 경로에서는 `homeFollowingNewsInboxPort.deactivateByMemberIdAndCreatorId(...)`를 호출하도록 `MemberService.creatorFollow(...)`만 최소 수정했다.
|
||||
- GREEN 확인: 같은 `MemberServiceTest` 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R2-GATE 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.HomeFollowingNewsInboxPersistenceAdapterTest" --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingNewsPublishServiceTest"` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- P4-R2-GATE lint `./gradlew --no-daemon ktlintCheck` 최초 실행은 신규 테스트 줄 길이 위반으로 `BUILD FAILED`; 포맷 정리 후 `MemberServiceTest`와 `ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
|
||||
- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
|
||||
|
||||
**수정 후 결론:** `REV-P4-002` 보완 완료.
|
||||
|
||||
## 11. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- source key, after-commit 발행, follower fan-out, 랭킹·오디오·무료 커뮤니티 호출부와 발행 실패 격리를 다시 추적했다.
|
||||
- `P4-R1`의 `creator_following` 잠금 순서와 `P4-R2`의 통합 `isActive=false` 경로가 전용 언팔로우와 같은 inbox 최종 상태를 만드는지 확인했다.
|
||||
- persistence·publish·`MemberService` 테스트가 중복 방지, 동시 publish/unfollow, 신규 inactive 요청과 재팔로우 미복구를 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. `REV-P4-001`, `REV-P4-002` 수정 완료와 기존 Phase 4 완료 판정을 유지한다.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Phase 5 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 5 / Task 5.1~5.2 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md` Feature A·D, `plan-task.md` Phase 5 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- facade의 비회원 단락, 도메인 조회와 기존 최근 대화 10개 조립, E2E API 표면을 정적으로 대조했다.
|
||||
- `HomeFollowingFacade.kt`, facade/controller/E2E 테스트를 검토했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- 비회원은 query/chat service 호출 없이 로그인 필요 응답을 받는다.
|
||||
- 로그인 회원은 `ChatRoomListService.getRooms(member, "ALL", null, 10)` 결과를 도메인 조회 결과에 조립한다.
|
||||
- E2E 테스트는 비회원 빈 섹션과 로그인 회원의 다섯 섹션, 최근 소식 JSON surface를 검증한다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | facade/controller/E2E 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- facade의 로그인 분기, 최근 대화 재사용, E2E 조립 범위를 현재 working tree 기준으로 다시 대조했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 5 완료 판정을 유지한다.
|
||||
|
||||
## 8. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 비회원 조기 반환, 로그인 회원의 도메인 조회와 `ChatRoomListService.getRooms(..., limit = 10)` 조립을 다시 대조했다.
|
||||
- controller/facade/E2E 테스트가 비회원의 조회 생략과 로그인 회원의 다섯 섹션 공개 응답을 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 5 완료 판정을 유지한다.
|
||||
@@ -1,60 +0,0 @@
|
||||
# Phase 5.5 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 5.5 / Task 5.5.1~5.5.5 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md` Feature F·G, `plan-task.md` Phase 5.5 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- 타입별 nullable nested DTO, 원천 target enrichment, 무료 커뮤니티 전용 발행과 E2E JSON 계약을 정적으로 대조했다.
|
||||
- DTO/domain/repository/커뮤니티 호출부와 대응 테스트를 포함했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- 최근 소식 최상위는 공통 필드와 다섯 nested 필드만 노출하고 flat 이동/표시 필드를 제거했다.
|
||||
- repository는 랭킹, 활성 오디오, 활성 무료 커뮤니티 원천을 타입별 payload로 조립한다.
|
||||
- 커뮤니티 좋아요와 최상위 댓글은 active row만 집계한다.
|
||||
- 유료 커뮤니티는 발행과 조회 양쪽에서 제외된다.
|
||||
- DTO 및 E2E 테스트는 nested payload의 상호 배타적 null 계약과 제거된 flat 필드를 검증한다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | DTO/repository/call site/test 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 7. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 타입별 nullable nested DTO, 원천 target enrichment, 무료 커뮤니티 발행 제한과 E2E JSON 계약을 현재 working tree 기준으로 다시 대조했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 5.5 완료 판정을 유지한다.
|
||||
|
||||
## 8. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 최근 소식 공통 필드와 타입별 상호 배타적 nested DTO, CDN/UTC 변환, 원천 오디오·무료 커뮤니티 enrichment를 다시 대조했다.
|
||||
- 유료 커뮤니티와 비활성·성인 원천 target 제외, active like·top-level comment 집계가 repository와 E2E 테스트에 연결되는지 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. 기존 Phase 5.5 완료 판정을 유지한다.
|
||||
@@ -1,185 +0,0 @@
|
||||
# Phase 6 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 6 / Task 6.1~6.2 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, DDL, 기존 검증 기록 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- PRD, 구현 계획, DDL, 실제 공개 DTO의 현재 계약과 Phase별 기존 검증 기록을 정적으로 대조했다.
|
||||
- 현재 compile/test 통과 상태는 사용자 설명과 `plan-task.md` 기존 기록을 근거로 삼았으며 직접 재실행하지 않았다.
|
||||
|
||||
## 3. 검토 근거
|
||||
|
||||
- PRD와 계획의 endpoint, 섹션 limit, nested 최근 소식 필드, 무료 커뮤니티 정책, DDL 유니크 키가 현재 코드와 일치한다.
|
||||
- `plan-task.md`에는 Phase 1~7의 focused/회귀/lint 성공 기록과 실패 후 보완 이력이 누적되어 있다.
|
||||
- 이번 리뷰에서 확정된 후속 결함은 기존 완료 상태를 되돌리지 않고 소유 Phase의 신규 Task로 추가했다.
|
||||
- `./gradlew tasks --all` 최초 실행은 sandbox의 사용자 Gradle cache 접근 제한으로 실패했고, 승인된 권한으로 재실행한 결과
|
||||
`BUILD SUCCESSFUL`이었다. task 목록만 확인했으며 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
## 4. 발견 사항
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
전환 항목 없음. Phase 3과 Phase 7의 코드 발견 사항은 각 Phase 보고서와 Task에 귀속했다.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | PRD/plan/DDL/code/기존 기록 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | Phase 6 자체 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 다른 Phase 소유 항목은 해당 Phase에 반영 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유와 기존 기록 분리 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음.
|
||||
|
||||
**남은 항목:** 없음. Phase 3·7 회귀 Goal과 Phase 6 후속 문서 검증 기록은 이후 섹션에 누적했다.
|
||||
|
||||
## 7. 2차 리뷰 — 2026-07-30
|
||||
|
||||
### 기존 후속 항목 확인
|
||||
|
||||
- Phase 3의 `P3-R1`과 Phase 7의 `P7-R1`·`P7-R2` 구현 및 기존 Gate 성공 기록이 누적된 것을 확인했다.
|
||||
- 이번 확인에서는 컴파일과 테스트를 재실행하지 않았다.
|
||||
|
||||
### REV-P6-001 — PRD의 `newsId` 형식이 현재 공개 동작과 불일치
|
||||
|
||||
**심각도:** Low
|
||||
|
||||
**상태:** 확정
|
||||
|
||||
**근거**
|
||||
|
||||
- PRD 공통 식별자 설명은 `scheduleId`와 `newsId` 모두 `{TYPE}:{targetId}` 형식을 기본안으로 적고 있다.
|
||||
- `DefaultHomeFollowingQueryRepository`는 `home_following_news_inbox.id`를 문자열로 변환해 `newsId`로 반환한다.
|
||||
- repository test와 E2E도 inbox PK의 10진 문자열을 계약으로 검증한다.
|
||||
- 최근 소식 정렬과 `CONTENT_RANKING` 동률 해소도 inbox `id`를 사용하므로, 현재 동작을 유지한 문서 수정이 가장 작은 정합성 보완이다.
|
||||
|
||||
**영향**
|
||||
|
||||
클라이언트 또는 후속 구현자가 PRD만 보면 `newsId`를 타입·target 기반 식별자로 해석할 수 있어 실제 응답 파싱, 정렬 의미, 동일 target의 여러 소식 식별을 잘못 구현할 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
runtime 코드와 공개 응답 값은 변경하지 않는다. PRD에서 `scheduleId`는 `{TYPE}:{targetId}`, `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며 같은 노출 시각의 정렬·동률 해소에 쓰인다고 분리해 명시한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — PRD, repository, repository test, E2E를 정적으로 대조해 확정했다. Gradle 명령은 실행하지 않았다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 6에 문서 전용 Task 6.3, `P6-R1`, `P6-R-GATE`를 추가했다.
|
||||
- 기존 Task 6.1~6.2의 완료 체크와 검증 기록은 유지했다.
|
||||
|
||||
### 2차 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | PRD/repository/test/E2E 식별자 계약 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 6.3 / `P6-R1` |
|
||||
| 검증 명령과 결과 기록 | 충족 | Gradle 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 문서 수정 goal 필요.
|
||||
|
||||
**남은 항목:** 없음. `P6-R1`과 `P6-R-GATE` 수정 후 검증은 아래 기록에 누적했다.
|
||||
|
||||
## 8. 수정 후 검증 — 2026-07-30
|
||||
|
||||
- PRD에서 `scheduleId`와 `newsId` 식별자 설명을 분리했다.
|
||||
- `scheduleId`는 `{TYPE}:{targetId}` 형식, `newsId`는 `home_following_news_inbox.id`의 10진 문자열이며 `visibleFromAtUtc desc`, `newsId desc` 정렬과 `CONTENT_RANKING` 동률 해소에 사용한다고 명시했다.
|
||||
- runtime 코드, 테스트, DDL, 공개 응답 필드는 변경하지 않았다.
|
||||
- 정적 검색 `rg -n "newsId|scheduleId|home_following_news_inbox\.id" ...` 실행 결과 PRD와 repository/test/E2E의 식별자 계약이 일치함을 확인했다.
|
||||
- `git diff --check` 실행 결과 오류 없음.
|
||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
|
||||
|
||||
**수정 후 결론:** `REV-P6-001` 문서 보완 완료.
|
||||
|
||||
## 9. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
### 리뷰 범위
|
||||
|
||||
- PRD의 최근 소식 최신 배치 정책, Phase 7 회귀 Task, repository 조건과 회귀 테스트를 정적으로 대조했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
|
||||
### REV-P6-002 — PRD의 최신 크리에이터 랭킹 배치 판정 기준이 현재 구현과 불일치
|
||||
|
||||
**심각도:** Low
|
||||
|
||||
**상태:** 확정
|
||||
|
||||
**관련 요구사항:** 최신 공개 `WEEKLY` 배치만 조회하고 빈 최신 배치에서 과거 순위를 보충하지 않는 정책
|
||||
|
||||
**소유 Task:** Task 6.4 / `P6-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
PRD Feature F와 기술 제약은 최신 `CREATOR_RANKING` 배치를 `creator_ranking_snapshot`의 최대 공개 시각으로 판정한다고 기술한다. 그러나 `REV-P7-001` 보완 이후 현재 repository는 적용 가능한 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 시각을 우선 사용하고, 해당 job이 전혀 없을 때만 snapshot으로 fallback한다.
|
||||
|
||||
**근거**
|
||||
|
||||
- PRD의 최신 배치 설명 두 곳은 `creator_ranking_snapshot`만 판정 원천으로 명시한다.
|
||||
- `plan-task.md` Task 7.2는 빈 최신 완료 배치를 식별하기 위해 job 우선·legacy snapshot 제한 fallback을 확정했다.
|
||||
- `DefaultHomeFollowingQueryRepository.latestVisibleCreatorRankingBatchCondition(...)`은 최신 DONE job 시각을 우선하고 `hasRankingJob.not()`일 때만 snapshot 시각을 사용한다.
|
||||
- `DefaultHomeFollowingQueryRepositoryTest.shouldExcludeCreatorRankingNewsWhenLatestDoneBatchIsEmpty`는 최신 DONE job의 snapshot row가 0건이어도 과거 소식을 반환하지 않는 현재 계약을 검증한다.
|
||||
|
||||
**영향**
|
||||
|
||||
후속 구현자가 PRD만 따르면 최신 빈 완료 배치에서 과거 snapshot을 다시 최신으로 판정해 이미 수정한 회귀를 재도입할 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
runtime은 변경하지 않는다. PRD 두 곳을 최신 공개 DONE job 우선으로 동기화하고, 적용 가능한 DONE job이 전혀 없는 legacy/backfill 데이터에서만 snapshot fallback을 허용하며 빈 DONE 배치는 과거 snapshot으로 보충하지 않는다고 명시한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트를 정적으로 대조해 확정했다. Gradle 명령은 실행하지 않았다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 6에 문서 전용 Task 6.4, `P6-R2`, `P6-R2-GATE`를 추가했다.
|
||||
- 기존 Task 6.1~6.3의 완료 체크와 검증 기록은 유지했다.
|
||||
|
||||
### 3차 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | PRD/Task/repository/test 최신 배치 기준 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P6-002` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 6.4 / `P6-R2` |
|
||||
| 검증 명령과 결과 기록 | 충족 | Gradle 미실행 사유와 정적 근거 기록 |
|
||||
|
||||
**최종 결론:** 문서 수정 goal 필요.
|
||||
|
||||
**남은 항목:** 없음. `P6-R2`와 `P6-R2-GATE` 수정 후 검증은 아래 기록에 누적했다.
|
||||
|
||||
## 10. 3차 수정 후 검증 — 2026-07-30
|
||||
|
||||
- PRD Feature F와 최근 소식 Inbox 기술 제약의 `CREATOR_RANKING` 최신 공개 배치 설명을 현재 구현의 `creator_ranking_snapshot_job` 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc` 우선 기준과 일치하도록 갱신했다.
|
||||
- 적용 가능한 완료 job이 전혀 없는 legacy/backfill 데이터에서만 `creator_ranking_snapshot` fallback을 허용하고, 최신 완료 job의 결과가 0건이면 과거 snapshot으로 보충하지 않는다고 명시했다.
|
||||
- P6-R2-GATE 정적 검색 `rg -n "creator_ranking_snapshot_job|creator_ranking_snapshot|최신 공개 배치|WEEKLY|DONE|legacy|fallback" ...` 실행 결과 PRD, Task 7.2, repository, 빈 최신 배치 회귀 테스트의 기준을 대조했다. 검색 결과의 과거 Task 7.1 snapshot 설명은 Task 7.2에서 superseded된 완료 기록으로 확인했다.
|
||||
- P6-R2-GATE `git diff --check` 실행 결과 오류 없음.
|
||||
- P6-R2-GATE `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 추가 전체 회귀 확인: `./gradlew --no-daemon test` 실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 리뷰 게이트에서 Critical/Important/Minor blocker 없음으로 승인됐다.
|
||||
|
||||
**수정 후 결론:** `REV-P6-002` 문서 보완 완료.
|
||||
|
||||
## 11. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- PRD·plan·DDL·공개 DTO와 현재 repository의 endpoint, limit, 식별자, latest ranking batch, nested 최근 소식 계약을 다시 대조했다.
|
||||
- `newsId`의 inbox PK 문자열 계약과 최신 `WEEKLY`, `DONE` job 우선·legacy snapshot 제한 fallback 설명이 현재 코드·테스트와 일치함을 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. `REV-P6-001`, `REV-P6-002` 문서 보완 완료와 기존 Phase 6 완료 판정을 유지한다.
|
||||
@@ -1,162 +0,0 @@
|
||||
# Phase 7 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 7 / Task 7.1 |
|
||||
| 기준 commit 또는 working tree | `e6f56f24` + 2026-07-30 working tree |
|
||||
| 리뷰 일자 | 2026-07-30 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md` Feature F·Decision Log, `plan-task.md` Phase 7 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
- 최신 공개 크리에이터 랭킹 배치 필터와 콘텐츠별 최신 랭킹 중복 제거가 모든 확정 edge case에서 limit 전에 적용되는지 검토했다.
|
||||
- 현재 working tree의 repository, repository test, E2E fixture 변경을 포함했다.
|
||||
- 사용자의 지시에 따라 컴파일과 테스트는 실행하지 않았다. 기존 Phase 7 성공 기록은 참고 증거로만 사용했다.
|
||||
|
||||
## 3. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P7-001` | High | 확정 | 최신 완료 배치가 0건이면 과거 크리에이터 랭킹이 다시 최신으로 판정된다 | Task 7.2 | `P7-R1` |
|
||||
| `REV-P7-002` | High | 확정 | 노출 불가능한 최신 콘텐츠 랭킹 row가 이전의 노출 가능한 row를 가린다 | Task 7.3 | `P7-R2` |
|
||||
|
||||
## 4. 발견 사항 상세
|
||||
|
||||
### REV-P7-001 — 최신 완료 배치가 0건이면 과거 크리에이터 랭킹이 다시 최신으로 판정된다
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** 최신 공개 배치만 노출, 최신 배치에 없는 크리에이터의 과거 순위 미보충
|
||||
- **소유 Task:** 신규 Task 7.2 / `P7-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
최신 배치 조건은 `creator_ranking_snapshot.visibleFromAtUtc.max()`만 조회한다. 최신 집계가 성공했지만 점수 조건을 통과한
|
||||
snapshot이 0건이면 해당 배치를 나타내는 snapshot row가 없으므로 max 값은 직전 배치 시각으로 남는다.
|
||||
|
||||
랭킹 job은 결과가 0건이어도 `creator_ranking_snapshot_job`에 `WEEKLY`, `DONE`, `visibleFromAtUtc`를 남기므로 완료 배치를
|
||||
식별할 근거가 이미 있지만 현재 팔로잉 조회는 이를 사용하지 않는다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `DefaultHomeFollowingQueryRepository.kt:428-451`
|
||||
- 코드: `DefaultCreatorRankingSnapshotRepository.kt:51-65`
|
||||
- 코드: `CreatorRankingSnapshotJobService.kt:52-69`
|
||||
- 테스트 공백: `shouldFindOnlyLatestVisibleCreatorRankingBatchInRecentNews`는 최신 배치에 snapshot이 1건 이상인 경우만 검증한다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 직전 주간 snapshot과 같은 시각의 `CREATOR_RANKING` inbox를 둔다.
|
||||
2. 더 최신 주간 job을 `DONE`으로 완료하지만 집계 결과 snapshot은 0건으로 둔다.
|
||||
3. 최신 job 공개 시각 이후 API를 조회한다.
|
||||
4. 요구 결과는 크리에이터 랭킹 0건이지만, 현재 subquery max는 직전 snapshot 시각이므로 과거 inbox가 노출된다.
|
||||
|
||||
**영향**
|
||||
|
||||
활동 부족 등으로 최신 배치 결과가 비어 있는 주에 명시적으로 금지된 과거 랭킹 보충이 발생한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
결과 row와 독립적으로 남는 최신 공개 `DONE` job을 배치 식별 기준으로 사용하고, 최신 빈 배치에서는 과거 snapshot으로
|
||||
fallback하지 않는 회귀 테스트를 추가한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — snapshot 교체와 job 완료 흐름을 정적으로 추적해 확정했다.
|
||||
|
||||
### REV-P7-002 — 노출 불가능한 최신 콘텐츠 랭킹 row가 이전의 노출 가능한 row를 가린다
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** 같은 콘텐츠의 노출 가능한 row 중 최신 1건 조회
|
||||
- **소유 Task:** 신규 Task 7.3 / `P7-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
외부 row는 `CONTENT_RANKING`의 `rank is not null`, inbox 성인 조건 등으로 노출 가능 여부를 확인한다. 그러나
|
||||
`latestContentRankingNewsCondition(...)`의 newer-row subquery는 member/type/target/active/visible/order만 확인하고
|
||||
`rank is not null`과 회원별 성인 조건을 확인하지 않는다.
|
||||
|
||||
따라서 더 최신 row의 `rank`가 null이면 최신 row는 외부 조건에서 제외되면서도 이전 정상 row를 subquery에서 가려 결과가 0건이 된다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `DefaultHomeFollowingQueryRepository.kt:154-166`
|
||||
- 코드: `DefaultHomeFollowingQueryRepository.kt:428-438`
|
||||
- 코드: `DefaultHomeFollowingQueryRepository.kt:454-471`
|
||||
- 테스트 공백: 현재 콘텐츠 중복 테스트는 모든 newer row가 노출 가능한 경우만 검증한다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 같은 회원·콘텐츠에 `rank=2`, 노출 가능 시각 09:00인 정상 row를 둔다.
|
||||
2. 같은 키에 `rank=null`, 노출 가능 시각 10:00인 active row를 둔다.
|
||||
3. 10:00 이후 조회하면 이전 row는 newer row 존재로 제외되고, newer row는 `rank is not null` 조건에서 제외된다.
|
||||
4. 요구 결과는 노출 가능한 row 중 최신인 09:00 row 1건이지만 실제 결과는 0건이다.
|
||||
|
||||
**영향**
|
||||
|
||||
부분 적재나 정책 변경으로 최신 inbox row가 노출 불가능해지면 유효한 이전 콘텐츠 랭킹까지 사라져 “노출 가능한 row 중 최신” 계약을 위반한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
newer-row 판정에 외부 조회와 동일한 row별 노출 조건을 적용한다. 최소한 `rank is not null`과 회원별 inbox 성인 조건의 회귀
|
||||
테스트를 추가하고 기존 시각/id tie-break 테스트를 유지한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-30 — 외부 where와 correlated subquery 조건을 대조해 확정했다.
|
||||
|
||||
## 5. plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 7에 Task 7.2/7.3, `P7-R1`, `P7-R2`, `P7-R-GATE`를 추가했다.
|
||||
- 기존 Task 7.1과 `P7-GATE`의 완료 기록은 유지했다.
|
||||
|
||||
## 6. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | working tree repository/test/E2E 정적 대조 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P7-001`, `REV-P7-002` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task 7.2/7.3 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 테스트 미실행 사유 기록 |
|
||||
|
||||
**최종 결론:** 수정 goal 필요.
|
||||
|
||||
**남은 항목:** `P7-R1` → `P7-R2` → `P7-R-GATE` 실행 후 이 문서에 수정 검증을 누적한다.
|
||||
|
||||
## 7. 수정 후 검증 기록
|
||||
|
||||
- 2026-07-30 — RED 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest" --tests "kr.co.vividnext.sodalive.v2.home.following.adapter.out.persistence.DefaultHomeFollowingQueryRepositoryTest"` 실행 결과 최신 빈 배치와 노출 불가 newer row 보완 전 컴파일/회귀 실패를 확인했다.
|
||||
- 2026-07-30 — `CREATOR_RANKING` 최신 배치 기준을 `creator_ranking_snapshot_job`의 최신 `WEEKLY`, `DONE`, `visibleFromAtUtc <= nowUtc`로 보강해 최신 완료 배치가 비어 있어도 과거 배치를 보충하지 않도록 했다.
|
||||
- 2026-07-30 — `CONTENT_RANKING` newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건을 적용해 노출 불가 최신 row가 이전 노출 가능 row를 가리지 않도록 했다.
|
||||
- 2026-07-30 — GREEN 확인: 같은 focused test 명령 재실행 결과 `BUILD SUCCESSFUL`.
|
||||
- 2026-07-30 — Gate 확인: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"`와 `./gradlew --no-daemon ktlintCheck` 재실행 결과 모두 `BUILD SUCCESSFUL`.
|
||||
- 2026-07-30 — 리뷰 게이트: Oracle reviewer가 Critical/Important blocker 없음으로 승인했다.
|
||||
|
||||
## 8. 후속 종료 판정 — 2026-07-30
|
||||
|
||||
- 최신 공개 크리에이터 랭킹 배치가 빈 결과여도 과거 배치로 fallback하지 않는 현재 job 기준 조건을 정적으로 재확인했다.
|
||||
- `CONTENT_RANKING` newer row 판정에 `rank is not null`과 회원별 inbox 성인 조건이 적용된 현재 코드를 정적으로 재확인했다.
|
||||
- 이번 후속 판정에서는 컴파일과 테스트를 재실행하지 않았고, 위 `## 7. 수정 후 검증 기록`의 기존 성공 결과를 근거로 삼았다.
|
||||
|
||||
**최종 결론:** `P7-R1`, `P7-R2` 및 `P7-R-GATE` 수정 검증 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 9. 3차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 최신 DONE job 우선의 크리에이터 랭킹 배치 판정, legacy snapshot fallback, 노출 가능한 콘텐츠 랭킹 중복 제거를 현재 working tree에서 다시 추적했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- runtime 신규 확정 발견 사항 없음. PRD의 stale 최신 배치 설명은 문서 소유 Phase의 `REV-P6-002`와 Task 6.4로 전환했으며, 기존 Phase 7 완료 판정을 유지한다.
|
||||
|
||||
## 10. 4차 정적 리뷰 — 2026-07-30
|
||||
|
||||
- 최신 공개 `WEEKLY`, `DONE` job 기준과 job 부재 시 snapshot fallback, 최신 빈 배치의 과거 랭킹 미보충을 다시 추적했다.
|
||||
- `CONTENT_RANKING`의 노출 가능한 newer row 판정에 active/rank/성인/visible/id 조건이 적용되고 전체 limit 전에 콘텐츠별 한 건만 남는지 확인했다.
|
||||
- repository 테스트가 신규 배치 공개 전후, 빈 최신 배치, rank null·성인 newer row와 동률 id 해소를 고정하는지 정적으로 확인했다.
|
||||
- 컴파일과 테스트는 사용자 지시에 따라 실행하지 않았다.
|
||||
- 신규 확정 발견 사항 없음. `REV-P7-001`, `REV-P7-002` 수정 완료와 기존 Phase 7 완료 판정을 유지한다.
|
||||
@@ -20,7 +20,6 @@
|
||||
- 유지: `DefaultHomeRecommendationQueryRepository.findAiCharacterRecommendationDetails(...)`의 상세 조립, `characterId`, `creatorId`, 원작명, 전체 채팅 수, 활성 `creatorMember` 필터는 변경하지 않는다.
|
||||
- 유지: `HomeRecommendationFacade`, `HomeRecommendationController`, `HomeRecommendationResponse.HomeAiCharacterItem`의 공개 API URL과 응답 필드는 변경하지 않는다.
|
||||
- 유지: 최근 응원/인기 커뮤니티 스냅샷 산식, window, 저장 limit, random tie-breaker 정렬은 변경하지 않는다.
|
||||
- 추가: 홈 통합 조회에서 최신 스냅샷 상세 결과가 20개 미만이면 스냅샷 캐릭터 id를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다.
|
||||
- 변경: `findAiCharacterSnapshots(...)`의 입력 window, AI 캐릭터 점수 산식, 신규 부스트 값, 팔로우 증가 수 포함, 후보 제외 조건, top 20 동점 정렬 기준만 변경한다.
|
||||
- 변경: `RecommendationSnapshotRefreshService`에는 AI 캐릭터 단일 refresh 경로를 추가하되, 기존 전체 일 refresh가 최근 응원/인기 커뮤니티까지 함께 갱신하는 동작은 유지한다.
|
||||
- 추가: 최신 AI 캐릭터 스냅샷이 없을 때만 fallback refresh orchestration을 추가한다.
|
||||
@@ -218,21 +217,6 @@
|
||||
|
||||
### Phase 7: 최종 검증과 문서 갱신
|
||||
|
||||
- [x] **Task 6.3: 홈 통합 AI 캐릭터 부족분 랜덤 보충**
|
||||
- 파일 경로:
|
||||
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md`
|
||||
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 새 `findRandomAiCharacterRecommendationIds(...)` port 계약을 추가하고, 스냅샷 상세 결과가 limit 미만이면 부족분만큼 랜덤 id를 조회한 뒤 기존 상세 조회를 재사용하는 service 테스트와 제외 id/활성 AI creator member 조건을 검증하는 repository 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`
|
||||
- GREEN: 홈 통합 조회에서 `backfillRandom = true`로 호출하고, 보충이 명시적으로 켜져 있으며 스냅샷이 존재하고 상세 결과가 limit 미만인 경우에만 랜덤 id를 먼저 조회한 뒤 기존 상세 조회로 상세와 채팅 수를 재사용한다.
|
||||
- REFACTOR: 빈 스냅샷 refresh 완료 marker와 전체보기 paging 조회는 기존 빈 결과/paging 계약을 유지한다.
|
||||
- 기대 결과: 홈 통합 AI 캐릭터 섹션은 가능한 경우 20개까지 채워지고, 공개 API 응답 필드는 변경되지 않는다.
|
||||
|
||||
- [x] **Task 7.1: focused regression 실행**
|
||||
- 파일 경로:
|
||||
- Modify: `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/plan-task.md`
|
||||
@@ -262,7 +246,7 @@
|
||||
- Feature A: Task 2.1, Task 3.1에서 AI 채팅 수 45%, 활성 사용자 수 35%, 팔로우 증가 수 20% 산식을 검증한다.
|
||||
- Feature B: Task 2.3, Task 4.1에서 KST 전날 하루를 UTC 조회 범위로 변환하는 정책을 검증한다.
|
||||
- Feature C: Task 2.2, Task 3.2에서 `ChatCharacter.createdAt` 기준 AI 전용 신규 부스트와 null/future 제외를 검증한다.
|
||||
- Feature D: Task 3.3, Task 5.3, Task 6.1, Task 6.3에서 top 20, 동점 생성일 최신순, 홈 통합 부족분 랜덤 보충, 기존 응답 스키마 유지를 검증한다.
|
||||
- Feature D: Task 3.3, Task 5.3, Task 6.1에서 top 20, 동점 생성일 최신순, 기존 응답 스키마 유지를 검증한다.
|
||||
- Feature E: Task 5.1, Task 5.2, Task 5.3에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다.
|
||||
- Non-Goals: Task 6.1과 Task 7.2에서 공개 API URL/응답 필드 변경 없음, AI 캐릭터 팔로우 생성/취소 동작 변경 없음, 관리자/ML/A-B 제외를 확인한다.
|
||||
|
||||
@@ -274,9 +258,6 @@
|
||||
- 2026-07-10: 구현 검증으로 산식/윈도우 focused 테스트 `RecommendationScorePolicyTest`, `RecommendationSnapshotWindowPolicyTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 2026-07-10: AI 캐릭터 스냅샷 집계 focused 테스트 `DefaultHomeRecommendationQueryRepositoryTest`를 실행해 전날 UTC window, 팔로우 증가량, 후보 제외, top 20/동점 정렬이 `BUILD SUCCESSFUL`임을 확인했다.
|
||||
- 2026-07-10: refresh/fallback/query focused 테스트 `RecommendationSnapshotRefreshServiceTest`, `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`를 실행해 section lock, 300ms lock wait, 1,500ms home wait, timeout 후 background 완료, double-check, fallback 연결이 `BUILD SUCCESSFUL`임을 확인했다.
|
||||
- 2026-07-22: 사용자 피드백에 따라 홈 통합 AI 캐릭터 섹션의 스냅샷 상세 결과가 20개 미만이면 랜덤 활성 AI 캐릭터로 부족분을 보충하도록 PRD와 plan-task를 보강했다. RED에서 신규 port 계약 미구현으로 `DefaultHomeRecommendationQueryRepository` 컴파일이 실패했고, GREEN에서 홈 통합 조회에만 `backfillRandom = true`를 전달해 랜덤 id 조회와 기존 상세 조회 재사용 조건을 추가했다. focused 검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- 2026-07-22: 리뷰 지적에 따라 랜덤 보충 쿼리가 전체 AI 캐릭터의 채팅 메시지를 집계한 뒤 `RAND()`/`LIMIT`하지 않도록 2단계 조회로 보정했다. 랜덤 쿼리는 활성 AI 캐릭터 id만 limit만큼 선택하고, 상세/전체 채팅 수는 기존 `findAiCharacterRecommendationDetails(...)` 경로를 재사용한다. RED에서 기존 production 구현의 `findRandomAiCharacterRecommendationDetails(...)` 참조로 `compileKotlin`이 실패했고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`와 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-07-22: 리뷰 게이트에서 전체보기 첫 페이지가 `offset == 0` 조건으로 랜덤 보충되는 blocker를 확인해 `backfillRandom` 명시 플래그로 홈 통합 조회와 전체보기 조회를 분리했다. 재검증으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`, `./gradlew ktlintCheck`, `git diff --check`, `./gradlew test`를 실행해 모두 `BUILD SUCCESSFUL` 또는 무출력 통과를 확인했고, 재리뷰에서 blocker 없이 승인받았다.
|
||||
- 2026-07-10: API 회귀 focused 테스트 `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`를 실행해 공개 응답 스키마 유지가 `BUILD SUCCESSFUL`임을 확인했다.
|
||||
- 2026-07-10: 전체 검증으로 `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`, `git diff --check`를 실행했다. `./gradlew test`는 120초 제한에서 1회 timeout되어 600초 제한으로 재실행했고, 모든 명령이 최종 `BUILD SUCCESSFUL` 또는 무출력 통과했다.
|
||||
- 2026-07-10: 리뷰 게이트에서 발견된 fallback 조건/단일 실행/section lock 해제 시점 이슈를 수정했다. 최신 스냅샷 존재 여부는 요청 page가 아니라 `offset=0, limit=1` 존재 확인으로 분리했고, fallback refresh는 단일 in-flight future로 제한했으며, scheduler AI section lock은 트랜잭션 완료 후 해제되도록 보강했다.
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
- 팔로우 증가 수는 전날 발생한 AI 캐릭터 신규 팔로우 수로 계산한다.
|
||||
- 신규 부스트는 `ChatCharacter.createdAt` 기준으로 10일 이내 1.15, 20일 이내 1.10, 30일 이내 1.05, 그 외 1.0을 적용한다.
|
||||
- 홈 통합 조회와 AI 캐릭터 전체보기 조회는 최신 AI 캐릭터 스냅샷의 점수순 결과를 사용한다.
|
||||
- 홈 통합 조회는 최신 스냅샷 상세 조회 결과가 20개 미만이면 스냅샷 캐릭터를 제외한 활성 AI 캐릭터 랜덤 조회로 부족분을 채운다.
|
||||
|
||||
---
|
||||
|
||||
@@ -118,14 +117,11 @@
|
||||
- 사용자들이 친 전체 채팅 수
|
||||
- 스냅샷 정렬은 점수 내림차순, 동일 점수면 더 늦게 생성된 캐릭터를 우선한다.
|
||||
- 조회 시점에도 비활성 또는 노출 제한 캐릭터는 제외한다.
|
||||
- 홈 통합 조회에서 스냅샷 상세 조회 결과가 20개 미만이면 이미 조회한 스냅샷 캐릭터 id를 제외하고 활성 AI 캐릭터를 랜덤으로 조회해 부족분을 뒤에 붙인다.
|
||||
|
||||
#### Edge Cases
|
||||
- 스냅샷에는 존재하지만 조회 시점에 캐릭터 또는 `creatorMember`가 비활성화된 경우 응답에서 제외한다.
|
||||
- fallback refresh 후에도 최신 스냅샷이 없으면 기존 홈 추천 API 동작과 동일하게 빈 배열을 반환한다.
|
||||
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
|
||||
- 랜덤 보충 후보가 부족하면 중복 없이 조회 가능한 AI 캐릭터만 반환한다.
|
||||
- 빈 스냅샷 refresh 완료 marker가 있거나 전체보기 paging 조회이면 랜덤 보충을 적용하지 않는다.
|
||||
|
||||
### Feature E. 스냅샷 없음 fallback refresh
|
||||
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
# 메인 홈 추천 응원 크리에이터 스냅샷 수정 Plan/Task
|
||||
|
||||
## 후속 변경 상태
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | Jenkins 회귀 수정 완료 |
|
||||
| 확정일 | 2026-08-03 |
|
||||
| 요구사항 기준 | `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 전체 |
|
||||
| 현재 Phase | Phase 1~7 및 `P5-R2` 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
| 다음 Goal | 없음 |
|
||||
|
||||
## 시나리오 계약
|
||||
- Happy path: 인기 커뮤니티와 동일한 최근 7일 UTC half-open 범위로 `CHEER_CREATOR` 점수를 계산하고, 점수순 상위 16개 스냅샷을 저장한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotRefreshServiceTest`.
|
||||
- Score: 응원 점수는 `((donationAmount * 0.45) + (fanTalkCount * 0.30) + (donationCount * 0.10)) * newBoost`다. 후원 금액은 `CHANNEL_DONATION`과 `DONATION`의 `use_can_calculate.can`을 그대로 사용하고, 후원 수는 `UseCanCalculate.useCan` 기준으로 중복 제거한다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`.
|
||||
- Boost: 신규 부스트는 크리에이터 데뷔일 기준 0~10일 `1.15`, 11~20일 `1.10`, 21~30일 `1.05`, 31일 이상 `1.0`이다. Real surface: `RecommendationScorePolicyTest`, `DefaultHomeRecommendationQueryRepositoryTest`.
|
||||
- Fallback: 최신 `CHEER_CREATOR` 스냅샷이 없으면 lock, double-check, 동일 refresh 로직 재사용, refresh 후 재조회 순서로 fallback을 실행한다. lock 대기는 최대 300ms, 홈 API refresh 완료 대기는 최대 1,500ms다. Real surface: fallback service test, `HomeRecommendationQueryServiceTest`.
|
||||
- Empty marker: `CHEER_CREATOR` refresh 결과가 0건이면 `targetId = 0` marker를 저장해 정상 refresh 완료 상태를 남기고, 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`, `HomeRecommendationQueryServiceTest`.
|
||||
- Personalized filter: 인증 회원의 `cheerCreators`에서 조회자 본인과 `CreatorFollowing.isActive == true`인 팔로우 크리에이터를 제외한다. 비활성 팔로우 이력과 비회원 조회는 기존 동작을 유지한다. Real surface: `DefaultHomeRecommendationQueryRepositoryTest`.
|
||||
- Adjacent regression: 메인 홈 추천 API URL과 `CHEER_CREATOR` 응답 필드는 변경하지 않는다. AI 캐릭터, 인기 커뮤니티, 최근 데뷔 등 다른 섹션 산식과 공개 스키마는 이번 변경으로 바꾸지 않는다. Real surface: 기존 focused tests, `HomeRecommendationControllerTest`.
|
||||
|
||||
## 범위와 전제
|
||||
@@ -28,15 +16,12 @@
|
||||
- 집계 기간은 인기 커뮤니티와 동일하게 KST 전날을 포함한 최근 7일이며, DB 조회에는 UTC half-open window를 사용한다.
|
||||
- fallback orchestration은 AI 캐릭터 전용 구현을 그대로 복사하지 않고, 섹션별 lock key와 refresh action을 받을 수 있는 최소 공통 runner를 우선 적용한다.
|
||||
- 다른 스냅샷 섹션으로 empty marker를 확장하는 작업은 이번 구현 범위에서 제외한다. 단, `CHEER_CREATOR`에 적용할 때 이후 공통화가 가능하도록 조건문/상수명을 명확히 둔다.
|
||||
- 본인·팔로우 제외는 스냅샷 생성이 아닌 `findCheerCreatorRecommendationDetails(...)` 상세 조회 시점에서 기존 `memberId`로 적용한다.
|
||||
- 기존 16명 스냅샷 후보 안에서만 필터링하며, 필터 결과가 8명 미만이어도 하위 후보 조회나 스냅샷 저장 수 확대를 하지 않는다.
|
||||
|
||||
## 기존 CHEER_CREATOR 로직 유지/변경 경계
|
||||
- 유지: `RecommendedSectionType.CHEER_CREATOR` enum 값과 code는 변경하지 않는다.
|
||||
- 유지: 홈 응원 크리에이터 응답 필드인 `creatorId`, `creatorNickname`, `creatorProfileImage`는 변경하지 않는다.
|
||||
- 유지: 홈 첫 화면 응답은 최대 8명, 스냅샷 후보 조회는 최대 16개를 사용한다.
|
||||
- 유지: 상세 조회 시점의 활성 크리에이터 필터와 차단 필터는 유지한다.
|
||||
- 유지: 비회원 조회와 비활성 팔로우 이력이 있는 크리에이터 조회는 기존 동작을 유지한다.
|
||||
- 유지: 후원 금액은 `use_can_calculate.can` 값을 그대로 합산한다.
|
||||
- 변경: 점수 가중치는 후원 금액 45%, 팬Talk 수 30%, 후원 수 10%로 바꾼다.
|
||||
- 변경: 후원 수는 `UseCanCalculate.useCan` 기준 distinct count로 계산한다.
|
||||
@@ -45,7 +30,6 @@
|
||||
- 변경: 신규 부스트는 기존 크리에이터 공통 부스트 `1.5/1.3/1.2`가 아니라 `CHEER_CREATOR` 전용 `1.15/1.10/1.05`를 사용한다.
|
||||
- 추가: `CHEER_CREATOR` 최신 스냅샷이 없을 때 fallback refresh를 실행한다.
|
||||
- 추가: `CHEER_CREATOR` refresh 결과 0건이면 empty snapshot marker를 저장한다.
|
||||
- 추가: 인증 회원 본인과 활성 팔로우 중인 크리에이터를 `cheerCreators` 상세 조회에서 제외한다.
|
||||
|
||||
## 실행 명령
|
||||
- 문서 명령 확인: `./gradlew tasks --all`
|
||||
@@ -71,32 +55,6 @@
|
||||
- REFACTOR: 기존 홈 추천 구현 파일과 테스트 파일 기준으로 task별 수정/검증 경로를 맞춘다.
|
||||
- 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다.
|
||||
|
||||
#### Task R1.1 PRD 관련 문서 경로 정합성 복구
|
||||
|
||||
**Goal 실행 `P1-R1`:** `REV-P1-001`에서 확인한 존재하지 않는 샘플 PRD 경로를 실제 가이드 경로로 정정한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-1-review.md`의 `REV-P1-001` 확정.
|
||||
- **완료 증거:** 문서 경로 정정, 대상 파일 존재 확인, `git diff --check` 통과, 전체 검증 기록 누적.
|
||||
- **범위 밖:** PRD 요구사항·결정 내용 변경, 코드·테스트 변경, 다른 문서의 링크 일괄 정리.
|
||||
- **TDD 예외 사유:** 문서 링크 정정만 수행하며 런타임 동작을 변경하지 않는다.
|
||||
|
||||
- [x] `prd.md`의 `docs/prd/sample-prd.md`를 실제 파일인 `docs/sample/sample-prd.md`로 정정한다.
|
||||
- [x] `test -f docs/sample/sample-prd.md`와 `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`로 링크 대상과 문서 diff를 확인한다.
|
||||
- [x] 무엇을/왜/어떻게 검증했는지 전체 검증 기록에 누적한다.
|
||||
|
||||
#### Task R1.2 후속 변경 상태 정합성 복구
|
||||
|
||||
**Goal 실행 `P1-R2`:** `REV-P1-002`에서 확인한 상단 상태표의 요구사항 범위·Phase·다음 Goal을 현재 계획과 검증 상태에 맞게 정리한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-1-review.md`의 `REV-P1-002` 확정.
|
||||
- **완료 증거:** 상태표가 PRD 전체, Phase 1~7, 미완료 후속 Goal을 정확히 가리킴, `git diff --check`와 문서 명령 유효성 확인, 전체 검증 기록 누적.
|
||||
- **범위 밖:** PRD 요구사항 변경, 코드·테스트 변경, 이전 검증 기록 삭제.
|
||||
- **TDD 예외 사유:** 현재 작업 상태 문구만 복구하며 런타임 동작을 변경하지 않는다.
|
||||
|
||||
- [x] 상태표의 요구사항 기준을 PRD 전체로, Phase 상태를 Phase 1~7 현재 판정으로, 다음 Goal을 실제 미완료 Goal로 맞춘다.
|
||||
- [x] `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`와 `./gradlew tasks --all`로 문서 diff와 명령 유효성을 확인한다.
|
||||
- [x] 완료 후 다음 Goal을 `P3-R2`로 갱신하고 전체 검증 기록에 무엇을/왜/어떻게 검증했는지 누적한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 산식과 부스트 정책
|
||||
@@ -186,32 +144,6 @@
|
||||
- REFACTOR: AI 캐릭터와 인기 커뮤니티 스냅샷 query가 영향받지 않았는지 focused test로 확인한다.
|
||||
- 기대 결과: `CHEER_CREATOR` 스냅샷 저장 후보만 정확히 변경된다.
|
||||
|
||||
#### Task R3.1 `CHEER_CREATOR` 후보 경계·정렬 완료 근거 보강
|
||||
|
||||
**Goal 실행 `P3-R1`:** `REV-P3-001`에서 누락이 확인된 Task 3.5의 후보 제외·상위 16개·저장 정렬 회귀 증거를 테스트로 고정한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-3-review.md`의 `REV-P3-001` 확정.
|
||||
- **완료 증거:** 실패 재현 테스트 작성·확인, 필요한 경우 최소 구현, repository/persistence focused test 통과, 전체 검증 기록 누적.
|
||||
- **범위 밖:** 점수 가중치·집계 기간·스냅샷 저장 수 변경, 랜덤 정책 변경, 다른 추천 섹션 쿼리 수정.
|
||||
|
||||
- [x] **RED:** 실제 데뷔 이력이 있지만 후원·팬Talk가 모두 0인 후보, 미래 데뷔 이력만 있는 후보, 비활성 크리에이터가 제외되는 테스트를 추가한다.
|
||||
- [x] **RED:** 17개 이상의 점수 후보에서 점수 내림차순 상위 16개만 반환되는 repository 테스트와, 저장된 동점 스냅샷이 `randomTieBreaker` 오름차순으로 조회되는 `CHEER_CREATOR` persistence 테스트를 추가한다.
|
||||
- [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 해당 조건·정렬 경로를 최소 수정하고, 테스트 누락뿐이면 프로덕션 코드는 변경하지 않는다.
|
||||
- [x] **REFACTOR/GATE:** `DefaultHomeRecommendationQueryRepositoryTest`와 `RecommendationSnapshotPersistenceAdapterTest` focused test 및 `git diff --check`를 실행해 결과를 누적한다.
|
||||
|
||||
#### Task R3.2 종료된 라이브 데뷔 이력 복구
|
||||
|
||||
**Goal 실행 `P3-R2`:** `REV-P3-002`에서 확인한 `CHEER_CREATOR` 데뷔일 계산이 채널명이 있는 종료 라이브를 제외하는 문제를 수정하고 회귀를 방지한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-3-review.md`의 `REV-P3-002` 확정과 `P1-R2` 완료.
|
||||
- **완료 증거:** 종료 라이브 재현 테스트의 의도한 실패, `CHEER_CREATOR` 데뷔 CTE 최소 수정, repository focused test·`ktlintCheck`·`git diff --check` 통과, 전체 검증 기록 누적.
|
||||
- **범위 밖:** 점수 가중치·집계 window·저장 수·정렬 변경, 다른 추천 섹션의 데뷔 정책 변경, 공개 API 변경.
|
||||
|
||||
- [x] **RED:** 활성 콘텐츠는 없고 `channel_name`이 있는 종료 라이브와 최근 7일 응원 활동만 있는 활성 크리에이터가 `CHEER_CREATOR` 후보에 포함되는 실패 테스트를 추가한다. 빈 `channel_name`의 종료 라이브는 데뷔 이력으로 인정하지 않는 경계를 함께 유지한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 현재 `lr.is_active = true` 조건 때문에 종료 라이브 후보가 누락되는 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** `findCheerCreatorSnapshots(...)`의 `creator_debut` 라이브 branch에서 `lr.is_active = true`만 제거하고, `channel_name is not null`, `channel_name <> ''`, `begin_date_time <= :snapshotAt` 조건은 유지한다.
|
||||
- [x] **REFACTOR/GATE:** repository focused test, `./gradlew ktlintCheck`, `git diff --check`를 실행하고 점수·window·후보 상한·다른 섹션 쿼리가 변경되지 않았음을 기록한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: refresh 저장과 empty marker
|
||||
@@ -246,19 +178,6 @@
|
||||
- REFACTOR: 전체 일괄 refresh 성공 로그는 유지하되, 섹션별 로그와 중복되어도 검색 가능한 event key를 사용한다.
|
||||
- 기대 결과: 운영에서 `CHEER_CREATOR` refresh 결과 0건과 실패를 구분할 수 있다.
|
||||
|
||||
#### Task R4.1 `CHEER_CREATOR` refresh 실패 로그 보강
|
||||
|
||||
**Goal 실행 `P4-R1`:** `REV-P4-001`에서 확인한 섹션별 refresh 실패 로그 누락을 보완해 성공·빈 결과·실패를 운영 로그로 구분한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-4-review.md`의 `REV-P4-001` 확정.
|
||||
- **완료 증거:** 실패 로그 RED 테스트, 최소 로그 구현, refresh focused test 통과, 전체 검증 기록 누적.
|
||||
- **범위 밖:** fallback 실패 로그 형식 변경, 로그 수집 인프라·메트릭 시스템 추가, refresh 트랜잭션 정책 변경.
|
||||
|
||||
- [x] **RED:** `refreshCheerCreatorSnapshots(nowUtc)`의 query 또는 저장 실패 시 `event=cheer_creator_recommendation_snapshot_refresh_failure`, window·`snapshotAt`, 오류 정보가 기록되고 예외는 기존처럼 전파되는 테스트를 추가한다.
|
||||
- [x] **GREEN:** 성공 경로를 변경하지 않는 최소 `runCatching` 또는 `try/catch` 로그를 추가한 뒤 원래 예외를 다시 던진다.
|
||||
- [x] **REFACTOR/GATE:** `RecommendationSnapshotRefreshServiceTest`와 `git diff --check`를 실행하고 중복 로그가 의도된 event key로 구분되는지 확인한다.
|
||||
- [x] 무엇을/왜/어떻게 검증했는지 전체 검증 기록에 누적한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: fallback refresh
|
||||
@@ -305,33 +224,6 @@
|
||||
- REFACTOR: 스냅샷 후보 16개 조회, 상세 조회 후 최대 8개 반환, 차단 필터 전달은 기존 동작을 유지한다.
|
||||
- 기대 결과: 홈 통합 조회의 최근 응원이 많은 크리에이터 섹션이 스냅샷 없음 상황을 자체 복구할 수 있다.
|
||||
|
||||
#### Task R5.1 fallback single-flight·double-check 회귀 증거 보강
|
||||
|
||||
**Goal 실행 `P5-R1`:** `REV-P5-001`에서 누락이 확인된 동일 섹션 동시 요청 single-flight와 lock 내부 double-check를 결정적 테스트로 고정한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-5-review.md`의 `REV-P5-001` 확정.
|
||||
- **완료 증거:** 동시성·double-check RED 테스트 작성·확인, 필요한 경우 최소 구현, fallback focused test 통과, 전체 검증 기록 누적.
|
||||
- **범위 밖:** worker 수 조정, timeout·lock 대기 값 변경, 다른 섹션 fallback 정책 확대.
|
||||
|
||||
- [x] **RED:** 동일 JVM에서 동시에 들어온 둘 이상의 `CHEER_CREATOR` fallback 요청이 하나의 refresh future만 공유하고 `refreshCheerCreatorSnapshots(...)`를 1회만 호출하는 테스트를 latch 기반으로 추가한다.
|
||||
- [x] **RED:** 최초 조회 뒤 lock 획득 전 다른 요청/스케줄러가 대상일 marker 또는 실제 row를 저장하면 lock 안의 double-check가 refresh를 생략하고 최신 상태를 다시 조회하는 테스트를 추가한다.
|
||||
- [x] **GREEN:** 새 테스트가 구현 결함을 드러낼 때만 `refreshFutures` 또는 lock 내부 존재 확인 경로를 최소 수정한다.
|
||||
- [x] **REFACTOR/GATE:** sleep 없이 `RecommendationSnapshotFallbackServiceTest` focused test와 `git diff --check`를 실행해 결과를 누적한다.
|
||||
|
||||
#### Task R5.2 Jenkins single-flight 테스트 scheduling race 제거
|
||||
|
||||
**Goal 실행 `P5-R2`:** 두 동시 요청이 pending refresh future를 공유하는 계약을 worker 완료 순서에 의존하지 않고 검증한다.
|
||||
|
||||
- **시작 조건:** Jenkins에서 `shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests`의 두 번째 request `Future.get(...)`이 `TimeoutException`으로 실패하고, `REV-P5-002`가 테스트 scheduling race로 확정됨.
|
||||
- **완료 증거:** Jenkins 실패 원인 기록, 결정적 테스트 최소 수정, fallback focused test 반복 통과, 직접 영향 회귀·format·문서 명령·diff 검증 결과 누적.
|
||||
- **범위 밖:** `RecommendationSnapshotFallbackService` production 동작, worker 수, 300ms lock 대기와 1,500ms 홈 대기, 다른 fallback 섹션 변경.
|
||||
|
||||
- [x] **RED:** Jenkins 실패의 `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`과 로컬 focused test 통과를 함께 기록해 worker scheduling에 따라 결과가 달라지는 기존 테스트를 확인한다.
|
||||
- [x] **RED 확인:** `./gradlew cleanTest test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest.shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests'`의 로컬 통과와 Jenkins 실패를 대조해 비결정성을 확인한다.
|
||||
- [x] **GREEN:** 수동 `CapturingExecutor.runNext()` 완료 경쟁 대신 refresh worker를 latch로 pending 상태에 유지한다. 두 요청이 홈 대기 timeout으로 반환할 때까지 refresh를 완료하지 않고 `refreshCheerCreatorSnapshots(...)` 호출이 1회인지 검증한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test를 반복 실행하고 `RecommendationSnapshotFallbackServiceTest` 전체를 실행해 single-flight, timeout 후 worker 지속, lock 내부 double-check가 모두 통과하는지 확인한다.
|
||||
- [x] **REFACTOR/GATE:** 사용하지 않는 test helper/import만 제거하고 `ktlintCheck`, `./gradlew tasks --all`, `git diff --check` 결과를 전체 검증 기록에 누적한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: API 회귀와 최종 검증
|
||||
@@ -370,106 +262,18 @@
|
||||
- REFACTOR: 문서와 코드의 산식/timeout/window 값이 다르면 구현 또는 문서를 수정한 뒤 재검증한다.
|
||||
- 기대 결과: 전체 테스트, 포맷, 문서 명령 유효성, diff 공백 검사가 모두 통과한다.
|
||||
|
||||
#### Task R6.1 `cheerCreators` item 공개 스키마 회귀 테스트 보강
|
||||
|
||||
**Goal 실행 `P6-R1`:** `REV-P6-001`에서 확인한 빈 배열 중심 스키마 검증을 실제 item의 정확한 필드 계약 검증으로 보강한다.
|
||||
|
||||
- **시작 조건:** `reviews/phase-6-review.md`의 `REV-P6-001` 확정.
|
||||
- **완료 증거:** 비어 있지 않은 item 직렬화/컨트롤러 테스트, 정확한 필드 집합 검증, API focused test 통과, 전체 검증 기록 누적.
|
||||
- **범위 밖:** DTO 필드 추가·이름 변경, API URL 변경, 다른 홈 섹션 스키마 정리.
|
||||
|
||||
- [x] **RED:** `cheerCreators`에 item을 넣고 `creatorId`, `creatorNickname`, `creatorProfileImage` 값과 필드 수 3을 검증하는 직렬화 테스트를 추가한다.
|
||||
- [x] **RED:** 홈 API 테스트에서 비어 있지 않은 `cheerCreators` item의 동일 필드 계약을 검증하고 예상 밖 필드가 없음을 확인한다.
|
||||
- [x] **GREEN:** 기존 DTO가 테스트를 만족하면 프로덕션 코드는 변경하지 않고, 계약 불일치가 드러날 때만 기존 공개 스키마로 최소 복구한다.
|
||||
- [x] **REFACTOR/GATE:** `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`, `git diff --check`를 실행해 결과를 누적한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: 본인·팔로우 크리에이터 노출 제외
|
||||
|
||||
**Phase 결과:** 인증 회원의 `cheerCreators`에서 조회자 본인과 활성 팔로우 크리에이터가 제외되고, 비활성 팔로우 이력과 비회원 조회는 기존 동작을 유지한다.
|
||||
|
||||
**선행조건:** Phase 1~6 완료와 PRD Feature D의 2026-07-31 후속 요구사항 확정.
|
||||
|
||||
**Phase 완료 조건:** `P7-T1`과 `P7-GATE` 완료, focused test·영향 범위 회귀·문서 검증 결과 누적.
|
||||
|
||||
#### Task 7.1 `cheerCreators` 상세 조회 개인화 필터 보강
|
||||
|
||||
**Goal 실행 `P7-T1`:** 기존 `memberId` 기반 상세 조회에서 본인과 활성 팔로우 크리에이터만 제외하는 최소 조회 조건을 추가한다.
|
||||
|
||||
- **시작 조건:** PRD Feature D의 본인·활성 팔로우 제외, 비활성 팔로우·비회원 유지, 16명 후보 안에서만 필터링한다는 결정 확정.
|
||||
- **완료 증거:** 아래 체크박스 전체 완료, repository focused test 통과, 직접 영향 회귀 통과, 실행 결과를 이 문서의 전체 검증 기록에 누적.
|
||||
- **범위 밖:** 스냅샷 산식·정렬·저장 수, 16명 밖 하위 후보 보충, 공개 DTO/API, 다른 추천 섹션의 팔로우 필터.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- Verify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `HomeRecommendationQueryPort.findCheerCreatorRecommendationDetails(creatorIds: List<Long>, memberId: Long?): List<HomeCheerCreatorRecommendationRecord>`
|
||||
- Produces: 같은 시그니처와 반환 타입을 유지하면서, `memberId != null`일 때만 본인과 활성 팔로우를 제외하는 상세 조회 계약.
|
||||
|
||||
- [x] **RED:** `DefaultHomeRecommendationQueryRepositoryTest`에 조회자 크리에이터, 활성 팔로우 크리에이터, 비활성 팔로우 이력만 있는 크리에이터, 관계가 없는 크리에이터를 준비한다. `memberId = viewer.id`로 조회했을 때 비활성 팔로우 이력 크리에이터와 관계가 없는 크리에이터만 반환하는 `shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails` 테스트를 작성한다. `memberId = null`은 모든 활성 후보를 유지하는 `shouldKeepAnonymousCheerCreatorDetailsWithoutMemberFilters` 테스트도 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 현재 조회가 조회자 본인과 활성 팔로우 크리에이터를 함께 반환하여 첫 번째 테스트가 의도한 assertion 실패를 내는지 확인한다.
|
||||
- [x] **GREEN:** `findCheerCreatorRecommendationDetails(...)`에 `memberId`가 있을 때 `member.id != memberId`를 적용하고, 동일 회원과 후보 크리에이터 사이에 `CreatorFollowing.isActive == true`인 row가 존재하지 않는 조건을 추가한다. `memberId == null`이면 두 조건은 적용하지 않는다.
|
||||
- [x] **GREEN 확인:** 같은 repository focused test를 재실행해 본인·활성 팔로우 제외, 비활성 팔로우·비회원 유지, 기존 양방향 차단 제외 테스트가 모두 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 이번 조건에 필요한 QueryDSL alias/helper만 남기고, `HomeRecommendationQueryServiceTest`로 기존 16명 후보 조회·필터 후 최대 8명 조립이 유지되는지 확인한다. `HomeRecommendationControllerTest`와 `ktlintCheck`로 공개 스키마·포맷 회귀를 확인하고 실제 명령·결과를 전체 검증 기록에 남긴다.
|
||||
|
||||
### Phase 7 완료 조건
|
||||
|
||||
- [x] `P7-T1`의 RED·GREEN·REFACTOR 체크박스와 완료 증거가 모두 충족됐다.
|
||||
- [x] 인증 회원 본인·활성 팔로우만 제외되고 비활성 팔로우·비회원·양방향 차단·활성 크리에이터 정책이 조합되는 것이 검증됐다.
|
||||
- [x] 스냅샷 산식·저장 수·정렬, 응답 DTO, 다른 추천 섹션에 변경이 없다.
|
||||
|
||||
#### Phase 7 Gate
|
||||
|
||||
**Goal 실행 `P7-GATE`:** `cheerCreators` 개인화 필터와 직접 영향 회귀를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P7-T1` 완료.
|
||||
- **완료 증거:** 아래 명령 전부 통과, `git diff --check` 출력 없음, 전체 검증 기록 누적.
|
||||
- **범위 밖:** 게이트 통과를 위한 테스트 삭제·완화와 관련 없는 리팩터링.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest
|
||||
./gradlew ktlintCheck
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 모든 명령이 exit code 0이고, 인증 회원 본인·활성 팔로우는 제외되며 비활성 팔로우·비회원 결과와 기존 API 스키마는 유지된다.
|
||||
|
||||
---
|
||||
|
||||
## Coverage Check
|
||||
- Feature A: Task 2.1, Task 3.2, Task 3.4에서 후원 금액 45%, 팬Talk 30%, 후원 수 10%, 후원 수 distinct 기준을 검증한다.
|
||||
- Feature B: Task 3.1, Task 3.3에서 최근 7일 KST 범위를 UTC half-open 조회 범위로 변환하고 `windowEndExclusiveUtc` 경계를 검증한다.
|
||||
- Feature C: Task 2.2, Task 3.4에서 데뷔일 기준 응원 전용 신규 부스트와 경계값을 검증한다.
|
||||
- Feature D: Task 3.5, Task 5.4, Task 6.1, Task 7.1에서 최신 `CHEER_CREATOR` 스냅샷 순서, 후보 16개/응답 8개, 본인·활성 팔로우 제외, 비활성 팔로우·비회원 유지, 기존 응답 스키마 유지를 검증한다.
|
||||
- Feature D: Task 3.5, Task 5.4, Task 6.1에서 최신 `CHEER_CREATOR` 스냅샷 순서, 후보 16개/응답 8개, 기존 응답 스키마 유지를 검증한다.
|
||||
- Feature E: Task 5.1, Task 5.2, Task 5.3, Task 5.4에서 fallback refresh 재사용, double-check, 300ms lock 대기, 1,500ms 홈 API 대기, timeout 후 background 완료, 중복 refresh 방지를 검증한다.
|
||||
- Feature F: Task 4.1, Task 4.2, Task 5.3에서 `CHEER_CREATOR` empty marker 저장, 조회 제외, 존재 여부 true, marker 기반 fallback 반복 방지를 검증한다.
|
||||
- Non-Goals: Task 4.1, Task 6.1, Task 6.3, Task 7.1에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 16명 밖 후보 보충 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다.
|
||||
- Non-Goals: Task 4.1, Task 6.1, Task 6.3에서 다른 스냅샷 섹션 marker 확장 없음, 공개 API URL/응답 필드 변경 없음, 신규 DDL 없음, 관리자/ML/A-B 제외를 확인한다.
|
||||
|
||||
## 전체 검증 기록
|
||||
- 2026-08-03: `P5-R2` GREEN/GATE로 수동 `CapturingExecutor` 완료 경쟁을 제거하고, 실제 worker를 latch로 pending 상태에 유지한 채 두 요청의 worker task 제출과 `refreshCheerCreatorSnapshots(...)` 호출이 각각 1회인지 검증하도록 수정했다. focused test는 최초 1회와 연속 10회 모두 통과했고, `RecommendationSnapshotFallbackServiceTest` 전체, `./gradlew ktlintCheck`, `./gradlew tasks --all`, `./gradlew cleanTest test`, `git diff --check`가 모두 `BUILD SUCCESSFUL` 또는 출력 없음으로 통과했다. 전체 테스트는 7분 14초 소요됐다. Oracle 고강도 리뷰도 correctness·결정성·thread cleanup·production 무변경 판단에 blocker 없이 승인했다.
|
||||
- 2026-08-03: `P5-R2` RED 확인으로 Jenkins의 `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`과 로컬 `./gradlew cleanTest test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest.shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests'` `BUILD SUCCESSFUL`을 대조했다. 같은 코드가 worker scheduling에 따라 실패·성공하여 기존 테스트의 `taskSubmitted` latch가 두 요청의 동일 future 대기를 보장하지 않는 비결정성을 확인했다.
|
||||
- 2026-07-31: Phase 1~7 3차 리뷰로 PRD·plan-task·현재 코드·테스트를 정적으로 대조했다. 이전 리뷰에서 보완한 문서 정합성, 종료 라이브 데뷔 이력, `CHEER_CREATOR` refresh 실패 로그, fallback single-flight·double-check, 공개 응답 3개 필드, 본인·활성 팔로우 제외가 현재 구현과 회귀 테스트에 유지됨을 확인했다. 각 결과는 기존 `reviews/phase-1-review.md`~`reviews/phase-7-review.md`에 3차 리뷰로 별도 누적했다. 추가 확정 발견 사항이 없어 신규 회귀 Task/Goal은 추가하지 않았다. 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. `git diff --check`는 출력 없이 통과했다. 문서 명령 유효성 확인용 `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패한 뒤 승인된 동일 명령에서 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-07-31: `P3-R2`로 `CHEER_CREATOR` 데뷔 CTE가 채널명이 있는 종료 라이브를 데뷔 이력으로 인정하도록 복구했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut`가 `AssertionFailedError`로 실패하는 것을 확인했다. 이후 `findCheerCreatorSnapshots(...)` 라이브 branch에서 `lr.is_active = true`만 제거했고, 같은 repository focused test는 `BUILD SUCCESSFUL`로 통과했다. `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL`로 통과했으며, `git diff --check`는 출력 없이 통과했다.
|
||||
- 2026-07-31: `P1-R2`로 상단 후속 변경 상태를 현재 PRD 전체, Phase 1~7 판정, 실제 미완료 Goal `P3-R2` 기준으로 정리했다. 문서 정합성 복구만 수행해 TDD 예외로 처리했다. `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`는 출력 없이 통과했고, `./gradlew tasks --all`은 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-07-31: Phase 1~7 2차 리뷰로 PRD·plan-task·현재 코드·테스트·관련 구현 이력을 정적 대조했다. `REV-P1-002`의 상단 상태표 불일치와 `REV-P3-002`의 종료 라이브 데뷔 이력 제외를 확정해 각각 `P1-R2`, `P3-R2` 신규 회귀 Task로 전환했고, Phase 2·4·5·6·7은 추가 확정 발견 사항이 없다. 결과는 기존 `reviews/phase-1-review.md`~`reviews/phase-7-review.md`에 2차 리뷰로 각각 누적했다. 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다. `git diff --check`는 출력 없이 통과했고, `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패한 후 승인된 동일 명령으로 재실행해 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-07-31: `P1-R1`, `P3-R1`, `P4-R1`, `P5-R1`, `P6-R1` 후속 보완의 최종 focused 회귀로 `./gradlew cleanTest test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`를 실행해 `BUILD SUCCESSFUL`로 통과했다. 이어서 `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없이 통과했다. 전체 `./gradlew test`는 테스트 assertion 실패가 아니라 `build/test-results/test/TEST-*.xml` 결과 파일 쓰기 실패로 중단되어 별도 환경/파일시스템 이슈 확인이 필요하다.
|
||||
- 2026-07-31: `P1-R1`로 `prd.md`의 샘플 PRD 링크를 실제 파일 `docs/sample/sample-prd.md`로 정정했다. `test -f docs/sample/sample-prd.md`와 `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`를 실행해 출력 없이 통과했다.
|
||||
- 2026-07-31: `P3-R1`로 `CHEER_CREATOR` 후보 제외·상위 16개·동점 저장 정렬 회귀 테스트를 보강했다. 리뷰 판정처럼 구현 결함은 드러나지 않아 프로덕션 코드는 변경하지 않았다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없이 통과했다.
|
||||
- 2026-07-31: `P4-R1`로 `refreshCheerCreatorSnapshots(nowUtc)` 실패 시 `event=cheer_creator_recommendation_snapshot_refresh_failure`와 window·`snapshotAt`·오류 정보를 남기고 원 예외를 전파하도록 보강했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest.shouldLogCheerCreatorRefreshFailureWithWindow`가 `AssertionFailedError`로 실패했고, GREEN 후 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotRefreshServiceTest`는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없이 통과했다.
|
||||
- 2026-07-31: `P5-R1`로 동일 섹션 동시 요청 single-flight와 lock 내부 double-check를 sleep 없이 latch 기반 테스트로 보강했다. 리뷰 판정처럼 구현 결함은 드러나지 않아 프로덕션 코드는 변경하지 않았다. `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.RecommendationSnapshotFallbackServiceTest`는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없이 통과했다.
|
||||
- 2026-07-31: `P6-R1`로 `cheerCreators` item 직렬화와 홈 API 응답의 `creatorId`, `creatorNickname`, `creatorProfileImage` 3개 필드 계약을 보강했다. 최초 controller focused test는 대상일 snapshot fixture 불일치와 테스트 환경 CDN host 기대값 불일치로 실패했고, fixture를 `RecommendationSnapshotWindowPolicy.previousKstDayUtcWindow(LocalDateTime.now(UTC)).snapshotAt` 및 실제 테스트 host 설정에 맞춘 뒤 `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest --tests kr.co.vividnext.sodalive.v2.api.home.dto.recommendation.HomeRecommendationResponseTest`가 `BUILD SUCCESSFUL`로 통과했다. 관련 `git diff --check`는 출력 없이 통과했다.
|
||||
- 2026-07-31: Phase별 리뷰 문서와 회귀 Task 추가 후 `git diff --check`를 실행해 출력 없이 통과했다. 문서 명령 유효성 확인용 `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 권한으로 실패했고, 승인 후 동일 명령을 재실행해 `BUILD SUCCESSFUL`로 통과했다. compile/test task는 실행하지 않았다.
|
||||
- 2026-07-31: Phase 1~7 구현 상태를 PRD·plan-task·현재 코드·테스트와 정적으로 대조했다. 사용자 지시에 따라 Gradle compile/test는 새로 실행하지 않았고 기존 검증 기록을 실행 증거로 참조했다. 확정 항목은 `reviews/phase-1-review.md`부터 `reviews/phase-7-review.md`까지 Phase별로 기록했으며, `REV-P1-001`, `REV-P3-001`, `REV-P4-001`, `REV-P5-001`, `REV-P6-001`을 각각 `P1-R1`, `P3-R1`, `P4-R1`, `P5-R1`, `P6-R1` 신규 회귀 Task로 전환했다. Phase 2와 Phase 7은 확정 발견 사항이 없다.
|
||||
- 2026-07-31: Phase 7 `P7-T1` 구현으로 `findCheerCreatorRecommendationDetails(...)`가 인증 회원 조회 시 조회자 본인과 `CreatorFollowing.isActive == true`인 팔로우 크리에이터를 제외하도록 보강했다. RED 확인으로 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest`를 실행해 `shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails` assertion 실패를 확인했고, GREEN 후 같은 명령은 `BUILD SUCCESSFUL`로 통과했다. 직접 영향 회귀 `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest`와 `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-07-31: 후속 요구사항으로 인증 회원 본인과 활성 팔로우 크리에이터를 `cheerCreators` 상세 조회에서 제외하는 정책을 PRD Feature D와 Phase 7 `P7-T1`/`P7-GATE`에 반영했다. 비활성 팔로우·비회원 유지와 16명 후보 밖 보충 없음을 경계로 고정했다. `git diff --check`는 출력 없이 통과했다. `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 권한으로 실패했고, 승인 후 동일 명령을 재실행해 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-07-10: PRD 기반으로 `plan-task.md`를 생성했다. 구현 전 계획 문서 작성 작업이므로 코드 테스트는 아직 실행하지 않았고, 문서 형식/명령 유효성 검증을 진행한다.
|
||||
- 2026-07-10: 문서 검증으로 `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`를 실행해 통과했다. `./gradlew tasks --all`은 일반 sandbox에서 `~/.gradle` wrapper lock 파일 접근 제한으로 실패했고, 권한 상승 재실행 결과 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-07-10: 구현 RED 확인으로 `RecommendationScorePolicyTest`는 `CHEER_NEW_BOOST_*`와 `calculateCheerCreatorNewBoost(...)` 미구현 컴파일 실패를 확인했고, `DefaultHomeRecommendationQueryRepositoryTest`는 half-open/distinct 집계 기대값 불일치 실패를 확인했다. `RecommendationSnapshotPersistenceAdapterTest`는 `CHEER_CREATOR` empty marker 미지원 실패를 확인했다.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# PRD: 메인 홈 추천 응원 크리에이터 스냅샷 수정
|
||||
|
||||
## 1. Overview
|
||||
메인 홈 추천 탭의 `CHEER_CREATOR` 스냅샷 생성과 조회를 최근 7일 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다. 인증 회원에게는 조회자 본인과 현재 활성 팔로우 중인 크리에이터를 `cheerCreators` 응답에서 제외한다.
|
||||
메인 홈 추천 탭의 `CHEER_CREATOR` 스냅샷 생성과 조회를 최근 7일 데이터 기반의 응원 점수로 수정하고, 스냅샷이 없을 때 홈 API가 동일 refresh 로직을 안전하게 재사용하도록 fallback 흐름을 보강한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
- 현재 일괄 refresh와 홈 API fallback refresh가 섹션별로 동일한 생성 로직을 공유하지 않으면 산식 drift가 발생할 수 있다.
|
||||
- 스냅샷이 없는 초기 배포, 운영 데이터 삭제, 배치 실패 상황에서 홈 조회가 매 요청마다 무거운 집계를 중복 실행하면 API 지연과 DB 부하가 커질 수 있다.
|
||||
- 집계 산식이 추천 노출 순서를 직접 바꾸므로 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 산식/부스트 경계값 테스트가 필요하다.
|
||||
- 현재 `CHEER_CREATOR` 상세 조회는 활성 크리에이터와 양방향 차단 조건만 적용하여, 인증 회원 본인이나 이미 팔로우 중인 크리에이터가 추천에 노출될 수 있다.
|
||||
|
||||
---
|
||||
|
||||
@@ -24,24 +23,21 @@
|
||||
- 홈 API는 fallback refresh 완료를 최대 1,500ms까지만 기다리고, lock 대기는 최대 300ms로 제한한다.
|
||||
- fallback refresh 실패, timeout, refresh 결과 없음은 홈 API 전체 실패로 전파하지 않고 `CHEER_CREATOR` 섹션 빈 배열로 처리한다.
|
||||
- 산식과 신규 부스트는 단위 테스트에서 경계값과 가중치 계산을 촘촘히 검증한다.
|
||||
- 인증 회원의 `cheerCreators` 상세 조회에서 조회자 본인과 `CreatorFollowing.isActive == true`인 팔로우 크리에이터를 제외한다.
|
||||
- 비활성 팔로우 이력과 비회원 조회는 기존 조회 정책을 유지한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 메인 홈 추천 API URL, 응답 필드, 응답 JSON 스키마는 변경하지 않는다.
|
||||
- `CHEER_CREATOR` 이외 추천 섹션의 산식과 조회 정책은 변경하지 않는다.
|
||||
- 관리자 화면, 수동 추천 편집, A/B 테스트, 사용자별 응원 점수·스냅샷 순서 산정은 이번 범위에 포함하지 않는다.
|
||||
- 관리자 화면, 수동 추천 편집, A/B 테스트, 개인화 추천은 이번 범위에 포함하지 않는다.
|
||||
- 후원, 팬Talk 생성/수정/삭제 자체의 도메인 동작은 변경하지 않는다.
|
||||
- 신규 추천 스냅샷 테이블을 만들지 않고, 기존 `recommendation_snapshot` 구조를 우선 재사용한다.
|
||||
- 팔로우/본인 필터링으로 8명이 채워지지 않을 때 스냅샷 저장 수나 조회 후보를 16명 이상으로 늘리는 작업은 범위에 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 회원/비회원: 메인 홈 추천 탭에서 최근 7일 응원 반응이 많았던 크리에이터를 발견하는 사용자
|
||||
- 앱 클라이언트: 기존 응답 계약을 유지한 채 `CHEER_CREATOR` 점수 순서와 인증 회원 조회 필터를 반영한 결과를 받는 클라이언트
|
||||
- 앱 클라이언트: 기존 응답 계약을 유지한 채 `CHEER_CREATOR` 추천 순서만 변경된 결과를 받는 클라이언트
|
||||
- 운영자: 최근 7일 후원/팬Talk 반응이 추천 노출에 반영되는지 확인해야 하는 운영 담당자
|
||||
|
||||
---
|
||||
@@ -50,7 +46,6 @@
|
||||
- 사용자는 메인 홈 추천 탭에서 최근 7일 응원이 많았던 크리에이터를 우선 보고 싶다.
|
||||
- 사용자는 후원 금액뿐 아니라 팬Talk와 후원 참여 횟수도 함께 반영된 추천을 보고 싶다.
|
||||
- 사용자는 신규 크리에이터가 일정 기간 동안 적절한 노출 기회를 받기를 기대한다.
|
||||
- 인증 회원은 자신과 이미 팔로우 중인 크리에이터를 제외한 새로운 응원 크리에이터를 보고 싶다.
|
||||
- 앱 클라이언트는 스냅샷이 없는 상황에서도 홈 API가 실패하지 않고 안정적으로 빈 배열 또는 생성된 스냅샷을 받기를 원한다.
|
||||
- 운영자는 배치 실패 후 첫 홈 조회가 스케줄러와 동일한 로직으로 스냅샷을 복구하기를 원한다.
|
||||
|
||||
@@ -123,15 +118,10 @@
|
||||
- 크리에이터 닉네임
|
||||
- 크리에이터 프로필 이미지
|
||||
- 조회 시점에도 기존 차단 필터와 활성 크리에이터 필터를 적용한다.
|
||||
- 인증 회원이 크리에이터인 경우 `creatorId == memberId`인 조회자 본인을 제외한다.
|
||||
- 인증 회원과 크리에이터 사이의 `CreatorFollowing.isActive == true`인 팔로우 관계가 있으면 해당 크리에이터를 제외한다.
|
||||
- 과거 언팔로우로 `CreatorFollowing.isActive == false`인 이력만 있는 크리에이터는 제외하지 않는다.
|
||||
- 비회원은 본인과 팔로우 관계를 판정할 `memberId`가 없으므로 해당 필터를 적용하지 않는다.
|
||||
- 스냅샷 후보는 최대 16개까지 조회하고, 상세 조회/필터링 후 홈 첫 화면에는 최대 8명을 반환한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 최신 스냅샷 row가 존재하지만 조회 필터로 모두 제외되면 빈 배열을 반환한다.
|
||||
- 본인과 활성 팔로우 크리에이터를 제외한 결과가 8명보다 적으면 16명 스냅샷 후보 범위 안에서 조회 가능한 수만 반환하고, 16명 밖의 하위 후보로 보충하지 않는다.
|
||||
- 상세 조회 결과가 스냅샷 저장 개수보다 적어도 홈 조회 전체는 성공 처리한다.
|
||||
- 스냅샷 정렬 순서와 응답 순서는 일치해야 한다.
|
||||
|
||||
@@ -185,7 +175,6 @@
|
||||
- 기존 `kr.co.vividnext.sodalive.v2.recommendation` 패키지 경계와 `v2.api.home`에서 `v2.recommendation`을 호출하는 의존 방향을 유지한다.
|
||||
- 기존 `RecommendationSnapshot`, `RecommendationSnapshotPort`, `HomeRecommendationQueryPort` 기반 저장/조회 구조를 재사용한다.
|
||||
- 공개 API 응답 DTO는 필드 추가 없이 유지한다.
|
||||
- 본인과 활성 팔로우 제외는 기존 `memberId`를 사용하는 `findCheerCreatorRecommendationDetails(...)` 상세 조회 경로에서 적용하고, 스냅샷 생성 산식과 저장 데이터는 변경하지 않는다.
|
||||
- 스케줄러 refresh와 fallback refresh는 산식, 기간, 저장 limit, 정렬 기준이 갈라지지 않도록 같은 application service 경로를 사용한다.
|
||||
- fallback refresh 기능은 AI 캐릭터 전용 구현을 복사하기보다 섹션별로 재사용 가능한 형태를 우선 검토한다. 단, 과도한 일반화가 필요하면 `CHEER_CREATOR`에 필요한 최소 추상화만 적용한다.
|
||||
- `CHEER_CREATOR` 집계는 정확한 top 후보를 위해 최종 점수 계산 전 candidate pre-limit를 두지 않는다.
|
||||
@@ -219,9 +208,6 @@
|
||||
- 후원 수는 `UseCanCalculate.useCan`이 같은 row를 1개 후원 이벤트로 보고 중복 제거한다.
|
||||
- 팬Talk 수는 `CreatorCheers.isActive == true`인 row 수로 계산한다.
|
||||
- 빈 결과 marker 정책은 다른 스냅샷 섹션에도 확장하는 것이 맞지만, 이번 구현 범위에서는 `CHEER_CREATOR`에만 적용한다.
|
||||
- 인증 회원 본인과 활성 팔로우 중인 크리에이터는 `cheerCreators`에서 제외하고, 비활성 팔로우 이력은 제외 근거로 사용하지 않는다.
|
||||
- 필터링 후 8명 미만이어도 기존 16명 스냅샷 후보 범위를 넘어서 보충하지 않는다.
|
||||
- 비회원은 기존 `CHEER_CREATOR` 조회 결과를 유지한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -258,7 +244,7 @@
|
||||
---
|
||||
|
||||
## 13. Related Documents
|
||||
- `docs/sample/sample-prd.md`
|
||||
- `docs/prd/sample-prd.md`
|
||||
- `docs/agent-guides/작업절차.md`
|
||||
- `docs/agent-guides/문서유지보수.md`
|
||||
- `docs/20260529_메인_홈_추천_API/prd.md`
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
# Phase 1 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / Task 1.1 |
|
||||
| 기준 commit 또는 working tree | `5123494e` 기준 working tree |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md` |
|
||||
| 리뷰 상태 | 보완 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
- PRD와 구현 계획이 같은 작업 디렉터리에 있고 구현 기준을 충분히 고정했는지 확인한다.
|
||||
- 문서 참조 경로와 완료 체크가 현재 저장소 근거와 일치하는지 확인한다.
|
||||
- 코드·테스트 동작과 다른 Phase의 구현 품질은 제외한다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
- 심각도는 `docs/sample/sample-review.md`의 Blocker/High/Medium/Low 기준을 사용한다.
|
||||
- 존재하지 않는 근거 문서 링크는 문서 정합성 문제인 Low로 판정한다.
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
- 계획: Task 1.1
|
||||
- 문서: `prd.md`의 Related Documents, `plan-task.md`의 시나리오 계약·범위·Phase 분해
|
||||
- 정적 검증: `test -e docs/prd/sample-prd.md`, `test -e docs/sample/sample-prd.md`, `git diff --check`
|
||||
- 문서 명령 검증: `./gradlew tasks --all`은 sandbox 권한 실패 후 승인된 동일 명령에서 `BUILD SUCCESSFUL`
|
||||
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P1-001` | Low | 보완 완료 | PRD의 샘플 문서 링크가 실제 경로와 다르다 | Task R1.1 | `P1-R1` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-P1-001 — PRD의 샘플 문서 링크가 실제 경로와 다르다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** 문서 유지보수 규칙의 샘플 PRD 참조
|
||||
- **소유 Task:** Task R1.1
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`prd.md`는 `docs/prd/sample-prd.md`를 관련 문서로 가리키지만 해당 파일은 없고, 가이드가 지정한 실제 파일은 `docs/sample/sample-prd.md`다.
|
||||
|
||||
**영향**
|
||||
|
||||
후속 요구사항 보강 시 잘못된 템플릿 경로를 따라가 문서 작성 절차가 중단될 수 있다. 런타임 영향은 없다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
요구사항 내용은 바꾸지 않고 관련 문서 경로 한 곳만 실제 파일로 정정한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — 두 경로의 파일 존재 여부를 정적으로 확인해 확정했다.
|
||||
- 2026-07-31 — `prd.md` 링크를 `docs/sample/sample-prd.md`로 정정하고 `test -f docs/sample/sample-prd.md`, `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md` 출력 없음으로 보완 완료했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 1에 Task R1.1 / `P1-R1`을 추가했다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | PRD·plan-task·가이드 대조 완료 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P1-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task R1.1 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 파일 존재 확인과 diff 공백 검사 |
|
||||
|
||||
**최종 결론:** 보완 완료
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
---
|
||||
|
||||
## 9. 2차 리뷰 기록 — 2026-07-31
|
||||
|
||||
### 리뷰 정보·범위
|
||||
|
||||
- **기준:** `5123494e` 기준 미커밋 working tree, PRD·`plan-task.md`·문서 유지보수 가이드.
|
||||
- **목적:** 후속 요구사항과 Phase 7 구현 이후 상단 작업 상태가 현재 근거와 일치하는지 점검한다.
|
||||
- **검증:** `plan-task.md` 상태표·Phase 1~7·전체 검증 기록을 정적 대조했다. 사용자가 컨파일·테스트 통과 상태를 전제로 제공했으므로 Gradle compile/test는 실행하지 않았다.
|
||||
|
||||
### 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P1-002` | Low | 보완 완료 | 상단 후속 변경 상태가 현재 계획·검증 상태와 다르다 | Task R1.2 | `P1-R2` |
|
||||
|
||||
#### REV-P1-002 — 상단 후속 변경 상태가 현재 근거와 다르다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 보완 완료
|
||||
- **관련 요구사항:** 문서 유지보수 규칙의 현재 상태·활성/다음 Goal 기록
|
||||
- **근거:** `plan-task.md:7-12`는 요구사항 기준을 Feature D로만 표시하고 Phase 7을 현재 Phase에서 누락하며, 다음 Goal로 이전 XML 파일 쓰기 실패 조사를 유지한다. 반면 문서 본문은 Feature A~F와 Phase 7 완료를 기록하고, 사용자는 현재 컨파일·테스트가 통과했다고 명시했다.
|
||||
- **영향:** 다음 작업자가 이미 종료된 환경 이슈를 다음 Goal로 오인하거나 전체 요구사항 범위를 Feature D로 잘못 판단할 수 있다. 런타임 영향은 없다.
|
||||
- **권장 조치:** 상태표만 현재 PRD 전체, Phase 1~7, 미완료 후속 Goal 기준으로 정리한다.
|
||||
- **판정 기록:** 2026-07-31 — 상태표와 Phase 7·전체 검증 기록을 정적 대조해 확정했다.
|
||||
- **보완 기록:** 2026-07-31 — `plan-task.md` 상단 상태표를 PRD 전체, Phase 1~7 후속 보완 상태, 다음 Goal `P3-R2` 기준으로 정리했다. `git diff --check -- docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/plan-task.md`는 출력 없이 통과했고, `./gradlew tasks --all`은 `BUILD SUCCESSFUL`로 통과했다.
|
||||
|
||||
### plan·goal 전환과 종료 판정
|
||||
|
||||
- `plan-task.md` Phase 1에 Task R1.2 / `P1-R2`를 추가했다.
|
||||
- **최종 결론:** 보완 완료.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
---
|
||||
|
||||
## 10. 3차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 1 / Task 1.1·Task R1.1~R1.2, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `prd.md`, `plan-task.md`의 상태표·시나리오 계약·Phase 1~7·전체 검증 기록, `docs/agent-guides/작업절차.md`, `docs/agent-guides/문서유지보수.md`.
|
||||
- **검증 방법:** PRD·plan-task 동시 존재, 실제 샘플 문서 경로, 완료 Task와 현재 상태·활성/다음 Goal의 일치 여부를 정적으로 대조했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음. 상단 상태만 3차 리뷰 완료로 갱신했다.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,79 +0,0 @@
|
||||
# Phase 2 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 2 / Task 2.1~2.2 |
|
||||
| 기준 commit 또는 working tree | 구현 commit `c9e35f2e`, 현재 `5123494e` 기준 working tree |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | PRD Feature A·C, `plan-task.md` Phase 2 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
- 응원 점수 가중치와 전용 신규 부스트가 PRD 값 및 경계일 계약과 일치하는지 확인한다.
|
||||
- 기존 크리에이터·AI·커뮤니티 점수 정책 값의 회귀 여부를 정적으로 확인한다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
- 확정 가중치 `0.45/0.30/0.10`, 부스트 `1.15/1.10/1.05/1.0`, 경계일 `0·10/11·20/21·30/31`을 기준으로 판정한다.
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
- 코드: `RecommendationScoreSpec.kt`, `RecommendationScorePolicy.kt`
|
||||
- 테스트: `RecommendationScorePolicyTest.shouldCalculateCheerScore`, `shouldApplyCheerCreatorNewBoostByDebutDays`
|
||||
- 이력: `git show c9e35f2e`
|
||||
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았으며 plan-task의 기존 통과 기록을 참조했다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
- 점수 함수와 DB가 공유하는 상수 값이 PRD와 일치한다.
|
||||
- 0일, 10/11일, 20/21일, 30/31일 단위 테스트가 모두 존재한다.
|
||||
- 기존 `calculateCreatorNewBoost(...)` 값은 `1.5/1.3/1.2`로 유지된다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 코드·테스트·구현 이력 대조 완료 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검토 및 기존 검증 기록 참조 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
---
|
||||
|
||||
## 9. 2차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 2 / Task 2.1~2.2, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `RecommendationScoreSpec.kt:16-18,29-31`, `RecommendationScorePolicy.kt:21-29,67-78`, `RecommendationScorePolicyTest.shouldCalculateCheerScore`, `shouldApplyCheerCreatorNewBoostByDebutDays`.
|
||||
- **검증 방법:** PRD의 `0.45/0.30/0.10`, `1.15/1.10/1.05/1.0`, 10/20/30일 경계를 코드·테스트와 정적 대조했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
---
|
||||
|
||||
## 10. 3차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 2 / Task 2.1~2.2, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `RecommendationScoreSpec.kt:16-18,29-31`, `RecommendationScorePolicy.kt:21-29,67-78`, `RecommendationScorePolicyTest.shouldCalculateCheerScore`, `shouldApplyCheerCreatorNewBoostByDebutDays`.
|
||||
- **검증 방법:** 점수 가중치 `0.45/0.30/0.10`, 부스트 `1.15/1.10/1.05/1.0`, 0·10/11·20/21·30/31일 경계와 기존 크리에이터 부스트 유지 여부를 코드·테스트로 정적 대조했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,130 +0,0 @@
|
||||
# Phase 3 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 3 / Task 3.1~3.5 |
|
||||
| 기준 commit 또는 working tree | 구현 commit `4f348c36`, 보정 commit `6da2378b`, 현재 `5123494e` 기준 working tree |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | PRD Feature A~C, `plan-task.md` Phase 3 |
|
||||
| 리뷰 상태 | 보완 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
- 최근 7일 UTC half-open window, 후원·팬Talk 집계, DB-side 점수·부스트, 후보·정렬·limit 구현을 확인한다.
|
||||
- 완료 처리된 Task의 테스트 증거가 명시된 경계 조건을 직접 고정하는지 확인한다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
- 구현 결함과 완료 증거 누락을 구분한다. 이번 발견은 현재 코드 동작 위반이 아니라 회귀 테스트 근거 누락으로 Low다.
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
- 코드: `RecommendationSnapshotRefreshService.kt`, `RecommendationSnapshotWindowPolicy.kt`, `DefaultHomeRecommendationQueryRepository.kt`
|
||||
- 테스트: `RecommendationSnapshotRefreshServiceTest`, `DefaultHomeRecommendationQueryRepositoryTest`, `RecommendationSnapshotPersistenceAdapterTest`
|
||||
- 이력: `git show 4f348c36`, `git show 6da2378b`
|
||||
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P3-001` | Low | 보완 완료 | Task 3.5의 일부 후보·상위 16개·동점 정렬 완료 증거가 직접 테스트로 고정되지 않았다 | Task R3.1 | `P3-R1` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-P3-001 — Task 3.5의 일부 완료 증거가 직접 테스트로 고정되지 않았다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** Feature A·C Edge Cases, Task 3.5
|
||||
- **소유 Task:** Task R3.1
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
현재 테스트는 donation 종류·상태·half-open 경계, distinct 후원 수, 점수/부스트, 데뷔 이력 없음과 `limit = 1` 점수 우선순위를 검증한다. 그러나 Task 3.5에 명시된 다음 계약의 직접 테스트는 확인되지 않았다.
|
||||
|
||||
- 실제 데뷔 이력은 있으나 후원·팬Talk가 모두 0인 후보 제외
|
||||
- 미래 데뷔 이력만 있는 후보와 비활성 크리에이터 제외
|
||||
- 17개 이상 후보의 상위 16개 제한
|
||||
- `CHEER_CREATOR` 저장 row의 동점 `randomTieBreaker` 오름차순 조회
|
||||
|
||||
코드는 해당 조건을 구현하고 있어 현재 런타임 결함으로 판정하지 않는다.
|
||||
|
||||
**영향**
|
||||
|
||||
후속 native SQL 수정에서 후보 조건·저장 수·동점 순서가 회귀해도 focused test가 이를 직접 잡지 못할 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
프로덕션 코드를 선제 변경하지 않고 누락된 경계 테스트를 먼저 추가하며, 실제 실패가 드러나는 조건만 최소 수정한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — 테스트명·fixture·assertion과 Task 3.5 체크리스트를 대조해 확정했다.
|
||||
- 2026-07-31 — 후보 제외, 상위 16개 제한, `CHEER_CREATOR` 동점 `randomTieBreaker` 오름차순 조회 테스트를 추가했다. focused repository/persistence test는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 3에 Task R3.1 / `P3-R1`을 추가했다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | query·window·정책·테스트 대조 완료 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P3-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task R3.1 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검색·구현 이력 확인 |
|
||||
|
||||
**최종 결론:** 보완 완료
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
---
|
||||
|
||||
## 9. 2차 리뷰 기록 — 2026-07-31
|
||||
|
||||
### 리뷰 정보·범위
|
||||
|
||||
- **기준:** `5123494e` 기준 미커밋 working tree, PRD Feature A~C, `plan-task.md` Phase 3, 선행 홈 추천 PRD Feature E.
|
||||
- **목적:** 최종 SQL의 7일 window·집계·점수·데뷔일·후보 조건을 현재 요구사항과 다시 대조한다.
|
||||
- **검증:** `findCheerCreatorSnapshots(...)` SQL과 관련 repository 테스트, `075ca88f` 종료 라이브 데뷔 판정 보강 이력을 정적 추적했다. Gradle compile/test는 실행하지 않았다.
|
||||
|
||||
### 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P3-002` | High | 보완 완료 | 종료된 라이브가 `CHEER_CREATOR` 데뷔 이력에서 제외된다 | Task R3.2 | `P3-R2` |
|
||||
|
||||
#### REV-P3-002 — 종료된 라이브가 `CHEER_CREATOR` 데뷔 이력에서 제외된다
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 보완 완료
|
||||
- **관련 요구사항:** PRD Feature C, Task 3.4
|
||||
- **근거:** 현재 PRD는 데뷔일을 선행 홈 추천 PRD와 동일하게 계산하도록 한다(`prd.md:102-109`). 선행 PRD는 `channel_name`이 있는 종료 라이브도 데뷔 이력으로 인정하고 `live_room.is_active`를 조건으로 사용하지 않도록 확정한다(`docs/20260529_메인_홈_추천_API/prd.md:157-159`). 그러나 `DefaultHomeRecommendationQueryRepository.kt:589-594`의 `CHEER_CREATOR` 데뷔 CTE는 `lr.is_active = true`를 요구한다. 관련 스냅샷 테스트는 빈 채널명과 활성 라이브만 검증하고 종료 라이브 경계를 고정하지 않는다.
|
||||
- **재현 경로:** 활성 콘텐츠는 없고 채널명이 있는 `is_active = false` 종료 라이브와 최근 7일 응원 활동만 있는 활성 크리에이터를 준비한다. 현재 SQL에서는 `creator_debut` row가 생성되지 않아 후보에서 제외된다.
|
||||
- **영향:** 정상적으로 라이브를 종료한 크리에이터가 응원 점수가 있어도 스냅샷 후보에서 누락되고, 더 늦은 활성 콘텐츠가 있으면 실제 최초 데뷔일보다 높은 신규 부스트를 받을 수 있다.
|
||||
- **권장 조치:** `CHEER_CREATOR` CTE의 라이브 branch에서 `lr.is_active = true`만 제거하고, 종료 라이브·빈 채널명 경계를 repository 회귀 테스트로 고정한다.
|
||||
- **판정 기록:** 2026-07-31 — 요구사항·SQL·선행 보강 commit·테스트 누락을 정적 대조해 확정했다.
|
||||
- **보완 기록:** 2026-07-31 — 채널명이 있는 종료 라이브와 최근 7일 응원 활동만 있는 크리에이터가 `CHEER_CREATOR` 후보에 포함되는 RED 테스트를 추가했고, 빈 `channel_name` 종료 라이브 제외 경계를 함께 고정했다. RED 확인에서 `shouldIncludeEndedLiveWithChannelNameAsCheerCreatorDebut`가 `AssertionFailedError`로 실패했고, `findCheerCreatorSnapshots(...)` 라이브 branch의 `lr.is_active = true`만 제거한 뒤 repository focused test가 `BUILD SUCCESSFUL`로 통과했다. `./gradlew ktlintCheck`도 `BUILD SUCCESSFUL`로 통과했으며, `git diff --check`는 출력 없이 통과했다.
|
||||
|
||||
### plan·goal 전환과 종료 판정
|
||||
|
||||
- `plan-task.md` Phase 3에 Task R3.2 / `P3-R2`를 추가했다.
|
||||
- **최종 결론:** 보완 완료.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
---
|
||||
|
||||
## 10. 3차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 3 / Task 3.1~3.5·Task R3.1~R3.2, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `RecommendationSnapshotWindowPolicy.kt:7-34`, `DefaultHomeRecommendationQueryRepository.kt:573-647,1185-1227`, `RecommendationSnapshotRefreshService.kt:91-127`, 산식·half-open 경계·후원 distinct·후보 상한·종료 라이브·동점 정렬 repository/persistence 테스트.
|
||||
- **검증 방법:** 최근 7일 UTC half-open window, `CHANNEL_DONATION`·`DONATION`, `UseCanCalculate.useCan` distinct 후원 수, active 팬Talk, DB-side 점수·부스트, 미래/데뷔 없음/비활성 후보 제외, 종료 라이브 데뷔 이력, 점수순 상위 16개와 저장 정렬을 정적으로 추적했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,101 +0,0 @@
|
||||
# Phase 4 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 4 / Task 4.1~4.3 |
|
||||
| 기준 commit 또는 working tree | 구현 commit `64b05dee`, `ce43cf2c`, 현재 `5123494e` 기준 working tree |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | PRD Feature F·Metrics, `plan-task.md` Phase 4 |
|
||||
| 리뷰 상태 | 보완 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
- empty marker 저장·조회 제외·존재 판정·실제 row 대체를 확인한다.
|
||||
- `CHEER_CREATOR` refresh 성공·실패 관측성 완료 여부를 확인한다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
- 응답/데이터 무결성 문제와 운영 관측성 누락을 구분한다. 섹션 실패 로그 누락은 Low다.
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
- 코드: `RecommendationSnapshotPersistenceAdapter.kt`, `RecommendationSnapshotRepository.kt`, `RecommendationSnapshotRefreshService.kt`
|
||||
- 테스트: `RecommendationSnapshotPersistenceAdapterTest`, `RecommendationSnapshotRefreshServiceTest`
|
||||
- 정적 검색: `cheer_creator_recommendation_snapshot_refresh_success|failure`
|
||||
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P4-001` | Low | 보완 완료 | `CHEER_CREATOR` 섹션별 refresh 실패 로그가 없다 | Task R4.1 | `P4-R1` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-P4-001 — `CHEER_CREATOR` 섹션별 refresh 실패 로그가 없다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** PRD Metrics, Task 4.3 GREEN·기대 결과
|
||||
- **소유 Task:** Task R4.1
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`refreshCheerCreatorSnapshots(...)`는 커밋 후 `event=cheer_creator_recommendation_snapshot_refresh_success`를 남긴다. 반면 query 또는 저장 실패를 같은 섹션 event로 기록하는 코드는 없고, 일괄 refresh의 공통 실패 로그 또는 fallback 공통 실패 로그만 남는다.
|
||||
|
||||
**영향**
|
||||
|
||||
운영에서 스케줄러·fallback 중 어느 경로에서 `CHEER_CREATOR` 생성이 실패했는지 섹션 event만으로 일관되게 집계하기 어렵다. 저장/응답 동작 자체의 결함은 확인되지 않았다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
성공 경로와 예외 전파를 유지하면서 섹션 실패 event와 window·오류 정보를 최소 추가한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — 코드와 테스트 전체에서 섹션 실패 event가 없음을 정적 검색해 확정했다.
|
||||
- 2026-07-31 — 실패 로그 RED 테스트를 추가해 `AssertionFailedError`를 확인한 뒤 `refreshCheerCreatorSnapshots`에 최소 실패 로그와 예외 재전파를 추가했다. `RecommendationSnapshotRefreshServiceTest`는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 4에 Task R4.1 / `P4-R1`을 추가했다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | marker·로그 경로 확인 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P4-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task R4.1 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검색과 코드 대조 |
|
||||
|
||||
**최종 결론:** 보완 완료
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
---
|
||||
|
||||
## 9. 2차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 4 / Task 4.1~4.3·Task R4.1, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `RecommendationSnapshotPersistenceAdapter.kt:38-50,73-96`, `RecommendationSnapshotRepository.kt:10-55`, `RecommendationSnapshotRefreshService.kt:91-127`, marker 대체·조회 제외·존재 판정·성공/실패 로그 테스트.
|
||||
- **검증 방법:** marker가 응답 조회에서 제외되면서 대상일 refresh 존재 판정에는 포함되는지, 실제 row 재실행이 marker를 대체하는지, 성공·실패 event가 분리되는지를 정적 대조했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
---
|
||||
|
||||
## 10. 3차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 4 / Task 4.1~4.3·Task R4.1, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `RecommendationSnapshotPersistenceAdapter.kt:38-50,73-96`, `RecommendationSnapshotRepository.kt:10-55`, `RecommendationSnapshotRefreshService.kt:91-127`, marker 저장·조회 제외·존재 판정·실제 row 대체·성공/실패 로그 테스트.
|
||||
- **검증 방법:** `CHEER_CREATOR` 빈 결과가 `targetId = 0` marker로 저장되고 응답 조회에서는 제외되는지, 대상일 존재 판정과 실제 row 재실행 대체가 유지되는지, 성공·실패 event가 구분되고 원 예외가 전파되는지 정적으로 대조했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,118 +0,0 @@
|
||||
# Phase 5 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 5 / Task 5.1~5.4 |
|
||||
| 기준 commit 또는 working tree | 구현 commit `391acf9e`, `7d0cf0a8`, 현재 `5123494e` 기준 working tree |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | PRD Feature E·F, `plan-task.md` Phase 5 |
|
||||
| 리뷰 상태 | 보완 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
- 대상일 조회, section lock, lock 내부 double-check, single-flight, timeout·실패 격리, refresh 후 재조회 흐름을 확인한다.
|
||||
- 구현된 동시성 계약의 직접 회귀 테스트가 존재하는지 확인한다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
- 구현에는 해당 경로가 존재하지만 직접 테스트가 없는 경우 완료 증거 누락인 Low로 판정한다.
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
- 코드: `RecommendationSnapshotFallbackService.kt`, `HomeRecommendationQueryService.kt`
|
||||
- 테스트: `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`
|
||||
- 정적 검색: `single|동시|refreshFutures|double-check`와 latch 기반 테스트
|
||||
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P5-001` | Low | 보완 완료 | 동일 섹션 single-flight와 lock 내부 double-check의 직접 회귀 테스트가 없다 | Task R5.1 | `P5-R1` |
|
||||
| `REV-P5-002` | Medium | 보완 완료 | single-flight 테스트가 worker 완료 순서에 의존해 Jenkins에서 timeout된다 | Task R5.2 | `P5-R2` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-P5-001 — single-flight와 double-check의 직접 회귀 테스트가 없다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** Feature E, Task 5.1·5.3
|
||||
- **소유 Task:** Task R5.1
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
구현은 section별 `refreshFutures`와 lock 획득 후 `hasSnapshot(...)` 재확인을 수행한다. 현재 테스트는 lock miss, refresh 실패, timeout 후 worker 지속, marker 선존재, AI 작업 중 CHEER 독립 실행을 검증하지만 다음 경쟁 조건을 직접 재현하지 않는다.
|
||||
|
||||
- 동일 섹션의 동시 요청이 실제 refresh 1회만 공유하는지
|
||||
- 최초 조회 뒤 lock 진입 전에 다른 실행 주체가 row/marker를 저장했을 때 refresh를 생략하는지
|
||||
|
||||
**영향**
|
||||
|
||||
향후 executor·future·lock 코드 변경에서 중복 refresh 방지의 핵심 경쟁 조건이 깨져도 focused test가 탐지하지 못할 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
sleep 대신 latch와 결정적 fake를 사용해 두 경쟁 조건을 고정하고, 실패가 확인될 때만 프로덕션 코드를 최소 수정한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — fallback 테스트 전체와 구현의 동시성 분기를 대조해 확정했다.
|
||||
- 2026-07-31 — 동일 섹션 동시 요청 single-flight와 lock 내부 double-check를 latch 기반 테스트로 추가했다. 현 구현이 테스트를 만족해 프로덕션 코드는 변경하지 않았고, `RecommendationSnapshotFallbackServiceTest`는 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 5에 Task R5.1 / `P5-R1`을 추가했다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | fallback 코드·테스트 분기 대조 완료 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P5-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task R5.1 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검색과 기존 기록 참조 |
|
||||
|
||||
**최종 결론:** 보완 완료
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
---
|
||||
|
||||
## 9. 2차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 5 / Task 5.1~5.4·Task R5.1, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `RecommendationSnapshotFallbackService.kt:81-205`, `HomeRecommendationQueryService.kt:112-136`, `RecommendationSnapshotFallbackServiceTest`, `HomeRecommendationQueryServiceTest`.
|
||||
- **검증 방법:** 대상일 exact snapshot 조회, marker 존재 판정, section lock·double-check·single-flight, 300ms/1,500ms, timeout 후 worker 유지, refresh 실패 격리, 16명 후보 재조회를 코드·테스트와 정적 대조했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
---
|
||||
|
||||
## 10. 3차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 5 / Task 5.1~5.4·Task R5.1, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `RecommendationSnapshotFallbackService.kt:28-245`, `HomeRecommendationQueryService.kt:112-136`, fallback service와 query service의 대상일·marker·lock miss·timeout·실패·동시성 테스트.
|
||||
- **검증 방법:** 대상일 exact snapshot, section별 lock key, 300ms lock 대기, 1,500ms 홈 대기, lock 내부 double-check, section별 single-flight, timeout 후 worker 유지, 실패 격리, refresh 후 재조회와 16명 후보 전달을 정적으로 추적했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
---
|
||||
|
||||
## 11. 4차 리뷰 기록 — 2026-08-03
|
||||
|
||||
- **리뷰 대상:** Phase 5 / Task R5.1의 `shouldShareSingleCheerCreatorRefreshFutureForConcurrentRequests` Jenkins 실패.
|
||||
- **검토 근거:** Jenkins `RecommendationSnapshotFallbackServiceTest.kt:322 TimeoutException`, `RecommendationSnapshotFallbackService.kt:141-149`, 테스트의 `CapturingExecutor`와 latch 흐름, 로컬 `cleanTest` focused test 실행, JDK 17 `CompletableFuture`·`CountDownLatch` 계약.
|
||||
- **확정 발견 사항:** `REV-P5-002`. `taskSubmitted` latch는 worker task가 executor에 전달됐다는 사실만 보장하며, refresh future가 `refreshFutures`에 게시됐거나 두 요청이 같은 future에서 대기 중임을 보장하지 않는다. Jenkins에서 첫 worker가 먼저 완료되면 reference가 제거되고 두 번째 요청은 실행되지 않는 새 captured task를 기다려 바깥 `get(1s)`에서 timeout된다.
|
||||
- **판정:** production single-flight 결함 근거는 없다. timeout 확대는 경쟁을 숨기므로 제외하고, refresh를 latch로 pending 상태에 유지한 채 두 요청의 refresh 호출 횟수 1회를 검증하도록 테스트만 수정한다.
|
||||
- **plan·goal 전환:** `plan-task.md` Phase 5에 Task R5.2 / `P5-R2`를 추가했다.
|
||||
- **검증 결과:** focused test 최초 1회와 연속 10회, `RecommendationSnapshotFallbackServiceTest` 전체, `ktlintCheck`, `tasks --all`, 전체 `cleanTest test`, `git diff --check`가 모두 통과했다. Oracle 고강도 리뷰도 blocker 없이 승인했다.
|
||||
- **최종 결론:** `P5-R2` 보완 완료.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,101 +0,0 @@
|
||||
# Phase 6 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 6 / Task 6.1~6.3 |
|
||||
| 기준 commit 또는 working tree | 현재 `5123494e` 기준 working tree와 plan-task 기존 검증 기록 |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | PRD Non-Goals·Feature D, `plan-task.md` Phase 6 |
|
||||
| 리뷰 상태 | 보완 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
- 홈 API URL과 `cheerCreators` item의 공개 필드가 유지되는지 확인한다.
|
||||
- 완료 처리된 API 스키마 회귀 테스트가 비어 있지 않은 item 계약을 직접 고정하는지 확인한다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
- DTO 구현은 맞지만 회귀 테스트가 필드 계약을 검출하지 못하는 경우 완료 증거 누락인 Low로 판정한다.
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
- 코드: `HomeRecommendationResponse.kt`, `HomeRecommendationFacade.kt`, `HomeRecommendationQueryPort.kt`
|
||||
- 테스트: `HomeRecommendationControllerTest`, `HomeRecommendationResponseTest`
|
||||
- 기존 실행 증거: plan-task의 2026-07-10, 2026-07-31 검증 기록
|
||||
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P6-001` | Low | 보완 완료 | `cheerCreators` item의 정확한 3개 필드 계약이 테스트로 고정되지 않았다 | Task R6.1 | `P6-R1` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-P6-001 — `cheerCreators` item의 정확한 3개 필드 계약이 테스트로 고정되지 않았다
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 확정
|
||||
- **관련 요구사항:** Feature D, Non-Goals, Task 6.1
|
||||
- **소유 Task:** Task R6.1
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
응답 DTO는 기존 `HomeCreatorItem`을 사용해 `creatorId`, `creatorNickname`, `creatorProfileImage`를 유지한다. 그러나 `HomeRecommendationResponseTest`는 `cheerCreators = emptyList()`로 직렬화하고, controller 테스트는 배열 존재만 확인한다. 따라서 item 필드가 추가·삭제·개명되어도 현재 두 assertion은 통과할 수 있다.
|
||||
|
||||
**영향**
|
||||
|
||||
향후 DTO 변경에서 공개 API 스키마 회귀가 focused test를 빠져나갈 수 있다. 현재 DTO 자체의 계약 위반은 확인되지 않았다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
비어 있지 않은 item을 사용해 값과 정확한 필드 수 3을 직렬화 및 controller 계층에서 고정한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — 관련 테스트 fixture와 JSON assertion을 대조해 확정했다.
|
||||
- 2026-07-31 — `cheerCreators` item 직렬화와 홈 API 응답의 정확한 3개 필드 계약을 테스트로 추가했다. focused API test는 fixture 보정 후 `BUILD SUCCESSFUL`, 관련 `git diff --check`는 출력 없음으로 보완 완료했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
- `plan-task.md` Phase 6에 Task R6.1 / `P6-R1`을 추가했다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | DTO·facade·API 테스트 대조 완료 |
|
||||
| 후보 항목 판정 완료 | 충족 | `REV-P6-001` 확정 |
|
||||
| 확정 항목 plan 반영 | 충족 | Task R6.1 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검토와 기존 실행 기록 참조 |
|
||||
|
||||
**최종 결론:** 보완 완료
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
---
|
||||
|
||||
## 9. 2차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 6 / Task 6.1~6.3·Task R6.1, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `HomeRecommendationResponseTest.kt:61-67,117-123`, `HomeRecommendationControllerTest.kt:529-549`, `HomeRecommendationFacade.kt`, `HomeRecommendationQueryService.kt`.
|
||||
- **검증 방법:** `cheerCreators` 실제 item의 `creatorId`, `creatorNickname`, `creatorProfileImage` 값과 정확한 3개 필드 계약, 기존 API URL·DTO 유지, 대상일 snapshot fixture를 정적 대조했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
---
|
||||
|
||||
## 10. 3차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 6 / Task 6.1~6.3·Task R6.1, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `HomeRecommendationController.kt`, `HomeRecommendationResponse.kt:20-51`, `HomeRecommendationFacade.kt:50-95,294-298`, `HomeRecommendationControllerTest.shouldKeepCheerCreatorItemSchemaOnHomeRecommendations`, `HomeRecommendationResponseTest`의 정확한 필드 집합 assertion.
|
||||
- **검증 방법:** 기존 `GET /api/v2/home/recommendations` URL과 `cheerCreators` item의 `creatorId`, `creatorNickname`, `creatorProfileImage` 3개 필드만 유지되는지, 대상일 스냅샷 fixture가 실제 조회 경로와 일치하는지 정적으로 대조했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Phase 7 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 7 / Task 7.1 / `P7-GATE` |
|
||||
| 기준 commit 또는 working tree | `5123494e` 기준 미커밋 working tree |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | PRD Feature D, `plan-task.md` Phase 7 |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
- 인증 회원 본인과 활성 팔로우 크리에이터만 제외되는지 확인한다.
|
||||
- 비활성 팔로우 이력, 비회원, 기존 양방향 차단, 스냅샷 순서·후보 16개/응답 8개 정책이 유지되는지 확인한다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
- 공개 API나 스냅샷 산식을 변경하지 않고 상세 조회의 `memberId != null` 조건에서만 개인화 필터를 적용해야 한다.
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
- 변경 diff: `DefaultHomeRecommendationQueryRepository.kt`, `DefaultHomeRecommendationQueryRepositoryTest.kt`, PRD, plan-task
|
||||
- 코드: `notViewerCondition(...)`, `notActiveFollowedCreatorCondition(...)`, 기존 `notBlockedCreatorCondition(...)`
|
||||
- 테스트: `shouldExcludeSelfAndActiveFollowedCreatorsFromCheerCreatorDetails`, `shouldKeepAnonymousCheerCreatorDetailsWithoutMemberFilters`, 기존 양방향 차단 테스트
|
||||
- 정적 검증: `git diff --check`, `git diff --name-status`
|
||||
- 사용자 지시에 따라 Gradle compile/test는 실행하지 않았으며 plan-task의 기존 통과 기록을 참조했다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
- `memberId == null`이면 신규 두 조건이 모두 생략된다.
|
||||
- 인증 회원이면 `member.id != memberId`와 활성 `CreatorFollowing` row의 `not exists`가 적용된다.
|
||||
- `isActive == false` 팔로우 이력은 제외 조건이 아니며 기존 차단 조건은 그대로 조합된다.
|
||||
- 서비스는 기존처럼 스냅샷 순서로 상세를 재조립하고 최대 8개만 반환한다.
|
||||
- 변경 범위는 PRD·plan-task·repository·repository test 네 파일로 한정되어 공개 DTO나 스냅샷 생성 경로를 수정하지 않았다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 요구사항·diff·테스트 정적 대조 완료 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | diff 검사 및 기존 검증 기록 참조 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
---
|
||||
|
||||
## 9. 2차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 7 / Task 7.1·`P7-GATE`, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `DefaultHomeRecommendationQueryRepository.kt:804-828,1274-1304`, `HomeRecommendationQueryService.kt:112-120`, 본인·활성/비활성 팔로우·비회원·양방향 차단 repository 테스트.
|
||||
- **검증 방법:** `memberId == null`의 필터 생략, 인증 회원의 본인 제외, `CreatorFollowing.isActive == true` 필터, 비활성 이력 유지, 기존 차단 조건 조합, snapshot 순서·최대 8명 재조립을 정적 대조했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
|
||||
---
|
||||
|
||||
## 10. 3차 리뷰 기록 — 2026-07-31
|
||||
|
||||
- **리뷰 대상:** Phase 7 / Task 7.1·`P7-GATE`, `5123494e` 기준 미커밋 working tree.
|
||||
- **검토 근거:** `DefaultHomeRecommendationQueryRepository.kt:803-827,1273-1303`, `HomeRecommendationQueryService.kt:112-120`, 본인·활성/비활성 팔로우·비회원·양방향 차단 repository 테스트와 service의 memberId·순서·limit 테스트.
|
||||
- **검증 방법:** `memberId == null` 필터 생략, 인증 회원 본인 제외, `CreatorFollowing.isActive == true`의 `not exists`, 비활성 이력 유지, 기존 양방향 차단, 16명 후보 안에서 스냅샷 순서대로 최대 8명 반환을 정적으로 대조했다. Gradle compile/test는 실행하지 않았다.
|
||||
- **발견 사항:** 확정 발견 사항 없음.
|
||||
- **plan·goal 전환:** 전환 항목 없음.
|
||||
- **최종 결론:** 확정 발견 사항 없음.
|
||||
- **남은 항목:** 없음.
|
||||
@@ -1,15 +0,0 @@
|
||||
# DM룸 푸시문구 수정 구현 계획
|
||||
|
||||
## 시나리오
|
||||
1. Happy path: 상대방이 DM Room에 입장하지 않은 상태에서 WebSocket 텍스트 메시지를 보내면 `FcmEvent.messageKey`가 `message.fcm.dm_received`이다.
|
||||
2. Edge: `message.fcm.dm_received`가 KO/EN/JA에서 조회되고, 기존 `message.fcm.text_received` 한국어 문구는 그대로다.
|
||||
3. Adjacent regression: DM 음성 메시지 푸시는 계속 `message.fcm.voice_received`를 사용한다.
|
||||
4. Title: DM 텍스트 푸시의 `titleKey`는 `message.fcm.dm_title`("DM"), 음성 등 그 외는 `message.fcm.title`을 유지한다.
|
||||
|
||||
## 작업
|
||||
1. RED: `SodaMessageSourceTest`에 DM 전용 키 다국어 조회와 기존 키 보존 테스트를 추가한다.
|
||||
2. RED: `UserCreatorChatServiceTest`에 DM 텍스트 푸시 키와 음성 푸시 키 회귀 assertion을 추가한다.
|
||||
3. GREEN: `SodaMessageSource`에 `message.fcm.dm_received` 다국어 메시지를 추가한다.
|
||||
4. GREEN: `UserCreatorChatService.publishMessagePush`의 텍스트 분기만 새 키로 바꾼다.
|
||||
5. GREEN: `SodaMessageSource`에 `message.fcm.dm_title`(KO/EN/JA "DM")를 추가하고, `publishMessagePush`가 DM 텍스트일 때만 `titleKey`를 `message.fcm.dm_title`로 분기한다.
|
||||
6. VERIFY: 관련 테스트와 lint를 실행한다.
|
||||
@@ -1,24 +0,0 @@
|
||||
# DM룸 푸시문구 수정 PRD
|
||||
|
||||
## 목적
|
||||
DM Room에서 텍스트 메시지를 보냈을 때 상대방이 해당 채팅방에 입장하지 않은 상태라면 발송되는 푸시 본문을 DM 전용 문구로 분리한다.
|
||||
|
||||
## 요구사항
|
||||
- 기존 `message.fcm.text_received` 메시지는 변경하지 않는다.
|
||||
- DM 텍스트 푸시는 새 메시지 키를 사용한다.
|
||||
- 새 한국어 문구는 기존 문구의 `문자메시지가`를 `DM이`로 바꾼다.
|
||||
- 새 메시지 키는 한국어, 영어, 일본어를 모두 제공한다.
|
||||
- DM 음성 메시지 푸시는 기존 `message.fcm.voice_received`를 유지한다.
|
||||
- DM 텍스트 푸시(`message.fcm.dm_received`) 사용 시 푸시 제목을 새 키 `message.fcm.dm_title`("DM")로 표시한다.
|
||||
- 음성 등 그 외 푸시 제목은 기존 `message.fcm.title`을 유지한다.
|
||||
|
||||
## 비범위
|
||||
- FCM 발송 구조, deep link, 수신자 선정 로직은 변경하지 않는다.
|
||||
- 기존 일반 메시지 푸시 문구는 변경하지 않는다.
|
||||
|
||||
## 성공 기준
|
||||
- 오프라인 DM 텍스트 메시지 푸시 이벤트가 새 DM 메시지 키를 사용한다.
|
||||
- `message.fcm.text_received`는 기존 값으로 남아 있다.
|
||||
- 새 DM 메시지 키가 `Lang.KO`, `Lang.EN`, `Lang.JA`에서 조회된다.
|
||||
- DM 텍스트 푸시 이벤트의 `titleKey`가 `message.fcm.dm_title`이고, 음성 등 그 외 푸시는 `message.fcm.title`을 유지한다.
|
||||
- `message.fcm.dm_title`이 `Lang.KO`, `Lang.EN`, `Lang.JA` 모두 "DM"으로 조회된다.
|
||||
@@ -1,230 +0,0 @@
|
||||
# 메인 콘텐츠 추천 오디오 스냅샷 폴백 Plan/Task
|
||||
|
||||
## 시나리오 계약
|
||||
- Happy path: `GET /api/v2/audio/recommendations`가 사용자 visibility에 맞는 `NEW_AND_HOT`, `MOST_COMMENTED`, `RECOMMENDED_AUDIO` 스냅샷을 각각 조회하고, 비어 있는 섹션만 fallback refresh 후 재조회한다. Real surface: `AudioRecommendationQueryServiceTest`, `AudioRecommendationEndToEndTest`.
|
||||
- Independent fallback: `NEW_AND_HOT` 스냅샷이 존재해도 `MOST_COMMENTED` 또는 `RECOMMENDED_AUDIO`가 비어 있으면 해당 섹션 fallback을 독립적으로 실행한다. Real surface: `AudioRecommendationQueryServiceTest`, 신규 fallback service test.
|
||||
- Visibility: 비회원/19금 노출 불가 회원은 `*_SAFE`, 19금 노출 가능 회원은 `*_ALL` section type으로 fallback을 수행한다. Real surface: `AudioRecommendationQueryServiceTest`.
|
||||
- Lock and wait: fallback은 `lock:audio-recommendation-snapshot-refresh:{SECTION_TYPE}` lock, 300ms lock wait, 1,500ms API wait, JVM single-flight, lock 안 double-check를 사용한다. Real surface: 신규 fallback service test.
|
||||
- Empty marker: 오디오 snapshot-backed 6개 section type은 refresh 결과 0건이면 `targetId = 0` marker를 저장하고, snapshot 조회 응답에서는 marker를 제외한다. Real surface: `RecommendationSnapshotPersistenceAdapterTest`.
|
||||
- Refresh reuse: fallback refresh는 `AudioRecommendationSnapshotRefreshService`의 스케줄러와 동일한 snapshot 생성 로직을 사용한다. Real surface: `AudioRecommendationSnapshotRefreshServiceTest`, 신규 fallback service test.
|
||||
- Adjacent regression: `GET /api/v2/audio/recommendations` URL, response field, 오디오 점수 산식, 배너/오리지널/최신/무료/포인트 섹션 조회 정책은 변경하지 않는다. Real surface: `AudioRecommendationFacadeTest`, `AudioRecommendationEndToEndTest`.
|
||||
|
||||
## 범위와 전제
|
||||
- 이번 문서는 `docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/prd.md`의 구현 계획이다.
|
||||
- 신규 공개 API, 신규 응답 필드, 운영 DDL 추가는 범위에 포함하지 않는다.
|
||||
- 기존 `recommendation_snapshot` 테이블과 오디오 관련 `RecommendedSectionType` 6개를 재사용한다.
|
||||
- `AudioRecommendationQueryService.getRecommendations`의 snapshot-backed 3개 섹션만 이번 fallback 보강의 대상이다.
|
||||
- `findNewAndHotAudios` 전체보기 fallback은 기존 lazy refresh 동작을 유지하되, 공통 helper 변경 시 회귀 테스트로 보호한다.
|
||||
- 오디오 스냅샷은 기존 `findLatestSnapshots(...)` 정책을 유지한다. 홈 추천처럼 대상일 `snapshotAt` exact 조회로 바꾸는 것은 이번 범위에 포함하지 않는다.
|
||||
- 기존 `AudioRecommendationSnapshotRefreshService`는 일괄 refresh만 제공하므로, 단일 섹션 refresh 추가와 일괄 refresh 재사용 중 더 작은 변경을 구현 단계에서 선택한다.
|
||||
- 우선 권장안은 오디오 전용 fallback service를 두고, 홈 추천 fallback service의 lock/timeout/double-check/single-flight 패턴을 복제보다 작은 형태로 재사용 가능한 helper로 분리할지 검토하는 것이다.
|
||||
|
||||
## 기존 오디오 추천 로직 유지/변경 경계
|
||||
- 유지: `GET /api/v2/audio/recommendations` endpoint와 응답 JSON 필드.
|
||||
- 유지: `NEW_AND_HOT`, `MOST_COMMENTED`, `RECOMMENDED_AUDIO` 점수 산식과 limit.
|
||||
- 유지: `SAFE`/`ALL` visibility variant 선택 기준.
|
||||
- 유지: `AudioRecommendationSnapshotScheduler`의 매일 00:00 KST refresh.
|
||||
- 유지: `banners`, `originalSeries`, `latestAudios`, `freeAudios`, `pointAudios` 조회 경로.
|
||||
- 변경: `MOST_COMMENTED`와 `RECOMMENDED_AUDIO`만 비어 있어도 fallback refresh를 시도한다.
|
||||
- 변경: `NEW_AND_HOT` fallback도 다른 두 섹션과 같은 공통 fallback 경로를 사용하도록 정리한다.
|
||||
- 추가: 오디오 snapshot-backed 6개 section type에 empty marker를 적용한다.
|
||||
- 추가: 오디오 fallback refresh lock/timeout/single-flight 로그를 남긴다.
|
||||
|
||||
## 실행 명령
|
||||
- 문서 명령 확인: `./gradlew tasks --all`
|
||||
- 오디오 조회 service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- 오디오 refresh service 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
|
||||
- snapshot marker 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
|
||||
- 오디오 API 회귀 테스트: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest`
|
||||
- 포맷 검증: `./gradlew ktlintCheck`
|
||||
- 전체 회귀: `./gradlew test`
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: 문서와 현재 동작 고정
|
||||
|
||||
- [x] **Task 1.1: PRD와 구현 계획 문서 작성**
|
||||
- 파일 경로:
|
||||
- Create: `docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/prd.md`
|
||||
- Create: `docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/plan-task.md`
|
||||
- RED: 문서 작업은 TDD 예외. TDD 예외 사유: 코드 동작 변경 전 요구사항과 구현 순서를 고정하는 작업이다.
|
||||
- GREEN: 오디오 snapshot-backed 3개 섹션의 독립 fallback, lock, timeout, empty marker, refresh 재사용 요구사항을 문서화한다.
|
||||
- REFACTOR: 기존 메인 콘텐츠 추천 탭 PRD와 홈 추천 snapshot fallback 문서의 정책 차이를 반영해 범위/비범위를 정리한다.
|
||||
- 기대 결과: 구현 시작 전에 PRD와 plan-task가 같은 디렉터리에 준비된다.
|
||||
|
||||
- [x] **Task 1.2: 현재 오디오 snapshot fallback 부재를 회귀 테스트로 고정**
|
||||
- 파일 경로:
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- RED: `NEW_AND_HOT_AUDIO_SAFE`는 기존 스냅샷이 있지만 `MOST_COMMENTED_AUDIO_SAFE`만 비어 있을 때, `MOST_COMMENTED` fallback port가 호출되어야 한다는 실패 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- GREEN: 아직 구현하지 않는다. 이 task는 구현 단계에서 실패를 확인한 뒤 Phase 3에서 통과시킨다.
|
||||
- REFACTOR: `RECOMMENDED_AUDIO_SAFE`만 비어 있는 케이스도 별도 테스트로 추가해 두 섹션이 `NEW_AND_HOT` 존재 여부에 묶이지 않음을 고정한다.
|
||||
- 기대 결과: 현재 결함이 테스트로 재현된다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: empty marker 저장 정책
|
||||
|
||||
- [x] **Task 2.1: 오디오 스냅샷 section type empty marker 지원 추가**
|
||||
- 파일 경로:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt`
|
||||
- RED: `replaceSnapshots(NEW_AND_HOT_AUDIO_SAFE, snapshotAt, emptyList())`, `replaceSnapshots(MOST_COMMENTED_AUDIO_SAFE, snapshotAt, emptyList())`, `replaceSnapshots(RECOMMENDED_AUDIO_SAFE, snapshotAt, emptyList())` 호출 시 marker가 저장되고 `findSnapshots(...)`는 빈 배열, `existsSnapshot(...)`는 true인 실패 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
|
||||
- GREEN: `supportsEmptySnapshotMarker(...)`에 오디오 snapshot-backed 6개 section type을 추가한다.
|
||||
- REFACTOR: 홈 추천 `AI_CHARACTER`, `CHEER_CREATOR`, `POPULAR_COMMUNITY` marker 동작 회귀 assertion을 유지한다.
|
||||
- 기대 결과: 데이터가 없는 오디오 섹션도 정상 refresh 완료 상태를 저장한다.
|
||||
|
||||
- [x] **Task 2.2: marker 대체와 latest 조회 제외 검증**
|
||||
- 파일 경로:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapter.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/RecommendationSnapshotPersistenceAdapterTest.kt`
|
||||
- RED: 오디오 marker가 있는 같은 `sectionType`, `snapshotAt`에 실제 row를 저장하면 marker가 제거되는 실패 테스트를 작성한다. `findLatestSnapshots(...)`에서도 marker가 반환되지 않음을 검증한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
|
||||
- GREEN: 기존 delete 후 save 흐름이 marker 대체를 보장하는지 확인하고, 조회 query의 `target_id <> 0` 조건이 latest 조회에도 적용되게 유지한다.
|
||||
- REFACTOR: marker 관련 상수와 지원 section 판정 함수 이름이 오디오/홈 모두에 어색하지 않은지 정리한다.
|
||||
- 기대 결과: marker가 사용자 응답이나 이후 실제 스냅샷을 오염시키지 않는다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 오디오 fallback orchestration
|
||||
|
||||
- [x] **Task 3.1: 오디오 fallback port/service 추가**
|
||||
- 파일 경로:
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackServiceTest.kt`
|
||||
- RED: 특정 오디오 `sectionType`의 latest snapshot이 없고 marker도 없으면 lock을 획득하고 refresh service를 호출한 뒤 같은 section latest snapshot을 재조회하는 실패 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackServiceTest`
|
||||
- GREEN: `RecommendationSnapshotPort`, `AudioRecommendationSnapshotRefreshService`, `RedissonClient`를 사용하는 오디오 전용 fallback service를 추가한다.
|
||||
- REFACTOR: 홈 추천 fallback의 lock wait 300ms, API wait 1,500ms, single-flight, double-check 패턴을 맞추되, 공통화가 과하면 오디오 전용 최소 구현으로 유지한다.
|
||||
- 기대 결과: 오디오 섹션 하나를 입력받아 fallback refresh와 재조회를 수행할 수 있다.
|
||||
|
||||
- [x] **Task 3.2: lock miss, timeout, 실패, marker 케이스 검증**
|
||||
- 파일 경로:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotFallbackServiceTest.kt`
|
||||
- RED: lock 획득 실패, 1,500ms timeout, refresh 예외, marker 존재, 동시 요청 single-flight 케이스를 실패 테스트로 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackServiceTest`
|
||||
- GREEN: 각 실패/대기 상황에서 빈 배열을 반환하고 warn/info log를 남기며 전체 API 예외로 전파하지 않게 구현한다.
|
||||
- REFACTOR: fallback service가 상세 DTO 조립이나 점수 계산을 직접 하지 않도록 유지한다.
|
||||
- 기대 결과: fallback이 요청 지연과 중복 refresh를 제한한다.
|
||||
|
||||
- [x] **Task 3.3: refresh service에 섹션 단위 refresh 경로 추가 또는 일괄 refresh 재사용 확정**
|
||||
- 파일 경로:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationSnapshotRefreshServiceTest.kt`
|
||||
- RED: `refreshSection(sectionType, now)` 또는 동등한 경로가 입력 section type에 맞는 queryPort 함수와 limit을 사용해 `replaceSnapshots(...)`를 호출하는 실패 테스트를 작성한다. 일괄 refresh 재사용을 선택하면 fallback service test에서 `refreshDailySnapshots()` 호출을 명시적으로 검증한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
|
||||
- GREEN: 가장 작은 변경으로 스케줄러와 fallback이 같은 snapshot 생성 로직을 공유하게 한다.
|
||||
- REFACTOR: `SAFE`/`ALL` section type 매핑 중복이 커지면 기존 `AudioRecommendationVisibility` extension 또는 작은 helper로 정리한다.
|
||||
- 기대 결과: fallback refresh와 scheduler refresh 사이에 산식 drift가 생기지 않는다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: `AudioRecommendationQueryService` 연결
|
||||
|
||||
- [x] **Task 4.1: `getRecommendations`의 3개 snapshot 조회를 fallback 경로로 변경**
|
||||
- 파일 경로:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- RED: `MOST_COMMENTED_AUDIO_SAFE`만 비어 있을 때 `mostCommentedAudios`가 fallback 재조회 결과로 조립되는 실패 테스트를 작성한다. `RECOMMENDED_AUDIO_SAFE`만 비어 있는 케이스도 추가한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- GREEN: `NEW_AND_HOT`, `MOST_COMMENTED`, `RECOMMENDED_AUDIO` 각각에 대해 `findSnapshotsWithFallback(sectionType, offset, limit)` 형태의 경로를 사용한다.
|
||||
- REFACTOR: 기존 `refreshMissingNewAndHotSnapshots(...)`와 Redis 날짜 marker는 새 fallback 경로로 대체하거나 전체보기 전용으로만 남긴다. 사용하지 않게 되면 관련 의존성/상수를 제거한다.
|
||||
- 기대 결과: 3개 오디오 스냅샷 섹션이 서로 독립적으로 fallback을 실행한다.
|
||||
|
||||
- [x] **Task 4.2: visibility별 fallback section type 검증**
|
||||
- 파일 경로:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- RED: 비회원은 `*_SAFE`, 성인 콘텐츠 노출 가능 회원은 `*_ALL` section type으로 fallback service가 호출되는 실패 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- GREEN: 기존 `newAndHotSectionType`, `mostCommentedSectionType`, `recommendedAudioSectionType` 매핑을 fallback 호출에도 그대로 사용한다.
|
||||
- REFACTOR: 기존 성인 preference 조회 정책과 `initializeDefaultPreference` 미호출 회귀 테스트를 유지한다.
|
||||
- 기대 결과: fallback이 현재 사용자 visibility와 다른 variant를 잘못 갱신하거나 조회하지 않는다.
|
||||
|
||||
- [x] **Task 4.3: `findNewAndHotAudios` 전체보기 회귀 정리**
|
||||
- 파일 경로:
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- RED: 전체보기 `findNewAndHotAudios(member, offset, limit)`가 기존처럼 offset/limit snapshot 순서를 유지하고, snapshot이 없을 때 새 fallback 경로 또는 기존 lazy refresh 정책 중 결정된 경로를 사용하는 테스트를 작성한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- GREEN: 전체보기 동작을 구현 결정에 맞춰 최소 수정한다.
|
||||
- REFACTOR: 홈 첫 화면 limit 12와 전체보기 paging limit이 섞이지 않도록 helper 인자를 명확히 유지한다.
|
||||
- 기대 결과: 첫 화면 fallback 보강이 전체보기 paging을 깨지 않는다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: API 회귀와 최종 검증
|
||||
|
||||
- [x] **Task 5.1: 오디오 추천 API 응답 스키마 회귀 검증**
|
||||
- 파일 경로:
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
|
||||
- RED: `newAndHotAudios`, `mostCommentedAudios`, `recommendedAudios` 필드명이 유지되고 신규 필드가 추가되지 않는 회귀 테스트를 확인/보강한다.
|
||||
- 실패 확인: `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest`
|
||||
- GREEN: controller/facade/DTO 변경 없이 application service 결과가 기존 response로 매핑되게 한다.
|
||||
- REFACTOR: 공개 API URL과 JSON field name 변경이 없음을 assertion으로 유지한다.
|
||||
- 기대 결과: 클라이언트 공개 스키마는 변경되지 않는다.
|
||||
|
||||
- [x] **Task 5.2: focused regression 실행**
|
||||
- 파일 경로:
|
||||
- Modify: `docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/plan-task.md`
|
||||
- RED: 구현 task 완료 후 계획 문서에 기록할 focused command 목록을 확정한다.
|
||||
- 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다.
|
||||
- GREEN: 아래 명령을 실행하고 결과를 이 문서 하단 검증 기록에 누적한다.
|
||||
- REFACTOR: 실패한 명령이 있으면 원인과 재실행 결과를 같은 task 아래에 기록한다.
|
||||
- 실행 명령:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest`
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest`
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackServiceTest`
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest`
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest`
|
||||
- 기대 결과: 오디오 fallback, marker, refresh, API 회귀가 최소 명령으로 검증된다.
|
||||
|
||||
- [ ] **Task 5.3: 전체 회귀와 문서 검증**
|
||||
- 파일 경로:
|
||||
- Modify: `docs/20260712_메인_콘텐츠_추천_오디오_스냅샷_폴백/plan-task.md`
|
||||
- RED: 구현 완료 후 전체 회귀 명령 실행 전에는 검증 기록이 구현 전 상태여야 한다.
|
||||
- 실패 확인: 해당 없음. TDD 예외 사유: 검증 기록 문서화 task다.
|
||||
- GREEN: `./gradlew ktlintCheck`, `./gradlew test`, `./gradlew tasks --all`을 실행하고 결과를 문서 하단 검증 기록에 누적한다.
|
||||
- REFACTOR: PRD와 plan-task가 구현 결과와 어긋나면 먼저 문서를 갱신하고 필요한 focused test를 재실행한다.
|
||||
- 기대 결과: 포맷, 전체 테스트, 문서 명령 유효성을 모두 확인한다.
|
||||
|
||||
---
|
||||
|
||||
## 검증 기록
|
||||
- 2026-07-12: PRD와 구현 계획 문서만 작성했다. 코드 변경은 수행하지 않았다.
|
||||
- 2026-07-12: 문서 변경 검증을 수행했다.
|
||||
- `git diff --check` 성공.
|
||||
- `./gradlew tasks --all`은 최초 샌드박스 실행에서 `~/.gradle` lock 파일 권한 오류로 실패했고, 권한 승인 후 재실행해 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 2026-07-12: 오디오 snapshot-backed 3개 섹션의 독립 fallback 구현을 수행했다.
|
||||
- RED 확인:
|
||||
- `RecommendationSnapshotPersistenceAdapterTest.shouldSaveAudioEmptySnapshotMarkerWhenReplacingWithEmptySnapshots`는 오디오 section marker 미지원으로 실패했다.
|
||||
- `AudioRecommendationSnapshotRefreshServiceTest.shouldRefreshRequestedAudioSnapshotSectionOnly`는 `refreshSection` 미정의 컴파일 오류로 실패했다.
|
||||
- `AudioRecommendationSnapshotFallbackServiceTest`는 `AudioRecommendationSnapshotFallbackService` 미정의 컴파일 오류로 실패했다.
|
||||
- `AudioRecommendationQueryServiceTest`는 기존 생성자/조회 경로가 fallback service를 사용하지 않아 컴파일 오류로 실패했다.
|
||||
- GREEN 확인:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` 성공.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 성공.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackServiceTest` 성공.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.RecommendationSnapshotPersistenceAdapterTest` 성공.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest --tests kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest` 성공.
|
||||
- 포맷/문서 명령:
|
||||
- `./gradlew ktlintCheck` 성공.
|
||||
- `./gradlew tasks --all` 성공.
|
||||
- `git diff --check` 성공.
|
||||
- 전체 회귀:
|
||||
- `./gradlew test`는 최초 300초 제한에 걸렸고, 재실행은 사용자 요청으로 중단했다. 전체 테스트는 사용자가 별도로 재실행하기로 했다.
|
||||
- 2026-07-12: post-implementation review에서 blocking issue 2건을 확인하고 수정했다.
|
||||
- `AudioRecommendationQueryService`의 fallback 기준 시간을 `Asia/Seoul` 기준으로 분리해 JVM 기본 timezone 의존을 제거했다.
|
||||
- `AudioRecommendationSnapshotScheduler`가 fallback과 동일한 6개 section lock을 획득한 뒤 일괄 refresh를 실행하도록 수정했다.
|
||||
- 추가 RED 확인:
|
||||
- `AudioRecommendationSnapshotSchedulerTest.shouldSkipWhenSectionLockNotAcquired`는 section lock miss에도 일 배치를 실행해 실패했다.
|
||||
- `AudioRecommendationSnapshotSchedulerTest.shouldRefreshOnlyWhenLockAcquired`는 section lock unlock 검증 실패로 실패했다.
|
||||
- 수정 후 검증:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.scheduler.AudioRecommendationSnapshotSchedulerTest` 성공.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest` 성공.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotFallbackServiceTest` 성공.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationSnapshotRefreshServiceTest` 성공.
|
||||
- `./gradlew ktlintCheck` 성공.
|
||||
@@ -1,168 +0,0 @@
|
||||
# PRD: 메인 콘텐츠 추천 오디오 스냅샷 폴백
|
||||
|
||||
## 1. Overview
|
||||
메인 콘텐츠 추천 탭의 오디오 스냅샷 기반 3개 섹션이 각각 독립적으로 스냅샷 없음 fallback refresh를 수행하도록 보강한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- `AudioRecommendationQueryService.getRecommendations`는 `NEW_AND_HOT_AUDIO_*`, `MOST_COMMENTED_AUDIO_*`, `RECOMMENDED_AUDIO_*` 3개 스냅샷을 조회한다.
|
||||
- 현재 조회 흐름은 `NEW_AND_HOT_AUDIO_*`가 비어 있을 때만 lazy refresh를 시도한다.
|
||||
- `MOST_COMMENTED_AUDIO_*` 또는 `RECOMMENDED_AUDIO_*`만 비어 있는 경우에는 refresh가 실행되지 않아 해당 섹션이 빈 배열로 내려간다.
|
||||
- `NEW_AND_HOT_AUDIO_*` fallback이 `refreshDailySnapshots()`를 호출하면 6개 오디오 스냅샷 variant를 모두 갱신하지만, 같은 요청에서 이미 읽어둔 `MOST_COMMENTED_AUDIO_*`, `RECOMMENDED_AUDIO_*`는 재조회하지 않는다.
|
||||
- 홈 추천의 `AI_CHARACTER`, `CHEER_CREATOR`, `POPULAR_COMMUNITY`는 각 섹션별 fallback, lock, double-check, timeout, empty marker 정책을 갖고 있어 오디오 추천과 동작 일관성이 다르다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 오디오 추천의 스냅샷 기반 3개 섹션 모두 독립 fallback을 갖는다.
|
||||
- fallback 대상은 visibility variant를 포함한 실제 조회 섹션 타입 기준으로 분리한다.
|
||||
- `NEW_AND_HOT_AUDIO_SAFE`
|
||||
- `NEW_AND_HOT_AUDIO_ALL`
|
||||
- `MOST_COMMENTED_AUDIO_SAFE`
|
||||
- `MOST_COMMENTED_AUDIO_ALL`
|
||||
- `RECOMMENDED_AUDIO_SAFE`
|
||||
- `RECOMMENDED_AUDIO_ALL`
|
||||
- 각 섹션 스냅샷이 없으면 스케줄러와 동일한 오디오 스냅샷 refresh 로직으로 저장한 뒤, 해당 섹션을 다시 조회한다.
|
||||
- fallback은 중복 refresh를 막기 위해 섹션 단위 lock, double-check, JVM 내 single-flight를 사용한다.
|
||||
- fallback refresh 실패, timeout, lock miss는 전체 API 실패로 전파하지 않고 해당 섹션 빈 배열로 처리한다.
|
||||
- refresh 결과 0건인 섹션은 정상 refresh 완료 상태를 저장해 매 요청마다 fallback을 반복하지 않게 한다.
|
||||
- 기존 공개 API URL, 응답 JSON 필드, 오디오 추천 산식, 스냅샷 스케줄 시각은 변경하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- `GET /api/v2/audio/recommendations` 응답 스키마를 변경하지 않는다.
|
||||
- `NEW_AND_HOT`, `MOST_COMMENTED`, `RECOMMENDED_AUDIO` 점수 산식과 집계 window를 변경하지 않는다.
|
||||
- 오디오 배너, 오리지널 시리즈, 최신 오디오, 무료 오디오, 포인트 오디오 조회 정책은 변경하지 않는다.
|
||||
- 신규 추천 스냅샷 테이블 또는 DDL을 만들지 않는다.
|
||||
- 홈 추천 `RecommendationSnapshotFallbackService`를 무리하게 공통화하지 않는다. 오디오 추천에 필요한 최소 재사용/확장만 검토한다.
|
||||
- 전체보기 `findNewAndHotAudios`의 공개 API 스키마와 paging 계약은 변경하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 회원/비회원: 메인 콘텐츠 추천 탭에서 스냅샷 누락으로 특정 오디오 추천 섹션이 비는 상황을 덜 겪어야 하는 사용자
|
||||
- 앱 클라이언트: 기존 응답 계약을 유지한 채 가능한 추천 섹션을 안정적으로 받는 클라이언트
|
||||
- 운영자: 스케줄러 실패 또는 일부 스냅샷 누락 후 첫 조회에서 자동 복구 흐름을 기대하는 운영 담당자
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 추천 탭 진입 시 `New & Hot`, `최근 댓글 많은 오디오`, `추천 오디오`가 각각 가능한 데이터로 채워지기를 기대한다.
|
||||
- 사용자는 한 섹션의 스냅샷이 없더라도 추천 탭 전체가 실패하지 않기를 기대한다.
|
||||
- 앱 클라이언트는 특정 스냅샷 섹션이 없는 날에도 기존 응답 구조 그대로 빈 배열 또는 복구된 결과를 받기를 원한다.
|
||||
- 운영자는 오디오 추천 스케줄러가 실패한 뒤 첫 사용자 조회가 스케줄러와 같은 refresh 로직으로 스냅샷을 복구하기를 원한다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 오디오 스냅샷 섹션별 독립 fallback
|
||||
|
||||
#### Requirements
|
||||
- `AudioRecommendationQueryService.getRecommendations`는 `NEW_AND_HOT`, `MOST_COMMENTED`, `RECOMMENDED_AUDIO` 각각에 대해 fallback 조회 경로를 사용한다.
|
||||
- fallback 판단은 현재 사용자 visibility에 맞는 `RecommendedSectionType` 기준으로 수행한다.
|
||||
- 한 섹션의 스냅샷이 비어 있더라도 다른 섹션의 기존 스냅샷 조회 결과를 버리거나 재정렬하지 않는다.
|
||||
- fallback refresh 후에는 refresh를 요청한 섹션을 다시 조회한다.
|
||||
- `NEW_AND_HOT`이 비어 fallback을 실행한 경우에도 `MOST_COMMENTED`, `RECOMMENDED_AUDIO`가 비어 있으면 각 섹션의 fallback 판단이 독립적으로 수행되어야 한다.
|
||||
- 각 섹션 fallback은 refresh 결과를 직접 응답으로 조립하지 않고 `recommendation_snapshot`에 저장된 row를 재조회해 사용한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 특정 섹션 refresh가 실패해도 다른 섹션 응답은 가능한 범위에서 유지한다.
|
||||
- fallback 후 상세 조회 필터에서 모두 제외되면 해당 섹션은 빈 배열로 반환한다.
|
||||
- `SAFE`와 `ALL` variant 중 현재 요청에서 사용하지 않는 variant의 누락 여부는 해당 요청의 fallback 조건이 아니다.
|
||||
|
||||
### Feature B. 섹션 단위 lock, double-check, single-flight
|
||||
|
||||
#### Requirements
|
||||
- fallback refresh는 섹션 타입 단위로 lock key를 분리한다.
|
||||
- 권장 lock key 형식은 `lock:audio-recommendation-snapshot-refresh:{SECTION_TYPE}`이다.
|
||||
- lock 대기 시간은 홈 추천 fallback과 동일하게 최대 300ms를 우선 적용한다.
|
||||
- API 요청이 fallback refresh 완료를 기다리는 시간은 홈 추천 fallback과 동일하게 최대 1,500ms를 우선 적용한다.
|
||||
- timeout은 요청 대기 timeout이며, 이미 시작된 background refresh를 반드시 중단한다는 의미가 아니다.
|
||||
- 동일 JVM에서는 같은 `SECTION_TYPE`에 대해 single-flight를 적용해 동시 요청이 중복 refresh를 시작하지 않게 한다.
|
||||
- lock 획득 후에는 대상 스냅샷 존재 여부를 다시 확인하고, 이미 존재하면 refresh를 실행하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- lock 획득 직전에 다른 요청 또는 스케줄러가 스냅샷을 저장할 수 있으므로 double-check가 필요하다.
|
||||
- lock 획득 실패 시 refresh를 시작하지 않고 짧게 재조회한 뒤 없으면 빈 배열을 반환한다.
|
||||
- timeout 이후 background refresh가 완료되면 다음 요청은 저장된 스냅샷을 사용한다.
|
||||
|
||||
### Feature C. 오디오 스냅샷 empty marker
|
||||
|
||||
#### Requirements
|
||||
- 오디오 스냅샷 섹션도 refresh 결과 0건이면 정상 refresh 완료 상태를 저장해야 한다.
|
||||
- 기존 `recommendation_snapshot` 구조를 재사용하고, 홈 추천과 같은 `targetId = 0` empty snapshot marker 방식을 우선 적용한다.
|
||||
- marker 적용 대상은 오디오 스냅샷 기반 6개 section type이다.
|
||||
- 조회 쿼리는 marker가 사용자 응답에 노출되지 않도록 `target_id <> 0` 정책을 유지한다.
|
||||
- 존재 여부 확인은 marker를 포함해 판단하여 집계 결과가 없는 섹션이 매 요청마다 fallback refresh를 반복하지 않게 한다.
|
||||
|
||||
#### Edge Cases
|
||||
- marker만 있으면 snapshot 조회 결과는 빈 배열이어야 한다.
|
||||
- 같은 `sectionType`, `snapshotAt`에 실제 row가 생기는 재실행이 있으면 marker는 실제 row로 대체되어야 한다.
|
||||
- 오디오 marker 추가가 기존 홈 추천 marker 동작을 바꾸면 안 된다.
|
||||
|
||||
### Feature D. 오디오 refresh 경로 재사용
|
||||
|
||||
#### Requirements
|
||||
- fallback refresh는 스케줄러와 같은 `AudioRecommendationSnapshotRefreshService`의 refresh 로직을 사용한다.
|
||||
- 현재 `refreshDailySnapshots()`가 6개 오디오 스냅샷을 일괄 갱신하는 구조는 유지할 수 있다.
|
||||
- 가능하면 단일 섹션 refresh 함수를 추가해 fallback 요청 섹션만 갱신하는 방식을 우선 검토한다.
|
||||
- 단일 섹션 refresh를 추가하더라도 기존 일괄 스케줄러는 6개 섹션을 계속 갱신해야 한다.
|
||||
- `snapshotAt`과 집계 window는 기존 오디오 추천 정책을 유지한다.
|
||||
- KST 기준 전날 23:59:59
|
||||
- `NEW_AND_HOT`: 최근 3일
|
||||
- `MOST_COMMENTED`: 최근 7일
|
||||
- `RECOMMENDED_AUDIO`: 최근 7일
|
||||
|
||||
#### Edge Cases
|
||||
- 단일 섹션 refresh가 과도한 중복을 만들면 기존 일괄 refresh를 호출하고 해당 섹션만 재조회하는 최소 구현을 허용한다.
|
||||
- 단, 일괄 refresh를 호출하는 경우에도 fallback trigger와 재조회는 섹션별로 독립이어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 8. Technical Constraints
|
||||
- Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
|
||||
- 기존 `recommendation_snapshot` 테이블과 `RecommendedSectionType` enum 값을 재사용한다.
|
||||
- 기존 `AudioRecommendationQueryService`, `AudioRecommendationSnapshotRefreshService`, `AudioRecommendationSnapshotScheduler` 경계를 우선 유지한다.
|
||||
- fallback orchestration은 홈 추천 `RecommendationSnapshotFallbackService`의 lock, timeout, double-check, single-flight 패턴을 기준으로 설계한다.
|
||||
- 공개 API 응답 DTO와 controller endpoint는 변경하지 않는다.
|
||||
- 성인 콘텐츠 visibility는 기존 `MemberContentPreferenceService.canViewAdultContent(member)` 결과에 따른 `SAFE`/`ALL` section type 선택을 유지한다.
|
||||
- `findLatestSnapshots(...)` 기반의 현재 오디오 snapshot 조회 정책은 유지한다. 대상일 `snapshotAt` exact 조회 방식으로 바꾸는 것은 이번 요구사항의 필수 범위가 아니다.
|
||||
- 신규 DDL은 만들지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Metrics
|
||||
- 오디오 fallback refresh 실행/성공/실패/timeout 로그
|
||||
- 오디오 fallback lock 획득 성공/실패 로그
|
||||
- section type별 fallback refresh 대기 시간
|
||||
- section type별 empty marker 저장 횟수
|
||||
- `newAndHotAudios`, `mostCommentedAudios`, `recommendedAudios` 빈 응답 비율
|
||||
- `audio_recommendation_snapshot_refresh_success` 저장 수 또는 section별 저장 수
|
||||
|
||||
---
|
||||
|
||||
## 10. Open Questions
|
||||
- 없음.
|
||||
|
||||
---
|
||||
|
||||
## 11. Decisions
|
||||
- 이번 작업은 문서 작성만 수행한다.
|
||||
- 오디오 추천 snapshot-backed 3개 섹션 모두 홈 추천 snapshot-backed 3개 섹션과 같은 수준의 fallback 정책을 갖는 것을 목표로 한다.
|
||||
- fallback 실패는 전체 API 실패가 아니라 해당 섹션 빈 배열로 처리한다.
|
||||
- 기존 `MOST_COMMENTED`가 비면 빈 배열로 내려주던 초기 PRD 정책은 이번 요구사항으로 변경한다.
|
||||
- 스냅샷 산식, visibility 정책, 공개 응답 스키마는 변경하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 12. Related Documents
|
||||
- `docs/prd/sample-prd.md`
|
||||
- `docs/agent-guides/작업절차.md`
|
||||
- `docs/agent-guides/문서유지보수.md`
|
||||
- `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md`
|
||||
- `docs/20260709_메인_홈_추천_AI캐릭터_스냅샷/prd.md`
|
||||
- `docs/20260710_메인_홈_추천_응원크리에이터_스냅샷/prd.md`
|
||||
- `docs/20260710_메인_홈_추천_인기커뮤니티_스냅샷/prd.md`
|
||||
@@ -1,30 +0,0 @@
|
||||
# 캔 쿠폰 OSIV 회귀 테스트 보강 Plan/TASK
|
||||
|
||||
## Goal
|
||||
`CanCouponService.useCanCoupon()`가 OSIV off 환경에서 Spring 트랜잭션 프록시를 통해 호출될 때 `Member.auth` lazy 연관 접근을 포함한 쿠폰 사용 흐름이 예외 없이 완료됨을 검증한다.
|
||||
|
||||
### Phase 1: 실제 호출 경로 통합 테스트 추가
|
||||
|
||||
- [x] **Task 1.1: `CanCouponServiceIntegrationTest` 추가**
|
||||
- 파일: `src/test/kotlin/kr/co/vividnext/sodalive/can/coupon/CanCouponServiceIntegrationTest.kt`
|
||||
- RED: 트랜잭션 없는 테스트 메서드에서 인증 회원, lazy `Member.auth`, CAN 쿠폰 fixture를 만든 뒤 Spring 빈 `CanCouponService.useCanCoupon()`를 호출하는 테스트를 작성한다.
|
||||
- GREEN: 기존 `CanCouponService.useCanCoupon()`의 `@Transactional` 경계로 테스트가 통과하는지 확인한다.
|
||||
- REFACTOR: 테스트 fixture를 최소화하고, 쿠폰 사용 후 저장 상태를 확인한다.
|
||||
- 검증 기준:
|
||||
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.can.coupon.CanCouponServiceIntegrationTest`
|
||||
- `./gradlew --no-daemon ktlintCheck`
|
||||
- 검증 기록:
|
||||
- 무엇: `CanCouponServiceIntegrationTest`에서 인증 회원, lazy `Member.auth`, CAN 쿠폰 fixture를 DB에 저장한 뒤 트랜잭션 없는 테스트 메서드에서 Spring 빈 `CanCouponService.useCanCoupon()`를 호출했다.
|
||||
- 왜: 단순 애노테이션 검사가 아니라 OSIV off 환경의 실제 서비스 호출 경로에서 lazy 초기화 예외가 재발하지 않는지 확인하기 위해서다.
|
||||
- 어떻게: 첫 실행 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.can.coupon.CanCouponServiceIntegrationTest`는 컨텍스트 부팅 중 Redis 연결 실패로 실패했다. 테스트에 기존 관례인 `EmbeddedRedisInitializer` opt-in과 `@DirtiesContext`를 추가했다.
|
||||
- 결과: 재실행 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.can.coupon.CanCouponServiceIntegrationTest`가 `BUILD SUCCESSFUL in 2m 1s`로 통과했다.
|
||||
|
||||
## 검증 기록
|
||||
- Run: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.can.coupon.CanCouponServiceTest --tests kr.co.vividnext.sodalive.can.coupon.CanCouponServiceIntegrationTest`
|
||||
- Result: `BUILD SUCCESSFUL in 2m 24s`
|
||||
- Run: `./gradlew --no-daemon ktlintCheck`
|
||||
- Result: `BUILD SUCCESSFUL in 1m 9s`
|
||||
- Run: `git diff --check`
|
||||
- Result: 공백 오류 없이 통과
|
||||
- Run: `./gradlew tasks --all`
|
||||
- Result: sandbox 환경에서는 `~/.gradle` wrapper lock 파일 접근 제한으로 실패했고, 승인 실행 후 `BUILD SUCCESSFUL in 11s`로 통과했다.
|
||||
@@ -1,24 +0,0 @@
|
||||
# PRD: 캔 쿠폰 OSIV 회귀 테스트 보강
|
||||
|
||||
## 1. Overview
|
||||
`CanCouponService.useCanCoupon()`가 `spring.jpa.open-in-view=false` 환경에서도 실제 Spring 호출 경로에서 lazy 초기화 예외 없이 동작하는지 검증하는 통합 테스트를 추가한다.
|
||||
|
||||
## 2. Problem
|
||||
- 기존 단위 테스트는 `@Transactional` 애노테이션 존재만 확인한다.
|
||||
- 실제 DB 엔티티, Spring 트랜잭션 프록시, lazy 연관 접근이 함께 동작하는지는 검증하지 못한다.
|
||||
- `Member.auth` lazy 연관 접근이 다시 트랜잭션 밖에서 실행되면 `LazyInitializationException` 회귀가 발생할 수 있다.
|
||||
|
||||
## 3. Goals
|
||||
- 트랜잭션 없는 테스트 메서드에서 Spring 빈 `CanCouponService.useCanCoupon()`를 호출한다.
|
||||
- 인증된 회원의 `member.auth` lazy 연관을 포함한 실제 쿠폰 사용 경로가 예외 없이 완료됨을 검증한다.
|
||||
- 쿠폰 사용 후 회원 보상 캔과 쿠폰 사용 회원이 저장됐는지 확인한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
- 공개 API 스키마 변경은 하지 않는다.
|
||||
- 쿠폰 사용 정책이나 충전 정책 로직은 변경하지 않는다.
|
||||
- 전체 OSIV 회귀 테스트를 재구성하지 않는다.
|
||||
|
||||
## 5. Technical Constraints
|
||||
- 기존 Kotlin/Spring Boot 테스트 스타일을 따른다.
|
||||
- `spring.jpa.open-in-view=false` 테스트 설정을 유지한다.
|
||||
- 검증 범위는 캔 쿠폰 사용 실제 호출 경로로 제한한다.
|
||||
@@ -1,154 +0,0 @@
|
||||
# 라이브 예약 LazyInitializationException 수정 Plan/TASK
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: `superpowers:executing-plans`를 사용해 각 task를 순서대로 실행한다. 각 단계는 체크박스 상태와 검증 기록을 즉시 갱신한다.
|
||||
|
||||
**Goal:** OSIV off 환경에서 라이브 예약 생성이 `LiveRoom.reservations` lazy 컬렉션 초기화 예외 없이 완료되고, 결제와 예약 저장이 하나의 트랜잭션에 참여하게 한다.
|
||||
|
||||
**Architecture:** 기존 API와 엔티티 매핑은 유지한다. `LiveReservationService.makeReservation()`을 서비스 계층의 쓰기 트랜잭션 경계로 만들고, 트랜잭션이 없는 통합 테스트에서 실제 Spring 프록시를 호출해 detached `LiveRoom`의 lazy 컬렉션 접근 오류를 재현하고 수정한다.
|
||||
|
||||
**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5, H2, Gradle Wrapper
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 공개 API URL, 요청 및 응답 스키마를 변경하지 않는다.
|
||||
- `spring.jpa.open-in-view=false`를 유지한다.
|
||||
- `LiveRoom.reservations`의 fetch 전략과 `LiveReservation.room` setter를 변경하지 않는다.
|
||||
- 예약 중복 방지, 결제 정책, 응답 포맷을 변경하지 않는다.
|
||||
- 변경은 PRD, Plan/TASK, `LiveReservationService.makeReservation()`, 해당 통합 테스트로 제한한다.
|
||||
|
||||
---
|
||||
|
||||
## 파일 구조 계획
|
||||
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt`
|
||||
- 실제 Spring 서비스 프록시와 JPA 엔티티로 OSIV off 예약 생성 경로를 검증한다.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt`
|
||||
- `makeReservation()`에 쓰기 `@Transactional`을 추가한다.
|
||||
- Modify: `docs/20260715_라이브_예약_LazyInitializationException_수정/plan-task.md`
|
||||
- RED/GREEN/회귀 검증 결과를 누적 기록한다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: LazyInitializationException 재현
|
||||
|
||||
- [x] **Task 1.1: 라이브 예약 서비스 통합 실패 테스트 작성**
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt`
|
||||
- RED: `@SpringBootTest`와 `@Transactional(propagation = Propagation.NOT_SUPPORTED)`를 사용해 테스트 자체 트랜잭션이 서비스 경계를 가리지 않게 한다.
|
||||
- RED: `EmbeddedRedisInitializer`를 명시적으로 적용하고 클래스 종료 후 Context를 정리한다.
|
||||
- RED: `TransactionTemplate` 안에서 예약자, 크리에이터, 가격이 0인 예약 라이브방을 저장하고 `EntityManager.flush()`, `EntityManager.clear()`를 실행한다.
|
||||
- RED: `MockHttpServletRequest`를 `RequestContextHolder`에 등록해 request-scoped `LangContext`를 사용할 수 있게 한 뒤 실제 Spring 빈 `service.makeReservation(...)`을 호출한다.
|
||||
- RED 코드의 핵심 검증은 다음과 같다.
|
||||
|
||||
```kotlin
|
||||
val response = service.makeReservation(
|
||||
request = MakeLiveReservationRequest(
|
||||
roomId = fixture.roomId,
|
||||
container = "web",
|
||||
timezone = "Asia/Seoul"
|
||||
),
|
||||
memberId = fixture.memberId
|
||||
)
|
||||
|
||||
val reservation = transactionTemplate.execute {
|
||||
repository.findById(response.reservationId).orElseThrow()
|
||||
}!!
|
||||
|
||||
assertEquals(fixture.roomId, reservation.room!!.id)
|
||||
assertEquals(fixture.memberId, reservation.member!!.id)
|
||||
```
|
||||
|
||||
- 실패 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
|
||||
- 기대 결과: production code 수정 전 `reservation.room = room`에서 `LiveRoom.reservations`를 초기화하려다 `LazyInitializationException`으로 실패한다.
|
||||
- GREEN: 이 task에서는 production code를 변경하지 않는다.
|
||||
- REFACTOR: fixture와 결과 검증용 타입은 테스트 파일 내부 private data class로 제한하고, request context는 `@AfterEach`에서 해제한다.
|
||||
- 검증 기록:
|
||||
- 무엇: 트랜잭션 없는 테스트 메서드에서 detached `LiveRoom`을 다시 조회하는 실제 Spring `LiveReservationService` 빈을 호출했다.
|
||||
- 왜: 테스트 트랜잭션이나 OSIV가 결함을 가리지 않은 상태에서 운영 오류와 같은 lazy 컬렉션 접근을 재현하기 위해서다.
|
||||
- 어떻게: production code 수정 전 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 실행했다.
|
||||
- 결과: `LiveReservationServiceIntegrationTest.kt:55`에서 `failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.live.room.LiveRoom.reservations, could not initialize proxy - no Session`으로 실패해 RED를 확인했다. Stack trace는 `PersistentBag.add`를 가리켰다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 서비스 쓰기 트랜잭션 적용
|
||||
|
||||
- [x] **Task 2.1: `makeReservation()` 트랜잭션 경계 추가**
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt`
|
||||
- Consumes: `LiveReservationService.makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse`
|
||||
- Produces: 같은 메서드 시그니처와 응답을 유지하는 transactional 예약 생성 흐름
|
||||
- GREEN: 기존 import인 `org.springframework.transaction.annotation.Transactional`을 사용해 다음 한 줄만 추가한다.
|
||||
|
||||
```kotlin
|
||||
@Transactional
|
||||
fun makeReservation(request: MakeLiveReservationRequest, memberId: Long): MakeLiveReservationResponse {
|
||||
```
|
||||
|
||||
- 통과 확인: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
|
||||
- 기대 결과: `BUILD SUCCESSFUL`이며 저장된 예약의 방 ID와 회원 ID가 fixture와 일치한다.
|
||||
- REFACTOR: 불필요한 fetch 전략, setter, 응답 로직 변경이 없는지 `git diff`로 확인한다.
|
||||
- 검증 기록:
|
||||
- 무엇: `LiveReservationService.makeReservation()`에 쓰기 `@Transactional`을 추가했다.
|
||||
- 왜: 라이브방 조회부터 lazy 컬렉션 접근, 결제, 예약 저장까지 같은 영속성 컨텍스트와 트랜잭션에서 처리하기 위해서다.
|
||||
- 어떻게: RED와 같은 `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 재실행했다.
|
||||
- 결과: `BUILD SUCCESSFUL in 51s`로 통과했고 저장된 예약의 방 ID와 회원 ID가 fixture와 일치했다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 회귀 및 문서 검증
|
||||
|
||||
- [x] **Task 3.1: 관련 테스트와 저장소 규칙 검증**
|
||||
- Verify: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
|
||||
- Verify: `./gradlew --no-daemon ktlintCheck`
|
||||
- Verify: `./gradlew --no-daemon tasks --all`
|
||||
- Verify: `git diff --check`
|
||||
- 기대 결과: 모든 Gradle 명령은 `BUILD SUCCESSFUL`, `git diff --check`는 출력 없이 exit code 0이다.
|
||||
- RED/GREEN: Phase 1과 Phase 2의 실패 및 통과 결과를 다시 확인한다.
|
||||
- REFACTOR: 이번 요청과 무관한 코드 및 문서 변경이 없는지 확인한다.
|
||||
- 검증 기록:
|
||||
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`: GREEN 확인 실행은 `BUILD SUCCESSFUL in 51s`, 최종 재실행은 `BUILD SUCCESSFUL in 11s`로 통과했다.
|
||||
- `./gradlew --no-daemon ktlintCheck`: `BUILD SUCCESSFUL in 23s`로 통과했다.
|
||||
- `./gradlew --no-daemon tasks --all`: `BUILD SUCCESSFUL in 6s`로 통과했다.
|
||||
- `./gradlew --no-daemon test`: 전체 테스트 스위트가 `BUILD SUCCESSFUL in 5m 48s`로 통과했다.
|
||||
- `git diff --check`: 출력 없이 통과했다.
|
||||
- `git diff`: production code 변경이 `makeReservation()`의 `@Transactional` 한 줄뿐이며 fetch 전략, setter, API 응답 로직은 변경하지 않았음을 확인했다.
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 유료 예약 원자성 회귀 보강
|
||||
|
||||
- [x] **Task 4.1: 예약 저장 실패 시 결제와 예약의 전체 롤백 검증**
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationServiceIntegrationTest.kt`
|
||||
- Modify: `docs/20260715_라이브_예약_LazyInitializationException_수정/plan-task.md`
|
||||
- RED: 실제 `CanPaymentService`와 JPA 저장소를 사용하는 유료 예약 fixture를 만들고, `LiveReservationRepository.save()` spy에서 `UseCan` 증가를 확인한 뒤 `DataIntegrityViolationException`을 던지게 한다.
|
||||
- RED: 기존 production code가 이미 `@Transactional`을 포함하므로, 임시로 `noRollbackFor = [DataIntegrityViolationException::class]` 변이를 적용했을 때 결제 잔액 검증이 실패하는지 확인한 뒤 즉시 원복한다.
|
||||
- GREEN: 원래 `@Transactional`에서 같은 테스트가 통과하고 회원 캔 잔액, 충전 잔액, 사용 내역 건수와 예약 존재 여부가 호출 전 상태와 같은지 검증한다.
|
||||
- REFACTOR: 새 production code나 별도 추상화를 추가하지 않고 기존 통합 테스트 fixture만 최소 확장한다.
|
||||
- Verify: `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`
|
||||
- 기대 결과: 2개 테스트가 실행되고 모두 통과하며, 테스트 결과 XML의 failures/errors가 0이다.
|
||||
- 범위 분리: 전체 테스트/clean build의 KAPT 산출물 재현성 문제는 이번 기능 변경에 포함하지 않고 별도 빌드 작업으로 분리한다.
|
||||
- 검증 기록:
|
||||
- 무엇: 유료 예약의 저장 실패 뒤 회원 캔, 충전 잔액, `UseCan` 건수와 예약 존재 여부를 별도 트랜잭션에서 다시 조회했다.
|
||||
- 왜: `CanPaymentService.spendCan()`의 기본 `REQUIRED` 전파가 외부 예약 트랜잭션에 참여해 결제 변경도 함께 롤백되는지 직접 확인하기 위해서다.
|
||||
- 결제 선행 확인: 예약 저장 실패를 발생시키기 직전에 같은 트랜잭션의 `UseCan` 건수가 호출 전보다 1 증가했는지 확인해 결제가 저장보다 먼저 실행됐음을 고정했다.
|
||||
- RED: `makeReservation()`에 임시 `noRollbackFor = [DataIntegrityViolationException::class]` 변이를 적용하고 새 단일 테스트를 실행했다. XML은 tests=1, failures=1이며 회원 캔 검증이 expected 100, actual 0으로 실패했다.
|
||||
- GREEN: 변이를 즉시 원복하고 같은 단일 테스트를 재실행했다. `BUILD SUCCESSFUL in 58s`, XML은 tests=1, failures=0, errors=0이다.
|
||||
- 리뷰 보정: 예약 저장 실패 직전 `UseCan` 증가 assertion을 추가한 뒤 정상 경계에서 단일 테스트가 `BUILD SUCCESSFUL in 39s`로 통과했다. 같은 변이를 다시 적용하면 XML tests=1, failures=1과 expected 100, actual 0을 재현했다.
|
||||
- 최종 GREEN: 변이를 원복하고 `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 실행했다. `BUILD SUCCESSFUL in 3m 47s`, XML은 tests=2, failures=0, errors=0이다.
|
||||
- production code 원복 확인: `git diff -- src/main/kotlin/kr/co/vividnext/sodalive/live/reservation/LiveReservationService.kt`가 출력 없이 종료됐다.
|
||||
|
||||
---
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 계획 작성 시점에는 production code와 테스트를 변경하지 않았다.
|
||||
- 2026-07-15: 문서 변경 후 `./gradlew --no-daemon tasks --all` 명령 유효성을 확인했다.
|
||||
- sandbox 실행은 Gradle wrapper lock 파일 접근 제한으로 실패했다.
|
||||
- 승인 실행은 `BUILD SUCCESSFUL in 11s`로 통과했다.
|
||||
- 2026-07-15: `git diff --check`가 출력 없이 통과해 문서 공백 오류가 없음을 확인했다.
|
||||
- 2026-07-15: production code 수정 전 단일 통합 테스트가 예상한 `LiveRoom.reservations`의 `LazyInitializationException`으로 실패해 RED를 확인했다.
|
||||
- 2026-07-15: `makeReservation()`에 `@Transactional`을 추가한 뒤 같은 통합 테스트가 통과해 GREEN을 확인했다.
|
||||
- 2026-07-15: 관련 단일 테스트, `ktlintCheck`, `tasks --all`, `git diff --check`가 최종 통과했다.
|
||||
- 2026-07-15: 완료 선언 전 `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.live.reservation.LiveReservationServiceIntegrationTest`를 실행해 캐시 없이 `BUILD SUCCESSFUL in 3m 39s`를 확인했다. 출력된 deprecation/unchecked cast 경고는 기존 파일에서 발생했으며 이번 변경 파일과 무관하다.
|
||||
- 2026-07-15: 브랜치 완료 전 전체 회귀 검증으로 `./gradlew --no-daemon test`를 실행해 `BUILD SUCCESSFUL in 5m 48s`를 확인했다.
|
||||
- 2026-07-15: 유료 예약 원자성 회귀 테스트 보강 후 같은 통합 테스트 클래스를 `--rerun-tasks`로 실행해 `BUILD SUCCESSFUL in 3m 47s`, XML tests=2, failures=0, errors=0을 확인했다.
|
||||
- 2026-07-15: `./gradlew --no-daemon ktlintCheck`는 `BUILD SUCCESSFUL in 22s`, `./gradlew --no-daemon tasks --all`은 `BUILD SUCCESSFUL in 6s`로 통과했다.
|
||||
- 2026-07-15: 최초 캐시 사용 단일 테스트 명령은 `:test NO-SOURCE`와 빈 테스트 산출물로 종료되어 검증 증거로 인정하지 않았다. `--rerun-tasks` 실행에서는 실제 테스트 XML을 확인했으며, 이 산출물 재현성 현상의 원인 조사와 수정은 별도 빌드 작업으로 분리한다.
|
||||
@@ -1,63 +0,0 @@
|
||||
# PRD: 라이브 예약 LazyInitializationException 수정
|
||||
|
||||
## 1. Overview
|
||||
`spring.jpa.open-in-view=false` 환경에서 라이브 예약 생성 시 `LiveRoom.reservations` lazy 컬렉션 접근으로 발생하는 `LazyInitializationException`을 서비스 트랜잭션 경계로 방지한다.
|
||||
|
||||
## 2. Problem
|
||||
- `LiveReservationService.makeReservation()`에는 쓰기 트랜잭션 경계가 없다.
|
||||
- `liveRoomRepository.findByIdOrNull()` 호출이 끝난 뒤 반환된 `LiveRoom`은 영속성 컨텍스트에서 분리된다.
|
||||
- `reservation.room = room`은 `LiveReservation.room`의 사용자 정의 setter를 호출하고, setter는 `room.reservations.add(this)`로 lazy 컬렉션을 초기화한다.
|
||||
- OSIV가 비활성화된 상태에서는 컬렉션을 초기화할 Session이 없어 `org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.live.room.LiveRoom.reservations, could not initialize proxy - no Session`이 발생한다.
|
||||
- 유료 예약에서는 `CanPaymentService.spendCan()`만 자체 트랜잭션으로 먼저 커밋될 수 있어, 이후 예약 저장이 실패하면 결제와 예약 상태가 분리될 위험도 있다.
|
||||
|
||||
## 3. Goals
|
||||
- OSIV off 환경에서도 라이브 예약 생성이 lazy 초기화 예외 없이 완료된다.
|
||||
- `makeReservation()`의 라이브방 조회, 결제, 예약 저장을 하나의 트랜잭션 경계에서 처리한다.
|
||||
- 트랜잭션 없는 테스트 메서드에서 실제 Spring 서비스 프록시를 호출해 기존 오류를 재현하고 회귀를 방지한다.
|
||||
- 기존 라이브 예약 API URL, 요청 및 응답 스키마를 변경하지 않는다.
|
||||
|
||||
## 4. Non-Goals
|
||||
- `spring.jpa.open-in-view`를 활성화하지 않는다.
|
||||
- `LiveRoom.reservations`를 eager fetch로 변경하지 않는다.
|
||||
- `LiveReservation.room`의 양방향 연관관계 setter를 재설계하지 않는다.
|
||||
- 예약 중복 방지나 동시성 정책을 새로 도입하지 않는다.
|
||||
- 결제 및 예약 정책, 응답 문구와 날짜 포맷을 변경하지 않는다.
|
||||
|
||||
## 5. Target Users
|
||||
- 사용자: 무료 또는 유료 라이브를 오류 없이 예약하려는 회원
|
||||
- 운영자: OSIV off 정책을 유지하면서 결제와 예약 저장의 일관성을 보장하려는 운영 담당자
|
||||
|
||||
## 6. User Stories
|
||||
- 사용자는 예약 가능한 라이브를 선택했을 때 서버의 lazy 초기화 오류 없이 예약을 완료할 수 있어야 한다.
|
||||
- 유료 라이브 예약은 결제와 예약 저장 중 하나가 실패하면 전체 작업이 함께 롤백되어야 한다.
|
||||
- 운영자는 OSIV와 엔티티 fetch 전략을 변경하지 않고 서비스 트랜잭션 경계로 오류를 방지할 수 있어야 한다.
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 라이브 예약 생성 트랜잭션 보강
|
||||
|
||||
#### Requirements
|
||||
- `LiveReservationService.makeReservation()`에 쓰기 `@Transactional`을 적용한다.
|
||||
- `liveRoomRepository.findByIdOrNull()`로 조회한 `LiveRoom`은 예약 연관관계 설정과 저장이 끝날 때까지 managed 상태를 유지한다.
|
||||
- `CanPaymentService.spendCan()`의 기본 `REQUIRED` 전파 속성은 외부 예약 트랜잭션에 참여한다.
|
||||
- 기존 비밀번호 검증, 중복 예약 검증, 보유 캔 검증, 결제, 예약 응답 생성 순서를 유지한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 가격이 0인 무료 라이브도 `LiveRoom.reservations` lazy 컬렉션 초기화 예외 없이 예약된다.
|
||||
- 가격이 0보다 큰 라이브는 결제와 예약 저장이 같은 트랜잭션에 참여한다.
|
||||
- 존재하지 않는 라이브방이나 회원, 잘못된 비밀번호, 중복 예약, 캔 부족에 대한 기존 예외 동작을 유지한다.
|
||||
|
||||
## 8. Technical Constraints
|
||||
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, JUnit 5, Gradle Wrapper를 사용한다.
|
||||
- 서비스 쓰기 메서드 단위로 `@Transactional` 경계를 명확히 한다.
|
||||
- 테스트 클래스 자체 트랜잭션은 비활성화해 서비스 프록시의 트랜잭션 경계만 검증한다.
|
||||
- 실제 JPA 엔티티를 저장하고 영속성 컨텍스트를 비운 뒤 서비스를 호출하는 통합 테스트로 검증한다.
|
||||
- 변경 범위는 `LiveReservationService.makeReservation()`, 해당 회귀 테스트, PRD와 Plan/TASK 문서로 제한한다.
|
||||
|
||||
## 9. Metrics
|
||||
- 수정 전 회귀 테스트가 `LazyInitializationException`으로 실패한다.
|
||||
- 수정 후 같은 회귀 테스트가 통과하고 예약 레코드가 저장된다.
|
||||
- 관련 테스트, `ktlintCheck`, `tasks --all`, `git diff --check`가 통과한다.
|
||||
|
||||
## 10. Open Questions
|
||||
- 없음. 승인된 권장안인 서비스 쓰기 트랜잭션 적용과 OSIV off 통합 회귀 테스트로 범위를 확정한다.
|
||||
@@ -1,296 +0,0 @@
|
||||
# AI 캐릭터 관리자 API Contract
|
||||
|
||||
## 1. 문서 목적
|
||||
|
||||
클라이언트 개발과 서버 계약 테스트가 같은 스키마를 사용하도록 AI 캐릭터 관리자 API의 전체 request/response를
|
||||
OpenAPI 3.1 JSON으로 고정한다.
|
||||
|
||||
- 정식 계약: `api-contract.openapi.json`
|
||||
- endpoint: 37개
|
||||
- 현재 route 구현: 37개
|
||||
- 현재 계약과 일치하는 구현 완료: 37개
|
||||
- 계약 정합화 필요: 0개
|
||||
- 구현 예정: 0개
|
||||
- 공통 envelope: `ApiResponse<T>`
|
||||
- 인증: JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN` 동시 충족
|
||||
|
||||
`x-implementation-status`의 의미는 다음과 같다.
|
||||
|
||||
- `implemented`: route가 구현되어 있고 확정 계약과 후속 리뷰 Gate를 통과했다.
|
||||
- `alignment-required`: route는 구현되어 있으나 승인된 최신 계약에 맞춘 request/response 정합화가 필요하다.
|
||||
- `planned`: 계약과 구현 Task는 확정됐으나 route가 아직 구현되지 않았다.
|
||||
|
||||
## 2. 계약 결정
|
||||
|
||||
1. 신규 endpoint와 `characterId` 기반 관리자 target 경계는 유지한다.
|
||||
2. JSON 필드명, 타입, optional/nullable, 기본값과 성공 `data` 형태는 레거시 API를 유지한다.
|
||||
3. 신규 path에 포함된 `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`, `replyId`만 request body에서 중복
|
||||
제거한다.
|
||||
4. 레거시 mutation이 `ApiResponse.ok(null)`이면 신규 endpoint도 `data: null`을 반환한다.
|
||||
5. 오디오 콘텐츠 생성은 레거시 `CreateAudioContentResponse(contentId)`를 반환한다.
|
||||
6. FanTalk 답변 작성만 신규 계획의 축약 응답
|
||||
`fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 사용한다.
|
||||
7. FanTalk 목록은 공개 v2 `CreatorChannelFanTalkTabResponse`의 필드 형태를 유지하는 관리자 전용 endpoint로 추가한다.
|
||||
8. 시리즈 연결 콘텐츠 목록과 미연결 콘텐츠 검색은 response shape가 달라 별도 endpoint로 분리한다.
|
||||
9. 시리즈 상세 `data`는 배열 wrapper 없이 시리즈 목록 `items`의 단일 객체와 동일한 필드·타입을 사용한다.
|
||||
10. 오디오 콘텐츠와 커뮤니티 게시글 댓글의 원댓글/답글 조회는 레거시 응답을 유지하고, 작성은 target AI 캐릭터
|
||||
명의로 수행한다.
|
||||
11. 댓글 수정은 target AI 캐릭터가 작성한 댓글/답글만 허용한다. 삭제는 target 소유 리소스에 달린 댓글/답글이면
|
||||
작성자와 관계없이 해당 row만 soft delete하며, 이미 비활성인 row의 삭제는 성공 no-op이다.
|
||||
12. 댓글 생성의 optional `parentId`가 없으면 원댓글, 있으면 같은 리소스의 활성 원댓글에 대한 답글이다.
|
||||
13. 팬이 작성한 FanTalk 원글 삭제는 target 채널의 root만 soft delete하고 이미 비활성이면 성공 no-op이며 연결된 creator reply row는 유지한다.
|
||||
14. FanTalk 답변 수정은 레거시 `PutWriteCheersRequest`에서 path `replyId`로 이동한 `cheersId`만 제거하고
|
||||
optional/nullable `content`, `isActive`, 빈 객체 no-op과 `CreatorChannelFanTalkResponse` 성공 `data`를 유지한다.
|
||||
15. 캐릭터에 직접 달리는 레거시 댓글 삭제 API는 v2 전환 후 사용하지 않으므로 계약과 구현 범위에서 제외한다.
|
||||
16. 캐릭터 등록용 원작 검색과 시리즈 장르 목록은 기존 관리자 조회 규칙과 전체 response 필드를 재사용한다.
|
||||
17. AI 캐릭터 관리자 오디오·커뮤니티 API는 `timezone` query/body를 받지 않는다. 오디오 생성의 nullable
|
||||
`releaseDate`는 클라이언트가 ISO-8601 UTC(`Z`)로 변환해 보내며, 오디오 상세 `releaseDate`와 오디오·커뮤니티
|
||||
댓글 `date`도 기존 필드명과 null/노출 조건을 유지한 채 ISO-8601 UTC(`Z`)로 반환한다.
|
||||
|
||||
## 3. endpoint와 레거시 근거
|
||||
|
||||
| 상태 | Method | Endpoint | request 근거 | response `data` 근거 |
|
||||
|---|---|---|---|---|
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters` | `searchTerm?`, `page`, `size` | `ChatCharacterListPageResponse` / `ChatCharacterSearchListPageResponse` |
|
||||
| 구현 완료 | POST | `/api/v2/admin/ai-characters` | `ChatCharacterRegisterRequest`, 필수 `image` | `null` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/original-works/search` | 필수 `searchTerm` | `List<OriginalWorkResponse>` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}` | path only | `ChatCharacterDetailResponse` |
|
||||
| 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}` | `ChatCharacterUpdateRequest`에서 `id` 제외 | `null` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/audio-content-themes` | body 없음 | `List<GetAudioContentThemeResponse>` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | `search_word?`, `page`, `size` | `GetCreatorAdminContentListResponse` |
|
||||
| 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | `timezone`을 제외하고 nullable UTC `releaseDate`를 받는 `AudioContentCreateRequest`, `contentFile`, `coverImage` | `CreateAudioContentResponse` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | path only | `releaseDate`가 nullable UTC인 `GetAudioContentDetailResponse` |
|
||||
| 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | `UpdateCreatorAdminContentRequest`에서 `id` 제외 | `null` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments` | `page`, `size` | 댓글 `date`가 UTC인 `GetAudioContentCommentListResponse` |
|
||||
| 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments` | `comment`, `parentId?`, `isSecret?`, `languageCode?` | `null` |
|
||||
| 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}` | `comment` | `null` |
|
||||
| 구현 완료 | DELETE | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}` | body 없음 | `null` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments/{commentId}/replies` | `page`, `size` | 댓글 `date`가 UTC인 `GetAudioContentCommentListResponse` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/series-genres` | body 없음 | `List<GetSeriesGenreListResponse>` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/series` | `page`, `size` | `GetCreatorAdminContentSeriesListResponse` |
|
||||
| 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/series` | `CreateSeriesRequest`, 필수 `image` | `null` |
|
||||
| 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/series/orders` | `UpdateOrdersRequest` | `null` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}` | path only | 시리즈 목록 `items` 단일 객체 |
|
||||
| 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}` | `ModifySeriesRequest`에서 `seriesId` 제외 | `null` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents` | `page`, `size` | `GetCreatorAdminContentSeriesContentResponse` |
|
||||
| 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents` | `AddingContentToTheSeriesRequest`에서 `seriesId` 제외 | `null` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/search` | 필수 `search_word` | `List<SearchContentNotInSeriesResponse>` |
|
||||
| 구현 완료 | DELETE | `/api/v2/admin/ai-characters/{characterId}/series/{seriesId}/contents/{contentId}` | body 없음 | `null` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/community-posts` | `page`, `size` | `AiCharacterAdminCommunityPostListResponse(totalCount, page, size, hasNext, items)` |
|
||||
| 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/community-posts` | `CreateCommunityPostRequest`, `audioFile?`, `postImage?` | `null` |
|
||||
| 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}` | 두 레거시 update request에서 ID 제외 | `null` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` | `page`, `size` | 댓글 `date`가 UTC인 `GetCommunityPostCommentListResponse` |
|
||||
| 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` | `comment`, `parentId?`, `isSecret?` | `null` |
|
||||
| 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | `comment` | `null` |
|
||||
| 구현 완료 | DELETE | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}` | body 없음 | `null` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments/{commentId}/replies` | `page`, `size` | 댓글 `date`가 UTC인 `GetCommunityPostCommentListResponse` |
|
||||
| 구현 완료 | GET | `/api/v2/admin/ai-characters/{characterId}/fan-talks` | 공개 v2 `page?`, `size?` | `CreatorChannelFanTalkTabResponse` |
|
||||
| 구현 완료 | DELETE | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}` | body 없음 | `null` |
|
||||
| 구현 완료 | POST | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies` | `content` | 신규 축약 응답 |
|
||||
| 구현 완료 | PUT | `/api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}` | `PutWriteCheersRequest`에서 `cheersId` 제외: `content?`, `isActive?` | `CreatorChannelFanTalkResponse` |
|
||||
|
||||
## 4. JSON Schema 해석
|
||||
|
||||
- 객체의 `required` 배열에 포함된 필드는 JSON key가 필수다.
|
||||
- `required`에 없으면 optional이며 key를 생략할 수 있다.
|
||||
- `type: ["string", "null"]`, 다른 union의 `type: "null"`은 명시적 `null`을 허용한다.
|
||||
- response DTO의 nullable 생성자 필드는 key는 존재하고 값이 `null`일 수 있으므로 `required`와 nullable을 함께 사용한다.
|
||||
- request의 optional nullable 필드는 key 생략과 명시적 `null`을 모두 허용한다.
|
||||
- request는 `additionalProperties: false`이므로 정의되지 않은 이름은 계약 위반이다.
|
||||
- 모든 DB ID는 JSON integer, OpenAPI `int64`다.
|
||||
- 승인된 UTC 예외인 오디오 생성·상세 `releaseDate`, 오디오·커뮤니티 댓글 `date`, FanTalk `createdAtUtc`는
|
||||
OpenAPI `date-time`이다. 그 밖의 레거시 날짜 문자열은 기존 format을 유지한다.
|
||||
- enum은 대소문자를 구분하며 Kotlin enum 이름을 그대로 사용한다.
|
||||
- `Accept-Language`는 `ko`, `en`, `ja` 외 문자열도 전송할 수 있고 서버가 KO로 fallback하므로 enum으로 제한하지 않는다.
|
||||
|
||||
### multipart
|
||||
|
||||
multipart API의 `request` part는 schema상 JSON 객체다.
|
||||
|
||||
- part name: `request`
|
||||
- part Content-Type: `application/json`
|
||||
- Character 생성: `image`, `request` 필수
|
||||
- Character 수정: `image` optional, `request` 필수
|
||||
- AudioContent 생성: `contentFile`, `coverImage`, `request` 필수
|
||||
- AudioContent 수정: `coverImage` optional, `request` 필수
|
||||
- Series 생성: `image`, `request` 필수
|
||||
- Series 수정: `image` optional, `request` 필수
|
||||
- Community 생성: `audioFile`, `postImage` optional, `request` 필수
|
||||
- Community 수정/고정: `postImage` optional, `request` 필수
|
||||
|
||||
## 5. domain별 전체 필드 근거
|
||||
|
||||
### Character
|
||||
|
||||
- request:
|
||||
`ChatCharacterRegisterRequest`, `ChatCharacterUpdateRequest`,
|
||||
`ChatCharacterRelationshipRequest`, `ChatCharacterPersonalityRequest`,
|
||||
`ChatCharacterBackgroundRequest`, `ChatCharacterMemoryRequest`
|
||||
- response:
|
||||
`ChatCharacterListResponse`, `ChatCharacterListPageResponse`,
|
||||
`ChatCharacterSearchListPageResponse`, `ChatCharacterDetailResponse`,
|
||||
`RelationshipResponse`, `PersonalityResponse`, `BackgroundResponse`,
|
||||
`MemoryResponse`, `OriginalWorkBriefResponse`, `OriginalWorkResponse`
|
||||
- 목록 response는 `totalCount`, `content`이며 `items/page/size/hasNext`로 바꾸지 않는다.
|
||||
- 상세에는 `systemPrompt`, 캐릭터 속성 배열과 `originalWork`를 포함한다.
|
||||
- 생성과 수정의 성공 `data`는 상세가 아니라 `null`이다.
|
||||
- 캐릭터 등록용 원작 검색은 필수 `searchTerm`으로 제목·콘텐츠 타입·카테고리를 부분 검색하고, soft delete 원작을
|
||||
제외한 `OriginalWorkResponse` 전체 필드의 직접 배열을 반환한다. 별도 pagination은 두지 않는다.
|
||||
- 수정은 `isActive=false`와 다른 optional field의 동시 입력도 레거시 request처럼 허용한다. 이 경우 레거시 service 의미대로
|
||||
비활성화만 반영하고 나머지 JSON field는 적용하지 않는다.
|
||||
|
||||
### AudioContent
|
||||
|
||||
- request:
|
||||
`AudioContentCreateRequest`, `UpdateCreatorAdminContentRequest`,
|
||||
`RegisterCommentRequest`, `ModifyCommentRequest`
|
||||
- response:
|
||||
`GetAudioContentThemeResponse`, `GetCreatorAdminContentListResponse`,
|
||||
`GetCreatorAdminContentListItem`, `CreateAudioContentResponse`,
|
||||
`GetAudioContentDetailResponse`, `OtherContentResponse`,
|
||||
`AudioContentCreator`, `ContentBuyer`,
|
||||
`GetAudioContentCommentListItem`, `TranslatedContent`
|
||||
- `detail`, `releaseDate`, `contentFile`, `id/theme/image`,
|
||||
`audioContentId`, `contentUrl`, `tags`를 레거시 이름 그대로 사용한다.
|
||||
- 현재 v2 전용 `description`, `releaseDateUtc`, `audioSignedUrl`, `status`,
|
||||
`seriesIds`, `themeName`, `imageUrl` 별칭은 정식 계약에 포함하지 않는다.
|
||||
- 생성 request는 `timezone`을 받지 않으며 nullable `releaseDate`를 ISO-8601 UTC(`Z`)로 받는다. 로컬 시각과
|
||||
timezone을 함께 받는 레거시 생성 형식은 신규 관리자 endpoint에서 지원하지 않는다.
|
||||
- 상세 response의 nullable `releaseDate`는 기존 null/노출 조건을 유지하고, 값이 있으면 ISO-8601 UTC(`Z`)로 반환한다.
|
||||
- 댓글/답글 목록은 `timezone` 없이 `page`, `size`와 레거시 `totalCount`, `items`를 유지하고, 각 `date`를
|
||||
ISO-8601 UTC(`Z`)로 반환한다.
|
||||
- 댓글 생성은 target AI 캐릭터 명의로 수행한다. `parentId`는 optional/nullable, `isSecret` 기본값은 `false`,
|
||||
`languageCode`는 optional/nullable이다.
|
||||
- 댓글 수정 request는 `comment`만 받으며 target AI 캐릭터가 작성한 활성 댓글/답글만 수정한다.
|
||||
- 삭제는 target 소유 활성 오디오 콘텐츠의 댓글/답글 row 하나만 `isActive=false`로 변경한다. 하위 답글을
|
||||
cascade 삭제하지 않으며 mutation 성공 `data`는 `null`이다.
|
||||
|
||||
### Series
|
||||
|
||||
- request:
|
||||
`CreateSeriesRequest`, `ModifySeriesRequest`,
|
||||
`AddingContentToTheSeriesRequest`, `RemoveContentToTheSeriesRequest`,
|
||||
`UpdateOrdersRequest`
|
||||
- response:
|
||||
`GetCreatorAdminContentSeriesListResponse`,
|
||||
`GetCreatorAdminContentSeriesContentResponse`,
|
||||
`SearchContentNotInSeriesResponse`, `GetSeriesGenreListResponse`
|
||||
- `publishedDaysOfWeek` enum은 `SUN..SAT`, `RANDOM`이며 state는
|
||||
`PROCEEDING`, `SUSPEND`, `COMPLETE`다.
|
||||
- 상세 `data`는 목록 `items`의 단일 객체와 동일하게
|
||||
`seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genreId`, `isAdult`,
|
||||
`state`, `isActive`, `writer`, `studio`를 반환한다. 별도 `genre`, `keywords`는 반환하지 않는다.
|
||||
- 등록용 장르 목록은 활성 장르를 `orders` 오름차순으로 조회하고 `id`, `genre`, `isAdult`의 직접 배열을 반환한다.
|
||||
- 연결 request는 `contentIdList`, 순서 request는 `ids`다.
|
||||
|
||||
### Community
|
||||
|
||||
- request:
|
||||
`CreateCommunityPostRequest`, `ModifyCommunityPostRequest`,
|
||||
`UpdateCommunityPostFixedRequest`, `CreateCommunityPostCommentRequest`,
|
||||
`ModifyCommunityPostCommentRequest`
|
||||
- response:
|
||||
`GetCommunityPostListResponse`, `GetCommunityPostCommentListResponse`,
|
||||
`GetCommunityPostCommentListItem`
|
||||
- 2026-07-29 사용자 확정에 따라 목록은 레거시 직접 배열의 예외다. `timezone` query를 제거하고 `data`를
|
||||
`AiCharacterAdminCommunityPostListResponse(totalCount, page, size, hasNext, items)`로 반환한다.
|
||||
- `totalCount`는 target creatorMember 소유 active 게시글 전체 개수이고, `items`는 기존
|
||||
`GetCommunityPostListResponse` 필드·고정 우선 정렬을 유지한다. `hasNext`는 현재 page 뒤에 active owner 게시글이
|
||||
더 있는지를 나타낸다.
|
||||
- 생성 part 이름은 `postImage`, `audioFile`이다.
|
||||
- 수정 endpoint는 기존 본문 수정과 고정/해제를 합치므로 ID를 제외한
|
||||
`content`, `isCommentAvailable`, `isAdult`, `isActive`, `isFixed`를 받는다.
|
||||
- 레거시에 없는 수정 `price`, `audioFile`은 포함하지 않는다.
|
||||
- 댓글/답글 목록은 `timezone` 없이 `page`, `size`와 레거시 `totalCount`, `items`를 유지하고, 각 `date`를
|
||||
ISO-8601 UTC(`Z`)로 반환한다.
|
||||
- 댓글 생성은 target AI 캐릭터 명의로 수행하고 optional/nullable `parentId`와 기본값 `false`인 `isSecret`을
|
||||
받는다.
|
||||
- 댓글 수정 request는 `comment`만 받으며 target AI 캐릭터가 작성한 활성 댓글/답글만 수정한다.
|
||||
- 삭제는 target 소유 활성 게시글의 댓글/답글 row 하나만 `isActive=false`로 변경한다. 하위 답글을 cascade
|
||||
삭제하지 않으며 mutation 성공 `data`는 `null`이다.
|
||||
|
||||
### FanTalk
|
||||
|
||||
- 목록 response:
|
||||
`CreatorChannelFanTalkTabResponse`, `CreatorChannelFanTalkResponse`,
|
||||
`CreatorChannelFanTalkReplyResponse`
|
||||
- 공개 v2 endpoint를 직접 사용하지 않는다. 공개 v2는 `creatorId`, viewer 인증과 block filter, 다른 CORS 경계를 사용하기
|
||||
때문이다.
|
||||
- 신규 관리자 목록은 `characterId`로 creator를 해석하고 공개 v2 response field만 유지한다.
|
||||
- 답변 작성 request는 path로 이동한 `creatorId`, `parentId`를 제외하고 `content`만 받는다.
|
||||
- 답변 작성 response는 사용자 승인 예외인 신규 축약 형태다.
|
||||
- 답변 수정 request는 레거시 `PutWriteCheersRequest`에서 path로 이동한 `cheersId`만 제외하고 optional/nullable
|
||||
`content`, `isActive`를 받는다. 두 필드를 함께 입력할 수 있고, 모두 생략하거나 `null`이면 성공 no-op이다.
|
||||
- 답변 수정 대상은 target AI가 writer이자 creator이고 path의 활성 root에 직접 연결된 reply로 한정한다. reply 자체가
|
||||
비활성이어도 `isActive=true`로 재활성화할 수 있으며, 다른 target/root·팬 작성 row·nested mismatch는 400이다.
|
||||
- 답변 수정은 non-null field만 반영하고 기존 `languageCode`를 변경하거나 언어 감지·이벤트를 발생시키지 않는다.
|
||||
- 답변 수정 response `data`는 레거시 `CreatorChannelFanTalkResponse` 필드 형태다. `fanTalkId`는 수정한 reply row ID이고
|
||||
`creatorReplies`는 빈 배열이다.
|
||||
- 삭제는 target 채널에 속한 팬 작성 원글만 허용하고 해당 root row만 `isActive=false`로 변경한다. 연결된 creator reply
|
||||
row는 유지되며 목록에서 root가 제외되므로 함께 노출되지 않는다.
|
||||
- 이미 비활성인 원글 삭제는 성공 no-op이고 성공 `data`는 `null`이다.
|
||||
|
||||
## 6. 공통 응답과 오류
|
||||
|
||||
일반 조회 성공:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": null,
|
||||
"data": {},
|
||||
"errorProperty": null
|
||||
}
|
||||
```
|
||||
|
||||
레거시 mutation 성공:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": null,
|
||||
"data": null,
|
||||
"errorProperty": null
|
||||
}
|
||||
```
|
||||
|
||||
오류:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "현지화된 오류 메시지",
|
||||
"data": null,
|
||||
"errorProperty": null
|
||||
}
|
||||
```
|
||||
|
||||
주요 status는 400, 401, 403, 404, 405, 406, 415, 500이다. 405는 `Allow`, 415는 `Accept` header를 유지한다.
|
||||
Spring CORS 계층이 차단한 미허용 Origin의 403 body는 이 envelope 계약 대상이 아니다.
|
||||
|
||||
## 7. 기계 검증과 클라이언트 생성
|
||||
|
||||
```bash
|
||||
jq empty docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json
|
||||
|
||||
npx --yes @redocly/cli lint --skip-rule info-license \
|
||||
docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json
|
||||
|
||||
npx --yes @openapitools/openapi-generator-cli validate \
|
||||
-i docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json
|
||||
|
||||
npx --yes @openapitools/openapi-generator-cli generate \
|
||||
-i docs/20260724_AI캐릭터_관리자_API/api-contract.openapi.json \
|
||||
-g typescript-fetch \
|
||||
-o /tmp/ai-character-admin-typescript-client
|
||||
|
||||
tsc --noEmit --target ES2020 --module commonjs --lib ES2020,DOM \
|
||||
/tmp/ai-character-admin-typescript-client/index.ts
|
||||
```
|
||||
|
||||
`info-license`만 제외하는 이유는 저장소 라이선스 값을 추측해 계약에 추가하지 않기 위해서다.
|
||||
생성 클라이언트에는 계약의 37개 operation이 모두 포함된다. 37개 모두 실제 route가 구현되어 있고 `implemented` 상태다.
|
||||
|
||||
OpenAPI의 `/` server URL은 현재 host를 의미하지만 OpenAPI Generator 7.24.0의 `typescript-fetch` runtime 기본값은
|
||||
`http://localhost`다. 실제 클라이언트는 배포 환경의 API origin을 `new Configuration({ basePath: "..." })`로 반드시
|
||||
지정한다.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,422 +0,0 @@
|
||||
# PRD: AI 캐릭터 관리자 API
|
||||
|
||||
## 1. Overview
|
||||
운영자가 AI 캐릭터용 Member로 직접 로그인하지 않고, `ADMIN` 권한으로 선택한 AI 캐릭터의 크리에이터 채널 자산과 팬 상호작용을
|
||||
대리 관리하는 신규 v2 관리자 API를 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem
|
||||
- AI 캐릭터용 `Member(memberKind = AI_CHARACTER)`는 직접 로그인할 수 없어야 하지만, 운영자는 캐릭터의 콘텐츠, 시리즈,
|
||||
커뮤니티, 각 자산의 댓글과 FanTalk를 관리해야 한다.
|
||||
- 기존 기능은 `creatorMember.id` 기반으로 흩어져 있으며, 관리자 frontend가 레거시 endpoint를 조합하면 권한, 소유권, soft delete 의미가 일관되지 않을 수 있다.
|
||||
- 기존 creator/admin service 일부에는 소유권 검증이 약한 경로가 있어, 단순 위임만으로는 다른 캐릭터나 HUMAN creator 자원을 변경할 위험이 있다.
|
||||
- 캐릭터 등록에 필요한 원작 검색과 시리즈 등록에 필요한 장르 목록은 범용 관리자 API에만 있어 캐릭터 관리자 배포 Origin에서
|
||||
호출할 수 없다.
|
||||
- 기존 legacy/public API 계약은 유지해야 하므로 신규 관리자 표면은 별도 v2 경계로 제공되어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
- 신규 prefix `/api/v2/admin/ai-characters/**`는 JWT `auth` claim의 `ROLE_ADMIN`과 JWT subject로 조회한 현재 DB
|
||||
`Member.role == ADMIN`을 모두 만족하는 요청만 허용한다.
|
||||
- 모든 신규 target endpoint는 외부 대상 식별자로 `characterId`를 받고, 서버가 `ChatCharacter.creatorMember`를 내부 행위자로
|
||||
해석한다. 단, 캐릭터 목록/검색·생성, 원작 검색과 시리즈 장르 목록은 선택된 target이 필요하지 않아 `characterId`를 받지
|
||||
않는다.
|
||||
- target 해석 시 `ChatCharacter` 존재, `creatorMember` 존재, `creatorMember.role == CREATOR`, `creatorMember.memberKind == AI_CHARACTER`를 모두 검증한다.
|
||||
- 검증 실패 시 4xx로 거부하고 DB, S3, 외부 캐릭터 API, 이벤트 발행 등 후속 부작용을 만들지 않는다.
|
||||
- 캐릭터, 오디오 콘텐츠와 댓글, 시리즈, 커뮤니티 게시글과 댓글, FanTalk 목록·답변 작성·수정·팬 원글 삭제, 오디오
|
||||
signed URL을 신규 관리자 API에서 관리한다.
|
||||
- 기존 legacy/public endpoint의 URI, 성공·오류 HTTP status, response body, message/i18n을 포함한 외부 계약은 변경하지 않는다.
|
||||
단, 캐릭터 관리자 frontend가 기존 관리자 인증을 재사용할 수 있도록 `/admin/member/login`, `/member/logout`의 CORS 허용
|
||||
Origin만 path-specific으로 확장한다.
|
||||
- 내부 구현은 신규 v2 controller/facade/application 경계를 두고, 기존 entity/repository/S3/CloudFront/event 컴포넌트는 테스트로 고정한 뒤 선택적으로 재사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-Goals
|
||||
- 이번 PRD는 관리자 API backend 요구사항과 구현 계획만 포함하며, 관리자 UI/frontend 구현은 포함하지 않는다.
|
||||
- AI 캐릭터용 Member의 access token, refresh token, 임시 세션, impersonation 로그인은 만들지 않는다.
|
||||
- `creatorMemberId`를 관리자 frontend의 필수 입력으로 노출하지 않는다.
|
||||
- HUMAN creator를 이 API로 대리 관리하지 않는다.
|
||||
- 위 두 공유 인증 경로의 CORS 허용 Origin 확장 외 기존 legacy/public endpoint 변경, 폐기, deprecation, schema 변경은 포함하지
|
||||
않는다.
|
||||
- 기존 external character API business contract 변경은 포함하지 않으며, 변경이 필요하면 재확인한다.
|
||||
- 기존 soft delete 의미 변경은 포함하지 않으며, 변경이 필요하면 재확인한다.
|
||||
- 물리 삭제와 연관 데이터 cascade 삭제는 포함하지 않는다.
|
||||
- 신규 DB schema/DDL 또는 `ChatCharacter`-`Member` 관계 모델 변경은 포함하지 않는다.
|
||||
- 라이브, DM, 후원, 정산, 알림 설정, 랭킹 관리, 콘텐츠 구매·좋아요와 커뮤니티 구매·좋아요 관리는 포함하지 않는다.
|
||||
- 사용하지 않는 레거시 캐릭터 직접 댓글 `/api/chat/character/{characterId}/comments`의 v2 전환·조회·삭제는 포함하지 않는다.
|
||||
- FanTalk 원글 작성, creator reply 전용 삭제 endpoint·hard delete, 일반 사용자 대리 작성, nested reply 작성과 FanTalk
|
||||
원글 삭제 시 reply cascade 변경은 포함하지 않는다.
|
||||
- `AudioContentCloudFront` 복사/이동, signed URL 신규 dependency 추가는 포함하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target Users
|
||||
- 운영자: AI 캐릭터를 대신해 캐릭터 프로필, 콘텐츠, 시리즈, 커뮤니티 게시글, 각 자산의 댓글과 FanTalk를 관리하는 관리자
|
||||
- 관리자 frontend: 신규 v2 AI 캐릭터 관리자 API만으로 In-Scope 작업을 수행해야 하는 클라이언트
|
||||
- 서버 개발자: 기존 creator 기능을 회귀시키지 않으면서 AI 캐릭터 대리 관리 경계를 유지해야 하는 개발자
|
||||
|
||||
---
|
||||
|
||||
## 6. User Stories
|
||||
- 운영자는 AI 캐릭터 목록을 검색하고 상세 정보를 확인한 뒤 생성, 수정, 비활성화하고 싶다.
|
||||
- 운영자는 캐릭터 등록 시 soft delete되지 않은 원작을 검색해 `originalWorkId`를 선택하고 싶다.
|
||||
- 운영자는 선택한 AI 캐릭터 소유 오디오 콘텐츠를 조회, 생성, 수정, soft delete하고 관리자 화면에서 재생 가능한 signed URL을 받고 싶다.
|
||||
- 운영자는 선택한 AI 캐릭터의 오디오 콘텐츠에 달린 댓글·답글을 조회하고, 캐릭터 명의로 작성·수정하며, 부적절한 댓글·답글을
|
||||
soft delete하고 싶다.
|
||||
- 운영자는 선택한 AI 캐릭터 소유 시리즈를 조회, 생성, 수정, soft delete하고 콘텐츠 연결/해제/순서를 관리하고 싶다.
|
||||
- 운영자는 시리즈 등록 시 활성 장르 목록에서 `genreId`를 선택하고 싶다.
|
||||
- 운영자는 선택한 AI 캐릭터 소유 커뮤니티 게시글을 작성, 수정, 고정/해제, soft delete하고 싶다.
|
||||
- 운영자는 선택한 AI 캐릭터의 커뮤니티 게시글에 달린 댓글·답글을 조회하고, 캐릭터 명의로 작성·수정하며, 부적절한 댓글·답글을
|
||||
soft delete하고 싶다.
|
||||
- 운영자는 선택한 AI 캐릭터의 FanTalk 목록과 기존 creator reply를 확인한 뒤 활성 root FanTalk에 답변하고, 기존 답변의
|
||||
내용·활성 상태를 수정하거나 팬이 작성한 원글을 soft delete하고 싶다.
|
||||
- 서버는 다른 AI 캐릭터나 HUMAN creator의 resource ID가 전달되면 변경 없이 4xx로 거부해야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 7. Core Features
|
||||
|
||||
### Feature A. 공통 인증, 인가, target 해석
|
||||
|
||||
#### Requirements
|
||||
- 모든 신규 prefix endpoint는 JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 독립적으로 모두 검증한다.
|
||||
- JWT가 없거나 잘못됐거나 만료·폐기된 경우는 401, JWT role과 현재 DB role 중 하나라도 ADMIN이 아닌 경우는 403으로
|
||||
처리한다.
|
||||
- JWT에는 `ROLE_ADMIN`이 남아 있지만 현재 DB role이 강등된 stale claim도 403으로 거부한다.
|
||||
- 선택한 캐릭터 자원을 다루는 domain write/read는 `characterId`로 `ChatCharacter`를 조회한 뒤 연결된 `creatorMember`를
|
||||
사용한다. 원작 검색과 시리즈 장르 목록은 target 없는 reference endpoint이므로 `characterId`를 받거나 target을 해석하지
|
||||
않는다.
|
||||
- `creatorMember`는 도메인 소유권/작성자 판단에만 사용하고 Spring Security principal로 교체하지 않는다.
|
||||
- `creatorMember` 누락, role 불일치, memberKind 불일치 요청은 4xx로 거부한다.
|
||||
- 요청 중 누락 Member 생성, role/memberKind 자동 보정 같은 lazy repair는 하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- stale claim을 포함한 인증·인가 실패는 target resolver와 domain use-case 실행 전에 종료되어야 한다.
|
||||
- 유효하지 않은 `characterId` 요청은 DB write, S3 upload/delete, 외부 캐릭터 API 호출, 이벤트 발행 없이 실패해야 한다.
|
||||
- 다른 AI 캐릭터 또는 HUMAN creator 소유 resource ID는 조회/수정/삭제/연결/답변 모두 거부해야 한다.
|
||||
|
||||
### Feature B. AI 캐릭터 관리
|
||||
|
||||
#### Requirements
|
||||
- 목록 조회, 검색, 상세 조회, 생성, 수정, 삭제 의미의 비활성화(`isActive=false`)를 제공한다.
|
||||
- 캐릭터 등록 화면에서 사용할 soft delete되지 않은 원작 검색을 `characterId` 없는 관리자 전용 endpoint로 제공한다.
|
||||
- 원작 검색은 레거시 `AdminOriginalWorkController.search`처럼 필수 `searchTerm`으로 제목·콘텐츠 타입·카테고리를 부분
|
||||
검색하고 soft delete된 원작을 제외하며, 페이징 없는 `OriginalWorkResponse` 목록을 반환한다.
|
||||
- 레거시 플랫폼 관리자와 중복 이름 검증, 외부 캐릭터 API 연동, 대표 이미지 저장, 원작 연결, 언어 감지/번역 이벤트, AI 캐릭터용 `creatorMember` 생성 및 표시 정보 동기화 동작 parity를 유지한다.
|
||||
- 삭제는 soft delete이며 row, 연결 Member, 콘텐츠를 물리 삭제하지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- 중복 이름, 외부 캐릭터 API 실패, 이미지 저장 실패는 기존 관리자 동작을 특성화 테스트로 고정한 뒤 유지한다.
|
||||
- 비활성화 실패 시 일부 관계만 변경된 상태로 남기지 않는다.
|
||||
- 원작 검색은 캐릭터 target을 해석하지 않지만 신규 prefix의 ADMIN 이중 인가·오류·CORS 계약을 동일하게 적용한다.
|
||||
|
||||
### Feature C. 오디오 콘텐츠 관리 및 signed URL
|
||||
|
||||
#### Requirements
|
||||
- 캐릭터 소유 콘텐츠 목록/검색/상세 조회, 생성, 수정, 기존 삭제 동작에 따른 soft delete를 제공한다.
|
||||
- 콘텐츠 생성 화면에서 사용할 활성 콘텐츠 테마(카테고리) 목록 조회를 제공한다. 기존 크리에이터 관리자 콘텐츠 등록
|
||||
화면의 `GetAudioContentThemeResponse`와 같은 `id`, `theme`, `image` 필드명을 유지한다.
|
||||
- 기존 크리에이터 콘텐츠 관리의 검증, 파일 처리, content upload/processing pipeline, 가격, 공개/예약, 번역/알림 등 business behavior parity를 유지한다.
|
||||
- 관리자 화면 재생용 signed URL을 콘텐츠 목록/상세 응답에 제공한다.
|
||||
- signed URL은 공통 `AudioContentCloudFront`를 재사용하고 기존 크리에이터 관리자와 같은 만료 정책을 따른다.
|
||||
- 기존 signed URL 구현을 재사용하기 전에 creator admin 만료 계산식과 만료 계산·path 처리에서 실제로 관찰되는 edge case를 통과하는 특성화 테스트로 고정한다.
|
||||
- 응답에 private object path나 서명 키 정보를 노출하지 않는다.
|
||||
- 신규 관리자 오디오 생성 request는 `timezone`을 받지 않고 nullable `releaseDate`를 클라이언트가 변환한 ISO-8601
|
||||
UTC(`Z`)로 받는다. 로컬 시각과 timezone을 함께 받는 레거시 생성 형식은 병행 지원하지 않는다.
|
||||
- 오디오 상세의 nullable `releaseDate`는 기존 필드명과 null/노출 조건을 유지하고, 값이 있으면 ISO-8601 UTC(`Z`)로
|
||||
반환한다. 상세 조회는 `timezone` query를 받지 않는다.
|
||||
- target 소유의 활성 오디오 콘텐츠에 대해 댓글 목록, 답글 목록, 댓글·답글 작성, 캐릭터가 작성한 댓글·답글 수정과 모든
|
||||
댓글·답글 soft delete를 제공한다.
|
||||
- 댓글·답글 작성자는 관리자 principal이 아니라 target `creatorMember`이며, optional `parentId`가 없으면 원댓글, 있으면
|
||||
답글로 저장한다.
|
||||
- 댓글·답글 내용 수정은 작성자가 target `creatorMember`인 활성 row에만 허용한다.
|
||||
- 댓글·답글 삭제는 작성자와 관계없이 target 소유 콘텐츠에 연결된 row의 `isActive=false`만 반영하고 자식 답글을 cascade
|
||||
변경하거나 물리 삭제하지 않는다.
|
||||
- 댓글·답글 조회는 `timezone` 없이 `page`, `size`를 받고 레거시 응답 필드를 유지하되, 각 `date`를 ISO-8601
|
||||
UTC(`Z`)로 반환한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 콘텐츠 소유자가 target `creatorMember`와 다르면 조회/수정/삭제 모두 거부한다.
|
||||
- 댓글 또는 답글이 target 소유 콘텐츠에 연결되지 않았거나, 답글 작성의 `parentId`가 같은 콘텐츠의 활성 원댓글이 아니면
|
||||
mutation 전에 400으로 거부한다.
|
||||
- 댓글 수정은 팬이 작성한 row를 target 캐릭터 명의로 변경하지 않으며, 이미 비활성인 row도 수정하지 않는다.
|
||||
- 이미 비활성인 댓글·답글의 삭제는 성공 no-op으로 처리하고 추가 상태 변경이나 이벤트를 만들지 않는다.
|
||||
- 커뮤니티 오디오의 기존 30분 signed URL 정책은 이 콘텐츠 재생 정책과 임의 통합하지 않는다.
|
||||
|
||||
### Feature D. 시리즈 관리
|
||||
|
||||
#### Requirements
|
||||
- 목록/상세 조회, 생성, 수정, `isActive=false` soft delete를 제공한다.
|
||||
- 시리즈 상세 응답 `data`는 목록 wrapper가 아니라 목록 `items`의 단일 항목과 동일한 schema를 사용한다. 필드는
|
||||
`seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genreId`, `isAdult`, `state`, `isActive`,
|
||||
`writer`, `studio`이며 기존 상세 전용 `genre`, `keywords`는 반환하지 않는다.
|
||||
- 콘텐츠 연결/해제, 시리즈 콘텐츠 조회/검색, 순서 관리를 제공한다.
|
||||
- 기존 creator series 관리의 생성/수정/soft delete, 콘텐츠 연결/해제, 조회/검색, 순서 관리 behavior를 먼저 통과하는 특성화 테스트로 고정하고 신규 v2 경로에서 parity를 유지한다.
|
||||
- 시리즈와 연결 콘텐츠는 모두 동일한 `creatorMember` 소유여야 한다.
|
||||
- 기존 `updateSeriesOrders(ids)`처럼 소유권 없는 ID-only 갱신은 신규 v2 경로에서 허용하지 않는다.
|
||||
- 시리즈 콘텐츠 조회는 관리자 연결 작업을 위해 검색어 기반 필터를 제공한다.
|
||||
- 시리즈 등록 화면에서 사용할 활성 장르 목록을 `characterId` 없는 관리자 전용 endpoint로 제공한다.
|
||||
- 장르 목록은 레거시 범용 관리자 API처럼 활성 장르 전체를 `orders` 오름차순으로 반환하며 각 항목에 `id`, `genre`,
|
||||
`isAdult`를 포함한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 순서 변경 요청의 모든 series/content ID는 target character 소유 검증을 통과해야 한다.
|
||||
- inactive series는 일반 활성 조회에서 제외한다.
|
||||
- 장르 목록은 캐릭터 target을 해석하지 않지만 신규 prefix의 ADMIN 이중 인가·오류·CORS 계약을 동일하게 적용한다.
|
||||
|
||||
### Feature E. 커뮤니티 게시글 관리
|
||||
|
||||
#### Requirements
|
||||
- 등록, 수정, 공지 고정/해제(`isFixed`), 수정 요청의 `isActive=false` soft delete를 제공한다.
|
||||
- soft delete 시 현재 동작처럼 `isFixed=false`, `fixedAt=null`을 적용한다.
|
||||
- 기존 최대 고정 게시글 수 3개, 이미지/오디오/유료 게시글 검증, 알림/최근 소식 side effect를 유지한다.
|
||||
- 관리자 UI에 필요한 조회는 기존 v2 커뮤니티 조회 로직을 무비판적으로 복제하지 않고 신규 관리자 facade/endpoint에서 안전하게 재사용하거나 최소 query adapter를 둔다.
|
||||
- 관리자 커뮤니티 목록은 `timezone` query를 받지 않고 `page`, `size`만 받는다.
|
||||
- 목록 응답 `data`는 `totalCount`, `page`, `size`, `hasNext`, `items`를 포함해 관리자 UI가 전체 개수와 다음 페이지
|
||||
추가 로딩 필요 여부를 판단할 수 있어야 한다.
|
||||
- target 소유의 활성 커뮤니티 게시글에 대해 댓글 목록, 답글 목록, 댓글·답글 작성, 캐릭터가 작성한 댓글·답글 수정과 모든
|
||||
댓글·답글 soft delete를 제공한다.
|
||||
- 댓글·답글 작성자는 관리자 principal이 아니라 target `creatorMember`이며, optional `parentId`가 없으면 원댓글, 있으면
|
||||
답글로 저장한다.
|
||||
- 댓글·답글 내용 수정은 작성자가 target `creatorMember`인 활성 row에만 허용한다.
|
||||
- 댓글·답글 삭제는 작성자와 관계없이 target 소유 게시글에 연결된 row의 `isActive=false`만 반영하고 자식 답글을 cascade
|
||||
변경하거나 물리 삭제하지 않는다.
|
||||
- 댓글·답글 조회는 `timezone` 없이 `page`, `size`를 받고 레거시 응답 필드를 유지하되, 각 `date`를 ISO-8601
|
||||
UTC(`Z`)로 반환한다.
|
||||
|
||||
#### Edge Cases
|
||||
- 고정 게시글이 이미 3개인 상태에서 추가 고정은 기존 정책대로 실패한다.
|
||||
- soft delete된 고정 게시글은 고정 상태와 시간이 반드시 제거되어야 한다.
|
||||
- 댓글 또는 답글이 target 소유 게시글에 연결되지 않았거나, 답글 작성의 `parentId`가 같은 게시글의 활성 원댓글이 아니면
|
||||
mutation 전에 400으로 거부한다.
|
||||
- 댓글 수정은 팬이 작성한 row를 target 캐릭터 명의로 변경하지 않으며, 이미 비활성인 row도 수정하지 않는다.
|
||||
- 이미 비활성인 댓글·답글의 삭제는 성공 no-op으로 처리하고 추가 상태 변경이나 이벤트를 만들지 않는다.
|
||||
|
||||
### Feature F. FanTalk 관리
|
||||
|
||||
#### Requirements
|
||||
- 선택한 AI 캐릭터의 FanTalk 목록과 각 root 글의 creator reply 목록을 관리자 전용 endpoint로 조회한다.
|
||||
- 목록 응답 필드와 page 정책은 공개 v2 `CreatorChannelFanTalkTabResponse`를 유지하되, 공개 v2 endpoint를 직접 재사용하지
|
||||
않고 `characterId` target 해석과 관리자 ownership 정책을 적용한다.
|
||||
- 선택한 AI 캐릭터가 자신의 활성 root FanTalk에 creator reply를 작성한다.
|
||||
- 요청은 `characterId`와 대상 root `fanTalkId`를 포함한다.
|
||||
- 대상 FanTalk가 존재하고 활성 상태이며, 대상 creator가 해석된 `creatorMember`와 일치하는 root 글인지 검증한다.
|
||||
- 언어 감지와 기존 응답 DTO 의미 등 검증 가능한 business behavior를 유지하고, 저장된 답변의 writer/creator는 해석된 `creatorMember`와 일관되어야 한다.
|
||||
- 선택한 AI 캐릭터가 작성한 기존 direct reply의 내용과 활성 상태를 수정하는 관리자 전용 endpoint를 제공한다.
|
||||
- 답변 수정 request는 레거시 `PutWriteCheersRequest`에서 path로 이동한 `cheersId`를 제외한 optional/nullable `content`,
|
||||
`isActive`를 그대로 받는다. 두 필드는 함께 입력할 수 있고 모두 생략하거나 `null`이면 성공 no-op이다.
|
||||
- 답변 수정 대상은 target `creatorMember`가 writer이자 creator인 direct reply여야 하며, `fanTalkId`로 지정한 target
|
||||
채널의 활성 root에 직접 연결되어야 한다. 비활성 reply는 조회 대상에 포함해 `isActive=true`로 재활성화할 수 있다.
|
||||
- 답변 수정은 레거시와 같이 non-null field만 반영하며 저장된 `languageCode`를 변경하거나 언어 감지·이벤트를 발생시키지
|
||||
않는다.
|
||||
- 답변 수정 성공 `data`는 레거시 `CreatorChannelFanTalkResponse` 필드 형태를 유지한다. 응답의 `fanTalkId`는 수정한
|
||||
reply row ID이고 `creatorReplies`는 빈 배열이다.
|
||||
- 팬이 작성한 root FanTalk를 `isActive=false`로 soft delete하는 관리자 전용 endpoint를 제공한다.
|
||||
- FanTalk 삭제는 root row만 비활성화하고 연결된 creator reply row는 변경하지 않는다. 비활성 root가 목록에서 제외되므로
|
||||
연결된 reply도 함께 노출되지 않는다.
|
||||
|
||||
#### Edge Cases
|
||||
- 다른 캐릭터의 FanTalk, reply에 대한 nested reply, 비활성 FanTalk, 미존재 FanTalk에는 답변하지 않는다.
|
||||
- 실패 시 reply 저장과 이벤트 발행이 없어야 한다.
|
||||
- 답변 수정 시 root·reply가 다른 target에 속하거나, reply가 지정한 root의 direct child가 아니거나, root가
|
||||
비활성·미존재이거나, target AI가 작성하지 않은 팬 root/reply이면 400으로 거부하고 변경하지 않는다.
|
||||
- 답변 수정 대상 reply 자체의 비활성 상태는 거부 조건이 아니며, `isActive=true` 재활성화를 허용한다.
|
||||
- FanTalk 삭제 대상은 target `creatorMember` 채널에 연결된 `parent=null`의 fan 작성 root여야 한다. 같은 target의 이미
|
||||
비활성인 fan root는 성공 no-op이고, creator가 작성한 root, reply, 다른 creator의 root, 미존재 root는 변경 없이
|
||||
400으로 거부한다.
|
||||
- FanTalk 원글 삭제는 reply 삭제·수정이나 별도 이벤트를 발생시키지 않는다.
|
||||
|
||||
---
|
||||
|
||||
## 8. API Expectations
|
||||
- 신규 endpoint prefix는 기존 공개 `/api/v2/creator-channels/*`와 legacy `/admin/*`, `/creator-admin/*`를 변경하지 않기 위해 `/api/v2/admin/ai-characters`를 기본안으로 한다.
|
||||
- 성공 응답은 `ApiResponse.ok(...)`, API application/controller/security filter 오류는 오류 의미에 맞는 HTTP status와
|
||||
`ApiResponse.error(...)`를 사용한다.
|
||||
- 이 API 오류 응답은 `success=false`와 현지화된 `message`를 포함하며 2xx로 normalize하지 않는다.
|
||||
- `Accept-Language: ko|en|ja`에 따라 KO/EN/JA 메시지를 반환하고, 없거나 지원하지 않는 언어는 KO로 fallback한다.
|
||||
- security filter 단계의 오류도 MVC interceptor에 의존하지 않고 `Accept-Language`를 직접 해석해 동일한 응답 계약을 따른다.
|
||||
- 신규 prefix는 캐릭터 관리자 frontend Origin `http://localhost:8888`,
|
||||
`https://test-character-admin.sodalive.net`, `https://character-admin.sodalive.net`만 허용한다.
|
||||
- 기존 범용 관리자 frontend와 creator frontend Origin을 캐릭터 관리자 Origin 대신 허용하지 않는다.
|
||||
- 공유 인증 경로 `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 위 캐릭터 관리자 Origin의 합집합만 허용한다.
|
||||
이 path-specific 확장은 다른 legacy/public 경로의 CORS 허용 범위를 변경하지 않는다.
|
||||
- 위 관리자 Origin의 신규 prefix 오류와 preflight는 404 fallback 및 실제 mapped endpoint의 405/406/415 경로를 포함해 기존
|
||||
전역 CORS 응답 계약을 유지하며, 두 공유 인증 경로에서도 허용·거부 Origin을 검증한다.
|
||||
- 허용되지 않은 Origin, method 또는 header를 Spring CORS 계층에서 정책 거부하는 경우는 handler 진입 전 403으로 종료되는
|
||||
브라우저 보안 경계다. 이 403의 body, content type, 현지화 및 `ApiResponse.error` envelope는 신규 API 오류 계약의 예외로
|
||||
두고 외부 계약으로 고정하지 않는다.
|
||||
- 표준 HTTP method가 MVC까지 도달했지만 해당 mapping이 없으면 기존 Spring MVC의 405와 `Allow` header를 유지한다.
|
||||
- `StrictHttpFirewall`이 신규 prefix에서 비표준 HTTP method 또는 위험 URL을 `RequestRejectedException`으로 거부하면,
|
||||
캐릭터 관리자 허용 Origin에는 CORS header를 포함한 400 `common.error.invalid_request`와 현지화된 `ApiResponse.error`를
|
||||
반환한다. 허용되지 않은 Origin은 기존 Spring CORS 정책과 같이 body 계약 없는 403으로 종료한다.
|
||||
- `SecurityConfig`는 기존 `AiCharacterAdminSecurityErrorHandler`를 global `RequestRejectedHandler`로 등록하되 신규 prefix만 위
|
||||
400/CORS 계약으로 처리하고, legacy/public은 `DefaultRequestRejectedHandler`에 위임해 기존 `RequestRejectedException` 동작을
|
||||
유지한다. Spring 5.3의 비표준 method enum 한계 때문에 CORS 검사 request만 `GET` wrapper를 사용하며 실제 firewall method
|
||||
허용 범위는 확장하지 않고 `setUnsafeAllowAnyHttpMethod(true)`도 사용하지 않는다.
|
||||
- Phase 1 공통 오류는 인증 정보 없음·잘못됨·만료·폐기 401 `common.error.bad_credentials`, JWT 또는 현재 DB role의
|
||||
ADMIN 불충족 403 `common.error.access_denied`, request/target 미존재·불변식 위반 400, 신규 prefix 미매핑 경로 404, 지원하지
|
||||
않는 HTTP method 405, 응답 media type 406, 요청 media type 415를 `common.error.invalid_request`로 고정한다. 405는 표준
|
||||
`Allow` header를, 415는 표준 `Accept` header를 유지한다. controller mapping의 필수 path variable 선언이 누락된
|
||||
`MissingPathVariableException`과 예상하지 못한 controller/JWT filter 오류는 500 `common.error.unknown`으로 고정한다.
|
||||
- malformed JSON의 `HttpMessageNotReadableException`, handler에 전달된 `MethodArgumentNotValidException`, multipart 필수 part
|
||||
누락의 `MissingServletRequestPartException`은 각각 400 `common.error.invalid_request`와 KO/EN/JA `ApiResponse.error`를 반환한다.
|
||||
- 이후 phase의 domain/client/server 오류는 각 task에서 정확한 HTTP status와 KO/EN/JA message key를 먼저 정의하고 같은
|
||||
envelope를 적용한다.
|
||||
- 신규 prefix 전용 오류 처리는 legacy/public endpoint의 기존 성공·오류 응답에 적용하지 않는다.
|
||||
- request/response의 기계 검증 가능한 단일 기준은 같은 디렉터리의 `api-contract.openapi.json`이다. 설명과 레거시 근거는
|
||||
`api-contract.md`에 기록한다.
|
||||
- 신규 endpoint는 레거시 API의 request/response 필드명, 타입, optional/nullable, 기본값과 성공 `data` 형태를 그대로
|
||||
이관한다. `characterId`, `contentId`, `seriesId`, `postId`, `fanTalkId`, `replyId`처럼 신규 path로 이동한 ID만
|
||||
request body에서 중복 제거한다.
|
||||
- 캐릭터 등록용 원작 검색은 `GET /api/v2/admin/ai-characters/original-works/search?searchTerm=...`, 시리즈 장르 목록은
|
||||
`GET /api/v2/admin/ai-characters/series-genres`로 제공하며 두 endpoint 모두 `characterId`를 받지 않는다.
|
||||
- 오디오 콘텐츠 댓글은
|
||||
`/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}/comments`, 커뮤니티 댓글은
|
||||
`/api/v2/admin/ai-characters/{characterId}/community-posts/{postId}/comments` 하위에서 각각 댓글 목록, 답글 목록, 작성,
|
||||
수정, 삭제 5개 operation으로 제공한다. 답글 목록은 `/{commentId}/replies`, 수정·삭제는 `/{commentId}` 하위 path를
|
||||
사용한다.
|
||||
- 오디오 댓글 작성 request는 `comment`, optional `parentId`, `isSecret`, `languageCode`, 커뮤니티 댓글 작성 request는
|
||||
`comment`, optional `parentId`, `isSecret`을 받는다. 수정 request는 두 domain 모두 `comment`만 받으며, 삭제는 request
|
||||
body를 받지 않는다.
|
||||
- 두 댓글 domain의 목록과 답글 목록은 `GetAudioContentCommentListResponse` 또는
|
||||
`GetCommunityPostCommentListResponse`에 해당하는 `totalCount`, `items` 형태를 유지한다. 두 목록은 `timezone`
|
||||
query를 받지 않고 각 댓글 `date`를 ISO-8601 UTC(`Z`)로 반환한다.
|
||||
- FanTalk 팬 원글 삭제는
|
||||
`DELETE /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}`로 제공한다.
|
||||
- FanTalk 답변 수정은
|
||||
`PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`로 제공한다.
|
||||
- 위 14개 신규 operation을 기존 23개에 추가해 전체 관리자 계약은 37개 operation으로 관리한다.
|
||||
- `GET /api/v2/admin/ai-characters/{characterId}/series/{seriesId}`의 성공 `data`는
|
||||
`GET /api/v2/admin/ai-characters/{characterId}/series`의 `items` 단일 항목과 동일한 schema를 참조한다.
|
||||
- 캐릭터 수정은 레거시 `ChatCharacterUpdateRequest`처럼 `isActive=false`와 다른 optional field의 동시 입력을 허용하며,
|
||||
이 경우 레거시 service 의미대로 비활성화만 반영한다.
|
||||
- 목록 endpoint의 query와 page 동작은 각 레거시 API를 따른다. FanTalk 관리자 목록만 공개 v2 탭의 `page` 기본값 0,
|
||||
`size` 기본값 20, 최소 20, 최대 50 보정을 따른다.
|
||||
- 오디오 콘텐츠 생성 multipart의 `request`는 `timezone`을 포함하지 않는다. nullable `releaseDate`는 클라이언트가
|
||||
UTC로 변환한 ISO-8601 `date-time`(`Z`)이며 기존 로컬 시각+timezone 형식은 신규 endpoint에서 받지 않는다.
|
||||
- 오디오 콘텐츠 상세, 오디오 댓글·답글 목록, 커뮤니티 댓글·답글 목록은 `timezone` query를 받지 않는다. 상세
|
||||
`releaseDate`와 댓글 `date`는 기존 필드명 및 nullable/노출 조건을 유지한 ISO-8601 UTC(`Z`)다.
|
||||
- 2026-07-29 사용자 확정에 따라 커뮤니티 관리자 목록은 위 레거시 이관 원칙의 예외로 둔다. 사용하지 않는
|
||||
`timezone` query를 제거하고 `data`를 `totalCount`, `page`, `size`, `hasNext`, `items` pagination wrapper로 반환한다.
|
||||
- multipart 생성/수정은 기존 admin/creator-admin 관례대로 파일 part와 `request` JSON string part를 사용한다.
|
||||
- 시리즈 연결 콘텐츠 목록과 미연결 콘텐츠 검색은 응답 형태가 다르므로 각각
|
||||
`GET .../series/{seriesId}/contents`와 `GET .../series/{seriesId}/contents/search?search_word=...`로 분리한다.
|
||||
- 레거시 mutation이 `ApiResponse.ok(null)`을 반환하면 신규 endpoint도 `data: null`을 반환한다. 오디오 콘텐츠 생성은
|
||||
레거시 `CreateAudioContentResponse(contentId)`를 유지한다. FanTalk 답변 작성만 신규 계획의 축약 응답
|
||||
`fanTalkId`, `replyId`, `creatorMemberId`, `content`, `createdAtUtc`를 사용한다. FanTalk 답변 수정은 레거시
|
||||
`CreatorChannelFanTalkResponse` 필드 형태를 유지한다.
|
||||
- 신규 댓글 작성·수정·삭제와 FanTalk 원글 삭제의 성공 응답은 모두 `ApiResponse.ok(null)`을 사용한다.
|
||||
- request/response 구현 DTO는 신규 v2 AI character admin API 전용으로 둘 수 있지만, JSON 외부 계약은
|
||||
`api-contract.openapi.json`의 레거시 필드명과 형태를 유지한다.
|
||||
|
||||
---
|
||||
|
||||
## 9. Technical Constraints
|
||||
- Kotlin, Java 17, Spring Boot 2.7.14, Gradle Wrapper를 유지한다.
|
||||
- 신규 dependency를 추가하지 않는다.
|
||||
- 신규 DB schema/DDL을 만들지 않는다.
|
||||
- 기존 v2 API 조립 계층과 domain/application 의존 방향을 따른다.
|
||||
- controller 내부 호출, 서버 내부 legacy HTTP 호출, 기존 controller 역참조는 하지 않는다.
|
||||
- 신규 v2 application/domain 계층은 기존 controller와 v2 API response DTO를 역참조하지 않는다.
|
||||
- 기존 business method를 재사용하기 전 특성화/회귀 테스트를 작성한다.
|
||||
- 특성화/회귀 테스트는 신규 v2 use-case의 미구현 RED 테스트와 분리하고, 기존 legacy/creator-admin 구현을 대상으로 먼저 통과해야 한다.
|
||||
- 단순 복사-붙여넣기 대신 필요한 최소 추출 또는 v2 use-case 재개발을 선택한다.
|
||||
|
||||
---
|
||||
|
||||
## 10. Metrics
|
||||
- 신규 endpoint별 또는 controller slice별 JWT role × 현재 DB role 인가 매트릭스와 stale ADMIN claim 403 테스트 존재 여부
|
||||
- 신규 prefix의 각 API 오류 분기에 정확한 HTTP status, `ApiResponse.error`, KO/EN/JA와 405 `Allow`/415 `Accept` header 테스트
|
||||
존재 여부
|
||||
- 신규 prefix 실제 mapped endpoint 및 공유 인증 경로의 허용·거부 Origin/preflight 테스트 존재 여부
|
||||
- 신규 prefix의 표준 method 미매핑 405 `Allow` 유지와 `RequestRejectedException` 400/i18n/`ApiResponse.error`/허용 Origin CORS
|
||||
header, 미허용 Origin body 계약 없는 403 테스트 존재 여부
|
||||
- legacy/public firewall 동작 불변 및 `setUnsafeAllowAnyHttpMethod(true)` 미사용 확인 여부
|
||||
- core controller security/error 계약의 production `@SpringBootTest` full-context 실행과 Redis token fixture cleanup 확인 여부
|
||||
- target resolver 조회 직후 `creatorMember` 초기화와 fetch join 제거 시 실패하는 non-vacuous 회귀 테스트 존재 여부
|
||||
- `HttpMessageNotReadableException`, `MethodArgumentNotValidException`, `MissingServletRequestPartException`의 exact exception
|
||||
type과 KO/EN/JA 400 envelope 직접 검증 여부
|
||||
- target/ownership 실패 시 no-side-effect 테스트 존재 여부
|
||||
- character/original-work/content/content-comment/series/series-genre/community/community-comment/FanTalk slice별 targeted test
|
||||
통과 여부
|
||||
- 오디오 생성 request와 오디오 상세·댓글·답글 및 커뮤니티 댓글·답글 조회에서 `timezone`이 제거되고,
|
||||
`releaseDate`/`date`가 ISO-8601 UTC(`Z`)로 검증되는지 여부
|
||||
- 댓글 작성·수정의 target 작성자 제한, 소유 자산 댓글 삭제, parent 소유권·root 검증과 soft-delete no-cascade 테스트 존재 여부
|
||||
- FanTalk 팬 root 삭제, 비활성 팬 root no-op, creator reply row 보존·비노출 테스트 존재 여부
|
||||
- FanTalk 답변 수정의 optional/nullable `content`·`isActive`, 빈 객체 no-op, 비활성 reply 재활성화,
|
||||
target/root/direct-reply ownership과 레거시 성공 응답 테스트 존재 여부
|
||||
- signed URL TTL 계산식·edge case parity 및 private path 비노출 테스트 통과 여부
|
||||
- 기존 legacy/public endpoint의 성공·오류 status/body/message 회귀 테스트 통과 여부
|
||||
|
||||
---
|
||||
|
||||
## 11. Acceptance Criteria
|
||||
- JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 모두 만족하는 요청만 유효한 AI character 대상으로 신규 endpoint를
|
||||
호출할 수 있다.
|
||||
- 비로그인 또는 잘못된 JWT 요청은 401이고, JWT 비ADMIN + DB ADMIN과 JWT ADMIN + DB 비ADMIN stale claim은 모두 403이다.
|
||||
- 신규 prefix의 API application/controller/security filter 오류는 정확한 비2xx status, `ApiResponse.error`,
|
||||
`Accept-Language`에 따른 KO/EN/JA message를 반환한다. Spring CORS 계층의 정책 거부 403 body는 이 envelope 계약의 예외다.
|
||||
- 지원하지 않는 HTTP method는 405와 `Allow` header, 응답 media type은 406, 요청 media type은 415와 `Accept` header를
|
||||
반환하고, `MissingPathVariableException`은 500 `common.error.unknown`을 반환한다.
|
||||
- 표준 HTTP method가 MVC에 도달한 뒤 mapping이 없을 때는 기존 405와 `Allow` header를 유지한다. 신규 prefix의 비표준 HTTP
|
||||
method 또는 위험 URL이 `StrictHttpFirewall`에서 `RequestRejectedException`으로 거부되면 허용된 캐릭터 관리자 Origin에는
|
||||
CORS header와 현지화된 400 `common.error.invalid_request` `ApiResponse.error`를, 미허용 Origin에는 body 계약 없는 403을
|
||||
반환한다. legacy/public firewall 동작은 변하지 않고 `setUnsafeAllowAnyHttpMethod(true)`는 사용하지 않는다.
|
||||
- 신규 prefix는 캐릭터 관리자 Origin만 허용하고, `/admin/member/login`, `/member/logout`는 기존 전역 Origin과 캐릭터 관리자
|
||||
Origin의 합집합을 허용한다. 실제 mapped endpoint와 공유 인증 경로의 CORS 허용·거부가 테스트로 고정된다.
|
||||
- core controller security/error 계약은 production `@SpringBootTest` full context에서 검증하고 Redis token fixture를 테스트 후
|
||||
정리해 다음 테스트에 남기지 않는다.
|
||||
- target resolver의 repository 조회 결과는 반환 직후 `creatorMember`가 초기화되어 있어야 하며, fetch join 제거 시 실패하는
|
||||
회귀 테스트로 고정한다.
|
||||
- malformed JSON, handler에 전달된 `MethodArgumentNotValidException`, multipart 필수 part 누락은 각각 정확한 MVC exception
|
||||
type과 현지화된 400 `ApiResponse.error` 계약을 만족한다.
|
||||
- character 미존재, creatorMember 미존재, role 불일치, memberKind 불일치 요청은 4xx이며 아무 side effect도 남기지 않는다.
|
||||
- 다른 character 소유 resource ID를 사용한 조회/수정/삭제/연결/답변은 4xx로 거부된다.
|
||||
- 캐릭터 생성/수정/비활성화는 레거시 관리자 behavior parity를 유지한다.
|
||||
- 캐릭터 등록용 원작 검색은 soft delete된 원작을 제외하고 제목·콘텐츠 타입·카테고리 부분 검색 결과를 반환한다.
|
||||
- 콘텐츠 생성/수정/soft delete와 signed URL 응답은 기존 creator/admin behavior parity를 유지한다.
|
||||
- 신규 관리자 오디오 생성은 `timezone` 없이 nullable `releaseDate`를 ISO-8601 UTC(`Z`)로 받고, 상세 조회도
|
||||
`timezone` 없이 기존 nullable `releaseDate`를 ISO-8601 UTC(`Z`)로 반환한다.
|
||||
- 오디오 콘텐츠 댓글은 target 소유 활성 콘텐츠 범위에서 조회되고, target 명의 작성·작성자 한정 수정·소유자 moderation
|
||||
soft delete가 적용된다. 댓글·답글 목록은 `timezone` 없이 각 `date`를 ISO-8601 UTC(`Z`)로 반환한다.
|
||||
- 시리즈 CRUD, 콘텐츠 연결/해제/순서 변경은 target character 소유 범위를 벗어나지 않는다.
|
||||
- 시리즈 상세 `data`는 목록 `items` 한 건과 동일한 필드·타입을 반환하고 상세 전용 `genre`, `keywords`를 반환하지 않는다.
|
||||
- 시리즈 장르 목록은 활성 장르의 `id`, `genre`, `isAdult`를 `orders` 순으로 반환한다.
|
||||
- 커뮤니티 생성/수정/고정/해제/soft delete는 target creatorMember 소유 게시글에만 적용된다.
|
||||
- 커뮤니티 목록은 `timezone` 없이 조회되고 active owner 게시글의 전체 개수, 요청 page/size, 다음 페이지 여부와
|
||||
기존 목록 item 필드를 반환한다.
|
||||
- 커뮤니티 댓글은 target 소유 활성 게시글 범위에서 조회되고, target 명의 작성·작성자 한정 수정·소유자 moderation soft
|
||||
delete가 적용된다. 댓글·답글 목록은 `timezone` 없이 각 `date`를 ISO-8601 UTC(`Z`)로 반환한다.
|
||||
- FanTalk 관리자 목록은 target AI character의 root 글과 creator reply를 공개 v2 응답 필드명으로 반환하며, 공개 v2의
|
||||
viewer/block 필터나 creator 관리자 CORS 경계에 의존하지 않는다.
|
||||
- FanTalk 답변은 target AI character 자신의 활성 root FanTalk에만 저장된다.
|
||||
- FanTalk 답변 수정은 target AI character가 작성하고 해당 target의 활성 root에 직접 연결된 reply에만 적용된다.
|
||||
optional/nullable `content`와 `isActive`의 레거시 상태 전이 및 빈 객체 no-op을 유지하며, 비활성 reply를 재활성화할 수
|
||||
있고 성공 `data`는 레거시 `CreatorChannelFanTalkResponse` 필드 형태다.
|
||||
- FanTalk 원글 삭제는 target 채널의 팬 작성 root만 비활성화하고 이미 비활성이면 성공 no-op이며 연결 creator reply row를 변경하지 않는다.
|
||||
- 레거시 캐릭터 직접 댓글 API는 신규 v2 endpoint나 구현 계획에 포함되지 않는다.
|
||||
- 기존 legacy/public endpoint 테스트가 통과하고, 두 공유 인증 경로의 CORS 허용 Origin 확장 외 성공·오류
|
||||
status/body/message를 포함한 request/response contract가 변경되지 않는다.
|
||||
- 신규 dependency, 신규 DDL, 관련 없는 리팩터링이 없다.
|
||||
|
||||
---
|
||||
|
||||
## 12. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 범위 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-07-29 | `DEC-COMMENT-001` | 확정 | 오디오 콘텐츠·커뮤니티 댓글은 관리자 principal이 아닌 target AI 캐릭터 명의로 작성하고, target 작성 댓글만 내용을 수정하며, target 소유 자산의 댓글은 작성자와 관계없이 soft delete한다. | 사용자 승인과 기존 콘텐츠·게시글 소유자의 댓글 비활성화 동작 | Feature C, Feature E, API Expectations |
|
||||
| 2026-07-29 | `DEC-CHAR-COMMENT-001` | 제외 | 사용하지 않는 레거시 캐릭터 직접 댓글 API는 v2로 전환하거나 관리자 삭제 기능을 추가하지 않는다. | 사용자 확인 결과 v2 전환 후 미사용 | Non-Goals, Acceptance Criteria |
|
||||
| 2026-07-29 | `DEC-FANTALK-DELETE-001` | 확정 | 팬 작성 FanTalk root 삭제는 원글만 soft delete하고 연결 creator reply row는 변경하지 않는다. | 사용자 승인과 기존 `CreatorCheers.isActive` 동작 유지 | Feature F, API Expectations |
|
||||
| 2026-07-29 | `DEC-FANTALK-REPLY-UPDATE-001` | 확정 | FanTalk 답변 수정은 레거시 `PUT /explorer/profile/cheers`의 optional/nullable `content`, `isActive`, 빈 객체 no-op, 비활성 reply 재활성화와 `CreatorChannelFanTalkResponse` 성공 `data`를 유지한다. 신규 path의 `characterId`, root `fanTalkId`, `replyId`로 target AI 소유 direct reply를 한정한다. | 사용자 요청과 “기존 계약과 동일” 확정, 레거시 `ExplorerService.modifyCheers` 동작 | Feature F, API Expectations |
|
||||
| 2026-07-29 | `DEC-REGISTRATION-REFERENCE-001` | 확정 | 캐릭터 등록용 원작 검색과 시리즈 등록용 장르 목록을 target 없는 신규 v2 관리자 endpoint로 제공한다. | 레거시 API는 캐릭터 관리자 배포 Origin에서 호출할 수 없고 신규 frontend는 v2 경계를 사용해야 함 | Feature B, Feature D, API Expectations |
|
||||
| 2026-07-29 | `DEC-SERIES-DETAIL-001` | 확정 | 시리즈 상세 `data`를 목록 `items`의 단일 항목과 동일한 schema로 변경하고 기존 상세 전용 `genre`, `keywords`를 제거한다. | 사용자 확정과 관리자 목록·상세 DTO 일관성 | Feature D, API Expectations |
|
||||
| 2026-07-29 | `DEC-UTC-DATE-001` | 확정 | 신규 관리자 오디오 생성의 `timezone` body와 오디오 상세·오디오 댓글/답글·커뮤니티 댓글/답글 조회의 `timezone` query를 제거한다. 생성 `releaseDate`는 클라이언트가 UTC로 변환해 보내고, 상세 `releaseDate`와 댓글 `date`는 기존 필드명을 유지한 ISO-8601 UTC(`Z`)로 반환한다. | 클라이언트별 timezone 표시 변환을 제거하고 단일 절대 시각 계약을 유지한다는 사용자 승인 | Feature C, Feature E, API Expectations |
|
||||
|
||||
---
|
||||
|
||||
## 13. Open Questions
|
||||
- 없음.
|
||||
@@ -1,310 +0,0 @@
|
||||
# Phase 1 공통 경계·보안 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / 공통 target resolver, 보안, 오류 경계 |
|
||||
| 기준 commit 또는 working tree | `2f93e2c9` + 현재 Phase 2~7 working tree |
|
||||
| 리뷰 일자 | 2026-07-28 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `api-contract.openapi.json` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- PRD Feature A와 공통 API Expectations가 현재 resolver/security/error 구현에 유지되는지 확인한다.
|
||||
- Phase 2~6의 모든 신규 controller가 같은 prefix 경계와 target 불변식을 공유하는지 정적으로 추적한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `AiCharacterAdminTargetResolver`, `SecurityConfig`, 신규 prefix 오류 handler/writer
|
||||
- `AiCharacterAdminAuthorizationTest`, `AiCharacterAdminErrorContractTest`, resolver 관련 테스트
|
||||
- OpenAPI 공통 오류·security 정의와 plan Phase 1 완료 기록
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 테스트 재실행, Phase 2~6 domain 세부 동작, legacy/public API 변경
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 인증·인가 우회, cross-owner write, 데이터 손실 위험 |
|
||||
| High | PRD/OpenAPI 공통 보안·오류 계약 위반 |
|
||||
| Medium | 제한된 경로의 오류·현지화·부작용 계약 누락 |
|
||||
| Low | 유지보수성 또는 문서 정합성 문제 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: PRD Feature A, API Expectations, Acceptance Criteria
|
||||
- 계획: Phase 1 `Task 1.1`~`Task 1.7`과 완료 증거
|
||||
- 코드: `AiCharacterAdminTargetResolver.kt:17`~`35`
|
||||
- 코드: `SecurityConfig.kt:118`~`130`, `:202`~`:207`
|
||||
- 코드: `AiCharacterAdminExceptionHandler.kt:28`~`97`
|
||||
- 테스트: resolver unit/integration, authorization, error contract 테스트
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| 코드·문서 정적 추적 | 성공 | creator role/kind 불변식, JWT authority + 현재 DB ADMIN 이중 인가, prefix 전용 오류 경계 확인 |
|
||||
| Gradle/컴파일/테스트 | 미실행 | 사용자가 기존 통과 사실을 제공하고 직접 실행하지 말 것을 요청함 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 6. 주요 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| target 해석 | 충족 | character와 creatorMember를 함께 조회하고 `CREATOR + AI_CHARACTER`를 검증 |
|
||||
| ADMIN 이중 인가 | 충족 | `ROLE_ADMIN`, `MemberAdapter`, 현재 DB `Member.role == ADMIN`을 모두 요구 |
|
||||
| prefix 오류 경계 | 충족 | security/MVC/fallback 오류가 신규 prefix 전용 handler로 연결됨 |
|
||||
| Phase별 재사용 | 충족 | Character/Content/Series/Community/FanTalk facade가 공통 resolver를 사용 |
|
||||
| 후속 Task 필요성 | 없음 | 정적 근거에서 신규 확정 finding이 발견되지 않음 |
|
||||
|
||||
## 7. plan·goal 전환
|
||||
|
||||
확정 finding이 없어 Phase 1 신규 Task나 Goal을 추가하지 않았다. 기존 Phase 1 완료 이력은 유지한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
**최종 결론:** Phase 1 추가 수정 없음
|
||||
|
||||
**검증 제한:** 이번 판정은 정적 리뷰 결과다. 사용자 요청에 따라 테스트·컴파일을 재실행하지 않았으며 기존 plan의 통과
|
||||
기록을 실행 증거로 재사용하지 않고 참고만 했다.
|
||||
|
||||
## 9. 2차 정적 리뷰 및 판정 — 2026-07-28
|
||||
|
||||
### 리뷰 정보와 범위
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature A·API Expectations, plan Phase 1, OpenAPI 공통 security/error
|
||||
- 검토 범위: target resolver, SecurityConfig/WebConfig, JWT와 prefix 전용 security/MVC 오류 handler,
|
||||
Phase 2~6 facade의 resolver 사용
|
||||
- 검증 방식: 코드·문서·테스트 정적 추적. 사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| target 불변식 | 충족 | `CREATOR + AI_CHARACTER`, creatorMember 연결을 공통 resolver에서 검증 |
|
||||
| ADMIN 이중 인가 | 충족 | JWT authority와 현재 DB role을 모두 확인 |
|
||||
| 오류/CORS 경계 | 충족 | 신규 prefix 전용 handler와 허용 Origin 분리 유지 |
|
||||
| Phase별 적용 | 충족 | Character/AudioContent/Series/Community/FanTalk facade가 resolver 사용 |
|
||||
| plan 전환 | 해당 없음 | Phase 1 신규 확정 finding 없음 |
|
||||
|
||||
**최종 결론:** Phase 1 추가 수정 없음
|
||||
|
||||
**남은 항목:** 없음. `REV-030`~`REV-033`의 소유 Phase 보완 뒤 `P7-R2` 통합 재판정에 참여한다.
|
||||
|
||||
## 10. 3차 정적 리뷰 및 판정 — 2026-07-28
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature A·공통 오류, plan Phase 1, OpenAPI security/error
|
||||
- 검토 범위: target resolver, ADMIN 이중 인가, 신규 prefix 오류·CORS, Phase 2~6 resolver 적용
|
||||
- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| target 불변식 | 충족 | creatorMember fetch와 `CREATOR + AI_CHARACTER` 검증 유지 |
|
||||
| ADMIN 인가 | 충족 | JWT authority와 현재 DB role 이중 확인 유지 |
|
||||
| 오류/CORS | 충족 | 신규 prefix 전용 handler와 전용 Origin 정책 유지 |
|
||||
| Phase 적용 | 충족 | 각 domain facade가 공통 resolver를 통해 target을 해석 |
|
||||
| plan 전환 | 해당 없음 | Phase 1 신규 Task 불필요 |
|
||||
|
||||
**최종 결론:** Phase 1 추가 수정 없음
|
||||
|
||||
**남은 항목:** `P7-R3`에서 공통 인가·오류 회귀를 통합 재검증한다.
|
||||
|
||||
## 11. 4차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature A·공통 오류, plan Phase 1, OpenAPI 공통 security/error
|
||||
- 검토 범위: target resolver, ADMIN 이중 인가, 오류·CORS·firewall, 각 domain의 resolver 적용
|
||||
- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| target 불변식 | 충족 | fetch join과 `CREATOR + AI_CHARACTER` 검증 유지 |
|
||||
| ADMIN 인가 | 충족 | JWT authority와 현재 DB role을 독립 확인 |
|
||||
| 오류/CORS/firewall | 충족 | prefix 전용 handler와 legacy fallback 유지 |
|
||||
| Phase 적용 | 충족 | Character~FanTalk facade가 공통 resolver 사용 |
|
||||
| plan 전환 | 해당 없음 | Phase 1 신규 Task 불필요 |
|
||||
|
||||
**최종 결론:** Phase 1 추가 수정 없음
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 12. 5차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위
|
||||
|
||||
- PRD·OpenAPI 공통 ADMIN 인가, target resolver, 오류·CORS·firewall 계약
|
||||
- Phase 2~6 facade의 공통 resolver 사용과 JSON mapping 오류 변환 경계
|
||||
- primitive nullability 보완을 전역 설정이 아닌 각 v2 request 경계에 둘 수 있는지
|
||||
|
||||
### 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| target 불변식 | 충족 | `CREATOR + AI_CHARACTER` 검증과 owner 전달 경로 유지 |
|
||||
| ADMIN 인가 | 충족 | JWT authority와 현재 DB role의 이중 확인 유지 |
|
||||
| 오류/CORS/firewall | 충족 | prefix 전용 error writer/handler와 허용 origin 정책 유지 |
|
||||
| primitive finding 소유 | Phase 2~5 | 공통 mapper가 아니라 domain별 수동 `ObjectMapper` reader와 DTO에서 발생 |
|
||||
| plan 전환 | 해당 없음 | 전역 Jackson·공통 계층 변경 없이 각 Phase Task로 분리 |
|
||||
|
||||
사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않고 코드·계약만 정적으로 대조했다.
|
||||
|
||||
**최종 결론:** Phase 1 신규 수정 없음
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 13. 6차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD 공통 보안·오류·CORS 요구사항, OpenAPI 공통 response/security 계약
|
||||
- 검토 범위: 신규 prefix의 security matcher, JWT authority와 DB role 이중 인가, target resolver,
|
||||
공통 exception handler와 CORS 설정
|
||||
- 기준 상태: 현재 working tree
|
||||
- 검증 방식: 문서·코드·관련 테스트 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
|
||||
|
||||
### 확인 결과
|
||||
|
||||
| 항목 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| ADMIN 인가 | 충족 | 신규 prefix는 JWT `ROLE_ADMIN`과 현재 principal Member의 DB `ADMIN` role을 모두 확인 |
|
||||
| target 불변식 | 충족 | `characterId`가 가리키는 creatorMember의 `CREATOR + AI_CHARACTER`를 공통 resolver에서 검증 |
|
||||
| 오류/CORS | 충족 | prefix 전용 handler/writer와 승인된 Origin 범위 유지 |
|
||||
| 6차 finding 영향 | 없음 | `REV-052`~`REV-058`은 domain controller의 query/media type 경계에 한정 |
|
||||
|
||||
### finding 및 plan 전환
|
||||
|
||||
- 신규 Phase 1 finding 없음.
|
||||
- Phase 1 신규 Task/Gate 없음.
|
||||
|
||||
**최종 결론:** Phase 1 공통 보안·resolver·오류 경계 유지
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 14. 7차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD 공통 보안·오류·CORS 요구사항, OpenAPI 공통 security/error 계약
|
||||
- 검토 범위: security matcher, JWT authority/현재 DB role 이중 인가, target resolver, 공통 exception/CORS 경계
|
||||
- 검증 방식: 현재 working tree의 문서·코드·관련 테스트를 정적으로 대조했다. 사용자 요청에 따라 컴파일과 테스트는
|
||||
실행하지 않았다.
|
||||
|
||||
### 판정
|
||||
|
||||
| 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| ADMIN 이중 인가 | 충족 | JWT `ROLE_ADMIN`과 현재 principal Member의 DB `ADMIN` role을 독립 확인 |
|
||||
| target 불변식 | 충족 | `characterId` 대상의 `CREATOR + AI_CHARACTER` 검증과 owner 전달 경로 유지 |
|
||||
| 공통 오류/CORS | 충족 | 신규 prefix 전용 handler와 승인 Origin 정책 유지 |
|
||||
| 7차 finding 소유 | Phase 2~5·7 | multipart 이름 검증은 domain controller, 구현 상태 문서는 통합 Phase 소유 |
|
||||
|
||||
### finding 및 plan 전환
|
||||
|
||||
- 신규 Phase 1 finding 없음.
|
||||
- Phase 1 신규 Task/Gate 없음.
|
||||
|
||||
**최종 결론:** Phase 1 추가 수정 없음
|
||||
|
||||
**남은 항목:** `P7-R9` 통합 재판정에 공통 오류·인가 회귀 근거로 참여한다.
|
||||
|
||||
## 15. 8차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD 공통 보안·오류·CORS 요구사항, OpenAPI 공통 security/error 계약
|
||||
- 검토 범위: security matcher, JWT authority/현재 DB role 이중 인가, target resolver, 공통 exception/CORS 경계
|
||||
- 기준 상태: 현재 working tree
|
||||
- 리뷰어/상태: Codex / 판정 완료
|
||||
- 검증 방식: 문서·코드·관련 테스트 소스 정적 대조. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 판정
|
||||
|
||||
| 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| ADMIN 이중 인가 | 충족 | JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`을 독립 확인하는 경계 유지 |
|
||||
| target 불변식 | 충족 | `CREATOR + AI_CHARACTER` 검증과 owner 전달 경로 유지 |
|
||||
| 공통 오류/CORS | 충족 | 신규 prefix 전용 오류 envelope/i18n과 승인 Origin 정책 유지 |
|
||||
| 8차 finding 소유 | Phase 2~7 | multipart 전체 part, Series 장르 ID, FanTalk 설명, 문서 상태 문제로 공통 경계 변경 불필요 |
|
||||
|
||||
### finding 및 plan 전환
|
||||
|
||||
- 신규 Phase 1 finding 없음.
|
||||
- Phase 1 신규 Task/Gate 없음.
|
||||
|
||||
**최종 결론:** Phase 1은 요구사항과 일치하며 추가 수정이 없다.
|
||||
|
||||
**남은 항목:** Phase 2~7 후속 Goal 완료 뒤 `P7-R10` 통합 재판정에 공통 오류·인가 근거로 참여한다.
|
||||
|
||||
## 16. 9차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD 공통 인가·target resolver·오류·CORS 요구사항, OpenAPI 공통 security
|
||||
- 검토 범위: security matcher, JWT/현재 DB role 이중 인가, target resolver, 신규 prefix 오류·CORS 경계
|
||||
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
|
||||
테스트는 실행하지 않았다.
|
||||
|
||||
### 판정
|
||||
|
||||
| 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| ADMIN 이중 인가 | 충족 | JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN` 검증 경계 유지 |
|
||||
| target 불변식 | 충족 | `CREATOR + AI_CHARACTER` 검증과 creator member 해석 경로 유지 |
|
||||
| 오류·CORS | 충족 | 신규 prefix 전용 오류 envelope/i18n과 승인 Origin 경계 유지 |
|
||||
| 신규 finding | 없음 | Phase 3 preview 검증 회귀는 공통 security/target 경계 변경 없이 소유 Phase에서 수정 가능 |
|
||||
|
||||
### plan 전환
|
||||
|
||||
- 신규 Phase 1 Task/Gate 없음.
|
||||
|
||||
**최종 결론:** Phase 1 추가 수정 없음.
|
||||
|
||||
**남은 항목:** Phase 3 보완 뒤 `P7-R11` 통합 재판정.
|
||||
|
||||
## 17. 10차 정적 리뷰 및 판정 — 2026-07-30
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature A, OpenAPI 공통 security/error 계약
|
||||
- 검토 범위: 신규 prefix security matcher, JWT authority/현재 DB role 이중 인가, target resolver, 오류·CORS 경계
|
||||
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
|
||||
테스트는 실행하지 않았다.
|
||||
|
||||
### 판정
|
||||
|
||||
- JWT `ROLE_ADMIN`과 현재 DB `Member.role == ADMIN`의 이중 인가가 신규 prefix보다 먼저 적용된다.
|
||||
- target resolver는 `ChatCharacter.creatorMember`의 `CREATOR + AI_CHARACTER` 불변식을 유지한다.
|
||||
- prefix 전용 오류 envelope/i18n, 405 `Allow`, 415 `Accept`, 승인 Origin과 공유 로그인·로그아웃 CORS 경계가 유지된다.
|
||||
- 신규 확정 finding이 없어 Phase 1 회귀 수정 Task/Gate를 추가하지 않는다.
|
||||
|
||||
**최종 결론:** Phase 1 요구사항 충족, 추가 수정 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,605 +0,0 @@
|
||||
# Phase 4 시리즈 관리 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 4 / 시리즈 9개 operation |
|
||||
| 기준 commit 또는 working tree | `2f93e2c9` + 현재 Phase 4~7 working tree |
|
||||
| 리뷰 일자 | 2026-07-28 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `api-contract.openapi.json` |
|
||||
| 리뷰 상태 | 후속 수정 및 Gate 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- PRD Feature D와 OpenAPI Series 9개 operation을 controller/facade/repository/test에 직접 대조한다.
|
||||
- 공개 route 수, soft delete 방식, owner 검증과 JSON request schema 경계를 점검한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `src/main/kotlin/.../aicharacter/series/*`
|
||||
- `src/test/kotlin/.../aicharacter/series/*`
|
||||
- OpenAPI Series path/schema와 plan Phase 4
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- production 수정, legacy series API 변경, 테스트 실행
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·소유권 우회 또는 데이터 손실 |
|
||||
| High | OpenAPI operation/path 위반 또는 주요 사용자 흐름 회귀 |
|
||||
| Medium | request schema·오류 계약의 제한된 위반 |
|
||||
| Low | 유지보수성 또는 문서 정합성 문제 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
| 근거 | 판정 |
|
||||
|---|---|
|
||||
| OpenAPI `:247`~`:389` | Series는 9개 operation이며 `/series/{seriesId}`에는 GET/PUT만 존재 |
|
||||
| `AiCharacterAdminSeriesController.kt:23`~`:112` | 10개 route를 노출하며 마지막 DELETE가 계약에 없음 |
|
||||
| `AiCharacterAdminSeriesFacade.kt:166`~`:187` | 별도 DELETE facade가 PUT과 같은 `isActive=false`를 구성 |
|
||||
| `AiCharacterAdminSeriesMutationTest.kt:201` 이후 | 계약 밖 DELETE 성공·거부 동작을 테스트가 고정 |
|
||||
| OpenAPI `:1099`~`:1109` | 콘텐츠 추가와 순서 변경 schema는 `additionalProperties: false` |
|
||||
| controller `:55`~`:82` | 두 request를 기본 `@RequestBody` DTO binding으로 수신 |
|
||||
| strict reader 검색 | multipart create/update에만 `FAIL_ON_UNKNOWN_PROPERTIES`가 있고 두 JSON body에는 없음 |
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| route/schema/호출 정적 대조 | 성공 | OpenAPI 9개 대비 controller 10개, strict JSON 경계 누락 확인 |
|
||||
| Gradle/컴파일/테스트 | 미실행 | 사용자 요청에 따라 실행하지 않음 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-023` | High | 처리 완료 | OpenAPI에 없는 시리즈 DELETE route 노출 | `Task 4.7` | `P4-R1` |
|
||||
| `REV-024` | Medium | 처리 완료 | 콘텐츠 추가·순서 변경이 미지 JSON 필드를 허용 | `Task 4.7` | `P4-R1` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-023 — OpenAPI에 없는 시리즈 DELETE route
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 처리 완료
|
||||
- **관련 요구사항:** PRD Feature D, 공개 API schema 임의 변경 금지
|
||||
- **관련 계약:** `/series/{seriesId}`는 GET과 PUT 수정/soft delete만 제공
|
||||
- **소유 Task:** `Task 4.7`, `P4-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
controller는 `DELETE /series/{seriesId}`를 추가로 노출한다. 같은 soft delete는 계약에 있는 PUT의 `isActive=false`로 이미
|
||||
표현되므로 별도 route는 중복이자 공개 API 표면 확장이다.
|
||||
|
||||
**영향**
|
||||
|
||||
서버와 OpenAPI 기반 client가 서로 다른 operation 집합을 사용한다. 현재 테스트도 계약 밖 route를 정상 동작으로 고정해
|
||||
향후 차이를 지속시킨다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
DELETE controller/facade를 제거하고 기존 DELETE 성공 기대를 405 계약으로 교정하며 PUT `isActive=false` actual endpoint
|
||||
테스트를 보강한다.
|
||||
|
||||
**처리 결과**
|
||||
|
||||
`DELETE /series/{seriesId}` controller/facade 경로를 제거했고, actual endpoint 테스트를 405와 `PUT isActive=false` soft delete
|
||||
계약으로 교정했다.
|
||||
|
||||
### REV-024 — 두 JSON body의 `additionalProperties: false` 미적용
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 처리 완료
|
||||
- **관련 요구사항:** OpenAPI request schema 준수
|
||||
- **관련 계약:** `SeriesContentAddRequest`, `SeriesOrderUpdateRequest`
|
||||
- **소유 Task:** `Task 4.7`, `P4-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
multipart create/update는 facade strict reader를 사용하지만 콘텐츠 추가와 순서 변경은 기본 `@RequestBody` binding을
|
||||
사용한다. repository 전역 설정에는 unknown property 실패 설정이 없어 두 schema의 계약 밖 필드를 무시하고 요청을 처리한다.
|
||||
현재 malformed JSON 테스트는 문법 오류만 확인하고 미지 필드를 확인하지 않는다.
|
||||
|
||||
**영향**
|
||||
|
||||
잘못된 client field가 성공으로 처리되어 계약 오류를 조기에 발견할 수 없고 mutation side effect까지 진행될 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
두 body만 strict parse하고 미지 필드 요청의 400 `common.error.invalid_request`와 DB/event 0회를 actual endpoint로
|
||||
고정한다. 전역 ObjectMapper 설정은 legacy/public 영향이 있으므로 변경하지 않는다.
|
||||
|
||||
**처리 결과**
|
||||
|
||||
콘텐츠 추가와 순서 변경 body를 facade strict reader로 역직렬화하도록 변경했고, 미지 필드 요청의 400/no-side-effect 테스트를
|
||||
추가했다.
|
||||
|
||||
## 7. plan·goal 전환
|
||||
|
||||
`plan-task.md` Phase 4의 `Task 4.7` / `P4-R1`과 `P4-R1-GATE`를 완료 처리했다. 기존 `P4-GATE` 완료 이력은 유지한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| OpenAPI operation 대조 | 충족 | Series controller mapping 9개, 계약 밖 `DELETE /series/{seriesId}` 제거 |
|
||||
| JSON schema 대조 | 충족 | content add/order body strict parse와 unknown-field 400 회귀 통과 |
|
||||
| ownership/soft delete 핵심 추적 | 충족 | facade owner 검증과 PUT soft delete 경로 존재 |
|
||||
| 실행 검증 | 충족 | focused 테스트, series/common 회귀, `ktlintCheck`, `git diff --check` 통과 |
|
||||
|
||||
**최종 결론:** 후속 수정 및 Gate 완료
|
||||
|
||||
**남은 항목:** 없음. 다음 Goal은 `P5-R1`이다.
|
||||
|
||||
## 9. 2차 정적 리뷰 및 판정 — 2026-07-28
|
||||
|
||||
### 리뷰 정보와 검증 범위
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature D·API Expectations, plan Phase 4, OpenAPI Series 9개 operation
|
||||
- 검토 범위: 시리즈 생성 multipart binding, 콘텐츠 연결/해제 facade·repository·legacy service와 관련 테스트
|
||||
- 검증 방식: controller → facade → repository/legacy service 상태 전이를 정적으로 추적했다.
|
||||
사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 추가 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-031` | Medium | 처리 완료 | soft-delete된 linked content를 시리즈에서 해제할 수 없음 | `Task 4.8` | `P4-R2` |
|
||||
| `REV-032` | Medium | 처리 완료 | 생성 필수 `image`가 nullable binding으로 공통 오류 계약 우회 | `Task 4.8` | `P4-R2` |
|
||||
|
||||
### REV-031 — soft-delete 콘텐츠의 기존 link 해제 차단
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 처리 완료
|
||||
- **관련 요구사항:** PRD Feature D의 콘텐츠 연결/해제와 동일 owner 검증
|
||||
- **관련 계약:** 존재하는 owner link 해제 성공, 없는/cross-owner link만 400
|
||||
- **소유 Task:** `Task 4.8`, `P4-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
해제 facade가 실제 link 존재 여부뿐 아니라 연결 추가/미연결 검색용 적격성 method를 호출한다. 이 method는
|
||||
`duration != null && (isActive || releaseDate != null)`을 요구하므로, 정상 연결 후 콘텐츠가 soft delete되어
|
||||
`isActive=false`, `releaseDate=null`이 되면 link가 존재해도 해제를 400으로 거부한다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `AiCharacterAdminSeriesFacade.kt:109`~`:120`은 해제 전에 `findEligibleContentByIdAndCreatorMemberId`를 요구한다.
|
||||
- 코드: `AiCharacterAdminSeriesRepository.kt:28`~`:30`은 inactive/unreleased 콘텐츠를 제외한다.
|
||||
- 코드: legacy `CreatorAdminContentSeriesService.kt:297`~`:303`은 owner series의 link를 content 상태와 무관하게 제거한다.
|
||||
- 테스트: `AiCharacterAdminSeriesContentTest`는 정상 active link 해제와 inactive 콘텐츠 연결 거부를 분리 검증하지만,
|
||||
이미 연결된 콘텐츠를 soft delete한 뒤 해제하는 상태 전이는 없다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. owner의 active content를 active series에 연결한다.
|
||||
2. content를 soft delete해 `isActive=false`, `releaseDate=null`로 만든다.
|
||||
3. 같은 owner/series/content로 DELETE unlink를 요청한다.
|
||||
4. 실제 link는 남아 있지만 적격성 guard가 null을 반환해 400이 된다.
|
||||
|
||||
**영향**
|
||||
|
||||
관리자는 삭제된 콘텐츠의 시리즈 연결을 정리할 수 없고 orphan link가 남는다. 콘텐츠 연결 추가 적격성과 기존 link 해제
|
||||
조건이 불필요하게 결합된 문제다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
해제는 series에 존재하는 link와 link 콘텐츠의 owner만 검증한다. `findEligibleContentByIdAndCreatorMemberId`는 연결 추가에만
|
||||
유지하고 soft-delete link 해제 회귀를 추가한다.
|
||||
|
||||
**처리 결과**
|
||||
|
||||
해제 경로에서 연결 추가·검색용 active/release/duration 적격성 guard를 제거하고, 실제 series link와 link 콘텐츠의 owner만
|
||||
검증하도록 변경했다. soft-delete된 owner content의 기존 link 해제 성공 회귀를 추가했다.
|
||||
|
||||
### REV-032 — 시리즈 생성 필수 image의 nullable binding
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 처리 완료
|
||||
- **관련 요구사항:** PRD API Expectations의 필수 multipart 누락 오류
|
||||
- **관련 계약:** OpenAPI `SeriesCreateMultipart.required = ["image", "request"]`
|
||||
- **소유 Task:** `Task 4.8`, `P4-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
OpenAPI는 생성 `image`를 필수로 선언하지만 controller와 facade는 nullable로 받아 누락 요청을 legacy service까지
|
||||
전달한다. 결과는 400이지만 exact `MissingServletRequestPartException`과 `common.error.invalid_request` 대신
|
||||
legacy `creator.admin.series.cover_image_required`가 된다. plan의 Phase 4 오류 표가 이 legacy 결과를 기록해 PRD 공통 계약과
|
||||
충돌한다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 계약: OpenAPI `SeriesCreateMultipart`의 required `image`.
|
||||
- 요구사항: PRD `:183`~`:184`는 필수 part 누락을 exact `MissingServletRequestPartException`,
|
||||
400 `common.error.invalid_request`로 고정한다.
|
||||
- 코드: `AiCharacterAdminSeriesController.kt:83`~`:90`은 `required=false`, nullable image를 사용한다.
|
||||
- 코드: facade create와 legacy service는 null을 받아 domain key로 변환한다.
|
||||
- 테스트: `AiCharacterAdminSeriesMutationTest`는 missing image가 facade/legacy까지 도달하는 동작을 기대한다.
|
||||
|
||||
**영향**
|
||||
|
||||
클라이언트는 같은 신규 prefix의 다른 필수 multipart 누락과 다른 message/exception 계약을 받는다. 상태는 400으로 같고
|
||||
mutation은 시작되지 않으므로 영향은 오류 표면에 제한된다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
PRD/OpenAPI를 우선해 생성 image만 non-null binding으로 변경하고 세 locale의 exact exception/envelope과
|
||||
facade/DB/S3/event 0회를 검증한다. update image의 optional 계약은 유지한다.
|
||||
|
||||
**처리 결과**
|
||||
|
||||
시리즈 생성 `image` part를 non-null `MultipartFile` binding으로 변경해 누락 요청을 `MissingServletRequestPartException`,
|
||||
400 `common.error.invalid_request`로 통일했다. KO/EN/JA envelope과 no-side-effect를 actual endpoint로 고정했고 update의 optional
|
||||
`image` 계약은 유지했다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-28 — `REV-031`, `REV-032` 모두 코드·문서·테스트 정적 추적으로 확정. 테스트는 미실행.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
`plan-task.md` Phase 4에 `Task 4.8` / `P4-R2`와 `P4-R2-GATE`를 추가했다.
|
||||
`DEC-P4-R2-001`로 생성 image 누락의 canonical 오류를 확정했으며 기존 완료 이력은 유지한다.
|
||||
`P4-R2`와 `P4-R2-GATE`를 완료 처리했다.
|
||||
|
||||
### 2차 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| operation/path 대조 | 충족 | Series 9개 mapping 유지 |
|
||||
| 상태 전이 대조 | 충족 | soft-delete linked content unlink 성공 회귀 통과 |
|
||||
| multipart binding | 충족 | required image 누락이 `MissingServletRequestPartException` 400으로 처리됨 |
|
||||
| plan 반영 | 충족 | `Task 4.8`, `P4-R2`, `P4-R2-GATE` |
|
||||
| 실행 검증 | 충족 | focused 테스트, series/common 회귀, `ktlintCheck`, `git diff --check` 통과 |
|
||||
|
||||
**최종 결론:** 후속 수정 및 Gate 완료
|
||||
|
||||
**남은 항목:** 없음. 다음 Goal은 `P5-R2`다.
|
||||
|
||||
## 10. 3차 정적 리뷰 및 판정 — 2026-07-28
|
||||
|
||||
### 리뷰 정보와 범위
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature D, plan Phase 4, OpenAPI Series 9개 operation
|
||||
- 검토 범위: 생성·수정 multipart facade, legacy series service의 S3 upload, mutation 테스트
|
||||
- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항
|
||||
|
||||
#### `REV-037` — Medium — 시리즈 빈 image의 0-byte upload
|
||||
|
||||
- 생성 controller의 non-null binding은 part 누락만 차단하며 `MultipartFile.isEmpty`는 검증하지 않는다.
|
||||
- facade는 생성 image를 그대로 legacy service로 넘기고, legacy service는 size 0 metadata와 input stream을 S3에
|
||||
업로드한 뒤 cover를 교체한다.
|
||||
- 수정의 optional 빈 image도 null이 아니므로 같은 0-byte upload와 기존 cover 교체가 발생한다.
|
||||
- 현재 테스트는 생성 image 누락과 정상 image 경로를 다루지만 생성·수정의 빈 part는 다루지 않는다.
|
||||
|
||||
**권장 조치:** 생성 빈 image는 legacy 호출 전에 400으로 거부하고, 수정 빈 image는 optional part 생략으로 정규화한다.
|
||||
정상 upload, 수정 image 생략, 빈 image no-S3/no-cover-change를 actual endpoint로 고정한다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
`plan-task.md` Phase 4에 `Task 4.9` / `P4-R3`과 `P4-R3-GATE`를 추가했다.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| operation/path | 충족 | Series 9개 mapping 유지 |
|
||||
| 생성 empty file | 수정 필요 | 0-byte upload와 DB/event 부작용 발생 |
|
||||
| 수정 empty file | 수정 필요 | 기존 cover가 0-byte 객체로 교체됨 |
|
||||
| plan 반영 | 충족 | `Task 4.9`, `P4-R3`, `P4-R3-GATE` 추가 |
|
||||
| 실행 검증 | 미실행 | 사용자 요청에 따라 컴파일·테스트 미실행 |
|
||||
|
||||
**최종 결론:** Phase 4 후속 수정 필요
|
||||
|
||||
**남은 항목:** `P3-R11-GATE` 후 `P4-R3` → `P4-R3-GATE`.
|
||||
|
||||
## 11. 3차 후속 수정 및 Gate — 2026-07-29
|
||||
|
||||
- 무엇을: `REV-037`을 처리했다.
|
||||
- 왜: 생성·수정의 빈 `image` part가 legacy service로 전달되어 0-byte S3 upload와 cover 교체를 유발했기 때문이다.
|
||||
- 어떻게:
|
||||
- RED: `AiCharacterAdminSeriesMutationTest`에 빈 생성 image 400/no-side-effect, 빈 수정 image cover 유지, 빈 image-only no_changes 유지 테스트를 추가했다. focused 실행에서 신규 3건이 실패했다.
|
||||
- GREEN: `AiCharacterAdminSeriesFacade.create`는 empty image를 400으로 거부하고, `update`는 empty image를 null로 정규화해 legacy service에 전달했다.
|
||||
- 검증: focused series mutation test, targeted aicharacter 회귀, 전체 `./gradlew test`, `ktlintCheck`, OpenAPI/mapping/diff 점검을 실행했다.
|
||||
- 결과: `REV-037` 처리 완료. 생성 empty image는 부작용 전에 400이고, 수정 empty image는 생략으로 처리해 기존 cover를 유지한다.
|
||||
|
||||
**최종 결론:** Phase 4 3차 리뷰 종결
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 12. 4차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature D, plan Phase 4, OpenAPI Series 9개 operation
|
||||
- 검토 범위: CRUD, 콘텐츠 조회·검색·연결·해제, owner-scoped 순서 변경, 최신 empty image 보완
|
||||
- 검증 방식: 코드·schema·테스트 정적 대조. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
Series runtime의 신규 확정 finding은 없다. 9개 controller mapping, owner-scoped lock·연결/해제와
|
||||
생성·수정 empty image 정책은 현재 OpenAPI와 일치한다.
|
||||
|
||||
다만 plan Phase 4 endpoint 설명은 콘텐츠 해제를 `DELETE /series/{seriesId}/contents`와 request body로 적고 있어,
|
||||
OpenAPI와 실제 `DELETE /series/{seriesId}/contents/{contentId}` body 없음 route와 달랐다. 이를 `REV-039`로 확정했고,
|
||||
Phase 7 `Task 7.6` / `P7-R4`에서 설명을 정정했다. `Task 4.9` 헤더의 미완료 표기도 `REV-038`로 함께 동기화했다.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| runtime operation | 충족 | Series controller mapping 9개와 OpenAPI 일치 |
|
||||
| ownership/순서 | 충족 | active owner ID 전체 검증과 ID 정렬 lock 유지 |
|
||||
| 연결/해제 | 충족 | owner link 검증 후 path `contentId`로 해제 |
|
||||
| 문서 계약 | 충족 | `REV-039`의 stale DELETE path/body 설명 정정 |
|
||||
| plan 전환 | 충족 | `Task 7.6`, `P7-R4` 완료 |
|
||||
|
||||
**최종 결론:** Phase 4 기능 추가 수정 없음, Phase 7 문서 보완 완료
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 13. 5차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 확인된 문제
|
||||
|
||||
#### `REV-042` — 시리즈 생성 primitive의 explicit null 허용 가능성
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 처리 완료
|
||||
- **계약:** OpenAPI `SeriesCreateRequest`의 `genreId`, `isAdult`은 nullable이 아니며 각각 생략 기본값 `0`,
|
||||
`false`를 가진다.
|
||||
- **구현:** `CreateSeriesRequest`의 두 값은 Kotlin primitive이고 facade strict reader는 미지 필드만 거부한다.
|
||||
- **근거:** Jackson Kotlin/databind 2.13.5 기본 설정에서 explicit null은 primitive 기본값으로 보정될 수 있다.
|
||||
- **영향:** 특히 `isAdult: null`이 계약상 400 대신 `false`인 정상 생성으로 이어져 S3·DB·event mutation이 발생할 수 있다.
|
||||
|
||||
### 보완 결과
|
||||
|
||||
| 항목 | 판정 |
|
||||
|---|---|
|
||||
| 신규 Task | `Task 4.10` / `P4-R4` 처리 완료 |
|
||||
| 시작 조건 | `P3-R12-GATE` 완료 후 실행 |
|
||||
| Gate | `P4-R4-GATE` 완료 |
|
||||
| RED | `genreId: null`, `isAdult: null` actual POST가 보완 전 400 기대 실패 |
|
||||
| GREEN | v2 생성 strict reader에서 explicit null 거부, 필드 생략 기본값 유지 |
|
||||
| 범위 제한 | 전역 mapper·레거시 service·OpenAPI 변경 없음 |
|
||||
|
||||
### 실행 검증
|
||||
|
||||
| 명령 또는 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `AiCharacterAdminSeriesMutationTest.shouldRejectNullPrimitiveCreateFieldsBeforeSideEffects` RED | 실패 확인 | 신규 2개 invocation이 400 기대 실패 |
|
||||
| 같은 focused RED/GREEN 명령 | 통과 | strict reader 보완 후 `BUILD SUCCESSFUL` |
|
||||
| `AiCharacterAdminSeriesMutationTest` | 통과 | 기존 생성 기본값·정상 mutation 회귀 유지 |
|
||||
| series/common 영향 범위 회귀 | 통과 | `BUILD SUCCESSFUL in 1m 12s` |
|
||||
| `ktlintCheck`, `git diff --check` | 통과 | `ktlintCheck`는 `BUILD SUCCESSFUL in 20s`, diff check 출력 없음 |
|
||||
|
||||
**최종 결론:** Phase 4 `REV-042` 보완 완료
|
||||
|
||||
**다음 Goal:** `P5-R3`.
|
||||
|
||||
## 14. 장르 참조 API·상세 응답 후속 검토 — 2026-07-29
|
||||
|
||||
### 확인 결과
|
||||
|
||||
- **`REV-046` / Medium / 구현 대기:** 시리즈 등록용 활성 장르 목록은 레거시 관리자 service/repository에 존재하지만
|
||||
신규 v2 캐릭터 관리자 route에는 없다. 계약은 `orders` 오름차순의 `id`, `genre`, `isAdult` 직접 배열이다.
|
||||
- **`REV-047` / Medium / 정합화 대기:** 현재 시리즈 상세는 레거시 상세 DTO를 반환해 목록 item과
|
||||
`publishedDaysOfWeek`, `genreId`, `state`, `isActive` 필드·타입이 다르다.
|
||||
- 승인된 상세 `data`는 목록 `items` 단일 객체와 동일한 11개 필드이며 기존 상세 전용 `genre`, `keywords`는 제거한다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
- 장르 목록: `Task 4.11` / `P4-R5`, Gate `P4-R5-GATE`
|
||||
- 상세 정합화: `Task 4.12` / `P4-R6`, Gate `P4-R6-GATE`
|
||||
- 범위 밖: 장르 CRUD, 목록 wrapper 변경, legacy/public 상세 DTO 변경
|
||||
|
||||
사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
|
||||
|
||||
**최종 결론:** Phase 4 장르 목록 구현과 시리즈 상세 정합화 필요
|
||||
|
||||
**다음 Goal:** `P4-R5`.
|
||||
|
||||
## 15. 장르 참조 API 구현 및 Gate — 2026-07-29
|
||||
|
||||
- 무엇을: `REV-046`을 처리했다.
|
||||
- 왜: 시리즈 등록 화면에서 사용할 활성 장르 목록이 레거시 관리자 API에는 있으나 신규 v2 캐릭터 관리자 route에는 없었기 때문이다.
|
||||
- 어떻게:
|
||||
- RED: `AiCharacterAdminSeriesGenreTest`에 활성/비활성 장르, `orders` 오름차순, 빈 목록, ADMIN 공통 경계 actual GET 테스트 2건을 추가했고 미구현 route로 실패했다.
|
||||
- GREEN: `AiCharacterAdminSeriesReferenceController`에 `GET /api/v2/admin/ai-characters/series-genres`를 추가하고 facade에서 기존 `AdminContentSeriesGenreService.getSeriesGenreList()`를 그대로 재사용했다.
|
||||
- Gate: focused 장르 테스트, series/common 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행했다.
|
||||
- 결과: `REV-046` 처리 완료. 별도 query, DTO, pagination, 장르 CRUD 변경은 추가하지 않았다.
|
||||
|
||||
**최종 결론:** Phase 4 장르 목록 후속 기능 종결
|
||||
|
||||
**다음 Goal:** `P4-R6`.
|
||||
|
||||
## 16. 시리즈 상세 정합화 및 Gate — 2026-07-29
|
||||
|
||||
- 무엇을: `REV-047`을 처리했다.
|
||||
- 왜: 시리즈 상세가 레거시 상세 DTO를 반환해 목록 item과 field/type이 달랐기 때문이다.
|
||||
- 어떻게:
|
||||
- RED: `AiCharacterAdminSeriesQueryTest`에서 상세 `data`의 11개 목록 item field와 `genre`, `keywords` 부재를 고정했고 레거시 상세 응답 차이로 실패했다.
|
||||
- GREEN: v2 상세 response type을 `GetCreatorAdminContentSeriesListItem`으로 통일하고 facade에서 owner-scoped series를 동일 필드로 매핑했다.
|
||||
- Gate: focused query/contract, series/common 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행한다.
|
||||
- 결과: `REV-047` 처리 완료. legacy/public 상세 DTO와 mapper는 변경하지 않았다.
|
||||
|
||||
**최종 결론:** Phase 4 시리즈 상세 정합화 종결
|
||||
|
||||
**다음 Goal:** `P5-R5`.
|
||||
|
||||
## 17. 6차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature D, OpenAPI Series 10개 operation
|
||||
- 검토 범위: Series/reference controller, facade/repository, JSON·multipart mapping과 목록·상세 DTO
|
||||
- 기준 상태: 현재 working tree
|
||||
- 검증 방식: 문서·코드·관련 테스트 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
|
||||
|
||||
### 확인 결과
|
||||
|
||||
| 항목 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| route/operation | 충족 | 장르 참조를 포함한 Series 10개 OpenAPI operation과 실제 mapping 유지 |
|
||||
| request 전체 media type | 충족 | 생성·수정 multipart, 순서 변경·콘텐츠 추가 JSON `consumes` 선언 일치 |
|
||||
| query/default | 충족 | 목록·연결 콘텐츠 page/size 기본값과 미연결 검색 필수 query 일치 |
|
||||
| response/ownership | 충족 | 상세-목록 item schema 정합화와 target owner 경계 유지 |
|
||||
|
||||
### `REV-057` — High — 처리 완료 — Series multipart request part의 JSON media type 미강제
|
||||
|
||||
- OpenAPI와 계약 설명은 생성·수정 multipart의 `request` part Content-Type을 `application/json`으로 고정한다.
|
||||
- 두 controller는 `@RequestPart("request") request: String`으로 받아 part 자체의 media type을 검사하지 않는다.
|
||||
- 정상 테스트는 JSON media type만 사용하며 미지원/누락 part media type의 415 `Accept` header와
|
||||
S3/DB/event no-side-effect를 고정하지 않는다.
|
||||
- 같은 shared converter와 signature에서 AudioContent의 `text/plain` 성공 테스트가 있어 permissive binding을
|
||||
정적으로 확인할 수 있다.
|
||||
|
||||
### 처리 결과
|
||||
|
||||
- `AiCharacterAdminSeriesController`의 POST·PUT에 `MultipartHttpServletRequest`를 받고 Character·AudioContent와
|
||||
같은 part header 검사와 `HttpMediaTypeNotSupportedException`을 적용했다. 기존 String strict reader와
|
||||
facade/domain 로직은 변경하지 않았다.
|
||||
- `AiCharacterAdminSeriesMutationTest`는 POST·PUT 각각의 `text/plain`·Content-Type 누락 request part를 KO/EN/JA
|
||||
415 `ApiResponse.error`, `Accept: application/json`, S3/DB/event no-side-effect로 고정했고, 필수 `request` part
|
||||
누락의 기존 400 binding 오류도 확인했다.
|
||||
- RED는 신규 media type 12건이 415 기대와 달리 실패했고, GREEN focused 36건과 Series/common error 영향 범위 회귀,
|
||||
`ktlintCheck`, OpenAPI encoding 정적 대조, `git diff --check`를 통과했다.
|
||||
|
||||
**최종 결론:** `REV-057`, `P4-R7`, `P4-R7-GATE` 처리 완료
|
||||
|
||||
**다음 Goal:** `P5-R7` (`P4-R7-GATE` 완료 후).
|
||||
|
||||
## 18. 7차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature D, OpenAPI `SeriesCreateMultipart`·`SeriesUpdateMultipart`
|
||||
- 검토 범위: Series POST·PUT controller의 multipart binding과 image/genre/owner mutation 테스트
|
||||
- 검증 방식: 현재 working tree의 문서·코드·테스트를 정적으로 대조했다. 사용자 요청에 따라 컴파일과 테스트는
|
||||
실행하지 않았다.
|
||||
|
||||
### `REV-062` — Medium — `image`, `request` 외 multipart part가 무시됨
|
||||
|
||||
- 두 Series multipart schema는 `additionalProperties: false`이며 허용 이름은 `image`, `request`다.
|
||||
- controller의 `requireJsonRequestPart()`는 `request` media type만 검사하고 전체 part 이름을 열거하지 않는다.
|
||||
- 정상 part와 미정의 part를 함께 보낸 요청이 facade로 전달될 수 있어 OpenAPI 입력 범위보다 runtime이 넓다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 신규 Task | `Task 4.14` / `P4-R8` |
|
||||
| Gate | `P4-R8-GATE` |
|
||||
| RED | POST·PUT 미정의 part와 S3·DB·event 결과 |
|
||||
| GREEN | 실제 part 이름을 `{image, request}`와 비교해 초과 이름 400 |
|
||||
| 회귀 | 필수/빈 image, request part 415, genre/owner 경계 |
|
||||
|
||||
**처리 결과 (2026-07-29 / P4-R8):**
|
||||
|
||||
- Series POST·PUT controller 경계에 multipart part allow-list를 추가해 `image`, `request` 외 file part를 400 `common.error.invalid_request`로 거부했다.
|
||||
- RED에서 POST·PUT `unexpected` part KO/EN/JA 테스트 6개가 기존 mutation 경로로 실패함을 확인했고, GREEN 후 focused/영향 범위 회귀, `ktlintCheck`, `git diff --check`를 통과했다.
|
||||
|
||||
**Gate 결과 (2026-07-29 / P4-R8-GATE):**
|
||||
|
||||
- Focused multipart 회귀, Phase 4 Series 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행했다.
|
||||
- POST·PUT 미정의 part 400/no-side-effect, 정상·필수/빈 image, request part 415, genre/owner 경계가 유지됨을 확인했다.
|
||||
|
||||
**최종 결론:** `REV-062` resolved. Phase 4 완료.
|
||||
|
||||
**다음 Goal:** `P5-R9`.
|
||||
|
||||
## 19. 8차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature D, OpenAPI Series create/update multipart 및 장르 계약
|
||||
- 검토 범위: Series controller/facade, active genre 조회 경계, legacy genre repository와 mutation 테스트
|
||||
- 기준 상태: 현재 working tree
|
||||
- 리뷰어/상태: Codex / 판정 완료
|
||||
- 검증 방식: 문서·코드·테스트 소스와 기존 compile output을 정적으로 대조했다. 사용자 지시에 따라 컴파일과 테스트는
|
||||
실행하지 않았다.
|
||||
|
||||
### `REV-067` — Medium — 일반 form-field multipart part가 allow-list 우회
|
||||
|
||||
- `AiCharacterAdminSeriesController.kt:114-117`은 `fileMap.keys`만 `{image, request}`와 비교한다.
|
||||
- 기존 `AiCharacterAdminSeriesMutationTest.kt:664-710`은 filename이 있는 `MockMultipartFile("unexpected", ...)`만
|
||||
검증하므로 filename 없는 일반 form-field part 경계는 빠져 있다.
|
||||
- OpenAPI의 create/update multipart `additionalProperties: false`를 우회할 수 있어 Medium으로 확정한다.
|
||||
|
||||
### `REV-068` — Medium — `genreId <= 0`이 active genre 검사를 우회
|
||||
|
||||
- `AiCharacterAdminSeriesFacade.kt:155-168`은 create와 non-null update `genreId`에 공통 검사를 호출하지만,
|
||||
`:213-215`의 구현은 양수인 경우에만 active genre 존재를 확인한다.
|
||||
- OpenAPI create schema는 0이 domain validation에서 유효하지 않다고 명시한다. 그러나 0 또는 음수는 legacy service로
|
||||
전달되고, `CreatorAdminContentSeriesGenreRepository.kt:19-26`의 QueryDSL `fetchFirst()` 결과를 Kotlin non-null
|
||||
반환으로 취급하는 repository 경계에서 NPE가 발생할 수 있다.
|
||||
- 기존 compile output의 해당 repository bytecode를 `javap`로 확인한 결과 `fetchFirst()` 뒤 Kotlin
|
||||
`checkNotNullExpressionValue`가 존재했다. 공통 예상 밖 예외는 500 경로이므로 요청 오류 400 계약과 다르다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
| finding | 신규 Task / Goal | Gate | 최소 보완 |
|
||||
|---|---|---|---|
|
||||
| `REV-067` | `Task 4.15` / `P4-R9` | `P4-R9-GATE` | servlet 전체 part 이름을 `{image, request}`와 대조 |
|
||||
| `REV-068` | `Task 4.16` / `P4-R10` | `P4-R10-GATE` | 0 이하 또는 비활성·미존재 장르를 legacy 호출 전에 400으로 거부 |
|
||||
|
||||
**최종 결론:** Phase 4 보완 필요 — Medium 2건 확정
|
||||
|
||||
**다음 Goal:** `P4-R9` (`P3-R18-GATE` 완료 후), 이어서 `P4-R10`.
|
||||
|
||||
## 20. 8차 후속 수정 및 Gate — 2026-07-29
|
||||
|
||||
- 무엇을: `REV-067`의 Series POST·PUT filename 없는 multipart part 우회와 `REV-068`의 `genreId <= 0` 사전 거부 누락을 보완했다.
|
||||
- 왜: `{image, request}` 외 일반 form-field part와 0 이하 장르가 legacy service 호출 전 400으로 고정되어야 하기 때문이다.
|
||||
- 어떻게: `AiCharacterAdminSeriesMutationTest`에 filename 없는 part와 `genreId=0/-1` create·update KO/EN/JA actual endpoint 회귀를 추가하고, controller part 검사와 facade active genre guard를 최소 수정했다.
|
||||
- 결과: RED 묶음에서 신규 multipart/genre 36건 실패를 확인했고, 보완 후 focused GREEN 묶음은 `BUILD SUCCESSFUL in 1m 17s`였다. 영향 범위 회귀와 lint 결과는 `P7-R10-GATE`에 통합 기록한다.
|
||||
|
||||
**최종 결론:** `REV-067`, `REV-068` 처리 완료. Phase 4 후속 Gate 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 21. 9차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature E, OpenAPI Series 10개 operation
|
||||
- 검토 범위: 목록·상세·생성·수정·연결 콘텐츠·순서·장르, owner/active genre/multipart 경계
|
||||
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
|
||||
테스트는 실행하지 않았다.
|
||||
|
||||
### 판정 및 plan 전환
|
||||
|
||||
- Series 10개 operation과 controller/facade의 owner·active genre·exact multipart 경계를 대조했다.
|
||||
- 기존 완료 finding 이후 신규 확정 finding은 없다.
|
||||
- Phase 4 신규 Task/Gate 없음.
|
||||
|
||||
**최종 결론:** Phase 4 추가 수정 없음.
|
||||
|
||||
**남은 항목:** Phase 3 보완 뒤 `P7-R11` 통합 재판정.
|
||||
|
||||
## 22. 10차 정적 리뷰 및 판정 — 2026-07-30
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature D, OpenAPI Series 10개 operation
|
||||
- 검토 범위: 장르·목록·상세·생성·수정, 연결 콘텐츠 조회·검색·추가·해제와 순서 변경
|
||||
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
|
||||
테스트는 실행하지 않았다.
|
||||
|
||||
### 판정
|
||||
|
||||
- Series 10개 operation과 controller mapping, 목록 item과 동일한 상세 schema가 일치한다.
|
||||
- active target/series·genre와 owner-scoped 콘텐츠 연결/해제, 순서 변경 lock·전체 ID 선검증이 유지된다.
|
||||
- 생성·수정의 필수/빈 image, strict JSON과 exact multipart part 경계가 OpenAPI와 일치한다.
|
||||
- 신규 확정 finding이 없어 Phase 4 회귀 수정 Task/Gate를 추가하지 않는다.
|
||||
|
||||
**최종 결론:** Phase 4 요구사항 충족, 추가 수정 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
@@ -1,641 +0,0 @@
|
||||
# Phase 5 커뮤니티 관리 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 5 / 커뮤니티 3개 operation |
|
||||
| 기준 commit 또는 working tree | `2f93e2c9` + 현재 Phase 5~7 working tree |
|
||||
| 리뷰 일자 | 2026-07-28 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `api-contract.openapi.json` |
|
||||
| 리뷰 상태 | 후속 수정 및 Gate 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- PRD Feature E와 OpenAPI Community 3개 operation을 facade/legacy service/test에 대조한다.
|
||||
- multipart JSON 오류, 미지 필드, pagination과 side-effect 차단 순서를 점검한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- `AiCharacterAdminCommunityPostController`, `Facade`, `Repository`, DTO
|
||||
- 관리자 community 테스트와 재사용하는 `CreatorCommunityService`
|
||||
- OpenAPI Community path/schema와 plan Phase 5
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- production 수정, legacy/public community 계약 변경, 테스트 실행
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 소유권 우회 또는 데이터 손실 |
|
||||
| High | 잘못된 요청이 500/side effect로 이어지는 주요 계약 위반 |
|
||||
| Medium | pagination 또는 제한된 request schema 위반 |
|
||||
| Low | 유지보수성 또는 문서 정합성 문제 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
| 근거 | 판정 |
|
||||
|---|---|
|
||||
| `AiCharacterAdminCommunityPostFacade.kt:36`~`:50` | create는 raw JSON을 legacy service에 전달하고 update는 기본 ObjectMapper로 직접 parse |
|
||||
| `CreatorCommunityService.kt:73`~`:87` | create JSON을 기본 ObjectMapper로 parse한 뒤 media 검증·side effect 진행 |
|
||||
| `AiCharacterAdminExceptionHandler.kt:58`~`:71` | Jackson parse 예외 전용 400 변환이 없고 미분류 예외는 500 |
|
||||
| OpenAPI `:1156`~`:1176` | create/update request는 필수 필드와 `additionalProperties: false`를 정의 |
|
||||
| `AiCharacterAdminCommunityPostFacade.kt:68`~`:81`, `:141`~`:145` | 목록에 `size in 1..50`을 강제 |
|
||||
| OpenAPI `Size` parameter `:519` | minimum 1만 있고 maximum은 없음 |
|
||||
| create/update 테스트 | request part 누락은 검증하지만 malformed/missing JSON field/unknown field는 직접 검증하지 않음 |
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| parse 흐름·예외 handler·schema 정적 추적 | 성공 | parse 예외의 500 가능성과 unknown-field 허용 경계 확인 |
|
||||
| pagination 계약 대조 | 성공 | runtime 최대 50과 OpenAPI maximum 부재 확인 |
|
||||
| Gradle/컴파일/테스트 | 미실행 | 사용자 요청에 따라 실행하지 않음 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-025` | High | 처리 완료 | multipart JSON parse 오류가 500이 될 수 있고 미지 필드를 허용 | `Task 5.7` | `P5-R1` |
|
||||
| `REV-026` | Medium | 처리 완료 | OpenAPI에 없는 목록 size 50 상한 | `Task 5.7` | `P5-R1` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-025 — Community JSON 오류 경계 불일치
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 처리 완료
|
||||
- **관련 요구사항:** PRD Feature E, 공통 오류/side-effect 계약
|
||||
- **관련 계약:** 잘못된 request는 400 `common.error.invalid_request`, request schema는 미지 필드 금지
|
||||
- **소유 Task:** `Task 5.7`, `P5-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
create는 raw request 문자열을 legacy service에 넘기고 update는 facade에서 기본 ObjectMapper로 읽는다. 두 경로 모두
|
||||
`FAIL_ON_UNKNOWN_PROPERTIES`를 활성화하지 않으며 `JsonProcessingException`을 관리자 API 예외로 변환하지 않는다.
|
||||
|
||||
**영향**
|
||||
|
||||
malformed JSON이나 필수 non-null 필드 누락이 공통 handler의 500 `common.error.unknown`으로 분류될 수 있다. 미지 필드는
|
||||
무시되어 잘못된 요청이 mutation과 S3/event 경로까지 진행될 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
legacy 호출 전에 create/update DTO를 strict reader로 검증하고 Jackson mapping 오류를
|
||||
400 `common.error.invalid_request`로 변환한다. malformed, 필수 필드 누락, 미지 필드의 DB/S3/event 0회를 actual
|
||||
endpoint로 고정한다.
|
||||
|
||||
**처리 결과**
|
||||
|
||||
create/update `request` part를 legacy service 호출 전에 strict reader로 검증하고 Jackson parse/mapping 오류를
|
||||
400 `common.error.invalid_request`로 변환했다. create malformed·필수 field 누락·미지 field, update malformed·미지 field의
|
||||
actual endpoint no-side-effect 테스트를 추가했다. OpenAPI상 update request에는 required field가 없어 update 필수 field 누락
|
||||
케이스는 계약 밖으로 제외했다.
|
||||
|
||||
### REV-026 — 계약에 없는 Community size 상한
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 처리 완료
|
||||
- **관련 요구사항:** 레거시 목록 query/pagination 유지
|
||||
- **관련 계약:** 공통 `Size` parameter는 default 20, minimum 1이며 maximum 없음
|
||||
- **소유 Task:** `Task 5.7`, `P5-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
facade는 `size !in 1..50`을 400으로 거부한다. OpenAPI 단일 원본에는 maximum 50이 없으므로 `size=51`은 계약상 유효하다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
OpenAPI를 임의 변경하지 않고 관리자 facade의 상한 guard만 제거한다. page 음수와 size 1 미만 검증은 유지한다.
|
||||
|
||||
**처리 결과**
|
||||
|
||||
목록의 `size <= 50` 상한 guard만 제거하고 `page < 0`, `size < 1` 검증은 유지했다. `size=51` actual endpoint 요청이
|
||||
정상 pagination으로 처리되는 테스트를 추가했다.
|
||||
|
||||
## 7. plan·goal 전환
|
||||
|
||||
`plan-task.md` Phase 5의 `Task 5.7` / `P5-R1`과 `P5-R1-GATE`를 완료 처리했다. 기존 `P5-GATE` 완료 이력은 유지한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| operation/path 대조 | 충족 | Community 3개 route 존재 |
|
||||
| JSON 오류/schema 대조 | 충족 | strict parse와 parse 예외 400 변환, unknown-field 거부 회귀 통과 |
|
||||
| pagination 대조 | 충족 | 계약에 없는 maximum 50 제거, `size=51` 회귀 통과 |
|
||||
| ownership 선검증 | 충족 | target/post owner 확인은 mutation 전 수행 |
|
||||
| 실행 검증 | 충족 | focused 테스트, community/common 회귀, `ktlintCheck`, `git diff --check` 통과 |
|
||||
|
||||
**최종 결론:** 후속 수정 및 Gate 완료
|
||||
|
||||
**남은 항목:** 없음. 다음 Goal은 `P6-R1`이다.
|
||||
|
||||
## 9. 2차 정적 리뷰 및 판정 — 2026-07-28
|
||||
|
||||
### 리뷰 정보와 검증 범위
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature E, plan Phase 5, OpenAPI Community 3개 operation
|
||||
- 검토 범위: fixed update facade/legacy service/repository, transaction·lock 경계와 concurrency test
|
||||
- 검증 방식: 두 병렬 transaction의 count/read/update 순서를 코드와 테스트로 정적 추적했다.
|
||||
사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 추가 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-033` | High | 처리 완료 | 최대 고정 3개가 실제 병렬 요청에서 보장되지 않음 | `Task 5.8` | `P5-R2` |
|
||||
|
||||
### REV-033 — lock 없는 count-then-update 경쟁 조건
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 처리 완료
|
||||
- **관련 요구사항:** PRD Feature E의 최대 고정 게시글 수 3개
|
||||
- **관련 계약:** plan transaction/concurrency 고려사항과 `Task 5.5`의 동시 요청 완료 증거
|
||||
- **소유 Task:** `Task 5.8`, `P5-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
legacy fixed update는 활성 고정 수를 조회한 뒤 별도 게시글 entity의 `isFixed`를 변경한다. owner 또는 고정 집합을 잠그는
|
||||
lock/constraint가 없으므로, 고정 2개 상태에서 서로 다른 게시글을 고정하는 두 transaction이 모두 count 2를 읽고 커밋하면
|
||||
최종 고정 수는 4개가 된다.
|
||||
|
||||
`AiCharacterAdminCommunityPostConcurrencyTest`는 이름과 달리 두 요청을 순서대로 호출한다. plan `Task 5.5`도 실제 병렬
|
||||
재현을 하지 못했다고 기록하면서 Task objective·완료 증거와 Phase acceptance를 완료 처리했다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `AiCharacterAdminCommunityPostFacade.kt:62`~`:69`은 lock 없이 legacy fixed update를 호출한다.
|
||||
- 코드: `CreatorCommunityService.kt:247`~`:262`는 `countBy...` 후 서로 다른 post를 갱신한다.
|
||||
- 테스트: `AiCharacterAdminCommunityPostConcurrencyTest.kt:55`~`:79`는 세 번째 요청 완료 후 네 번째 요청을 실행하는
|
||||
순차 시나리오다.
|
||||
- 계획: `Task 5.5` objective/완료 증거는 동시 요청을 요구하지만 `:2849`~`:2852`에서 실제 병렬 요청은 미검증이라고
|
||||
명시한다.
|
||||
- 기존 코드: `MemberRepository.findByIdForUpdate` owner row pessimistic lock을 재사용할 수 있다.
|
||||
|
||||
**정적 재현 절차**
|
||||
|
||||
1. 같은 owner에 active fixed post 2개와 미고정 post 2개를 준비한다.
|
||||
2. 두 독립 transaction이 서로 다른 미고정 post를 `isFixed=true`로 수정한다.
|
||||
3. lock이 없으므로 두 transaction 모두 count 2를 읽을 수 있다.
|
||||
4. 서로 다른 row를 갱신해 둘 다 커밋하면 최종 active fixed count는 4가 된다.
|
||||
|
||||
**영향**
|
||||
|
||||
PRD의 최대 3개 데이터 불변식이 깨지고 관리자 목록 정렬·운영 정책이 비결정적이 된다. 단순 순차 회귀는 통과하므로
|
||||
현재 테스트 통과만으로 문제를 탐지할 수 없다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
신규 DDL이나 dependency 없이 기존 `MemberRepository.findByIdForUpdate`로 fixed/unfixed count·update 전에 owner를 잠근다.
|
||||
두 독립 transaction과 barrier/lock probe를 사용하는 결정적 병렬 테스트로 최종 3개, 한 요청 400, 실패 side effect 0건을
|
||||
검증하고 sleep·반복 확률 기반 테스트는 사용하지 않는다.
|
||||
|
||||
**처리 결과**
|
||||
|
||||
fixed 변경 요청에서 legacy count/update 전에 `MemberRepository.findByIdForUpdate`로 owner row를 잠그도록 변경했다.
|
||||
두 병렬 요청이 같은 count 경계에 진입하는 결정적 회귀 테스트를 추가했고, 최종 고정 수 3개와 한 요청 400을 확인했다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-28 — 코드·plan 완료 증거·테스트 실행 구조로 경쟁 조건을 확정. 테스트는 사용자 요청에 따라 미실행.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
`plan-task.md` Phase 5에 `Task 5.8` / `P5-R2`와 `P5-R2-GATE`를 추가했다. 기존 `Task 5.5` 완료 이력은
|
||||
되돌리지 않고 실제 동시성 보완을 새 Goal로 추적한다.
|
||||
`P5-R2`와 `P5-R2-GATE`를 완료 처리했다.
|
||||
|
||||
### 2차 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| operation/schema 대조 | 충족 | Community 3개 mapping과 JSON 경계 유지 |
|
||||
| 순차 fixed 정책 | 충족 | 세 번째 성공·네 번째 거부 테스트 존재 |
|
||||
| 실제 동시성 불변식 | 충족 | owner row lock과 결정적 병렬 회귀 통과 |
|
||||
| plan 반영 | 충족 | `Task 5.8`, `P5-R2`, `P5-R2-GATE` |
|
||||
| 실행 검증 | 충족 | focused concurrency, community/common 회귀, `ktlintCheck`, `git diff --check` 통과 |
|
||||
|
||||
**최종 결론:** 후속 수정 및 Gate 완료
|
||||
|
||||
**남은 항목:** 없음. 다음 Goal은 `P7-R2`다.
|
||||
|
||||
## 10. 3차 정적 리뷰 및 판정 — 2026-07-28
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature E, plan Phase 5, OpenAPI Community 3개 operation
|
||||
- 검토 범위: 목록·생성·수정 facade, strict multipart JSON, owner-scoped query, fixed owner lock과 관련 테스트
|
||||
- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| operation/schema | 충족 | Community 3개 mapping과 OpenAPI 경계 유지 |
|
||||
| ownership | 충족 | active target과 owner post 검증 유지 |
|
||||
| JSON/multipart | 충족 | strict parse와 malformed/unknown-field 400 유지 |
|
||||
| fixed 동시성 | 충족 | owner row lock이 count/update 앞에서 수행됨 |
|
||||
| plan 전환 | 해당 없음 | Phase 5 신규 Task 불필요 |
|
||||
|
||||
**최종 결론:** Phase 5 추가 수정 없음
|
||||
|
||||
**남은 항목:** `P7-R3`에서 Community/common 회귀를 통합 재검증한다.
|
||||
|
||||
## 11. 4차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature E, plan Phase 5, OpenAPI Community 3개 operation
|
||||
- 검토 범위: 목록·생성·수정, owner query, strict JSON, 고정 수 lock과 soft delete
|
||||
- 검증 방식: 코드·schema·테스트 정적 대조. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| operation/schema | 충족 | Community 3개 mapping과 OpenAPI 경계 유지 |
|
||||
| ownership | 충족 | active target과 owner post를 mutation 전에 확인 |
|
||||
| 고정/soft delete | 충족 | owner row lock과 fixed 상태 동시 해제 유지 |
|
||||
| 오류/부작용 | 충족 | strict JSON과 target/owner 실패 선검증 유지 |
|
||||
| plan 전환 | 해당 없음 | Phase 5 신규 Task 불필요 |
|
||||
|
||||
**최종 결론:** Phase 5 추가 수정 없음
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 12. 5차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 확인된 문제
|
||||
|
||||
#### `REV-043` — 커뮤니티 primitive의 required·null 계약 미강제
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 처리 완료
|
||||
- **계약:** OpenAPI create request는 `isCommentAvailable`, `isAdult`를 required non-null boolean으로 정의하고,
|
||||
`price`와 update의 `isFixed`도 nullable로 선언하지 않는다.
|
||||
- **구현:** create DTO의 boolean/price는 Kotlin primitive이고 update `isFixed`는 nullable이라, strict reader가
|
||||
미지 필드만 거부하면 누락·explicit null을 계약대로 구분하지 못한다.
|
||||
- **근거:** Jackson Kotlin/databind 2.13.5 기본 설정에서 create primitive는 false·0으로 보정될 수 있고,
|
||||
update `isFixed: null`은 필드 생략과 같은 null로 처리된다.
|
||||
- **영향:** 계약상 invalid 요청이 생성 mutation을 진행하거나 성공 no-op update로 처리될 수 있다.
|
||||
|
||||
### 보완 결과
|
||||
|
||||
| 항목 | 판정 |
|
||||
|---|---|
|
||||
| 신규 Task | `Task 5.9` / `P5-R3` 처리 완료 |
|
||||
| 시작 조건 | `P4-R4-GATE` 완료 후 실행 |
|
||||
| Gate | `P5-R3-GATE` 완료 |
|
||||
| RED | required boolean 누락·null, `price: null`, `isFixed: null`이 400 기대 실패 |
|
||||
| GREEN | v2 create/update 경계 required/non-null 검증, 생략 의미 유지 |
|
||||
| 범위 제한 | 전역 mapper·레거시 service·OpenAPI 변경 없음 |
|
||||
|
||||
### 실행 검증
|
||||
|
||||
| 명령 또는 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| invalid primitive RED focused | 실패 확인 | 신규 6건이 400 기대 실패 |
|
||||
| invalid primitive GREEN focused | 통과 | `BUILD SUCCESSFUL in 41s` |
|
||||
| create/update focused | 통과 | 리뷰 보완 후 `BUILD SUCCESSFUL in 34s` |
|
||||
| community/common 영향 범위 회귀 | 통과 | 리뷰 보완 후 `BUILD SUCCESSFUL in 1m 2s` |
|
||||
| `ktlintCheck`, `git diff --check` | 통과 | `ktlintCheck`는 `BUILD SUCCESSFUL in 13s`, diff check 출력 없음 |
|
||||
|
||||
**최종 결론:** Phase 5 `REV-043` 보완 완료
|
||||
|
||||
**다음 Goal:** `P5-R4`.
|
||||
|
||||
## 13. Community 목록 요구사항 변경 판정 — 2026-07-29
|
||||
|
||||
### 확정 요구사항
|
||||
|
||||
- **추적 ID:** `DEC-P5-LIST-001`
|
||||
- GET 목록에서 실제 응답 생성에 사용하지 않는 `timezone` query를 제거한다.
|
||||
- 성공 `data`는 직접 배열 대신
|
||||
`AiCharacterAdminCommunityPostListResponse(totalCount, page, size, hasNext, items)`를 반환한다.
|
||||
- `totalCount`와 `hasNext`는 target creatorMember 소유 active 게시글만 기준으로 계산한다.
|
||||
- `items`의 기존 18개 필드와 고정 우선 정렬, owner/inactive 격리, page/size 오류 정책은 유지한다.
|
||||
|
||||
### 설계 판정
|
||||
|
||||
| 항목 | 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| query | `page`, `size`만 유지 | timezone은 facade에서 유효성 검사 외 사용되지 않음 |
|
||||
| response | 전용 pagination wrapper 추가 | UI가 전체 개수와 추가 로딩 필요 여부를 판단해야 함 |
|
||||
| count | active owner count query 1개 추가 | totalCount가 필요해 size+1 조회만으로는 충족 불가 |
|
||||
| hasNext | `pageable.offset + items.size < totalCount` | 마지막·범위 밖 page를 단순하게 처리 |
|
||||
| 기존 item | 변경 없음 | 요청 범위 밖 schema 변경 방지 |
|
||||
| 공용 추상화 | 추가하지 않음 | 단일 endpoint 전용 DTO가 최소 변경 |
|
||||
|
||||
### plan 전환
|
||||
|
||||
- 신규 Task: `Task 5.10` / `P5-R4`
|
||||
- Gate: `P5-R4-GATE`
|
||||
- 시작 조건: `P5-R3-GATE` 완료
|
||||
- 통합 조건: `P7-R5` 시작 전에 `P5-R4-GATE` 완료
|
||||
|
||||
### 처리 결과
|
||||
|
||||
`P5-R4`에서 controller/facade의 `timezone` query와 미사용 검증을 제거하고, repository에 active owner count query를 추가했다.
|
||||
성공 `data`는 `AiCharacterAdminCommunityPostListResponse(totalCount, page, size, hasNext, items)`로 반환한다. 기존 item 18개 필드,
|
||||
고정 우선 정렬, owner/inactive 격리, `page < 0`·`size < 1` 오류 정책과 문서에 없는 size 상한 부재는 유지했다. OpenAPI
|
||||
Community GET status는 `implemented`로 복구했다.
|
||||
|
||||
### 실행 검증
|
||||
|
||||
| 명령 또는 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| query RED focused | 실패 확인 | 신규 3건이 400/직접 배열 응답 차이로 실패 |
|
||||
| query GREEN focused | 통과 | `BUILD SUCCESSFUL in 1m 28s` |
|
||||
| query+contract focused | 통과 | `BUILD SUCCESSFUL in 37s` |
|
||||
| community/common 영향 범위 회귀 | 통과 | `BUILD SUCCESSFUL in 1m 6s` |
|
||||
| OpenAPI jq assertion | 통과 | `true` |
|
||||
| `ktlintCheck`, `git diff --check` | 통과 | `ktlintCheck`는 `BUILD SUCCESSFUL in 12s`, diff check 출력 없음 |
|
||||
|
||||
**최종 결론:** Phase 5 `DEC-P5-LIST-001` 목록 계약 정합화 및 Gate 완료
|
||||
|
||||
**다음 Goal:** `P7-R5`.
|
||||
|
||||
## 14. 커뮤니티 댓글 후속 검토 — 2026-07-29
|
||||
|
||||
### 확인 결과
|
||||
|
||||
- **`REV-048` / High / 구현 대기:** 신규 v2 관리자 경계에 target 소유 커뮤니티 게시글의 댓글·답글
|
||||
조회/작성/수정/삭제 5개 operation이 없다.
|
||||
- 조회는 필수 `timezone`과 `page`, `size`, 레거시 `totalCount/items`를 유지한다.
|
||||
- 작성자는 target `creatorMember`, 수정은 target 작성 활성 row만 허용한다. 삭제는 target 소유 게시글의 row를
|
||||
작성자와 관계없이 soft delete하고 cascade하지 않으며 이미 비활성이면 성공 no-op이다.
|
||||
- 답글 `parentId`는 같은 게시글의 활성 원댓글이어야 한다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
- 신규 Task: `Task 5.11` / `P5-R5`
|
||||
- Gate: `P5-R5-GATE`
|
||||
- 범위 밖: 캐릭터 직접 댓글, hard delete·cascade, legacy/public endpoint 변경
|
||||
|
||||
사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
|
||||
|
||||
**최종 결론:** Phase 5 커뮤니티 댓글 CRUD 구현 필요
|
||||
|
||||
**다음 Goal:** `P5-R5`.
|
||||
|
||||
## 15. 커뮤니티 댓글 CRUD 구현 및 Gate — 2026-07-29
|
||||
|
||||
- 무엇을: `REV-048`을 처리했다.
|
||||
- 왜: target AI 소유 커뮤니티 게시글의 댓글·답글 조회/작성/수정/삭제 5개 operation이 신규 v2 관리자 경계에 없었기 때문이다.
|
||||
- 어떻게:
|
||||
- RED: `AiCharacterAdminCommunityPostCommentTest`에 root/reply 목록, target AI 작성, parent 검증, target 작성자 수정 제한, row-only soft delete, 요청 오류 계약 테스트 7건을 추가했고 미구현 route로 실패했다.
|
||||
- GREEN: `AiCharacterAdminCommunityPostController`에 5개 route를 추가하고, facade에서 target/owner/parent/actor를 선검증한 뒤 기존 `CreatorCommunityService` 댓글 조회·작성·수정 의미를 재사용했다.
|
||||
- Gate: focused 댓글 테스트, community/common 영향 범위 회귀, `ktlintCheck`, `git diff --check`를 fresh 실행했다.
|
||||
- 결과: `REV-048` 처리 완료. 캐릭터 직접 댓글, hard delete, cascade, legacy/public endpoint 변경은 추가하지 않았다.
|
||||
|
||||
**최종 결론:** Phase 5 커뮤니티 댓글 후속 기능 종결
|
||||
|
||||
**다음 Goal:** `P6-R2`.
|
||||
|
||||
## 16. 커뮤니티 댓글 UTC 날짜 계약 변경 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature E, OpenAPI 2.2.0 Community 8개 operation, `DEC-UTC-DATE-001`
|
||||
- 검토 범위: 커뮤니티 댓글·답글 GET의 controller/facade/repository와 관련 테스트
|
||||
- 검증 방식: 문서·코드·테스트 정적 대조. 사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### `REV-051` — High — 처리 완료: 커뮤니티 댓글 2개 GET의 timezone/UTC 계약 불일치
|
||||
|
||||
- 처리 전 댓글·답글 GET은 필수 `timezone` query를 controller/facade/repository로 전달하고, 각 댓글 `date`를
|
||||
요청 timezone에 맞춘 표시 문자열로 반환한다.
|
||||
- 승인된 최신 계약은 `timezone` query 없이 `page`, `size`만 받고 기존 `totalCount`, `items`, `date` 필드명을
|
||||
유지하되 `date` 값을 ISO-8601 UTC(`Z`)로 반환한다.
|
||||
- 댓글 작성·수정·삭제의 actor/owner/parent/soft delete 의미와 커뮤니티 게시글 목록의 pagination wrapper는
|
||||
변경 대상이 아니다.
|
||||
|
||||
### 판정
|
||||
|
||||
| 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| route 수 | 유지 | Community 8개 operation 자체는 변경 없음 |
|
||||
| 댓글·답글 query | 처리 완료 | v2 controller/facade에서 필수 `timezone`을 제거하고 `page`·`size`만 사용 |
|
||||
| 댓글 `date` | 처리 완료 | owner-scoped 조회 결과를 `createdAt.toUtcIso()`로 재매핑해 UTC `date-time` 반환 |
|
||||
| 기존 댓글 의미 | 유지 | pagination·ownership·block/secret·mutation 정책 변경 없이 영향 범위 회귀 통과 |
|
||||
| legacy/public 격리 | 충족 | 기존 community 댓글 timezone service/repository 계약을 변경하지 않음 |
|
||||
| OpenAPI 상태 | 처리 완료 | 영향 2개 operation을 `implemented`로 동기화 |
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
- 신규 Task: `Task 5.12` / `P5-R6`
|
||||
- Gate: `P5-R6-GATE`
|
||||
- 시작 조건: `P3-R14-GATE`
|
||||
- 완료 조건: 댓글·답글 actual GET UTC exact JSON, 기존 pagination/ownership/block/secret 의미와
|
||||
legacy/public 회귀
|
||||
|
||||
### `P5-R6` / `P5-R6-GATE` 처리 결과
|
||||
|
||||
- RED: production 변경 전 `AiCharacterAdminCommunityPostCommentTest`는 timezone 없는 root/reply GET이 400을 반환해 2건 실패했고 `BUILD FAILED in 38s`였다.
|
||||
- GREEN: controller/facade의 timezone 입력·검증을 제거하고 legacy 조회 결과의 `date`만 `createdAt.toUtcIso()`로 재매핑한 뒤 같은 focused 테스트는 `BUILD SUCCESSFUL in 43s`였다.
|
||||
- Gate: community/common·legacy 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 11s`, `ktlintCheck`는 `BUILD SUCCESSFUL in 26s`였고, OpenAPI는 36개 `implemented`와 0개 `alignment-required`, `git diff --check`는 출력 없음을 확인했다.
|
||||
|
||||
**최종 결론:** `REV-051` 처리 완료, Phase 5 UTC 계약 정합화 완료
|
||||
|
||||
**다음 Goal:** `P7-R7`.
|
||||
|
||||
## 17. 6차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature E, OpenAPI Community 8개 operation
|
||||
- 검토 범위: Community controller의 multipart/JSON mapping, 댓글 facade와 관련 actual endpoint 테스트
|
||||
- 기준 상태: 현재 working tree
|
||||
- 검증 방식: 문서·코드·테스트 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
|
||||
|
||||
### `REV-053` — High — 처리 완료
|
||||
|
||||
- OpenAPI는 댓글 POST와 PUT의 requestBody media type을 `application/json` 하나로 정의하고 415 response를 선언한다.
|
||||
- 검토 당시 두 controller mapping에는 `consumes = [MediaType.APPLICATION_JSON_VALUE]`가 없었다.
|
||||
- body를 `String`으로 받으므로 mapping 단계에서 media type을 제한하지 않으면 `text/plain` 같은 요청이
|
||||
`HttpMediaTypeNotSupportedException`으로 차단되지 않고 handler/parser까지 진입할 수 있다.
|
||||
- 기존 댓글 테스트는 정상·오류 JSON 요청을 모두 `application/json`으로만 보내 미지원 media type과
|
||||
415 `Accept` header/no-side-effect를 고정하지 않는다.
|
||||
- 외부 HTTP 요청 수용 범위와 명시된 415가 달라 High로 판정한다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
- 신규 Task: `Task 5.13` / `P5-R7`
|
||||
- Gate: `P5-R7-GATE`
|
||||
- 최소 수정: 댓글 POST·PUT mapping에 JSON `consumes` 추가
|
||||
- 완료 조건: 정상 JSON 회귀, 미지원 media type의 KO/EN/JA 415 envelope, `Accept` header,
|
||||
작성 insert/event 0회와 수정 row 불변
|
||||
- 범위 밖: facade/parser·댓글 actor/owner/parent 의미, OpenAPI·legacy/public API 변경
|
||||
|
||||
### 처리 결과
|
||||
|
||||
- 댓글 POST·PUT mapping에 `consumes = [MediaType.APPLICATION_JSON_VALUE]`를 추가했다.
|
||||
- actual endpoint 회귀에서 KO/EN/JA `text/plain` 요청의 localized 415 `ApiResponse.error`, `Accept: application/json`,
|
||||
작성 insert/event 0회와 수정 row 불변을 확인했다.
|
||||
|
||||
### `REV-058` — High — 처리 완료
|
||||
|
||||
- OpenAPI와 계약 설명은 게시글 생성·수정 multipart의 `request` part Content-Type을 `application/json`으로 고정한다.
|
||||
- 두 controller는 `@RequestPart("request") request: String`으로 받아 part 자체의 media type을 검사하지 않는다.
|
||||
- 정상 테스트는 JSON media type만 사용하며 미지원/누락 part media type의 415 `Accept` header와
|
||||
S3/DB/event no-side-effect를 고정하지 않는다.
|
||||
- 같은 shared converter와 signature에서 AudioContent의 `text/plain` 성공 테스트가 있어 permissive binding을
|
||||
정적으로 확인할 수 있다.
|
||||
|
||||
### 추가 plan 전환
|
||||
|
||||
- 신규 Task: `Task 5.14` / `P5-R8`
|
||||
- Gate: `P5-R8-GATE`
|
||||
- 최소 수정: 기존 strict String reader는 유지하고 v2 multipart 경계에서 part-level JSON media type만 강제
|
||||
- 완료 조건: POST·PUT 정상 JSON 회귀, 미지원/누락 media type의 KO/EN/JA 415 envelope, `Accept` header,
|
||||
S3/DB/event no-side-effect
|
||||
|
||||
### `P5-R8` / `P5-R8-GATE` 처리 결과
|
||||
|
||||
- RED: production 변경 전 `AiCharacterAdminCommunityPostCreateTest`와 `AiCharacterAdminCommunityPostUpdateTest`에
|
||||
KO/EN/JA `text/plain` 및 Content-Type 누락 `request` part 415 matrix를 추가했고, focused 명령은 12개 invocation이
|
||||
415 기대 실패로 `BUILD FAILED in 56s`였다.
|
||||
- GREEN: controller POST·PUT 경계에서 `request` part의 JSON 호환 media type만 확인하도록 추가했다. facade strict reader,
|
||||
media/fixed/owner 의미, OpenAPI schema는 변경하지 않았다.
|
||||
- Gate: 같은 focused 명령은 `BUILD SUCCESSFUL in 59s`, community/common 영향 범위와
|
||||
`AiCharacterAdminErrorContractTest` 회귀는 `BUILD SUCCESSFUL in 55s`였다.
|
||||
|
||||
**최종 결론:** `REV-053`, `REV-058` 처리 완료, Phase 5 HTTP media type 계약 정합화 완료
|
||||
|
||||
**다음 Goal:** `P6-R3`.
|
||||
|
||||
## 18. 7차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature E, OpenAPI `CommunityPostCreateMultipart`·`CommunityPostUpdateMultipart`
|
||||
- 검토 범위: Community POST·PUT controller의 multipart binding과 media/fixed/owner mutation 테스트
|
||||
- 검증 방식: 현재 working tree의 문서·코드·테스트를 정적으로 대조했다. 사용자 요청에 따라 컴파일과 테스트는
|
||||
실행하지 않았다.
|
||||
|
||||
### `REV-063` — Medium — 생성·수정의 서로 다른 허용 part 집합을 강제하지 않음
|
||||
|
||||
- OpenAPI는 생성에 `audioFile`, `postImage`, `request`, 수정에 `postImage`, `request`만 허용하고 두 schema 모두
|
||||
`additionalProperties: false`다.
|
||||
- controller는 각 `@RequestPart`와 `request` media type만 처리하며 전체 part 이름을 검사하지 않는다.
|
||||
- 특히 수정 요청에 OpenAPI가 금지한 `audioFile`이나 임의 `unexpected` part를 추가해도 해당 part가 무시된 채 게시글
|
||||
mutation이 진행될 수 있다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 신규 Task | `Task 5.15` / `P5-R9` |
|
||||
| Gate | `P5-R9-GATE` |
|
||||
| RED | 생성·수정 미정의 part, 수정 `audioFile`, S3·DB·event 결과 |
|
||||
| GREEN | 생성 `{audioFile, postImage, request}`, 수정 `{postImage, request}` exact allow-list |
|
||||
| 회귀 | 정상 media/fixed/owner, request part 누락·415 |
|
||||
|
||||
### `P5-R9` / `P5-R9-GATE` 처리 결과
|
||||
|
||||
- RED: production 변경 전 Community POST `unexpected`, PUT `unexpected`·`audioFile` actual endpoint 테스트를 추가했고,
|
||||
focused 명령은 3개 케이스 모두 400 기대 실패로 `BUILD FAILED in 3m 23s`였다.
|
||||
- GREEN: Community controller POST는 `{audioFile, postImage, request}`, PUT은 `{postImage, request}` exact allow-list를
|
||||
적용해 초과 part를 400 `common.error.invalid_request`로 거부한다. media/fixed/owner 의미와 OpenAPI schema는 변경하지 않았다.
|
||||
- Gate: focused 명령은 `BUILD SUCCESSFUL in 2m 30s`, community/common 영향 범위 회귀는 `BUILD SUCCESSFUL in 1m 44s`,
|
||||
`ktlintCheck`는 `BUILD SUCCESSFUL in 55s`였다.
|
||||
|
||||
**최종 결론:** `REV-063` 처리 완료, Phase 5 multipart part 이름 계약 정합화 완료
|
||||
|
||||
**다음 Goal:** `P7-R9`.
|
||||
|
||||
## 19. 8차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: OpenAPI Community create/update multipart schema의 operation별 허용 part와
|
||||
`additionalProperties: false`
|
||||
- 검토 범위: Community POST·PUT controller와 미정의 part·media/fixed/owner 회귀 테스트
|
||||
- 기준 상태: 현재 working tree
|
||||
- 리뷰어/상태: Codex / 판정 완료
|
||||
- 검증 방식: 문서·코드·테스트 소스 정적 대조. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### `REV-069` — Medium — 일반 form-field multipart part가 allow-list 우회
|
||||
|
||||
- `AiCharacterAdminCommunityPostController.kt:63-69`는 operation별 allow-list를 받지만 실제 검사는
|
||||
`fileMap.keys`에 한정한다.
|
||||
- 기존 create/update 회귀는 `AiCharacterAdminCommunityPostCreateTest.kt:202-210`과
|
||||
`AiCharacterAdminCommunityPostUpdateTest.kt:256-268`에서 filename이 있는 `MockMultipartFile`만 사용한다.
|
||||
- filename 없는 일반 form-field `unexpected`는 생성 `{audioFile, postImage, request}`, 수정
|
||||
`{postImage, request}` 계약을 우회할 수 있어 Medium으로 확정한다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 신규 Task | `Task 5.16` / `P5-R10` |
|
||||
| Gate | `P5-R10-GATE` |
|
||||
| RED | filename 없는 미정의 part의 POST·PUT 400과 S3·DB·event no-side-effect |
|
||||
| GREEN | servlet 전체 part 이름을 operation별 allow-list와 비교 |
|
||||
| 범위 제한 | media/fixed/concurrency·OpenAPI·전역 resolver·legacy/public 변경 없음 |
|
||||
|
||||
**최종 결론:** Phase 5 보완 필요 — `REV-069` 확정
|
||||
|
||||
**다음 Goal:** `P5-R10` (`P4-R10-GATE` 완료 후).
|
||||
|
||||
## 20. 8차 후속 수정 및 Gate — 2026-07-29
|
||||
|
||||
- 무엇을: `REV-069`의 Community POST·PUT filename 없는 일반 form-field multipart part 우회를 보완했다.
|
||||
- 왜: 생성 `{audioFile, postImage, request}`, 수정 `{postImage, request}` 외 일반 form-field part가 기존 파일 map 검사만으로는 mutation 전 거부되지 않았기 때문이다.
|
||||
- 어떻게: create/update focused test에 filename 없는 `unexpected` part KO/EN/JA actual endpoint 회귀를 추가하고, controller가 `fileMap.keys`와 servlet `parts` 이름을 모두 operation별 allow-list와 비교하게 했다.
|
||||
- 결과: RED 묶음에서 신규 multipart/genre 36건 실패를 확인했고, 보완 후 focused GREEN 묶음은 `BUILD SUCCESSFUL in 1m 17s`였다. 영향 범위 회귀와 lint 결과는 `P7-R10-GATE`에 통합 기록한다.
|
||||
|
||||
**최종 결론:** `REV-069` 처리 완료. Phase 5 후속 Gate 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 21. 9차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature F의 Community 요구사항, OpenAPI Community 8개 operation
|
||||
- 검토 범위: 게시글 목록·생성·수정, 댓글 CRUD, owner/actor/parent, multipart·concurrency 경계
|
||||
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
|
||||
테스트는 실행하지 않았다.
|
||||
|
||||
### 판정 및 plan 전환
|
||||
|
||||
- Community 8개 operation과 target owner, 댓글 actor/parent, exact multipart, 고정 제한 동시성 경계를 대조했다.
|
||||
- 기존 완료 finding 이후 신규 확정 finding은 없다.
|
||||
- Phase 5 신규 Task/Gate 없음.
|
||||
|
||||
**최종 결론:** Phase 5 추가 수정 없음.
|
||||
|
||||
**남은 항목:** Phase 3 보완 뒤 `P7-R11` 통합 재판정.
|
||||
|
||||
## 22. 10차 정적 리뷰 및 판정 — 2026-07-30
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature E, OpenAPI Community 8개 operation
|
||||
- 검토 범위: 게시글 목록·생성·수정, 고정 동시성, 댓글 CRUD, owner/actor/parent와 multipart 경계
|
||||
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
|
||||
테스트는 실행하지 않았다.
|
||||
|
||||
### 판정
|
||||
|
||||
- Community 8개 operation과 controller mapping, active owner pagination wrapper·고정 우선 정렬이 일치한다.
|
||||
- 게시글 생성·수정의 strict request와 operation별 multipart part, 최대 고정 3개 owner lock·soft delete 정리가 유지된다.
|
||||
- 댓글의 target AI 작성/수정, 동일 게시글 활성 원댓글, row-only soft delete와 UTC 응답 계약이 유지된다.
|
||||
- 신규 확정 finding이 없어 Phase 5 회귀 수정 Task/Gate를 추가하지 않는다.
|
||||
|
||||
**최종 결론:** Phase 5 요구사항 충족, 추가 수정 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
@@ -1,488 +0,0 @@
|
||||
# Phase 6 FanTalk 관리 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 6 / FanTalk 2개 operation |
|
||||
| 기준 commit 또는 working tree | `2f93e2c9` + 현재 Phase 6~7 working tree |
|
||||
| 리뷰 일자 | 2026-07-28 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `api-contract.openapi.json` |
|
||||
| 리뷰 상태 | 후속 수정 및 Gate 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- PRD Feature F와 OpenAPI FanTalk 2개 operation을 관리자/public v2 query policy, controller, facade, 테스트에 대조한다.
|
||||
- pagination 보정과 reply JSON/side-effect 경계를 점검한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 관리자 FanTalk controller/facade/repository/DTO와 관련 테스트
|
||||
- 공개 v2 `CreatorChannelFanTalkQueryPolicy`
|
||||
- OpenAPI FanTalk path/parameter/schema와 plan Phase 6
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 공개 v2 정책 변경
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | cross-owner reply 또는 데이터 손실 |
|
||||
| High | 승인된 공개 v2 parity나 주요 조회 계약 위반 |
|
||||
| Medium | reply request schema·오류 계약의 제한된 위반 |
|
||||
| Low | 유지보수성 또는 문서 정합성 문제 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
| 근거 | 판정 |
|
||||
|---|---|
|
||||
| OpenAPI `FanTalkPage`/`FanTalkSize` `:520`~`:521` | page는 0 이상, size는 20..50으로 보정 |
|
||||
| `CreatorChannelFanTalkQueryPolicy.kt:8`~`:12`, `:23`~`:28` | 공개 v2가 실제로 같은 보정을 수행 |
|
||||
| `AiCharacterAdminFanTalkFacade.kt:30`~`:55` | 관리자는 범위 밖 값을 400으로 거부하고 size 1도 허용 |
|
||||
| `AiCharacterAdminFanTalkQueryTest.kt:120`~`:189` | size 1 성공과 -1/0/51 거부를 테스트가 반대 계약으로 고정 |
|
||||
| OpenAPI `FanTalkReplyCreateRequest` `:1238`~`:1242` | `content` required, `additionalProperties: false` |
|
||||
| `AiCharacterAdminFanTalkController.kt:27`~`:33` | reply를 기본 `@RequestBody` DTO binding으로 수신 |
|
||||
| reply contract test `:50`~`:100` | blank/malformed/missing은 검증하지만 미지 필드는 검증하지 않음 |
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| query policy·facade·test 정적 대조 | 성공 | pagination 구현과 테스트가 PRD/OpenAPI에 반대임을 확인 |
|
||||
| reply schema/binding 정적 대조 | 성공 | unknown-field strict 경계 누락 확인 |
|
||||
| Gradle/컴파일/테스트 | 실행 | `P6-R1` RED/GREEN focused test 수행 |
|
||||
| `P6-R1` RED | 성공 | pagination 400과 reply unknown-field 허용으로 4건 실패 확인 |
|
||||
| `P6-R1` GREEN | 성공 | focused 재실행 `BUILD SUCCESSFUL in 3m 14s` |
|
||||
| `P6-R1-GATE` FanTalk/common 회귀 | 성공 | `BUILD SUCCESSFUL in 1m 36s` |
|
||||
| `P6-R1-GATE` lint/diff | 성공 | `ktlintCheck` `BUILD SUCCESSFUL in 35s`, `git diff --check` 출력 없음 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-027` | High | 처리 완료 | 관리자 목록 pagination이 공개 v2 보정 정책과 반대 | `Task 6.6` | `P6-R1-GATE` |
|
||||
| `REV-028` | Medium | 처리 완료 | reply body가 미지 JSON 필드를 허용 | `Task 6.6` | `P6-R1-GATE` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-027 — FanTalk pagination 보정 불일치
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 처리 완료
|
||||
- **관련 요구사항:** PRD Feature F, 공개 v2 응답/query policy parity
|
||||
- **관련 계약:** page default 0·최소 0 보정, size default 20·20..50 보정
|
||||
- **소유 Task:** `Task 6.6`, `P6-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
관리자 facade는 음수 page, size 0, size 51을 400으로 거부하고 size 1을 허용한다. 공개 v2 policy와 OpenAPI는 각각
|
||||
page 0, size 20, size 50으로 보정해야 하며 size 1도 20으로 올려야 한다. 현재 query 테스트가 잘못된 구현을 의도한
|
||||
동작으로 고정한다.
|
||||
|
||||
**후속 수정 결과**
|
||||
|
||||
관리자 목록 facade가 공개 v2 `CreatorChannelFanTalkQueryPolicy`를 재사용하도록 변경되어 `page < 0 -> 0`,
|
||||
`size < 20 -> 20`, `size > 50 -> 50` 보정이 actual endpoint 테스트로 고정됐다.
|
||||
|
||||
**영향**
|
||||
|
||||
OpenAPI client가 보정 계약을 신뢰하면 관리자 endpoint에서 예상하지 못한 400을 받으며, size 1 요청의 응답 metadata도
|
||||
계약과 달라진다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
공개 v2 query policy와 동일한 정규화를 적용하고 기존 pagination 테스트를 경계값 기반으로 교정한다.
|
||||
|
||||
### REV-028 — FanTalk reply unknown-field 미거부
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 처리 완료
|
||||
- **관련 요구사항:** OpenAPI request schema 준수, 잘못된 request no-side-effect
|
||||
- **관련 계약:** `FanTalkReplyCreateRequest.additionalProperties: false`
|
||||
- **소유 Task:** `Task 6.6`, `P6-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
controller의 기본 DTO binding은 malformed/missing content는 거부하지만 계약 밖 필드를 무시한다. repository 전역 설정에
|
||||
unknown property 실패 설정이 없고 현재 contract test도 extra field를 다루지 않는다.
|
||||
|
||||
**후속 수정 결과**
|
||||
|
||||
reply controller는 raw JSON 문자열을 facade로 넘기고, facade가 `FAIL_ON_UNKNOWN_PROPERTIES` strict reader로
|
||||
`AiCharacterAdminFanTalkReplyRequest`를 역직렬화한다. 미지 필드 요청은 400 `common.error.invalid_request`, reply insert
|
||||
0건, `LanguageDetectEvent` 0회로 actual endpoint 테스트에 고정됐다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
reply body만 strict parse하고 미지 필드가 있으면 400 `common.error.invalid_request`, reply insert 0건,
|
||||
`LanguageDetectEvent` 0회를 actual endpoint로 고정한다.
|
||||
|
||||
## 7. plan·goal 전환
|
||||
|
||||
`plan-task.md` Phase 6에 두 finding을 함께 처리하는 `Task 6.6` / `P6-R1`과 `P6-R1-GATE`를 추가했다. 기존
|
||||
`P6-GATE` 완료 이력은 유지한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| operation/path 대조 | 충족 | FanTalk 2개 route 존재 |
|
||||
| 공개 v2 pagination parity | 충족 | 공개 v2 query policy 재사용과 경계값 actual test 통과 |
|
||||
| reply ownership/storage 추적 | 충족 | active owner root 선검증과 target creator 저장 확인 |
|
||||
| reply JSON schema | 충족 | unknown-field 400/no insert/no event actual test 통과 |
|
||||
| 실행 검증 | 충족 | focused, FanTalk/common 회귀, lint, diff 성공 |
|
||||
|
||||
**최종 결론:** Phase 6 후속 리뷰 종료
|
||||
|
||||
**남은 항목:** 없음. 다음 Goal은 `P7-R1`이다.
|
||||
|
||||
## 9. 2차 정적 리뷰 및 판정 — 2026-07-28
|
||||
|
||||
### 리뷰 정보와 범위
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature F, plan Phase 6, OpenAPI FanTalk 2개 operation
|
||||
- 검토 범위: 관리자 root/reply query, 공개 v2 pagination policy, reply strict JSON·owner/root/active 검증,
|
||||
writer/creator 저장과 언어 감지 event 테스트
|
||||
- 검증 방식: 코드·문서·테스트 정적 대조. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 목록 query/pagination | 충족 | 공개 v2 `CreatorChannelFanTalkQueryPolicy` 재사용 |
|
||||
| reply JSON | 충족 | strict reader와 blank/malformed/unknown-field 거부 |
|
||||
| root/ownership | 충족 | active owner root만 조회하고 nested/cross-owner를 저장 전 차단 |
|
||||
| writer/event | 충족 | target creator를 writer/creator로 저장하고 언어 감지 event 발행 |
|
||||
| plan 전환 | 해당 없음 | Phase 6 신규 Task 불필요 |
|
||||
|
||||
**최종 결론:** Phase 6 추가 수정 없음
|
||||
|
||||
**남은 항목:** 없음. `P7-R2` 통합 재판정에서 기존 FanTalk/common 회귀만 확인한다.
|
||||
|
||||
## 10. 3차 정적 리뷰 및 판정 — 2026-07-28
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature F, plan Phase 6, OpenAPI FanTalk 2개 operation
|
||||
- 검토 범위: 관리자 목록·답변 facade, 공개 v2 pagination policy, strict JSON, root ownership과 event 테스트
|
||||
- 검증 방식: 코드·문서·테스트 정적 추적. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 목록 query | 충족 | 공개 v2 page/size 보정 정책 재사용 |
|
||||
| reply JSON | 충족 | malformed/blank/unknown-field 저장 전 거부 |
|
||||
| root/ownership | 충족 | active owner root만 허용하고 nested/cross-owner 차단 |
|
||||
| writer/event | 충족 | target creator 저장과 언어 감지 event 유지 |
|
||||
| plan 전환 | 해당 없음 | Phase 6 신규 Task 불필요 |
|
||||
|
||||
**최종 결론:** Phase 6 추가 수정 없음
|
||||
|
||||
**남은 항목:** `P7-R3`에서 FanTalk/common 회귀를 통합 재검증한다.
|
||||
|
||||
## 11. 4차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Feature F, plan Phase 6, OpenAPI FanTalk 2개 operation
|
||||
- 검토 범위: 관리자 root/reply 목록, pagination policy, strict reply JSON, root ownership·event
|
||||
- 검증 방식: 코드·schema·테스트 정적 대조. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 목록/pagination | 충족 | 공개 v2 query policy와 root/reply owner query 유지 |
|
||||
| reply JSON | 충족 | malformed·blank·unknown field를 저장 전에 거부 |
|
||||
| root/ownership | 충족 | active owner root만 답변 허용 |
|
||||
| writer/event | 충족 | target creator를 writer/creator로 저장하고 언어 감지 발행 |
|
||||
| plan 전환 | 해당 없음 | Phase 6 신규 Task 불필요 |
|
||||
|
||||
**최종 결론:** Phase 6 추가 수정 없음
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 12. 5차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위
|
||||
|
||||
- OpenAPI FanTalk 2개 operation과 controller/facade/query 구현
|
||||
- reply request의 required/non-null, malformed·unknown·blank 입력 처리
|
||||
- pagination 보정, root ownership, writer/event 경계
|
||||
|
||||
### 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 목록/pagination | 충족 | 공개 v2 query policy 보정과 owner root/reply query 유지 |
|
||||
| reply JSON | 충족 | request는 non-null `String content` 하나이며 null/malformed/blank/unknown을 저장 전 거부 |
|
||||
| root/ownership | 충족 | active owner root만 답변 허용 |
|
||||
| primitive nullability | 해당 없음 | FanTalk JSON request에 primitive 필드가 없음 |
|
||||
| plan 전환 | 해당 없음 | Phase 6 신규 Task 불필요 |
|
||||
|
||||
사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
|
||||
|
||||
**최종 결론:** Phase 6 신규 수정 없음
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 13. 팬 작성 FanTalk 원글 삭제 후속 검토 — 2026-07-29
|
||||
|
||||
### 확인 결과
|
||||
|
||||
- **`REV-049` / High / 구현 대기:** 신규 v2 관리자 경계에 target 채널의 팬 작성 FanTalk root를 삭제할
|
||||
operation이 없다.
|
||||
- 삭제는 팬 작성 root row만 `isActive=false`로 변경하고 연결 creator reply row는 유지한다.
|
||||
- target AI가 작성한 row, reply row, 다른 채널 root는 거부하며 이미 비활성인 같은 target 팬 root는 성공 no-op이다.
|
||||
- 캐릭터 직접 댓글 삭제는 v2 미사용 API로 별도 구현하지 않는다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
- 신규 Task: `Task 6.7` / `P6-R2`
|
||||
- Gate: `P6-R2-GATE`
|
||||
- 범위 밖: hard delete·cascade, FanTalk 원글 작성, public v2 endpoint 변경
|
||||
|
||||
사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
|
||||
|
||||
**최종 결론:** Phase 6 팬 작성 FanTalk 원글 삭제 구현 필요
|
||||
|
||||
**다음 Goal:** `P6-R2`.
|
||||
|
||||
## 14. 팬 작성 FanTalk 원글 삭제 구현 검토 — 2026-07-29
|
||||
|
||||
### 구현 결과
|
||||
|
||||
- `DELETE /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}`를 추가했다.
|
||||
- target 채널의 팬 작성 root만 `CreatorCheers.isActive=false`로 변경한다.
|
||||
- 연결 creator reply row는 변경하지 않고, 목록·`fanTalkCount`에서는 삭제된 root가 제외된다.
|
||||
- target AI 작성 root, reply row, 다른 채널 root, 비활성 target, 누락 ID는 400/no mutation으로 거부한다.
|
||||
- 같은 target의 이미 비활성인 팬 root는 200 no-op으로 처리한다.
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.AiCharacterAdminFanTalkDeleteTest` | 성공 | RED 6건 미구현 route 실패 확인 후 GREEN focused `BUILD SUCCESSFUL in 29s` |
|
||||
| `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest` | 성공 | DELETE 인가 matrix 보강 후 `BUILD SUCCESSFUL in 29s` |
|
||||
| `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.fantalk.*' --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminAuthorizationTest --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.AiCharacterAdminErrorContractTest` | 성공 | FanTalk/common 영향 범위 회귀 `BUILD SUCCESSFUL in 58s` |
|
||||
| `./gradlew ktlintCheck` | 성공 | `BUILD SUCCESSFUL in 14s` |
|
||||
| `git diff --check` | 성공 | 출력 없음 |
|
||||
|
||||
**최종 결론:** `REV-049` 처리 완료. Phase 6 후속 Gate 완료.
|
||||
|
||||
**다음 Goal:** `P7-R6`.
|
||||
|
||||
## 15. 6차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature F, OpenAPI FanTalk 3개 operation
|
||||
- 검토 범위: FanTalk controller의 query/JSON mapping, reply strict parser·저장 경계와 관련 테스트
|
||||
- 기준 상태: 현재 working tree
|
||||
- 검증 방식: 문서·코드·테스트 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
|
||||
|
||||
### `REV-054` — High — 처리 완료
|
||||
|
||||
- OpenAPI는 reply POST의 requestBody media type을 `application/json` 하나로 정의하고 415 response를 선언한다.
|
||||
- reply controller mapping에는 `consumes = [MediaType.APPLICATION_JSON_VALUE]`가 없다.
|
||||
- body를 `String`으로 받으므로 미지원 media type이 mapping 단계에서 차단되지 않고 handler/parser까지 진입할 수 있다.
|
||||
- 기존 reply 계약·생성·ownership 테스트는 `application/json` 요청만 사용해 415 `Accept` header와
|
||||
insert/event no-side-effect를 고정하지 않는다.
|
||||
- 외부 HTTP 요청 수용 범위와 명시된 415가 달라 High로 판정한다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
- 신규 Task: `Task 6.8` / `P6-R3`
|
||||
- Gate: `P6-R3-GATE`
|
||||
- 최소 수정: reply POST mapping에 JSON `consumes` 추가
|
||||
- 완료 조건: 정상 JSON 축약 응답 회귀, 미지원 media type의 KO/EN/JA 415 envelope, `Accept` header,
|
||||
reply insert/event 0회
|
||||
- 범위 밖: strict parser·root/ownership·언어 감지, 목록/삭제, OpenAPI·legacy/public API 변경
|
||||
|
||||
### `P6-R3` / `P6-R3-GATE` 처리 결과
|
||||
|
||||
- RED: production 변경 전 `AiCharacterAdminFanTalkReplyContractTest`에 KO/EN/JA `text/plain` reply POST 415 matrix를
|
||||
추가했고, focused 명령은 3개 invocation이 415 기대 실패로 `BUILD FAILED in 33s`였다.
|
||||
- GREEN: reply POST mapping에 `consumes = [MediaType.APPLICATION_JSON_VALUE]`만 추가했다. strict parser,
|
||||
root/ownership, 언어 감지, 목록/삭제, OpenAPI schema는 변경하지 않았다.
|
||||
- Gate: 같은 focused 명령은 `BUILD SUCCESSFUL in 41s`, FanTalk/common 영향 범위와
|
||||
`AiCharacterAdminErrorContractTest` 회귀는 `BUILD SUCCESSFUL in 47s`였다.
|
||||
|
||||
**최종 결론:** `REV-054` 처리 완료. Phase 6은 `P6-R4` 완료 전 종결할 수 없다.
|
||||
|
||||
**다음 Goal:** `P6-R4`.
|
||||
|
||||
## 16. FanTalk 답변 수정 계약 검토 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 근거
|
||||
|
||||
- 요청: FanTalk 답변을 수정하는 V2 관리자 API 추가, 레거시 `PUT /explorer/profile/cheers` 계약 유지
|
||||
- 레거시 근거: `ExplorerController.modifyCheers`, `ExplorerService.modifyCheers`, `PutWriteCheersRequest`,
|
||||
`CreatorChannelFanTalkResponse`
|
||||
- 현재 V2 근거: FanTalk controller/facade/repository/DTO와 목록·답변 작성·팬 원글 삭제 3개 operation
|
||||
- 검증 방식: 문서·레거시·현재 V2 코드 정적 대조와 `./gradlew tasks --all` 프로젝트 인식 확인. 사용자 지시에 따라
|
||||
컴파일·테스트·lint는 실행하지 않았다.
|
||||
|
||||
### `REV-059` — High — FanTalk 답변 수정 V2 관리자 operation 부재
|
||||
|
||||
- 처리 전 V2 관리자 FanTalk에는 선택한 AI 캐릭터가 작성한 기존 reply의 내용이나 활성 상태를 수정할 route가 없었다.
|
||||
- 레거시 request는 `cheersId`와 optional/nullable `content`, `isActive`를 받고 non-null 값만 반영한다. 두 필드를
|
||||
함께 입력할 수 있고 `{}` 또는 explicit null은 성공 no-op이다.
|
||||
- 레거시는 비활성 row도 조회하므로 `isActive=true` 재활성화가 가능하고, 수정 시 `languageCode`와 event를 변경하지 않는다.
|
||||
- 성공 `data`는 `CreatorChannelFanTalkResponse`이며 reply row를 매핑하므로 `fanTalkId`는 reply ID,
|
||||
`creatorReplies`는 빈 배열이다.
|
||||
- 관리자 V2에서는 위 계약에 `characterId`, root `fanTalkId`, `replyId` path를 적용하고 target AI가 writer이자
|
||||
creator이며 지정한 활성 root의 direct child인 reply로 소유 경계를 강화해야 한다.
|
||||
|
||||
### 확정 계약과 plan 전환
|
||||
|
||||
- 신규 operation:
|
||||
`PUT /api/v2/admin/ai-characters/{characterId}/fan-talks/{fanTalkId}/replies/{replyId}`
|
||||
- request: optional/nullable `content`, `isActive`; 동시 입력과 빈 객체 no-op 허용, JSON-only·미지 필드 거부
|
||||
- response: 레거시 `CreatorChannelFanTalkResponse` 필드 형태
|
||||
- inactive reply 재활성화 허용, inactive root·cross-target/root·팬 작성 row·direct-parent mismatch는 400/no mutation
|
||||
- 신규 Task: `Task 6.9` / `P6-R4`
|
||||
- Gate: `P6-R4-GATE`
|
||||
- OpenAPI 상태: 전체 37개 operation 모두 `implemented`
|
||||
|
||||
### 구현 결과와 Gate
|
||||
|
||||
- RED: `AiCharacterAdminFanTalkReplyUpdateTest`와 `AiCharacterAdminFanTalkReplyUpdateContractTest` 신규 15건이
|
||||
미구현 route 404로 `BUILD FAILED in 49s`였다.
|
||||
- GREEN: 신규 PUT route, JSON `consumes`, strict request DTO, active root와 target AI writer/creator direct reply를
|
||||
검증하는 repository query, non-null field만 반영하는 facade를 추가했다.
|
||||
- Gate: focused 재실행은 `BUILD SUCCESSFUL in 42s`, FanTalk/common/legacy 영향 범위 회귀는
|
||||
`BUILD SUCCESSFUL in 1m 2s`, OpenAPI status 집계는 37개 모두 `implemented`, `ktlintCheck`는
|
||||
`BUILD SUCCESSFUL in 25s`, `git diff --check`는 출력이 없었다.
|
||||
|
||||
**최종 결론:** `REV-059` 처리 완료. Phase 6의 P6-R3/P6-R4 후속 보완은 완료됐다.
|
||||
|
||||
**다음 Goal:** `P7-R8`.
|
||||
|
||||
## 17. 7차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature F, OpenAPI FanTalk 4개 operation
|
||||
- 검토 범위: 목록 pagination, 답변 작성·수정 JSON 경계, 팬 root 삭제, target/root/reply ownership
|
||||
- 검증 방식: 현재 working tree의 문서·코드·관련 테스트를 정적으로 대조했다. 사용자 요청에 따라 컴파일과 테스트는
|
||||
실행하지 않았다.
|
||||
|
||||
### 판정
|
||||
|
||||
| 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| route/operation | 충족 | FanTalk 4개 OpenAPI operation과 controller mapping 일치 |
|
||||
| JSON request | 충족 | 답변 작성·수정의 JSON-only mapping과 strict unknown-field 거부 유지 |
|
||||
| ownership/state | 충족 | active root, target AI direct reply, fan root soft delete 조건 유지 |
|
||||
| 7차 multipart finding 영향 | 없음 | FanTalk에는 multipart request가 없음 |
|
||||
|
||||
### finding 및 plan 전환
|
||||
|
||||
- 신규 Phase 6 finding 없음.
|
||||
- Phase 6 신규 Task/Gate 없음.
|
||||
|
||||
**최종 결론:** Phase 6 추가 수정 없음
|
||||
|
||||
**남은 항목:** `P7-R9` 통합 재판정.
|
||||
|
||||
## 18. 8차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature F/Edge Cases, OpenAPI FanTalk DELETE description, `api-contract.md`
|
||||
- 검토 범위: FanTalk root delete facade/repository와 `AiCharacterAdminFanTalkDeleteTest`
|
||||
- 기준 상태: 현재 working tree
|
||||
- 리뷰어/상태: Codex / 판정 완료
|
||||
- 검증 방식: 문서·코드·테스트 소스 정적 대조. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### `REV-070` — Low — 비활성 팬 root 삭제 설명 상충
|
||||
|
||||
- OpenAPI `api-contract.openapi.json:785`와
|
||||
`AiCharacterAdminFanTalkDeleteTest.kt:76-101`은 같은 target의 이미 비활성인 팬 root 삭제를 성공 no-op으로
|
||||
정의한다. `api-contract.md:229-231`도 같은 결과를 설명한다.
|
||||
- 반면 PRD `prd.md:227-228`은 비활성 root를 400 거부 대상으로 묶고, `api-contract.md:41`도 “활성 root만”이라고
|
||||
적어 같은 문서 안에서 뒤쪽 no-op 설명과 상충한다.
|
||||
- 기계 계약인 OpenAPI와 현재 구현·회귀가 일치하므로 runtime 변경보다 설명 문서를 no-op 계약에 맞추는 최소 보완이
|
||||
적절하다. 실행 오류가 아니라 문서 불일치이므로 Low로 판정한다.
|
||||
- 이 판정은 OpenAPI를 기계 계약 원본으로 두고 구현·테스트와 일치하는 쪽을 유지한 결과다. PRD의 400 문장이 최신 제품
|
||||
의도라면 `P6-R5`를 실행하기 전에 OpenAPI와 runtime/test까지 변경하는 별도 범위로 재확정해야 한다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 신규 Task | `Task 6.10` / `P6-R5` |
|
||||
| Gate | `P6-R5-GATE` |
|
||||
| 변경 | PRD와 `api-contract.md`의 상충 문장만 OpenAPI/runtime no-op 계약에 동기화 |
|
||||
| TDD 예외 | 문서 전용 Task이며 OpenAPI·구현·test 소스 정적 대조로 검증 |
|
||||
| 범위 제한 | runtime/test/OpenAPI·legacy/public 변경 없음 |
|
||||
|
||||
**최종 결론:** Phase 6 문서 보완 필요 — `REV-070` 확정
|
||||
|
||||
**다음 Goal:** `P6-R5` (`P5-R10-GATE` 완료 후).
|
||||
|
||||
## 19. 8차 후속 문서 정합화 및 Gate — 2026-07-29
|
||||
|
||||
- 무엇을: `REV-070`의 FanTalk 비활성 팬 root 삭제 설명 상충을 정리했다.
|
||||
- 왜: OpenAPI·구현·`AiCharacterAdminFanTalkDeleteTest`는 같은 target의 이미 비활성인 팬 root 삭제를 200 `data:null` no-op으로 고정하지만 PRD 일부 문장이 400 거부로 설명했기 때문이다.
|
||||
- 어떻게: PRD Edge Cases와 `api-contract.md` 삭제 설명을 같은 target 비활성 팬 root no-op, creator root·reply·다른 target·미존재 root 400으로 동기화했다. runtime/test/OpenAPI는 변경하지 않았다.
|
||||
- 결과: 문서-only 보완으로 `REV-070` 처리 완료. 정적 대조와 diff check 결과는 `P7-R10-GATE`에 통합 기록한다.
|
||||
|
||||
**최종 결론:** `REV-070` 처리 완료. Phase 6 후속 Gate 완료.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 20. 9차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature F/Edge Cases, OpenAPI FanTalk 4개 operation
|
||||
- 검토 범위: 목록, 답변 작성·수정, 팬 root 삭제, target/root/direct reply ownership
|
||||
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
|
||||
테스트는 실행하지 않았다.
|
||||
|
||||
### 판정 및 plan 전환
|
||||
|
||||
- FanTalk 4개 operation과 pagination, strict JSON, target AI reply ownership, root 삭제 no-op 계약을 대조했다.
|
||||
- 기존 완료 finding 이후 신규 확정 finding은 없다.
|
||||
- Phase 6 신규 Task/Gate 없음.
|
||||
|
||||
**최종 결론:** Phase 6 추가 수정 없음.
|
||||
|
||||
**남은 항목:** Phase 3 보완 뒤 `P7-R11` 통합 재판정.
|
||||
|
||||
## 21. 10차 정적 리뷰 및 판정 — 2026-07-30
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature F, OpenAPI FanTalk 4개 operation
|
||||
- 검토 범위: 목록, creator reply 작성·수정, 팬 root 삭제와 target/root/direct reply ownership
|
||||
- 검증 방식: 현재 working tree의 문서·production·test 소스를 정적으로 대조했다. 사용자 지시에 따라 컴파일과
|
||||
테스트는 실행하지 않았다.
|
||||
|
||||
### 판정
|
||||
|
||||
- FanTalk 4개 operation과 controller mapping, 공개 v2 page/size 보정·응답 필드가 일치한다.
|
||||
- 답변 작성은 active root와 target creator writer/creator를, 수정은 target의 active root direct reply를 검증한다.
|
||||
- 팬 root row-only soft delete와 동일 target 비활성 root 성공 no-op 계약이 문서·구현에 일치한다.
|
||||
- 신규 확정 finding이 없어 Phase 6 회귀 수정 Task/Gate를 추가하지 않는다.
|
||||
|
||||
**최종 결론:** Phase 6 요구사항 충족, 추가 수정 없음.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
@@ -1,759 +0,0 @@
|
||||
# Phase 7 통합·문서 정합성 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 7 / 23개 operation 통합 상태와 문서 추적성 |
|
||||
| 기준 commit 또는 working tree | `2f93e2c9` + 현재 Phase 2~7 working tree |
|
||||
| 리뷰 일자 | 2026-07-28 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md`, `api-contract.md`, `api-contract.openapi.json` |
|
||||
| 리뷰 상태 | 후속 수정 및 Gate 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- Phase 7 완료 기록과 실제 신규 prefix controller/OpenAPI operation 수를 대조한다.
|
||||
- 계획, 사람이 읽는 계약 설명, OpenAPI 구현 상태 metadata가 현재 구현 상태를 정확히 나타내는지 확인한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 신규 prefix controller mapping 전체
|
||||
- OpenAPI operation과 `x-implementation-status`
|
||||
- plan 현재 상태/Endpoint Contract Summary/Phase 7 Progress
|
||||
- `api-contract.md` 구현 현황
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Phase 2~6 finding의 production 수정, API schema 변경
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 인수·배포 판정을 무효화하는 미구현 핵심 기능 |
|
||||
| High | operation 누락 또는 공개 schema 불일치 |
|
||||
| Medium | 완료 상태·생성 client 판단에 영향을 주는 metadata/문서 불일치 |
|
||||
| Low | 비핵심 설명·형식 정합성 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
| 근거 | 판정 |
|
||||
|---|---|
|
||||
| OpenAPI 정적 집계 | 23개 operation: Character 4, AudioContent 5, Series 9, Community 3, FanTalk 2 |
|
||||
| controller mapping 정적 집계 | 후속 Gate 후 Character 4, AudioContent 5, Series 9, Community 3, FanTalk 2로 총 23개 |
|
||||
| OpenAPI status 정적 집계 | 후속 수정 후 23개 `implemented` |
|
||||
| `api-contract.md:9`~`:12` | 후속 수정 후 endpoint 23개, route 구현 23개, 구현 완료 23개, 예정 0개 |
|
||||
| plan Endpoint Contract Summary | 후속 수정 후 5개 domain 모두 구현 완료로 표시 |
|
||||
| plan `P7-GATE`와 후속 기록 | 23개 operation 구현 완료로 판정 |
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `jq` operation/status 집계 | 성공 | operation 23, status 9/14 확인 |
|
||||
| controller annotation `rg` 집계 | 성공 | mapping 24, 초과 1개는 Series DELETE |
|
||||
| `P7-R1` 후속 `jq` operation/status 집계 | 성공 | operation 23, implemented 23 |
|
||||
| `P7-R1` 후속 controller annotation 집계 | 성공 | mapping 23 |
|
||||
| OpenAPI validate/client 생성/compile | 성공 | validate 이슈 없음, TypeScript compile exit 0 |
|
||||
| Gradle/문서 diff | 성공 | `./gradlew tasks --all` 성공, `git diff --check` 출력 없음 |
|
||||
| `P7-R1-GATE` 최종 대조 | 성공 | OpenAPI 23개 implemented, controller mapping 23개, 미처리 finding 0건 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-029` | Medium | 처리 완료 | 구현 완료 기록과 계약 metadata/현황 문서 불일치 | `Task 7.3` | `P7-R1-GATE` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-029 — 구현 상태 metadata와 완료 기록 불일치
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 처리 완료
|
||||
- **관련 요구사항:** Phase 7 API contract·diff·문서 추적성 완료 조건
|
||||
- **관련 계약:** OpenAPI 23개 operation과 실제 controller mapping 일치
|
||||
- **소유 Task:** `Task 7.3`, `P7-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
Phase 7 완료 기록은 모든 operation 구현을 선언하지만 `api-contract.md`, plan Endpoint Contract Summary,
|
||||
OpenAPI `x-implementation-status`는 계약 확정 당시의 9개 정합화 필요/14개 예정 상태를 유지한다. 실제 controller는
|
||||
23개가 아니라 계약 밖 Series DELETE를 포함한 24개다.
|
||||
|
||||
**후속 수정 결과**
|
||||
|
||||
`P4-R1-GATE`에서 계약 밖 Series DELETE route를 제거했고, `P7-R1`에서 plan/API 설명/OpenAPI status를 실제 구현 상태와
|
||||
동기화했다. OpenAPI는 23개 operation 모두 `implemented`이며, 신규 prefix controller mapping도 23개로 일치한다.
|
||||
|
||||
**영향**
|
||||
|
||||
문서 독자와 생성 도구가 구현 완료 여부를 다르게 판단하며, Phase 7의 “OpenAPI와 route 일치” 완료 증거를 현재 정적 집계로
|
||||
재현할 수 없다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
먼저 `P4-R1-GATE`에서 계약 밖 route를 제거해 controller를 23개로 맞춘다. 나머지 Phase 후속 Gate가 끝난 뒤
|
||||
`P7-R1`에서 plan/API 설명/OpenAPI status를 모두 23개 `implemented`로 동기화하고 validator와 client 생성을 재검증한다.
|
||||
path/request/response schema는 변경하지 않는다.
|
||||
|
||||
## 7. plan·goal 전환
|
||||
|
||||
`plan-task.md` Phase 7에 `Task 7.3` / `P7-R1`과 `P7-R1-GATE`를 추가했다. `P7-R1`은 Phase 2~6 후속 Gate가 모두
|
||||
끝난 뒤 실행한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| OpenAPI operation 수 | 충족 | 23개 |
|
||||
| controller mapping 수 | 충족 | 후속 Gate 후 23개 |
|
||||
| 구현 상태 문서 | 충족 | plan/API 설명/OpenAPI status 모두 23개 구현 완료로 동기화 |
|
||||
| dependency/DDL 신규 변경 | 신규 finding 없음 | 정적 변경 범위에서 관련 추가 없음 |
|
||||
| 실행 검증 | 충족 | jq, validator, TypeScript client 생성·compile, Gradle tasks, diff check 성공 |
|
||||
|
||||
**최종 결론:** Phase 7 후속 리뷰 종료
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 9. 2차 정적 리뷰 및 판정 — 2026-07-28
|
||||
|
||||
### 리뷰 정보와 범위
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Acceptance Criteria, plan Phase 7, OpenAPI 23개 operation
|
||||
- 검토 범위: Phase별 신규 finding 종결 상태, controller/OpenAPI operation 수, 구현 status, dependency/DDL·최종 Gate 조건
|
||||
- 검증 방식: `rg`, `jq`, `git diff` 기반 정적 점검. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
Phase 7 자체의 신규 독립 결함은 없다. 정적 집계는 OpenAPI 23개 operation과 23개 `implemented`, controller mapping
|
||||
23개를 유지한다. Phase 3~5의 `REV-030`~`REV-033`은 모두 처리 완료됐고, targeted·전체 회귀·lint·diff 검증도 통과했다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
`plan-task.md` Phase 7에 `Task 7.4` / `P7-R2`와 `P7-R2-GATE`를 추가했다. 이 Task는 독립 production 수정이 아니라
|
||||
`P3-R10-GATE`, `P4-R2-GATE`, `P5-R2-GATE` 뒤 targeted·전체 회귀와 문서/operation 상태를 재판정한다.
|
||||
`P7-R2`와 `P7-R2-GATE`를 완료 처리했고, plan 상태를 `구현 완료`로 되돌렸다.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| OpenAPI operation/status | 충족 | 23개 operation, 23개 `implemented` |
|
||||
| controller mapping | 충족 | Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2 |
|
||||
| 미처리 finding | 충족 | `REV-030`~`REV-033` 처리 완료 |
|
||||
| 최종 Gate | 충족 | `P7-R2`, `P7-R2-GATE` 완료 |
|
||||
| 실행 검증 | 충족 | targeted, 전체 회귀, lint, OpenAPI/controller/diff 점검 통과 |
|
||||
|
||||
**최종 결론:** 통합 재판정 및 Gate 완료
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 16. 후속 기능 통합 최종 판정 — 2026-07-29
|
||||
|
||||
### Phase별 결과
|
||||
|
||||
| Phase | 판정 | finding / 후속 Goal |
|
||||
|---:|---|---|
|
||||
| 1 | 변경 없음 | 공통 ADMIN·resolver·오류 경계 재사용 |
|
||||
| 2 | 처리 완료 | `REV-044`, `P2-R9` / `P2-R9-GATE` |
|
||||
| 3 | 처리 완료 | `REV-045`, `P3-R13` / `P3-R13-GATE` |
|
||||
| 4 | 처리 완료 | `REV-046`~`REV-047`, `P4-R5`~`P4-R6-GATE` |
|
||||
| 5 | 처리 완료 | `REV-048`, `P5-R5` / `P5-R5-GATE` |
|
||||
| 6 | 처리 완료 | `REV-049`, `P6-R2` / `P6-R2-GATE` |
|
||||
| 7 | 통합 재판정 완료 | `P7-R6` / `P7-R6-GATE` |
|
||||
|
||||
### 통합 판정
|
||||
|
||||
- 기존 23개 route와 후속 13개 operation을 합쳐 OpenAPI 계약은 36개다.
|
||||
- 현재 상태는 36개 operation 모두 `implemented`다.
|
||||
- 캐릭터 직접 댓글 API는 v2 미사용 결정에 따라 operation과 Task를 추가하지 않는다.
|
||||
- Phase 2~6 신규 Gate 완료 뒤 36개 operation/mapping/`implemented`, 공통 보안·오류, actor·ownership,
|
||||
row-only soft delete와 legacy/public 회귀를 `Task 7.8`에서 재판정했다.
|
||||
|
||||
targeted 회귀, 전체 `./gradlew test`, `ktlintCheck`, OpenAPI/controller/diff 정적 검증이 모두 성공했다.
|
||||
|
||||
**최종 결론:** 36개 operation 통합 재판정 및 Gate 완료
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 12. 4차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 정보와 범위
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Acceptance Criteria, plan Phase 1~7, OpenAPI 23개 operation
|
||||
- 검토 범위: Phase별 runtime 경계, operation/status/mapping, 완료 상태표·Task header, dependency/DDL 범위
|
||||
- 검증 방식: `sed`, `rg`, `jq`, `git diff` 기반 정적 점검. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-038` | Low | 처리 완료 | 완료된 후속 Task 헤더와 상단 완료 상태가 모순됨 | `Task 7.6` | `P7-R4` |
|
||||
| `REV-039` | Low | 처리 완료 | Phase 4 콘텐츠 해제 설명이 OpenAPI/controller route와 다름 | `Task 7.6` | `P7-R4` |
|
||||
|
||||
### `REV-038` — 완료 Task 헤더와 현재 상태 불일치
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 처리 완료
|
||||
- **처리 상태:** 처리 완료 (`P7-R4`)
|
||||
- **관련 요구사항:** 작업절차의 구현 완료 즉시 Task 체크박스 갱신, 문서유지보수의 완료 상태 동기화
|
||||
- **관련 계약:** plan 상단 현재 상태·Goal Progress·Phase별 Task 완료 증거
|
||||
- **소유 Task:** `Task 7.6`, `P7-R4`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`Task 2.13`, `Task 3.21`, `Task 4.9`, `Task 7.5`는 하위 단계·Gate·2026-07-29 검증 기록에서 완료됐지만,
|
||||
Task 헤더는 `[ ]`다. 반면 상단 표는 각 Phase를 전체 완료로 표시해 동일 문서 안의 상태가 모순된다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 계획: 네 Task header의 `[ ]`
|
||||
- Gate: `P2-R7-GATE`, `P3-R11-GATE`, `P4-R3-GATE`, `P7-R3-GATE`의 `[x]`
|
||||
- Progress: 2026-07-29 후속 수정·통합 검증 완료 기록
|
||||
|
||||
**영향**
|
||||
|
||||
후속 agent가 이미 완료된 기능 Task를 다시 실행하거나 Phase 완료 조건을 잘못 판정할 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
기존 완료 증거를 삭제하지 않고 네 Task header, 상단 상태표와 Progress만 같은 완료 상태로 동기화한다.
|
||||
|
||||
### `REV-039` — Phase 4 DELETE 설명의 stale path/body
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 처리 완료
|
||||
- **처리 상태:** 처리 완료 (`P7-R4`)
|
||||
- **관련 요구사항:** OpenAPI를 request/response의 기계 검증 가능한 단일 기준으로 사용
|
||||
- **관련 계약:** `removeAiCharacterSeriesContent`
|
||||
- **소유 Task:** `Task 7.6`, `P7-R4`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
plan Phase 4 endpoint 설명은 콘텐츠 해제를 `DELETE /series/{seriesId}/contents`와
|
||||
`RemoveContentToTheSeriesRequest(contentId)` body로 적는다. OpenAPI와 실제 controller는
|
||||
`DELETE /series/{seriesId}/contents/{contentId}`이며 request body가 없다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 계획: Phase 4 `API endpoint와 request/response contract`
|
||||
- OpenAPI: operationId `removeAiCharacterSeriesContent`
|
||||
- 코드: `AiCharacterAdminSeriesController.removeContent`
|
||||
|
||||
**영향**
|
||||
|
||||
runtime은 올바르지만 계획만 읽는 후속 구현·클라이언트 작업이 폐기된 body route를 사용할 수 있다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
production/OpenAPI는 변경하지 않고 Phase 4 설명만 현재 path parameter 계약으로 정정한다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
두 항목은 모두 문서 정합성이고 같은 파일에서 최소 수정할 수 있어 `plan-task.md` Phase 7의
|
||||
`Task 7.6` / `P7-R4`로 묶었다.
|
||||
|
||||
### 실행한 정적 검증
|
||||
|
||||
- `jq empty api-contract.openapi.json` — 성공.
|
||||
- OpenAPI 23개 operation, 고유 operationId 23개, `implemented` 23개, 200 response 누락 0개 — 성공.
|
||||
- controller mapping — Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2, 합계 23개.
|
||||
- `git diff --check` — 출력 없음.
|
||||
- 신규 dependency/DDL 파일 변경 — 없음.
|
||||
- Gradle·컴파일·테스트 — 사용자 요청에 따라 실행하지 않음.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| Phase 1~6 runtime | 충족 | 신규 기능 finding 없음 |
|
||||
| OpenAPI operation/status | 충족 | 23개 operation·고유 ID·implemented 유지 |
|
||||
| controller mapping | 충족 | domain별 합계 23개 |
|
||||
| 완료 상태 문서 | 충족 | `REV-038` 처리 완료 |
|
||||
| Phase 4 route 설명 | 충족 | `REV-039` 처리 완료 |
|
||||
| plan 반영 | 충족 | `Task 7.6`, `P7-R4` 추가 |
|
||||
|
||||
**최종 결론:** 기능 추가 수정 없음, 문서 정합성 goal 완료
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
### `P7-R4` 처리 결과
|
||||
|
||||
- `Task 2.13`, `Task 3.21`, `Task 4.9`, `Task 7.5`, `Task 7.6` 헤더를 완료 상태로 동기화했다.
|
||||
- Phase 4 시리즈 콘텐츠 해제 설명을 `DELETE /series/{seriesId}/contents/{contentId}`와 request body 없음으로 정정했다.
|
||||
- 검증: `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 767ms`, OpenAPI 23개 operation/status `jq` assertion은 `true`, 미완료 Task header `rg`와 `git diff --check`는 출력 없음, controller mapping은 23개였다.
|
||||
|
||||
### `P7-R2` 실행 검증
|
||||
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` — `BUILD SUCCESSFUL in 2m 45s`.
|
||||
- `./gradlew test` — `BUILD SUCCESSFUL in 7m 58s`.
|
||||
- `./gradlew ktlintCheck` — `BUILD SUCCESSFUL in 1s`.
|
||||
- OpenAPI operation/status `jq` assertion — `true`.
|
||||
- controller mapping 23개 assertion, dependency/DDL diff, `git diff --check` — 출력 없이 통과.
|
||||
|
||||
## 10. 3차 정적 리뷰 및 판정 — 2026-07-28
|
||||
|
||||
### 리뷰 정보와 범위
|
||||
|
||||
- 기준 commit/working tree: `2f93e2c9` + 현재 working tree
|
||||
- 기준 문서: PRD Acceptance Criteria, plan Phase 7, OpenAPI 23개 operation
|
||||
- 검토 범위: Phase별 신규 finding, operation/status/mapping, 최종 완료 Gate와 dependency/DDL 범위
|
||||
- 검증 방식: `rg`, `jq`, `git diff` 기반 정적 점검. 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
Phase 7 자체의 신규 독립 결함은 없다. OpenAPI는 23개 operation과 23개 `implemented` status를 유지하고 controller
|
||||
mapping 수도 23개다. 다만 Phase 2~4의 `REV-034`~`REV-037`이 미처리이므로 현재 최종 완료 판정은 유지할 수 없다.
|
||||
|
||||
### plan·goal 전환
|
||||
|
||||
`plan-task.md` Phase 7에 검증 전용 `Task 7.5` / `P7-R3`과 `P7-R3-GATE`를 추가했다. 이 Goal은
|
||||
`P2-R7-GATE`, `P3-R11-GATE`, `P4-R3-GATE` 완료 후 targeted·전체 회귀와 문서/operation 상태를 fresh 재판정한다.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| OpenAPI operation/status | 충족 | 23개 operation, 23개 `implemented` 유지 |
|
||||
| controller mapping | 충족 | Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2 |
|
||||
| 독립 Phase 7 결함 | 없음 | route/schema/dependency/DDL 추가 문제 없음 |
|
||||
| 최종 완료 상태 | 보류 | `REV-034`~`REV-037` 미처리 |
|
||||
| plan 반영 | 충족 | `Task 7.5`, `P7-R3`, `P7-R3-GATE` 추가 |
|
||||
| 실행 검증 | 미실행 | 사용자 요청에 따라 컴파일·테스트 미실행 |
|
||||
|
||||
**최종 결론:** Phase 7 통합 재판정 요청(당시 판정, 15절에서 처리 완료)
|
||||
|
||||
**남은 항목:** `P2-R7-GATE` → `P3-R11-GATE` → `P4-R3-GATE` → `P7-R3` → `P7-R3-GATE`.
|
||||
|
||||
## 11. 3차 통합 재판정 및 Gate — 2026-07-29
|
||||
|
||||
### 발견 사항과 판정
|
||||
|
||||
Phase 7 자체의 신규 독립 결함은 없다. `REV-034`~`REV-037`은 각 소유 Phase에서 처리 완료됐고, OpenAPI 23개 operation과 23개 `implemented` status 및 controller mapping 23개를 유지한다.
|
||||
|
||||
### 실행 검증
|
||||
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.jwt.TokenProviderTest --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.*'` — `BUILD SUCCESSFUL in 2m 25s`.
|
||||
- `./gradlew test` — `BUILD SUCCESSFUL in 6m 54s`.
|
||||
- `./gradlew ktlintCheck` — `BUILD SUCCESSFUL in 18s`.
|
||||
- OpenAPI operation/status `jq` assertion — `true`.
|
||||
- controller mapping count — 23.
|
||||
- `git diff --check` — 출력 없음.
|
||||
- 변경 파일명 점검 결과 신규 dependency/DDL 파일 변경 없음.
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| `REV-034`~`REV-037` | 충족 | Phase 2~4 후속 Gate 처리 완료 |
|
||||
| OpenAPI operation/status | 충족 | 23개 operation, 23개 `implemented` |
|
||||
| controller mapping | 충족 | Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2 |
|
||||
| targeted/전체 회귀 | 충족 | targeted와 전체 Gradle test 성공 |
|
||||
| lint/diff/dependency/DDL | 충족 | ktlint 성공, diff check 출력 없음, 신규 dependency/DDL 없음 |
|
||||
|
||||
**최종 결론:** 통합 재판정 및 Gate 완료
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 13. 5차 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### Phase별 결과
|
||||
|
||||
| Phase | 판정 | finding / 후속 Goal |
|
||||
|---:|---|---|
|
||||
| 1 | 신규 finding 없음 | 공통 보안·resolver·오류 경계 유지 |
|
||||
| 2 | 후속 처리 요청(당시 판정) | `REV-040`, `P2-R8` / `P2-R8-GATE` |
|
||||
| 3 | 후속 처리 요청(당시 판정) | `REV-041`, `P3-R12` / `P3-R12-GATE` |
|
||||
| 4 | 후속 처리 요청(당시 판정) | `REV-042`, `P4-R4` / `P4-R4-GATE` |
|
||||
| 5 | 후속 처리 요청(당시 판정) | `REV-043`, `P5-R3` / `P5-R3-GATE` |
|
||||
| 6 | 신규 finding 없음 | FanTalk request에는 primitive 필드 없음 |
|
||||
| 7 | 통합 재판정 요청(당시 판정) | `P7-R5` / `P7-R5-GATE` |
|
||||
|
||||
### 통합 판정
|
||||
|
||||
- OpenAPI operation과 controller mapping은 Character 4 + AudioContent 5 + Series 9 + Community 3 + FanTalk 2,
|
||||
합계 23개를 유지한다.
|
||||
- 신규 finding은 route 수가 아니라 Phase 2~5 multipart JSON의 primitive required/non-null 의미에 있다.
|
||||
- 전역 Jackson 정책은 legacy endpoint까지 영향을 넓히므로 각 v2 request 경계의 최소 보완으로 계획했다.
|
||||
- 네 Phase Gate 완료 전에는 문서의 `구현 완료` 최종 판정을 유지하지 않는다.
|
||||
- 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
|
||||
|
||||
**최종 결론:** `REV-040`~`REV-043` 처리 뒤 통합 재판정 요청(당시 판정, 15절에서 처리 완료)
|
||||
|
||||
**당시 Goal:** `P2-R8`.
|
||||
|
||||
## 14. Community 목록 계약 변경 영향 판정 — 2026-07-29
|
||||
|
||||
### 변경 영향
|
||||
|
||||
- `DEC-P5-LIST-001`에 따라 Community GET의 route 수는 유지되지만 query와 성공 response schema가 변경됐다.
|
||||
- OpenAPI operation은 runtime 정합화 전까지 `implemented-contract-alignment-required`로 표시한다.
|
||||
- `P7-R5`는 기존 `REV-040`~`REV-043`뿐 아니라 `P5-R4-GATE`의 timezone 제거, pagination wrapper,
|
||||
active owner count·hasNext 증거를 함께 대조해야 한다.
|
||||
- 구현 완료 뒤 OpenAPI 23개 operation이 모두 `implemented`로 복구됐는지 확인한다.
|
||||
|
||||
**최종 결론:** Phase 7 통합 재판정 시작 조건에 `P5-R4-GATE` 추가
|
||||
|
||||
**다음 Goal:** 기존 실행 순서대로 `P2-R8`.
|
||||
|
||||
## 15. 5차 통합 재판정 및 Gate — 2026-07-29
|
||||
|
||||
### Phase별 결과
|
||||
|
||||
| Phase | 판정 | finding / 후속 Goal |
|
||||
|---:|---|---|
|
||||
| 1 | 신규 finding 없음 | 공통 보안·resolver·오류 경계 유지 |
|
||||
| 2 | 처리 완료 | `REV-040`, `P2-R8` / `P2-R8-GATE` |
|
||||
| 3 | 처리 완료 | `REV-041`, `P3-R12` / `P3-R12-GATE` |
|
||||
| 4 | 처리 완료 | `REV-042`, `P4-R4` / `P4-R4-GATE` |
|
||||
| 5 | 처리 완료 | `REV-043`, `P5-R3` / `P5-R3-GATE`, `DEC-P5-LIST-001`, `P5-R4` / `P5-R4-GATE` |
|
||||
| 6 | 신규 finding 없음 | FanTalk request에는 primitive 필드 없음 |
|
||||
| 7 | 통합 재판정 완료 | `P7-R5` / `P7-R5-GATE` |
|
||||
|
||||
### 통합 판정
|
||||
|
||||
- Phase 2~5 focused와 package/common 영향 범위 회귀 증거가 모두 완료 상태다.
|
||||
- Community 목록은 `timezone` query 없이 `totalCount/page/size/hasNext/items` wrapper를 반환하며 active owner count와 `hasNext` 계약을 유지한다.
|
||||
- targeted 통합 test와 전체 `./gradlew test`, `ktlintCheck`가 성공했다.
|
||||
- OpenAPI는 23개 operation과 23개 `implemented`를 유지하고 controller mapping도 23개다.
|
||||
- 변경 파일 중 신규 dependency, migration, DDL, `.sql` 경로는 없다.
|
||||
|
||||
**최종 결론:** 통합 재판정 및 Gate 완료
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 17. UTC 날짜 계약 변경 통합 판정 — 2026-07-29
|
||||
|
||||
### Phase별 결과
|
||||
|
||||
| Phase | 판정 | finding / 후속 Goal |
|
||||
|---:|---|---|
|
||||
| 1 | 영향 없음 | 공통 보안·resolver·오류 계약 변경 없음 |
|
||||
| 2 | 영향 없음 | Character 계약 변경 없음 |
|
||||
| 3 | 처리 완료 | `REV-050`, `P3-R14` / `P3-R14-GATE` |
|
||||
| 4 | 영향 없음 | Series 계약 변경 없음 |
|
||||
| 5 | 처리 완료 | `REV-051`, `P5-R6` / `P5-R6-GATE` |
|
||||
| 6 | 영향 없음 | FanTalk 계약 변경 없음 |
|
||||
| 7 | 통합 재판정 완료 | `P7-R7` / `P7-R7-GATE` |
|
||||
|
||||
### 통합 판정
|
||||
|
||||
- route와 operation 수는 36개로 유지된다.
|
||||
- 최신 OpenAPI 상태는 `implemented` 36개, `alignment-required` 0개, `planned` 0개다.
|
||||
- 영향 operation은 오디오 생성·상세·댓글·답글 4개와 커뮤니티 댓글·답글 2개다.
|
||||
- OpenAPI의 query parameter/schema `Timezone`은 0개이고 생성 request의 `timezone` property도 제거했다.
|
||||
- 생성 nullable `releaseDate`, 상세 nullable `releaseDate`, 댓글 `date`는 기존 필드명을 유지한
|
||||
ISO-8601 UTC(`Z`) `date-time` 계약이다.
|
||||
- 기존 `P7-R6-GATE`의 36개 구현 완료 판정에 UTC 날짜 계약 6개 operation 정합화 결과를 누적했다.
|
||||
- 오디오 focused 재실행은 `BUILD SUCCESSFUL in 52s`, 커뮤니티 댓글 focused `--rerun-tasks` 재실행은
|
||||
`BUILD SUCCESSFUL in 4m 33s`였고, 각 Gate의 영향 범위 회귀·lint·diff 성공 기록과 OpenAPI/controller 정적 집계를
|
||||
대조했다.
|
||||
|
||||
**최종 결론:** UTC 날짜 계약 36개 operation 통합 재판정 및 Gate 완료
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
## 18. 6차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD acceptance criteria, plan Phase 1~7, OpenAPI 36개 operation
|
||||
- 검토 범위: Phase별 controller/facade/test, operation/mapping 수, request media type, pagination,
|
||||
dependency·DDL 변경 범위
|
||||
- 기준 상태: 현재 working tree
|
||||
- 검증 방식: `jq`, `rg`, diff 기반 정적 대조. 사용자 요청에 따라 Gradle, 컴파일, 테스트는 실행하지 않았다.
|
||||
|
||||
### Phase별 결과
|
||||
|
||||
| Phase | 판정 | finding / 후속 Goal |
|
||||
|---:|---|---|
|
||||
| 1 | 신규 finding 없음 | 공통 ADMIN 인가·resolver·오류/CORS 경계 유지 |
|
||||
| 2 | 보완 필요 | `REV-055`, `P2-R10` / `P2-R10-GATE` |
|
||||
| 3 | 보완 필요 | `REV-052`, `REV-056`, `P3-R15`~`P3-R16-GATE` |
|
||||
| 4 | 보완 필요 | `REV-057`, `P4-R7` / `P4-R7-GATE` |
|
||||
| 5 | 보완 필요 | `REV-053`, `REV-058`, `P5-R7`~`P5-R8-GATE` |
|
||||
| 6 | 보완 필요 | `REV-054`, `P6-R3` / `P6-R3-GATE` |
|
||||
| 7 | 재판정 대기 | `P7-R8` / `P7-R8-GATE` |
|
||||
|
||||
### 정적 검증 결과
|
||||
|
||||
- OpenAPI JSON 문법은 유효하고 operationId는 36개 모두 고유하다.
|
||||
- 실제 controller mapping도 36개이며 신규 dependency·migration·DDL 변경은 없다.
|
||||
- 영향 operation은 오디오 댓글·답글 GET 2개, 커뮤니티 댓글 POST·PUT 2개, FanTalk reply POST 1개와
|
||||
Character·AudioContent·Series·Community multipart 생성·수정 8개로 총 13개다.
|
||||
- OpenAPI의 36개 `x-implementation-status`는 모두 `implemented`지만 위 13개 HTTP 경계가 아직 계약과 달라
|
||||
전체 구현 완료 판정은 보류한다.
|
||||
|
||||
### plan 전환 및 종결 조건
|
||||
|
||||
- 소유 Phase 순서: `P2-R10` → `P3-R15` → `P3-R16` → `P4-R7` → `P5-R7` → `P5-R8` → `P6-R3`
|
||||
- 통합 재판정: `Task 7.10` / `P7-R8`, Gate `P7-R8-GATE`
|
||||
- 종결 조건: `REV-052`~`REV-058` 처리 완료, 영향 13개 operation 회귀, 36개 contract/mapping 유지,
|
||||
lint·diff와 dependency/DDL 무변경 확인
|
||||
|
||||
**최종 결론:** Phase 7 완료 판정 보류, 일곱 HTTP 계약 보완 후 통합 재판정 필요
|
||||
|
||||
**다음 Goal:** `P2-R10`.
|
||||
|
||||
## 19. FanTalk 답변 수정 계약 통합 영향 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD Feature F, OpenAPI 2.3.0, plan Phase 6·7
|
||||
- 검토 범위: 신규 FanTalk 답변 수정 계약, 기존 36개 operation/mapping 상태, `P7-R8` 종결 조건
|
||||
- 검증 방식: 문서·OpenAPI·controller mapping 정적 대조와 `./gradlew tasks --all` 프로젝트 인식 확인. 사용자 지시에
|
||||
따라 컴파일·테스트·lint는 실행하지 않았다.
|
||||
|
||||
### 통합 판정
|
||||
|
||||
- OpenAPI 계약은 기존 36개 `implemented` operation에 FanTalk 답변 수정 `planned` operation 1개를 추가해 총 37개다.
|
||||
- 현재 controller mapping은 36개이므로 신규 PUT이 구현되기 전 전체 계약 완료로 판정할 수 없다.
|
||||
- `REV-059`는 Phase 6 `Task 6.9` / `P6-R4`와 `P6-R4-GATE`가 소유한다.
|
||||
- 기존 미처리 `REV-052`~`REV-058`과 함께 최종 `P7-R8`에서 37개 operation/고유 operationId와 controller 37개
|
||||
mapping, 영향 14개 operation, 공통 ADMIN·오류·CORS, dependency·DDL·legacy/public 무변경을 재판정한다.
|
||||
- 별도 Phase 7 Task를 추가하지 않고 아직 미실행인 `Task 7.10` / `P7-R8`의 시작 조건과 완료 증거를 확장했다.
|
||||
|
||||
**최종 결론:** Phase 7 완료 판정 보류. `P6-R4-GATE`를 포함한 여덟 소유 Gate 후 37개 operation을 통합 재판정한다.
|
||||
|
||||
**다음 Goal:** `P2-R10`.
|
||||
|
||||
## 20. HTTP 경계 최종 통합 재판정 및 Gate — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD acceptance criteria, OpenAPI 2.3.0, plan `P2-R10`~`P7-R8-GATE`
|
||||
- 검토 범위: `REV-052`~`REV-059`, 37개 operation/mapping, 영향 14개 operation의 pagination·JSON-only·multipart part-level JSON/415·FanTalk 답변 수정 계약, dependency·DDL·legacy/public 변경 범위
|
||||
- 검증 방식: OpenAPI/controller 정적 대조, focused 회귀, 전체 회귀, lint, diff 확인
|
||||
|
||||
### 통합 판정
|
||||
|
||||
- OpenAPI는 operationId 37개, 고유 operationId 37개, `x-implementation-status=implemented` 37개다.
|
||||
- controller mapping은 37개로 OpenAPI operation 수와 일치한다.
|
||||
- `REV-052`~`REV-059`는 모두 소유 Phase Gate와 회귀 검증으로 처리 완료 상태다.
|
||||
- dependency·DDL 추가와 legacy/public API 변경은 없다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
- 영향 14개 operation과 공통 error/authorization focused 회귀: `BUILD SUCCESSFUL in 1m 26s`
|
||||
- 전체 회귀: `./gradlew test` → `BUILD SUCCESSFUL in 8m 8s`
|
||||
- lint: `./gradlew ktlintCheck` → `BUILD SUCCESSFUL in 1s`
|
||||
- OpenAPI/controller 정적 대조: 37개 operationId/status와 37개 controller mapping 일치
|
||||
- `git diff --check`: 출력 없음
|
||||
|
||||
**최종 결론:** Phase 7 HTTP 경계 통합 재판정 및 Gate 완료. AI 캐릭터 관리자 API 37개 operation 구현 완료.
|
||||
|
||||
**다음 Goal:** 없음.
|
||||
|
||||
## 23. 8차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD, plan, OpenAPI 37개 operation, API 계약 설명, Phase 1~6 최신 구현·테스트 소스
|
||||
- 검토 범위: endpoint/controller 집계, multipart 8개 operation, Phase별 신규 finding과 plan 상태
|
||||
- 기준 상태: 현재 working tree
|
||||
- 리뷰어/상태: Codex / 판정 완료
|
||||
- 검증 방식: 문서·코드·테스트 소스 정적 대조. 사용자 지시에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### Phase별 판정
|
||||
|
||||
| Phase | 판정 | finding / 후속 Goal |
|
||||
|---:|---|---|
|
||||
| 1 | 충족 | 신규 finding 없음 |
|
||||
| 2 | 보완 필요 | `REV-065` / `P2-R12` |
|
||||
| 3 | 보완 필요 | `REV-066` / `P3-R18` |
|
||||
| 4 | 보완 필요 | `REV-067`, `REV-068` / `P4-R9`, `P4-R10` |
|
||||
| 5 | 보완 필요 | `REV-069` / `P5-R10` |
|
||||
| 6 | 문서 보완 필요 | `REV-070` / `P6-R5` |
|
||||
| 7 | 통합 보완 필요 | `REV-071` / `P7-R10` |
|
||||
|
||||
### `REV-071` — Low — 완료 Gate와 finding 상태 불일치
|
||||
|
||||
- `P2-R11-GATE`, `P3-R17-GATE`, `P4-R8-GATE`, `P5-R9-GATE`, `P7-R9-GATE`는 완료 기록이 있고
|
||||
OpenAPI/controller/API 계약 설명도 37개 구현 완료로 동기화돼 있다.
|
||||
- 그러나 `plan-task.md` finding 표의 `REV-060`~`REV-062`, `REV-064`는 여전히 `확정`으로 남아 완료 상태와
|
||||
모순된다. 기존 완료 이력을 다시 열지 않고 8차 후속 Gate가 끝난 뒤 상태 표만 정리해야 한다.
|
||||
|
||||
### plan 전환
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 신규 Task | `Task 7.12` / `P7-R10` |
|
||||
| Gate | `P7-R10-GATE` |
|
||||
| 선행조건 | `P2-R12-GATE`, `P3-R18-GATE`, `P4-R9-GATE`, `P4-R10-GATE`, `P5-R10-GATE`, `P6-R5-GATE` |
|
||||
| 통합 검증 | 8개 multipart operation, Series 장르 ID, FanTalk 문서, OpenAPI/controller 37개, finding/Phase 상태 |
|
||||
| 범위 제한 | 신규 기능·route/schema·legacy/public·dependency·DDL 변경 없음 |
|
||||
|
||||
### 현재 통합 집계
|
||||
|
||||
- OpenAPI: 37개 operationId, 37개 `implemented`
|
||||
- controller mapping: Character 5 + AudioContent 10 + Series 10 + Community 8 + FanTalk 4 = 37
|
||||
- 이번 리뷰의 production/test/OpenAPI 변경: 없음
|
||||
- 이번 리뷰에서 실행한 컴파일·테스트: 없음
|
||||
- 문서 검증: `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 1s`, `git diff --check`는 출력 없이 성공
|
||||
|
||||
**최종 결론:** Phase 7 보완 필요 — Phase 2~6의 6개 소유 Task와 통합 Task 완료 전에는 전체 구현 완료로
|
||||
재판정할 수 없다.
|
||||
|
||||
**다음 Goal:** `P2-R12`부터 실행하고 마지막에 `P7-R10`으로 통합한다.
|
||||
|
||||
## 21. 7차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD acceptance criteria, plan Phase 1~7, OpenAPI 37개 operation
|
||||
- 검토 범위: Phase별 controller/facade/test, 8개 multipart schema와 runtime part binding,
|
||||
operation/mapping/status 및 계약 설명 문서
|
||||
- 검증 방식: `jq`, `rg`, diff 기반 정적 대조. 사용자 요청에 따라 컴파일과 테스트는 실행하지 않았다.
|
||||
|
||||
### Phase별 결과
|
||||
|
||||
| Phase | 판정 | finding / 후속 Goal |
|
||||
|---:|---|---|
|
||||
| 1 | 신규 finding 없음 | 공통 ADMIN 인가·resolver·오류/CORS 유지 |
|
||||
| 2 | 보완 필요 | `REV-060`, `P2-R11` / `P2-R11-GATE` |
|
||||
| 3 | 보완 필요 | `REV-061`, `P3-R17` / `P3-R17-GATE` |
|
||||
| 4 | 보완 필요 | `REV-062`, `P4-R8` / `P4-R8-GATE` |
|
||||
| 5 | 보완 필요 | `REV-063`, `P5-R9` / `P5-R9-GATE` |
|
||||
| 6 | 신규 finding 없음 | FanTalk 4개 operation 계약 유지 |
|
||||
| 7 | 보완·재판정 필요 | `REV-064`, `P7-R9` / `P7-R9-GATE` |
|
||||
|
||||
### `REV-064` — Low — 구현 현황 설명이 실제 37개 구현 상태보다 오래됨
|
||||
|
||||
- OpenAPI는 37개 operationId가 모두 고유하고 `x-implementation-status=implemented` 37개다.
|
||||
- 실제 controller mapping도 37개이며 FanTalk 답변 수정 PUT이 구현돼 있다.
|
||||
- 그러나 `plan-task.md` Endpoint Contract Summary는 여전히 36개 구현과 답변 수정 1개 `planned`,
|
||||
선행 보완 완료 전 상태를 기술한다.
|
||||
- `api-contract.md`도 상단 집계, endpoint 표, client 생성 설명에서 route 36개·구현 예정 1개로 남아 있다.
|
||||
- 후속 작업자가 완료 상태를 잘못 판단할 수 있지만 runtime 결함은 아니므로 Low로 판정한다.
|
||||
|
||||
### multipart 통합 판정
|
||||
|
||||
- OpenAPI의 Character·AudioContent·Series·Community 생성·수정 8개 schema는 모두
|
||||
`additionalProperties: false`다.
|
||||
- Phase 2~5 controller는 request part media type은 확인하지만 전체 part 이름 집합을 operation별 허용 목록과
|
||||
비교하지 않아 `REV-060`~`REV-063`을 확정했다.
|
||||
- OpenAPI와 production route/schema는 변경하지 않고 각 소유 Phase controller 경계에서 최소 보완한다.
|
||||
|
||||
### plan 전환 및 종결 조건
|
||||
|
||||
- 소유 Phase 순서:
|
||||
`P2-R11` → `P2-R11-GATE` → `P3-R17` → `P3-R17-GATE` → `P4-R8` → `P4-R8-GATE` →
|
||||
`P5-R9` → `P5-R9-GATE`
|
||||
- 통합 재판정: `Task 7.11` / `P7-R9`, Gate `P7-R9-GATE`
|
||||
- 종결 조건: 미정의 multipart part 400/no-side-effect, 기존 정상/필수/415 회귀, 37개
|
||||
operation/mapping/implemented 일치, `plan-task.md`·`api-contract.md` 구현 상태 동기화
|
||||
|
||||
**최종 결론:** Phase 7 완료 판정 보류. `REV-060`~`REV-064` 처리 후 통합 재판정이 필요하다.
|
||||
|
||||
**다음 Goal:** `P2-R11`.
|
||||
|
||||
## 22. multipart part 이름·문서 상태 최종 통합 재판정 및 Gate — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD acceptance criteria, OpenAPI 37개 operation, plan `P2-R11`~`P7-R9-GATE`
|
||||
- 검토 범위: `REV-060`~`REV-064`, 8개 multipart schema의 part allow-list, 37개 operation/mapping/status, 계약 설명 문서 상태
|
||||
- 검증 방식: OpenAPI/controller 정적 대조, Phase 2~5 소유 Gate 증거 대조, 문서 diff 확인
|
||||
|
||||
### 통합 판정
|
||||
|
||||
- `REV-060`~`REV-063`은 각 소유 Phase Gate에서 처리 완료됐다.
|
||||
- OpenAPI의 8개 multipart schema는 모두 `additionalProperties: false`이며, runtime allow-list와 actual endpoint 회귀가 이를 따른다.
|
||||
- OpenAPI는 operation 37개, 고유 operationId 37개, `implemented` 37개, `alignment-required` 0개, `planned` 0개다.
|
||||
- controller mapping은 Character 5, AudioContent 10, Series 10, Community 8, FanTalk 4로 총 37개다.
|
||||
- `api-contract.md`의 상단 집계, FanTalk 답변 수정 endpoint 상태, client 생성 설명을 37개 구현 완료/예정 0개로 동기화했다.
|
||||
- dependency·DDL 추가와 legacy/public API 변경은 없다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
- OpenAPI operation/status 집계: `operations=37 uniqueOperationIds=37 implemented=37 alignmentRequired=0 planned=0`
|
||||
- controller mapping 집계: Character 5 + AudioContent 10 + Series 10 + Community 8 + FanTalk 4 = 37
|
||||
- 8개 multipart schema 집계: Character/Series `{image, request}`, AudioContent create `{contentFile, coverImage, request}`, AudioContent update `{coverImage, request}`, Community create `{audioFile, postImage, request}`, Community update `{postImage, request}`, 모두 `additionalProperties=false`
|
||||
- Phase 2~5 Gate 회귀: focused/영향 범위 회귀와 `ktlintCheck` 성공 기록 대조 완료
|
||||
- `git diff --check`: 출력 없음
|
||||
|
||||
**최종 결론:** Phase 7 multipart part 이름·문서 상태 통합 재판정 및 Gate 완료. AI 캐릭터 관리자 API 37개 operation 구현 완료.
|
||||
|
||||
## 24. 8차 후속 통합 Gate 완료 — 2026-07-29
|
||||
|
||||
### 통합 판정
|
||||
|
||||
- `REV-065`~`REV-069`는 Phase 2~5 controller가 파일 map과 servlet 전체 part 이름을 모두 operation별 allow-list와 대조하도록 보완해 처리 완료됐다.
|
||||
- `REV-068`은 Series 생성·수정의 `genreId <= 0`을 legacy 호출 전 400으로 거부하도록 보완해 처리 완료됐다.
|
||||
- `REV-070`은 FanTalk 비활성 팬 root 삭제를 OpenAPI·구현·테스트와 같은 200 no-op 계약으로 PRD와 `api-contract.md`에 동기화해 처리 완료됐다.
|
||||
- `REV-071`은 plan finding 표와 상단 Phase 상태를 실제 완료 상태로 동기화해 처리 완료됐다.
|
||||
|
||||
### 검증 결과
|
||||
|
||||
- RED: Phase 2~5 multipart 일반 form-field와 Phase 4 `genreId <= 0` focused RED 묶음에서 신규 multipart/genre 36건 실패.
|
||||
- GREEN: 같은 focused 묶음 재실행 `BUILD SUCCESSFUL in 1m 17s`.
|
||||
- 통합 회귀: ai-character admin character/content/series/community/fantalk focused와 authorization/error/token 회귀 `BUILD SUCCESSFUL in 4m 11s`.
|
||||
- 정적 검증: OpenAPI `operations=37 uniqueOperationIds=37 implemented=37 alignmentRequired=0 planned=0`, controller mapping 37개, FanTalk 삭제 no-op 정적 대조 완료.
|
||||
- 정적 품질: `./gradlew ktlintCheck` `BUILD SUCCESSFUL in 51s`, `git diff --check` 출력 없음.
|
||||
|
||||
**최종 결론:** Phase 7 8차 후속 Gate 완료. AI 캐릭터 관리자 API 37개 operation 구현 완료 상태와 문서 상태가 일치한다.
|
||||
|
||||
**남은 항목:** 없음.
|
||||
|
||||
**다음 Goal:** 없음.
|
||||
|
||||
## 25. 9차 통합 정적 리뷰 및 판정 — 2026-07-29
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD acceptance criteria, plan Phase 1~7, OpenAPI 37개 operation
|
||||
- 검토 범위: Phase별 최신 production/test 소스, operation/controller 집계, 신규·미처리 finding과 문서 상태
|
||||
- 검증 방식: `rg`·`sed`·`jq` 기반 정적 대조. 사용자 지시에 따라 Gradle·컴파일·테스트는 실행하지 않았다.
|
||||
|
||||
### Phase별 결과
|
||||
|
||||
| Phase | 판정 | 신규 finding/후속 |
|
||||
|---:|---|---|
|
||||
| 1 | 추가 수정 없음 | 없음 |
|
||||
| 2 | 추가 수정 없음 | 없음 |
|
||||
| 3 | 완료 | `REV-072` 처리 완료 |
|
||||
| 4 | 추가 수정 없음 | 없음 |
|
||||
| 5 | 추가 수정 없음 | 없음 |
|
||||
| 6 | 추가 수정 없음 | 없음 |
|
||||
| 7 | 완료 | `P7-R11` / `P7-R11-GATE` 완료 |
|
||||
|
||||
### 통합 판정
|
||||
|
||||
- OpenAPI JSON 문법, operation 37개, 고유 operationId 37개, `x-implementation-status=implemented` 37개는
|
||||
정적으로 확인했다.
|
||||
- `REV-072`는 Phase 3에서 처리 완료됐다. v2 actual endpoint의 preview 오류 3종 KO/EN/JA, no-side-effect,
|
||||
정상 preview metadata, legacy 생성 회귀가 통과했다.
|
||||
- OpenAPI implemented count는 37개이고 controller mapping은 파일별 4/5/10/9/8/1 합계 37개다. 신규 dependency·DDL 변경은 없다.
|
||||
- 기존 완료 이력은 변경하지 않고 `Task 3.29`와 그 후속 `Task 7.13`만 완료로 동기화했다.
|
||||
|
||||
**최종 결론:** Phase 7 통합 재판정 및 Gate 완료. AI 캐릭터 관리자 API 37개 operation 구현 완료.
|
||||
|
||||
**다음 Goal:** 없음.
|
||||
|
||||
## 26. 10차 통합 정적 리뷰 및 판정 — 2026-07-30
|
||||
|
||||
### 리뷰 범위와 방식
|
||||
|
||||
- 기준 문서: PRD acceptance criteria, plan Phase 1~7, OpenAPI 37개 operation
|
||||
- 검토 범위: Phase별 최신 production/test 소스, operation/controller 집계, 내부 `$ref`, finding·Task 상태
|
||||
- 검증 방식: `rg`·`sed`·`jq` 기반 정적 대조. 사용자 지시에 따라 Gradle·컴파일·테스트는 실행하지 않았다.
|
||||
|
||||
### Phase별 결과
|
||||
|
||||
| Phase | 판정 | 신규 finding/후속 |
|
||||
|---:|---|---|
|
||||
| 1 | 충족 | 없음 |
|
||||
| 2 | 충족 | 없음 |
|
||||
| 3 | 충족 | 없음 |
|
||||
| 4 | 충족 | 없음 |
|
||||
| 5 | 충족 | 없음 |
|
||||
| 6 | 충족 | 없음 |
|
||||
| 7 | 완료 유지 | 없음 |
|
||||
|
||||
### 통합 판정
|
||||
|
||||
- OpenAPI JSON과 내부 `$ref`가 유효하고 operation 37개·고유 operationId 37개·`implemented` 37개다.
|
||||
- controller mapping은 Character 5, AudioContent 10, Series 10, Community 8, FanTalk 4로 총 37개다.
|
||||
- 기존 `REV-001`~`REV-072`는 모두 `처리 완료`이며 신규 확정 finding과 미완료 Task/Gate가 없다.
|
||||
- Phase별 완료 수와 계획 상태가 실제 구현 현황과 일치하므로 신규 회귀 수정 Task/Goal을 추가하지 않는다.
|
||||
|
||||
**최종 결론:** Phase 7 통합 완료 상태 유지. AI 캐릭터 관리자 API는 문서 기준 37개 operation 구현 완료다.
|
||||
|
||||
**다음 Goal:** 없음.
|
||||
@@ -1,410 +0,0 @@
|
||||
# 무료 콘텐츠 포인트 결제 불가 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 리뷰 후속 작업 완료 |
|
||||
| 작성일 | 2026-07-31 |
|
||||
| 요구사항 기준 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md` |
|
||||
| API 기준 | PRD `8. API 계약` |
|
||||
| 현재 Phase | Phase 1 회귀 수정 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
무료 오디오 콘텐츠가 소비자 조회에서 포인트 결제 가능으로 노출되거나 POINT 전용 목록에 포함되지 않게 한다.
|
||||
|
||||
## 구현 방식
|
||||
|
||||
- 응답 DTO의 공개 필드와 내부 조회 record는 변경하지 않는다.
|
||||
- 각 소비자 응답 조립 지점에서 `isPointAvailable && price > 0`을 적용한다.
|
||||
- 추천과 전체 탭 POINT repository 조건에 `price > 0`을 추가한다.
|
||||
- 관리자 mapper와 저장 로직은 수정하지 않는다.
|
||||
- 단일 식 적용을 위한 새 공통 abstraction이나 dependency는 만들지 않는다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1. 소비자 응답 정규화 | 완료 | `3/3` | 없음 | 없음 |
|
||||
| 2. POINT 조회 조건 보정 | 완료 | `1/1` | 없음 | 없음 |
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
|
||||
- goal에는 token budget을 설정하지 않는다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- legacy 콘텐츠 상세 `isAvailableUsePoint` 보정
|
||||
- v2 콘텐츠 overview·전체 탭·추천 탭 응답 보정
|
||||
- v2 홈 추천 첫 오디오 응답 보정
|
||||
- v2 크리에이터 채널 홈·오디오·라이브의 공통 오디오 응답 보정
|
||||
- 추천 `pointAudios`와 전체 탭 `type=POINT`의 유료 조건 보강
|
||||
- 전체 탭 POINT count·pagination 회귀 검증
|
||||
- AI 캐릭터 관리자 조회 원본값 유지 회귀 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- DB 저장값과 기존 데이터 변경
|
||||
- 콘텐츠 생성·수정 validation 변경
|
||||
- 관리자 콘텐츠 mapper·service 변경
|
||||
- legacy 콘텐츠 상세 이외의 legacy 조회 API 변경
|
||||
- 결제·주문·포인트 차감 로직 변경
|
||||
- API schema와 dependency 변경
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 기술 스택: Kotlin, Java 17, Spring Boot 2.7.14, JUnit 5, QueryDSL/JPA.
|
||||
- 기존 package와 mapper/repository 책임을 유지한다.
|
||||
- 공개 API 필드명 `isAvailableUsePoint`, `isPointAvailable`을 유지한다.
|
||||
- `price == 0`은 무료, 실질 포인트 가능 여부는 `configured && price > 0`으로 고정한다.
|
||||
- POINT 조회의 목록과 count는 같은 repository 조건을 사용한다.
|
||||
- 모든 구현 Task는 `RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR` 순서로 실행한다.
|
||||
- focused test부터 실행하고 최종 Gate에서 영향 범위 회귀와 전체 `test`를 실행한다.
|
||||
|
||||
## Phase 1: 소비자 응답 정규화
|
||||
|
||||
**Phase 결과:** 무료·저장값 true인 콘텐츠가 대상 소비자 응답에서는 false로 보이지만 관리자 조회에서는 true를 유지한다.
|
||||
|
||||
**선행조건:** PRD `POINT-001~004`, `POINT-007~008` 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, 검증 기록 누적.
|
||||
|
||||
**후속 리뷰 완료 조건:** `P1-R1` 완료, `P1-GATE` 재검증과 수정 후 검증 기록 누적.
|
||||
|
||||
### Task 1.1 소비자 응답의 실질 포인트 가능 여부 적용
|
||||
|
||||
**Goal 실행 `P1-T1`:** legacy 상세와 모든 대상 v2 응답 조립 경계에서 가격을 반영한 포인트 가능 여부를 반환한다.
|
||||
|
||||
- **시작 조건:** PRD `DEC-001`, `DEC-003` 확인.
|
||||
- **완료 증거:** RED/GREEN 체크박스, 대상 mapper 테스트, 관리자 원본값 회귀 테스트와 Progress 기록.
|
||||
- **범위 밖:** POINT 전용 repository 필터와 콘텐츠 저장값 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 각 mapper가 이미 받는 `price: Int`와 저장된 Boolean 필드.
|
||||
- Produces: schema를 바꾸지 않고 `configured && price > 0`으로 보정된 소비자 응답 Boolean.
|
||||
- Preserves: AI 캐릭터 관리자 목록·상세의 원본 Boolean.
|
||||
|
||||
- [x] **RED:** 각 응답 경계에 무료·저장값 true fixture를 추가하고 소비자 응답은 false, 유료·저장값 true 응답은 true로 기대한다. 관리자 상세에는 무료·저장값 true가 true로 유지되는 회귀 assertion을 추가한다.
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 소비자 응답이 현재 true를 전달하여 발생하는 assertion 실패를 확인하고, 관리자 회귀 assertion은 기존 동작으로 통과하는지 구분해 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponseTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest'
|
||||
```
|
||||
|
||||
- [x] **GREEN:** 소비자 응답 조립 지점의 Boolean 대입을 `storedValue && price > 0`으로 바꾼다. `AiCharacterAdminAudioContentMapper`와 관리자 service는 수정하지 않는다.
|
||||
- [x] **GREEN 확인:** 같은 focused test 명령을 다시 실행해 무료 true → false, 유료 true → true, 관리자 무료 true → true가 모두 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 동일한 한 줄 식을 유지하고 새 helper나 구조 변경을 추가하지 않는다. 변경 파일에 `./gradlew ktlintCheck`를 실행하고 결과를 Progress에 기록한다.
|
||||
|
||||
### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 대상 소비자 응답 계약과 관리자 제외 계약을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** 아래 명령 성공, 공개 필드명 유지 확인과 Progress 기록.
|
||||
- **범위 밖:** POINT repository 필터 구현과 관련 없는 응답 리팩터링.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.*' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.home.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** 대상 소비자 응답에서 무료 콘텐츠가 포인트 가능으로 노출되지 않고 기존 JSON 필드 집합이 유지된다.
|
||||
|
||||
### Task 1.2 소비자·관리자 포인트 가능 계약의 회귀 증거 보강
|
||||
|
||||
**Goal 실행 `P1-R1`:** 소비자 응답의 저장값 false 조건과 무료 관리자 응답의 원본값 유지 조건을 자동 회귀 테스트로 증명한다.
|
||||
|
||||
- **시작 조건:** `REV-P1-001`, `REV-P1-002` 확정과 기존 `P1-GATE` 완료.
|
||||
- **완료 증거:** 대상 소비자 경계의 `price > 0, storedIsPointAvailable == false` assertion, 관리자 목록·상세의
|
||||
`price == 0, storedIsPointAvailable == true` assertion, focused test와 `P1-GATE` 재검증 기록.
|
||||
- **범위 밖:** production 코드 변경, 관리자 응답 보정, 공개 API schema 변경.
|
||||
- **TDD 예외 사유:** production 구현은 정적 검토상 계약을 이미 충족하며, 누락된 것은 완료 근거인 회귀 assertion이다.
|
||||
의도적인 production 결함을 만들어 RED를 재현하지 않는다.
|
||||
- **대체 검증 방법:** 기존 fixture가 누락한 진리표 조건을 추가하고 focused test와 `P1-GATE`를 통과시킨 뒤 mapper 식과
|
||||
관리자 원본 전달 식을 다시 대조한다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponseTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponseTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/application/AudioRecommendationFacadeTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponseTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt`
|
||||
- Modify: `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`
|
||||
|
||||
- [x] 대상 소비자 응답 경계마다 `price > 0, storedIsPointAvailable == false`가 false로 유지되는 assertion을 추가한다.
|
||||
- [x] AI 캐릭터 관리자 목록·상세 fixture를 `price == 0, storedIsPointAvailable == true`로 구성하고 저장값 true가 그대로
|
||||
반환되는지 확인한다.
|
||||
- [x] Task 1.1 focused test와 `P1-GATE`를 재실행하고 결과를 Progress에 누적한다.
|
||||
- [x] `REV-P1-001`, `REV-P1-002`의 수정 후 검증 기록과 이 문서의 현재 상태를 갱신한다.
|
||||
|
||||
### Task 1.3 legacy 상세의 유료 포인트 가능 positive 회귀 증거 보강
|
||||
|
||||
**Goal 실행 `P1-R2`:** legacy 상세에서 `price > 0, storedIsPointAvailable == true`가 true를 반환하는 계약을 자동 회귀 테스트로 증명한다.
|
||||
|
||||
- **시작 조건:** `REV-P1-003` 확정과 `P1-R1` 완료.
|
||||
- **완료 증거:** legacy 상세의 무료·저장값 true → false, 유료·저장값 false → false,
|
||||
유료·저장값 true → true assertion, focused test와 `P1-GATE` 재검증 기록.
|
||||
- **범위 밖:** production 코드, v2 응답 test, 관리자 mapper/service 변경.
|
||||
- **TDD 예외 사유:** production 구현은 정적 검토상 positive 계약을 이미 충족하며, 누락된 것은 회귀 assertion이다.
|
||||
의도적인 production 결함을 만들어 RED를 재현하지 않는다.
|
||||
- **대체 검증 방법:** 기존 legacy 상세 test의 진리표를 완성하고 focused test와 `P1-GATE`를 통과시킨다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
- Modify: `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md`
|
||||
|
||||
- [x] legacy 상세 test에 `price > 0, storedIsPointAvailable == true` 응답이 true인 assertion을 추가한다.
|
||||
- [x] 하나의 test가 세 계약 조건을 드러내도록 `DisplayName`과 test 함수명을 맞춘다.
|
||||
- [x] `AudioContentServiceTest`, `P1-GATE`, `ktlintCheck`를 실행하고 결과를 Progress에 누적한다.
|
||||
- [x] `REV-P1-003`의 수정 후 검증 기록과 이 문서의 현재 상태를 갱신한다.
|
||||
|
||||
## Phase 2: POINT 조회 조건 보정
|
||||
|
||||
**Phase 결과:** 추천과 전체 탭 POINT 목록, count와 pagination 후보에서 무료 콘텐츠가 제외된다.
|
||||
|
||||
**선행조건:** `P1-GATE` 완료와 PRD `POINT-005~006` 확정.
|
||||
|
||||
**Phase 완료 조건:** `P2-T1`과 `P2-GATE` 완료, 검증 기록 누적.
|
||||
|
||||
### Task 2.1 추천·전체 탭 POINT 조회에 유료 조건 적용
|
||||
|
||||
**Goal 실행 `P2-T1`:** 두 POINT 조회가 `isPointAvailable == true && price > 0` 조건을 공통으로 사용한다.
|
||||
|
||||
- **시작 조건:** `P1-GATE` 완료, PRD `DEC-002` 확인.
|
||||
- **완료 증거:** repository RED/GREEN, endpoint E2E와 Progress 기록.
|
||||
- **범위 밖:** FREE/AUDIO/추천 점수·랜덤 정렬·공개/성인/차단 조건 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 기존 `audioContent.isPointAvailable`과 `audioContent.price` QueryDSL 필드.
|
||||
- Produces: 추천 `pointAudios`, 전체 탭 POINT 목록과 count에 공통 적용되는 `isPointAvailable.isTrue.and(price.gt(0))` 조건.
|
||||
- Preserves: FREE 목록은 `price.eq(0)`, AUDIO 목록은 `price.gt(0)`인 기존 조건.
|
||||
|
||||
- [x] **RED:** 두 repository fixture에 `price = 0, isPointAvailable = true`와 `price > 0, isPointAvailable = true`를 함께 두고 POINT 결과에는 유료 항목만 포함되도록 기대한다. 전체 탭 E2E는 `totalCount`, `audios`, `hasNext`가 같은 후보 집합을 반영하도록 기대한다.
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 무료 true 콘텐츠가 POINT 결과에 포함되어 발생하는 목록 또는 count assertion 실패를 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.content.all.adapter.out.persistence.DefaultMainContentAllQueryRepositoryTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.all.adapter.in.web.MainContentAllEndToEndTest'
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `findPointAudios` 조건과 `optionalAudioPointCondition`에 `price.gt(0)`을 결합한다. 전체 탭 count와 목록은 기존 `audioCondition`을 계속 공유한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 POINT 결과에서 무료가 제외되고 유료 true는 유지되며 `totalCount`와 `hasNext`가 일치하는지 확인한다.
|
||||
- [x] **REFACTOR:** FREE/AUDIO 조건, 랜덤/가격/인기 정렬과 공통 visibility 조건이 바뀌지 않았는지 직접 영향 회귀와 `ktlintCheck`로 확인한다.
|
||||
|
||||
### Phase 2 Gate
|
||||
|
||||
**Goal 실행 `P2-GATE`:** 소비자 응답과 POINT 조회의 전체 요구사항을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P2-T1` 완료.
|
||||
- **완료 증거:** focused·전체 회귀·lint 성공, 문서 Progress와 최종 검증 기록.
|
||||
- **범위 밖:** 실패를 숨기기 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.content.all.*' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.content.*'
|
||||
./gradlew test
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** 모든 테스트와 lint가 성공하고 무료 콘텐츠가 어떤 대상 소비자 응답이나 POINT 전용 결과에서도 포인트 결제 가능으로 취급되지 않는다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | 없음 | 아니요 | 실패 응답 경계와 fixture를 다시 대조 |
|
||||
| 2 | `P1-GATE` | `P1-T1` | 아니요 | 실패 소유 mapper의 회귀 수정 goal 추가 |
|
||||
| 3 | `P2-T1` | `P1-GATE` | 아니요 | 목록·count 조건 공유 여부 확인 |
|
||||
| 4 | `P2-GATE` | `P2-T1` | 아니요 | 실패 소유 repository 또는 mapper로 되돌림 |
|
||||
| 5 | `P1-R1` | Phase별 review 판정 완료 | 아니요 | 누락된 계약 fixture와 assertion 범위를 다시 대조 |
|
||||
| 6 | `P1-R2` | `P1-R1` | 아니요 | legacy 상세의 세 계약 조건을 다시 대조 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-GATE → P2-T1 → P2-GATE
|
||||
```
|
||||
|
||||
리뷰 후속 실행 순서:
|
||||
|
||||
```text
|
||||
P1-R1 → P1-R2
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 관리자 조회와 DB 저장값을 보정하지 않는다.
|
||||
- 공개 DTO 필드를 추가·삭제·이름 변경하지 않는다.
|
||||
- POINT 조건 보강을 FREE/AUDIO/추천 점수·정렬 변경으로 확장하지 않는다.
|
||||
- 새 dependency, schema migration과 공통 abstraction을 추가하지 않는다.
|
||||
- 기존 완료 문서의 체크박스와 검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||
- test를 삭제·skip·완화해 Gate를 통과시키지 않는다.
|
||||
|
||||
## Progress
|
||||
|
||||
실제 구현 시 기존 기록을 삭제하거나 덮어쓰지 않고 Goal 실행 결과를 차수별로 누적한다.
|
||||
|
||||
### 문서 작성 검증 — 2026-07-31
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 확정 요구사항을 새 PRD와 goal 실행형 계획에 기록하고 기존 추천·전체 탭 문서의 충돌 계약을 정정했다.
|
||||
- 왜: 구현 전에 단일 기준 문서, 범위, 제외 조건과 완료 증거를 확정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `rg` placeholder·요구사항/Goal 추적 검색 — placeholder 없음, `POINT-001~008`과 `P1-T1`·`P2-T1` 연결 확인.
|
||||
- 계획에 기록된 production 파일 존재 확인 — 누락 없음.
|
||||
- `git diff --check` — 출력 없음.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- 구현 test: 문서만 변경했으므로 실행하지 않았다.
|
||||
- 남은 항목: `P1-T1`부터 구현 실행.
|
||||
- 다음 행동: 사용자가 구현을 요청하면 `P1-T1`의 RED부터 시작한다.
|
||||
|
||||
### 구현 검증 — 2026-07-31
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 무료 콘텐츠 포인트 결제 불가 정책을 대상 소비자 응답과 POINT 전용 조회 조건에 적용했다.
|
||||
- 왜: `price == 0` 콘텐츠가 포인트 결제 가능 상태와 동시에 노출되거나 POINT 목록·개수·페이징 후보에 포함되지 않도록 하기 위해서다.
|
||||
- 어떻게:
|
||||
- P1 RED focused test — 소비자 응답 경계에서 무료·저장값 true가 기존 true로 전달되어 assertion 실패 확인.
|
||||
- P1 GREEN focused test — `BUILD SUCCESSFUL`, 무료 true → false, 유료 true → true, 관리자 상세 원본 true 유지 확인.
|
||||
- P1-GATE — 원 wildcard 명령은 600초 제한 초과로 분할 실행했고, content 범위 `BUILD SUCCESSFUL`(9m31s), creator/home 범위 `BUILD SUCCESSFUL`(4m27s), `./gradlew ktlintCheck` `BUILD SUCCESSFUL`(1m02s).
|
||||
- P2 RED focused test — recommendation/main-all repository와 E2E에서 무료·저장값 true가 POINT 후보에 포함되어 4개 assertion 실패 확인.
|
||||
- P2 GREEN focused test — `BUILD SUCCESSFUL`, 추천 `pointAudios`와 전체 탭 POINT 목록·`totalCount`·`hasNext`가 유료 포인트 후보만 반영함을 확인.
|
||||
- P2-GATE 영향 범위 — `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.content.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.content.all.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*'` `BUILD SUCCESSFUL`(1m48s).
|
||||
- 최종 Gate — `./gradlew test` `BUILD SUCCESSFUL`(13m27s), `./gradlew ktlintCheck` `BUILD SUCCESSFUL`(40s).
|
||||
- 결정: 관리자 mapper/service, DB 저장값, 공개 DTO 필드명과 구조는 변경하지 않았다.
|
||||
- 남은 항목: 없음.
|
||||
|
||||
### Phase별 리뷰 — 2026-07-31
|
||||
|
||||
- 상태: Phase 1 수정 goal 필요, Phase 2 확정 발견 사항 없음.
|
||||
- 무엇을: PRD, 구현 계획, staged production/test diff와 관련 호출 경계를 Phase별로 대조했다.
|
||||
- 왜: 완료 체크와 실제 계약 증거가 일치하는지 판정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `git diff --cached --check` — 출력 없음.
|
||||
- `rg`로 대상 응답 mapper, POINT repository 조건과 관련 test assertion을 대조했다.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- 사용자 지시에 따라 compile과 test는 다시 실행하지 않았으며 기존 구현 검증 기록을 근거로만 확인했다.
|
||||
- 후속: `REV-P1-001`, `REV-P1-002`를 `P1-R1`로 전환했다.
|
||||
- 리뷰 문서:
|
||||
- `docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-1-review.md`
|
||||
- `docs/20260731_무료_콘텐츠_포인트_결제_불가/reviews/phase-2-review.md`
|
||||
|
||||
### 리뷰 후속 검증 — 2026-07-31
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `REV-P1-001`, `REV-P1-002`의 누락된 회귀 증거를 `P1-R1`로 보강했다.
|
||||
- 왜: 소비자 응답의 저장값 false 조건과 AI 캐릭터 관리자 무료 원본값 유지 조건을 자동 테스트로 고정하기 위해서다.
|
||||
- 어떻게:
|
||||
- P1-R1 focused test — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.overview.dto.ContentOverviewPageResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.all.dto.MainContentAllTabResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.recommendation.application.AudioRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.common.dto.CreatorChannelAudioContentResponseTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.content.AiCharacterAdminAudioContentControllerTest'` `BUILD SUCCESSFUL`(3m11s).
|
||||
- P1-GATE — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` `BUILD SUCCESSFUL`(12m15s).
|
||||
- `./gradlew ktlintCheck` — `BUILD SUCCESSFUL`(47s).
|
||||
- 결정: production 코드, 관리자 mapper/service, 공개 DTO schema는 변경하지 않았다.
|
||||
- 남은 항목: 없음.
|
||||
|
||||
### 2차 Phase 1 리뷰 — 2026-07-31
|
||||
|
||||
- 상태: `REV-P1-001`, `REV-P1-002` 수정 확인, 추가 수정 goal 필요.
|
||||
- 무엇을: `P1-R1` test diff, Phase 1 production mapper, 관리자 제외 경계와 완료 기록을 다시 대조했다.
|
||||
- 왜: 기존 확정 발견 사항이 실제로 수정됐는지, 계약 진리표에 다른 누락은 없는지 판정하기 위해서다.
|
||||
- 어떻게:
|
||||
- staged diff와 `rg`로 6개 소비자 조립 경계의 유료·저장값 false assertion을 확인했다.
|
||||
- 관리자 목록·상세의 `price == 0, stored == true` fixture와 true assertion을 확인했다.
|
||||
- `git diff --check`, `git diff --cached --check` — 출력 없음.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- 사용자 지시에 따라 compile과 test는 다시 실행하지 않았다.
|
||||
- 후속: legacy 상세의 유료·저장값 true positive assertion 누락을 `REV-P1-003`, `P1-R2`로 전환했다.
|
||||
|
||||
### 리뷰 후속 검증 2차 — 2026-07-31
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `REV-P1-003`의 누락된 legacy 상세 positive 회귀 증거를 `P1-R2`로 보강했다.
|
||||
- 왜: legacy 상세에서 무료·저장값 true, 유료·저장값 false, 유료·저장값 true의 포인트 사용 가능 계약을 하나의 test로 고정하기 위해서다.
|
||||
- 어떻게:
|
||||
- P1-R2 focused test — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest'` `BUILD SUCCESSFUL`(14s).
|
||||
- P1-GATE 단위 범위 — `./gradlew test --tests 'kr.co.vividnext.sodalive.content.AudioContentServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*'` `BUILD SUCCESSFUL`(2m 26s).
|
||||
- `./gradlew ktlintCheck` — `BUILD SUCCESSFUL`(14s).
|
||||
- 결정: production 코드, 관리자 mapper/service, 공개 DTO schema는 변경하지 않았다.
|
||||
- 전체 테스트: 사용자 지시에 따라 실행하지 않았다.
|
||||
- 남은 항목: 없음.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-07-31 | `PLAN-DEC-001` | 확정 | 응답 조립 지점에서 `configured && price > 0`을 직접 적용하고 새 abstraction을 만들지 않는다 | 기존 mapper 책임과 최소 변경 원칙 | `P1-T1` |
|
||||
| 2026-07-31 | `PLAN-DEC-002` | 확정 | POINT repository 조건에 `price > 0`을 결합하고 전체 탭 목록과 count의 기존 조건 공유 구조를 유지한다 | 목록·count 일관성 | `P2-T1` |
|
||||
| 2026-07-31 | `PLAN-DEC-003` | 확정 | 여러 소비자 API 경계를 변경하므로 최종 Gate에서 전체 `test`를 실행한다 | 영향 범위 회귀 증거 필요 | `P2-GATE` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
초기 구현 시 발견된 문제 없음.
|
||||
|
||||
### 2026-07-31 Phase별 리뷰
|
||||
|
||||
- `REV-P1-001` — 소비자 응답 테스트에 `price > 0, storedIsPointAvailable == false` 계약 증거가 없다.
|
||||
- `REV-P1-002` — 관리자 회귀 테스트가 무료 fixture로 저장값 유지 계약을 검증하지 않는다.
|
||||
- `REV-P1-003` — legacy 상세 test가 유료·저장값 true의 positive 계약을 검증하지 않는다.
|
||||
- 후속 Goal: `P1-R1`
|
||||
- 처리: 2026-07-31 `P1-R1` 완료.
|
||||
- 추가 후속 Goal: `P1-R2`
|
||||
- 처리: 2026-07-31 `P1-R2` 완료.
|
||||
- Phase 2: 확정 발견 사항 없음.
|
||||
|
||||
## 최종 보고 형식
|
||||
|
||||
```markdown
|
||||
구현 결과: 무료 콘텐츠 포인트 결제 불가 정책을 소비자 응답과 POINT 조회에 적용
|
||||
|
||||
- 변경: 대상 응답 mapper와 추천·전체 탭 POINT 조회 조건
|
||||
- 결정: 관리자·DB 원본 유지, 공개 schema 유지
|
||||
- 검증: focused test, 영향 범위 회귀, 전체 test, ktlintCheck 결과
|
||||
- 남은 항목: 없음 또는 실패·외부 조건
|
||||
- 문서: PRD와 이 plan-task.md의 Progress·검증 기록
|
||||
```
|
||||
@@ -1,171 +0,0 @@
|
||||
# PRD: 무료 콘텐츠 포인트 결제 불가
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-07-31 |
|
||||
| 최종 수정일 | 2026-07-31 |
|
||||
| 대상 제품 | 소비자용 오디오 콘텐츠 조회 API |
|
||||
| 작성자·결정권자 | 사용자 |
|
||||
| 관련 API Contract | 별도 문서 없음. 이 문서의 `8. API 계약`을 기준으로 사용 |
|
||||
| 관련 구현 계획 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/plan-task.md` |
|
||||
| 관련 review | `reviews/phase-1-review.md`, `reviews/phase-2-review.md` |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
무료 오디오 콘텐츠와 포인트 결제 가능 상태가 소비자 화면에서 동시에 노출되지 않도록 조회 계약을 보정한다.
|
||||
저장된 포인트 결제 가능 설정은 유지하되, 소비자용 응답과 포인트 전용 목록에서는 가격을 함께 반영한 실질 상태를 사용한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- 현재 일부 조회 응답은 `price == 0`이면서 저장된 `isPointAvailable == true`인 콘텐츠를 그대로 포인트 결제 가능으로 노출한다.
|
||||
- 포인트 추천과 전체 탭 POINT 조회는 저장된 `isPointAvailable`만 필터링해 무료 콘텐츠가 포함될 수 있다.
|
||||
- 무료와 포인트 결제 가능 상태가 함께 노출되면 클라이언트의 가격 표시와 결제 진입 판단이 서로 모순될 수 있다.
|
||||
|
||||
문제를 해결했다는 판단은 소비자용 모든 대상 응답에서 무료 콘텐츠의 포인트 결제 가능 여부가 `false`이고,
|
||||
POINT 전용 목록과 개수에서 무료 콘텐츠가 제외되는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 무료 콘텐츠의 소비자용 포인트 결제 가능 여부를 항상 `false`로 응답한다.
|
||||
- 유료이면서 저장된 포인트 결제 가능 설정이 `true`인 콘텐츠는 기존처럼 `true`로 응답한다.
|
||||
- 포인트 전용 목록, 전체 개수와 페이징 판단에서 무료 콘텐츠를 제외한다.
|
||||
- 기존 공개 API 필드명, 응답 구조와 DB 저장값을 변경하지 않는다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 콘텐츠 생성·수정 시 `isPointAvailable` 저장값을 강제로 변경하지 않는다.
|
||||
- 기존 데이터의 일괄 수정이나 DB migration을 수행하지 않는다.
|
||||
- `/api/v2/admin/ai-characters/**/audio-contents` 관리자 목록·상세의 저장값 표현을 변경하지 않는다.
|
||||
- `/audio-content/{id}` 상세를 제외한 legacy 목록·추천·랭킹 API는 변경하지 않는다.
|
||||
- 콘텐츠 구매·대여·소장·포인트 차감 로직은 변경하지 않는다.
|
||||
- 공개 DTO의 필드 추가·삭제·이름 변경을 수행하지 않는다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 적용 범위 |
|
||||
|---|---|---|---|
|
||||
| 소비자 | 무료 콘텐츠를 포인트 결제 대상으로 오인하지 않는다 | 콘텐츠 상세·목록·추천 조회 | 대상 소비자용 API |
|
||||
| 관리자 | 저장된 콘텐츠 설정을 그대로 확인한다 | AI 캐릭터 콘텐츠 목록·상세 조회 | 변경 제외 |
|
||||
|
||||
기존 endpoint별 인증·성인 노출·차단 관계·공개 상태 정책은 변경하지 않는다.
|
||||
|
||||
## 6. 핵심 정책
|
||||
|
||||
### 6.1 가격과 포인트 결제 가능 여부
|
||||
|
||||
- 무료 콘텐츠는 `price == 0`으로 정의한다.
|
||||
- 소비자에게 노출하는 실질 포인트 결제 가능 여부는 다음 식으로 정의한다.
|
||||
|
||||
```text
|
||||
effectivePointAvailable = storedIsPointAvailable && price > 0
|
||||
```
|
||||
|
||||
- `price == 0`이고 저장값이 `true`이면 소비자 응답은 `false`다.
|
||||
- `price > 0`이고 저장값이 `true`이면 소비자 응답은 `true`다.
|
||||
- 저장값이 `false`이면 가격과 관계없이 소비자 응답은 `false`다.
|
||||
- 이 정책은 응답 조립 시 적용하며 엔티티의 저장값은 변경하지 않는다.
|
||||
|
||||
### 6.2 포인트 전용 조회
|
||||
|
||||
- 포인트 전용 콘텐츠는 `isPointAvailable == true && price > 0`인 공개 오디오로 정의한다.
|
||||
- `GET /api/v2/audio/recommendations`의 `pointAudios`는 이 조건을 사용한다.
|
||||
- `GET /api/v2/audio/contents?type=POINT`의 목록과 `totalCount`는 동일한 조건을 사용한다.
|
||||
- `hasNext`는 보정된 목록 조건으로 조회한 `size + 1` 결과를 기준으로 기존 방식대로 계산한다.
|
||||
- 무료 콘텐츠는 `isPointAvailable == true`로 저장되어 있어도 POINT 목록, 개수와 페이징 후보에서 제외한다.
|
||||
|
||||
## 7. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계획 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `POINT-001` | 확정 | 무료 기준은 `price == 0`이다 | 무료 fixture가 가격 0으로 판정된다 | `P1-T1`, `P2-T1` |
|
||||
| `POINT-002` | 확정 | 소비자용 실질 포인트 가능 여부는 `storedIsPointAvailable && price > 0`이다 | 무료·저장값 true 응답이 false이고 유료·저장값 true 응답이 true다 | `P1-T1` |
|
||||
| `POINT-003` | 확정 | legacy 콘텐츠 상세의 `isAvailableUsePoint`에 실질 상태를 적용한다 | `GET /audio-content/{id}` 응답 회귀 테스트가 통과한다 | `P1-T1` |
|
||||
| `POINT-004` | 확정 | 대상 v2 소비자 응답의 `isPointAvailable`에 실질 상태를 적용한다 | 각 응답 변환 테스트가 무료 true 저장값을 false로 보정한다 | `P1-T1` |
|
||||
| `POINT-005` | 확정 | 추천 `pointAudios`에서 무료 콘텐츠를 제외한다 | 추천 repository·E2E 테스트에서 가격 0 항목이 없다 | `P2-T1` |
|
||||
| `POINT-006` | 확정 | 전체 탭 POINT 목록·`totalCount`·`hasNext`가 같은 유료 포인트 조건을 사용한다 | repository·E2E 테스트의 목록과 페이징 메타데이터가 일치한다 | `P2-T1` |
|
||||
| `POINT-007` | 확정 | AI 캐릭터 관리자 콘텐츠 조회는 저장값을 그대로 반환한다 | 무료·저장값 true인 관리자 상세가 true를 유지한다 | `P1-T1` |
|
||||
| `POINT-008` | 확정 | 공개 API 스키마와 DB 저장값을 유지한다 | DTO 필드 집합과 관리자 저장값 회귀 테스트가 통과한다 | `P1-GATE`, `P2-GATE` |
|
||||
|
||||
## 8. API 계약
|
||||
|
||||
### 8.1 응답 보정 대상
|
||||
|
||||
| Method | Path | 응답 경계 | 보정 필드 |
|
||||
|---|---|---|---|
|
||||
| GET | `/audio-content/{id}` | `GetAudioContentDetailResponse` | `isAvailableUsePoint` |
|
||||
| GET | `/api/v2/contents` | `ContentOverviewItemResponse` | `isPointAvailable` |
|
||||
| GET | `/api/v2/audio/contents` | `MainContentAudioResponse` | `isPointAvailable` |
|
||||
| GET | `/api/v2/audio/recommendations` | `AudioCardResponse` | `isPointAvailable` |
|
||||
| GET | `/api/v2/home/recommendations` | `HomeFirstAudioContentItem` | `isPointAvailable` |
|
||||
| GET | `/api/v2/creator-channels/{creatorId}/home` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
|
||||
| GET | `/api/v2/creator-channels/{creatorId}/audio` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
|
||||
| GET | `/api/v2/creator-channels/{creatorId}/live` | `CreatorChannelAudioContentResponse` | `isPointAvailable` |
|
||||
|
||||
- `GET /api/v2/creator-channels/{creatorId}/series`는 콘텐츠 가격과 포인트 가능 필드를 반환하지 않아 코드 변경 대상이 아니다.
|
||||
- 현재 v2 소비자용 API에 동일 필드를 반환하는 새 경로가 발견되면 같은 식을 적용하고 계획 범위를 먼저 갱신한다.
|
||||
|
||||
### 8.2 포인트 전용 조회 조건 보정 대상
|
||||
|
||||
| Method | Path/section | 변경 전 | 변경 후 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v2/audio/recommendations`의 `pointAudios` | `isPointAvailable == true` | `isPointAvailable == true && price > 0` |
|
||||
| GET | `/api/v2/audio/contents?type=POINT` | `isPointAvailable == true` | `isPointAvailable == true && price > 0` |
|
||||
|
||||
### 8.3 변경 제외 관리자 계약
|
||||
|
||||
| Method | Path | 정책 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents` | 저장된 `isPointAvailable`을 그대로 반환 |
|
||||
| GET | `/api/v2/admin/ai-characters/{characterId}/audio-contents/{contentId}` | 저장된 값을 `isAvailableUsePoint`에 그대로 반환 |
|
||||
|
||||
## 9. 기술적 제약
|
||||
|
||||
- Kotlin, Java 17, Spring Boot 2.7.14와 현재 QueryDSL/JPA 구조를 유지한다.
|
||||
- 새 dependency, DB schema, API endpoint와 DTO를 추가하지 않는다.
|
||||
- 응답 변환 경계에서는 `configured && price > 0` 식을 직접 사용해 현재 파일 책임 안에서 최소 변경한다.
|
||||
- POINT 조회 조건은 기존 repository 조건 함수에 `price > 0`을 결합해 목록과 count가 같은 조건을 공유하게 한다.
|
||||
- 관리자 mapper와 관리자 조회 service는 변경하지 않는다.
|
||||
- 관련 없는 콘텐츠 가격·결제·추천 점수·정렬·성인·차단 정책은 변경하지 않는다.
|
||||
|
||||
## 10. 테스트와 품질 요구사항
|
||||
|
||||
- TDD 순서로 무료·저장값 true fixture의 실패 테스트를 먼저 작성하고 실패 원인이 기존 원본 전달임을 확인한다.
|
||||
- 소비자 응답 경계별로 무료 true → false와 유료 true → true를 검증한다.
|
||||
- 추천 POINT와 전체 탭 POINT에 무료 true fixture를 추가해 목록 제외를 검증한다.
|
||||
- 전체 탭은 POINT `totalCount`와 `hasNext`가 목록 조건과 일치하는지 검증한다.
|
||||
- 관리자 상세는 무료 true 저장값을 그대로 true로 응답하는 회귀 테스트를 유지한다.
|
||||
- focused test 후 직접 영향받는 v2 콘텐츠·홈·크리에이터 채널 회귀와 `ktlintCheck`를 실행한다.
|
||||
- 여러 API 경계를 변경하므로 최종 Gate에서 전체 `test`를 실행한다.
|
||||
|
||||
## 11. 성공 기준
|
||||
|
||||
- [ ] `price == 0`, 저장값 `true`인 콘텐츠가 모든 대상 소비자 응답에서 `false`다. (`POINT-002~004`)
|
||||
- [ ] `price > 0`, 저장값 `true`인 콘텐츠가 대상 소비자 응답에서 `true`다. (`POINT-002`)
|
||||
- [ ] 저장값 `false`인 콘텐츠는 가격과 관계없이 `false`다. (`POINT-002`)
|
||||
- [ ] 무료·저장값 true 콘텐츠가 추천 `pointAudios`에서 제외된다. (`POINT-005`)
|
||||
- [ ] 무료·저장값 true 콘텐츠가 전체 탭 POINT 목록·`totalCount`·`hasNext` 후보에서 제외된다. (`POINT-006`)
|
||||
- [ ] 관리자 목록·상세와 DB 저장값은 변경되지 않는다. (`POINT-007~008`)
|
||||
- [ ] 공개 응답 필드명과 구조가 변경되지 않는다. (`POINT-008`)
|
||||
|
||||
## 12. Open Questions
|
||||
|
||||
없음.
|
||||
|
||||
## 13. 요구사항 추적표
|
||||
|
||||
| 요구사항 | 계획 Phase | Goal | 자동 검증 |
|
||||
|---|---:|---|---|
|
||||
| `POINT-001~004`, `POINT-007~008` | 1 | `P1-T1`, `P1-GATE` | legacy 상세·v2 응답 mapper·관리자 회귀 테스트 |
|
||||
| `POINT-005~006`, `POINT-008` | 2 | `P2-T1`, `P2-GATE` | 추천/전체 탭 repository·E2E 테스트 |
|
||||
|
||||
## 14. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-07-31 | `DEC-001` | 확정 | 무료 기준을 `price == 0`으로 고정하고 소비자용 포인트 가능 여부를 `storedIsPointAvailable && price > 0`으로 계산한다 | 사용자 인터뷰 | `POINT-001~004`, `P1-T1` |
|
||||
| 2026-07-31 | `DEC-002` | 확정 | 추천과 전체 탭 POINT 조회에서 무료 콘텐츠를 제외한다 | 사용자 선택 A | `POINT-005~006`, `P2-T1` |
|
||||
| 2026-07-31 | `DEC-003` | 확정 | AI 캐릭터 관리자 조회와 DB 저장값은 변경하지 않는다 | 사용자 선택 A | `POINT-007~008`, `P1-T1` |
|
||||
| 2026-07-31 | `DEC-004` | 확정 | 새 통합 문서를 기준으로 만들고 충돌하는 기존 추천·전체 탭 문서에는 정정 기록을 누적한다 | 사용자 승인 | 관련 문서 전체 |
|
||||
@@ -1,229 +0,0 @@
|
||||
# Phase 1 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / `P1-T1`, `P1-GATE` |
|
||||
| 기준 commit 또는 working tree | `eb0ff7537e5fa6b083be21df3319be0ff2ecda51` + staged working tree |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md`, `plan-task.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- `POINT-001~004`, `POINT-007~008` 구현과 완료 증거가 일치하는지 확인한다.
|
||||
- 대상 소비자 응답과 변경 제외 관리자 응답이 각각 확정 계약을 유지하는지 확인한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: Phase 1에서 변경한 consumer response mapper와 `AudioContentService`
|
||||
- 테스트: Phase 1의 unit/facade test와 AI 캐릭터 관리자 controller test
|
||||
- 문서: PRD Phase 1 요구사항, `P1-T1`, `P1-GATE`, Progress
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Phase 2 POINT 전용 repository 조건
|
||||
- legacy 상세 외 legacy 목록·추천·랭킹 API
|
||||
- compile과 test 재실행
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
|
||||
| High | 확정 요구사항 또는 공개 API 계약 위반 |
|
||||
| Medium | 제한된 조건의 기능 회귀 또는 핵심 계약의 자동 검증 누락 |
|
||||
| Low | 문서 정합성 또는 비핵심 회귀 증거 누락 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: `POINT-001~004`, `POINT-007~008`
|
||||
- 계획: `P1-T1`, `P1-GATE`
|
||||
- 코드:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt:970`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/dto/ContentOverviewPageResponse.kt:49`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/dto/MainContentAllTabResponse.kt:63`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/dto/AudioRecommendationsResponse.kt:72`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/common/dto/CreatorChannelAudioContentResponse.kt:35`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt:270`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentMapper.kt:69`
|
||||
- 테스트:
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt:286`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/content/AiCharacterAdminAudioContentControllerTest.kt:495`
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `git diff --cached --check` | 성공 | 출력 없음 |
|
||||
| staged diff와 `rg` 기반 호출·assertion 대조 | 성공 | 6개 소비자 조립 경계는 모두 `stored && price > 0` 적용 |
|
||||
| `./gradlew --no-daemon tasks --all` | 성공 | `BUILD SUCCESSFUL`, exit code 0 |
|
||||
| compile/test | 미실행 | 사용자 지시에 따라 기존 성공 기록만 확인 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P1-001` | Medium | 수정 완료 | 소비자 테스트가 저장값 false 조건을 증명하지 않음 | `Task 1.2` | `P1-R1` |
|
||||
| `REV-P1-002` | Low | 수정 완료 | 관리자 회귀 테스트가 무료 원본값 유지 조건을 증명하지 않음 | `Task 1.2` | `P1-R1` |
|
||||
| `REV-P1-003` | Medium | 수정 완료 | legacy 상세 test가 유료·저장값 true positive 계약을 증명하지 않음 | `Task 1.3` | `P1-R2` |
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-P1-001 — 소비자 테스트가 저장값 false 조건을 증명하지 않음
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `POINT-002`, `POINT-004`
|
||||
- **소유 Task:** `Task 1.2`, `P1-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
변경된 소비자 테스트는 주로 `price == 0, stored == true → false`와
|
||||
`price > 0, stored == true → true`만 검증한다. 따라서 구현이 실수로 `price > 0`만 반환해도 해당 두 조건은 통과한다.
|
||||
PRD 성공 기준인 `stored == false → false`를 자동으로 구분할 수 없다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: 각 소비자 조립 경계는 현재 `stored && price > 0`으로 올바르게 구현되어 있다.
|
||||
- 테스트: Phase 1 변경 test에는 각 변경 경계의 `price > 0, stored == false` assertion이 없다.
|
||||
- 문서: PRD `POINT-002`와 성공 기준은 저장값 false가 가격과 무관하게 false일 것을 요구한다.
|
||||
|
||||
**영향**
|
||||
|
||||
현재 production 동작 결함은 확인되지 않았다. 다만 저장값 조건이 제거되는 회귀가 발생해도 Phase 1 test가 탐지하지 못한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
각 변경 경계의 기존 test fixture에 `price > 0, stored == false` 사례를 최소 추가하고 `P1-GATE`를 재검증한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — staged 구현식은 정상이나 확정 계약의 자동 검증 누락으로 판정해 `P1-R1`로 전환했다.
|
||||
- 2026-07-31 — `P1-R1`에서 대상 소비자 경계에 `price > 0, stored == false` assertion을 추가하고 focused test, `P1-GATE`, `ktlintCheck` 성공을 확인했다.
|
||||
|
||||
### REV-P1-002 — 관리자 회귀 테스트가 무료 원본값 유지 조건을 증명하지 않음
|
||||
|
||||
- **심각도:** Low
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `POINT-007`, `POINT-008`
|
||||
- **소유 Task:** `Task 1.2`, `P1-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
계획과 Progress는 관리자 상세의 “무료·저장값 true → true” 확인을 완료 증거로 기록했지만,
|
||||
`AiCharacterAdminAudioContentControllerTest`의 공통 fixture는 `price = 100`이다. 상세 assertion은 저장값 true 전달만 검증하며
|
||||
무료 조건에서 소비자 보정이 관리자 경계로 번지지 않았는지는 증명하지 않는다. 관리자 목록의 동일 조건 assertion도 없다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `AiCharacterAdminAudioContentMapper`는 현재 `content.isPointAvailable`을 그대로 전달한다.
|
||||
- 테스트: 관리자 helper의 `price = 100`, 상세의 `isAvailableUsePoint == true` assertion.
|
||||
- 문서: `P1-T1`과 구현 Progress는 무료 관리자 원본값 유지 확인을 완료 증거로 기록한다.
|
||||
|
||||
**영향**
|
||||
|
||||
현재 관리자 mapper의 기능 결함은 확인되지 않았다. 그러나 완료 기록과 실제 test fixture가 불일치하며 관리자 제외 계약의
|
||||
핵심 경계가 회귀 test로 고정되지 않았다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
관리자 목록·상세 fixture를 `price == 0, stored == true`로 구성해 목록 `isPointAvailable`과 상세
|
||||
`isAvailableUsePoint`가 true를 유지하는지 검증한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — 구현은 정상이나 완료 증거 불일치가 확인되어 `P1-R1`로 전환했다.
|
||||
- 2026-07-31 — `P1-R1`에서 관리자 목록·상세 fixture를 `price == 0, stored == true`로 보강하고 원본 true 유지 assertion 통과를 확인했다.
|
||||
|
||||
### REV-P1-003 — legacy 상세 test가 유료·저장값 true positive 계약을 증명하지 않음
|
||||
|
||||
- **심각도:** Medium
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `POINT-002`, `POINT-003`
|
||||
- **소유 Task:** `Task 1.3`, `P1-R2`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
`AudioContentServiceTest` 상세 회귀 test는 무료·저장값 true와 유료·저장값 false가 false인 것만 검증한다.
|
||||
`price > 0, stored == true → true` assertion이 없어 legacy 상세 구현이 항상 false로 회귀해도 해당 test가 통과한다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 코드: `AudioContentService` 상세 응답은 현재 `audioContent.isPointAvailable && audioContent.price > 0`으로 올바르게 구현되어 있다.
|
||||
- 테스트: `AudioContentServiceTest` 상세 포인트 assertion은 false 사례 2건만 포함한다.
|
||||
- 문서: `P1-T1`은 각 응답 경계의 유료·저장값 true 유지를 완료 증거로 요구한다.
|
||||
|
||||
**영향**
|
||||
|
||||
현재 production 동작 결함은 확인되지 않았다. 다만 legacy 상세의 positive 계약이 회귀 test로 고정되지 않았다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
기존 상세 test에 유료·저장값 true 응답 assertion을 추가해 세 계약 조건을 완성한다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-07-31 — staged production 구현은 정상이나 legacy 상세 positive 회귀 증거 누락으로 판정해 `P1-R2`로 전환했다.
|
||||
- 2026-07-31 — `P1-R2`에서 legacy 상세 test에 유료·저장값 true assertion을 추가해 세 계약 조건을 고정하고 focused test, P1-GATE 단위 범위, `ktlintCheck` 성공을 확인했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
- 신규 회귀 수정 Task: `plan-task.md`의 `Task 1.2`
|
||||
- 후속 goal: `P1-R1`
|
||||
- objective: 소비자 응답의 저장값 false 조건과 무료 관리자 응답의 원본값 유지 조건을 자동 회귀 테스트로 증명한다.
|
||||
- 추가 회귀 수정 Task: `plan-task.md`의 `Task 1.3`
|
||||
- 추가 후속 goal: `P1-R2`
|
||||
- objective: legacy 상세의 유료·저장값 true positive 계약을 자동 회귀 테스트로 증명한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 1 production/test/document diff와 관련 mapper 확인 |
|
||||
| 후보 항목 판정 완료 | 충족 | 3건 모두 판정 완료 |
|
||||
| 확정 항목 plan 반영 | 충족 | `Task 1.2`, `P1-R1`, `Task 1.3`, `P1-R2` |
|
||||
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, test 미실행 사유 명시 |
|
||||
|
||||
**최종 결론:** 수정 완료
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
## 9. 수정 후 검증 기록
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 차수별로 누적한다.
|
||||
|
||||
### 1차 수정 검증 — 2026-07-31
|
||||
|
||||
- 무엇을: `REV-P1-001`, `REV-P1-002`의 수정 내용과 회귀 증거를 재검토했다.
|
||||
- 왜: 소비자 저장값 false 계약과 관리자 무료 원본값 유지 계약의 자동 검증 누락을 해소했는지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- staged diff와 `rg` — 6개 소비자 조립 경계의 유료·저장값 false assertion 확인.
|
||||
- staged diff — 관리자 목록·상세의 무료·저장값 true fixture와 true assertion 확인.
|
||||
- `git diff --check`, `git diff --cached --check` — 출력 없음.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- compile/test — 사용자 지시에 따라 재실행하지 않고 `plan-task.md`의 기존 성공 기록만 확인.
|
||||
- 판정: `REV-P1-001`, `REV-P1-002` 수정 완료.
|
||||
- 남은 항목: `REV-P1-003`, `P1-R2`.
|
||||
|
||||
### 2차 수정 검증 — 2026-07-31
|
||||
|
||||
- 무엇을: `REV-P1-003`의 수정 내용과 회귀 증거를 재검토했다.
|
||||
- 왜: legacy 상세의 유료·저장값 true positive 계약이 자동 검증으로 고정됐는지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- `AudioContentServiceTest` — 무료·저장값 true → false, 유료·저장값 false → false, 유료·저장값 true → true assertion 확인.
|
||||
- `AudioContentService` — 응답식 `audioContent.isPointAvailable && audioContent.price > 0`과 세 assertion 대조.
|
||||
- `plan-task.md`의 기존 완료 기록 — focused test `BUILD SUCCESSFUL`(14s), P1-GATE 단위 범위
|
||||
`BUILD SUCCESSFUL`(2m 26s), `ktlintCheck` `BUILD SUCCESSFUL`(14s) 확인.
|
||||
- `git diff --check`, `git diff --cached --check` — 출력 없음.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- compile/test — 사용자 지시에 따라 이번 재검토에서는 실행하지 않음.
|
||||
- 판정: `REV-P1-003` 수정 완료.
|
||||
- 남은 항목: 없음.
|
||||
@@ -1,111 +0,0 @@
|
||||
# Phase 2 코드 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 2 / `P2-T1`, `P2-GATE` |
|
||||
| 기준 commit 또는 working tree | `eb0ff7537e5fa6b083be21df3319be0ff2ecda51` + staged working tree |
|
||||
| 리뷰 일자 | 2026-07-31 |
|
||||
| 리뷰어 | Codex |
|
||||
| 기준 문서 | `docs/20260731_무료_콘텐츠_포인트_결제_불가/prd.md`, `plan-task.md` |
|
||||
| 리뷰 상태 | 판정 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- `POINT-005~006`, `POINT-008` 구현과 완료 증거가 일치하는지 확인한다.
|
||||
- 추천과 전체 탭 POINT 조회가 무료 콘텐츠를 목록·count·pagination 후보에서 제외하는지 확인한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: 추천·전체 탭 QueryDSL repository 변경
|
||||
- 테스트: 두 repository test와 추천·전체 탭 E2E test
|
||||
- 문서: 관련 PRD, 기존 추천·전체 탭 후속 정정, `P2-T1`, `P2-GATE`, Progress
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- Phase 1 응답 mapper
|
||||
- FREE/AUDIO/정렬 정책의 신규 변경
|
||||
- compile과 test 재실행
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·데이터 손실 위험, 핵심 흐름 불능, 완료 판정을 무효화하는 문제 |
|
||||
| High | 확정 요구사항 또는 공개 API 계약 위반 |
|
||||
| Medium | 제한된 조건의 목록·count·pagination 불일치 |
|
||||
| Low | 문서 정합성 또는 비핵심 회귀 증거 누락 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: `POINT-005~006`, `POINT-008`
|
||||
- 계획: `P2-T1`, `P2-GATE`
|
||||
- 코드:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt:135`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt:35`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt:409`
|
||||
- 테스트:
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt:87`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt:43`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt:42`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt:97`
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `git diff --cached --check` | 성공 | 출력 없음 |
|
||||
| staged diff와 `rg` 기반 조건·호출 대조 | 성공 | 추천은 유료 POINT 조건, 전체 탭 list/count는 같은 `audioCondition` 공유 |
|
||||
| `./gradlew --no-daemon tasks --all` | 성공 | `BUILD SUCCESSFUL`, exit code 0 |
|
||||
| compile/test | 미실행 | 사용자 지시에 따라 기존 성공 기록만 확인 |
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
확정 발견 사항 없음.
|
||||
|
||||
## 6. 검토 결과
|
||||
|
||||
- 추천 `findPointAudios`는 `isPointAvailable.isTrue.and(price.gt(0))`을 조회 전에 적용해 limit 후보에서도 무료 콘텐츠를 제외한다.
|
||||
- 전체 탭 `countAudios`와 `findAudios`는 동일한 `audioCondition`과 `optionalAudioPointCondition`을 사용한다.
|
||||
- 전체 탭 E2E는 유료 1건과 무료·저장값 true 1건에서 `size=1`로 조회해 `totalCount=1`, 목록 1건,
|
||||
`hasNext=false`를 함께 검증한다.
|
||||
- repository test는 유료·저장값 false, 유료·저장값 true, 무료·저장값 true를 구분한다.
|
||||
- 기존 추천·전체 탭 문서는 2026-07-31 후속 요구사항 정정을 누적해 현재 PRD와 일치한다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
전환 항목 없음.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | Phase 2 production/test/document diff와 관련 호출 경계 확인 |
|
||||
| 후보 항목 판정 완료 | 충족 | 후보 없음 |
|
||||
| 확정 항목 plan 반영 | 해당 없음 | 확정 발견 사항 없음 |
|
||||
| 보류 항목의 담당·재개 조건 기록 | 해당 없음 | 보류 없음 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 정적 검증 기록, test 미실행 사유 명시 |
|
||||
|
||||
**최종 결론:** 확정 발견 사항 없음
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
## 9. 후속 상태 확인
|
||||
|
||||
### 1차 재확인 — 2026-07-31
|
||||
|
||||
- 무엇을: Phase 1의 `P1-R2` 수정이 Phase 2 POINT 조회 계약에 영향을 주지 않았는지 재검토했다.
|
||||
- 왜: 후속 test 변경 뒤에도 POINT 목록·count·pagination 조건과 기존 Phase 2 판정이 유효한지 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- production diff — `P1-R2`에 따른 Phase 2 repository 변경 없음 확인.
|
||||
- repository와 E2E test 정적 대조 — 추천 유료 POINT 조건과 전체 탭 list/count 공통 조건 유지 확인.
|
||||
- `git diff --check`, `git diff --cached --check` — 출력 없음.
|
||||
- `./gradlew --no-daemon tasks --all` — `BUILD SUCCESSFUL`, exit code 0.
|
||||
- compile/test — 사용자 지시에 따라 이번 재검토에서는 실행하지 않음.
|
||||
- 판정: 기존 Phase 2 판정 유지.
|
||||
- 남은 항목: 없음.
|
||||
@@ -1,170 +0,0 @@
|
||||
# 관리자 정산 크리에이터 번호 적용 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-04 |
|
||||
| 요구사항 기준 | `docs/20260804_관리자정산크리에이터번호적용/prd.md` |
|
||||
| 현재 Phase | Phase 1 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
이메일이 없는 크리에이터도 관리자 크리에이터별 정산에서 오류 없이 조회되고 `creatorId`로 구분된다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `2/2` | 없음 | 없음 |
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 관리자 라이브·콘텐츠·커뮤니티 크리에이터별 정산 QueryDSL projection 변경
|
||||
- 정산 query/response DTO의 `email` 제거 및 `creatorId` 추가
|
||||
- 크리에이터별 정산 엑셀 이메일 컬럼 제거 및 크리에이터 번호 컬럼 추가
|
||||
- null 이메일 크리에이터 QueryDSL 회귀 테스트와 엑셀 출력 테스트
|
||||
|
||||
### 제외
|
||||
|
||||
- 에이전트·채널후원 정산 변경
|
||||
- `Member.email` 정책 변경
|
||||
- 정산 공식·집계·필터·정렬·페이지네이션 변경
|
||||
- dependency 추가와 관련 없는 리팩터링
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- Kotlin, Spring Boot 2.7.14, QueryDSL 5.0.0, JUnit 5의 기존 패턴을 유지한다.
|
||||
- `@QueryProjection` 생성자 변경은 Gradle KAPT가 생성 코드를 갱신하도록 하고 생성 파일은 직접 수정하지 않는다.
|
||||
- 구현은 RED → GREEN → REFACTOR 순서로 진행한다.
|
||||
- Gradle 명령은 QueryDSL 생성 코드 충돌을 피하기 위해 순차 실행한다.
|
||||
|
||||
### Phase 1: 정산 projection 및 출력 변경
|
||||
|
||||
**Phase 결과:** null 이메일 크리에이터 정산 조회와 creatorId 기반 API·엑셀 출력이 동작한다.
|
||||
|
||||
**선행조건:** PRD와 본 계획 문서 작성 완료.
|
||||
|
||||
#### Task 1.1 QueryDSL 정산 응답에 creatorId 적용
|
||||
|
||||
**Goal 실행 `P1-T1`:** null 이메일 크리에이터의 세 정산 조회가 creatorId를 반환하도록 한다.
|
||||
|
||||
- **시작 조건:** `CALC-001`, `CALC-002`, `CALC-004` 확정.
|
||||
- **완료 증거:** RED/GREEN 결과와 focused test 기록.
|
||||
- **범위 밖:** 엑셀 출력 변경은 `P1-T2`에서 처리한다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepositoryTest.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorQueryData.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/GetCalculateByCreatorItem.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateQueryRepository.kt`
|
||||
|
||||
- [x] **RED:** email이 null인 크리에이터의 라이브·콘텐츠·커뮤니티 정산 조회가 `creatorId`를 반환하는 통합 테스트를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest`를 실행해 기존 projection의 null email 생성 오류 또는 미구현 `creatorId` 계약 실패를 확인한다.
|
||||
- [x] **GREEN:** 두 DTO에서 `email`을 `creatorId: Long`으로 교체하고 세 QueryDSL projection에서 `member.id`를 선택한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
||||
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 정산 계산 회귀 테스트를 실행한다.
|
||||
|
||||
#### Task 1.2 엑셀 크리에이터 번호 적용
|
||||
|
||||
**Goal 실행 `P1-T2`:** 크리에이터별 정산 엑셀에서 이메일 대신 크리에이터 번호를 출력한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** 엑셀 헤더·값 테스트와 focused test 기록.
|
||||
- **범위 밖:** 다른 정산 엑셀 형식 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateServiceTest.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/calculate/AdminCalculateService.kt`
|
||||
|
||||
- [x] **RED:** 크리에이터별 정산 엑셀의 첫 헤더와 값이 크리에이터 번호인지 검증하는 테스트를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest`를 실행해 남아 있는 `item.email` 계약 때문에 컴파일이 실패하는지 확인한다.
|
||||
- [x] **GREEN:** 엑셀 첫 헤더를 `크리에이터 번호`로 교체하고 `creatorId`를 기록한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
||||
- [x] **REFACTOR:** 나머지 엑셀 컬럼과 계산 결과가 유지되는지 회귀 확인한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`, `P1-T2`의 체크박스와 완료 증거가 충족됐다.
|
||||
- [x] 문서와 구현의 차이가 없다.
|
||||
|
||||
### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 변경 범위의 기능·컴파일·포맷을 최종 판정한다.
|
||||
|
||||
```bash
|
||||
./gradlew compileKotlin
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest
|
||||
./gradlew ktlintCheck
|
||||
./gradlew tasks --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 모든 Gradle 명령이 `BUILD SUCCESSFUL`, 테스트가 모두 통과하고 `git diff --check` 출력이 없다.
|
||||
|
||||
전체 테스트는 관리자 크리에이터별 정산의 projection/DTO/엑셀 경계로 변경이 제한되고 focused test와 컴파일로 세 쿼리 호출부를 검증하므로 생략한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | 문서 작성 완료 | 아니요 | RED 실패 원인 재확인 |
|
||||
| 2 | `P1-T2` | `P1-T1` 완료 | 아니요 | 엑셀 계약 재확인 |
|
||||
| 3 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task에 회귀 수정 기록 |
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- QueryDSL 생성 파일을 직접 수정하지 않는다.
|
||||
- 정산 계산식과 쿼리 집계 조건을 변경하지 않는다.
|
||||
- 테스트를 삭제·skip·완화하지 않는다.
|
||||
- 요청 범위 밖의 정산 API를 변경하지 않는다.
|
||||
|
||||
## Progress
|
||||
|
||||
### `P1-T1` 1차 실행 — 2026-08-04
|
||||
|
||||
- 상태: 진행 중
|
||||
- 무엇을: PRD와 구현 계획을 작성하고 QueryDSL 통합 테스트 seam을 확정했다.
|
||||
- 왜: production 변경 전에 요구사항과 TDD 완료 기준을 고정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `docs/sample/sample-prd.md`, `docs/sample/sample-plan-task.md` 확인 — 완료
|
||||
- `AdminCalculateQueryRepository`와 관련 DTO 호출 경로 확인 — 완료
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest` — RED 확인, `creatorId` 미구현으로 `compileTestKotlin` 실패
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest` — RED 확인, 남아 있는 `item.email` 참조로 `compileKotlin` 실패
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest` — GREEN, `BUILD SUCCESSFUL`
|
||||
- 남은 항목: Phase Gate 검증.
|
||||
- 다음 행동: 컴파일·focused 회귀·ktlint·문서 검증을 순차 실행한다.
|
||||
|
||||
### `P1-GATE` 1차 실행 — 2026-08-04
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: creatorId projection·응답·엑셀 변경과 null 이메일 회귀 방지를 검증했다.
|
||||
- 왜: `CALC-001`~`CALC-004`의 수용 기준과 기존 계산 불변성을 최종 판정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew compileKotlin` — `BUILD SUCCESSFUL`
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateQueryRepositoryTest --tests kr.co.vividnext.sodalive.admin.calculate.AdminCalculateServiceTest --tests kr.co.vividnext.sodalive.admin.calculate.ContentSettlementCalculationTest` — 11 tests, failures 0, errors 0
|
||||
- `./gradlew ktlintCheck` — `BUILD SUCCESSFUL`
|
||||
- `./gradlew build -x test` — `BUILD SUCCESSFUL`
|
||||
- `git diff --check` — 출력 없음
|
||||
- 독립 Oracle 리뷰 — `APPROVED`, 차단 이슈 없음
|
||||
- 수동 검증: QueryDSL H2 통합 테스트로 null 이메일 세 정산 조회를 실행하고, 실제 XLSX workbook의 헤더와 numeric creatorId 셀을 확인했다.
|
||||
- 전체 테스트 생략: 변경이 관리자 크리에이터별 정산의 공용 projection/DTO/엑셀 경계에 한정되어 focused 통합·서비스·계산 테스트와 전체 빌드로 직접 영향 범위를 검증했다.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: 없음.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-04 | `DEC-001` | 확정 | email을 제거하고 creatorId를 projection·응답에 추가한다. | PRD `DEC-001` | `P1-T1` |
|
||||
| 2026-08-04 | `DEC-002` | 확정 | 엑셀 이메일 컬럼을 크리에이터 번호로 교체한다. | PRD `DEC-002` | `P1-T2` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||
|---|---|---|---|---|---|
|
||||
| `ISSUE-001` | High | 확정 | nullable `Member.email`이 non-null QueryProjection 생성자로 전달되어 정산 조회가 실패한다. | `P1-T1` | creatorId로 projection 계약 교체 |
|
||||
@@ -1,73 +0,0 @@
|
||||
# 관리자 정산 크리에이터 번호 적용 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-04 |
|
||||
| 최종 수정일 | 2026-08-04 |
|
||||
| 대상 제품 | 관리자 크리에이터별 정산 조회 및 엑셀 |
|
||||
| 작성자·결정권자 | 사용자 |
|
||||
| 관련 구현 계획 | `docs/20260804_관리자정산크리에이터번호적용/plan-task.md` |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
관리자 크리에이터별 정산에서 nullable인 `Member.email`을 필수 QueryDSL projection 값으로 사용해 발생하는 조회 오류를 제거한다. 정산 대상 식별값은 이메일 대신 non-null PK인 `creatorId`를 사용한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- `Member.email`은 nullable이지만 `GetCalculateByCreatorQueryData.email`은 non-null `String`이다.
|
||||
- 이메일이 없는 크리에이터가 정산 결과에 포함되면 QueryDSL이 DTO 생성 중 `ExpressionException`을 발생시킨다.
|
||||
- 이메일은 정산 계산식에 사용되지 않으므로 필수 projection 값으로 유지할 이유가 없다.
|
||||
|
||||
문제 해결 여부는 이메일이 null인 크리에이터의 정산 조회가 성공하고 `creatorId`를 반환하는지로 판단한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 라이브·콘텐츠·커뮤니티 크리에이터별 정산 projection에서 `email`을 제거한다.
|
||||
- 정산 응답에 `creatorId`를 추가한다.
|
||||
- 크리에이터별 정산 엑셀에서 이메일을 제거하고 크리에이터 번호를 제공한다.
|
||||
- 기존 정산 금액 계산, 집계, 필터, 정렬과 페이지네이션을 유지한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- `Member.email`의 nullable 정책을 변경하지 않는다.
|
||||
- 이메일이 없는 회원의 다른 기능을 수정하지 않는다.
|
||||
- 에이전트 정산과 채널후원 정산 API를 변경하지 않는다.
|
||||
- 정산 공식 또는 정산 비율을 변경하지 않는다.
|
||||
|
||||
## 5. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CALC-001` | 확정 | 크리에이터별 정산 조회는 이메일 대신 크리에이터 번호를 반환한다. | 응답 item에 `creatorId: Long`이 있고 `email`이 없다. | `P1-T1` |
|
||||
| `CALC-002` | 확정 | 이메일이 없는 크리에이터도 정산 조회 대상에 포함된다. | null 이메일 크리에이터의 라이브·콘텐츠·커뮤니티 정산 QueryDSL 조회가 예외 없이 성공한다. | `P1-T1` |
|
||||
| `CALC-003` | 확정 | 크리에이터별 정산 엑셀은 이메일 대신 크리에이터 번호를 제공한다. | 첫 헤더가 `크리에이터 번호`이고 데이터 셀에 `creatorId`가 기록된다. | `P1-T2` |
|
||||
| `CALC-004` | 확정 | 기존 정산 계산 결과를 유지한다. | `totalCan`, 원화, 결제수수료, 정산금액, 원천세와 입금액 계산 회귀 테스트가 통과한다. | `P1-T1`, `P1-GATE` |
|
||||
|
||||
## 6. API 계약
|
||||
|
||||
대상 endpoint는 다음과 같다.
|
||||
|
||||
- `GET /admin/calculate/live-by-creator`
|
||||
- `GET /admin/calculate/content-by-creator`
|
||||
- `GET /admin/calculate/community-by-creator`
|
||||
- 위 세 endpoint의 `/excel` 다운로드
|
||||
|
||||
조회 응답 item의 `email: String`을 제거하고 `creatorId: Long`을 추가한다. 요청 파라미터와 응답의 나머지 필드는 변경하지 않는다.
|
||||
|
||||
## 7. 성공 기준
|
||||
|
||||
- [x] null 이메일 크리에이터에 대한 세 종류의 크리에이터별 정산 조회가 성공한다.
|
||||
- [x] 조회 응답은 `creatorId`를 포함하고 `email`을 포함하지 않는다.
|
||||
- [x] 엑셀은 `크리에이터 번호` 컬럼을 포함하고 이메일 컬럼을 포함하지 않는다.
|
||||
- [x] 기존 정산 계산 및 관련 회귀 테스트가 통과한다.
|
||||
|
||||
## 8. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-04 | `DEC-001` | 확정 | 정산에서 email을 제거하고 creatorId를 사용한다. | email은 계산에 사용되지 않고 nullable이라 projection 오류를 발생시킨다. | `CALC-001`, `CALC-002`, `P1-T1` |
|
||||
| 2026-08-04 | `DEC-002` | 확정 | 엑셀의 이메일 컬럼을 크리에이터 번호 컬럼으로 교체한다. | 사용자가 이메일 제거와 creatorId 기반 구분을 확정했다. | `CALC-003`, `P1-T2` |
|
||||
| 2026-08-04 | `DEC-003` | 확정 | 이메일 제거에 따른 추가 식별력 보완은 하지 않는다. | 사용자가 식별력 저하는 문제가 되지 않는다고 확정했다. | Non-Goals |
|
||||
@@ -1,90 +0,0 @@
|
||||
# 추천 탭 배너 조회 조건 보정 Implementation Plan
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-05 |
|
||||
| 요구사항 기준 | `docs/20260805_추천탭_배너_조회조건/prd.md` |
|
||||
| API 기준 | 기존 공개 API 계약 유지 |
|
||||
| 현재 Phase | Phase 1: 배너 조회 조건 보정 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
메인 홈 추천과 메인 콘텐츠 추천에서 화면별 탭과 회원의 성인 콘텐츠 조회 가능 여부에 맞는 배너만 조회한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `2/2` | 없음 | 없음 |
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 홈 추천 배너의 `tab_id IS NULL` 조건 유지 및 성인 배너 필터 추가
|
||||
- 콘텐츠 추천 배너의 `tab_id = 2` 조건과 성인 배너 필터 추가
|
||||
- application에서 계산한 성인 콘텐츠 조회 가능 여부를 persistence port까지 전달
|
||||
- 관련 service/facade/Repository focused test와 직접 영향 범위 회귀
|
||||
|
||||
### 제외
|
||||
|
||||
- 공개 API endpoint와 응답 DTO 변경
|
||||
- 배너 언어/활성/차단/대상 유효성/정렬/limit 정책 변경
|
||||
- DB 데이터 또는 스키마 변경
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- Kotlin, Spring Boot 2.7.14, QueryDSL 기존 패턴을 유지한다.
|
||||
- 성인 콘텐츠 조회 가능 여부는 `MemberContentPreferenceService.canViewAdultContent(member)` 결과를 재사용한다.
|
||||
- 조회 가능 여부가 `false`이면 `audioContentBanner.isAdult.isFalse`, `true`이면 성인 조건을 추가하지 않는다.
|
||||
- 신규 공통 추상화나 의존성을 추가하지 않는다.
|
||||
|
||||
### Phase 1: 배너 조회 조건 보정
|
||||
|
||||
- [x] **Task 1.1: 추천 API별 탭 및 성인 배너 조회 조건 구현 (`P1-T1`)**
|
||||
- Objective: 두 추천 API의 배너가 확정된 탭과 성인 콘텐츠 조회 정책에 따라 반환된다.
|
||||
- 시작 조건: PRD `BANNER-001~004`가 확정되어 있다.
|
||||
- 완료 증거: 신규/보강 테스트가 RED 후 GREEN이고 관련 production/test 코드가 컴파일된다.
|
||||
- 범위 밖: 언어 필터와 공개 응답 스키마 변경.
|
||||
- Modify:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- RED: 홈 성인 조회 플래그 전달, 홈 비성인 필터, 콘텐츠 `tab_id = 2` 및 비성인 필터를 검증하는 테스트를 먼저 작성하고 실패를 확인한다.
|
||||
- GREEN: 기존 QueryDSL 조건에 필요한 탭/성인 조건만 추가하고 홈 application 경로에 플래그를 전달한다.
|
||||
- REFACTOR: 중복되지 않는 기존 조건 helper 패턴을 따르고 불필요한 변경이 없는지 확인한다.
|
||||
- Verify:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- 검증 기록:
|
||||
- RED: 두 Repository 테스트를 실행해 `shouldExcludeAdultHomeBannersWhenAdultContentIsNotVisible`, `shouldFindBannersForContentRecommendationTabWithAdultVisibility` 두 건의 기대값 실패를 확인했다.
|
||||
- RED: 홈 service 테스트는 `includeAdultBanners` 미구현 컴파일 실패, Facade 테스트는 플래그 미전달 assertion 실패를 각각 확인했다.
|
||||
- GREEN: 위 4개 focused test class를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
- [x] **Task 1.2: 영향 범위 회귀 및 문서 검증 (`P1-GATE`)**
|
||||
- Objective: 추천 배너 변경이 기존 API 계약과 코드 품질 규칙을 깨지 않았음을 확인한다.
|
||||
- 시작 조건: `P1-T1`이 완료되어 있다.
|
||||
- 완료 증거: focused test, `ktlintCheck`, `tasks --all`, `git diff --check`가 통과하고 결과가 기록되어 있다.
|
||||
- 범위 밖: 전체 회귀 테스트. 변경이 배너 조회 경로 두 곳에 한정되어 targeted test로 직접 영향 범위를 판단할 수 있으므로 생략한다.
|
||||
- Verify:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest --tests kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest --tests kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest --tests kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest`
|
||||
- `./gradlew ktlintCheck`
|
||||
- `./gradlew tasks --all`
|
||||
- `git diff --check`
|
||||
- 검증 기록:
|
||||
- `HomeRecommendationControllerTest`, `AudioRecommendationControllerTest`, `AudioRecommendationEndToEndTest`를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- `./gradlew ktlintCheck`: `BUILD SUCCESSFUL`.
|
||||
- `./gradlew tasks --all`: `BUILD SUCCESSFUL`.
|
||||
- `git diff --check`: 출력 없음.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 최초 Gradle 실행은 sandbox의 `~/.gradle` wrapper lock 접근 제한으로 실패했으며, 승인된 동일 명령을 재실행해 검증을 완료했다.
|
||||
- 전체 회귀 테스트는 실행하지 않았다. 변경이 두 배너 조회 조건과 홈 플래그 전달에 한정되어 focused Repository/application 테스트와 두 API의 controller/E2E 회귀로 직접 영향 범위를 검증했다.
|
||||
@@ -1,71 +0,0 @@
|
||||
# PRD: 추천 탭 배너 조회 조건 보정
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-05 |
|
||||
| 최종 수정일 | 2026-08-05 |
|
||||
| 대상 제품 | 메인 홈 추천 탭, 메인 콘텐츠 추천 탭 |
|
||||
| 작성자·결정권자 | 사용자 |
|
||||
| 관련 구현 계획 | `docs/20260805_추천탭_배너_조회조건/plan-task.md` |
|
||||
| 관련 기존 문서 | `docs/20260529_메인_홈_추천_API/prd.md`, `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md` |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
메인 홈 추천 API와 메인 콘텐츠 추천 API의 배너 조회에 회원의 성인 콘텐츠 조회 가능 여부를 반영하고, 두 화면이 서로 다른 탭의 배너를 조회하도록 조건을 보정한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- 두 추천 API가 모두 `content_banner.tab_id IS NULL`인 같은 배너를 조회한다.
|
||||
- 두 추천 API의 배너 조회가 `content_banner.is_adult`를 필터링하지 않아 성인 콘텐츠 조회 불가 사용자에게 성인 배너가 노출될 수 있다.
|
||||
|
||||
문제를 해결했다는 판단은 두 API의 탭 조건과 성인 배너 노출 조건이 Repository 테스트로 구분되어 검증되는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 성인 콘텐츠 조회 불가 사용자는 `is_adult = false`인 배너만 조회한다.
|
||||
- 성인 콘텐츠 조회 가능 사용자는 성인·비성인 배너를 모두 조회한다.
|
||||
- 메인 홈 추천 API는 기존처럼 `tab_id IS NULL`인 배너를 조회한다.
|
||||
- 메인 콘텐츠 추천 API는 `tab_id = 2`인 배너를 조회한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 공개 API endpoint와 응답 DTO를 변경하지 않는다.
|
||||
- 배너 언어 필터, 정렬, 최대 조회 개수, 대상 활성/차단 정책을 변경하지 않는다.
|
||||
- 배너 또는 탭 데이터와 DB 스키마를 변경하지 않는다.
|
||||
|
||||
## 5. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `BANNER-001` | 확정 | `GET /api/v2/home/recommendations`의 배너는 `tab_id IS NULL` 조건을 유지한다. | 탭이 없는 활성 배너만 조회하고 `tab_id = 2` 배너는 조회하지 않는다. | `P1-T1` |
|
||||
| `BANNER-002` | 확정 | `GET /api/v2/audio/recommendations`의 배너는 `tab_id = 2` 조건을 사용한다. | `tab_id = 2`인 활성 배너만 조회하고 탭이 없는 배너는 조회하지 않는다. | `P1-T1` |
|
||||
| `BANNER-003` | 확정 | 두 API 모두 회원의 성인 콘텐츠 조회 가능 여부를 배너 조회에 반영한다. | 조회 불가이면 비성인 배너만, 조회 가능이면 성인·비성인 배너를 모두 반환한다. | `P1-T1` |
|
||||
| `BANNER-004` | 확정 | 기존 배너 활성/차단/대상 유효성/정렬/limit 정책을 유지한다. | 기존 관련 Repository 테스트가 계속 통과한다. | `P1-GATE` |
|
||||
|
||||
## 6. API 계약
|
||||
|
||||
| Method | Path | 변경 내용 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v2/home/recommendations` | 응답 스키마 변경 없이 배너의 성인 조회 조건만 추가한다. |
|
||||
| `GET` | `/api/v2/audio/recommendations` | 응답 스키마 변경 없이 배너 탭 조건을 `tab_id = 2`로 변경하고 성인 조회 조건을 추가한다. |
|
||||
|
||||
## 7. 성공 기준
|
||||
|
||||
- [x] 홈 추천 배너가 `tab_id IS NULL` 조건을 유지한다.
|
||||
- [x] 콘텐츠 추천 배너가 `tab_id = 2` 조건만 사용한다.
|
||||
- [x] 성인 콘텐츠 조회 불가/가능 사용자의 배너 결과가 확정 정책과 일치한다.
|
||||
- [x] 기존 배너 응답 스키마와 활성/차단/정렬/limit 정책이 유지된다.
|
||||
|
||||
## 8. Open Questions
|
||||
|
||||
- 없음.
|
||||
|
||||
## 9. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-05 | `DEC-001` | 확정 | 성인 조회 불가이면 비성인 배너만, 조회 가능이면 성인·비성인 배너를 모두 조회한다. | 사용자 답변 A | `BANNER-003`, `P1-T1` |
|
||||
| 2026-08-05 | `DEC-002` | 확정 | 홈 추천은 `tab_id IS NULL`, 콘텐츠 추천은 `tab_id = 2`를 사용한다. | 사용자 직접 요구사항 | `BANNER-001`, `BANNER-002`, `P1-T1` |
|
||||
@@ -1,174 +0,0 @@
|
||||
# 크리에이터 관리자 시리즈 상세 LazyInitializationException 수정 Plan/TASK
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-05 |
|
||||
| 요구사항 기준 | `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/prd.md` |
|
||||
| API 기준 | 기존 `GET /creator-admin/audio-content/series/{seriesId}` 계약 유지 |
|
||||
| 현재 Phase | Phase 1 완료 |
|
||||
| 현재 활성 Goal | 없음, 구현 완료 |
|
||||
|
||||
## 목표
|
||||
|
||||
OSIV off 환경에서 크리에이터 관리자가 본인 시리즈 상세를 조회할 때 `Series.keywordList` lazy 초기화 예외 없이 기존 응답을 받게 한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `1/1` | 없음 | 없음 |
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- `CreatorAdminContentSeriesService`의 class-level read-only 트랜잭션 경계
|
||||
- `Series.keywordList` lazy 예외를 재현하고 방지하는 서비스 통합 테스트
|
||||
- 기존 상세 응답과 소유권 동작의 영향 범위 회귀 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- endpoint와 `GetCreatorAdminContentSeriesDetailResponse` 변경
|
||||
- OSIV, entity fetch 전략, repository query 변경
|
||||
- 다른 시리즈 조회·수정 흐름 리팩터링
|
||||
- 새 abstraction 또는 dependency 추가
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5를 유지한다.
|
||||
- production code는 `CreatorAdminContentSeriesService` class-level annotation 한 줄만 변경한다.
|
||||
- 기존 쓰기 메서드 `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`,
|
||||
`updateSeriesOrders()`의 메서드 레벨 `@Transactional`은 유지해 class-level read-only 기본값을 재정의한다.
|
||||
- 테스트는 실제 Spring 서비스 프록시를 사용하고 외부 테스트 트랜잭션으로 서비스 경계를 가리지 않는다.
|
||||
- focused test부터 실행하고 직접 영향받는 characterization test까지만 회귀 범위를 확장한다.
|
||||
- 전체 테스트는 class-level annotation 한 줄 변경과 targeted test로 영향 범위를 판정할 수 있으므로 기본적으로 생략한다. targeted test에서
|
||||
범위를 설명할 수 없는 실패가 발생하거나 공통 경계 변경으로 확대될 때만 실행한다.
|
||||
|
||||
## Phase 1: 상세 조회 트랜잭션 회귀 수정
|
||||
|
||||
**Phase 결과:** 크리에이터 관리자 시리즈 상세 조회가 OSIV off 환경에서 키워드를 포함한 기존 DTO를 정상 반환한다.
|
||||
|
||||
**선행조건:** `CASD-001`~`CASD-003` 요구사항과 `DEC-CASD-002`, `DEC-CASD-003` 결정 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, focused·영향 범위 회귀 결과 기록.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 상세 조회 lazy 예외 재현 및 최소 수정
|
||||
|
||||
**Goal 실행 `P1-T1`:** 서비스 클래스의 기본 read-only 트랜잭션 안에서 상세 조회가 키워드 lazy 컬렉션을 DTO로 변환하게 한다.
|
||||
|
||||
- **시작 조건:** PRD 구현 기준 확정, production code 미수정 상태.
|
||||
- **완료 증거:** RED/GREEN/REFACTOR 체크박스 완료, focused test 실제 실행 결과, class-level annotation 이외 production diff 없음,
|
||||
기존 쓰기 메서드 annotation 유지.
|
||||
- **범위 밖:** OSIV·entity mapping·repository query·controller·response DTO 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesServiceIntegrationTest.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/creator/admin/content/series/CreatorAdminContentSeriesService.kt`
|
||||
- Modify: `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md`
|
||||
- Verify: `src/test/resources/application.yml`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `CreatorAdminContentSeriesService.getDetail(id: Long, memberId: Long)`과 기존 `Series.toDetailResponse(imageHost)`
|
||||
- Produces: 기존 시그니처·DTO를 유지하면서 class-level read-only 트랜잭션 안에서 완성된
|
||||
`GetCreatorAdminContentSeriesDetailResponse`
|
||||
|
||||
- [x] **RED:** `@SpringBootTest`, `EmbeddedRedisInitializer`, 실제 `CreatorAdminContentSeriesService` 빈을 사용하는 통합 테스트를 작성한다.
|
||||
테스트 외부 트랜잭션은 사용하지 않고 `TransactionTemplate` 안에서 소유 회원, 장르, 시리즈, 해시태그와 `SeriesKeyword` fixture를
|
||||
저장한 뒤 트랜잭션 밖에서 `service.getDetail()`을 호출해 `keywords`와 주요 상세 필드를 검증한다.
|
||||
- [x] **RED 확인:** `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest`
|
||||
를 실행해 `Series.keywordList`의 `LazyInitializationException`으로 실패하는지 확인한다. 환경·fixture·컴파일 실패는 RED 증거로
|
||||
인정하지 않고 먼저 바로잡는다.
|
||||
- [x] **GREEN:** `CreatorAdminContentSeriesService` 클래스 선언 바로 위에 기존 import를 사용하는
|
||||
`@Transactional(readOnly = true)` 한 줄을 추가하고 기존 쓰기 메서드의 메서드 레벨 `@Transactional`을 유지한다.
|
||||
- [x] **GREEN 확인:** RED와 같은 focused test를 다시 실행해 `BUILD SUCCESSFUL`과 fixture의 `keywords`, `seriesId`,
|
||||
`publishedDaysOfWeek`, `state` 값 일치를 확인한다.
|
||||
- [x] **REFACTOR:** 새 abstraction 없이 테스트 fixture의 중복만 파일 내부 private helper로 제한한다. focused test와
|
||||
`./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`
|
||||
를 실행하고, production diff가 class-level annotation 한 줄이며 모든 기존 쓰기 메서드 annotation이 유지되는지 확인해 결과를
|
||||
이 Task 아래에 기록한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`의 RED/GREEN/REFACTOR와 완료 증거가 모두 충족됐다.
|
||||
- [x] `CASD-001`~`CASD-003`이 자동 검증 결과로 추적된다.
|
||||
- [x] API 계약, OSIV, entity mapping, repository query에 변경이 없다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 상세 조회 수정의 기능·회귀·문서 범위를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** 아래 명령 통과 및 실제 결과를 검증 기록에 누적.
|
||||
- **범위 밖:** Gate 통과를 위한 테스트 완화와 관련 없는 코드 수정.
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest
|
||||
./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest
|
||||
./gradlew --no-daemon ktlintCheck
|
||||
./gradlew --no-daemon tasks --all
|
||||
git diff --check
|
||||
./gradlew --no-daemon test
|
||||
```
|
||||
|
||||
- [x] focused test가 실제 실행되고 failures/errors 0으로 통과한다.
|
||||
- [x] 기존 시리즈 상세·소유권 characterization test가 통과한다.
|
||||
- [x] `ktlintCheck`, `tasks --all`, `git diff --check`가 통과한다.
|
||||
- [x] ultrawork verification 요구에 따라 전체 테스트를 실행하고 통과했다.
|
||||
- [x] production code 변경이 `CreatorAdminContentSeriesService`의 class-level `@Transactional(readOnly = true)` 한 줄뿐이다.
|
||||
- [x] 기존 쓰기 메서드 5개의 메서드 레벨 `@Transactional`이 유지된다.
|
||||
|
||||
## 실행 순서
|
||||
|
||||
| 순서 | Goal | 완료 후 다음 Goal |
|
||||
|---:|---|---|
|
||||
| 1 | `P1-T1` | `P1-GATE` |
|
||||
| 2 | `P1-GATE` | 구현 완료 |
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-08-05: 운영 stack trace와 controller → service → repository → entity DTO 변환 흐름을 확인했다.
|
||||
- 2026-08-05: `Series.keywordList` lazy 접근과 트랜잭션 없는 `getDetail()`을 원인으로 확정했다.
|
||||
- 2026-08-05: PRD와 Plan/TASK를 작성했으며 production code와 테스트는 아직 변경하지 않았다.
|
||||
- 2026-08-05: 메서드 분류 재검토 결과 트랜잭션 없는 public 메서드 4개는 모두 조회이고, 쓰기 메서드 5개는 모두 메서드 레벨
|
||||
`@Transactional`을 보유함을 확인했다. 해결안을 `getDetail()` 메서드 단위에서 서비스 class-level read-only 기본값으로 정정했다.
|
||||
- 2026-08-05: `P1-T1` RED/GREEN/REFACTOR를 완료했다. 다음 Goal은 `P1-GATE`이며 Gate 체크박스는 아직 미완료다.
|
||||
- 2026-08-05: `P1-GATE`의 focused·characterization·정적 검사·Gradle task 확인·전체 테스트를 모두 통과해 Phase 1과 구현을 완료했다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 문서 작성 시점에는 구현용 RED/GREEN 테스트를 실행하지 않았다.
|
||||
- 2026-08-05: 문서 변경 후 `./gradlew --no-daemon tasks --all`로 계획에 사용한 Gradle 명령이 유효한지 확인했다.
|
||||
- sandbox 실행은 `/Users/klaus/.gradle/wrapper/dists/.../gradle-8.1.1-bin.zip.lck` 접근 제한으로 실패했다.
|
||||
- 승인 실행은 `BUILD SUCCESSFUL in 8s`로 통과했다.
|
||||
- 2026-08-05: `git diff --check`가 출력 없이 통과했고, 구현 파일과 테스트 파일은 변경하지 않았음을 확인했다.
|
||||
- 2026-08-05: class-level read-only 트랜잭션 기준으로 문서를 보완한 뒤 `./gradlew --no-daemon tasks --all`을 재실행해
|
||||
`BUILD SUCCESSFUL in 7s`를 확인했다.
|
||||
- 2026-08-05: production 수정 전 focused test를 실행해 `BUILD FAILED in 6m 50s`와
|
||||
`org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.creator.admin.content.series.Series.keywordList, could not initialize proxy - no Session`을 확인했다.
|
||||
- 2026-08-05: `CreatorAdminContentSeriesService`에 `@Transactional(readOnly = true)` 한 줄을 추가한 뒤 같은 focused test를
|
||||
재실행해 `BUILD SUCCESSFUL in 4m 52s`를 확인했다. 테스트는 fixture의 `seriesId`, `title`, `introduction`, `coverImageUrl`,
|
||||
`publishedDaysOfWeek`, `genre`, `keywords`, `isAdult`, `state`, `writer`, `studio` 값을 검증한다.
|
||||
- 2026-08-05: `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`
|
||||
를 실행해 `BUILD SUCCESSFUL in 47s`를 확인했다.
|
||||
- 2026-08-05: production diff는 `CreatorAdminContentSeriesService` 클래스 선언 위의 `@Transactional(readOnly = true)` 한 줄뿐이며,
|
||||
`createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, `updateSeriesOrders()`의 기존 메서드 레벨
|
||||
`@Transactional` 5개가 유지됨을 확인했다. API 계약, OSIV, entity mapping, repository query 변경은 없다.
|
||||
- 2026-08-05: `P1-GATE`에서 명령을 순차 실행해 다음 결과를 확인했다.
|
||||
- `./gradlew --no-daemon test --rerun-tasks --tests kr.co.vividnext.sodalive.creator.admin.content.series.CreatorAdminContentSeriesServiceIntegrationTest`: exit code 0, `BUILD SUCCESSFUL in 4m 44s`.
|
||||
- `./gradlew --no-daemon test --tests kr.co.vividnext.sodalive.v2.api.admin.aicharacter.series.LegacyCreatorAdminSeriesCharacterizationTest`: exit code 0, `BUILD SUCCESSFUL in 41s`.
|
||||
- `./gradlew --no-daemon ktlintCheck`: exit code 0, `BUILD SUCCESSFUL in 30s`.
|
||||
- `./gradlew --no-daemon tasks --all`: exit code 0, `BUILD SUCCESSFUL in 7s`.
|
||||
- `git diff --check`: exit code 0, 출력 없음.
|
||||
- `./gradlew --no-daemon test`: exit code 0, `BUILD SUCCESSFUL in 6m 20s`. 기본 계획의 targeted 검증 범위를 넘어 전체 테스트를 실행한 이유는 ultrawork verification 요구사항 때문이다.
|
||||
- 2026-08-05: Gate 검증 후 production diff가 class-level `@Transactional(readOnly = true)` 한 줄뿐이고, 새 테스트 파일은
|
||||
`CreatorAdminContentSeriesServiceIntegrationTest.kt` 하나이며, 기존 쓰기 메서드의 `@Transactional` 5개가 유지됨을 재확인했다.
|
||||
@@ -1,148 +0,0 @@
|
||||
# PRD: 크리에이터 관리자 시리즈 상세 LazyInitializationException 수정
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-08-05 |
|
||||
| 최종 수정일 | 2026-08-05 |
|
||||
| 대상 기능 | 크리에이터 관리자 시리즈 상세 조회 |
|
||||
| 관련 구현 계획 | `docs/20260805_크리에이터관리자_시리즈상세_LazyInitializationException_수정/plan-task.md` |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
`spring.jpa.open-in-view=false` 환경에서 `GET /creator-admin/audio-content/series/{seriesId}` 호출 시
|
||||
`Series.keywordList` 접근으로 발생하는 `LazyInitializationException`을 서비스 클래스의 기본 read-only 트랜잭션 경계로 방지한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- `CreatorAdminContentSeriesController.getDetail()`은 `CreatorAdminContentSeriesService.getDetail()`에 상세 조회를 위임한다.
|
||||
- `CreatorAdminContentSeriesService.getDetail()`에는 트랜잭션이 없으며,
|
||||
`CreatorAdminContentSeriesRepository.findByIdAndCreatorId()`가 반환한 `Series`로 상세 DTO를 생성한다.
|
||||
- 같은 서비스의 트랜잭션 없는 public 메서드는 모두 조회 기능이고, 데이터를 변경하는 public 메서드에는 이미 메서드 레벨
|
||||
`@Transactional`이 적용되어 있다.
|
||||
- `Series.keywordList`는 별도 fetch 설정이 없는 `@OneToMany`이므로 lazy 컬렉션이다.
|
||||
- `Series.toDetailResponse()`은 `keywordList.map { it.keyword!!.tag }`를 실행한다.
|
||||
- 운영·테스트 설정의 `spring.jpa.open-in-view=false` 때문에 리포지토리 호출 후 영속성 컨텍스트가 종료되고, DTO 변환 중
|
||||
`org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: kr.co.vividnext.sodalive.creator.admin.content.series.Series.keywordList, could not initialize proxy - no Session`
|
||||
예외가 발생한다.
|
||||
- 기존 `LegacyCreatorAdminSeriesCharacterizationTest`는 클래스 레벨 `@Transactional`과 직접 생성한 서비스 객체를 사용하므로,
|
||||
실제 Spring 서비스 프록시의 트랜잭션 유무에 따른 회귀를 검증하지 못한다.
|
||||
|
||||
문제를 해결했다는 판단은 외부 테스트 트랜잭션이 없는 OSIV off 통합 테스트에서 실제 Spring 서비스 프록시로 상세 조회 후
|
||||
키워드가 포함된 기존 응답을 정상 생성하는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- OSIV off 환경에서도 소유한 시리즈 상세 조회가 `LazyInitializationException` 없이 완료된다.
|
||||
- 서비스 클래스의 조회 기본값을 read-only 트랜잭션으로 두고, 시리즈 조회부터 `toDetailResponse()`의 lazy 컬렉션 접근까지
|
||||
같은 영속성 컨텍스트에서 처리한다.
|
||||
- 실제 Spring 서비스 프록시를 호출하는 통합 테스트로 수정 전 실패와 수정 후 성공을 검증한다.
|
||||
- 기존 endpoint, 인증·소유권 검사, 성공 응답 필드와 값 형식을 유지한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- `spring.jpa.open-in-view`를 활성화하지 않는다.
|
||||
- `Series.keywordList`를 전역 eager fetch로 변경하지 않는다.
|
||||
- 상세 조회 쿼리를 fetch join 또는 projection으로 재작성하지 않는다.
|
||||
- `Series.toDetailResponse()` 또는 `GetCreatorAdminContentSeriesDetailResponse` 구조를 변경하지 않는다.
|
||||
- 시리즈 목록·수정·콘텐츠 연결 등 다른 흐름을 함께 리팩터링하지 않는다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
- 대상 사용자: 본인이 소유한 시리즈 상세를 조회하는 `CREATOR` 역할의 크리에이터 관리자
|
||||
- 인증·권한: 기존 `@PreAuthorize("hasRole('CREATOR')")`와 인증 회원 검사를 유지한다.
|
||||
- 소유권: 기존 `findByIdAndCreatorId(id, creatorId)` 조건과 `creator.admin.series.invalid_access` 오류를 유지한다.
|
||||
|
||||
## 6. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CASD-001` | 확정 | 서비스 클래스에 read-only 트랜잭션을 기본 적용하고 기존 쓰기 메서드의 메서드 레벨 트랜잭션을 유지한다. | 키워드가 있는 소유 시리즈 조회가 OSIV off 환경에서 예외 없이 완료되고 기존 쓰기 메서드 annotation이 보존된다. | `P1-T1` |
|
||||
| `CASD-002` | 확정 | 기존 상세 조회 API 계약을 유지한다. | endpoint, 권한, 오류 key, 응답 DTO의 필드·형식이 바뀌지 않는다. | `P1-T1`, `P1-GATE` |
|
||||
| `CASD-003` | 확정 | 테스트 외부 트랜잭션 없이 실제 서비스 프록시를 검증한다. | 수정 전 `Series.keywordList` 예외를 재현하고, 수정 후 같은 테스트가 통과한다. | `P1-T1` |
|
||||
|
||||
## 7. API 계약
|
||||
|
||||
| Method | Path | 변경 사항 |
|
||||
|---|---|---|
|
||||
| `GET` | `/creator-admin/audio-content/series/{seriesId}` | 공개 계약 변경 없음 |
|
||||
|
||||
- 성공 응답은 기존 `ApiResponse.ok(GetCreatorAdminContentSeriesDetailResponse)`를 유지한다.
|
||||
- `seriesId`, `title`, `introduction`, `coverImageUrl`, `publishedDaysOfWeek`, `genre`, `keywords`, `isAdult`,
|
||||
`state`, `writer`, `studio` 필드와 기존 문자열 변환 규칙을 유지한다.
|
||||
- 인증 실패와 타 소유자·미존재 시리즈 오류 처리를 변경하지 않는다.
|
||||
|
||||
## 8. 해결 방안
|
||||
|
||||
`CreatorAdminContentSeriesService` 클래스에 `@Transactional(readOnly = true)`를 기본 적용한다.
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
@Transactional(readOnly = true)
|
||||
class CreatorAdminContentSeriesService(
|
||||
```
|
||||
|
||||
`getDetail()`의 `findByIdAndCreatorId()` 조회와 `series.toDetailResponse()` 변환이 이 경계 안에서 모두 끝나므로 `keywordList`를
|
||||
정상 초기화할 수 있다. 다른 조회 메서드도 같은 기본 경계를 사용하며, 기존 쓰기 메서드의 메서드 레벨 `@Transactional`은
|
||||
class-level `readOnly = true`를 쓰기 트랜잭션으로 재정의한다. 이미 서비스 파일에서 `Transactional`을 사용하고 있어 새 의존성이나
|
||||
import는 필요하지 않다.
|
||||
|
||||
### 현재 메서드 분류
|
||||
|
||||
| 기본 read-only 트랜잭션을 사용하는 조회 메서드 | 메서드 레벨 쓰기 트랜잭션을 유지하는 메서드 |
|
||||
|---|---|
|
||||
| `getSeriesList()` | `createSeries()` |
|
||||
| `getDetail()` | `modifySeries()` |
|
||||
| `getSeriesContent()` | `addingContentToTheSeries()` |
|
||||
| `searchContentNotInSeries()` | `removeContentInTheSeries()` |
|
||||
| | `updateSeriesOrders()` |
|
||||
|
||||
### 제외한 대안
|
||||
|
||||
- OSIV 활성화: 요청 전체로 영속성 컨텍스트를 확장해 현재 저장소 정책을 되돌리므로 제외한다.
|
||||
- `keywordList` eager 변경: 모든 `Series` 조회 비용에 영향을 주는 전역 변경이므로 제외한다.
|
||||
- fetch join/projection 추가: 이 endpoint만의 결함을 고치는 데 리포지토리 계약과 쿼리 변경이 불필요하므로 제외한다.
|
||||
- 컨트롤러 트랜잭션: 영속성 및 DTO 변환 경계는 서비스가 소유하는 기존 구조에 맞지 않으므로 제외한다.
|
||||
- `getDetail()`에만 read-only 트랜잭션 적용: 현재 트랜잭션 없는 메서드가 모두 조회 기능이므로 class-level 기본값보다 반복과
|
||||
누락 가능성이 크다.
|
||||
|
||||
## 9. 기술적 제약
|
||||
|
||||
- Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA, Hibernate, JUnit 5, Gradle Wrapper를 사용한다.
|
||||
- `src/main/resources/application.yml`과 `src/test/resources/application.yml`의 `spring.jpa.open-in-view=false`를 유지한다.
|
||||
- 테스트는 클래스 외부 트랜잭션을 비활성화하고 fixture 생성만 `TransactionTemplate`로 분리한다.
|
||||
- 실제 Spring `CreatorAdminContentSeriesService` 빈을 주입해 proxy annotation 동작을 검증한다.
|
||||
- production code 변경은 `CreatorAdminContentSeriesService` class-level annotation 한 줄로 제한한다.
|
||||
- `createSeries()`, `modifySeries()`, `addingContentToTheSeries()`, `removeContentInTheSeries()`, `updateSeriesOrders()`의 기존
|
||||
메서드 레벨 `@Transactional`을 유지한다.
|
||||
|
||||
## 10. 성공 기준
|
||||
|
||||
- [ ] 수정 전 focused test가 `Series.keywordList`의 `LazyInitializationException`으로 실패한다.
|
||||
- [ ] 서비스 클래스에 `@Transactional(readOnly = true)` 적용 후 같은 테스트가 통과한다.
|
||||
- [ ] 응답의 `keywords`와 주요 기존 상세 필드 값이 fixture와 일치한다.
|
||||
- [ ] 기존 소유권·상세 동작 characterization test와 `ktlintCheck`가 통과한다.
|
||||
- [ ] `tasks --all`과 `git diff --check`가 통과한다.
|
||||
- [ ] API 스키마, 엔티티 fetch 전략, repository query에는 변경이 없다.
|
||||
- [ ] 모든 기존 쓰기 메서드의 메서드 레벨 `@Transactional`이 유지된다.
|
||||
|
||||
## 11. 요구사항 추적표
|
||||
|
||||
| 요구사항 | 계획 Phase | Goal | 자동 검증 |
|
||||
|---|---:|---|---|
|
||||
| `CASD-001`, `CASD-003` | 1 | `P1-T1` | `CreatorAdminContentSeriesServiceIntegrationTest` |
|
||||
| `CASD-002` | 1 | `P1-T1`, `P1-GATE` | focused test, `LegacyCreatorAdminSeriesCharacterizationTest` |
|
||||
|
||||
## 12. Open Questions
|
||||
|
||||
없음. 운영 stack trace, entity mapping, 서비스 호출 경계와 OSIV 설정으로 원인과 최소 해결 범위가 확인됐다.
|
||||
|
||||
## 13. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-05 | `DEC-CASD-001` | 확정 | `CreatorAdminContentSeriesService.getDetail()`에 메서드 단위 read-only 트랜잭션을 적용한다. | DTO 변환이 이미 서비스 내부에 있어 한 줄로 lazy 접근 전체를 영속성 컨텍스트 안에 포함할 수 있다. | `CASD-001`, `CASD-002`, `P1-T1` |
|
||||
| 2026-08-05 | `DEC-CASD-002` | 확정 | 실제 Spring 서비스 프록시와 외부 트랜잭션이 없는 통합 테스트로 회귀를 고정한다. | 기존 characterization test는 테스트 트랜잭션과 직접 생성한 서비스 때문에 annotation 회귀를 검증할 수 없다. | `CASD-003`, `P1-T1` |
|
||||
| 2026-08-05 | `DEC-CASD-003` | 정정 | `DEC-CASD-001`의 메서드 단위 적용을 class-level `@Transactional(readOnly = true)` 적용으로 정정하고 기존 쓰기 메서드의 메서드 레벨 `@Transactional`을 유지한다. | 트랜잭션 없는 기존 public 메서드는 모두 조회 기능이며, 쓰기 메서드는 이미 메서드 레벨 annotation으로 read-only 기본값을 재정의한다. | `CASD-001`, `CASD-002`, `P1-T1` |
|
||||
@@ -1,293 +0,0 @@
|
||||
# 크리에이터 채널 홈 커뮤니티 응답 확장 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 크리에이터 채널 홈의 `notices`와 `communities` 게시글에 실제 고정 여부와 댓글 가능 여부를 non-null Boolean으로 제공한다.
|
||||
|
||||
**Architecture:** 기존 `CreatorChannelCommunityPost`가 보유한 두 값을 공유 `CreatorChannelCommunityPostResponse.from`에서 그대로 매핑한다. 조회 계층과 데이터 모델은 건드리지 않고 공개 응답 DTO와 직접 영향받는 직렬화·E2E 테스트만 수정한다.
|
||||
|
||||
**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, Jackson, JUnit 5, MockMvc, Gradle Wrapper
|
||||
|
||||
## 문서 상태
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-10 |
|
||||
| 요구사항 기준 | `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/prd.md` |
|
||||
| API 기준 | PRD `7. API 계약` |
|
||||
| 현재 Phase | Phase 1 완료: 홈 커뮤니티 상태 응답 확장 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
| 다음 Goal | 없음 |
|
||||
| Gate | `P1-GATE` 완료 |
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `1/1` | 없음 | 없음 |
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 완료된 Task와 검증 기록은 되돌리거나 삭제하지 않는다.
|
||||
- goal 실행 시 token budget을 설정하지 않는다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- `GET /api/v2/creator-channels/{creatorId}/home`의 `notices[*]`, `communities[*]`에 `isPinned` 추가
|
||||
- 같은 게시글 응답에 `isCommentAvailable` 추가
|
||||
- 도메인 값의 DTO 매핑과 정확한 `is*` JSON 필드명 검증
|
||||
- 기존 홈 API E2E와 Kotlin 포맷 회귀 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- 조회 조건, 정렬, 최대 노출 개수와 권한 정책 변경
|
||||
- 커뮤니티 탭·상세·댓글 API 변경
|
||||
- DB, entity, domain model, repository 수정
|
||||
- 전용 DTO 분리, 신규 dependency와 관련 없는 리팩터링
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Kotlin + Java 17, Spring Boot 2.7.14와 기존 Jackson 직렬화 방식을 유지한다.
|
||||
- 기존 공유 `CreatorChannelCommunityPostResponse`와 `from` 변환만 확장한다.
|
||||
- 공개 필드명은 `isPinned`, `isCommentAvailable`로 고정하고 두 값은 nullable로 만들지 않는다.
|
||||
- `CreatorChannelCommunityPost.isPinned`, `CreatorChannelCommunityPost.isCommentAvailable` 값을 재계산하지 않는다.
|
||||
- 구현은 RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR 순서로 진행한다.
|
||||
- focused test에서 시작해 직접 영향받는 홈 E2E와 `ktlintCheck`까지만 확장한다.
|
||||
- 전체 `./gradlew test`는 공통 경계 영향이나 targeted test로 판단할 수 없는 실패가 있을 때만 실행한다.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 홈 커뮤니티 상태 응답 확장
|
||||
|
||||
**Phase 결과:** 홈 API의 공지와 일반 커뮤니티 게시글이 고정 여부와 댓글 가능 여부를 정확한 Boolean 필드로 반환한다.
|
||||
|
||||
**선행조건:** 승인된 PRD의 `CCHC-001~004`, `DEC-001~003` 확인.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, 체크박스와 실제 검증 결과를 Progress에 누적.
|
||||
|
||||
### Task 1.1 공유 게시글 응답에 상태 필드 추가
|
||||
|
||||
**Goal 실행 `P1-T1`:** 공유 홈 게시글 응답이 도메인의 `isPinned`, `isCommentAvailable`을 정확한 JSON 필드로 직렬화하게 한다.
|
||||
|
||||
- **시작 조건:** PRD 상태가 `구현 기준 확정`이고 현재 활성 Goal이 없다.
|
||||
- **완료 증거:** 아래 5단계 완료, controller focused test와 홈 E2E 통과, 변경 파일과 결과를 Progress에 기록.
|
||||
- **범위 밖:** 조회 service/repository, domain model, 다른 커뮤니티 API 수정.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt:92`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt:57`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt:173`
|
||||
- Modify: `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `CreatorChannelCommunityPost.isPinned: Boolean`, `CreatorChannelCommunityPost.isCommentAvailable: Boolean`
|
||||
- Produces: `CreatorChannelCommunityPostResponse.isPinned: Boolean`, `CreatorChannelCommunityPostResponse.isCommentAvailable: Boolean`
|
||||
- JSON: `data.notices[*].isPinned`, `data.notices[*].isCommentAvailable`, `data.communities[*].isPinned`, `data.communities[*].isCommentAvailable`
|
||||
|
||||
- [x] **RED:** `CreatorChannelHomeControllerTest.shouldReturnCreatorChannelHomeForAuthenticatedMember`의 일반 커뮤니티 fixture를 아래처럼 공지와 반대 상태로 만들고, 네 JSON 경로 assertion을 추가한다. 같은 assertion을 `CreatorChannelHomeEndToEndTest.shouldAssembleCreatorChannelHomeSectionsThroughSingleHttpRequest`에도 추가하되 E2E fixture의 댓글 가능 값은 기존 `true`를 사용한다.
|
||||
|
||||
```kotlin
|
||||
communities = listOf(
|
||||
post.copy(
|
||||
postId = 302L,
|
||||
content = "community",
|
||||
isCommentAvailable = false,
|
||||
isPinned = false
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
|
||||
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
|
||||
.andExpect(jsonPath("$.data.communities[0].isPinned").value(false))
|
||||
.andExpect(jsonPath("$.data.communities[0].isCommentAvailable").value(false))
|
||||
```
|
||||
|
||||
E2E assertion은 실제 저장값과 조회 조건을 검증한다.
|
||||
|
||||
```kotlin
|
||||
.andExpect(jsonPath("$.data.notices[0].isPinned").value(true))
|
||||
.andExpect(jsonPath("$.data.notices[0].isCommentAvailable").value(true))
|
||||
.andExpect(jsonPath("$.data.communities[0].isPinned").value(false))
|
||||
.andExpect(jsonPath("$.data.communities[0].isCommentAvailable").value(true))
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** 다음 focused test를 실행해 `$.data.notices[0].isPinned` 등 신규 JSON 경로에 값이 없어 실패하는지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --no-daemon
|
||||
```
|
||||
|
||||
**Expected:** 테스트가 신규 필드 미직렬화로 실패한다. compile 오류나 fixture 오류는 RED 증거로 인정하지 않는다.
|
||||
|
||||
- [x] **GREEN:** `CreatorChannelCommunityPostResponse`에 기존 커뮤니티 탭 DTO와 같은 `@JsonProperty` 관례로 필드를 추가하고 `from`에서 도메인 값을 직접 매핑한다.
|
||||
|
||||
```kotlin
|
||||
data class CreatorChannelCommunityPostResponse(
|
||||
val postId: Long,
|
||||
val creatorId: Long,
|
||||
val creatorNickname: String,
|
||||
val creatorProfileUrl: String,
|
||||
val imageUrl: String?,
|
||||
val audioUrl: String?,
|
||||
val content: String,
|
||||
val price: Int,
|
||||
val dateUtc: String,
|
||||
val existOrdered: Boolean,
|
||||
@JsonProperty("isCommentAvailable")
|
||||
val isCommentAvailable: Boolean,
|
||||
val likeCount: Int,
|
||||
val commentCount: Int,
|
||||
@JsonProperty("isPinned")
|
||||
val isPinned: Boolean,
|
||||
@JsonProperty("isLiked")
|
||||
val isLiked: Boolean
|
||||
) {
|
||||
companion object {
|
||||
fun from(post: CreatorChannelCommunityPost): CreatorChannelCommunityPostResponse {
|
||||
return CreatorChannelCommunityPostResponse(
|
||||
postId = post.postId,
|
||||
creatorId = post.creatorId,
|
||||
creatorNickname = post.creatorNickname,
|
||||
creatorProfileUrl = post.creatorProfileUrl,
|
||||
imageUrl = post.imageUrl,
|
||||
audioUrl = post.audioUrl,
|
||||
content = post.content,
|
||||
price = post.price,
|
||||
dateUtc = post.createdAt.toUtcIso(),
|
||||
existOrdered = post.existOrdered,
|
||||
isCommentAvailable = post.isCommentAvailable,
|
||||
likeCount = post.likeCount,
|
||||
commentCount = post.commentCount,
|
||||
isPinned = post.isPinned,
|
||||
isLiked = post.isLiked
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** controller test와 홈 E2E를 함께 실행해 공유 DTO 매핑, JSON 이름, 실제 조회값이 모두 통과하는지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' \
|
||||
--no-daemon
|
||||
```
|
||||
|
||||
**Expected:** 두 테스트 클래스가 `BUILD SUCCESSFUL`로 종료된다.
|
||||
|
||||
- [x] **REFACTOR:** 새 abstraction을 만들지 않고 이번 Task가 추가한 fixture·필드·매핑만 정리한다. 다음 명령을 실행하고 결과를 `P1-T1` Progress에 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew ktlintCheck --no-daemon
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 두 명령 모두 exit code `0`.
|
||||
|
||||
- [x] **Commit:** 커밋 전후 규칙 검증과 함께 구현·테스트·Progress 변경만 커밋한다.
|
||||
|
||||
```bash
|
||||
work/scripts/check-commit-message-rules.sh --message "feat(creator-channel): 홈 커뮤니티 응답 상태를 추가한다"
|
||||
git add \
|
||||
src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt \
|
||||
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt \
|
||||
src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt \
|
||||
docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md
|
||||
git commit -m "feat(creator-channel): 홈 커뮤니티 응답 상태를 추가한다"
|
||||
work/scripts/check-commit-message-rules.sh HEAD
|
||||
```
|
||||
|
||||
### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** PRD의 네 확정 요구사항과 작은 변경 범위를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1`의 체크박스, focused test, E2E, 포맷과 Progress 기록 완료.
|
||||
- **완료 증거:** 아래 자동 검증 통과, PRD 추적성 확인, Gate 결과를 Progress에 기록.
|
||||
- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' \
|
||||
--tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' \
|
||||
--no-daemon
|
||||
./gradlew ktlintCheck --no-daemon
|
||||
./gradlew tasks --all --no-daemon
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 모든 명령이 exit code `0`이고 다음 조건이 충족된다.
|
||||
|
||||
- [x] `notices[*]`와 `communities[*]`에 두 non-null Boolean이 존재한다. (`CCHC-001~003`)
|
||||
- [x] `notices[*].isPinned == true`, `communities[*].isPinned == false`가 실제 조회 조건과 일치한다.
|
||||
- [x] 댓글 가능 값이 `true`와 `false` 모두 그대로 직렬화된다.
|
||||
- [x] endpoint, 인증, 기존 홈 응답 필드와 조회 결과가 유지된다. (`CCHC-004`)
|
||||
- [x] 전체 회귀 테스트를 생략한 근거와 대신 실행한 focused·E2E 검증을 Progress에 기록한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | 승인된 PRD | 아니요 | RED 실패 원인이 신규 필드 누락인지 다시 확인 |
|
||||
| 2 | `P1-GATE` | `P1-T1` 완료 | 아니요 | 실패를 소유한 Task에 회귀 수정 goal 추가 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-GATE
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 공개 필드명이나 기존 홈 응답 필드를 근거 없이 변경하지 않는다.
|
||||
- domain, service, repository에서 두 Boolean을 재계산하지 않는다.
|
||||
- 전용 DTO, helper, 신규 dependency를 추가하지 않는다.
|
||||
- 테스트를 삭제·skip·완화하거나 관련 없는 코드를 정리하지 않는다.
|
||||
- 기존 완료 기록과 Decision Log를 삭제하거나 덮어쓰지 않는다.
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- 구현 범위가 바뀌면 PRD Decision Log와 이 계획을 먼저 갱신한다.
|
||||
- 신규 JSON 필드 외 실패가 발생하면 원인을 분리하고 범위 밖 변경을 임의로 적용하지 않는다.
|
||||
- targeted test로 영향 범위를 판단할 수 없을 때만 전체 `./gradlew test --no-daemon`을 실행한다.
|
||||
- 체크박스와 완료 증거, Progress 기록이 모두 충족된 뒤에만 goal을 완료 처리한다.
|
||||
|
||||
## Progress
|
||||
|
||||
기존 기록을 삭제하지 않고 Goal별 실행 결과를 아래에 누적한다.
|
||||
|
||||
### P1-T1
|
||||
|
||||
- RED 작성: controller fixture의 일반 게시글을 `postId=302L`, `content="community"`, `isCommentAvailable=false`, `isPinned=false`로 구성하고 controller 및 E2E에 공지/일반 게시글 상태 assertion을 추가했다.
|
||||
- RED 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --no-daemon`은 exit code `1`로 실패했다. `CreatorChannelHomeControllerTest.kt:138`의 `$.data.notices[0].isPinned` assertion에서 `PathNotFoundException`이 발생해 신규 JSON 필드 미직렬화가 원인임을 확인했으며 compile 및 fixture 오류는 없었다.
|
||||
- GREEN 구현: `CreatorChannelCommunityPostResponse`에 non-null Boolean `isCommentAvailable`, `isPinned`을 명시적 `@JsonProperty`와 함께 추가하고 `post.isCommentAvailable`, `post.isPinned`을 직접 매핑했다.
|
||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 54s`로 종료됐다. controller focused test와 직접 영향받는 홈 E2E가 함께 통과해 공지 `true/true`, 일반 게시글 `false/false` controller fixture 및 `false/true` 실제 E2E 저장값의 매핑과 JSON 이름을 확인했다.
|
||||
- REFACTOR 확인: 새 abstraction 없이 계획된 fixture, 응답 필드, 직접 매핑만 유지했다. `./gradlew ktlintCheck --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 25s`였고 `git diff --check`도 exit code `0`이었다.
|
||||
- 전체 회귀 생략: 이번 변경은 공유 홈 DTO의 additive 직렬화 필드 두 개와 직접 영향받는 테스트에 한정되고 controller focused test와 실제 저장·조회 E2E가 모두 통과했으므로 계획의 조건에 따라 전체 `./gradlew test`는 실행하지 않았다.
|
||||
- 변경 파일: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/dto/CreatorChannelHomeResponse.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/adapter/in/web/CreatorChannelHomeControllerTest.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt`, `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md`.
|
||||
- 커밋 검증: 커밋 전 `work/scripts/check-commit-message-rules.sh --message "feat(creator-channel): 홈 커뮤니티 응답 상태를 추가한다"`가 모든 규칙을 `[PASS]`로 확인했다. 최종 diff는 계획된 네 파일만 포함하고 비밀값, 관련 없는 변경, agent footer가 없음을 자체 검토했다. 커밋 후 `work/scripts/check-commit-message-rules.sh HEAD`를 커밋 `d99104d945743663803b506417b4d2ad3ac4b0a2`에 실행해 모든 규칙이 `[PASS]`임을 확인했다.
|
||||
|
||||
### P1-GATE
|
||||
|
||||
- 자동 검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.adapter.in.web.CreatorChannelHomeControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest' --no-daemon`은 최초 실행에서 exit code `0`, `BUILD SUCCESSFUL in 40s`였고 테스트 산출물이 `UP-TO-DATE`였다. 실제 HTTP 경계를 새로 실행하기 위해 `./gradlew cleanTest --no-daemon`을 exit code `0`, `BUILD SUCCESSFUL in 16s`로 수행한 뒤 같은 Gate 테스트 명령을 다시 실행해 `:test` 실행과 exit code `0`, `BUILD SUCCESSFUL in 48s`를 확인했다.
|
||||
- 정적·빌드 검증: `./gradlew ktlintCheck --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 1m 22s`, `./gradlew tasks --all --no-daemon`은 exit code `0`, `BUILD SUCCESSFUL in 22s`, `git diff --check`는 출력 없이 exit code `0`이었다.
|
||||
- 요구사항 추적: `CCHC-001~003`은 `notices[*]`, `communities[*]`의 non-null Boolean `isPinned`, `isCommentAvailable`과 도메인 값 직접 매핑으로 확인했다. 실제 저장·조회 E2E에서 공지는 `isPinned=true`, 일반 게시글은 `isPinned=false`였고 controller fixture와 E2E를 합쳐 `isCommentAvailable=true/false`가 모두 그대로 직렬화됨을 확인했다. `CCHC-004`는 기존 `GET /api/v2/creator-channels/{creatorId}/home`, 비회원 `401`, 인증 회원 `200`, 기존 홈 섹션·필드·조회 결과 assertion의 유지와 focused·E2E 통과로 확인했다.
|
||||
- 실제 HTTP 표면: `CreatorChannelHomeEndToEndTest.shouldAssembleCreatorChannelHomeSectionsThroughSingleHttpRequest`가 인증된 MockMvc `GET`을 controller에서 service, repository, H2 database 조회까지 통과시켜 실제 API 응답의 공지와 일반 게시글 값을 검증했다.
|
||||
- 전체 회귀 생략: 변경은 공유 홈 응답 DTO의 additive Boolean 두 필드와 직접 직렬화 경계에 한정되고 focused controller test와 실제 저장·조회 MockMvc E2E로 영향을 판정할 수 있었으며 모호한 실패가 없었다. 계획의 실행 조건에 따라 전체 `./gradlew test`는 실행하지 않았다.
|
||||
- 변경 문서 상태: 구현·테스트는 커밋 `d99104d945743663803b506417b4d2ad3ac4b0a2`에 있고, Gate에서는 기존 `P1-T1` 기록과 이미 존재한 커밋 후 검증 문장을 보존한 채 `plan-task.md`와 `prd.md`의 완료 상태만 변경했다. Kotlin 코드, 테스트, Decision Log는 변경하지 않았다.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-10 | `PLAN-DEC-001` | 확정 | 하나의 구현 Task와 하나의 Gate로 실행한다. | 공유 DTO 한 곳의 additive 변경이며 별도 구현 경계가 없다. | `P1-T1`, `P1-GATE` |
|
||||
| 2026-08-10 | `PLAN-DEC-002` | 확정 | 전체 회귀는 조건부로 두고 controller test, 홈 E2E, `ktlintCheck`를 기본 검증으로 사용한다. | 변경이 홈 게시글 응답 직렬화 경계에 한정된다. | `P1-T1`, `P1-GATE` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
없음.
|
||||
@@ -1,144 +0,0 @@
|
||||
# PRD: 크리에이터 채널 홈 커뮤니티 응답 확장
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-08-10 |
|
||||
| 최종 수정일 | 2026-08-10 |
|
||||
| 대상 제품 | 크리에이터 채널 홈 API |
|
||||
| 작성자·결정권자 | 사용자 |
|
||||
| 관련 API Contract | 별도 문서 없음, 이 문서의 `7. API 계약`을 기준으로 함 |
|
||||
| 관련 구현 계획 | `docs/20260810_크리에이터_채널_홈_커뮤니티_응답_확장/plan-task.md` |
|
||||
| 관련 review | 없음 |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
크리에이터 채널 홈 API의 `notices`와 `communities` 게시글 응답에 고정 여부와 댓글 가능 여부를 각각 `isPinned`, `isCommentAvailable`로 제공한다. 기존 조회 결과가 이미 보유한 값을 응답에 그대로 반영해 클라이언트가 별도 추론 없이 게시글 상태를 표시할 수 있게 한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- 현재 `GET /api/v2/creator-channels/{creatorId}/home`의 게시글 도메인 모델에는 고정 여부와 댓글 가능 여부가 있지만 홈 응답 DTO에는 두 값이 없다.
|
||||
- 클라이언트는 홈의 게시글이 고정 글인지, 댓글 작성이 가능한지 응답만으로 일관되게 판단할 수 없다.
|
||||
- `notices`와 `communities`가 같은 `CreatorChannelCommunityPostResponse`를 사용하므로 두 배열의 계약을 함께 확장해야 한다.
|
||||
|
||||
문제를 해결했다는 판단은 두 배열의 각 게시글에 실제 도메인 값과 일치하는 non-null Boolean 필드가 직렬화되는지로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 홈 응답의 `notices[*]`와 `communities[*]`에 `isPinned`를 제공한다.
|
||||
- 홈 응답의 `notices[*]`와 `communities[*]`에 `isCommentAvailable`을 제공한다.
|
||||
- 기존 커뮤니티 조회 결과의 값을 변형하거나 재계산하지 않고 그대로 사용한다.
|
||||
- 기존 홈 API의 endpoint, 인증 정책과 기존 응답 필드를 유지한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 커뮤니티 조회 조건, 고정 정렬, 최대 노출 개수는 변경하지 않는다.
|
||||
- 댓글 작성·수정·삭제 정책과 API는 변경하지 않는다.
|
||||
- 커뮤니티 탭 및 게시글 상세 API 계약은 변경하지 않는다.
|
||||
- DB schema, entity, domain model과 repository query는 변경하지 않는다.
|
||||
- `notices`와 `communities` 전용 응답 DTO를 새로 분리하지 않는다.
|
||||
- 관련 없는 홈 응답 필드나 패키지 구조는 리팩터링하지 않는다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
- 대상 사용자: 인증 후 크리에이터 채널 홈을 조회하는 앱 사용자
|
||||
- 인증 및 권한: 기존 홈 API 정책을 그대로 사용한다.
|
||||
- 차단, 성인 콘텐츠와 구매 여부 정책: 기존 홈 API 조회 결과를 그대로 사용한다.
|
||||
|
||||
## 6. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `CCHC-001` | 확정 | `notices[*]`와 `communities[*]`에 `isPinned`를 제공한다. | JSON 필드명이 정확히 `isPinned`이고 값은 `CreatorChannelCommunityPost.isPinned`와 일치한다. | `7. API 계약`, `P1-T1` |
|
||||
| `CCHC-002` | 확정 | `notices[*]`와 `communities[*]`에 `isCommentAvailable`을 제공한다. | JSON 필드명이 정확히 `isCommentAvailable`이고 값은 `CreatorChannelCommunityPost.isCommentAvailable`과 일치한다. | `7. API 계약`, `P1-T1` |
|
||||
| `CCHC-003` | 확정 | 두 필드는 non-null Boolean으로 응답한다. | 게시글이 존재하면 두 필드가 누락되거나 `null`이 되지 않는다. | `P1-T1`, `P1-GATE` |
|
||||
| `CCHC-004` | 확정 | 기존 홈 API 계약과 조회 정책을 보존한다. | endpoint, 인증 정책, 기존 응답 필드와 게시글 선택·정렬 결과가 변경되지 않는다. | `P1-GATE` |
|
||||
|
||||
### 상태별 기대값
|
||||
|
||||
| 배열 | `isPinned` | `isCommentAvailable` |
|
||||
|---|---:|---|
|
||||
| `notices` | 해당 게시글의 실제 값. 현재 홈 조회 조건상 `true` | 해당 게시글의 댓글 허용 설정값 |
|
||||
| `communities` | 해당 게시글의 실제 값. 현재 홈 조회 조건상 `false` | 해당 게시글의 댓글 허용 설정값 |
|
||||
|
||||
- 댓글 수가 `0`이어도 `isCommentAvailable`을 별도로 계산하지 않는다.
|
||||
- 빈 `notices` 또는 `communities`는 기존처럼 빈 배열로 응답한다.
|
||||
|
||||
## 7. API 계약
|
||||
|
||||
### 7.1 Endpoint
|
||||
|
||||
- Method: `GET`
|
||||
- Path: `/api/v2/creator-channels/{creatorId}/home`
|
||||
- 요청과 인증 정책: 변경 없음
|
||||
|
||||
### 7.2 응답 확장
|
||||
|
||||
`data.notices[*]`와 `data.communities[*]`에 다음 필드를 추가한다.
|
||||
|
||||
| 필드 | 타입 | nullable | 의미 |
|
||||
|---|---|---:|---|
|
||||
| `isPinned` | Boolean | 아니요 | 해당 커뮤니티 게시글의 고정 여부 |
|
||||
| `isCommentAvailable` | Boolean | 아니요 | 해당 커뮤니티 게시글의 댓글 작성 허용 여부 |
|
||||
|
||||
예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"notices": [
|
||||
{
|
||||
"postId": 301,
|
||||
"isPinned": true,
|
||||
"isCommentAvailable": true
|
||||
}
|
||||
],
|
||||
"communities": [
|
||||
{
|
||||
"postId": 302,
|
||||
"isPinned": false,
|
||||
"isCommentAvailable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
예시는 추가 필드와 배열별 의미만 나타내며 기존 게시글 응답 필드는 그대로 유지한다.
|
||||
|
||||
## 8. 기술적 제약
|
||||
|
||||
- Kotlin + Java 17, Spring Boot 2.7.14와 기존 Jackson 직렬화 방식을 유지한다.
|
||||
- 기존 공유 `CreatorChannelCommunityPostResponse`와 `from` 변환을 재사용한다.
|
||||
- `is*` JSON 이름이 변경되지 않도록 기존 Boolean 응답 필드의 `@JsonProperty` 관례를 따른다.
|
||||
- 신규 dependency와 별도 abstraction을 추가하지 않는다.
|
||||
- 구현은 응답 DTO와 직접 영향받는 테스트로 제한한다.
|
||||
|
||||
## 9. 성공 기준
|
||||
|
||||
- [x] `notices[*].isPinned`와 `notices[*].isCommentAvailable`이 실제 값으로 응답된다. (`CCHC-001~003`)
|
||||
- [x] `communities[*].isPinned`와 `communities[*].isCommentAvailable`이 실제 값으로 응답된다. (`CCHC-001~003`)
|
||||
- [x] 기존 홈 API 응답 및 조회 흐름 회귀 테스트가 통과한다. (`CCHC-004`)
|
||||
- [x] `ktlintCheck`가 통과한다.
|
||||
- [x] 전체 회귀 테스트를 생략하면 작은 응답 DTO 변경이라는 근거와 대신 실행한 focused·영향 범위 테스트를 검증 기록에 남긴다.
|
||||
|
||||
## 10. Open Questions
|
||||
|
||||
없음.
|
||||
|
||||
## 11. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||
|---|---:|---|---|---|
|
||||
| `CCHC-001~003` | 1 | `P1-T1` | `CreatorChannelHomeControllerTest` | 홈 응답 JSON 필드명과 Boolean 값 확인 |
|
||||
| `CCHC-004` | 1 | `P1-GATE` | `CreatorChannelHomeEndToEndTest`, `ktlintCheck` | 기존 필드 유지 여부 확인 |
|
||||
|
||||
## 12. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-10 | `DEC-001` | 확정 | `isPinned`, `isCommentAvailable`을 `notices`와 `communities` 모두에 추가한다. | 두 배열이 같은 게시글 응답 DTO를 사용하며 사용자가 인터뷰 선택지 A를 승인했다. | `CCHC-001~004`, `P1-T1`, `P1-GATE` |
|
||||
| 2026-08-10 | `DEC-002` | 확정 | 공유 응답 DTO를 확장하고 전용 DTO는 분리하지 않는다. | 기존 도메인 값과 변환 경로를 재사용하는 최소 변경이다. | `CCHC-001~004`, `P1-T1` |
|
||||
| 2026-08-10 | `DEC-003` | 확정 | PRD를 구현 기준으로 승인한다. | 사용자가 작성된 PRD를 검토하고 승인했다. | 문서 전체, `plan-task.md` |
|
||||
@@ -1,41 +0,0 @@
|
||||
SET @schema_name := DATABASE();
|
||||
|
||||
SET @lang_column_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'event'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @add_lang_column_sql := IF(
|
||||
@lang_column_exists = 0,
|
||||
'ALTER TABLE `event` ADD COLUMN lang VARCHAR(10) NULL COMMENT ''이벤트 노출 언어'' AFTER title',
|
||||
'SELECT ''event.lang already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
|
||||
EXECUTE add_lang_column_stmt;
|
||||
DEALLOCATE PREPARE add_lang_column_stmt;
|
||||
|
||||
UPDATE `event`
|
||||
SET lang = 'KO'
|
||||
WHERE lang IS NULL;
|
||||
|
||||
SET @lang_column_nullable := (
|
||||
SELECT IS_NULLABLE
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'event'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @alter_lang_column_sql := IF(
|
||||
@lang_column_nullable = 'YES',
|
||||
'ALTER TABLE `event` MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''이벤트 노출 언어 (KO 기본, EN/JA 등록 가능)''',
|
||||
'SELECT ''event.lang already normalized'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
|
||||
EXECUTE alter_lang_column_stmt;
|
||||
DEALLOCATE PREPARE alter_lang_column_stmt;
|
||||
@@ -1,834 +0,0 @@
|
||||
# 이벤트 접속 국가별 언어 필터 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 관리자가 언어를 지정해 이벤트를 등록·식별하고, 앱 이벤트 목록과 팝업이 기존 국가 판정에 맞는 언어만 반환하게 한다.
|
||||
|
||||
**Architecture:** 기존 `Lang`과 `MemberContentPreferenceService.resolveCountryCode(member)`를 재사용한다. `EventRepository`에 nullable `lang` 조건을 추가해 두 앱 API는 KO/JA를 전달하고, 관리자 목록과 콘텐츠 메인 탭의 기존 내부 조회는 null을 전달해 언어 전체 조회를 유지한다.
|
||||
|
||||
**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, JPA/QueryDSL, JUnit 5, Mockito, MockMvc, Gradle Wrapper, MySQL
|
||||
|
||||
**Spec:** `docs/20260819_이벤트_접속국가별_언어필터/prd.md`
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 및 회귀 수정 완료 |
|
||||
| 작성일 | 2026-08-19 |
|
||||
| 요구사항 기준 | `docs/20260819_이벤트_접속국가별_언어필터/prd.md` |
|
||||
| API 기준 | PRD 8절 |
|
||||
| 현재 Phase | Phase 2 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
| 다음 Goal | 없음 |
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `Event.lang`은 기존 `Lang` enum을 재사용하고 별도 언어 타입을 만들지 않는다.
|
||||
- 관리자 등록은 `KO`, `EN`, `JA`를 허용하고 누락·잘못된 값은 저장·S3 업로드 전에 거부한다.
|
||||
- 이벤트 수정 API와 레거시 `POST /event`의 요청 계약은 변경하지 않는다.
|
||||
- 기존 이벤트와 레거시 등록의 기본 언어는 `KO`다.
|
||||
- 관리자 목록은 기존 활성·종료 시각 조건을 유지하고 언어 조건을 추가하지 않는다.
|
||||
- `GET /event`, `GET /event/popup`만 `JP -> Lang.JA`, 그 외 `Lang.KO`를 사용한다.
|
||||
- 로그인 회원은 기존 강제 KR/JP 매핑을 유지하고, 비로그인·국가 누락은 기존 정규화·KR 기본값을 사용한다.
|
||||
- 콘텐츠 메인 탭 6곳의 `EventService.getEventList(isAdult)`는 언어 필터 없이 유지한다.
|
||||
- 언어 필터는 QueryDSL `where`에서 정렬·`fetchFirst()` 전에 적용하고 메모리 후처리를 추가하지 않는다.
|
||||
- 신규 dependency·cache·resolver abstraction·관련 없는 refactoring을 추가하지 않는다.
|
||||
- 모든 구현 Task는 RED → RED 확인 → GREEN → GREEN 확인 → REFACTOR 순서를 지킨다.
|
||||
- 전체 회귀는 focused·직접 영향 테스트로 범위를 판단할 수 없거나 공통 경계 회귀가 발생할 때만 실행한다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `3/3` | 없음 | 없음 |
|
||||
| 2 | 완료 | `2/2` + Gate + `P2-R1` | 없음 | 없음 |
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 구현 완료 즉시 해당 Task 체크박스와 현재 상태를 갱신한다.
|
||||
- 실제 검증 결과는 기존 기록을 덮어쓰지 않고 `Progress`에 누적한다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- `event.lang` 운영 DDL과 KO backfill
|
||||
- `Event` 엔티티의 `Lang.KO` 기본 언어
|
||||
- 관리자 이벤트 등록 `lang` 입력·검증·저장
|
||||
- 관리자 이벤트 목록 `lang` 응답과 언어 전체 조회
|
||||
- 앱 이벤트 목록·팝업의 접속 국가별 KO/JA 필터
|
||||
- 기존 강제 국가 매핑과 비로그인 KR 기본값 재사용
|
||||
- 콘텐츠 메인 탭 6곳의 언어 전체 조회 비회귀
|
||||
|
||||
### 제외
|
||||
|
||||
- 이벤트 수정 시 언어 변경
|
||||
- 레거시 `POST /event`의 `lang` request parameter
|
||||
- 콘텐츠 메인 탭 6곳의 국가별 이벤트 필터
|
||||
- EN 이벤트를 반환할 앱 국가 정책
|
||||
- 다른 언어 fallback, `Accept-Language` 판정, 신규 dependency·공통 abstraction
|
||||
- 기존 이벤트 성인·활성·게시 기간·정렬·URL 정책 변경
|
||||
|
||||
## 요구사항별 Goal 매핑
|
||||
|
||||
| 요구사항 | 소유 Goal | 완료 증거 |
|
||||
|---|---|---|
|
||||
| `EVENT-LANG-001` | `P1-T2` | KO/EN/JA 등록과 잘못된 언어 거부 통합 테스트 |
|
||||
| `EVENT-LANG-002` | `P1-GATE` | 관리자·레거시 수정 API request 스키마 diff 검토 |
|
||||
| `EVENT-LANG-003` | `P1-T2` | KO/EN/JA 관리자 목록과 `lang` 응답 통합 테스트 |
|
||||
| `EVENT-LANG-004` | `P2-T1` | JP·비JP 목록 Repository·controller 통합 테스트 |
|
||||
| `EVENT-LANG-005` | `P2-T1` | 다른 언어의 더 최신 팝업 fixture를 포함한 Repository·controller 통합 테스트 |
|
||||
| `EVENT-LANG-006` | `P2-T1`, `P2-GATE` | 기존 강제 국가 매핑 회귀와 service Lang 전달 테스트 |
|
||||
| `EVENT-LANG-007` | `P2-T2` | `lang = null`의 KO/EN/JA 전체 조회 mutation RED·GREEN |
|
||||
| `EVENT-LANG-008` | `P1-T1`, `P1-GATE` | KO 기본 저장 테스트와 KO backfill DDL 대조 |
|
||||
| `EVENT-LANG-009` | `P2-T1`, `P2-GATE` | QueryDSL `where` 조건과 정렬·`fetchFirst()` 선행 검증 |
|
||||
|
||||
## 파일 구조
|
||||
|
||||
| 책임 | 파일 |
|
||||
|---|---|
|
||||
| 엔티티 언어 저장 | `src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt` |
|
||||
| 운영 DB 이관 | `docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql` |
|
||||
| 관리자 등록·목록 API | `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerController.kt` |
|
||||
| 관리자 등록 검증·저장 | `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt` |
|
||||
| 관리자 언어 전체 목록 | `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerRepository.kt` |
|
||||
| 관리자 응답 | `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/GetAdminEventResponse.kt` |
|
||||
| 앱 HTTP 진입점 | `src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt` |
|
||||
| 앱 성인·국가·언어 조립 | `src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt` |
|
||||
| 앱 DB 언어 필터 | `src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt` |
|
||||
| 관리자 API 통합 검증 | `src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt` |
|
||||
| 앱 서비스 언어 전달 검증 | `src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt` |
|
||||
| 앱 Repository 필터·비회귀 검증 | `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt` |
|
||||
| 앱 요청 헤더부터 응답까지 검증 | `src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt` |
|
||||
|
||||
## Phase 1: 이벤트 언어 저장과 관리자 API
|
||||
|
||||
**Phase 결과:** 이벤트가 언어를 안전하게 저장하고, 관리자가 KO/EN/JA를 지정해 등록한 뒤 언어 전체 목록에서 식별한다.
|
||||
|
||||
**선행조건:** 승인된 PRD `EVENT-LANG-001~003`, `EVENT-LANG-008`.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-GATE` 완료와 실제 검증 결과 누적.
|
||||
|
||||
### Task 1.1 이벤트 언어 모델과 운영 DDL
|
||||
|
||||
**Goal 실행 `P1-T1`:** `Event`가 `Lang`을 필수로 저장하고 기존 데이터를 손실 없이 KO로 이관할 수 있게 한다.
|
||||
|
||||
- **시작 조건:** PRD `EVENT-LANG-008`, `DEC-006`, `DEC-007` 확정.
|
||||
- **완료 증거:** 엔티티 저장 테스트 RED/GREEN, 재실행 가능한 MySQL DDL, 문서·포맷 검증 기록.
|
||||
- **범위 밖:** 관리자 request 처리와 앱 언어 조회.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/event/Event.kt`
|
||||
- Create: `docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `kr.co.vividnext.sodalive.i18n.Lang`.
|
||||
- Produces: `Event.lang: Lang = Lang.KO`, DB `event.lang VARCHAR(10) NOT NULL DEFAULT 'KO'`.
|
||||
|
||||
- [x] **RED:** `EventRepositoryTest`에 명시적 `Lang.JA` 저장과 기본 `Lang.KO` 저장을 검증하는 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
@DisplayName("이벤트 언어는 명시적 값을 저장하고 누락 시 KO를 사용한다")
|
||||
fun shouldPersistEventLanguageAndDefaultToKorean() {
|
||||
val japanese = repository.saveAndFlush(activeEvent("ja").apply { lang = Lang.JA })
|
||||
val korean = repository.saveAndFlush(activeEvent("ko"))
|
||||
|
||||
entityManager.clear()
|
||||
|
||||
assertEquals(Lang.JA, repository.findById(japanese.id!!).orElseThrow().lang)
|
||||
assertEquals(Lang.KO, repository.findById(korean.id!!).orElseThrow().lang)
|
||||
}
|
||||
```
|
||||
|
||||
같은 테스트 파일에 사용할 fixture helper는 아래 계약으로 정의한다.
|
||||
|
||||
```kotlin
|
||||
private fun activeEvent(
|
||||
seed: String,
|
||||
isPopup: Boolean = false
|
||||
): Event {
|
||||
return Event(
|
||||
thumbnailImage = "$seed-thumbnail.png",
|
||||
detailImage = "$seed-detail.png",
|
||||
popupImage = if (isPopup) "$seed-popup.png" else null,
|
||||
link = null,
|
||||
title = seed,
|
||||
isAdult = false,
|
||||
isPopup = isPopup,
|
||||
startDate = LocalDateTime.now().minusDays(1),
|
||||
endDate = LocalDateTime.now().plusDays(1)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 `Event` 생성자와 `lang` property가 없어 발생하는 컴파일 실패를 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"
|
||||
```
|
||||
|
||||
**Expected RED:** `Cannot find a parameter with this name: lang` 또는 `Unresolved reference: lang`.
|
||||
|
||||
- [x] **GREEN:** `Event`에 다음 필드를 추가한다. 기존 생성자는 기본값으로 KO를 사용하므로 변경하지 않는다.
|
||||
|
||||
```kotlin
|
||||
@Column(nullable = false)
|
||||
@Enumerated(value = EnumType.STRING)
|
||||
var lang: Lang = Lang.KO,
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `docs/20260819_이벤트_접속국가별_언어필터/20260819_event_lang_ddl.sql`에 다음 3단계 DDL을 작성한다.
|
||||
|
||||
```sql
|
||||
SET @schema_name := DATABASE();
|
||||
|
||||
SET @lang_column_exists := (
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'event'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @add_lang_column_sql := IF(
|
||||
@lang_column_exists = 0,
|
||||
'ALTER TABLE `event` ADD COLUMN lang VARCHAR(10) NULL COMMENT ''이벤트 노출 언어'' AFTER title',
|
||||
'SELECT ''event.lang already exists'' AS message'
|
||||
);
|
||||
|
||||
PREPARE add_lang_column_stmt FROM @add_lang_column_sql;
|
||||
EXECUTE add_lang_column_stmt;
|
||||
DEALLOCATE PREPARE add_lang_column_stmt;
|
||||
|
||||
UPDATE `event`
|
||||
SET lang = 'KO'
|
||||
WHERE lang IS NULL;
|
||||
|
||||
SET @lang_column_nullable := (
|
||||
SELECT IS_NULLABLE
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = @schema_name
|
||||
AND table_name = 'event'
|
||||
AND column_name = 'lang'
|
||||
);
|
||||
|
||||
SET @alter_lang_column_sql := IF(
|
||||
@lang_column_nullable = 'YES',
|
||||
'ALTER TABLE `event` MODIFY COLUMN lang VARCHAR(10) NOT NULL DEFAULT ''KO'' COMMENT ''이벤트 노출 언어 (KO 기본, EN/JA 등록 가능)''',
|
||||
'SELECT ''event.lang already normalized'' AS message'
|
||||
);
|
||||
|
||||
PREPARE alter_lang_column_stmt FROM @alter_lang_column_sql;
|
||||
EXECUTE alter_lang_column_stmt;
|
||||
DEALLOCATE PREPARE alter_lang_column_stmt;
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** 같은 focused test를 재실행해 JA와 KO 저장이 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 새 언어 타입·converter·migration framework를 추가하지 않고 import와 필드 위치만 정리한다. focused test·`ktlintCheck`·`git diff --check`를 재실행해 `Progress`에 기록한다.
|
||||
|
||||
DDL은 실행 대상 운영 MySQL에 접속하지 않는 코드 작성 단계에서는 자동 실행하지 않는다. 운영 반영 전에 `information_schema` 조회 결과와 백업을 확인하고 검증 환경에서 2회 실행해 재실행 결과를 기록한다.
|
||||
|
||||
### Task 1.2 관리자 언어 등록과 언어 전체 목록
|
||||
|
||||
**Goal 실행 `P1-T2`:** 관리자가 KO/EN/JA 이벤트를 등록하고 현재 목록 조건 안의 모든 언어를 `lang`과 함께 조회한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료, PRD `EVENT-LANG-001~003`.
|
||||
- **완료 증거:** 관리자 multipart 등록·잘못된 언어 거부·언어 전체 목록 통합 테스트 RED/GREEN, 수정 API 스키마 미변경 diff 확인.
|
||||
- **범위 밖:** 수정·삭제 동작 변경, 앱 국가별 조회.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerController.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/admin/event/banner/GetAdminEventResponse.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/admin/event/banner/AdminEventBannerControllerIntegrationTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: multipart request parameter `lang: String`, `Lang.fromCode(value: String): Lang`.
|
||||
- Produces: `AdminEventBannerService.save(..., lang: String): Long`, `GetAdminEventResponse.lang: Lang`.
|
||||
- Preserves: `AdminEventBannerService.update` 시그니처와 `PUT /admin/event/banner` request parameter 목록.
|
||||
|
||||
- [x] **RED:** 관리자 통합 테스트에 `lang=en`으로 등록한 이벤트가 `Lang.EN`으로 저장되는 케이스를 작성한다.
|
||||
|
||||
```kotlin
|
||||
val thumbnail = MockMultipartFile(
|
||||
"thumbnail",
|
||||
"thumbnail.png",
|
||||
"image/png",
|
||||
"thumbnail".toByteArray()
|
||||
)
|
||||
|
||||
mockMvc.perform(
|
||||
multipart("/admin/event/banner")
|
||||
.file(thumbnail)
|
||||
.param("link", "https://event.test/en")
|
||||
.param("isPopup", "false")
|
||||
.param("startDate", "2099-01-01 00:00")
|
||||
.param("endDate", "2099-01-02 00:00")
|
||||
.param("lang", "en")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.success").value(true))
|
||||
|
||||
assertEquals(Lang.EN, eventRepository.findAll().single().lang)
|
||||
```
|
||||
|
||||
- [x] **RED:** `lang=fr`을 보내면 `success=false`이고 DB row·S3 upload이 생성되지 않는 케이스를 추가한다.
|
||||
- [x] **RED:** KO·JA·EN 활성 이벤트를 준비한 뒤 `GET /admin/event/banner`가 세 항목의 `lang`을 모두 반환하는 케이스를 추가한다.
|
||||
|
||||
```kotlin
|
||||
mockMvc.perform(
|
||||
get("/admin/event/banner")
|
||||
.with(user("admin").roles("ADMIN"))
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.length()").value(3))
|
||||
.andExpect(jsonPath("$.data[*].lang").value(containsInAnyOrder("KO", "EN", "JA")))
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 `lang` request parameter·service parameter·response projection이 없어 발생하는 컴파일 또는 assertion 실패를 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" \
|
||||
--tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"
|
||||
```
|
||||
|
||||
- [x] **GREEN:** controller의 등록 메서드에 `@RequestParam("lang") lang: String`을 추가하고 service 호출의 마지막 인자로 전달한다.
|
||||
- [x] **GREEN:** service는 엔티티 생성과 S3 업로드 전에 다음 기존 패턴으로 언어를 변환한다.
|
||||
|
||||
```kotlin
|
||||
val eventLang = try {
|
||||
Lang.fromCode(lang)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `Event` 생성 시 `lang = eventLang`을 넘긴다. 레거시 `EventService.save`는 수정하지 않아 `Lang.KO` 기본값을 사용하게 둔다.
|
||||
- [x] **GREEN:** `GetAdminEventResponse`에 `lang`을 추가하고 QueryDSL projection에 `event.lang`을 동일 순서로 추가한다.
|
||||
|
||||
```kotlin
|
||||
data class GetAdminEventResponse @QueryProjection constructor(
|
||||
val id: Long,
|
||||
val title: String? = null,
|
||||
val lang: Lang,
|
||||
val thumbnailImageUrl: String,
|
||||
val detailImageUrl: String? = null,
|
||||
val popupImageUrl: String? = null,
|
||||
val startDate: String,
|
||||
val endDate: String,
|
||||
val link: String? = null,
|
||||
val isAdult: Boolean? = null,
|
||||
val isPopup: Boolean
|
||||
)
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** focused test를 재실행해 소문자 코드 저장, 잘못된 코드 거부, 언어 전체 목록과 `lang` 응답이 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 수정 API에 `lang`을 추가하지 않고, 기존 `Lang.fromCode`와 `common.error.invalid_request`만 재사용한다. focused test·`ktlintCheck`·`git diff --check`를 재실행해 결과를 기록한다.
|
||||
|
||||
### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 이벤트 언어 저장과 관리자 등록·전체 목록 계약을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
|
||||
- **완료 증거:** 아래 명령과 수동 diff 검토 통과, `Progress` 기록.
|
||||
- **범위 밖:** Gate 통과를 위한 테스트 삭제·skip·assertion 완화.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" \
|
||||
--tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"
|
||||
./gradlew ktlintCheck
|
||||
./gradlew tasks --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 모든 명령이 exit code `0`으로 끝난다. KO/EN/JA 등록과 목록 응답이 통과하고, 잘못된 언어는 저장·업로드 전에 거부된다.
|
||||
|
||||
수동 검증:
|
||||
|
||||
- [x] `git diff`에서 `PUT /admin/event/banner`, `PUT /event`, `POST /event`의 request parameter에 `lang`이 추가되지 않았는지 확인한다.
|
||||
- [x] 관리자 Repository의 `where`에 `event.lang` 조건이 없고 기존 활성·종료 시각 조건이 유지되는지 확인한다.
|
||||
- [x] DDL이 컬럼 추가 → KO backfill → `NOT NULL DEFAULT 'KO'` 순서이고 모든 신규 컬럼에 COMMENT가 있는지 확인한다.
|
||||
|
||||
## Phase 2: 앱 접속 국가별 이벤트 조회
|
||||
|
||||
**Phase 결과:** `GET /event`, `GET /event/popup`이 기존 국가 판정에 따라 JA 또는 KO만 반환하고, 콘텐츠 메인 탭 6곳은 기존 언어 전체 조회를 유지한다.
|
||||
|
||||
**선행조건:** `P1-GATE` 완료, PRD `EVENT-LANG-004~007`, `EVENT-LANG-009`.
|
||||
|
||||
**Phase 완료 조건:** `P2-T1`, `P2-T2`, `P2-GATE` 완료와 실제 검증 결과 누적.
|
||||
|
||||
### Task 2.1 앱 목록·팝업 국가별 언어 필터
|
||||
|
||||
**Goal 실행 `P2-T1`:** 두 앱 API가 판정 국가 JP에서 JA, 그 외에서 KO 이벤트만 DB에서 조회한다.
|
||||
|
||||
- **시작 조건:** `P1-GATE` 완료, PRD `EVENT-LANG-004~006`, `EVENT-LANG-009`.
|
||||
- **완료 증거:** service 언어 전달, Repository 목록·팝업 필터, 요청 헤더부터 API 응답까지의 RED/GREEN 테스트.
|
||||
- **범위 밖:** 콘텐츠 메인 탭의 `getEventList(isAdult)` 경로와 이벤트 응답 DTO.
|
||||
- **범위 내 보정:** 실제 security chain에서 두 앱 GET API의 기존 비로그인 접근 계약을 검증하고, 누락된 `GET /event/popup` exact matcher만 복구한다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/event/EventController.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/event/EventService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt`
|
||||
- Create: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `Member?`, `MemberContentPreferenceService.resolveCountryCode(member: Member?): String`.
|
||||
- Produces: `EventService.getEventList(member: Member?): GetEventResponse`, `EventService.getEventPopup(member: Member?): EventItem?`.
|
||||
- Produces: `EventRepository.getEventList(isAdult: Boolean?, lang: Lang?): List<EventItem>`, `getMainEventPopup(isAdult: Boolean, lang: Lang?): EventItem?`.
|
||||
- Preserves: `EventService.getEventList(isAdult: Boolean?, lang: Lang? = null)`의 null 언어 조회.
|
||||
- Preserves: 기존 `GET /event` 비로그인 접근과 다른 SecurityConfig matcher 순서·인증 정책. `GET /event/popup`만 exact path로 비로그인 접근을 허용한다.
|
||||
|
||||
- [x] **RED:** `EventServiceTest`에 국가 판정 `JP`가 `Lang.JA`로 목록 Repository에 전달되는 케이스를 추가한다.
|
||||
|
||||
`EventServiceTest`의 fixture에 `MemberContentPreferenceService` mock을 추가하고 `EventService` 생성자에 전달한다.
|
||||
|
||||
```kotlin
|
||||
private val preferenceService = Mockito.mock(MemberContentPreferenceService::class.java)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
@DisplayName("JP 판정 회원의 이벤트 목록은 JA를 조회한다")
|
||||
fun shouldQueryJapaneseEventListForJapan() {
|
||||
val member = Member(password = "password", nickname = "jp-user").apply { id = 10L }
|
||||
Mockito.`when`(authRepository.getAuthIdByMemberId(10L)).thenReturn(null)
|
||||
Mockito.`when`(preferenceService.resolveCountryCode(member)).thenReturn("JP")
|
||||
Mockito.`when`(repository.getEventList(false, Lang.JA)).thenReturn(emptyList())
|
||||
|
||||
service.getEventList(member)
|
||||
|
||||
Mockito.verify(repository).getEventList(false, Lang.JA)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED:** 비로그인·KR 판정 팝업이 `Lang.KO`를 조회하는 케이스와 관리자 목록의 기존 `isAdult = null` 정책을 추가한다.
|
||||
- [x] **RED:** `EventRepositoryTest`에 KO·JA 활성 이벤트가 함께 있어도 요청 언어의 목록만 반환하는 케이스를 추가한다.
|
||||
- [x] **RED:** JA 팝업을 먼저 저장하고 더 최신 ID인 KO 팝업을 나중에 저장한 뒤, JA 조회가 앞선 JA 팝업을 반환하는 케이스를 추가한다.
|
||||
|
||||
```kotlin
|
||||
val japanesePopup = repository.saveAndFlush(activeEvent("ja-popup", Lang.JA, isPopup = true))
|
||||
repository.saveAndFlush(activeEvent("newer-ko-popup", Lang.KO, isPopup = true))
|
||||
|
||||
val result = repository.getMainEventPopup(isAdult = false, lang = Lang.JA)
|
||||
|
||||
assertEquals(japanesePopup.id, result?.id)
|
||||
```
|
||||
|
||||
- [x] **RED:** `EventControllerIntegrationTest`에 비로그인 `CloudFront-Viewer-Country: JP`의 목록·팝업이 JA만 반환하고, 헤더 누락 요청이 KO만 반환하는 케이스를 추가한다.
|
||||
|
||||
```kotlin
|
||||
val japaneseEventId = saveControllerEvent("controller-ja", Lang.JA).id!!
|
||||
saveControllerEvent("controller-ko", Lang.KO)
|
||||
|
||||
mockMvc.perform(
|
||||
get("/event")
|
||||
.header("CloudFront-Viewer-Country", "JP")
|
||||
.with(anonymous())
|
||||
)
|
||||
.andExpect(status().isOk)
|
||||
.andExpect(jsonPath("$.data.eventList.length()").value(1))
|
||||
.andExpect(jsonPath("$.data.eventList[0].id").value(japaneseEventId))
|
||||
```
|
||||
|
||||
`EventControllerIntegrationTest`의 fixture helper는 다음처럼 해당 테스트 파일 내에 정의한다.
|
||||
|
||||
```kotlin
|
||||
private fun saveControllerEvent(seed: String, lang: Lang): Event {
|
||||
return eventRepository.saveAndFlush(
|
||||
Event(
|
||||
thumbnailImage = "$seed-thumbnail.png",
|
||||
detailImage = "$seed-detail.png",
|
||||
popupImage = "$seed-popup.png",
|
||||
link = null,
|
||||
title = seed,
|
||||
lang = lang,
|
||||
isAdult = false,
|
||||
isPopup = true,
|
||||
startDate = LocalDateTime.now().minusDays(1),
|
||||
endDate = LocalDateTime.now().plusDays(1)
|
||||
)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 `MemberContentPreferenceService` 의존성, `Member?` 서비스 시그니처, Repository `lang` 인자와 필터가 없어 발생하는 컴파일·assertion 실패를 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.event.EventServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" \
|
||||
--tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `EventService`에 `MemberContentPreferenceService`를 생성자 주입하고 앱 진입점 두 개가 `Member?`를 받도록 최소 변경한다.
|
||||
|
||||
```kotlin
|
||||
@Transactional(readOnly = true)
|
||||
fun getEventList(member: Member?): GetEventResponse {
|
||||
val isAdult = if (member?.role == MemberRole.ADMIN) {
|
||||
null
|
||||
} else {
|
||||
member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
|
||||
}
|
||||
return getEventList(isAdult = isAdult, lang = resolveEventLang(member))
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun getEventPopup(member: Member?): EventItem? {
|
||||
val isAdult = member?.id?.let { authRepository.getAuthIdByMemberId(it) != null } ?: false
|
||||
return getEventPopup(isAdult = isAdult, lang = resolveEventLang(member))
|
||||
}
|
||||
|
||||
private fun resolveEventLang(member: Member?): Lang {
|
||||
return if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **GREEN:** 기존 저수준 조회 메서드는 nullable `lang`을 받고 Repository에 전달한다.
|
||||
|
||||
```kotlin
|
||||
fun getEventList(isAdult: Boolean? = null, lang: Lang? = null): GetEventResponse
|
||||
|
||||
fun getEventPopup(isAdult: Boolean, lang: Lang? = null): EventItem?
|
||||
```
|
||||
|
||||
- [x] **GREEN:** Repository 계약과 구현에 nullable `lang`을 추가하고, null이 아닐 때만 기존 `where`에 언어 조건을 합성한다.
|
||||
|
||||
```kotlin
|
||||
if (lang != null) {
|
||||
where = where.and(event.lang.eq(lang))
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `EventController.getEventList`, `getEventPopup`은 인증 principal에서 얻은 `Member?`를 그대로 새 service 진입점에 전달한다.
|
||||
|
||||
```kotlin
|
||||
service.getEventList(member = member)
|
||||
service.getEventPopup(member = member)
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** 같은 focused test를 재실행해 JP·비JP 언어 전달, Repository 목록·팝업 필터, CloudFront 헤더부터 응답까지 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 국가→언어 선택은 두 호출이 공유하는 한 줄 helper로만 유지하고 신규 resolver·DTO·response field를 만들지 않는다. focused test·`ktlintCheck`를 재실행해 결과를 기록한다.
|
||||
|
||||
### Task 2.2 콘텐츠 메인 탭 언어 전체 조회 비회귀
|
||||
|
||||
**Goal 실행 `P2-T2`:** 국가별 필터가 지정된 두 API 밖으로 확산되지 않아 `getEventList(isAdult)` 기존 경로가 KO/EN/JA 전체를 조회함을 고정한다.
|
||||
|
||||
- **시작 조건:** `P2-T1` 완료, PRD `EVENT-LANG-007`.
|
||||
- **완료 증거:** service null 언어 전달과 Repository 언어 전체 조회 테스트, mutation RED·복구 후 GREEN 기록.
|
||||
- **범위 밖:** 콘텐츠 메인 탭 service 6곳의 시그니처·응답 변경.
|
||||
- **TDD 예외 사유:** `P2-T1`에서 nullable 언어로 호환성을 이미 구현한 뒤 기존 경로를 고정하는 테스트 전용 Task다.
|
||||
- **대체 검증 방법:** null 조건 테스트 추가 후 Repository에 일시로 KO 강제 조건을 넣어 assertion 실패를 확인하고 즉시 복구한 뒤 동일 테스트의 통과를 확인한다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventServiceTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventRepositoryTest.kt`
|
||||
- Mutation 후 원상 복구 확인: `src/main/kotlin/kr/co/vividnext/sodalive/event/EventRepository.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `EventService.getEventList(isAdult: Boolean?, lang: Lang? = null)`.
|
||||
- Produces: `lang = null`이면 `event.lang` 조건을 추가하지 않는 호환성 계약.
|
||||
|
||||
- [x] `EventService.getEventList(isAdult = false)`가 `repository.getEventList(false, null)`을 호출함을 검증한다.
|
||||
- [x] KO·EN·JA 활성 이벤트를 저장하고 `repository.getEventList(isAdult = false, lang = null)`이 세 건을 모두 반환함을 검증한다.
|
||||
- [x] Repository의 null guard를 일시로 KO 강제 조건으로 변경해 언어 전체 테스트가 의도한 assertion 실패를 보이는지 확인한다.
|
||||
- [x] 임시 mutation을 즉시 복구하고 아래 focused test를 재실행해 통과를 확인한다.
|
||||
- [x] `git diff`에 콘텐츠 메인 탭 6곳의 production 파일 변경이 없는지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.event.EventServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"
|
||||
```
|
||||
|
||||
### Phase 2 Gate
|
||||
|
||||
**Goal 실행 `P2-GATE`:** 두 앱 API의 국가별 언어 필터와 지정 밖 경로의 비회귀를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P2-T1`, `P2-T2` 완료.
|
||||
- **완료 증거:** 아래 focused·직접 영향 test, lint, 문서 명령, 수동 diff 검토 통과와 `Progress` 기록.
|
||||
- **범위 밖:** 관련 없는 패키지 테스트 수정과 Gate 통과를 위한 테스트 완화.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" \
|
||||
--tests "kr.co.vividnext.sodalive.event.EventServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" \
|
||||
--tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest" \
|
||||
--tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"
|
||||
./gradlew ktlintCheck
|
||||
./gradlew tasks --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 모든 명령이 exit code `0`으로 끝난다. 강제 매핑을 포함한 JP 판정은 JA, 그 외은 KO 목록·팝업만 반환하고 null 언어 내부 조회는 KO/EN/JA 모두를 반환한다.
|
||||
|
||||
수동 검증:
|
||||
|
||||
- [x] `EventController`의 변경이 `getEventList`, `getEventPopup`에만 있고 레거시 관리자 등록·수정·삭제 계약은 유지되는지 확인한다.
|
||||
- [x] `EventRepository`의 언어 조건이 `where`에 있고 팝업 `orderBy(event.id.desc()).fetchFirst()`보다 먼저 적용되는지 확인한다.
|
||||
- [x] `GetEventResponse`, `EventItem`에 신규 필드가 없고 기존 URL 변환과 성인 조건이 유지되는지 확인한다.
|
||||
- [x] 콘텐츠 메인 탭 6곳의 production 파일과 응답 DTO가 변경되지 않았는지 확인한다.
|
||||
|
||||
전체 회귀 `./gradlew test`는 기본 생략한다. 변경이 이벤트·관리자 이벤트·기존 국가 판정 소비 경로에 한정되고 Gate가 persistence·service·controller integration을 모두 포함하기 때문이다. focused test로 영향 범위를 판단할 수 없는 실패, 공통 인증·예외·설정 회귀, 또는 사용자 요청이 있을 때만 실행하고 생략 근거와 대체 명령을 `Progress`에 기록한다.
|
||||
|
||||
### Task 2.R1 팝업의 기존 인증 경계 복원
|
||||
|
||||
**Goal 실행 `P2-R1`:** `REV-EVENT-LANG-001`에서 확인한 `/event/popup` 익명 공개를 제거하고, 인증 사용자의 국가별 언어 필터만 유지한다.
|
||||
|
||||
- **시작 조건:** `P2-GATE` 완료, `reviews/implementation-review.md`의 `REV-EVENT-LANG-001` 확정.
|
||||
- **완료 증거:** 익명 팝업 401 RED, exact `permitAll()` 제거 후 controller focused GREEN, 직접 영향 5개 클래스와 lint·문서 명령 통과, review·Progress 기록.
|
||||
- **범위 밖:** `GET /event` 익명 접근, JWT 구현, 언어 선택·Repository 조건, 다른 SecurityConfig matcher.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260819_이벤트_접속국가별_언어필터/prd.md`
|
||||
- Modify: `docs/20260819_이벤트_접속국가별_언어필터/plan-task.md`
|
||||
- Modify: `docs/20260819_이벤트_접속국가별_언어필터/reviews/implementation-review.md`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/event/EventControllerIntegrationTest.kt`
|
||||
|
||||
- [x] **RED:** security-on 통합 테스트에 인증 없는 `GET /event/popup`이 HTTP 401을 반환하는 케이스를 추가한다.
|
||||
- [x] **RED:** JP·국가 누락 팝업 언어 테스트를 기존 `MemberAdapter` 인증 방식으로 전환해 인증 사용자 경로의 KO/JA 응답을 유지한다.
|
||||
- [x] **RED 확인:** `EventControllerIntegrationTest`를 실행해 익명 401 테스트가 현재 HTTP 200으로 실패하고, 인증 사용자 팝업 테스트는 통과함을 확인한다.
|
||||
- [x] **GREEN:** `SecurityConfig`에 추가된 `GET /event/popup` exact `permitAll()` 한 줄만 제거한다.
|
||||
- [x] **GREEN 확인:** `EventControllerIntegrationTest`를 재실행해 익명 401, 익명 목록 200, 인증 사용자 JP→JA·국가 누락→KO가 모두 통과함을 확인한다.
|
||||
- [x] **REFACTOR:** 추가 abstraction 없이 테스트 helper만 최소화하고 직접 영향 5개 클래스, `ktlintCheck`, `tasks --all`, `git diff --check`를 실행한다.
|
||||
- [x] PRD 접근 계약, review 상태와 Progress에 실제 결과를 누적한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | 승인된 PRD | 아니요 | `Event` 실제 테이블·생성자·기존 DDL 패턴 재확인 |
|
||||
| 2 | `P1-T2` | `P1-T1` | 아니요 | 관리자 controller → service → entity → projection 순으로 계약 대조 |
|
||||
| 3 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패를 소유한 Task에 회귀 수정 Goal 추가 |
|
||||
| 4 | `P2-T1` | `P1-GATE` | 아니요 | 국가 판정 → Lang 전달 → QueryDSL 조건 순으로 대조 |
|
||||
| 5 | `P2-T2` | `P2-T1` | 아니요 | null 조건 호출자와 mutation 복구 상태 확인 |
|
||||
| 6 | `P2-GATE` | Phase 2 Task 전체 | 아니요 | 실패를 소유한 Task에 회귀 수정 Goal 추가 |
|
||||
| 7 | `P2-R1` | `P2-GATE`, `REV-EVENT-LANG-001` | 아니요 | 기준 HEAD matcher와 security-on controller test 재대조 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE → P2-R1
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 확정된 PRD와 API 계약을 근거 없이 변경하지 않는다.
|
||||
- 이벤트 수정 API와 레거시 `POST /event`에 `lang`을 추가하지 않는다.
|
||||
- 관리자 목록과 콘텐츠 메인 탭 내부 조회에 언어 조건을 추가하지 않는다.
|
||||
- `EN` 노출 국가, 다른 언어 fallback, `Accept-Language` 정책을 추가하지 않는다.
|
||||
- `Lang`, `MemberContentPreferenceService`, `CountryContext`와 중복되는 enum·converter·resolver를 만들지 않는다.
|
||||
- 조회 후 collection filter로 언어를 제거하지 않는다.
|
||||
- 요청 범위 밖 production 파일·dependency·설정을 변경하지 않는다.
|
||||
- 테스트를 삭제·skip·완화하거나 타입 오류를 우회해 Gate를 통과하지 않는다.
|
||||
- JWT, password, signed URL, 파일 본문과 실제 회원 식별자를 문서·fixture·로그에 남기지 않는다.
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- PRD와 현재 구현이 충돌하면 `prd.md` Decision Log와 이 계획을 먼저 갱신한 뒤 구현한다.
|
||||
- `CloudFront-Viewer-Country`, 기존 강제 KR/JP 매핑 또는 누락 시 KR 정책을 변경해야 하면 범위 확장으로 보고 사용자 승인 전 중단한다.
|
||||
- 수정 API 언어 변경, 레거시 등록 언어 입력, 콘텐츠 메인 탭 필터가 필요해지면 구현 전에 새 Decision Log와 Task를 추가한다.
|
||||
- 실제 DB 테이블명이 `event`가 아니거나 DDL 권한·백업이 준비되지 않았으면 DDL 반영만 중단하고 확인 결과를 기록한다.
|
||||
- 동일한 차단 사유가 최초 시도와 자동 후속을 포함해 3회 연속 반복되고 문서화나 독립 작업도 불가능할 때만 goal을 `blocked`로 갱신한다.
|
||||
- 체크박스·focused test·완료 증거·Progress 기록이 모두 충족된 뒤에만 goal을 `complete`로 갱신한다.
|
||||
|
||||
## Progress
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||
|
||||
### 문서 작성 — 2026-08-19
|
||||
|
||||
- 상태: 구현 대기
|
||||
- 무엇을: 사용자 인터뷰 결정을 반영한 PRD와 goal 실행형 구현 계획을 작성했다.
|
||||
- 왜: 구현 전 PRD·`plan-task.md` 준비 규칙과 코드 구현 금지 요청을 충족하기 위해서다.
|
||||
- 어떻게: 기존 이벤트·콘텐츠 배너·국가 판정·DDL·테스트 패턴을 대조했고, 모호성 0.02까지 인터뷰한 설계를 사용자가 승인했다.
|
||||
- 검증:
|
||||
- placeholder·트레일링 공백·요구사항 ID 누락 검사 결과 문제가 없었다.
|
||||
- 두 신규 문서에 `git diff --no-index --check` 적용 시 신규 파일 diff를 의미하는 exit code `1`이고 whitespace 오류 출력은 없었다.
|
||||
- `./gradlew tasks --all`은 최초 sandbox에서 Gradle wrapper lock 접근 제한으로 실패했고, 승인된 동일 명령 재실행에서 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- `git status --short`에는 이 작업의 신규 문서 디렉터리만 표시되었다.
|
||||
- 남은 항목: `P1-T1` 이후 구현 전체. production·test·DDL 파일은 아직 변경하지 않았다.
|
||||
- 다음 행동: 별도 구현 요청이 있을 때 `P1-T1` RED부터 시작한다.
|
||||
|
||||
### P1-T1 이벤트 언어 모델과 운영 DDL — 2026-08-20
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `Event.lang`을 `EnumType.STRING`, `nullable = false`, 기본값 `Lang.KO`로 추가하고, JA/KO 영속성 테스트와 3단계 멱등 MySQL DDL을 작성했다.
|
||||
- 왜: 신규 이벤트의 언어를 필수 저장하고 기존 행은 손실 없이 KO로 이관하기 위해서다.
|
||||
- 어떻게: 기존 `Lang`, JPA enum 매핑, `@DataJpaTest(properties = ["spring.cache.type=none"])`, `QueryDslConfig` 패턴만 재사용했다.
|
||||
- 검증:
|
||||
- RED: `./gradlew test --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"` 실행 시 `Unresolved reference: lang` 2건과 `Cannot find a parameter with this name: lang` 1건으로 `:compileTestKotlin`이 실패했고 `BUILD FAILED in 20s`를 확인했다.
|
||||
- GREEN: 같은 focused test 재실행 결과 `BUILD SUCCESSFUL in 48s`, `10 actionable tasks: 8 executed, 2 up-to-date`를 확인했다.
|
||||
- REFACTOR: `./gradlew ktlintCheck` 결과 `BUILD SUCCESSFUL in 22s`, `7 actionable tasks: 5 executed, 2 up-to-date`를 확인했다.
|
||||
- `git diff --check`는 exit code `0`이며 출력이 없었다.
|
||||
- 운영 DDL은 계획 범위에 따라 데이터베이스에 실행하지 않았다.
|
||||
- 전체 회귀 테스트는 엔티티 필드·focused persistence test·DDL만 변경했고 직접 영향 범위를 focused test로 확인할 수 있어 생략했다.
|
||||
- 품질 수정: fixture의 `lang` 기본 파라미터와 `Event` 전달을 제거해 KO 케이스가 실제 `Event.lang = Lang.KO` 생성자 기본값을 사용하도록 했다. JA 케이스는 생성 후 `Lang.JA`를 명시 설정해 비기본 enum round-trip을 유지했다.
|
||||
- 품질 수정 재검증: `./gradlew --no-daemon test --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"` 결과 `BUILD SUCCESSFUL in 51s`, `10 actionable tasks: 3 executed, 7 up-to-date`를 확인했다.
|
||||
- 품질 수정 재검증: `./gradlew --no-daemon ktlintCheck` 결과 `BUILD SUCCESSFUL in 22s`, `7 actionable tasks: 2 executed, 5 up-to-date`를 확인했고, `git diff --check`는 exit code `0`이며 출력이 없었다.
|
||||
- 남은 항목: `P1-T2` 이후 구현 전체. 어떤 Gate도 완료 처리하지 않았다.
|
||||
- 다음 행동: 별도 구현 요청이 있을 때 `P1-T2` RED부터 시작한다.
|
||||
|
||||
### P1-T2 관리자 언어 등록과 언어 전체 목록 — 2026-08-20
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 관리자 이벤트 등록에 필수 `lang`을 추가해 소문자 코드를 `KO`·`EN`·`JA`로 저장하고, 관리자 목록 응답에 모든 활성·미만료 이벤트의 `lang`을 추가했다.
|
||||
- 왜: 잘못된 언어가 DB나 S3에 부작용을 만들기 전에 거부되고 관리자가 언어별 이벤트를 식별하게 하기 위해서다.
|
||||
- 어떻게: 기존 `Lang.fromCode`와 `SodaException(messageKey = "common.error.invalid_request")`만 재사용하고, 관리자 QueryDSL projection에 `event.lang`만 추가했다. 조회 `where`는 기존 활성·종료 시각 조건만 유지했다.
|
||||
- 검증:
|
||||
- RED: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest"` 결과 `shouldPersistLowercaseLanguageWhenAdminCreatesEventBanner`, `shouldRejectInvalidLanguageWithoutPersistenceOrUpload`, `shouldRejectMissingLanguageWithoutPersistenceOrUpload`, `shouldReturnAllActiveNonExpiredLanguagesForAdmin` 4개가 기능 미지원 assertion으로 실패했고 `BUILD FAILED in 49s`를 확인했다.
|
||||
- RED: 계획의 두 focused test를 함께 실행한 결과 `EventRepositoryTest.kt:48 Unresolved reference: lang`으로 `:compileTestKotlin`이 실패했고 `BUILD FAILED in 7s`를 확인했다.
|
||||
- GREEN: `./gradlew test --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"` 결과 6개 테스트가 통과했고 `BUILD SUCCESSFUL in 36s`, `10 actionable tasks: 3 executed, 7 up-to-date`를 확인했다.
|
||||
- 최종 focused 재검증: `./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"`로 `shouldPersistLowercaseLanguageWhenAdminCreatesEventBanner`, `shouldRejectInvalidLanguageWithoutPersistenceOrUpload`, `shouldRejectMissingLanguageWithoutPersistenceOrUpload`, `shouldReturnAllActiveNonExpiredLanguagesForAdmin`와 Repository의 `shouldPersistEventLanguageAndDefaultToKorean`, `shouldReturnAllActiveNonExpiredLanguagesForAdmin`을 실제 재실행했다. 6개 모두 통과했고 `BUILD SUCCESSFUL in 58s`, `11 actionable tasks: 2 executed, 9 up-to-date`를 확인했다.
|
||||
- REFACTOR: 최초 `ktlintCheck`가 관리자 projection 들여쓰기 5건을 검출해 바로 정리했고, 재실행 결과 `BUILD SUCCESSFUL in 23s`, `7 actionable tasks: 2 executed, 5 up-to-date`를 확인했다.
|
||||
- `git diff --check`는 exit code `0`이며 출력이 없었다.
|
||||
- 수동 diff에서 `PUT /admin/event/banner`, `PUT /event`, `POST /event` 요청 파라미터와 `EventController`·`EventService`·`EventRepository`가 변경되지 않았고, 관리자 Repository `where`에 언어 조건이 없음을 확인했다.
|
||||
- 전체 회귀 테스트는 관리자 이벤트 등록·projection과 직접 영속성 경계를 focused Spring 통합·Repository 테스트가 모두 포함하므로 계획 기준에 따라 생략했다.
|
||||
- 남은 항목: `P1-GATE` 이후 구현 전체. `P1-GATE`는 완료 처리하지 않았다.
|
||||
- 다음 행동: 별도 요청이 있을 때 `P1-GATE`를 실행한다.
|
||||
|
||||
### P1-GATE 최종 검증 — 2026-08-20
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: P1-T1·P1-T2 구현의 focused test, lint, Gradle task 목록, diff whitespace와 세 가지 수동 계약을 최종 검증했다.
|
||||
- 검증:
|
||||
- `./gradlew test --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest"` — exit code `0`, `BUILD SUCCESSFUL in 3s`, 6개 테스트 대상.
|
||||
- `./gradlew ktlintCheck` — exit code `0`, `BUILD SUCCESSFUL in 10s`.
|
||||
- `./gradlew tasks --all` — exit code `0`, `BUILD SUCCESSFUL in 9s`.
|
||||
- `git diff --check` — exit code `0`, 출력 없음.
|
||||
- 수동 검증 1: diff에서 `PUT /admin/event/banner`, `PUT /event`, `POST /event` request parameter에 `lang` 추가 없음.
|
||||
- 수동 검증 2: 관리자 Repository `where`는 `event.isActive.isTrue`와 `event.endDate.goe(now)`만 유지하고 `event.lang` 조건 없음.
|
||||
- 수동 검증 3: DDL이 컬럼 추가 → `KO` backfill → `NOT NULL DEFAULT 'KO'` 순서이며 신규 컬럼 정의에 COMMENT 포함.
|
||||
- `git status --short --untracked-files=all`과 diff를 확인했으며, 요청대로 plan-task.md만 수정했다. Gradle은 deprecated features 경고를 출력했지만 모든 명령은 exit code `0`이었다.
|
||||
- 다음 행동: `P2-T1` RED부터 시작한다. P2 체크박스는 변경하지 않았다.
|
||||
|
||||
### P2-T1 앱 목록·팝업 국가별 언어 필터 — 2026-08-20
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `GET /event`, `GET /event/popup`이 기존 국가 판정 결과 `JP`에서는 `Lang.JA`, 그 외에는 `Lang.KO`를 QueryDSL `where`에서 적용하도록 구현했다.
|
||||
- 왜: 정렬과 `fetchFirst()` 전에 언어를 제한해 더 최신인 다른 언어 팝업이 요청 언어 팝업을 가리는 문제를 막기 위해서다.
|
||||
- 어떻게: controller는 `Member?`를 그대로 전달하고, service는 기존 `MemberContentPreferenceService.resolveCountryCode(member)`와 한 줄 언어 선택 helper를 재사용했다. 저수준 service·Repository 메서드는 nullable `lang = null` 기본값을 유지했으며 콘텐츠 메인 production 호출자 6곳은 수정하지 않았다.
|
||||
- 검증:
|
||||
- RED: `./gradlew test --tests "kr.co.vividnext.sodalive.event.EventServiceTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"` 실행 시 `memberContentPreferenceService` 생성자 인자, `Member?` service overload, Repository `lang` 인자가 없어 `:compileTestKotlin`이 예상대로 실패했고 `BUILD FAILED in 12s`를 확인했다.
|
||||
- GREEN: `./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.event.EventServiceTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"` 결과 11개 테스트가 통과했고 `BUILD SUCCESSFUL in 39s`를 확인했다.
|
||||
- service test ID: `shouldQueryJapaneseEventListForJapan`, `shouldQueryKoreanEventPopupForAnonymousKorea`, `shouldKeepAdminAdultFilterNullWhileApplyingResolvedLanguage`.
|
||||
- Repository test ID: `shouldFilterEventListByKoreanAndJapaneseLanguage`, `shouldFilterPopupByLanguageBeforeSelectingNewestEvent`. JA 팝업을 먼저, 더 최신 KO 팝업을 나중에 저장한 뒤 JA를 반환함을 확인했다.
|
||||
- controller 통합 test ID: `shouldReturnOnlyJapaneseEventListForAnonymousJapanRequest`, `shouldReturnOnlyJapanesePopupForAnonymousJapanRequest`, `shouldReturnOnlyKoreanEventListWhenCountryHeaderIsMissing`, `shouldReturnOnlyKoreanPopupWhenCountryHeaderIsMissing`. `MemberContentPreferenceService`를 stubbing하지 않고 실제 `CountryInterceptor`와 request-scoped `CountryContext`를 통과시켰다.
|
||||
- 통합 테스트는 P2-T1 국가·언어 경계에 집중하도록 security filter를 제외하고 인증 정보 없는 요청을 사용했다. 실제 security filter 포함 진단에서 기존 `/event/popup` 익명 요청은 401이었으며 SecurityConfig 변경은 이 Goal의 production 파일 범위 밖이라 변경하지 않았다.
|
||||
- REFACTOR: 최초 `./gradlew ktlintCheck`가 신규 Repository fixture 들여쓰기 6건을 검출해 정리했고, 재실행 결과 `BUILD SUCCESSFUL in 22s`를 확인했다.
|
||||
- 최종 focused 재검증: 같은 `cleanTest` 포함 3개 테스트 클래스 명령으로 11개 테스트를 다시 실행해 `BUILD SUCCESSFUL in 1m 26s`를 확인했다.
|
||||
- 최종 lint 재검증: `./gradlew ktlintCheck` 결과 `BUILD SUCCESSFUL in 13s`를 확인했다.
|
||||
- `./gradlew tasks --all` 결과 `BUILD SUCCESSFUL in 9s`를 확인했다.
|
||||
- `git diff --check`는 exit code `0`이며 출력이 없었다.
|
||||
- 전체 회귀 테스트는 변경이 Event controller/service/Repository와 해당 persistence·Spring MVC 통합 경계에 한정되고 focused 11개 테스트로 직접 영향 범위를 확인할 수 있어 계획 기준에 따라 생략했다.
|
||||
- 남은 항목: `P2-T2`, `P2-GATE`. 두 Goal은 완료 처리하지 않았다.
|
||||
|
||||
### P2-T2 콘텐츠 메인 탭 언어 전체 조회 비회귀 — 2026-08-20
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 저수준 `EventService.getEventList(isAdult = false)`의 `repository.getEventList(false, null)` 전달과 null 언어의 활성 KO·EN·JA 전체 조회 회귀 테스트를 추가했다.
|
||||
- 검증:
|
||||
- service test `shouldQueryNonAdultEventListWithoutLanguage`가 `getEventList(false, null)` 호출을 검증한다.
|
||||
- Repository test `shouldReturnAllActiveLanguagesWhenLanguageIsNull`이 활성 KO·EN·JA 이벤트를 저장하고 세 이벤트 ID와 count 3을 검증한다.
|
||||
- RED mutation: `EventRepository.kt`의 nullable `lang` guard에 임시 `else event.lang.eq(Lang.KO)`를 추가한 뒤 Repository focused test가 `EventRepositoryTest.kt:101` assertion failure로 종료됨을 확인했다.
|
||||
- GREEN: mutation을 즉시 제거해 원래 `if (lang != null)` guard를 복구한 뒤 focused EventService/EventRepository test가 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 콘텐츠 메인 탭 production 6곳 변경 여부와 `EventRepository` 복구 상태를 diff에서 확인했다.
|
||||
- `./gradlew ktlintCheck`와 `git diff --check`를 재실행했다.
|
||||
- 남은 항목: `P2-GATE`. Gate는 완료 처리하지 않았다.
|
||||
- 다음 행동: `P2-GATE`를 실행한다.
|
||||
|
||||
### P2-T1 QA 보안 경계 발견 — 2026-08-20
|
||||
|
||||
- 상태: 교정 진행 중
|
||||
- 발견: `EventControllerIntegrationTest`가 `@AutoConfigureMockMvc(addFilters = false)`로 전체 security filter를 제외해 실제 익명 접근 계약을 검증하지 못했다. 실제 `SecurityConfig`에는 `GET /event`만 `permitAll()`이고 `GET /event/popup` exact matcher가 없어 익명 팝업 요청이 401이었다.
|
||||
- 계획 보정: P2-T1 production Files에 `SecurityConfig.kt`를 추가하고, 범위를 `GET /event/popup` exact matcher 한 줄로 제한했다. `/event/**` 광역 허용, JWT/auth filter 수정, 다른 matcher 변경은 범위 밖이다.
|
||||
- 다음 행동: security filter를 켠 controller 통합 테스트로 목록 통과와 팝업 401 RED를 확인한 뒤 exact matcher를 추가한다.
|
||||
|
||||
### P2-T1 QA 보안 경계 교정 완료 — 2026-08-20
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `EventControllerIntegrationTest`에서 security filter 제외를 제거하고, `SecurityConfig`에 `HttpMethod.GET`, `/event/popup` exact `permitAll()` matcher만 추가했다.
|
||||
- RED: `./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"` 결과 4개 중 `shouldReturnOnlyJapanesePopupForAnonymousJapanRequest`, `shouldReturnOnlyKoreanPopupWhenCountryHeaderIsMissing` 2개가 각각 `Status expected:<200> but was:<401>`로 실패했다. 목록 2개는 통과했고 `BUILD FAILED in 46s`였다.
|
||||
- GREEN: exact matcher 추가 후 같은 security-on controller 통합 테스트 4개가 모두 통과했고 `BUILD SUCCESSFUL in 52s`였다.
|
||||
- 최종 focused: `./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.event.EventServiceTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest"` 결과 11개 테스트가 통과했고 `BUILD SUCCESSFUL in 1m 21s`였다.
|
||||
- 최종 검증: `./gradlew ktlintCheck`는 `BUILD SUCCESSFUL in 36s`, `./gradlew tasks --all`은 `BUILD SUCCESSFUL in 13s`, `git diff --check`는 exit code `0`이며 출력이 없었다.
|
||||
- 수동 검증: `@AutoConfigureMockMvc`가 실제 security chain을 사용하고 요청은 security postprocessor 없이 익명으로 전송된다. `CountryInterceptor`와 request-scoped `CountryContext` 경로를 유지했으며 `/event/**` 광역 허용이나 다른 SecurityConfig 규칙·순서 변경은 없다.
|
||||
- 남은 항목: `P2-T2`, `P2-GATE`. 두 Goal은 완료 처리하지 않았다.
|
||||
- 다음 행동: `P2-T2`에서 nullable 언어의 콘텐츠 메인 전체 조회 비회귀를 mutation RED·GREEN으로 고정한다.
|
||||
|
||||
### P2-GATE 최종 검증 — 2026-08-20
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 앱 이벤트 목록·팝업의 국가별 언어 필터, 관리자 이벤트 직접 영향 경로, 기존 국가 판정, 콘텐츠 메인 언어 전체 조회와 네 가지 수동 계약을 최종 판정했다.
|
||||
- 검증:
|
||||
- `./gradlew cleanTest test --tests "kr.co.vividnext.sodalive.admin.event.banner.AdminEventBannerControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.event.EventServiceTest" --tests "kr.co.vividnext.sodalive.event.EventRepositoryTest" --tests "kr.co.vividnext.sodalive.event.EventControllerIntegrationTest" --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` — exit code `0`, `BUILD SUCCESSFUL in 48s`, `11 actionable tasks: 2 executed, 9 up-to-date`. `cleanTest` 후 5개 클래스의 27개 테스트를 실제 실행했고 클래스별 `4 + 4 + 5 + 4 + 10`, failures `0`, errors `0`, skipped `0`이었다.
|
||||
- `./gradlew ktlintCheck` — exit code `0`, `BUILD SUCCESSFUL in 1s`, `7 actionable tasks: 7 up-to-date`.
|
||||
- `./gradlew tasks --all` — exit code `0`, `BUILD SUCCESSFUL in 6s`, `1 actionable task: 1 executed`.
|
||||
- `git diff --check` — exit code `0`, 출력 없음.
|
||||
- security-on 익명 검증: `EventControllerIntegrationTest`는 `@AutoConfigureMockMvc`에서 filter를 끄지 않고 security postprocessor 없이 `GET /event`, `GET /event/popup`을 호출한다. JP·국가 누락 목록·팝업 4개가 모두 통과했고 `SecurityConfig` diff는 `GET /event/popup` exact `permitAll()` 한 줄뿐이다.
|
||||
- 수동 검증 1: `EventController` diff는 `getEventList`, `getEventPopup`의 service 전달만 변경했다. 레거시 `POST /event`, `PUT /event`, `DELETE /event/{id}`와 관리자 수정·삭제 파라미터는 변경되지 않았다.
|
||||
- 수동 검증 2: 목록·팝업의 `event.lang.eq(lang)`은 QueryDSL `where`에 합성되고, 팝업 `.where(where)` 뒤의 `.orderBy(event.id.desc()).fetchFirst()`보다 먼저 적용된다.
|
||||
- 수동 검증 3: `GetEventResponse`와 같은 파일의 `EventItem` diff는 없었다. `EventService`의 기존 URL 변환은 유지됐고 Repository의 활성·기간·성인 조건과 ID 내림차순 정렬은 언어 조건과 함께 focused test로 통과했다.
|
||||
- 수동 검증 4: `git diff HEAD --name-only -- src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab`과 `GetEventResponse.kt` 확인 결과 출력이 없었다. 기존 6개 production 호출자는 모두 `eventService.getEventList(isAdult = isAdult)`를 유지한다.
|
||||
- `git status --short --untracked-files=all`로 staged·unstaged·untracked 파일을 확인했으며 Gate 중 production·test·DDL은 수정하지 않았다.
|
||||
- 전체 회귀 생략: 변경이 이벤트·관리자 이벤트·기존 국가 판정 소비 경로에 한정되고, 위 5개 클래스가 persistence·service·security-on controller integration과 기존 국가 강제 매핑을 직접 포함해 영향 범위를 판정할 수 있었다. 계획 592행의 확장 조건에 해당하는 실패나 공통 경계 회귀가 없어 `./gradlew test` 전체 실행은 생략했다.
|
||||
- PRD 감사: 11절 8개 성공 기준을 테스트·diff·DDL로 개별 대조해 구현 증거가 충분한 7개를 완료 처리했다. 운영 DB DDL은 실행 금지 범위이므로 실제 기존 row 이관 완료 항목 1개는 증명하지 않고 미완료로 유지했다.
|
||||
- 남은 구현 Goal: 없음.
|
||||
|
||||
### P2-R1 팝업의 기존 인증 경계 복원 — 2026-08-20
|
||||
|
||||
- 상태: 완료
|
||||
- 발견: 독립 구현 리뷰에서 기준 HEAD의 `GET /event/popup`은 `anyRequest().authenticated()`를 적용받지만, 구현 중 exact `permitAll()`이 추가되어 익명 공개로 확장된 점을 `REV-EVENT-LANG-001`로 확정했다.
|
||||
- 계획 선반영: production 변경 전에 `Task 2.R1`, `PLAN-DEC-005`, review 문서를 추가하고 PRD의 기존 접근 계약을 기준 HEAD와 일치시켰다.
|
||||
- RED: security-on `EventControllerIntegrationTest`에 익명 팝업 401을 추가하고 JP·국가 누락 팝업을 `MemberAdapter` 인증 사용자로 전환했다. 5개 중 신규 테스트 1개만 `Status expected:<401> but was:<200>`으로 실패했고, 인증 팝업 2개와 익명 목록 2개는 통과했다. `BUILD FAILED in 34s`였다.
|
||||
- GREEN: `SecurityConfig`의 신규 `GET /event/popup` exact `permitAll()` 한 줄을 제거했다. 같은 controller 통합 테스트 5개가 모두 통과했고 `BUILD SUCCESSFUL in 38s`였다.
|
||||
- 직접 영향 회귀: `./gradlew --no-daemon cleanTest test`에 관리자 controller, 이벤트 service/repository/controller, 기존 국가 판정 통합 5개 클래스를 지정해 `4 + 4 + 5 + 5 + 10 = 28`개를 실행했다. failures `0`, errors `0`, skipped `0`, `BUILD SUCCESSFUL in 42s`였다.
|
||||
- 정적 검증: `./gradlew --no-daemon ktlintCheck`는 `BUILD SUCCESSFUL in 22s`, `./gradlew --no-daemon tasks --all`은 `BUILD SUCCESSFUL in 5s`, `git diff --check HEAD`는 exit code `0`이고 출력이 없었다.
|
||||
- 수동 검증: 기준 HEAD 대비 `SecurityConfig.kt` diff가 없고, `GET /event` 익명 matcher와 마지막 `anyRequest().authenticated()`가 유지된다. 언어 predicate 두 곳은 QueryDSL `where`에 있으며 콘텐츠 메인 6개 호출자는 기존 `getEventList(isAdult = isAdult)`를 유지한다.
|
||||
- 독립 재리뷰: 수정 후 최종 diff·PRD·plan·review를 다시 검토해 Critical·Important·Minor 모두 추가 발견 없음, Ready to merge로 판정했다.
|
||||
- 전체 회귀 생략: security production 변경을 기준 HEAD 상태로 복원했고, 직접 영향 28개가 관리자 등록·목록, persistence, service, security-on controller, 강제 국가 매핑을 포함한다. focused 결과로 영향 범위를 판정할 수 없는 실패가 없어 전체 `./gradlew test`로 확장하지 않았다.
|
||||
- 남은 구현 Goal: 없음.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-19 | `PLAN-DEC-001` | 확정 | 기존 `Lang`, `Lang.fromCode`, `MemberContentPreferenceService.resolveCountryCode` 조합만 재사용한다. | 중복 없는 최소 구현과 기존 콘텐츠·추천 배너 패턴 | `P1-T2`, `P2-T1` |
|
||||
| 2026-08-19 | `PLAN-DEC-002` | 확정 | Repository `lang`은 nullable로 두고 두 앱 API만 non-null KO/JA를 전달한다. | 지정된 API만 필터하고 기존 내부 호출을 변경하지 않기 위함 | `P2-T1`, `P2-T2` |
|
||||
| 2026-08-19 | `PLAN-DEC-003` | 확정 | 국가→언어 변환은 `EventService`의 작은 private helper로 두 앱 조회가 공유한다. | controller의 정책 중복을 피하면서 별도 resolver는 만들지 않는 최소 경계 | `P2-T1` |
|
||||
| 2026-08-19 | `PLAN-DEC-004` | 확정 | 언어 필터는 QueryDSL `where`에서 적용하고 팝업 정렬·`fetchFirst()` 이전임을 테스트한다. | 잘못된 언어의 최신 팝업이 선택되는 회귀 방지 | `P2-T1`, `P2-GATE` |
|
||||
| 2026-08-20 | `PLAN-DEC-005` | 확정 | `GET /event`의 익명 접근은 유지하고 `GET /event/popup`은 기준 HEAD의 인증 필수 경계를 복원한다. | 언어 필터 요청과 무관한 보안 범위 확장을 제거 | `P2-R1`, `REV-EVENT-LANG-001` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
- `REV-EVENT-LANG-001`: `GET /event/popup`이 기준 HEAD와 달리 익명 공개된 문제. `P2-R1`에서 수정 완료.
|
||||
|
||||
## 최종 보고 규칙
|
||||
|
||||
- 첫 문장에 완료한 Phase와 사용자가 얻는 결과를 적는다.
|
||||
- 변경 항목에 실제 수정한 주요 파일과 동작을 적는다.
|
||||
- 결정 항목에 적용한 Decision Log ID와 내용을 적는다.
|
||||
- 검증 항목에 실행 명령·실제 결과·수동 검증 결과를 적는다.
|
||||
- 전체 회귀를 실행하지 않았으면 생략 근거와 대신 실행한 focused·직접 영향 명령을 적는다.
|
||||
- 남은 항목과 갱신한 PRD·plan·review 경로를 적는다.
|
||||
- 성공을 추정하지 않고 실제 실행한 최신 검증 결과와 완료되지 않은 범위를 함께 전달한다.
|
||||
@@ -1,204 +0,0 @@
|
||||
# 이벤트 접속 국가별 언어 필터 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-08-19 |
|
||||
| 최종 수정일 | 2026-08-20 |
|
||||
| 대상 제품 | 관리자 이벤트 배너, 앱 이벤트 목록·팝업 |
|
||||
| 작성자·결정권자 | 사용자 |
|
||||
| 관련 API Contract | 별도 문서 없음. 이 문서 8절을 기준으로 사용 |
|
||||
| 관련 구현 계획 | `docs/20260819_이벤트_접속국가별_언어필터/plan-task.md` |
|
||||
| 관련 기존 문서 | `docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md` |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
관리자가 이벤트를 등록할 때 기존 콘텐츠 배너처럼 언어를 지정한다. 관리자 목록은 언어로 필터링하지 않고 각 이벤트의 언어를 표시한다. 앱의 `GET /event`, `GET /event/popup`은 기존 접속 국가 판정 결과가 `JP`이면 일본어, 그 외에는 한국어 이벤트만 반환한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- `Event`에는 언어 필드가 없어 관리자가 한국어·일본어 이벤트를 구분해 등록할 수 없다.
|
||||
- 앱 이벤트 목록과 팝업 조회는 접속 국가를 고려하지 않아 여러 언어의 이벤트가 섞일 수 있다.
|
||||
- 팝업을 조회한 뒤 메모리에서 언어를 걸러내면 다른 언어의 최신 팝업이 `fetchFirst()`를 차지해 필요한 팝업을 놓칠 수 있다.
|
||||
|
||||
문제를 해결했다는 판단은 관리자가 허용된 언어로 이벤트를 등록·식별할 수 있고, 두 앱 API가 기존 조회 조건을 유지하면서 DB 조회 단계에서 판정 언어만 반환하는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 관리자는 이벤트 등록 시 `KO`, `EN`, `JA` 중 하나를 필수로 지정한다.
|
||||
- 관리자 이벤트 목록은 기존 활성·종료 시각 조건 안에서 모든 언어를 반환하고 `lang`을 포함한다.
|
||||
- 기존 `MemberContentPreferenceService.resolveCountryCode(member)`로 강제 KR/JP 회원 매핑, 요청 국가 정규화, 누락 시 KR 기본값을 그대로 재사용한다.
|
||||
- 판정 국가가 `JP`이면 `Lang.JA`, 그 외에는 `Lang.KO`를 선택한다.
|
||||
- 언어 조건을 QueryDSL `where`에 적용해 정렬과 `fetchFirst()`보다 먼저 필터링한다.
|
||||
- 기존 이벤트 데이터와 레거시 등록 경로의 기본 언어는 `KO`로 유지한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- 이벤트 수정 API에서 `lang`을 변경하지 않는다.
|
||||
- `EventController.createEvent`의 `POST /event` 요청 계약을 변경하지 않는다.
|
||||
- 콘텐츠 메인 탭 6곳이 `EventService.getEventList(isAdult)`로 조립하는 `eventBannerList`에 언어 필터를 적용하지 않는다.
|
||||
- `Lang`에 새 enum을 추가하거나 `EN`을 노출할 접속 국가 정책을 추가하지 않는다.
|
||||
- `Accept-Language`를 이벤트 언어 판정에 사용하지 않는다.
|
||||
- 요청 언어의 이벤트가 없을 때 다른 언어로 fallback하지 않는다.
|
||||
- 기존 이벤트의 성인·활성·시작·종료·정렬 정책과 응답 이미지 URL 처리를 변경하지 않는다.
|
||||
|
||||
## 5. 대상 사용자와 권한
|
||||
|
||||
| 사용자 | 주요 동작 | 국가·언어 정책 |
|
||||
|---|---|---|
|
||||
| 관리자 | 이벤트 등록, 언어 전체 목록 조회 | 등록 시 `KO`/`EN`/`JA` 중 하나를 지정, 목록에서 `lang` 확인 |
|
||||
| 강제 JP 매핑 회원 | 앱 목록·팝업 조회 | 요청 헤더보다 우선하는 `JP` 판정, `JA` 조회 |
|
||||
| 강제 KR 매핑 회원 | 앱 목록·팝업 조회 | 요청 헤더보다 우선하는 `KR` 판정, `KO` 조회 |
|
||||
| 일반 로그인 회원 | 앱 목록·팝업 조회 | 정규화한 `CloudFront-Viewer-Country`가 `JP`이면 `JA`, 그 외 `KO` |
|
||||
| 비로그인 사용자 | 앱 목록 조회 | 정규화한 요청 국가가 `JP`이면 `JA`, 누락·그 외 `KO` |
|
||||
|
||||
- 관리자 이벤트 API의 기존 `ROLE_ADMIN` 인가를 유지한다.
|
||||
- 앱 이벤트 목록은 기존 비로그인 접근을 유지하고, 팝업은 기존처럼 인증 사용자만 접근한다.
|
||||
|
||||
## 6. 핵심 흐름
|
||||
|
||||
### 6.1 관리자 등록·조회
|
||||
|
||||
1. 관리자가 `POST /admin/event/banner` multipart 요청에 `lang`을 필수로 보낸다.
|
||||
2. 서버는 기존 `Lang.fromCode` 규칙으로 `ko`/`KO`, `en`/`EN`, `ja`/`JA`를 처리한다.
|
||||
3. 허용되지 않는 값은 `common.error.invalid_request`로 거부하고 이벤트를 저장하지 않는다.
|
||||
4. `GET /admin/event/banner`는 기존처럼 활성 상태이고 종료 시각이 지나지 않은 이벤트를 언어 조건 없이 반환한다.
|
||||
5. 각 관리자 응답 항목은 `lang`을 포함한다.
|
||||
|
||||
### 6.2 앱 목록·팝업 조회
|
||||
|
||||
1. `CountryInterceptor`가 `CloudFront-Viewer-Country`를 `CountryContext`에 저장한다.
|
||||
2. `EventController`는 인증 사용자 `Member?`를 `EventService`에 전달한다.
|
||||
3. `EventService`는 `MemberContentPreferenceService.resolveCountryCode(member)`를 호출한다.
|
||||
4. 국가 결과가 `JP`이면 `Lang.JA`, 그 외에는 `Lang.KO`를 선택한다.
|
||||
5. `EventRepository`는 기존 성인·활성·게시 기간 조건과 언어 조건을 DB에서 함께 적용한다.
|
||||
6. 해당 언어의 이벤트가 없으면 목록은 빈 `eventList`, 팝업은 `null`을 반환한다.
|
||||
|
||||
### 6.3 기존 내부 조회
|
||||
|
||||
1. 콘텐츠 메인 탭 6곳은 기존 `EventService.getEventList(isAdult)`를 유지한다.
|
||||
2. 이 경로는 Repository에 `lang = null`을 전달해 언어 조건을 추가하지 않는다.
|
||||
3. 기존 응답 조립 범위와 순서를 변경하지 않는다.
|
||||
|
||||
## 7. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `EVENT-LANG-001` | 확정 | 관리자 이벤트 등록은 `lang`을 필수로 받는다. | `KO`, `EN`, `JA`가 저장되고 누락·잘못된 값은 저장 전 거부된다. | `P1-T2` |
|
||||
| `EVENT-LANG-002` | 확정 | 이벤트 언어는 등록 후 수정하지 않는다. | 두 수정 API의 요청·서비스 시그니처에 `lang`이 추가되지 않는다. | `P1-GATE` |
|
||||
| `EVENT-LANG-003` | 확정 | 관리자 목록은 모든 언어를 조회하고 `lang`을 반환한다. | 기존 활성·종료 시각 조건을 만족하는 KO/EN/JA 항목이 언어 필터 없이 반환되고 각 항목에 `lang`이 있다. | `P1-T2` |
|
||||
| `EVENT-LANG-004` | 확정 | `GET /event`는 판정 국가가 JP이면 JA, 그 외에는 KO 이벤트만 반환한다. | 서로 다른 언어의 활성 이벤트가 함께 있어도 판정 언어만 `eventList`에 있다. | `P2-T1` |
|
||||
| `EVENT-LANG-005` | 확정 | `GET /event/popup`은 판정 국가가 JP이면 JA, 그 외에는 KO 팝업만 반환한다. | 다른 언어 팝업의 ID가 더 최신이어도 언어 필터 후 선택된 팝업을 반환한다. | `P2-T1` |
|
||||
| `EVENT-LANG-006` | 확정 | 로그인 회원은 기존 강제 KR/JP 매핑을 포함한 국가 판정을 사용한다. | 강제 JP 회원은 비JP 헤더에서도 JA, 강제 KR 회원은 JP 헤더에서도 KO를 선택한다. | `P2-T1` |
|
||||
| `EVENT-LANG-007` | 확정 | 콘텐츠 메인 탭의 기존 `eventBannerList`는 언어 필터 없이 조회한다. | `getEventList(isAdult)` 경로가 `lang = null`을 유지하고 KO/JA/EN 모두를 조회할 수 있다. | `P2-T2` |
|
||||
| `EVENT-LANG-008` | 확정 | 기존 이벤트와 레거시 등록 경로의 언어는 KO다. | 기존 row를 KO로 backfill하고 `NOT NULL DEFAULT 'KO'`를 적용하며 `POST /event`에 `lang`을 추가하지 않는다. | `P1-T1` |
|
||||
| `EVENT-LANG-009` | 확정 | 앱 언어 필터는 DB 조회 조건으로 적용한다. | QueryDSL `where` 내 `event.lang.eq(lang)`이 정렬·`fetchFirst()` 전에 적용된다. | `P2-T1` |
|
||||
|
||||
## 8. API 계약
|
||||
|
||||
### 8.1 관리자 등록
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| Method / Path | `POST /admin/event/banner` |
|
||||
| 인증 | `ROLE_ADMIN` |
|
||||
| Content-Type | `multipart/form-data` |
|
||||
| 신규 필수 파라미터 | `lang`: `ko`, `en`, `ja` 또는 대소문자를 달리한 동일 enum 코드 |
|
||||
| 잘못된 값 | `common.error.invalid_request`, 저장·업로드 없음 |
|
||||
| 기존 파라미터 | 스키마와 필수·선택 정책 유지 |
|
||||
|
||||
### 8.2 관리자 목록
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| Method / Path | `GET /admin/event/banner` |
|
||||
| 조회 조건 | 기존 `is_active = true`, `end_date >= now`; 언어 조건 없음 |
|
||||
| 응답 변경 | `GetAdminEventResponse.lang: Lang` 추가 |
|
||||
| 기존 응답 필드 | 이름·타입·URL 처리 유지 |
|
||||
|
||||
### 8.3 앱 조회
|
||||
|
||||
| Method / Path | 변경 내용 | 빈 결과 |
|
||||
|---|---|---|
|
||||
| `GET /event` | 기존 응답 스키마를 유지하고 판정 언어 조건만 추가 | `eventList = []` |
|
||||
| `GET /event/popup` | 기존 응답 스키마를 유지하고 판정 언어 조건만 추가 | `data = null` |
|
||||
|
||||
- 신규 request header를 추가하지 않고 기존 `CloudFront-Viewer-Country`를 사용한다.
|
||||
- `GET /event`는 기존처럼 비로그인 접근을 허용하고, `GET /event/popup`은 기존 인증 필수 정책을 유지한다.
|
||||
- 앱 응답에 `lang`을 추가하지 않는다.
|
||||
- `EN` 이벤트는 관리자가 등록·조회할 수 있지만 현재 두 앱 API의 국가 정책으로는 노출되지 않는다.
|
||||
|
||||
## 9. 데이터 정책
|
||||
|
||||
- `Event.lang`은 기존 `kr.co.vividnext.sodalive.i18n.Lang`을 `EnumType.STRING`으로 저장한다.
|
||||
- 엔티티 기본값은 `Lang.KO`로 둔다.
|
||||
- 운영 DB DDL은 `event.lang VARCHAR(10)`을 nullable로 추가한 뒤 기존 `NULL`을 `KO`로 backfill하고 `NOT NULL DEFAULT 'KO'`로 변경한다.
|
||||
- DDL은 두 번 실행해도 이미 적용된 단계를 건너뛸는 기존 `information_schema` + `PREPARE` 패턴을 따른다.
|
||||
- 기존 row의 언어를 별도로 추론하거나 이미지·제목을 분석해 자동 분류하지 않는다.
|
||||
|
||||
## 10. 성능·품질·보안 요구사항
|
||||
|
||||
- 언어 조건은 Repository QueryDSL `where`에서 적용하고 메모리 후처리를 추가하지 않는다.
|
||||
- 기존 `Lang`, `MemberContentPreferenceService`, `CountryContext`를 재사용하고 신규 dependency·resolver abstraction을 추가하지 않는다.
|
||||
- 언어 파라미터는 `Lang.fromCode`로 정규화·검증하고 잘못된 값을 엔티티 생성과 S3 업로드 전에 거부한다.
|
||||
- 민감정보·헤더·파일 본문을 신규 로그에 기록하지 않는다.
|
||||
- TDD는 관리자 등록·목록, 앱 국가별 목록·팝업, 기존 내부 전체 언어 조회 비회귀를 포함한다.
|
||||
- focused test 후 직접 영향 회귀와 `ktlintCheck`를 실행한다. 전체 회귀는 targeted test로 영향 범위를 판단할 수 없거나 공통 경계 회귀가 발생할 때만 확장한다.
|
||||
|
||||
## 11. 성공 기준
|
||||
|
||||
- [x] 관리자가 `KO`, `EN`, `JA` 이벤트를 등록하고 목록에서 각 언어를 확인한다. (`EVENT-LANG-001`, `EVENT-LANG-003`)
|
||||
- [x] 수정 API와 레거시 `POST /event`의 요청 계약이 변경되지 않는다. (`EVENT-LANG-002`, `EVENT-LANG-008`)
|
||||
- [ ] 기존 이벤트 모두가 KO로 이관되고 신규 언어 누락 row가 생성되지 않는다. (`EVENT-LANG-008`)
|
||||
- [x] 판정 국가 JP에서 `GET /event`, `GET /event/popup`이 JA만 반환한다. (`EVENT-LANG-004~006`)
|
||||
- [x] JP 이외와 국가 누락에서 두 API가 KO만 반환한다. (`EVENT-LANG-004`, `EVENT-LANG-005`)
|
||||
- [x] 다른 언어의 최신 팝업이 있어도 언어 필터 후 선택된 팝업이 반환된다. (`EVENT-LANG-005`, `EVENT-LANG-009`)
|
||||
- [x] 콘텐츠 메인 탭 6곳의 기존 `eventBannerList`는 언어 필터 없이 조회된다. (`EVENT-LANG-007`)
|
||||
- [x] 기존 성인·활성·게시 기간·정렬·URL·응답 스키마 정책이 유지된다.
|
||||
|
||||
### 11.1 구현 완료 검증 — 2026-08-20
|
||||
|
||||
- 관리자 등록·목록: `AdminEventBannerControllerIntegrationTest` 4개와 `EventRepositoryTest`의 관리자 전체 언어 조회가 통과했다. `Lang.fromCode`는 기존 `KO`, `EN`, `JA` enum의 code/name을 대소문자 무시로 처리하며, 누락·잘못된 값은 저장·업로드 없이 거부된다.
|
||||
- 레거시 계약: controller·service diff에서 관리자 수정·삭제와 `POST /event`, `PUT /event`, `DELETE /event/{id}`에 `lang`이 추가되지 않았다.
|
||||
- DB 이관: `20260819_event_lang_ddl.sql`이 nullable 컬럼 추가 → `NULL`의 KO backfill → `NOT NULL DEFAULT 'KO'` 순서를 갖고, 엔티티 기본값과 JA/KO 영속성 테스트도 통과했다. 다만 운영 DDL은 실행하지 않았으므로 실제 기존 row 전체 이관 완료는 증명하지 않고 체크하지 않았다.
|
||||
- JP·강제 매핑: security-on 익명 JP 목록과 인증 JP 팝업 통합 테스트, `EventServiceTest`의 JP→JA 전달, `MemberContentPreferenceIntegrationTest`의 강제 JP/KR 매핑이 통과했다.
|
||||
- 비JP·누락: 국가 누락의 익명 KO 목록과 인증 KO 팝업 통합 테스트, 일반 회원의 US·누락 판정과 비로그인 JP 정규화·누락 KR 테스트가 통과했다. service의 `JP` 이외→`KO` 분기도 diff로 확인했다.
|
||||
- 팝업 선필터: 더 최신 KO와 성인 JA 팝업이 있어도 비성인 JA 조회가 대상 JA를 반환하는 Repository 테스트가 통과했고, 언어 predicate가 `where`에서 정렬·`fetchFirst()` 전에 적용됨을 확인했다.
|
||||
- 콘텐츠 메인: 저수준 service의 `lang = null` 전달과 Repository의 KO/EN/JA 3건 전체 조회 테스트가 통과했다. 6개 production 호출자와 해당 응답 DTO 디렉터리 diff는 없었다.
|
||||
- 기존 정책: Repository 테스트가 활성·시작·종료·성인·ID 내림차순 조건을 함께 검증했고, `EventService` URL 변환 및 `GetEventResponse`·`EventItem` diff가 없음을 확인했다. 구현 리뷰 후 익명 팝업 401과 인증 팝업의 국가별 언어를 함께 검증했고 기준 HEAD 대비 `SecurityConfig` diff가 없음을 확인했다.
|
||||
|
||||
## 12. Open Questions
|
||||
|
||||
- 없음.
|
||||
|
||||
## 13. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 |
|
||||
|---|---:|---|---|
|
||||
| `EVENT-LANG-001~003`, `EVENT-LANG-008` | 1 | `P1-T1`, `P1-T2`, `P1-GATE` | 엔티티·관리자 controller/service/repository 통합 테스트 |
|
||||
| `EVENT-LANG-004~006`, `EVENT-LANG-009` | 2 | `P2-T1`, `P2-GATE` | `EventServiceTest`, `EventRepositoryTest`, `EventControllerIntegrationTest` |
|
||||
| `EVENT-LANG-007` | 2 | `P2-T2`, `P2-GATE` | 언어 없는 기존 서비스 호출과 Repository null 조건 비회귀 테스트 |
|
||||
|
||||
## 14. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-19 | `DEC-001` | 확정 | 앱 국가 판정은 기존 `MemberContentPreferenceService.resolveCountryCode(member)`를 사용한다. | 사용자 인터뷰 A | `EVENT-LANG-004~006`, `P2-T1` |
|
||||
| 2026-08-19 | `DEC-002` | 확정 | 등록 언어는 기존 `Lang`의 `KO`, `EN`, `JA` 모두를 허용한다. | 사용자가 A를 철회하고 B로 확정, 신규 언어 타입·검증 중복 방지 | `EVENT-LANG-001`, `P1-T2` |
|
||||
| 2026-08-19 | `DEC-003` | 확정 | 언어는 등록할 때만 지정하고 수정하지 않는다. | 사용자 인터뷰 A, 콘텐츠 배너의 현재 패턴 | `EVENT-LANG-002`, `P1-GATE` |
|
||||
| 2026-08-19 | `DEC-004` | 확정 | 관리자 목록은 언어 전체를 조회하고 `lang`을 응답한다. | 사용자 인터뷰 A | `EVENT-LANG-003`, `P1-T2` |
|
||||
| 2026-08-19 | `DEC-005` | 확정 | 국가별 필터는 `EventController.getEventList`, `getEventPopup`에만 적용한다. | 사용자 인터뷰 A | `EVENT-LANG-004`, `EVENT-LANG-005`, `EVENT-LANG-007`, `P2-T1~T2` |
|
||||
| 2026-08-19 | `DEC-006` | 확정 | 기존 이벤트는 KO로 backfill하고 `NOT NULL DEFAULT 'KO'`를 적용한다. | 사용자 인터뷰 A, 기존 배너 DDL 패턴 | `EVENT-LANG-008`, `P1-T1` |
|
||||
| 2026-08-19 | `DEC-007` | 확정 | 언어 입력은 `AdminEventBannerController`에만 추가하고 레거시 `POST /event`는 KO 기본값을 사용한다. | 사용자 인터뷰 A | `EVENT-LANG-002`, `EVENT-LANG-008`, `P1-T1~T2` |
|
||||
| 2026-08-20 | `DEC-008` | 확정 | `GET /event`의 익명 접근은 유지하고 `GET /event/popup`은 기준 HEAD의 인증 필수 정책을 유지한다. | 구현 리뷰에서 언어 필터와 무관한 익명 공개 확장을 확인 | `EVENT-LANG-005`, `P2-R1` |
|
||||
|
||||
## 15. 변경 관리
|
||||
|
||||
요구사항이 변경되면 다음 순서로 갱신한다.
|
||||
|
||||
1. 이 문서의 Decision Log에 변경 이유와 날짜를 추가한다.
|
||||
2. 관련 요구사항·수용 기준·API 계약을 갱신한다.
|
||||
3. `plan-task.md`의 범위·Files·Interfaces·체크박스를 코드 변경 전에 먼저 갱신한다.
|
||||
4. 기존 Progress·검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||
@@ -1,148 +0,0 @@
|
||||
# 이벤트 접속 국가별 언어 필터 구현 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1~2 전체 구현과 `P1-GATE`, `P2-GATE` |
|
||||
| 기준 commit 또는 working tree | 기준 `2ad30f90699a03162000d3b87be39f45b92740af` + 미커밋 구현 변경 |
|
||||
| 리뷰 일자 | 2026-08-20 |
|
||||
| 리뷰어 | Codex, 독립 리뷰 agent `event_language_review` |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md` |
|
||||
| 리뷰 상태 | 수정 검증 완료 |
|
||||
|
||||
## 2. 리뷰 목적과 범위
|
||||
|
||||
### 목적
|
||||
|
||||
- 구현이 `EVENT-LANG-001~009`와 기존 API 경계를 충족하는지 확인한다.
|
||||
- 완료 기록과 실제 코드·테스트 결과가 일치하는지 확인한다.
|
||||
|
||||
### 포함 범위
|
||||
|
||||
- 코드: 관리자 이벤트 등록·목록, 앱 이벤트 controller/service/repository, `Event`, `SecurityConfig`
|
||||
- 테스트: 관리자·앱 controller 통합, service, repository, 기존 국가 판정 통합 테스트
|
||||
- 문서: `prd.md`, `plan-task.md`, 운영 DDL
|
||||
|
||||
### 제외 범위
|
||||
|
||||
- 운영 DB DDL 실제 실행과 배포
|
||||
- 이벤트 언어와 관계없는 기능
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
| 심각도 | 기준 |
|
||||
|---|---|
|
||||
| Blocker | 보안·데이터 손실 위험 또는 핵심 흐름 불능 |
|
||||
| High | 확정 요구사항·기존 API 경계 위반 또는 주요 회귀 |
|
||||
| Medium | 제한된 조건의 기능·복구 문제 |
|
||||
| Low | 비핵심 유지보수성·문서 정합성 문제 |
|
||||
|
||||
## 4. 검토한 근거
|
||||
|
||||
### 문서와 코드
|
||||
|
||||
- 요구사항: `EVENT-LANG-001~009`
|
||||
- 계획: `P1-T1~P2-GATE`
|
||||
- 코드: `AdminEventBannerController`, `AdminEventBannerService`, `AdminEventBannerRepository`, `EventController`, `EventService`, `EventRepository`, `Event`, `SecurityConfig`
|
||||
- 테스트: `AdminEventBannerControllerIntegrationTest`, `EventControllerIntegrationTest`, `EventServiceTest`, `EventRepositoryTest`, `MemberContentPreferenceIntegrationTest`
|
||||
|
||||
### 실행 환경
|
||||
|
||||
```text
|
||||
OS: Darwin 25.0.0 x86_64
|
||||
Java: OpenJDK 17.0.15
|
||||
Build: Gradle Wrapper 8.1.1
|
||||
DB: 테스트용 H2 MySQL mode
|
||||
```
|
||||
|
||||
### 실행한 검증
|
||||
|
||||
| 명령 또는 수동 검증 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| 직접 영향 5개 클래스 `cleanTest test` | 성공 | 겹침 제거 후 exit code 0, 27개 테스트 통과, `BUILD SUCCESSFUL in 46s` |
|
||||
| 독립 리뷰의 동일 5개 클래스 실행 | 성공 | exit code 0, `BUILD SUCCESSFUL in 56s` |
|
||||
| 전체 호출자·기준 HEAD 보안 설정 diff 대조 | 발견 | 기준 HEAD의 `/event/popup`은 `anyRequest().authenticated()` 적용 |
|
||||
| 수정 후 독립 정적 재리뷰 | 성공 | Critical·Important·Minor 추가 발견 없음, Ready to merge 판정 |
|
||||
| `git diff --check HEAD` | 성공 | exit code 0, whitespace 오류 없음 |
|
||||
|
||||
동시에 실행된 두 Gradle `cleanTest`가 같은 XML 결과 경로를 사용해 한 차례 writer 경합이 발생했다. 테스트 assertion은 모두 통과했고, 병행 실행을 중단한 뒤 동일 명령을 재실행해 성공 종료를 확인했다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-EVENT-LANG-001` | High | 수정 완료 | `GET /event/popup`의 기존 인증 경계가 익명 공개로 확장됨 | `P2-T1` | `P2-R1` |
|
||||
|
||||
언어 저장·관리자 전체 언어 조회·앱 KO/JA QueryDSL 필터·콘텐츠 메인 null 언어 경로에서는 추가 확정 문제를 찾지 못했다.
|
||||
|
||||
## 6. 발견 사항 상세
|
||||
|
||||
### REV-EVENT-LANG-001 — `GET /event/popup`의 기존 인증 경계가 익명 공개로 확장됨
|
||||
|
||||
- **심각도:** High
|
||||
- **상태:** 수정 완료
|
||||
- **관련 요구사항:** `EVENT-LANG-005`, 기존 접근 정책 유지
|
||||
- **소유 Task:** `P2-R1`
|
||||
|
||||
**관찰 내용**
|
||||
|
||||
기준 HEAD의 `SecurityConfig`는 `GET /event`만 `permitAll()`이고, `/event/popup`은 마지막 `anyRequest().authenticated()`를 적용받는다. 수정 전 구현은 `/event/popup` exact matcher를 추가해 익명 요청도 허용했다. 국가별 언어 필터에는 인증 정책 변경이 필요하지 않으므로 요청 범위를 넘어선 보안 경계 확장이었다.
|
||||
|
||||
**근거**
|
||||
|
||||
- 기준 코드: `SecurityConfig.kt`의 `/event` matcher 다음에 `/event/popup` matcher가 없고 마지막 규칙은 `anyRequest().authenticated()`다.
|
||||
- 수정 전 코드: `SecurityConfig.kt`에 `GET /event/popup` `permitAll()` 한 줄이 추가됐다.
|
||||
- 수정 전 테스트: `EventControllerIntegrationTest`의 팝업 2개가 익명 200을 기대해 확장된 동작을 고정했다.
|
||||
- 문서: `prd.md`가 두 API 모두 기존 로그인·비로그인 접근이라고 잘못 기술했다.
|
||||
|
||||
**재현 또는 검증 절차**
|
||||
|
||||
1. 수정 전 security filter를 켠 상태로 인증 없이 `GET /event/popup`을 호출한다.
|
||||
2. 수정 전 결과는 HTTP 200이다.
|
||||
3. 기준 HEAD의 matcher 순서에서는 HTTP 401이다.
|
||||
4. 요구되는 결과는 기존 인증 경계를 유지하는 HTTP 401이며, 인증 사용자의 국가별 팝업 필터는 계속 동작해야 한다.
|
||||
|
||||
**영향**
|
||||
|
||||
명시 승인 없이 endpoint 접근 범위가 넓어지고, PRD가 실제 기준 동작과 불일치한다.
|
||||
|
||||
**권장 조치**
|
||||
|
||||
`SecurityConfig`의 신규 matcher 한 줄을 제거한다. 통합 테스트에는 익명 팝업 401 회귀를 추가하고, JP·국가 누락 팝업 언어 테스트는 `MemberAdapter` 인증 사용자로 실행한다. `/event`의 기존 익명 접근과 언어 조회 구현은 변경하지 않는다.
|
||||
|
||||
**판정 기록**
|
||||
|
||||
- 2026-08-20 — 기준 HEAD·현재 diff·security-on 통합 테스트를 대조해 확정했다.
|
||||
- 2026-08-20 — `P2-R1`에서 exact `permitAll()`을 제거하고 익명 401·인증 사용자 KO/JA 팝업과 직접 영향 28개 테스트를 통과해 수정 완료로 판정했다.
|
||||
|
||||
## 7. 확정 항목의 plan·goal 전환
|
||||
|
||||
`REV-EVENT-LANG-001`을 `plan-task.md`의 `P2-R1` 회귀 수정 Goal로 전환했다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 판정 항목 | 결과 | 근거 |
|
||||
|---|---|---|
|
||||
| 리뷰 범위 전체 확인 | 충족 | 문서·production diff·전체 호출자·DDL·직접 영향 테스트 확인 |
|
||||
| 후보 항목 판정 완료 | 충족 | 1건 확정, 나머지 substantive issue 없음 |
|
||||
| 확정 항목 plan 반영 | 충족 | `P2-R1` 추가 |
|
||||
| 검증 명령과 결과 기록 | 충족 | 4절 기록 |
|
||||
|
||||
**최종 결론:** 수정 검증 완료
|
||||
|
||||
**남은 항목:** 없음
|
||||
|
||||
## 9. 수정 후 검증 기록
|
||||
|
||||
### 1차 수정 검증 — 2026-08-20
|
||||
|
||||
- 무엇을: `REV-EVENT-LANG-001`의 익명 팝업 공개를 제거하고 기존 인증 경계를 복원했다.
|
||||
- 왜: 국가별 언어 필터와 무관한 보안 접근 범위 확장을 제거하기 위해서다.
|
||||
- 어떻게:
|
||||
- RED: `EventControllerIntegrationTest` 5개 중 익명 팝업 401만 실제 200으로 실패했고 인증 팝업·익명 목록 4개는 통과했다.
|
||||
- GREEN: `SecurityConfig`의 exact matcher 한 줄 제거 후 controller 통합 테스트 5개가 `BUILD SUCCESSFUL in 38s`로 통과했다.
|
||||
- 회귀: 직접 영향 5개 클래스 28개가 failures `0`, errors `0`, skipped `0`, `BUILD SUCCESSFUL in 42s`로 통과했다.
|
||||
- 정적 검증: `ktlintCheck`, `tasks --all`, `git diff --check HEAD`가 모두 성공했고 기준 HEAD 대비 `SecurityConfig` diff가 없다.
|
||||
- 독립 재리뷰: 수정 후 최종 diff와 문서를 다시 검토해 Critical·Important·Minor 모두 추가 발견 없음으로 판정했다.
|
||||
- 남은 항목: 운영 DB DDL 실제 반영은 기존 범위대로 미실행이며, 리뷰 확정 항목은 남아 있지 않다.
|
||||
@@ -1,518 +0,0 @@
|
||||
# 추천 탭 배너 접속 국가별 언어 필터 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-19 |
|
||||
| 요구사항 기준 | `docs/20260819_추천탭_배너_접속국가별_언어필터/prd.md` |
|
||||
| API 기준 | 기존 공개 API 계약 유지 |
|
||||
| 현재 Phase | Phase 2: 언어 필터 limit 선행 회귀 테스트 보완 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
메인 홈 추천과 메인 콘텐츠 추천에서 로그인 여부와 기존 강제 국가 매핑을 반영해 일본은 일본어, 그 외 국가는 한국어 배너만 조회한다.
|
||||
|
||||
## Architecture
|
||||
|
||||
기존 `MemberContentPreferenceService.resolveCountryCode`가 비로그인 사용자까지 처리하도록 입력만 확장하고 강제 국가 매핑과 국가 정규화를 재사용한다. 두 추천 application 진입점에서 국가를 `Lang.JA` 또는 `Lang.KO`로 변환해 기존 port에 전달하며, 두 QueryDSL Repository가 `content_banner.lang`을 정렬·limit 전 조회 조건으로 적용한다.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
Kotlin, Java 17, Spring Boot 2.7.14, JPA/QueryDSL, JUnit 5, Mockito, Gradle Wrapper
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `JP`는 `Lang.JA`, 그 외 국가와 국가 정보 누락은 `Lang.KO`로 고정한다.
|
||||
- 로그인 회원의 기존 강제 KR/JP 매핑은 요청 헤더보다 우선한다.
|
||||
- `Accept-Language`, DB 스키마, 공개 API endpoint/DTO/응답 스키마를 변경하지 않는다.
|
||||
- 기존 탭·성인·활성·차단·대상 유효성·정렬·limit 조건을 유지한다.
|
||||
- 신규 dependency, 언어 resolver abstraction과 메모리 후처리를 추가하지 않는다.
|
||||
- 모든 구현 Task는 RED → GREEN → REFACTOR 순서로 진행한다.
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `3/3` | 없음 | 없음 |
|
||||
| 2 | 완료 | `1/1` | 없음 | 없음 |
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 구현 완료 즉시 해당 Task 체크박스와 현재 상태를 갱신한다.
|
||||
- 실제 검증 결과는 기존 기록을 덮어쓰지 않고 `Progress`에 누적한다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 로그인·비로그인 사용자 공통 국가 코드 판정
|
||||
- 홈 추천 `tab_id IS NULL` 배너의 `KO`/`JA` 필터
|
||||
- 콘텐츠 추천 `tab_id = 2` 배너의 `KO`/`JA` 필터
|
||||
- application → port → persistence 언어 전달
|
||||
- focused application/Repository 테스트와 직접 영향 controller/E2E 회귀
|
||||
|
||||
### 제외
|
||||
|
||||
- `Lang.EN`을 선택하는 국가 정책
|
||||
- 배너 외 추천 섹션의 국가·언어 필터
|
||||
- 관리자/v1 배너 조회 변경
|
||||
- DB 데이터·스키마, API 계약과 dependency 변경
|
||||
- 기존 배너 정책의 리팩터링
|
||||
|
||||
## 파일 구조
|
||||
|
||||
| 책임 | 파일 |
|
||||
|---|---|
|
||||
| 공통 국가 판정 | `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`, `MemberContentPreferenceCountryResolver.kt` |
|
||||
| 홈 application 언어 선택·전달 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`, `v2/recommendation/application/HomeRecommendationQueryService.kt` |
|
||||
| 홈 port·조회 조건 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`, `v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` |
|
||||
| 콘텐츠 추천 application 언어 선택·전달 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt` |
|
||||
| 콘텐츠 추천 port·조회 조건 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`, `v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt` |
|
||||
|
||||
## Phase 1: 접속 국가별 배너 언어 필터
|
||||
|
||||
**Phase 결과:** 두 추천 API가 판정 국가에 대응하는 한 언어의 배너만 기존 정책대로 반환한다.
|
||||
|
||||
**선행조건:** 승인된 PRD `BANNER-LANG-001~006`.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`~`P1-T3`과 `P1-GATE` 완료, 실제 검증 결과 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 로그인·비로그인 공통 국가 판정 (`P1-T1`)
|
||||
|
||||
**Goal 실행 `P1-T1`:** 기존 강제 국가 매핑을 유지하면서 비로그인 사용자도 동일한 정규화와 기본값으로 국가 코드를 판정한다.
|
||||
|
||||
- **시작 조건:** PRD `BANNER-LANG-003`, `BANNER-LANG-004` 확정.
|
||||
- **완료 증거:** nullable 회원 국가 판정 테스트가 RED 후 GREEN이고 기존 강제 KR/JP 매핑 테스트가 통과한다.
|
||||
- **범위 밖:** 국가를 배너 언어로 변환하거나 배너를 조회하는 로직.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceIntegrationTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `CountryContext.countryCode`, 기존 강제 KR/JP 회원 ID 매핑.
|
||||
- Produces: `MemberContentPreferenceService.resolveCountryCode(member: Member?): String` — 정규화한 국가 코드이며 null·blank는 `KR`.
|
||||
|
||||
- [x] **RED:** 비로그인 `JP`, 소문자/공백 포함 JP, 헤더 누락을 검증하는 테스트를 추가한다.
|
||||
|
||||
```kotlin
|
||||
countryContext.setCountryCode(" jp ")
|
||||
assertEquals("JP", service.resolveCountryCode(null))
|
||||
|
||||
countryContext.setCountryCode(null)
|
||||
assertEquals("KR", service.resolveCountryCode(null))
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 현재 `Member` non-null 계약 때문에 컴파일이 실패하는지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"
|
||||
```
|
||||
|
||||
**Expected:** `resolveCountryCode(null)` 호출의 타입 불일치로 실패한다.
|
||||
|
||||
- [x] **GREEN:** 기존 helper가 nullable 회원을 받고, non-null 회원만 기존 ID 필수 조건을 검사하도록 최소 변경한다.
|
||||
|
||||
```kotlin
|
||||
fun resolveCountryCode(member: Member?): String {
|
||||
if (member != null) requireMemberId(member)
|
||||
return resolveCountryCodeWithForcedMapping(member, countryContext.countryCode)
|
||||
}
|
||||
|
||||
fun resolveCountryCodeWithForcedMapping(member: Member?, requestCountryCode: String?): String {
|
||||
val memberId = member?.id
|
||||
if (memberId != null && FORCED_KR_MEMBER_IDS.contains(memberId)) {
|
||||
return "KR"
|
||||
}
|
||||
if (memberId != null && FORCED_JP_MEMBER_IDS.contains(memberId)) {
|
||||
return "JP"
|
||||
}
|
||||
return requestCountryCode
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.uppercase()
|
||||
?: "KR"
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 비로그인과 기존 강제 매핑 테스트가 모두 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 이번 변경에서 생긴 중복만 정리하고 공개 국가 정책 메서드를 추가하지 않는다. focused test를 재실행해 결과를 `Progress`에 기록한다.
|
||||
|
||||
#### Task 1.2 홈 추천 배너 언어 필터 (`P1-T2`)
|
||||
|
||||
**Goal 실행 `P1-T2`:** 홈 추천 배너가 판정 국가에 맞는 `JA` 또는 `KO`만 DB에서 조회한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료, PRD `BANNER-LANG-001`, `BANNER-LANG-005` 확정.
|
||||
- **완료 증거:** facade·query service 전달 테스트와 홈 Repository 언어 필터 테스트가 RED 후 GREEN이다.
|
||||
- **범위 밖:** 콘텐츠 추천 탭 배너와 홈의 다른 추천 섹션.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `MemberContentPreferenceService.resolveCountryCode(member: Member?): String`.
|
||||
- Produces: `findHomeBanners(..., lang: Lang = Lang.KO)` application/port 계약과 QueryDSL `audioContentBanner.lang.eq(lang)` 조건.
|
||||
|
||||
- [x] **RED:** facade가 `JP`를 `Lang.JA`, 그 외를 `Lang.KO`로 전달하고 query service가 `Lang`을 port에 위임하는 테스트를 추가한다.
|
||||
|
||||
```kotlin
|
||||
Mockito.doReturn("JP").`when`(preferenceService).resolveCountryCode(member)
|
||||
|
||||
facade.getHomeRecommendations(member)
|
||||
|
||||
assertEquals(Lang.JA, queryPort.bannerLang)
|
||||
```
|
||||
|
||||
- [x] **RED:** 홈 Repository fixture helper에 `lang: Lang = Lang.KO`를 추가하고 같은 조건의 KO/JA 배너 중 요청 언어만 반환하는 테스트를 추가한다.
|
||||
|
||||
```kotlin
|
||||
val koBanner = saveBanner("ko.png", AudioContentBannerType.LINK, 1, isActive = true, lang = Lang.KO)
|
||||
val jaBanner = saveBanner("ja.png", AudioContentBannerType.LINK, 2, isActive = true, lang = Lang.JA)
|
||||
|
||||
assertEquals(listOf(koBanner.thumbnailImage), repository.findHomeBanners(20, lang = Lang.KO).map { it.thumbnailImage })
|
||||
assertEquals(listOf(jaBanner.thumbnailImage), repository.findHomeBanners(20, lang = Lang.JA).map { it.thumbnailImage })
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 `Lang` 전달 계약과 Repository 조건이 없어 발생하는 컴파일/assertion 실패를 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest"
|
||||
```
|
||||
|
||||
- [x] **GREEN:** facade에서 국가를 언어로 변환해 전달한다. service와 port 시그니처 끝에는 기본값 `Lang.KO`인 인자를 추가하고 Repository override는 같은 `Lang` 인자를 받는다.
|
||||
|
||||
```kotlin
|
||||
val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||
|
||||
queryService.findHomeBanners(
|
||||
limit = HOME_BANNER_LIMIT,
|
||||
memberId = member?.id,
|
||||
includeAdultBanners = includeAdult,
|
||||
lang = bannerLang
|
||||
)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
.where(
|
||||
audioContentBanner.isActive.isTrue,
|
||||
audioContentBanner.tab.isNull,
|
||||
audioContentBanner.lang.eq(lang),
|
||||
includeAdultBannerCondition(includeAdultBanners),
|
||||
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||
)
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 언어 전달·필터와 기존 홈 배너 조건이 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 국가→언어 변환을 별도 abstraction으로 만들지 않고 한 줄 정책으로 유지한다. 이번 Task가 만든 중복만 정리하고 focused test 결과를 `Progress`에 기록한다.
|
||||
|
||||
#### Task 1.3 콘텐츠 추천 배너 언어 필터 (`P1-T3`)
|
||||
|
||||
**Goal 실행 `P1-T3`:** 콘텐츠 추천 배너가 판정 국가에 맞는 `JA` 또는 `KO`만 DB에서 조회한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료, PRD `BANNER-LANG-002`, `BANNER-LANG-005` 확정.
|
||||
- **완료 증거:** query service 전달 테스트와 콘텐츠 추천 Repository 언어 필터 테스트가 RED 후 GREEN이다.
|
||||
- **범위 밖:** 홈 추천 배너와 오디오 추천의 배너 외 섹션.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `MemberContentPreferenceService.resolveCountryCode(member: Member?): String`.
|
||||
- Produces: `AudioRecommendationQueryPort.findBanners(..., lang: Lang = Lang.KO)`와 QueryDSL `audioContentBanner.lang.eq(lang)` 조건.
|
||||
|
||||
- [x] **RED:** 기존 `shouldUseStoredPreferenceForMemberAdultVisibility`에 JP 국가 stub과 배너 호출 검증을 추가하고, 비로그인 조립 테스트는 `Lang.KO` 전달을 검증하도록 보강한다.
|
||||
|
||||
```kotlin
|
||||
Mockito.doReturn("JP").`when`(preferenceService).resolveCountryCode(member)
|
||||
|
||||
service.getRecommendations(member)
|
||||
|
||||
Mockito.verify(queryPort).findBanners(
|
||||
AudioRecommendationQueryService.BANNER_LIMIT,
|
||||
member.id,
|
||||
true,
|
||||
Lang.JA
|
||||
)
|
||||
```
|
||||
|
||||
- [x] **RED:** 콘텐츠 추천 Repository fixture helper에 `lang: Lang = Lang.KO`를 추가하고 동일 탭의 KO/JA 배너가 요청 언어별로 분리되는 테스트를 추가한다.
|
||||
|
||||
```kotlin
|
||||
val koBanner = saveBanner(
|
||||
"ko.png",
|
||||
AudioContentBannerType.LINK,
|
||||
1,
|
||||
link = "https://ko.test",
|
||||
tab = recommendationTab,
|
||||
lang = Lang.KO
|
||||
)
|
||||
val jaBanner = saveBanner(
|
||||
"ja.png",
|
||||
AudioContentBannerType.LINK,
|
||||
2,
|
||||
link = "https://ja.test",
|
||||
tab = recommendationTab,
|
||||
lang = Lang.JA
|
||||
)
|
||||
|
||||
assertEquals(listOf("https://cdn.test/${koBanner.thumbnailImage}"), repository.findBanners(20, null, false, Lang.KO).map { it.imageUrl })
|
||||
assertEquals(listOf("https://cdn.test/${jaBanner.thumbnailImage}"), repository.findBanners(20, null, false, Lang.JA).map { it.imageUrl })
|
||||
```
|
||||
- [x] **RED 확인:** 아래 focused test를 실행해 `Lang` 인자와 Repository 필터가 없어 발생하는 컴파일/assertion 실패를 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest"
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `getRecommendations`에서 국가를 언어로 변환해 배너 호출에만 전달한다. port에는 기본값 `Lang.KO`인 인자를 추가하고 Repository override는 같은 `Lang` 인자를 받는다.
|
||||
|
||||
```kotlin
|
||||
val bannerLang = if (memberContentPreferenceService.resolveCountryCode(member) == "JP") Lang.JA else Lang.KO
|
||||
|
||||
banners = queryPort.findBanners(BANNER_LIMIT, memberId, canViewAdultContent, bannerLang)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
.where(
|
||||
audioContentBanner.isActive.isTrue,
|
||||
audioContentBanner.tab.id.eq(2L),
|
||||
audioContentBanner.lang.eq(lang),
|
||||
adultBannerCondition(canViewAdultContent),
|
||||
activeBannerTargetCondition(memberId, bannerCreator, seriesOwner)
|
||||
)
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 언어 필터와 기존 탭·성인·차단 조건이 함께 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 배너 외 오디오 추천 로직을 변경하지 않는다. 이번 Task가 만든 중복만 정리하고 focused test 결과를 `Progress`에 기록한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`~`P1-T3`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||
- [x] `BANNER-LANG-001~006`이 구현 또는 명시적 제외로 추적된다.
|
||||
- [x] 공개 API와 DB 스키마 변경이 없다.
|
||||
- [x] 실제 검증 결과가 `Progress`에 기록됐다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 두 추천 API의 국가별 배너 언어 필터와 기존 배너 정책의 비회귀를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1`~`P1-T3` 완료.
|
||||
- **완료 증거:** 아래 focused/영향 범위 test, lint, 문서 명령과 수동 검증 통과 및 `Progress` 기록.
|
||||
- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
|
||||
./gradlew ktlintCheck
|
||||
./gradlew tasks --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 모든 명령이 exit code `0`으로 끝난다. JP 판정은 두 API에서 JA 배너만, 그 외 판정은 KO 배너만 반환하며 기존 탭·성인·활성·차단·대상 유효성·정렬·limit와 응답 스키마가 유지된다.
|
||||
|
||||
수동 검증:
|
||||
|
||||
- [x] `git diff --name-only`에 계획된 production/test 파일과 이 작업 문서 이외의 변경이 없는지 확인한다.
|
||||
- [x] `git diff`에서 endpoint, request/response DTO, DB 스키마, dependency와 `Accept-Language` 사용이 추가되지 않았는지 확인한다.
|
||||
- [x] 두 Repository 모두 `lang` 조건이 `orderBy`와 `limit`보다 앞선 `where`에 있는지 확인한다.
|
||||
|
||||
전체 회귀 `./gradlew test`는 변경이 국가 판정과 두 배너 조회 경로에 한정되고 위 명령이 application, persistence, controller/E2E를 포함하므로 기본 생략한다. focused test로 영향 범위를 판단할 수 없는 실패가 발생하거나 공통 국가 판정 변경의 회귀 범위가 확대되면 전체 회귀를 실행하고 결과를 `Progress`에 기록한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | 승인된 PRD | 아니요 | 기존 국가 정규화·강제 매핑 테스트와 구현을 다시 대조 |
|
||||
| 2 | `P1-T2` | `P1-T1` | 아니요 | 홈 application/port/repository 계약을 한 단계씩 대조 |
|
||||
| 3 | `P1-T3` | `P1-T1` | 아니요 | 콘텐츠 추천 application/port/repository 계약을 한 단계씩 대조 |
|
||||
| 4 | `P1-GATE` | `P1-T1`~`P1-T3` | 아니요 | 실패를 소유한 Task에 회귀 수정 Goal 추가 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-T2 → P1-T3 → P1-GATE
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 기존 완료 문서와 검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||
- 공개 endpoint, request/response DTO와 API envelope를 변경하지 않는다.
|
||||
- DB schema, 배너 데이터, 관리자/v1 배너 API를 변경하지 않는다.
|
||||
- `Accept-Language`나 회원 저장 언어를 국가 판정 대신 사용하지 않는다.
|
||||
- 조회 후 컬렉션 filter로 언어를 제거하지 않는다.
|
||||
- 신규 dependency, enum, 공통 abstraction과 관련 없는 리팩터링을 추가하지 않는다.
|
||||
- test를 삭제·skip·완화하지 않는다.
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- PRD와 구현이 충돌하면 `prd.md`의 Decision Log와 이 계획을 먼저 갱신한 뒤 구현한다.
|
||||
- `CloudFront-Viewer-Country` 또는 기존 강제 국가 매핑 정책을 바꿔야 하면 범위 확장으로 보고 사용자 승인 전 중단한다.
|
||||
- `Lang.EN`, 다른 국가별 언어 또는 배너 외 추천 섹션이 필요해지면 별도 요구사항으로 분리한다.
|
||||
- 공개 API, DB schema 또는 관리자/v1 조회 변경이 필요해지면 사용자 승인 전 진행하지 않는다.
|
||||
- 체크박스, test와 `Progress` 기록이 모두 충족된 뒤에만 Goal을 완료 처리한다.
|
||||
|
||||
## Progress
|
||||
|
||||
### 2026-08-19 문서 작성
|
||||
|
||||
- 사용자 인터뷰로 `JP → JA`, 그 외 국가 → `KO` 정책과 로그인 회원의 기존 강제 국가 매핑 적용을 확정했다.
|
||||
- PRD와 구현 계획만 작성했으며 production/test 코드는 변경하지 않았다.
|
||||
- placeholder 검색과 `git diff --check`에서 문제가 없음을 확인했다.
|
||||
- `./gradlew tasks --all`은 최초 sandbox의 Gradle wrapper lock 접근 제한으로 실패했으며, 승인된 동일 명령 재실행에서 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
### 2026-08-19 P1-T1 완료
|
||||
|
||||
- RED: `./gradlew test --tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest"` 실행 결과 `resolveCountryCode(null)` 4곳이 `Member` non-null 타입 불일치로 `:compileTestKotlin FAILED`가 됨을 확인했다.
|
||||
- GREEN/REFACTOR: `MemberContentPreferenceService.resolveCountryCode`와 `resolveCountryCodeWithForcedMapping`만 nullable 회원을 받도록 최소 변경했다. 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
### 2026-08-19 P1-T2 완료
|
||||
|
||||
- RED: 홈 facade/service/repository 테스트에 `Lang` 전달과 `content_banner.lang` 조건 기대를 추가한 뒤 focused test를 실행했고, `findHomeBanners`의 `lang` 파라미터 부재로 `:compileTestKotlin FAILED`가 됨을 확인했다.
|
||||
- GREEN/REFACTOR: 홈 facade에서 `JP`만 `Lang.JA`, 그 외 `Lang.KO`를 한 줄로 선택해 service/port/repository에 전달하고, QueryDSL `where`에 `audioContentBanner.lang.eq(lang)`을 추가했다. 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
### 2026-08-19 P1-T3 완료
|
||||
|
||||
- RED: 콘텐츠 추천 service/repository 테스트에 `Lang.KO` 기본 전달, `JP`의 `Lang.JA` 전달, 추천 탭 배너 언어 분리 기대를 추가한 뒤 focused test를 실행했고, `findBanners`의 `Lang` 인자 부재로 `:compileTestKotlin FAILED`가 됨을 확인했다.
|
||||
- GREEN/REFACTOR: `AudioRecommendationQueryService.getRecommendations`에서 배너 호출에만 `JP → Lang.JA`, 그 외 `Lang.KO`를 전달하고, QueryDSL `where`에 `audioContentBanner.lang.eq(lang)`을 추가했다. 같은 focused test 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
### 2026-08-19 P1-GATE 완료
|
||||
|
||||
- Phase Gate focused/controller/E2E 명령 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- `./gradlew ktlintCheck`는 최초 실행에서 테스트 import 순서 2곳으로 실패했고, import 정렬 후 재실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- `./gradlew tasks --all` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- `git diff --check` 실행 결과 출력 없이 통과했다.
|
||||
- `git diff --name-only`와 diff 검토로 계획된 production/test 파일 및 이 작업 문서 외 변경이 없고, 공개 endpoint/request/response DTO, DB 스키마, dependency, `Accept-Language` 정책이 추가되지 않았음을 확인했다.
|
||||
- 두 Repository 모두 `audioContentBanner.lang.eq(lang)` 조건이 `where` 안에서 `orderBy`와 `limit`보다 먼저 적용됨을 확인했다.
|
||||
- Reviewer Gate에서 요구사항 차단 이슈 없음으로 승인받았다.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-19 | `DEC-001` | 확정 | 공통 국가 판정을 먼저 nullable 회원까지 확장한 뒤 두 배너 경로가 소비한다. | 기존 강제 매핑·정규화 재사용과 중복 방지 | `P1-T1`~`P1-T3` |
|
||||
| 2026-08-19 | `DEC-002` | 확정 | 두 port의 `lang`은 기존 직접 호출 호환을 위해 기본값 `Lang.KO`를 사용한다. | 비JP 정책과 기존 KO fixture 일치 | `P1-T2`, `P1-T3` |
|
||||
| 2026-08-19 | `DEC-003` | 확정 | 국가→언어 변환은 두 application 진입점의 한 줄 정책으로 두고 별도 abstraction을 만들지 않는다. | 사용처가 두 곳뿐인 고정 정책의 최소 구현 | `P1-T2`, `P1-T3` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
### 2026-08-19 2차 리뷰
|
||||
|
||||
- production 호출 흐름과 QueryDSL 조건에는 요구사항 차단 문제가 없다.
|
||||
- 두 Repository 언어 테스트가 `limit = 20`으로 배너 2개를 모두 수용해 `BANNER-LANG-005`의 "다른 언어 배너가 limit을 차지하지 않음" 회귀를 직접 방어하지 못하는 테스트 공백을 확인했다.
|
||||
|
||||
## Phase 2: 언어 필터 limit 선행 회귀 테스트 보완
|
||||
|
||||
**Phase 결과:** 두 Repository 테스트가 언어 조건이 제거되거나 limit 이후로 밀리는 회귀를 직접 검출한다.
|
||||
|
||||
**선행조건:** `P1-GATE` 완료와 2차 리뷰 테스트 공백 확정.
|
||||
|
||||
**Phase 완료 조건:** `P2-T1`과 `P2-GATE` 완료, mutation RED와 복구 후 GREEN 결과 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 2.1 언어 필터가 limit보다 먼저 적용되는 회귀 테스트 (`P2-T1`)
|
||||
|
||||
**Goal 실행 `P2-T1`:** 낮은 `orders`의 반대 언어 배너가 있어도 `limit = 1` 조회는 요청 언어 배너를 반환함을 두 Repository에서 검증한다.
|
||||
|
||||
- **시작 조건:** PRD `BANNER-LANG-005`, 2차 리뷰 결과 확정.
|
||||
- **완료 증거:** 두 테스트가 정상 구현에서 통과하고, 각 Repository의 언어 조건 제거 mutation에서 의도한 assertion 실패를 보인 뒤 복구 후 다시 통과한다.
|
||||
- **범위 밖:** production query, 배너 정렬 정책, API 계약 변경.
|
||||
- **TDD 예외 사유:** production 동작은 이미 올바르고 이번 보완은 기존 테스트가 특정 회귀를 검출하는지 확인하는 테스트 전용 변경이다.
|
||||
- **대체 검증 방법:** 테스트를 먼저 보강한 뒤 각 Repository의 `audioContentBanner.lang.eq(lang)`을 일시 제거해 실패를 확인하고 즉시 복구한 뒤 같은 테스트의 통과를 확인한다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt`
|
||||
- Mutation 후 원상 복구 확인: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Mutation 후 원상 복구 확인: `src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt`
|
||||
|
||||
- [x] **RED:** 각 언어 테스트에서 `orders = 1`인 반대 언어와 `orders = 2`인 요청 언어를 만들고 `limit = 1` 결과가 요청 언어 한 건인지 검증한다.
|
||||
- [x] **RED 확인:** 홈 Repository의 언어 조건을 일시 제거하고 홈 단일 테스트가 반대 언어를 반환해 실패하는지 확인한 뒤 원상 복구한다.
|
||||
- [x] **RED 확인:** 콘텐츠 추천 Repository의 언어 조건을 일시 제거하고 콘텐츠 추천 단일 테스트가 반대 언어를 반환해 실패하는지 확인한 뒤 원상 복구한다.
|
||||
- [x] **GREEN:** production 변경 없이 두 테스트의 최종 fixture와 `limit = 1` assertion만 유지한다.
|
||||
- [x] **GREEN 확인:** 아래 두 focused test class가 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 테스트 이름·fixture 중복만 최소 정리하고 production diff가 Phase 1 구현과 동일한지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest"
|
||||
```
|
||||
|
||||
### Phase 2 Gate (`P2-GATE`)
|
||||
|
||||
- **시작 조건:** `P2-T1` 완료.
|
||||
- **완료 증거:** Phase 1 Gate focused/controller/E2E 테스트, `ktlintCheck`, `tasks --all`, `git diff --check` 통과와 재리뷰 결과 기록.
|
||||
- **범위 밖:** 새 production 동작과 관련 없는 테스트 확장.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceIntegrationTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.home.application.HomeRecommendationFacadeTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.recommendation.adapter.out.persistence.DefaultHomeRecommendationQueryRepositoryTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.application.AudioRecommendationQueryServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.content.recommendation.adapter.out.persistence.DefaultAudioRecommendationQueryRepositoryTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationControllerTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.content.recommendation.adapter.in.web.AudioRecommendationEndToEndTest"
|
||||
./gradlew ktlintCheck
|
||||
./gradlew tasks --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
**Expected:** 모든 명령이 exit code `0`으로 끝나고, 두 언어 테스트는 반대 언어가 더 낮은 `orders`여도 요청 언어 배너를 한 건 반환한다.
|
||||
|
||||
## Phase 2 Progress
|
||||
|
||||
### 2026-08-19 `P2-T1` 완료
|
||||
|
||||
- 테스트 보완: 두 Repository 언어 테스트를 `limit = 1`로 강화하고 이름을 limit 선행 동작이 드러나도록 변경했다.
|
||||
- 홈 mutation RED: `DefaultHomeRecommendationQueryRepository`의 언어 조건을 일시 제거한 단일 테스트가 `DefaultHomeRecommendationQueryRepositoryTest.kt:276`에서 assertion 실패함을 확인하고 즉시 복구했다.
|
||||
- 콘텐츠 추천 mutation RED: `DefaultAudioRecommendationQueryRepository`의 언어 조건을 일시 제거한 단일 테스트가 `DefaultAudioRecommendationQueryRepositoryTest.kt:126`에서 assertion 실패함을 확인하고 즉시 복구했다.
|
||||
- GREEN: 복구 후 두 Repository focused test class를 함께 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
|
||||
### 2026-08-19 `P2-GATE` 완료
|
||||
|
||||
- Phase 1 Gate와 같은 focused/application/Repository/controller/E2E 테스트 9개 class를 실행해 `BUILD SUCCESSFUL`을 확인했다.
|
||||
- `./gradlew ktlintCheck`와 `./gradlew tasks --all`이 각각 `BUILD SUCCESSFUL`로 끝났다.
|
||||
- `git diff --check` 결과 오류가 없고, 두 production Repository에 `audioContentBanner.lang.eq(lang)`이 복구된 상태임을 확인했다.
|
||||
- 최종 독립 재리뷰에서 최초 Important 이슈 해소와 Critical/Important/Minor 추가 이슈 없음 판정을 받았다.
|
||||
- 전체 회귀 `./gradlew test`는 실행하지 않았다. 변경이 두 기존 Repository 테스트의 limit 경계 강화에 한정되고 Phase Gate가 직접 영향 application/persistence/controller/E2E 범위를 포함하므로 생략했다.
|
||||
@@ -1,113 +0,0 @@
|
||||
# 추천 탭 배너 접속 국가별 언어 필터 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-08-19 |
|
||||
| 최종 수정일 | 2026-08-19 |
|
||||
| 대상 제품 | 메인 홈 추천 탭, 메인 콘텐츠 추천 탭 |
|
||||
| 작성자·결정권자 | 사용자 |
|
||||
| 관련 API Contract | 신규 없음. 기존 공개 API 계약 유지 |
|
||||
| 관련 구현 계획 | `docs/20260819_추천탭_배너_접속국가별_언어필터/plan-task.md` |
|
||||
| 관련 기존 문서 | `docs/20260805_추천탭_배너_조회조건/prd.md`, `docs/20260529_메인_홈_추천_API/prd.md`, `docs/20260623_메인_콘텐츠_추천_탭_API/prd.md` |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
메인 홈 추천 API와 메인 콘텐츠 추천 API에서 접속 국가에 맞는 언어의 배너만 반환한다. 로그인 회원은 기존 강제 국가 매핑을 포함한 국가 판정 결과를 사용하고, 비로그인 사용자는 `CloudFront-Viewer-Country` 요청 헤더를 기준으로 판정한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- 두 추천 API의 현재 배너 QueryDSL 조회에는 `content_banner.lang` 조건이 없다.
|
||||
- 그 결과 한국어·일본어·영어 배너가 같은 응답에 섞일 수 있다.
|
||||
- 언어 필터가 조회 후 적용되면 다른 언어 배너가 limit을 차지해 필요한 배너가 누락될 수 있다.
|
||||
|
||||
문제를 해결했다는 판단은 두 API가 배너 정렬과 limit 적용 전에 확정된 언어 조건을 적용하고, 기존 배너 조회 조건과 공개 응답 스키마를 유지하는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 접속 국가 판정 결과가 `JP`이면 `Lang.JA` 배너만 반환한다.
|
||||
- `JP` 이외의 국가와 국가 정보가 없는 경우에는 `Lang.KO` 배너만 반환한다.
|
||||
- 로그인 회원은 `MemberContentPreferenceService.resolveCountryCode`의 기존 강제 국가 매핑을 유지한다.
|
||||
- 비로그인 사용자도 요청 국가를 판정할 수 있도록 동일한 국가 정규화와 기본값 정책을 사용한다.
|
||||
- 언어 조건을 DB 조회 단계에서 정렬·limit보다 먼저 적용한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- `Lang.EN` 배너를 반환하는 국가 정책을 추가하지 않는다.
|
||||
- `Accept-Language`를 배너 언어 판정에 사용하지 않는다.
|
||||
- `CloudFront-Viewer-Country` 헤더 계약이나 `CountryInterceptor`를 변경하지 않는다.
|
||||
- 공개 API endpoint, request/response DTO와 응답 스키마를 변경하지 않는다.
|
||||
- 기존 배너의 탭·성인·활성·차단·대상 유효성·정렬·limit 정책을 변경하지 않는다.
|
||||
- DB 스키마, 배너 데이터, 관리자 배너 API와 기존 v1 배너 조회를 변경하지 않는다.
|
||||
- 배너 이외의 추천 섹션에 국가 또는 언어 필터를 추가하지 않는다.
|
||||
|
||||
## 5. 대상 사용자와 국가 판정
|
||||
|
||||
| 사용자 | 국가 판정 | 언어 선택 |
|
||||
|---|---|---|
|
||||
| 강제 JP 매핑 로그인 회원 | 기존 강제 매핑 결과 `JP` | `Lang.JA` |
|
||||
| 강제 KR 매핑 로그인 회원 | 기존 강제 매핑 결과 `KR` | `Lang.KO` |
|
||||
| 일반 로그인 회원 | 정규화한 `CloudFront-Viewer-Country`, 누락 시 `KR` | `JP`이면 `JA`, 그 외 `KO` |
|
||||
| 비로그인 사용자 | 정규화한 `CloudFront-Viewer-Country`, 누락 시 `KR` | `JP`이면 `JA`, 그 외 `KO` |
|
||||
|
||||
국가 코드는 기존 정책처럼 앞뒤 공백을 제거하고 대문자로 정규화한다.
|
||||
|
||||
## 6. 핵심 조회 흐름
|
||||
|
||||
1. `CountryInterceptor`가 `CloudFront-Viewer-Country`를 `CountryContext`에 저장한다.
|
||||
2. `HomeRecommendationFacade.getHomeRecommendations`와 `AudioRecommendationQueryService.getRecommendations`가 회원과 요청 국가로 국가 코드를 판정한다.
|
||||
3. 국가 코드가 `JP`이면 `Lang.JA`, 그 외에는 `Lang.KO`를 선택한다.
|
||||
4. 선택한 `Lang`을 기존 application → port → persistence 경로로 전달한다.
|
||||
5. Repository가 `content_banner.lang = :lang`을 기존 조건과 함께 적용한 뒤 정렬하고 limit을 적용한다.
|
||||
|
||||
## 7. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `BANNER-LANG-001` | 확정 | `GET /api/v2/home/recommendations`는 판정 국가가 `JP`이면 `JA`, 그 외에는 `KO` 홈 배너만 반환한다. | `tab_id IS NULL`인 활성 배너 중 선택 언어와 일치하는 배너만 응답한다. | `P1-T2` |
|
||||
| `BANNER-LANG-002` | 확정 | `GET /api/v2/audio/recommendations`는 판정 국가가 `JP`이면 `JA`, 그 외에는 `KO` 콘텐츠 추천 배너만 반환한다. | `tab_id = 2`인 활성 배너 중 선택 언어와 일치하는 배너만 응답한다. | `P1-T3` |
|
||||
| `BANNER-LANG-003` | 확정 | 로그인 회원의 국가 판정은 기존 강제 KR/JP 매핑을 요청 헤더보다 우선한다. | 강제 JP 회원은 비JP 헤더에서도 `JP`, 강제 KR 회원은 JP 헤더에서도 `KR`로 판정된다. | `P1-T1` |
|
||||
| `BANNER-LANG-004` | 확정 | 비로그인 사용자는 요청 국가를 정규화해 사용하고, 헤더가 없거나 비어 있으면 `KR`로 판정한다. | `JP`·`jp`·공백 포함 JP는 `JP`, null·blank는 `KR`로 판정된다. | `P1-T1` |
|
||||
| `BANNER-LANG-005` | 확정 | 언어 필터는 정렬·limit 전에 DB 조회 조건으로 적용한다. | 다른 언어 배너가 limit을 차지하지 않고, Repository 테스트가 언어별 결과를 검증한다. | `P1-T2`, `P1-T3` |
|
||||
| `BANNER-LANG-006` | 확정 | 기존 배너 조회 정책과 공개 API 계약을 유지한다. | 기존 탭·성인·활성·차단·대상 유효성·정렬·limit 테스트와 controller/E2E 회귀가 통과한다. | `P1-GATE` |
|
||||
|
||||
## 8. API 계약
|
||||
|
||||
| Method | Path | 변경 내용 |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v2/home/recommendations` | 응답 스키마 변경 없이 접속 국가별 배너 언어 조회 조건만 추가한다. |
|
||||
| `GET` | `/api/v2/audio/recommendations` | 응답 스키마 변경 없이 접속 국가별 배너 언어 조회 조건만 추가한다. |
|
||||
|
||||
- 신규 request header는 추가하지 않는다.
|
||||
- 기존 `CloudFront-Viewer-Country` 처리 경로를 재사용한다.
|
||||
- 배너가 없으면 기존처럼 빈 목록을 반환한다.
|
||||
|
||||
## 9. 성능과 품질 요구사항
|
||||
|
||||
- `content_banner.lang` 조건은 QueryDSL `where`에 포함하고 메모리 후처리를 사용하지 않는다.
|
||||
- 신규 dependency, 캐시, 공통 resolver abstraction을 추가하지 않는다.
|
||||
- 기존 `Lang`, `CountryContext`, `MemberContentPreferenceService`를 재사용한다.
|
||||
- application 전달 테스트와 두 Repository 조회 테스트를 TDD로 보강한다.
|
||||
- 직접 영향 controller/E2E 회귀와 `ktlintCheck`를 Phase Gate에서 검증한다.
|
||||
|
||||
## 10. 성공 기준
|
||||
|
||||
- [x] 강제 매핑을 포함한 판정 국가가 `JP`이면 두 API의 배너가 모두 `JA`로 제한된다. (`BANNER-LANG-001~003`)
|
||||
- [x] 일반 국가와 국가 정보가 없는 경우 두 API의 배너가 모두 `KO`로 제한된다. (`BANNER-LANG-001`, `BANNER-LANG-002`, `BANNER-LANG-004`)
|
||||
- [x] 다른 언어 배너가 정렬·limit 대상에 포함되지 않는다. (`BANNER-LANG-005`)
|
||||
- [x] 기존 배너 필터와 공개 응답 계약이 유지된다. (`BANNER-LANG-006`)
|
||||
|
||||
## 11. Open Questions
|
||||
|
||||
- 없음.
|
||||
|
||||
## 12. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-19 | `DEC-001` | 확정 | `JP`는 `JA`, 그 외 국가는 `KO` 배너만 조회한다. | 사용자 인터뷰 답변 B | `BANNER-LANG-001`, `BANNER-LANG-002`, `BANNER-LANG-005` |
|
||||
| 2026-08-19 | `DEC-002` | 확정 | 로그인 회원은 기존 강제 국가 매핑을 포함한 국가 판정을 사용한다. | 사용자 인터뷰 답변 B | `BANNER-LANG-003`, `P1-T1` |
|
||||
| 2026-08-19 | `DEC-003` | 확정 | 비로그인은 요청 국가를 사용하고 국가 정보가 없으면 기존 기본값 `KR`을 사용한다. | 승인된 설계 | `BANNER-LANG-004`, `P1-T1` |
|
||||
| 2026-08-19 | `DEC-004` | 확정 | 언어 필터는 Repository 조회 조건으로 적용한다. | limit 이전 필터링과 기존 QueryDSL 패턴 유지 | `BANNER-LANG-005`, `P1-T2`, `P1-T3` |
|
||||
@@ -1,280 +0,0 @@
|
||||
# 홈 팔로잉 최근 대화 팔로우 필터 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` or `superpowers:executing-plans` to implement this plan task-by-task. 체크박스는 구현 시 실제 진행 상태에 맞게 갱신한다.
|
||||
|
||||
**Goal:** 홈 팔로잉 탭에서 현재 팔로우 중인 크리에이터의 DM 및 AI 채팅만 최근 대화로 제공한다.
|
||||
|
||||
**Architecture:** 기존 `ChatRoomListService`의 병합·정렬·응답 변환을 재사용하고, 홈 팔로잉 호출만 `followedCreatorsOnly = true`를 전달한다. AI/DM 저장소 query는 활성 `CreatorFollowing` 존재 조건을 pagination 전에 적용하며, 기본값 `false`로 일반 채팅 목록 동작을 유지한다.
|
||||
|
||||
**Tech Stack:** Kotlin, Java 17, Spring Boot 2.7.14, Spring Data JPA/JPQL, Gradle, JUnit 5, Mockito, MockMvc
|
||||
|
||||
**Spec:** `docs/20260819_홈_팔로잉_최근대화_팔로우필터/prd.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 공개 API request/response schema를 변경하지 않는다.
|
||||
- DM과 AI 채팅을 모두 포함하고 현재 `CreatorFollowing.isActive = true`인 대상만 허용한다.
|
||||
- 팔로우 조건은 DB query에서 pagination보다 먼저 적용한다.
|
||||
- 일반 `GET /api/v2/chat/rooms`는 기존 전체 조회 동작을 유지한다.
|
||||
- DB schema, index, dependency를 추가하지 않는다.
|
||||
- 메시지 본문, 인증 정보와 식별자를 새 log에 기록하지 않는다.
|
||||
- 요청 범위 밖 리팩터링을 하지 않는다.
|
||||
|
||||
---
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-08-19 |
|
||||
| 요구사항 기준 | `docs/20260819_홈_팔로잉_최근대화_팔로우필터/prd.md` |
|
||||
| API 기준 | 기존 `GET /api/v2/home/following`, `GET /api/v2/chat/rooms` 계약 유지 |
|
||||
| 현재 Phase | Phase 1 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `1/1` | 없음 | 없음 |
|
||||
|
||||
- 동시에 하나의 미완료 goal만 운용한다.
|
||||
- 사용자가 명시적으로 요청하지 않았으므로 goal token budget은 설정하지 않는다.
|
||||
- 이 문서 작성 단계에서는 production/test 코드를 수정하거나 테스트를 실행하지 않는다.
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 홈 팔로잉 최근 대화에 활성 팔로우 DM/AI filter 적용
|
||||
- 기존 정렬, cursor 해석, 최대 10개와 응답 변환 재사용
|
||||
- 일반 채팅 목록의 전체 조회 기본값 유지
|
||||
- facade, service, repository query와 홈 팔로잉 E2E 회귀 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- 공개 API schema, 채팅방 생성과 메시지 전송 정책 변경
|
||||
- 팔로우/언팔로우 쓰기 로직과 알림 정책 변경
|
||||
- 차단·계정 활성 상태 등 추가 노출 정책
|
||||
- DB migration, index와 dependency 추가
|
||||
- 홈 팔로잉의 최근 대화 외 섹션 변경
|
||||
|
||||
## 파일 책임과 변경 범위
|
||||
|
||||
| 파일 | 계획된 책임 |
|
||||
|---|---|
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt` | 홈 팔로잉 최근 대화 호출에 `followedCreatorsOnly = true` 전달 |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt` | 팔로우 필터 옵션을 AI/DM repository에 전달하고 기존 병합·정렬 유지 |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt` | AI 캐릭터 `creatorMember.id` 기준 활성 팔로우 조건 적용 |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt` | DM 상대 `member.id` 기준 활성 팔로우 조건 적용 |
|
||||
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt` | facade가 홈 전용 팔로우 필터를 요청하는지 검증 |
|
||||
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt` | filter 옵션 전달과 기존 전체 조회 회귀 검증 |
|
||||
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` | 활성 팔로우 DM/AI 포함과 미팔로우·비활성 팔로우 제외 검증 |
|
||||
| `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListControllerTest.kt` | 수정 없이 일반 채팅 목록 위임 회귀 확인 |
|
||||
|
||||
## Phase 1: 최근 대화 팔로우 필터 적용
|
||||
|
||||
**Phase 결과:** 홈 팔로잉 탭은 활성 팔로우 크리에이터의 DM/AI 최근 대화만 반환하고 일반 채팅 목록은 기존 동작을 유지한다.
|
||||
|
||||
**선행조건:** 승인된 PRD `HFC-001~008`.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`과 `P1-GATE` 완료, 실제 검증 결과를 Progress에 기록한다.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 홈 팔로잉 최근 대화 필터
|
||||
|
||||
**Goal 실행 `P1-T1`:** 활성 팔로우 관계를 AI/DM query에 적용하고 홈 팔로잉 facade에서만 해당 필터를 활성화한다.
|
||||
|
||||
- **시작 조건:** `HFC-001~008` 확정, 현재 코드와 공개 API 계약 재확인.
|
||||
- **완료 증거:** 아래 체크박스 전체 완료, focused test 성공, 변경 파일과 실제 결과를 Progress에 기록.
|
||||
- **범위 밖:** 채팅 목록 공개 filter enum 추가, 별도 query 복제, 메모리 후처리, 신규 abstraction·dependency·migration.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacade.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/chat/service/ChatRoomListService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/chat/room/repository/ChatRoomRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/repository/UserCreatorChatRoomRepository.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/application/HomeFollowingFacadeTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/chat/ChatRoomListServiceTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: 요청 회원 `Member.id`, DM 상대 `opponent.member.id`, AI 대상 `pc.character.creatorMember.id`, `CreatorFollowing.isActive`.
|
||||
- Produces: 기존 반환형 `ChatRoomListPageResponse`; 공개 DTO와 endpoint 계약 변경 없음.
|
||||
- `ChatRoomListService.getRooms`는 아래 parameter만 마지막에 추가하고 기본값으로 기존 호출을 보존한다.
|
||||
|
||||
```kotlin
|
||||
fun getRooms(
|
||||
member: Member,
|
||||
filter: String = ChatRoomListFilter.ALL.name,
|
||||
cursor: String? = null,
|
||||
limit: Int = DEFAULT_LIMIT,
|
||||
followedCreatorsOnly: Boolean = false
|
||||
): ChatRoomListPageResponse
|
||||
```
|
||||
|
||||
- `ChatRoomRepository.findAiChatListRooms`와 `UserCreatorChatRoomRepository.findDmChatListRooms`에는 `@Param("followedCreatorsOnly") followedCreatorsOnly: Boolean`을 추가한다.
|
||||
|
||||
- [x] **RED:** `HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember` fixture에 활성 팔로우 DM, 활성 팔로우 AI, 더 최신인 미팔로우 DM 11개, `isActive = false` 팔로우 DM을 만든다. 응답에는 활성 팔로우 AI/DM room 두 개만 기존 최신순으로 남는 assertion을 추가해 저장소 filter가 pagination보다 먼저 적용되는지도 함께 검증한다.
|
||||
|
||||
```kotlin
|
||||
.andExpect(jsonPath("$.data.recentChats.length()").value(2))
|
||||
.andExpect(jsonPath("$.data.recentChats[0].roomId").value(fixture.aiChatRoomId))
|
||||
.andExpect(jsonPath("$.data.recentChats[0].chatType").value("AI"))
|
||||
.andExpect(jsonPath("$.data.recentChats[1].roomId").value(fixture.dmChatRoomId))
|
||||
.andExpect(jsonPath("$.data.recentChats[1].chatType").value("DM"))
|
||||
.andExpect(jsonPath("$.data.recentChats[?(@.roomId == ${fixture.unfollowedDmRoomIds.first()})]").isEmpty)
|
||||
.andExpect(jsonPath("$.data.recentChats[?(@.roomId == ${fixture.inactiveFollowingDmRoomId})]").isEmpty)
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** 아래 명령을 실행해 현재 구현이 더 최신인 미팔로우·비활성 팔로우 DM을 최대 10개 반환하므로 `recentChats.length()` 또는 room 순서 assertion이 실패하는지 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember"
|
||||
```
|
||||
|
||||
- [x] **GREEN:** AI/DM repository query parameter를 service에서 전달하고, query의 기존 방·참가자·메시지 활성 조건 뒤에 아래 활성 팔로우 존재 조건을 추가한다. 조건은 `Pageable` 적용 전에 평가한다.
|
||||
|
||||
AI query:
|
||||
|
||||
```sql
|
||||
AND (
|
||||
:followedCreatorsOnly = false
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM CreatorFollowing cf
|
||||
WHERE cf.member.id = :memberId
|
||||
AND cf.creator.id = pc.character.creatorMember.id
|
||||
AND cf.isActive = true
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
DM query:
|
||||
|
||||
```sql
|
||||
AND (
|
||||
:followedCreatorsOnly = false
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM CreatorFollowing cf
|
||||
WHERE cf.member.id = :memberId
|
||||
AND cf.creator.id = opponent.member.id
|
||||
AND cf.isActive = true
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `HomeFollowingFacade`만 아래처럼 filter를 활성화한다. 일반 `ChatRoomListController` 호출은 기본값 `false`를 사용한다.
|
||||
|
||||
```kotlin
|
||||
val recentChats = chatRoomListService.getRooms(
|
||||
member,
|
||||
filter = "ALL",
|
||||
cursor = null,
|
||||
limit = 10,
|
||||
followedCreatorsOnly = true
|
||||
).rooms
|
||||
```
|
||||
|
||||
- [x] **GREEN:** `ChatRoomListServiceTest`의 repository stub/verify에 `followedCreatorsOnly` 인자를 반영하고, `true`가 AI와 DM repository 양쪽에 전달되는 test를 추가한다. 기존 test는 `false` 전달과 병합·정렬·cursor·preview가 유지되는지 검증한다.
|
||||
- [x] **GREEN:** `HomeFollowingFacadeTest`가 `followedCreatorsOnly = true` 호출을 stub/verify하고 비로그인 시 무호출을 계속 검증하도록 수정한다.
|
||||
- [x] **GREEN 확인:** 아래 focused test를 실행해 facade, service, query와 endpoint 동작을 함께 확인한다.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
|
||||
```
|
||||
|
||||
**Expected:** 모든 test가 통과하고 홈 팔로잉 E2E에서 활성 팔로우 AI/DM 두 방만 기존 최신순으로 반환된다.
|
||||
|
||||
- [x] **REFACTOR:** 이번 Task에서 생긴 중복만 정리한다. 별도 filter enum, query method 복제와 공통 abstraction은 만들지 않는다. focused test와 아래 직접 영향 회귀·lint를 다시 실행하고 실제 결과를 Progress에 기록한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||
- [x] `HFC-001~008`이 구현 또는 명시적 제외로 추적된다.
|
||||
- [x] 공개 API와 DB schema 변경이 없다.
|
||||
- [x] 실제 검증 결과가 Progress에 기록됐다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 홈 팔로잉 필터와 일반 채팅 목록 비회귀를 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** 아래 test와 lint 성공, 전체 회귀 실행 여부와 근거를 Progress에 기록.
|
||||
- **범위 밖:** Gate 통과를 위한 test 삭제·skip·완화와 관련 없는 코드 수정.
|
||||
|
||||
```bash
|
||||
./gradlew test \
|
||||
--tests "kr.co.vividnext.sodalive.v2.chat.ChatRoomListServiceTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.chat.ChatRoomListControllerTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.home.following.application.HomeFollowingFacadeTest" \
|
||||
--tests "kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest"
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** 모든 focused/영향 범위 test와 `ktlintCheck`가 exit code `0`으로 끝난다. 홈 팔로잉은 활성 팔로우 AI/DM만 반환하고 일반 채팅 목록은 팔로우 filter 없이 기존 전체 조회를 위임한다.
|
||||
|
||||
수동 검증:
|
||||
|
||||
- [x] `git diff --name-only`에 계획된 production/test 파일과 이 작업 문서 이외의 변경이 없는지 확인한다.
|
||||
- [x] `git diff`에서 endpoint, response DTO, DB schema와 dependency 변경이 없는지 확인한다.
|
||||
- [x] 새 log에 회원·채팅·팔로우 식별자 또는 메시지 본문이 추가되지 않았는지 확인한다.
|
||||
|
||||
전체 회귀 `./gradlew test`는 작은 조회 조건 변경이며 위 test가 service, repository query, facade, endpoint와 인접 일반 채팅 목록을 포함하므로 기본 생략한다. 위 명령으로 영향 범위를 판단할 수 없는 실패가 발생하거나 공통 코드로 범위가 확장되면 전체 회귀를 실행하고 결과를 Progress에 기록한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | 승인된 PRD | 아니요 | query mapping과 fixture 근거를 PRD·현재 코드와 다시 대조 |
|
||||
| 2 | `P1-GATE` | `P1-T1` 완료 | 아니요 | 실패를 소유한 Task에 회귀 수정 goal 추가 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-GATE
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 기존 완료 문서와 검증 기록을 삭제하거나 덮어쓰지 않는다.
|
||||
- 공개 endpoint, request/response DTO와 chat type filter enum을 변경하지 않는다.
|
||||
- `followingCreators` 최대 20개 결과로 최근 대화를 메모리 후처리하지 않는다.
|
||||
- AI/DM query 전체를 새 method로 복제하지 않는다.
|
||||
- 신규 dependency, DB migration과 index를 추가하지 않는다.
|
||||
- 관련 없는 리팩터링 또는 포맷 변경을 하지 않는다.
|
||||
- test를 삭제·skip·완화하지 않는다.
|
||||
|
||||
## 의사결정 및 중단 규칙
|
||||
|
||||
- PRD와 구현이 충돌하면 `prd.md`의 Decision Log를 먼저 갱신하고 이 계획을 동기화한 뒤 구현한다.
|
||||
- `ChatCharacter.creatorMember.id` 또는 DM `opponent.member.id`로 활성 팔로우를 판정할 수 없으면 추정 구현을 중단하고 새 근거를 확인한다.
|
||||
- 구현 범위가 공개 API, DB schema 또는 팔로우 쓰기 로직으로 확장되면 사용자 승인 전 진행하지 않는다.
|
||||
- 체크박스, test와 Progress 기록이 모두 충족된 뒤에만 goal을 완료 처리한다.
|
||||
|
||||
## Progress
|
||||
|
||||
### 2026-08-19 `P1-T1`, `P1-GATE` 완료
|
||||
|
||||
- RED: `HomeFollowingEndToEndTest.shouldAssembleFollowingTabForMember`를 실행해 `$.data.recentChats.length()`가 기대값 `2` 대신 `10`을 반환하는 실패를 확인했다.
|
||||
- GREEN: `ChatRoomListServiceTest`, `HomeFollowingFacadeTest`, `HomeFollowingEndToEndTest` focused test가 exit code `0`으로 통과했다.
|
||||
- Phase Gate: 위 focused test에 `ChatRoomListControllerTest`를 추가한 영향 범위 회귀와 `./gradlew ktlintCheck`가 exit code `0`으로 통과했다.
|
||||
- 전체 회귀: `./gradlew test`가 exit code `0`으로 통과했다.
|
||||
- 수동 검증: 변경 파일은 계획된 production/test 7개와 이 작업의 `prd.md`, `plan-task.md`뿐이며 endpoint, response DTO, DB schema, dependency와 log 변경이 없음을 `git diff`로 확인했다.
|
||||
- LSP: 로컬에 `kotlin-ls`가 설치되어 있지 않아 실행하지 못했으며, Kotlin compile, 전체 test와 ktlint 성공으로 대체 검증했다.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-19 | `DEC-001` | 확정 | DM과 AI 채팅을 모두 활성 팔로우 대상으로 filter한다. | 사용자 인터뷰 승인 | `P1-T1`, PRD `HFC-001~003` |
|
||||
| 2026-08-19 | `DEC-002` | 확정 | 기존 service/query에 기본값 `false`인 최소 옵션을 추가하고 홈 facade만 활성화한다. | 일반 채팅 목록 회귀 없이 기존 병합·정렬을 재사용하는 최소 변경 | `P1-T1` |
|
||||
| 2026-08-19 | `DEC-003` | 확정 | filter는 DB query에서 pagination 전에 적용한다. | 후처리 시 최신 미팔로우 대화가 limit을 차지해 팔로우 대화가 누락될 수 있음 | `P1-T1`, PRD `HFC-004` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
현재 확정된 범위 내 발견된 문제는 없다.
|
||||
@@ -1,141 +0,0 @@
|
||||
# 홈 팔로잉 최근 대화 팔로우 필터 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-08-19 |
|
||||
| 최종 수정일 | 2026-08-19 |
|
||||
| 대상 제품 | 메인 홈 팔로잉 탭 API의 최근 대화 섹션 |
|
||||
| 작성자·결정권자 | Codex 작성, 사용자 승인 |
|
||||
| 선행 PRD | `docs/20260625_메인_홈_팔로잉_탭_API/prd.md` |
|
||||
| 관련 API Contract | 별도 문서 없음. 기존 `GET /api/v2/home/following` 계약을 유지한다. |
|
||||
| 관련 구현 계획 | `docs/20260819_홈_팔로잉_최근대화_팔로우필터/plan-task.md` |
|
||||
| 관련 review | 없음 |
|
||||
|
||||
### 선행 문서와의 관계
|
||||
|
||||
- 이 문서는 선행 PRD의 Feature D와 "최근 대화는 팔로잉 여부와 무관하다"는 Edge Case만 대체한다.
|
||||
- 선행 PRD에서 정의한 나머지 팔로잉 탭 요구사항과 완료 기록은 변경하지 않는다.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
로그인 사용자가 메인 홈 팔로잉 탭을 조회할 때 `recentChats`에는 현재 팔로우 중인 크리에이터와의 DM 및 AI 채팅만 노출한다. 기존 응답 스키마, 최신순 정렬, 최대 10개, 메시지 미리보기 정책은 유지한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- `HomeFollowingFacade`는 현재 `ChatRoomListService.getRooms(member, filter = "ALL", cursor = null, limit = 10)`을 호출한다.
|
||||
- 이 호출은 사용자의 모든 DM 및 AI 채팅방을 조회하므로, 팔로우하지 않은 크리에이터와의 대화도 팔로잉 탭에 노출된다.
|
||||
- 팔로잉 탭의 최근 대화 섹션이 탭의 목적과 다른 대상을 보여준다.
|
||||
|
||||
문제를 해결했다는 판단은 팔로잉 탭 응답에서 활성 팔로우 대상의 DM 및 AI 채팅만 최신순 최대 10개로 반환되고, 일반 채팅 목록 API의 결과는 바뀌지 않는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 팔로잉 탭의 `recentChats`를 현재 활성 팔로우 관계가 있는 크리에이터의 대화로 제한한다.
|
||||
- DM과 AI 채팅을 모두 포함한다.
|
||||
- 팔로우 필터를 저장소 조회에 적용한 뒤 최대 10개를 선택해, 더 최신인 미팔로우 대화 때문에 결과 수가 줄지 않게 한다.
|
||||
- 기존 공개 API 스키마, 정렬, 미리보기, 최대 개수 정책을 유지한다.
|
||||
- 일반 채팅 목록 API의 기존 전체 조회 동작을 유지한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- `GET /api/v2/home/following` 또는 `GET /api/v2/chat/rooms`의 request/response 스키마를 변경하지 않는다.
|
||||
- 채팅방 생성, 메시지 전송, 읽음 처리, cursor 형식과 메시지 미리보기 정책을 변경하지 않는다.
|
||||
- 팔로우/언팔로우 처리 자체와 알림 설정 정책을 변경하지 않는다.
|
||||
- 차단 관계, 크리에이터 계정 활성 상태 등 이번 요청에 포함되지 않은 추가 노출 조건을 도입하지 않는다.
|
||||
- DB schema, index, dependency를 추가하지 않는다.
|
||||
- 선행 홈 팔로잉 PRD의 최근 대화 이외 섹션을 변경하지 않는다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 기대 결과 |
|
||||
|---|---|
|
||||
| 로그인 회원 | 팔로우 중인 크리에이터와의 최근 DM 및 AI 채팅을 확인한다. |
|
||||
| 비로그인 사용자 | 기존과 동일하게 로그인 필요 상태와 빈 `recentChats`를 받는다. |
|
||||
| 앱 클라이언트 | 변경 없는 응답 스키마로 최근 대화 섹션을 표시한다. |
|
||||
|
||||
- 인증 회원의 `Member.id`를 팔로우 관계와 채팅 참가자 조회 기준으로 사용한다.
|
||||
- 비로그인 요청에서는 기존과 동일하게 채팅 조회를 실행하지 않는다.
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 사용자가 `GET /api/v2/home/following`을 호출한다.
|
||||
2. 서버는 로그인 회원의 홈 팔로잉 데이터와 최근 대화를 조회한다.
|
||||
3. 최근 대화 조회는 현재 `CreatorFollowing.isActive = true`인 크리에이터의 채팅만 선택한다.
|
||||
4. DM과 AI 결과를 기존 정렬 규칙으로 병합하고 최대 10개를 반환한다.
|
||||
5. 앱은 기존 `recentChats` 응답 필드로 채팅방 진입 화면을 구성한다.
|
||||
|
||||
## 7. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `HFC-001` | 확정 | 팔로잉 탭의 최근 대화는 요청 회원이 현재 활성 팔로우 중인 크리에이터의 채팅만 포함한다. | 활성 팔로우 대상은 포함되고 팔로우 row가 없거나 `isActive = false`인 대상은 제외된다. | `P1-T1`, `P1-GATE` |
|
||||
| `HFC-002` | 확정 | DM 채팅의 크리에이터 식별자는 `UserCreatorChatParticipant`의 상대 회원 `opponent.member.id`를 사용한다. | 상대 회원과 요청 회원의 활성 `CreatorFollowing`이 있을 때만 DM 방이 반환된다. | `P1-T1` |
|
||||
| `HFC-003` | 확정 | AI 채팅의 크리에이터 식별자는 `ChatCharacter.creatorMember.id`를 사용한다. | AI 캐릭터의 `creatorMember`와 요청 회원의 활성 `CreatorFollowing`이 있을 때만 AI 방이 반환된다. | `P1-T1` |
|
||||
| `HFC-004` | 확정 | 팔로우 필터는 저장소 조회에서 pagination보다 먼저 적용한다. | 최신 미팔로우 대화가 10개 이상이어도 그보다 오래된 팔로우 대화를 최대 10개까지 조회할 수 있다. | `P1-T1` |
|
||||
| `HFC-005` | 확정 | DM과 AI 결과는 기존 `lastMessageAt`, `chatType`, `roomId` 내림차순으로 병합하고 최대 10개를 반환한다. | 기존 정렬·limit·`ChatRoomListItemResponse` 변환 테스트가 통과한다. | `P1-T1` |
|
||||
| `HFC-006` | 확정 | 일반 채팅 목록은 기존처럼 팔로잉 여부와 무관하게 조회한다. | `GET /api/v2/chat/rooms`가 팔로우 필터를 요청하지 않고 기존 서비스 테스트가 통과한다. | `P1-T1`, `P1-GATE` |
|
||||
| `HFC-007` | 확정 | 비로그인 홈 팔로잉 요청 동작은 유지한다. | `isLoginRequired = true`, 빈 `recentChats`, 채팅 서비스 미호출이 유지된다. | `P1-GATE` |
|
||||
| `HFC-008` | 확정 | 홈 팔로잉의 크리에이터 목록 최대 20개와 관계없이 모든 활성 팔로우 관계를 최근 대화 필터에 사용한다. | `followingCreators` 응답 목록을 후처리 필터로 재사용하지 않고 DB의 활성 팔로우 관계를 직접 판정한다. | `P1-T1` |
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- 활성 팔로우 관계가 없으면 `recentChats`는 빈 배열이다.
|
||||
- 과거에 팔로우했더라도 현재 `CreatorFollowing.isActive = false`이면 해당 대화를 제외한다.
|
||||
- 활성 팔로우 관계가 있는 AI와 DM 대화가 함께 있으면 두 유형 모두 기존 최신순 정렬에 포함한다.
|
||||
- 방, 참가자 또는 메시지가 비활성인 경우 기존 채팅 목록 조회 조건대로 제외한다.
|
||||
- 메시지가 없는 방은 기존 채팅 목록 정책대로 최근 대화에 포함하지 않는다.
|
||||
|
||||
## 8. API 계약
|
||||
|
||||
### Endpoint
|
||||
|
||||
- Method/Path: `GET /api/v2/home/following`
|
||||
- request parameter: 변경 없음
|
||||
- 인증 처리: 변경 없음
|
||||
- response wrapper: 변경 없음
|
||||
- `recentChats`: 기존 `List<ChatRoomListItemResponse>` 유지
|
||||
- `roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`: 필드와 의미 변경 없음
|
||||
|
||||
`GET /api/v2/chat/rooms`의 공개 계약과 전체 조회 의미도 변경하지 않는다.
|
||||
|
||||
## 9. 데이터·보안·성능 요구사항
|
||||
|
||||
- 팔로우 판정은 `creator_following.member_id`, `creator_following.creator_id`, `creator_following.is_active = true`를 사용한다.
|
||||
- `creator_following`의 기존 `(member_id, creator_id)` unique constraint를 활용하고 신규 index를 추가하지 않는다.
|
||||
- 팔로우 여부는 응답 생성 시점의 DB 상태를 기준으로 한다.
|
||||
- 회원·채팅·팔로우 식별자와 메시지 본문을 새 log로 남기지 않는다.
|
||||
- 팔로우 필터는 DB query에 포함해 미팔로우 결과를 메모리에서 제거하거나 전체 대화를 로드하지 않는다.
|
||||
- 신규 dependency와 DB migration을 추가하지 않는다.
|
||||
|
||||
## 10. 성공 기준
|
||||
|
||||
- [x] 활성 팔로우 중인 일반 크리에이터와의 DM이 `recentChats`에 포함된다. (`HFC-001`, `HFC-002`)
|
||||
- [x] 활성 팔로우 중인 AI 캐릭터와의 AI 채팅이 `recentChats`에 포함된다. (`HFC-001`, `HFC-003`)
|
||||
- [x] 팔로우하지 않았거나 현재 비활성 팔로우인 크리에이터의 대화는 제외된다. (`HFC-001`)
|
||||
- [x] 필터 적용 후 최신순 최대 10개와 기존 응답 필드가 유지된다. (`HFC-004`, `HFC-005`)
|
||||
- [x] 일반 채팅 목록과 비로그인 홈 팔로잉 응답이 회귀하지 않는다. (`HFC-006`, `HFC-007`)
|
||||
- [x] 공개 API schema, DB schema와 dependency 변경이 없다.
|
||||
|
||||
## 11. Open Questions
|
||||
|
||||
없음.
|
||||
|
||||
인터뷰 종료 시 최종 모호성은 `0.07`이며, 명확성 점수는 Goal `1.00`, Scope `1.00`, Constraints `1.00`, Success `0.75`, Context `0.90`이다.
|
||||
|
||||
## 12. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 |
|
||||
|---|---:|---|---|
|
||||
| `HFC-001~005`, `HFC-008` | 1 | `P1-T1` | `ChatRoomListServiceTest`, `HomeFollowingFacadeTest`, `HomeFollowingEndToEndTest` |
|
||||
| `HFC-006~007` | 1 | `P1-GATE` | `ChatRoomListControllerTest`, `HomeFollowingFacadeTest`, `HomeFollowingEndToEndTest` |
|
||||
|
||||
## 13. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-08-19 | `DEC-001` | 확정 | 최근 대화는 DM과 AI 채팅을 모두 포함한다. | 사용자는 AI 채팅 가능 캐릭터도 크리에이터라고 확정했다. | `HFC-001~003`, `P1-T1` |
|
||||
| 2026-08-19 | `DEC-002` | 확정 | 일반 채팅 목록은 유지하고 홈 팔로잉 조회에서만 활성 팔로우 필터를 사용한다. | 변경 범위를 홈 팔로잉 탭으로 제한하고 기존 공개 API 회귀를 방지한다. | `HFC-006`, `P1-T1` |
|
||||
| 2026-08-19 | `DEC-003` | 확정 | 기존 완료 문서는 유지하고 이 PRD가 최근 대화 규칙만 대체한다. | 완료 이력을 보존하면서 새 변경의 범위와 검증을 독립적으로 추적한다. | 문서 전체 |
|
||||
@@ -1,332 +0,0 @@
|
||||
# v2 콘텐츠 목록 요청 언어별 번역 구현 계획
|
||||
|
||||
- 작성일: 2026-09-09
|
||||
- 상태: Phase 1·2 구현 및 자동 검증 완료, test 서버 HTTP Gate 대기
|
||||
- 요구사항·API 기준: [prd.md](prd.md)
|
||||
- 현재 활성 Goal: P1-GATE test 서버 HTTP 확인 대기
|
||||
- 다음 Goal: P2-GATE test 서버 HTTP 확인
|
||||
- 실행 여부와 결과는 각 Task/Gate 실행 기록을 기준으로 한다.
|
||||
|
||||
## 목표와 범위
|
||||
|
||||
PRD의 7개 GET API에서 오디오 제목·시리즈 제목/시리즈명·테마명을 요청 언어로 표시하고,
|
||||
번역이 없거나 공백이면 기존 원문을 반환한다. 제외 항목은 PRD §2와 동일하다.
|
||||
|
||||
| Phase | 결과 | 상태 | 완료 Task |
|
||||
|---|---|---|---|
|
||||
| 1 | 채널·메인 전체 목록 번역 통일 | 자동 검증 완료, test 서버 HTTP Gate 대기 | 2/2 |
|
||||
| 2 | 추천·더보기·랭킹 번역 통일 | 자동 검증 완료, test 서버 HTTP Gate 대기 | 2/2 |
|
||||
|
||||
## 구현 원칙
|
||||
|
||||
- 조사는 v2 패키지에서 시작하고 연결된 기존 번역 저장·조회 코드를 확인한다.
|
||||
- 기존 LangInterceptor/LangContext와 ko/en/ja 파싱을 재사용하며 공통 언어 파서를 변경하지 않는다.
|
||||
- Kotlin/Spring 기존 계층과 스타일을 따른다. 공개 응답 스키마와 인증·필터·정렬·페이지 조건은 유지한다.
|
||||
- 번역 테이블 locale 조인 또는 기존 일괄 조회를 재사용한다. 항목 수만큼 추가 조회하는 N+1은 금지한다.
|
||||
- 랭킹·추천 스냅샷의 집계/저장과 요청별 번역 표시를 분리한다.
|
||||
- 저장 데이터·DDL·번역 생성 파이프라인·배너 국가별 선택 정책은 변경하지 않는다.
|
||||
- 파일 목록은 수정 후보 및 확인 책임 범위다. 기존 동작이 맞는 파일은 테스트로만 확인한다.
|
||||
- 기존 함수의 모든 호출자를 확인하고 공용 호출에 변경이 전파되면 관련 테스트와 이 문서에 기록한다.
|
||||
- 새 추상화·의존성 없이 기존 Port/Repository를 최소 확장한다.
|
||||
|
||||
## 공통 참조 파일
|
||||
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangInterceptor.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/LangContext.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/i18n/Lang.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/content/translation/ContentTranslationRepository.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/content/series/translation/SeriesTranslationRepository.kt
|
||||
|
||||
## 공통 테스트 데이터
|
||||
|
||||
같은 원문에 서로 다른 ko/en/ja 번역을 저장한다. 번역 행 없음·빈 문자열·공백·다른 locale만 존재·
|
||||
오디오와 시리즈 중 한쪽만 번역된 경우를 포함한다.
|
||||
조회 요청에는 ko/en/ja, en-US, ja-JP, 누락, 빈 값, 미지원 fr를 사용한다.
|
||||
en→ja→en 연속 요청에서 언어가 섞이지 않아야 한다.
|
||||
언어별 응답은 대상 문자열만 달라지고 ID·개수·순서·필터·페이지·가격·제외 필드는 같아야 한다.
|
||||
|
||||
### Phase 1: 채널·메인 전체 목록
|
||||
|
||||
시작 조건: 사용자 구현 요청. 결과: LIST-01~04의 번역 표시와 기존 동작 유지.
|
||||
|
||||
- [x] **Task 1.1: 채널 오디오·시리즈·홈의 번역 표시 통일**
|
||||
|
||||
**Goal P1-T1**
|
||||
- Objective: 오디오 title·seriesName·테마명, 홈 오디오/시리즈/AUDIO 일정 제목의 번역과 필드별 fallback을 검증한다. 이미 구현된 시리즈·테마 번역은 회귀를 먼저 확인하며 불필요하게 수정하지 않는다.
|
||||
- 요구사항: LIST-01, LIST-02, LIST-04, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
||||
- 시작 조건: 사용자의 구현 착수 요청과 PRD 확인.
|
||||
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
||||
- 범위 밖: 메인 목록·추천·랭킹 변경.
|
||||
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
||||
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
||||
|
||||
**확인/수정 후보 파일**
|
||||
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/application/CreatorChannelAudioQueryService.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/port/out/CreatorChannelAudioQueryPort.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/port/out/CreatorChannelHomeQueryPort.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/application/CreatorChannelSeriesQueryService.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt
|
||||
|
||||
**테스트 파일**
|
||||
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepositoryTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepositoryTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepositoryTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/audio/adapter/in/web/CreatorChannelAudioEndToEndTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/series/adapter/in/web/CreatorChannelSeriesEndToEndTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt
|
||||
|
||||
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
||||
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
||||
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
||||
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest' --tests '*DefaultCreatorChannelSeriesQueryRepositoryTest' --tests '*CreatorChannelAudioEndToEndTest' --tests '*CreatorChannelSeriesEndToEndTest' --tests '*CreatorChannelHomeEndToEndTest'
|
||||
```
|
||||
|
||||
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
||||
|
||||
**Task 실행 기록:**
|
||||
|
||||
- 2026-09-09: 사용자가 현재 브랜치 구현을 지시해 worktree 없이 `test` 브랜치에서 진행했다.
|
||||
- RED: `./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest.shouldFindAudioContentsWithTranslatedTitleFallback' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest.shouldFindHomeAudioContentsWithTranslatedTitleFallback' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest.shouldFindHomeSchedulesAndSeriesWithTranslatedTitleFallback'` 실행. 종료 코드 1, 3개 테스트가 제목 assertion 실패로 실패했다.
|
||||
- GREEN: 같은 RED 명령 재실행. 종료 코드 0, 오디오 title·홈 오디오 title/seriesName·AUDIO 스케줄 title·홈 시리즈 title의 번역 및 공백 fallback을 확인했다.
|
||||
- Focused: `./gradlew test --tests '*DefaultCreatorChannelAudioQueryRepositoryTest' --tests '*DefaultCreatorChannelHomeQueryRepositoryTest' --tests '*DefaultCreatorChannelSeriesQueryRepositoryTest' --tests '*CreatorChannelAudioEndToEndTest' --tests '*CreatorChannelSeriesEndToEndTest' --tests '*CreatorChannelHomeEndToEndTest'` 실행. 종료 코드 0.
|
||||
- 리뷰 보완: 같은 콘텐츠에 en/ja 번역을 함께 저장해 locale별 제목 선택과 ID 순서 불변을 추가 검증했다. 홈 service는 `LangContext.lang.code`가 최신 오디오·오디오 목록·스케줄·시리즈 port 호출에 전달되는지 확인했다.
|
||||
- 보완 후 Focused 재실행: 위 P1-T1 focused command 실행. 종료 코드 0.
|
||||
|
||||
- [x] **Task 1.2: 메인 전체 목록 오디오 번역과 시리즈 회귀 확인**
|
||||
|
||||
**Goal P1-T2**
|
||||
- Objective: 각 기존 type에서 오디오 제목 번역을 확인하고 기존 시리즈 locale 전달·fallback을 보존한다. 기존 요일·정렬·유료/무료 필터와 페이지 결과가 동일해야 한다.
|
||||
- 요구사항: LIST-03, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
||||
- 시작 조건: P1-T1 완료.
|
||||
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
||||
- 범위 밖: 추천·랭킹 변경.
|
||||
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
||||
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
||||
|
||||
**확인/수정 후보 파일**
|
||||
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryService.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/port/out/MainContentAllQueryPort.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt
|
||||
|
||||
**테스트 파일**
|
||||
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepositoryTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/all/application/MainContentAllQueryServiceTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/all/adapter/in/web/MainContentAllEndToEndTest.kt
|
||||
|
||||
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
||||
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
||||
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
||||
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests '*DefaultMainContentAllQueryRepositoryTest' --tests '*MainContentAllQueryServiceTest' --tests '*MainContentAllEndToEndTest'
|
||||
```
|
||||
|
||||
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
||||
|
||||
**Task 실행 기록:**
|
||||
|
||||
- RED: `./gradlew test --tests '*MainContentAllQueryServiceTest.shouldQueryAudiosForAudioType' --tests '*DefaultMainContentAllQueryRepositoryTest.shouldFindAudiosWithTranslatedTitleFallback'` 실행. 종료 코드 1, service locale 전달 assertion과 repository 오디오 title assertion이 실패했다.
|
||||
- GREEN/Focused: `./gradlew test --tests '*DefaultMainContentAllQueryRepositoryTest' --tests '*MainContentAllQueryServiceTest' --tests '*MainContentAllEndToEndTest'` 실행. 종료 코드 0, 오디오 title 번역·공백 fallback과 기존 시리즈 번역 회귀를 확인했다.
|
||||
- 리뷰 보완: 같은 오디오에 en/ja 번역을 함께 저장해 locale별 title 선택과 ID 순서 불변을 추가 검증했다.
|
||||
- 보완 후 Focused 재실행: 위 P1-T2 focused command 실행. 종료 코드 0.
|
||||
|
||||
#### Phase 1 Gate — P1-GATE
|
||||
|
||||
- Objective: Phase 1의 요청 헤더부터 DB 번역 선택·HTTP 응답까지 확인한다.
|
||||
- 시작 조건: P1-T1, P1-T2 완료.
|
||||
- 완료 증거: 해당 Task 명령 통과, 아래 수동 확인과 실제 명령·결과·환경 기록.
|
||||
- 범위 밖: 운영 데이터 변경, Papago 실행, 테스트 삭제·완화.
|
||||
- [x] 해당 Phase의 모든 focused test 명령을 실행하여 종료 코드 0을 확인한다.
|
||||
- [ ] test 서버에서 같은 API·query를 유지하고 Accept-Language만 바꿔 GET 요청한다.
|
||||
- [ ] ko/en/ja 번역·없는 번역·공백 번역·기본 언어 동작을 실제 JSON에서 확인한다.
|
||||
- [ ] 대상 외 필드, 순서·개수·페이지·인증·성인/차단/구매 정책이 보존되는지 확인한다.
|
||||
- [ ] 요청별 번역 조회가 항목 수에 비례해 늘지 않고 GET에서 번역 작업/외부 API/쓰기 호출이 없는지 확인한다.
|
||||
- [ ] 검증 로그는 인증 토큰·개인정보를 제거해 기록한다.
|
||||
|
||||
수동 HTTP는 test 서버와 합성 테스트 계정을 사용한다. 인증이 필요한 API는 test 서버용 토큰을 사용하고 문서에 값을 기록하지 않는다.
|
||||
test 서버 배포·DB 준비가 안 되면 수동 Gate를 완료 처리하지 않고 원인과 재개 조건을 남긴다.
|
||||
|
||||
**Phase 1 Gate 실행 기록:**
|
||||
|
||||
- 2026-09-09: P1-T1 focused command 재실행. 종료 코드 0.
|
||||
- 2026-09-09: P1-T2 focused command 실행. 종료 코드 0.
|
||||
- 2026-09-09: `./gradlew ktlintCheck`는 신규 테스트 호출 줄바꿈과 import 정렬 지적 후 수정해 재실행했다. 최종 종료 코드 0.
|
||||
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0.
|
||||
- 2026-09-09: Oracle 리뷰에서 자동 검증 공백을 지적받아 다중 locale 선택, 홈 locale 전달, 공백 fallback 보강 테스트를 추가했다. 보강 focused test 종료 코드 0.
|
||||
- 2026-09-09: 보강 후 P1-T1 focused command, P1-T2 focused command, `./gradlew ktlintCheck`, `./gradlew assemble`을 다시 실행했다. 각 종료 코드 0.
|
||||
- 로컬 수동 테스트가 불가능하다는 사용자 확인에 따라 HTTP Gate를 test 서버로 이관했다. 재개 조건: test 서버 배포와 합성 인증 수단 준비 후 동일 query에서 `Accept-Language`만 바꿔 LIST-01~04 JSON을 확인한다.
|
||||
|
||||
### Phase 2: 추천·더보기·랭킹
|
||||
|
||||
시작 조건: P1-GATE 통과. 결과: LIST-05~07의 번역 표시와 요청 간 격리.
|
||||
|
||||
- [x] **Task 2.1: 추천·더보기의 모든 오디오 제목 번역**
|
||||
|
||||
**Goal P2-T1**
|
||||
- Objective: 일반 카드와 별도 SQL을 쓰는 mostCommentedAudios를 모두 검증한다. 더보기 두 type과 FIRST_AUDIO_CONTENT 공유 호출자를 확인한다. 추천 스냅샷·백그라운드 작업에 요청 범위 의존을 추가하지 않으며, 공유 홈 추천 호출의 기존 응답을 회귀 검증한다.
|
||||
- 요구사항: LIST-05, LIST-07, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
||||
- 시작 조건: P1-GATE 완료.
|
||||
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
||||
- 범위 밖: 배너·댓글·닉네임 및 다른 API의 신규 번역 기능.
|
||||
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
||||
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
||||
|
||||
**확인/수정 후보 파일**
|
||||
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/application/AudioRecommendationQueryService.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/port/out/AudioRecommendationQueryPort.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacade.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt
|
||||
|
||||
**테스트 파일**
|
||||
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepositoryTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/application/ContentOverviewFacadeTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/recommendation/adapter/in/web/AudioRecommendationEndToEndTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/overview/adapter/in/web/ContentOverviewEndToEndTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt
|
||||
|
||||
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
||||
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
||||
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
||||
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests '*DefaultAudioRecommendationQueryRepositoryTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*ContentOverviewFacadeTest' --tests '*AudioRecommendationEndToEndTest' --tests '*ContentOverviewEndToEndTest' --tests '*HomeRecommendationFacadeTest'
|
||||
```
|
||||
|
||||
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
||||
|
||||
**Task 실행 기록:**
|
||||
|
||||
- 2026-09-09: RED는 이전 구현 담당자가 아래 focused command로 포착했다. 원시 Gradle 콘솔 로그는 현재 세션에 보관되어 있지 않아 출력은 재구성하지 않는다. LIST-05 전체 카드와 LIST-07 NEW_AND_HOT_AUDIO/FIRST_AUDIO_CONTENT가 요청 locale 번역 대신 원문 제목을 반환한 assertion 실패였다.
|
||||
- 2026-09-09: GREEN 구현 후 저장소에서 locale별 배치 조회와 nonblank title fallback을 적용했다. Oracle 보완으로 같은 fixture의 en→ja→en MockMvc 격리와 빈 문자열·공백·누락·다른 locale fallback, most-commented의 ID 순서·댓글·작성자 프로필 필드 보존을 추가 검증했다.
|
||||
- 2026-09-09: Focused: 위 command 실행, `BUILD SUCCESSFUL in 1m 43s`. production 코드는 Oracle 보완에서 변경하지 않았고, `pointAudios`의 기존 개수·인덱스 검증을 누적 fixture와 `rand()` 정렬에 독립적인 fixture ID·가격 조건으로 교체했다.
|
||||
- 수동 HTTP는 로컬 실행이 불가능하다는 사용자 확인에 따라 test 서버 P2-GATE에서 대기한다.
|
||||
|
||||
- [x] **Task 2.2: 랭킹 응답 제목을 요청 언어로 표시**
|
||||
|
||||
**Goal P2-T2**
|
||||
- Objective: 같은 랭킹 스냅샷의 contentId로 요청 locale 번역을 일괄 조회하여 표시 제목에만 반영한다. 번역 없음·공백이면 기존 snapshot.title을 사용한다. 집계 재실행 없이 번역 반영과 rank·rankChange·점수 불변을 검증한다.
|
||||
- 요구사항: LIST-06, RANK-01, LANG-01/02, TEXT-01/02, COMPAT-01, READ-01, ISOLATE-01
|
||||
- 시작 조건: P2-T1 완료.
|
||||
- 완료 증거: 아래 TDD 체크박스, focused test 성공, 실제 결과 기록.
|
||||
- 범위 밖: 집계·점수·스냅샷 저장 형식 변경.
|
||||
- Consumes: 기존 원문·저장된 번역·LangContext.lang.code.
|
||||
- Produces: 기존 응답 계약을 유지한 요청 locale별 표시 문자열.
|
||||
|
||||
**확인/수정 후보 파일**
|
||||
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryService.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/port/out/AudioRankingSnapshotPort.kt
|
||||
- src/main/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapter.kt
|
||||
|
||||
**테스트 파일**
|
||||
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/application/AudioRankingQueryServiceTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/content/ranking/adapter/out/persistence/DefaultAudioRankingSnapshotPersistenceAdapterTest.kt
|
||||
- src/test/kotlin/kr/co/vividnext/sodalive/v2/api/content/ranking/adapter/in/web/AudioRankingControllerTest.kt
|
||||
|
||||
- [x] **RED:** 위 테스트에서 대상 경로의 언어 선택·원문 fallback·기존 값 불변을 검증하는 가장 작은 실패 사례를 작성한다.
|
||||
- [x] **RED 확인:** 아래 명령으로 미구현 번역 때문에 발생한 제목 assertion 실패를 확인한다. 환경 실패는 RED 증거가 아니다.
|
||||
- [x] **GREEN:** 기존 계층에서 locale 전달과 번역 조회/매핑만 최소 구현한다.
|
||||
- [x] **GREEN 확인:** 같은 명령으로 의도한 번역·fallback 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 이번 변경이 만든 중복만 정리하고 아래 focused test 및 직접 호출자 회귀를 확인한다. 실행 결과를 Task 기록에 누적한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests '*AudioRankingQueryServiceTest' --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest' --tests '*AudioRankingControllerTest'
|
||||
```
|
||||
|
||||
기대 결과: 종료 코드 0. 번역이 없어서 콘텐츠가 누락되거나 순서·개수·페이지가 달라지는 사례 0건.
|
||||
|
||||
**Task 실행 기록:**
|
||||
|
||||
- 2026-09-09: RED: `./gradlew test --tests '*AudioRankingQueryServiceTest' --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest' --tests '*AudioRankingControllerTest'` 종료 코드 1. `AudioRankingQueryServiceTest.shouldUseRequestedLocaleTitlesWithoutChangingRankingValues`의 `expected: <[en-title-2, en-title-1]> but was: <[audio-2, audio-1]>` 및 `AudioRankingControllerTest.shouldIsolateRequestedLocaleTitlesWithoutChangingRankingResponse`의 `JSON path "$.data.items[0].title" expected:<en-title-2> but was:<audio-2>`를 확인했다. `DefaultAudioRankingSnapshotPersistenceAdapterTest.shouldTranslateSnapshotTitlesInBatchesWithSnapshotFallback`도 snapshot title 반환 assertion으로 실패했다.
|
||||
- 2026-09-09: GREEN: `LangContext.lang.code`을 조회 port에만 전달하고, 최신·이전 각 스냅샷 결과 집합에서 `findByContentIdInAndLocale` 한 번으로 유효한 title만 적용했다. 빈 문자열·공백·누락·다른 locale 전용 번역은 각 snapshot.title로 fallback하며, replace/집계/refresh 경로는 변경하지 않았다.
|
||||
- 2026-09-09: Focused: 위 명령 종료 코드 0, `BUILD SUCCESSFUL in 40s`. query service의 en→ja→en locale 전달과 title 외 `showRankChange`·type·순서·rank·rankChange·isNew·닉네임·cover URL 불변, 실제 adapter의 최신·이전 조회 배치 8회 statement, controller의 en→ja→en JSON을 검증했다.
|
||||
- 2026-09-09: 직접 호출자 회귀: `./gradlew test --tests '*AudioRankingSnapshotRefreshServiceTest'` 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
||||
- 수동 HTTP는 test 서버 배포 후 P2-GATE에서 수행한다.
|
||||
- 2026-09-09: P2-T2 spec-review 보강: `./gradlew test --tests '*DefaultAudioRankingSnapshotPersistenceAdapterTest'` 종료 코드 0, `BUILD SUCCESSFUL in 34s`. `rendered_payload`를 SQL NULL로 저장한 실제 translation row는 converter의 빈 payload로 읽혀 snapshot.title으로 fallback했다. `{ "title": null }` JSON은 non-null `ContentTranslationPayload.title` 계약 밖이라 생성하지 않았다.
|
||||
- 2026-09-09: 빈 latest 조회 후 Hibernate `prepareStatementCount=1`, 이어진 빈 previous 조회 후 누적 `=2`를 확인했다. 각 snapshot native SELECT만 발생했고 ContentTranslation SELECT는 0회다. non-empty latest·previous en→ja→en 여섯 조회는 snapshot+batch translation 각 1회씩 총 12 statements였으며, title만 빈 문자열로 정규화한 전체 `AudioRankingSnapshotRecord`가 locale 간 동일했다. latest score base 10~14와 previous 20~24의 final/normalized/raw·count·growth·boost 필드를 포함했고, 재조회한 저장 snapshot title과 모든 score 값도 변경되지 않았다.
|
||||
|
||||
#### Phase 2 Gate — P2-GATE
|
||||
|
||||
- Objective: Phase 2의 요청 헤더부터 DB 번역 선택·HTTP 응답까지 확인한다.
|
||||
- 시작 조건: P2-T1, P2-T2 완료.
|
||||
- 완료 증거: 해당 Task 명령 통과, 아래 수동 확인과 실제 명령·결과·환경 기록.
|
||||
- 범위 밖: 운영 데이터 변경, Papago 실행, 테스트 삭제·완화.
|
||||
- [x] 해당 Phase의 모든 focused test 명령을 실행하여 종료 코드 0을 확인한다.
|
||||
- [ ] test 서버에서 같은 API·query를 유지하고 Accept-Language만 바꿔 GET 요청한다.
|
||||
- [ ] ko/en/ja 번역·없는 번역·공백 번역·기본 언어 동작을 실제 JSON에서 확인한다.
|
||||
- [ ] 대상 외 필드, 순서·개수·페이지·인증·성인/차단/구매 정책이 보존되는지 확인한다.
|
||||
- [ ] 요청별 번역 조회가 항목 수에 비례해 늘지 않고 GET에서 번역 작업/외부 API/쓰기 호출이 없는지 확인한다.
|
||||
- [ ] 검증 로그는 인증 토큰·개인정보를 제거해 기록한다.
|
||||
|
||||
수동 HTTP는 test 서버와 합성 테스트 계정을 사용한다. 인증이 필요한 API는 test 서버용 토큰을 사용하고 문서에 값을 기록하지 않는다.
|
||||
test 서버 배포·DB 준비가 안 되면 수동 Gate를 완료 처리하지 않고 원인과 재개 조건을 남긴다.
|
||||
|
||||
**Phase 2 Gate 실행 기록:**
|
||||
|
||||
- 2026-09-09: P2-T1 focused command를 `--rerun-tasks`로 강제 재실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 5m 12s`, 10 tasks executed.
|
||||
- 2026-09-09: P2-T2 focused command를 `--rerun-tasks`로 강제 재실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 5m 14s`, 10 tasks executed.
|
||||
- 2026-09-09: `AudioRankingSnapshotRefreshServiceTest` 직접 호출자 회귀를 실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
||||
- 로컬 수동 테스트는 불가능하다는 사용자 확인에 따라 LIST-01~07 HTTP 검증을 test 서버 배포 후 수행한다.
|
||||
|
||||
## 실행 순서와 완료 기준
|
||||
|
||||
P1-T1 → P1-T2 → P1-GATE → P2-T1 → P2-T2 → P2-GATE. 2026-09-09 사용자 지시로 로컬 P1-GATE를 test 서버로 이관하고 P2 자동 구현·검증을 먼저 완료했다.
|
||||
한 번에 하나의 Task 또는 Gate만 활성화하며, 완료된 기록은 되돌리거나 삭제하지 않는다.
|
||||
|
||||
최종 품질 명령:
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.*' --tests 'kr.co.vividnext.sodalive.v2.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.content.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.*' --tests '*HomeRecommendation*'
|
||||
./gradlew ktlintCheck
|
||||
./gradlew assemble
|
||||
```
|
||||
|
||||
기대 결과: 각 자동 검증 명령 종료 코드 0. 대상 7개 API의 실제 HTTP 확인은 test 서버 Gate에서 별도 완료한다.
|
||||
전체 test는 공통 인증·언어 파서·serialization 변경 또는 targeted 결과로 영향 판단이 불가능할 때 추가한다.
|
||||
생략 시 국소적인 표시값 변경이라는 근거와 대신 실행한 위 영향 범위 회귀를 기록한다.
|
||||
초기 문서 작성 단계에서는 위 구현 검증 명령을 실행하지 않았으며, 구현 후 결과는 각 Task/Gate 기록에 누적한다.
|
||||
|
||||
## 결정 기록
|
||||
|
||||
PRD의 DEC-LIST-01~07을 따른다.
|
||||
기존 파싱·API 계약·원문 fallback을 유지하며, 요청 외 영역으로 확대해야 할 경우 먼저 문서와 사용자 결정을 갱신한다.
|
||||
|
||||
## 문서 검증 기록
|
||||
|
||||
- 2026-09-09: 사용자 인터뷰의 원문 fallback과 댓글·닉네임·배너 유지 결정을 PRD와 Task에 반영.
|
||||
- 초기 문서 작성 당시 코드·테스트·운영 데이터 변경 없이 구현 Task와 Gate는 전부 미착수였다.
|
||||
- 경로 존재 확인 및 필수 Gradle 명령 확인 결과는 아래에 누적한다.
|
||||
- 2026-09-09: Task의 기존 소스·테스트 경로 존재를 확인했다. 채널 홈 EndToEndTest는 현재 없으므로 신규 작성 예정으로 구분했다. 구현 완료 체크박스와 템플릿 placeholder가 남아 있지 않음을 확인했다.
|
||||
- 2026-09-09: 문서 유지보수 규칙에 따라 `./gradlew tasks --all` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 26s`; test·ktlintCheck·assemble 태스크를 확인했다. 기능 테스트·빌드·HTTP 검증은 구현하지 않은 문서 작업이므로 실행하지 않았다.
|
||||
- 2026-09-09: Phase 1·2 전체 영향 범위 test 명령 실행. 종료 코드 0, `BUILD SUCCESSFUL in 3m 23s`.
|
||||
- 2026-09-09: 최초 `./gradlew ktlintCheck`에서 신규 랭킹·추천 테스트의 들여쓰기와 긴 줄 위반을 확인해 포맷만 수정했다. 관련 두 repository test 종료 코드 0, 최종 `./gradlew ktlintCheck` 종료 코드 0.
|
||||
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
||||
- 2026-09-09: 전체 `./gradlew test` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 8m 19s`.
|
||||
- 2026-09-09: 문서 상태를 Phase 1·2 자동 검증 완료와 test 서버 HTTP Gate 대기로 갱신하고 `./gradlew tasks --all`로 명령 유효성을 재확인했다. 종료 코드 0.
|
||||
- 2026-09-09: 전체 영향 범위 회귀 명령을 실행했다. 종료 코드 0, `BUILD SUCCESSFUL in 3m 23s`.
|
||||
- 2026-09-09: `./gradlew ktlintCheck` 최초 실행에서 신규 테스트 두 파일의 들여쓰기·긴 줄 위반을 확인해 포맷만 수정했다. 관련 ranking adapter·recommendation repository 테스트 재실행 종료 코드 0, 최종 `ktlintCheck` 종료 코드 0.
|
||||
- 2026-09-09: `./gradlew assemble` 실행. 종료 코드 0, `BUILD SUCCESSFUL in 6s`.
|
||||
- 2026-09-09: 문서 상태와 test 서버 Gate 갱신 후 `./gradlew tasks --all` 재실행. 종료 코드 0, `BUILD SUCCESSFUL in 928ms`.
|
||||
- 로컬 수동 테스트는 불가능하므로 자동 검증 완료 상태로 test 서버 배포를 준비한다. 배포 후 P1-GATE부터 LIST-01~07의 실제 HTTP 응답을 순서대로 확인한다.
|
||||
@@ -1,99 +0,0 @@
|
||||
# v2 콘텐츠 목록 요청 언어별 번역 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
- 작성일: 2026-09-09
|
||||
- 상태: Phase 1·2 구현 및 자동 검증 완료, test 서버 HTTP Gate 대기
|
||||
- 결정권자: 사용자
|
||||
- 관련 계획: [plan-task.md](plan-task.md)
|
||||
- 공개 API 경로·요청·응답 스키마는 유지하며 별도 API 계약 문서는 만들지 않는다.
|
||||
- 사용자의 2026-09-09 구현 지시 이후 Phase 1·2 코드·테스트 구현을 진행했다. 운영 데이터 변경과 번역 실행은 수행하지 않는다.
|
||||
|
||||
## 1. 문제와 목표
|
||||
|
||||
동일한 콘텐츠라도 v2 조회 위치에 따라 번역 제목과 원문이 혼재한다.
|
||||
요청의 Accept-Language로 결정된 언어에 맞춰 이미 저장된 번역을 표시하고,
|
||||
번역이 없거나 공백이면 해당 필드의 기존 원문을 표시한다.
|
||||
|
||||
서버의 언어 결정은 기존 LangInterceptor → 요청 범위 LangContext를 재사용한다.
|
||||
앱의 설정 언어가 실제 헤더로 전송되는지는 이 서버 작업에서 보장하지 않는다.
|
||||
|
||||
## 2. 대상 API와 필드
|
||||
|
||||
아래 경로는 모두 GET이다. 기존 응답에 존재하는 필드만 변경하며 설명·태그 같은 새 필드를 추가하지 않는다.
|
||||
|
||||
| ID | API | 대상 |
|
||||
|---|---|---|
|
||||
| LIST-01 | /api/v2/creator-channels/{creatorId}/audio | 오디오 title, 연결된 seriesName, 테마명 |
|
||||
| LIST-02 | /api/v2/creator-channels/{creatorId}/series | 시리즈 title. 기존 연재 요일 언어 처리 유지 |
|
||||
| LIST-03 | /api/v2/audio/contents | 오디오 title, 시리즈 title. 기존 type·정렬·요일 필터 유지 |
|
||||
| LIST-04 | /api/v2/creator-channels/{creatorId}/home | 오디오 카드 title·seriesName, 시리즈 title, 일정 중 AUDIO 유형의 title |
|
||||
| LIST-05 | /api/v2/audio/recommendations | latestAudios, newAndHotAudios, freeAudios, pointAudios, mostCommentedAudios, recommendedAudios의 title |
|
||||
| LIST-06 | /api/v2/audio/rankings | 각 랭킹 항목 title |
|
||||
| LIST-07 | /api/v2/contents | NEW_AND_HOT_AUDIO, FIRST_AUDIO_CONTENT의 title |
|
||||
|
||||
- LIST-05의 originalSeries는 현재 seriesId·coverImageUrl만 반환한다. 번역용 title 필드를 추가하지 않는다.
|
||||
- LIST-04의 라이브 제목, 후원·팬톡 문구 등 오디오·시리즈·테마 외 필드는 대상이 아니다.
|
||||
- /api/v2/home/recommendations 등 다른 경로는 신규 기능 범위가 아니다. 공유 조회 코드 변경 시 기존 동작 회귀를 검증한다.
|
||||
- 관리자 목록, 상세·검색 API, 음성 파일 번역, 설명·태그 응답 확장은 제외한다.
|
||||
|
||||
## 3. 기능 요구사항과 수용 기준
|
||||
|
||||
| ID | 상태/근거 | 요구사항 | 수용 기준 | 연결 |
|
||||
|---|---|---|---|---|
|
||||
| LANG-01 | 사용자 확정 | 대상 API 전체에서 Accept-Language 기준 번역 선택 | 동일 데이터에 ko/en/ja 요청 시 해당 locale 번역 표시 | 모든 Task |
|
||||
| LANG-02 | 기존 동작 유지 | Lang.fromAcceptLanguage 파싱 재사용 | 헤더 누락·빈 값·미지원 값은 ko, en-US는 en, ja-JP는 ja. q 가중치 파싱 확장 없음 | P1-GATE |
|
||||
| TEXT-01 | 사용자 확정 | 번역 없음·빈 문자열·공백 문자열은 원문 표시 | null/빈 문자열/공백별 원문 일치, 다른 언어 번역으로 대체하지 않음 | 모든 Task |
|
||||
| TEXT-02 | 사용자 확정 | 오디오 제목·시리즈 제목/시리즈명·테마명만 적용 | 댓글·닉네임·배너는 기존 값·선택 정책 유지 | 모든 Task |
|
||||
| COMPAT-01 | 기존 기능 유지 | 언어는 표시 문자열에만 영향 | ID·개수·순서·hasNext·권한·성인/차단/구매 필터·가격·이미지 불변 | 각 Gate |
|
||||
| RANK-01 | 구현 제약 | 랭킹 집계와 번역 표시를 분리 | 같은 스냅샷으로 언어만 바꿔 제목 변경, 순위·점수·rankChange 불변 | P2-T2 |
|
||||
| READ-01 | 범위 제약 | 저장된 번역만 조회 | GET에서 Papago 호출·번역 작업 생성·DB 쓰기 없음 | 각 Gate |
|
||||
| ISOLATE-01 | 기존 요청 범위 유지 | 언어가 다른 요청끼리 값 공유 금지 | en→ja→en 요청에서도 언어 혼입 없음 | 각 Gate |
|
||||
|
||||
원문이란 번역 적용 전 해당 경로에서 반환하던 문자열이다.
|
||||
랭킹은 기존 스냅샷 title을 fallback으로 유지하며 최신 콘텐츠 제목으로 갱신하는 별도 기능을 추가하지 않는다.
|
||||
번역 완료 전 원문이 보이는 것은 정상이다. 원문과 요청 언어가 같아도 별도 추정 없이 같은 locale 번역 조회/fallback 규칙을 적용한다.
|
||||
|
||||
## 4. 기술 근거와 최소 변경 방향
|
||||
|
||||
모든 경로는 src/main/kotlin/kr/co/vividnext/sodalive/ 아래를 기준으로 한다.
|
||||
|
||||
| 근거 파일 | 현재 확인한 동작 |
|
||||
|---|---|
|
||||
| i18n/LangInterceptor.kt, i18n/Lang.kt, configs/WebConfig.kt | 전체 경로에서 헤더를 읽고 ko/en/ja로 결정 |
|
||||
| content/translation/ContentTranslationRepository.kt | findByContentIdInAndLocale 일괄 조회가 이미 존재 |
|
||||
| i18n/translation/TranslationReadModelMaterializer.kt | content·series 번역을 renderedPayload에 저장 |
|
||||
| v2/creator/channel/audio/adapter/out/persistence/DefaultCreatorChannelAudioQueryRepository.kt | 오디오 title·seriesName·테마는 요청 locale 번역 우선 |
|
||||
| v2/creator/channel/series/adapter/out/persistence/DefaultCreatorChannelSeriesQueryRepository.kt | 시리즈 번역 우선과 원문 fallback 구현됨 |
|
||||
| v2/creator/channel/home/adapter/out/persistence/DefaultCreatorChannelHomeQueryRepository.kt | 오디오·시리즈 제목 및 AUDIO 일정 제목은 요청 locale 번역 우선 |
|
||||
| v2/content/all/adapter/out/persistence/DefaultMainContentAllQueryRepository.kt | 오디오·시리즈 제목은 요청 locale 번역 우선 |
|
||||
| v2/content/recommendation/adapter/out/persistence/DefaultAudioRecommendationQueryRepository.kt | 일반 카드 및 mostCommentedAudios 제목은 요청 locale 번역 우선 |
|
||||
| v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt | FIRST_AUDIO_CONTENT 제목은 요청 locale 번역 우선 |
|
||||
| v2/content/ranking/application/AudioRankingQueryService.kt | 요청 locale을 조회 port에 전달하고 snapshot 표시 제목만 번역 |
|
||||
|
||||
기존 QueryService → Port → Persistence 경계를 유지하며 필요한 locale을 전달한다.
|
||||
기존 번역 조인 또는 ID 일괄 조회를 재사용한다. 항목별 단건 조회로 N+1을 만들지 않는다.
|
||||
페이지·정렬·필터 결과를 유지한 채 제목을 매핑한다. 별도 범용 번역 프레임워크·캐시·의존성·DDL은 추가하지 않는다.
|
||||
랭킹/추천 스냅샷 생성 작업에 요청 범위 LangContext를 주입하지 않는다.
|
||||
|
||||
## 5. 검증 시나리오
|
||||
|
||||
1. 같은 원문에 ko/en/ja별 서로 다른 번역을 준비하고 7개 API의 대상 필드가 헤더에 맞는지 확인한다.
|
||||
2. 번역 행 없음, 빈 제목, 공백 제목, 다른 locale 번역만 존재하는 경우 필드별 원문 fallback을 확인한다.
|
||||
3. 오디오 번역만 있고 시리즈 번역이 없는 혼합 사례에서 각 필드가 독립적으로 선택되는지 확인한다.
|
||||
4. 언어를 바꿔도 콘텐츠 ID·순서·페이지·필터·랭킹과 제외 필드가 동일한지 확인한다.
|
||||
5. 실제 HTTP 요청과 DB 조회 테스트로 연결을 검증한다. Mock 응답의 문자열 확인만으로 완료 처리하지 않는다.
|
||||
|
||||
## 6. 결정 기록
|
||||
|
||||
| 날짜 | ID | 구분 | 내용 |
|
||||
|---|---|---|---|
|
||||
| 2026-09-09 | DEC-LIST-01 | 사용자 확정 | 앞서 조사한 조회 위치 모두 Accept-Language에 따른 번역 표시 |
|
||||
| 2026-09-09 | DEC-LIST-02 | 사용자 확정 | 번역 없음·공백이면 원문 표시 |
|
||||
| 2026-09-09 | DEC-LIST-03 | 사용자 확정 | 오디오 제목·시리즈 제목/시리즈명·테마명 대상, 댓글·닉네임·배너 유지 |
|
||||
| 2026-09-09 | DEC-LIST-04 | 기존 동작 유지 | 언어 파싱·기본 언어·인증·조회 조건·응답 스키마 유지 |
|
||||
| 2026-09-09 | DEC-LIST-05 | 사용자 제한 | 문서만 작성, 구현하지 않음 |
|
||||
| 2026-09-09 | DEC-LIST-06 | 사용자 지시 | 현재 브랜치에서 Phase 1 구현 진행. PRD에도 구현 상태를 최종 갱신 |
|
||||
| 2026-09-09 | DEC-LIST-07 | 사용자 지시 | 로컬 수동 테스트가 불가능하므로 Phase 2까지 자동 검증을 완료하고 test 서버에서 HTTP 검증 |
|
||||
|
||||
제품 결정이 필요한 미결 항목은 없다. test 서버 배포 후 대상 7개 API의 수동 HTTP 검증이 남아 있다.
|
||||
@@ -1,965 +0,0 @@
|
||||
# 크리에이터 커뮤니티 게시물 본문 번역 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 기존 로컬 Gate 기록 유지 · `P1-R4` 구현·자동 회귀·수정 후 리뷰 완료 · 테스트 서버 수동 검증 대기 |
|
||||
| 작성일 | 2026-09-10 |
|
||||
| 요구사항 / API 기준 | [PRD](prd.md), 특히 §3 요구사항과 §5 API 계약 |
|
||||
| 기준 템플릿 | `docs/sample/sample-plan-task.md` |
|
||||
| 현재 Phase / 활성 Goal | `P1-R4` 및 P1/P2 영향 회귀 완료 / 활성 구현 Goal 없음 |
|
||||
| 다음 Goal | 테스트 브랜치 배포 후 실제 MySQL/Papago/HTTP 수동 검증 |
|
||||
|
||||
## 목표·범위·제약
|
||||
|
||||
본문 원문을 보존하면서 한국어·영어·일본어 번역을 저장하고, 상세·목록·미리보기 전체에 일관되게 제공한다.
|
||||
신규/본문 수정은 전체 목표 언어를 처리하고, 기존 게시물의 누락 번역은 상세에서 요청 언어만 예약한다.
|
||||
|
||||
기존 Kotlin, Java 17, Spring Boot 2.7.14, Gradle Wrapper, JUnit 5, QueryDSL/JPA, Papago를 사용한다.
|
||||
새 큐·스케줄러·외부 의존성·공개 API 필드를 추가하지 않는다. 원문과 번역을 분리하며 구매/차단/성인 정책을 유지한다.
|
||||
새 재사용 기능은 `v2/creator/channel/community/translation` 아래에 두고 기존 공통 번역 연결부만 확장한다.
|
||||
현재 PRD의 제외 범위는 모든 Goal에 적용한다. 신규 파일 경로는 아래 표에서 `생성`으로 구분한다.
|
||||
|
||||
로컬 검증은 Gradle 자동 테스트와 ktlint으로 한정한다. DDL·HTTP·실제 MySQL 동시성은 로컬에서 실행하지 않고,
|
||||
테스트 브랜치 배포 후 이 문서의 수동 체크리스트에서 확인한다.
|
||||
|
||||
## 현재 상태와 실행 순서
|
||||
|
||||
| Phase | 결과 | 상태 | 완료 Task | 다음 Goal |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 현재 본문에 대응하는 번역 저장·감지 기반 | `P1-R4` 포함 로컬 자동 검증 완료 | 6/6 | 테스트 서버 수동 검증 |
|
||||
| 2 | 작성·수정·상세의 비동기 연결과 폴백 | 완료 | 2/2 | 테스트 서버 수동 검증 |
|
||||
| 3 | 모든 목록·홈 적용과 전체 사용자 흐름 검증 | 로컬 Gate 완료 | 2/2 | 테스트 서버 수동 검증 |
|
||||
|
||||
실행 순서: `P1-T1 → P1-T2 → P1-GATE → P1-R1 → P1-GATE 재검증 → P1-R2 → P1-GATE 재검증 → P1-R3 → P1-GATE 재검증 → P2-T1 → P2-T2 → P2-GATE → P3-T1 → P3-T2 → P3-GATE → P3-R1 → P3-GATE 재검증 → P1-R4 → P1/P2 영향 회귀 → 수정 후 리뷰`.
|
||||
Task 또는 Gate 하나만 활성 Goal로 운용한다. 각 Goal의 시작 조건·체크박스·완료 증거·검증 기록을 모두 충족해야 완료한다.
|
||||
과거 완료 기록은 되돌리지 않고 후속 문제를 별도 수정 Task로 누적한다.
|
||||
|
||||
2026-09-10 후속 리뷰: [Phase 1](reviews/phase-1-review.md)은 수정 1건, [Phase 2](reviews/phase-2-review.md)와
|
||||
[Phase 3](reviews/phase-3-review.md)는 추가 확정 발견 사항 없음.
|
||||
당시 후속 실행 순서: `P1-R4 → P1-GATE 영향 범위 재검토 → P2 상세 경로 영향 재검토 → 기존 테스트 서버 수동 검증`.
|
||||
당시 정적 리뷰는 사용자 지시에 따라 테스트를 재실행하지 않았다. 이후 `P1-R4`의 RED/GREEN, P1/P2 영향 회귀와
|
||||
수정 후 리뷰를 완료했다. 기존 Task/Gate 기록은 유지하며 남은 단계는 테스트 서버 수동 검증이다.
|
||||
|
||||
## 설계 계약
|
||||
|
||||
### 데이터와 공통 파이프라인
|
||||
|
||||
- `CreatorCommunity.languageCode: String?`, `contentRevision: Long`을 추가한다. 기존 행은 NULL/0, 신규 행도 0부터 시작한다.
|
||||
실제 원문 문자열 변경 때만 개정 번호를 증가시키고 언어를 NULL로 초기화한다. 동시 수정 시 개정 번호가 충돌하지 않도록
|
||||
기존 소유권 조건을 유지한 행 잠금 조회를 사용한다.
|
||||
- 신규 `CreatorCommunityTranslation`에는 `creatorCommunityId`, `locale`, `content`, `sourceRevision`, `sourceHash`,
|
||||
`sourceLanguage`와 기존 BaseEntity 시각 필드를 둔다. 게시물 ID와 locale 조합을 유일하게 만든다.
|
||||
- `LanguageDetectTargetType`과 `LanguageTranslationTargetType`에 `CREATOR_COMMUNITY`를 추가한다.
|
||||
번역 필드 키는 `content` 하나다. 다른 리소스 분기는 수정하지 않는다.
|
||||
- `TranslationSource`에 커뮤니티 원문의 개정 번호를 전달할 선택 필드를 추가하고 기존 호출은 기본값으로 유지한다.
|
||||
`TranslationSourceExtractor`는 활성 커뮤니티의 본문·언어·개정 번호를 같은 스냅샷으로 추출한다.
|
||||
- materializer는 추출된 개정 번호에 대응하는 메모리만 사용한다. 저장 시 현재 행의 개정 번호·언어·활성을 다시 검사하고
|
||||
동일한 짧은 DB 트랜잭션 안에서 upsert한다. 오래된 값이 새 번역을 덮어쓰지 않도록 개정 검사와 쓰기를 직렬화한다.
|
||||
외부 Papago 호출은 해당 잠금 밖에서 기존 워커가 처리한다.
|
||||
- 기존 `translation_job` 유일 키와 메모리 키는 유지한다. 이미 완료한 원문으로 복원되는 경우에는 메모리에서
|
||||
현재 개정 번호의 조회 모델을 먼저 materialize하고, 부족한 경우에만 기존 scheduler를 호출한다.
|
||||
|
||||
### 신규 공유 서비스 경계
|
||||
|
||||
`CreatorCommunityTranslationService` 하나에 다음의 명확히 분리된 진입점을 둔다. 단순 내부 호출을 위한 별도 port는 만들지 않는다.
|
||||
|
||||
| 인터페이스 | 책임 / 호출 조건 |
|
||||
|---|---|
|
||||
| `findDisplayContents(postIds: List<Long>, locale: String): Map<Long, CreatorCommunityDisplayContent>` | 읽기 전용. 현재 게시물과 번역을 일괄 조회해 표시 본문과 유효 번역 여부를 반환한다. 감지·예약·materialize 금지. 조회 권한 판정은 호출자가 먼저 수행한다. |
|
||||
| `requestTranslations(postId: Long, targetLanguage: String? = null)` | 별도 쓰기 트랜잭션. NULL이면 원문 외 지원 언어 전체, 값이 있으면 그 언어만 처리한다. 언어가 없으면 감지 이벤트를 발행하고, 있으면 메모리 복원 후 누락 작업을 예약한다. |
|
||||
|
||||
감지 이벤트에는 커뮤니티용 `sourceRevision: Long? = null`, `targetLanguage: String? = null` 정보를 추가한다.
|
||||
기존 이벤트 발행자는 기본값으로 동작한다. 커뮤니티 핸들러는 감지 요청 당시 개정과 현재 개정을 비교한다.
|
||||
감지 결과 저장은 개정 번호·활성 상태 조건을 포함한 갱신으로 처리하고, 저장 커밋 후 공유 서비스를 호출한다.
|
||||
상세에서 넘어온 targetLanguage를 유지해야 기존 글의 첫 감지가 전체 언어 번역으로 확대되지 않는다.
|
||||
|
||||
이미 감지된 본문에는 감지를 반복하지 않고 해당 언어로 예약을 이어간다. 다중 상세 요청에서 감지 API의 동시 중복 호출을
|
||||
완전히 제거하는 신규 분산 잠금은 추가하지 않는다. 기존 감지 캐시를 재사용하고 작업 중복/유일성 경합은 기존 DB 키와
|
||||
커뮤니티 예약 경계에서 제어한다. 유일 키 경합으로 정상 상세 응답이 실패하지 않도록 통합 검증한다.
|
||||
|
||||
### 트랜잭션과 표시 순서
|
||||
|
||||
- 작성·수정 커밋 후 처리에는 기존 커밋 후 실행 패턴을 재사용한다. 감지·예약은 롤백된 원문을 소비하지 않는다.
|
||||
- 레거시 서비스와 v2 facade/query service는 읽기 전용 트랜잭션이 있으므로 상세의 감지/예약은 별도 Spring bean의
|
||||
`REQUIRES_NEW` 진입점을 통해 처리한다. 자기 호출로 트랜잭션 프록시를 우회하지 않는다.
|
||||
- 권한/노출 판정 → 현재 원문/유효 번역 선택 → 기존 유료 마스킹 → 응답 조립 순서로 적용한다.
|
||||
- 목록·채널 홈·홈 추천·팔로잉은 일괄 읽기 메서드만 호출한다. 추천 스냅샷에는 언어별 본문을 넣지 않고
|
||||
기존 후보 ID로 얻은 상세 레코드의 본문만 응답 조립 전에 치환한다.
|
||||
- 원문을 담은 관리자 편집 응답, 댓글·답글, 푸시 이벤트와 정산 조회는 그대로 둔다.
|
||||
|
||||
### Phase 1: 번역 저장과 원문 언어 감지 기반
|
||||
|
||||
#### 구현 항목
|
||||
|
||||
- [x] **Task 1.1: 개정 번호에 대응하는 본문 번역 저장·조회·메모리 복원**
|
||||
|
||||
**Goal ID / objective:** `P1-T1` — 현재 본문과 일치하는 번역만 저장·조회할 수 있다.
|
||||
|
||||
- 시작 조건: PRD `CCT-001`, `CCT-007`, `CCT-010` 확인, 사용자 구현 요청.
|
||||
- 완료 증거: 아래 파일과 local automatic gate, 원문 복원/오래된 번역 차단 검증 기록.
|
||||
실제 MySQL DDL·HTTP·동시성 확인은 테스트 브랜치 배포 후 수동 체크리스트로 이관한다.
|
||||
- 범위 밖: 언어 감지 이벤트 연결, HTTP 진입점 변경.
|
||||
- 생산 인터페이스: 설계 계약의 `findDisplayContents`, 언어 확정 상태의 `requestTranslations`, 커뮤니티 source/materializer 분기.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/LanguageTranslationEvent.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationSourceExtractor.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationReadModelMaterializer.kt` |
|
||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt` |
|
||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslationRepository.kt` |
|
||||
| 생성 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorkerTest.kt` |
|
||||
| 생성 | `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql` |
|
||||
|
||||
- [x] **RED:** 번역 없음/동일 언어/현재 개정 일치/이전 개정/다른 언어 결과의 표시 테스트와 본문 A→B→A 복원 테스트를 작성한다.
|
||||
materializer는 작업 실행 도중 원문 변경·비활성화 시 오래된 번역을 저장/노출하지 않는 테스트로 보호한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령에서 해당 동작의 assertion 실패를 확인한다. 컴파일 오류만으로 RED를 완료하지 않는다.
|
||||
- [x] **GREEN:** 기존 메모리/워커를 연결하고 일괄 읽기, 개정 검사, 현재 메모리 복원을 최소 구현한다.
|
||||
MySQL DDL은 기존 행 NULL/0 보존, locale 유일 키, 모든 컬럼 COMMENT, 테이블 COMMENT를 포함한다.
|
||||
시각은 `TIMESTAMP`, created/updated 기본값은 `docs/agent-guides/문서유지보수.md`를 따른다.
|
||||
- [x] **GREEN 확인:** focused test에서 원문 보존·중복 키·동시 upsert·이전 결과 차단·캐시 복원 통과를 확인한다.
|
||||
- [x] **REFACTOR:** 이번 변경의 중복만 정리하고 focused test와 공통 번역 회귀를 실행한다. 자동 검증 결과와 배포 후 DDL 확인 항목을 이 Task 아래에 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
```
|
||||
|
||||
DDL은 테스트 브랜치 배포 후 격리된 MySQL 테스트 DB에 적용해 기존 행의 언어 NULL/개정 0, 원문 유지, locale 유일 키를 확인한다.
|
||||
운영 DB 적용은 이 Task의 검증으로 실행하지 않는다.
|
||||
|
||||
#### P1-T1 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `CreatorCommunityTranslationServiceTest.shouldRequireCreatorCommunityTranslationPersistenceContract`를 먼저 작성했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest'`는
|
||||
exit 1로 실패했고, `CreatorCommunityTranslationServiceTest.kt:13`의 `languageCode` 누락 assertion이 원인이었다.
|
||||
- GREEN/REFACTOR: `CreatorCommunity`의 nullable 언어·0 시작 개정, 현재 개정/해시/언어/활성 상태를 잠금으로 재확인하는
|
||||
materializer, 메모리 우선 복원 후 기존 scheduler를 쓰는 서비스, 일괄 표시 조회와 locale 유일 번역 저장을 추가했다.
|
||||
같은 focused 명령은 exit 0, `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 동시성: H2 MySQL 모드 `@DataJpaTest`에서 두 worker를 같은 원문 메모리 재료화 지점에 동시에 진입시켜,
|
||||
게시물 `PESSIMISTIC_WRITE` 잠금과 locale 유일 키 후 번역 행 1개·오류 0개를 확인했다.
|
||||
- 공통 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- DDL: `schema.sql`에 기존 행의 `language_code` NULL 및 `content_revision` 0 기본값, locale 유일 키, 전 컬럼/테이블 COMMENT,
|
||||
BaseEntity 시각 컬럼 규칙을 기록했다. 운영 DB에는 적용하지 않았다.
|
||||
- 전체 회귀/수동 HTTP/Papago/MySQL 적용 검증은 이 Task의 지정 focused/common Gradle 검증 범위를 넘어 실행하지 않았다.
|
||||
|
||||
#### P1-T1 검토 수정 — 2026-09-10
|
||||
|
||||
- 검토 확인 RED: `CreatorCommunityTranslationServiceTest.shouldRejectStaleManagedCreatorCommunityAfterConcurrentCommit`를
|
||||
실제 JPA transaction으로 추가했다. focused 명령은 exit 1, 20개 테스트 중 1개 실패였고,
|
||||
stale managed `CreatorCommunity`로 이전 번역을 저장한 `CreatorCommunityTranslationServiceTest.kt:358` assertion이 원인이었다.
|
||||
- GREEN: `EntityManager.refresh(..., PESSIMISTIC_WRITE)`로 잠긴 게시물의 최신 상태를 다시 읽고,
|
||||
번역 행도 `findByCreatorCommunityIdAndLocaleForUpdate`의 잠금 현재 읽기로 조회하게 했다. provider 호출은 기존처럼
|
||||
materializer transaction 밖의 worker에 남는다. 같은 focused 명령은 exit 0, `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 테스트 보강: 번역 없음·원문 언어 불일치·원문 해시 불일치의 원문 폴백, 실제 JPA stale persistence-context 차단,
|
||||
A→B→A 기존 번역 행 갱신, repeatable-read snapshot 뒤 동시 upsert를 확인했다.
|
||||
- H2 설정: `@AutoConfigureTestDatabase(replace = NONE)`와 `jdbc:h2:mem:creator-community-translation;MODE=MySQL`을
|
||||
사용한다. 이는 H2 호환 모드일 뿐 실제 MySQL DDL·REPEATABLE READ 검증 증거가 아니다.
|
||||
- 기록 정정: 이전 검증 기록의 H2 MySQL 모드 표기는 datasource replacement를 끄기 전에는 성립하지 않았다.
|
||||
현재 H2 설정을 바로잡았지만, 과거 결과를 실제 MySQL 결과로 소급하지 않는다.
|
||||
- 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 사용자 검증 정책 변경: local acceptance는 automatic Gradle test와 ktlint으로 한정한다. 위 자동 검증이 통과했으므로
|
||||
Task 1.1과 GREEN 확인/REFACTOR 체크박스를 완료로 표시한다. Docker, 서버, HTTP, MySQL은 local에서 실행하지 않는다.
|
||||
- 배포 후 보류: `schema.sql` 적용, 기존 행 NULL/0, locale 유일 키, 실제 MySQL REPEATABLE READ 동작은
|
||||
테스트 브랜치 배포 후 아래 수동 체크리스트에서만 확인하며 아직 통과로 기록하지 않는다.
|
||||
|
||||
- [x] **Task 1.2: 미확정 원문 언어의 비동기 감지와 목표 언어 전달**
|
||||
|
||||
**Goal ID / objective:** `P1-T2` — 본문 언어가 없으면 현재 본문을 감지하고 요청 범위의 번역으로 이어진다.
|
||||
|
||||
- 시작 조건: `P1-T1` 완료.
|
||||
- 완료 증거: 한국어/영어/일본어 감지, 단일/전체 목표 언어 전달, 지연 감지·실패 테스트 통과.
|
||||
- 범위 밖: 사용자 언어 입력 필드, 다른 리소스의 감지 정책 변경.
|
||||
- 소비/생산: 기존 `LanguageDetectionCacheService.detectWithCache`를 소비하고 커뮤니티 감지 분기와 완성된 `requestTranslations`를 제공한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt` |
|
||||
|
||||
- [x] **RED:** 언어 NULL인 본문 감지 후 `targetLanguage=ja`는 일본어만, NULL은 원문 외 두 언어로 이어지는 실패 테스트를 작성한다.
|
||||
빈 본문·미지원 원문 언어·감지 실패·감지 중 본문 수정·중복 이벤트도 포함한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 목표 언어 유실과 오래된 감지 결과 적용의 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 기존 감지 이벤트에 기본값이 있는 커뮤니티 메타데이터를 추가하고 개정 조건부 저장·커밋 후 예약을 연결한다.
|
||||
다른 리소스 이벤트와 동작은 유지한다.
|
||||
- [x] **GREEN 확인:** 외부 감지 provider 경계만 통제한 통합 테스트로 실제 커밋 후 이벤트/별도 트랜잭션을 확인한다.
|
||||
테스트를 감싼 트랜잭션이 커밋되지 않아 AFTER_COMMIT이 실행되지 않는 문제를 피한다.
|
||||
- [x] **REFACTOR:** focused test와 기존 감지 캐시/번역 scheduler 회귀 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
```
|
||||
|
||||
#### P1-T2 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `CreatorCommunityLanguageDetectTest.shouldKeepLegacyDefaultsAndDeclareCreatorCommunityMetadata`를 먼저 작성했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`는 exit 1,
|
||||
`CreatorCommunityLanguageDetectTest.kt:14`의 `CREATOR_COMMUNITY` 누락 assertion으로 실패했다.
|
||||
- 행위 RED: 커밋 후 단일/전체 목표 언어 전달, 빈 본문·미지원 감지값·provider 실패·stale 본문·중복 이벤트를 추가했다.
|
||||
같은 명령은 exit 1, `CreatorCommunityLanguageDetectTest.kt:85`, `:106`, `:179`의
|
||||
`CreatorCommunityTranslationService.requestTranslations(...)` `WantedButNotInvoked` assertion으로 실패했다.
|
||||
- GREEN: `LanguageDetectEvent`의 기본값 있는 커뮤니티 메타데이터, 언어 미확정 본문의 현재 content/revision 이벤트 발행,
|
||||
감지 외부 호출 전 무잠금 상태 확인과 잠금 후 active/revision/content 재확인, 저장 커밋 뒤 기존 번역 서비스 호출을 연결했다.
|
||||
감지 언어가 지원 범위 밖이거나 비어 있으면 원문·작업 상태를 유지한다.
|
||||
- AFTER_COMMIT: `@Transactional(propagation = Propagation.NOT_SUPPORTED)` 테스트와 명시적 `TransactionTemplate` 커밋으로
|
||||
이벤트를 발행했다. 테스트용 감지 provider 경계만 제어하고 실제 행 언어 저장 뒤 `requestTranslations(postId, "ja")` 또는
|
||||
`requestTranslations(postId, null)` 호출을 검증했다.
|
||||
- focused: 위 P1-T2 명령 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: local automatic 정책에 따라 Docker, HTTP, 실제 Papago, MySQL DDL/동시성은 실행하지 않았다.
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
- [x] **Goal `P1-GATE`: 현재 본문 기반의 감지·번역 저장 경계 판정**
|
||||
|
||||
- 시작 조건: `P1-T1`, `P1-T2` 체크박스와 검증 기록 완료.
|
||||
- 완료 증거: 아래 명령 exit 0, DDL·엔티티 정적 대조, 개정/언어 불일치 미노출 기록.
|
||||
- 범위 밖: API 연결 구현. TDD 예외 사유: 이미 작성한 동작과 DDL의 Gate 검증이며 별도 제품 코드가 아니다.
|
||||
- 대체 검증: 기존 통합 테스트를 실행하고 DB의 현재 개정/번역 개정/언어를 대조한다.
|
||||
- 확인 파일: 두 Task의 source/test 파일 및 `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql`.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
```
|
||||
|
||||
#### P1-GATE 검증 기록 — 2026-09-10
|
||||
|
||||
- 자동 Gate: 위 명령 — exit 0, `BUILD SUCCESSFUL`. 현재 개정 번역 저장/메모리 복원/작업 예약과 커밋 후 감지 경계를 함께 회귀했다.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 보류: 사용자 정책에 따라 실제 MySQL DDL·격리 수준, HTTP, Docker, 실제 Papago 검증은 실행하지 않았다.
|
||||
테스트 브랜치 배포 후 수동 체크리스트는 모두 미완료 상태로 유지한다.
|
||||
|
||||
#### Task 1.R: P1-T2 리뷰 회귀 수정
|
||||
|
||||
**Goal ID / objective:** `P1-R1` — 감지 커밋 후 독립 트랜잭션에서 현재 본문만 요청 범위대로 번역 작업으로 예약한다.
|
||||
|
||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-001~004` 확정, `P1-T2` 및 `P1-GATE` 기존 기록 확인.
|
||||
- 완료 증거: 실제 Spring 감지 listener와 scheduler를 사용하는 실패 재현 test, 최소 수정 후 focused test와 P1 Gate 재검증, 검증 기록.
|
||||
- 범위 밖: 다른 리소스 감지 정책, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 생성 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
||||
|
||||
- [x] **RED:** `REQUIRES_NEW`, 실제 `translation_job` 저장, stale 본문 차단, known-language 다중 target continuation을 실제 Spring 경계로 재현한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`에서 7개 중 5개 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 감지 listener의 잠금 현재 읽기와 커밋 후 독립 번역 요청 트랜잭션을 최소 수정한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P1-R1 진행 기록 — 2026-09-10
|
||||
|
||||
- RED: 새 `CreatorCommunityLanguageDetectTest`는 초기 Kotlin generic 추론과 `AudioContentThemeQueryRepository` test bean 누락을 바로잡은 뒤 실행했다.
|
||||
focused 명령은 exit 1, 7개 중 5개 assertion 실패였다. `REQUIRES_NEW` annotation 부재, 커밋 뒤 job 미저장,
|
||||
stale 감지 결과의 언어 저장, 이미 언어가 있는 두 target 이벤트의 job 미저장을 확인했다.
|
||||
- GREEN: `requestTranslations`를 `REQUIRES_NEW`로 분리하고, listener는 잠금 조회 뒤 `EntityManager.refresh(..., PESSIMISTIC_WRITE)`로
|
||||
현재 본문을 확인한다. 이미 언어가 있는 이벤트도 감지 없이 after-commit에서 원 target을 요청한다.
|
||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
실제 Spring listener와 scheduler를 통해 단일/전체 target job 저장, stale 본문 차단, known-language 두 target continuation을 확인했다.
|
||||
- REFACTOR/Phase Gate 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck bootJar`
|
||||
— exit 0, `BUILD SUCCESSFUL`. focused/공통 번역 회귀, ktlint, bootJar를 함께 확인했다.
|
||||
- 범위: 사용자 정책에 따라 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
#### Task 1.R3: P1-T2 동일 게시물 작업 예약 직렬화
|
||||
|
||||
**Goal ID / objective:** `P1-R3` — 같은 게시물·target의 동시 `requestTranslations`가 누락 메모리에서도 하나의 translation job만 예약한다.
|
||||
|
||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-006` 확정, `P1-R2` 완료 기록 확인.
|
||||
- 완료 증거: 실제 Spring/JPA service와 job repository의 concurrent missing-memory RED test, 최소 lock 수정 후 두 호출 정상 종료·job 1개, P1 Gate 재검증.
|
||||
- 범위 밖: 공통 scheduler 동작, 다른 resource type, 분산 lock·의존성, P2/P3, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/translation/CreatorCommunityTranslationServiceTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
||||
|
||||
- [x] **RED:** 같은 언어 확정 게시물·target의 두 요청을 동시에 시작해 실제 `translation_job` unique insert 경합을 재현한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`에서 unique-key 오류 또는 정상 완료/job 1개 위반을 확인한다.
|
||||
- [x] **GREEN:** `REQUIRES_NEW` request transaction 시작에 active post write lock과 refresh를 추가한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 두 호출 정상 종료와 job 1개를 확인한다.
|
||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P1-R3 진행 기록 — 2026-09-10
|
||||
|
||||
- 원인: `requestTranslations`가 post lock 없이 memory lookup과 `TranslationJobScheduler`의 select-then-insert를 수행해,
|
||||
동일 post·target의 두 request transaction이 unique key에 경합할 수 있다.
|
||||
- RED: actual JPA `translation_job` 저장을 사용하는 두 `REQUIRES_NEW` transaction의 missing-job 조회 창을 동기화했다.
|
||||
focused 명령은 exit 1이었고, H2 `SQLState 23505`의 `uk_translation_job_resource_field_target_hash` unique index 충돌을 확인했다.
|
||||
- GREEN: `requestTranslations` 시작에서 active post를 `findByIdAndIsActiveTrueForUpdate`로 잠그고
|
||||
`EntityManager.refresh(..., PESSIMISTIC_WRITE)`한 뒤 source extraction, memory lookup, job 조회·저장을 진행한다.
|
||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`; 두 호출이 정상 종료하고 job 1개만 저장됨을 확인했다.
|
||||
- REFACTOR/Phase Gate 재검증:
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 전체 `./gradlew test`는 private scheduling service의 직접 영향 회귀와 P1 Gate가 통과했으므로 실행하지 않았다.
|
||||
Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증도 사용자 정책에 따라 실행하지 않았다.
|
||||
|
||||
#### Task 1.R2: P1-T2 동시 감지 target 보존
|
||||
|
||||
**Goal ID / objective:** `P1-R2` — 두 언어 미확정 감지가 같은 현재 본문에서 경합해도 각 이벤트의 요청 target을 커밋 후 예약한다.
|
||||
|
||||
- 시작 조건: `reviews/p1-t2-review.md`의 `REV-P1-T2-005` 확정, `P1-R1` 완료 기록 확인.
|
||||
- 완료 증거: 두 감지가 언어 NULL에서 시작한 뒤 한 감지만 먼저 커밋하는 실제 Spring failure test, 최소 수정 후 focused test와 P1 Gate 재검증, 검증 기록.
|
||||
- 범위 밖: 다른 리소스 감지 정책, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/p1-t2-review.md` |
|
||||
|
||||
- [x] **RED:** 두 감지가 `languageCode=NULL`에서 시작하고 첫 감지 커밋 뒤 두 번째가 lock을 얻는 실제 `ja`/`en` target 보존 test를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'`에서 두 번째 target job 미저장 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** lock/refresh 뒤 현재 revision/content가 유효하고 언어만 이미 설정된 경우 after-commit에 원 target을 등록한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test가 성공하는지 확인한다.
|
||||
- [x] **REFACTOR:** P1-T2 직접 영향 회귀, P1 Gate, ktlint을 실행하고 실제 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.TranslationJobSchedulerTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P1-R2 진행 기록 — 2026-09-10
|
||||
|
||||
- 원인: `handleCreatorCommunityLanguageDetect`는 lock과 refresh 뒤 다른 감지가 저장한 languageCode를 발견하면,
|
||||
현재 revision/content가 일치해도 return하여 두 번째 이벤트의 target을 잃는다.
|
||||
- RED: `shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage`는 두 감지가 languageCode NULL에서 시작한 뒤,
|
||||
첫 감지의 `ja` 예약·커밋 후 두 번째 감지의 `en` 진행을 순서대로 제어했다. focused 명령은 exit 1, 8개 중 1개 assertion 실패였고,
|
||||
`translation_job`의 `en` 누락이 원인이었다.
|
||||
- GREEN: lock/refresh 뒤 revision/content가 같고 languageCode만 이미 설정되어 있으면 이를 덮어쓰지 않고,
|
||||
기존 `requestTranslationsAfterCommit`에 두 번째 이벤트의 원 target을 등록한다. lock 보유 중 `REQUIRES_NEW` 호출은 하지 않는다.
|
||||
- GREEN 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
실제 listener/scheduler/repository에서 `ja`와 `en` job 저장 및 source language `ko` 유지를 확인했다.
|
||||
- REFACTOR/Phase Gate 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: 사용자 정책에 따라 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
#### Task 1.R4: 최초 언어 감지 캐시 경합에서도 요청 target 보존
|
||||
|
||||
- [x] **Goal `P1-R4`: 동일 본문의 최초 감지 캐시 저장이 경합해도 각 이벤트의 목표 언어 번역을 예약한다**
|
||||
|
||||
- 시작 조건: [Phase 1 리뷰](reviews/phase-1-review.md)의 `REV-P1-007` 확인 및 후속 구현 요청.
|
||||
기존 `P1-R1~R3` 완료 기록을 유지한다.
|
||||
- 관련 요구사항: `CCT-002`, `CCT-005`, `CCT-010`, 기존 `P1-R2`의 요청 target 보존.
|
||||
- 완료 증거: 실제 감지 캐시 INSERT 경합의 실패 회귀 테스트 → 최소 수정 → focused 성공 →
|
||||
P1 감지/번역 및 P2 상세 영향 회귀 → 실행 결과와 리뷰 재판정 기록.
|
||||
- 범위 밖: 외부 감지 중 게시물 행 잠금, 새 분산 잠금/큐/스케줄러, 번역 지원 언어·공개 API 변경,
|
||||
감지 provider 실패를 무조건 성공으로 처리, 다른 리소스의 제품 정책 변경, 운영 DB 작업.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResultRepository.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResult.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectionCacheConcurrencyTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheServiceTest.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 기록 | `docs/20260910_크리에이터커뮤니티게시물본문번역/reviews/phase-1-review.md` |
|
||||
| 기록 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
||||
|
||||
설계 기준:
|
||||
|
||||
- 캐시 miss → detector → 무조건 INSERT를 같은 키의 원자적 저장으로 바꾸고 저장된 결과로 수렴시켰다.
|
||||
MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`를 사용한다. DB 예외를 잡아 무시하지 않는다.
|
||||
- 동일 해시/provider/normalizationVersion의 승자 결과를 사용하며, 반복 읽기 격리에서도 최초 miss 스냅샷에 갇히지 않도록
|
||||
저장 후 current read를 보장한다. 기본 언어를 임의로 지정하거나 감지 결과를 최신 본문에 무조건 적용하지 않는다.
|
||||
- 이미 rollback-only가 된 같은 JPA 트랜잭션에서 예외만 잡고 target 예약을 계속하는 방식은 금지한다.
|
||||
공통 캐시 저장 수정이 다른 리소스의 기존 감지/캐시 재사용 동작을 바꾸지 않도록 회귀 검증한다.
|
||||
- 새 테스트는 실제 `LanguageDetectionCacheService`와 repository를 사용한다. `detectWithCache` 전체를 override하지 않고
|
||||
감지 HTTP 응답/provider 경계만 통제해 두 트랜잭션이 모두 캐시 miss를 읽는 순서를 제어한다.
|
||||
|
||||
- [x] **RED:** `shouldPreserveBothTargetsWhenInitialDetectionCacheWritesRace`를 추가한다.
|
||||
언어 NULL/캐시 없음의 동일 게시물에서 `ja`, `en` 감지를 동시에 시작하고 두 cache miss 이후 첫 저장/커밋,
|
||||
후발 저장 순서로 진행한다. 두 흐름의 정상 완료, 캐시 1행, `ja`/`en` 작업 모두 존재를 assertion으로 검증한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 후발 중복 키 예외 또는 target 누락의 의도한 실패를 확인한다.
|
||||
두 이벤트가 애초에 cache miss에 진입하지 않은 테스트를 경합 재현으로 인정하지 않는다.
|
||||
- [x] **GREEN:** 원자적 캐시 저장과 저장 결과 재사용만 최소 수정한다. 감지 후 기존 개정/본문 검사와
|
||||
커밋 후 요청 target 전달을 유지한다.
|
||||
- [x] **GREEN 확인:** 같은 focused 테스트를 통과시키고, 같은 본문의 서로 다른 게시물 생성도 캐시 1행을 공유하면서
|
||||
각 게시물의 원문 외 두 언어 작업이 보존되는지 검증한다.
|
||||
- [x] **REFACTOR:** 감지 캐시·listener·scheduler·materializer 및 실제 상세 프록시 영향 회귀를 실행한다.
|
||||
선택한 DB 저장 문법의 MySQL 동작과 H2 테스트 지원 범위를 구분하고 실제 MySQL 미검증은 기존 수동 체크리스트에 남긴다.
|
||||
관련 없는 변경 없이 아래 명령 결과와 `REV-P1-007` 수정 판정을 누적한다.
|
||||
|
||||
실행한 focused/영향 회귀 명령이다. focused의 fresh 재실행에는 `--rerun-tasks --no-parallel`을 추가했다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
공통 캐시 동작 변경의 영향이 위 회귀로 판정되지 않으면 전체 테스트로 확장한다. 전체 테스트 생략 시 대체 명령과 근거를 기록한다.
|
||||
현재 상태: RED/GREEN·영향 회귀·수정 후 리뷰 완료. 실제 MySQL 검증은 미완료다.
|
||||
|
||||
#### P1-R4 실행 기록 · 2026-09-10
|
||||
|
||||
- RED: 위 focused 명령은 두 provider 진입을 모두 확인한 뒤 exit 1로 실패했다.
|
||||
`ExecutionException → DataIntegrityViolationException → H2 23505`로 감지 캐시 유일 키 충돌을 재현했다.
|
||||
- GREEN: native MySQL `ON DUPLICATE KEY UPDATE id=id`로 승자 행을 보존하고 scalar `SELECT ... FOR UPDATE`로
|
||||
저장 결과를 현재 읽기한다. native INSERT의 audit 시각을 명시하며 DB 예외를 잡고 계속 진행하지 않는다.
|
||||
- GREEN 확인: 실제 캐시 service/repository를 사용해 같은 게시물의 `ja`/`en` 이중 miss 및
|
||||
서로 다른 게시물의 동일 본문 경합을 검증했다. 캐시 1행으로 수렴하며 각 요청의 목표 언어 작업을 보존한다.
|
||||
- focused fresh 재실행: 위 첫 명령 + `--rerun-tasks --no-parallel`, `BUILD SUCCESSFUL` (5분 19초).
|
||||
- P1 listener/scheduler/materializer 회귀: 위 두 번째 명령, `BUILD SUCCESSFUL` (1분 09초).
|
||||
- P2 상세/API 회귀: 위 세 번째 명령, `BUILD SUCCESSFUL` (1분 42초).
|
||||
- 형식: `./gradlew ktlintCheck`, `BUILD SUCCESSFUL` (44초).
|
||||
- 수정 후 리뷰: spec `PASS` (`ses_f7657d350ffe3YUYhEHvHPuikK`), quality `APPROVED`
|
||||
(`ses_f7656994dffeR2odTLswaw6RTo`). `REV-P1-007`은 로컬 자동 검증 범위에서 수정 완료로 재판정했다.
|
||||
- 전체 `./gradlew test`는 생략했다. 공통 캐시 계약, 실제 repository 경합, 모든 직접 listener/scheduler/materializer
|
||||
테스트와 P2 상세/API 소비자가 위 대체 명령으로 통과했고, 미해결 상태로 남은 별도의 공통 경계가 없었다.
|
||||
- 범위: H2 `MODE=MySQL`은 실제 MySQL 8/InnoDB 검증이 아니다. 실제 MySQL/Papago/HTTP, Docker, 배포는
|
||||
실행하지 않았다. 상세 명령·리뷰 출처는 [후속 증거](reviews/review-evidence.md)에 누적한다.
|
||||
|
||||
### Phase 2: 작성·수정·상세에서 번역 처리
|
||||
|
||||
#### 구현 항목
|
||||
|
||||
- [x] **Task 2.1: 작성 커밋과 실제 본문 변경에 감지·번역 연결**
|
||||
|
||||
**Goal ID / objective:** `P2-T1` — 생성과 본문 수정이 현재 원문 기준의 전체 목표 언어 번역으로 이어진다.
|
||||
|
||||
- 시작 조건: `P1-GATE` 완료, `CCT-003~004`, `CCT-007` 확인.
|
||||
- 완료 증거: 신규·실제 변경·동일 본문·롤백·관리자 공용 경로·동시 수정 검증.
|
||||
- 범위 밖: 이미지·오디오 업로드, 알림 발행, 소유권 정책 변경.
|
||||
- 소비 인터페이스: `requestTranslations(postId, targetLanguage = null)`.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` |
|
||||
| 확인/테스트 보강 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostCreateTest.kt` |
|
||||
| 확인/테스트 보강 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostUpdateTest.kt` |
|
||||
|
||||
- [x] **RED:** 생성 커밋 후 감지, 본문 변경 때만 언어 NULL/개정 증가, 롤백 시 작업 없음의 실패 테스트를 작성한다.
|
||||
수정 후 새 요청에서 이전 번역 미노출, 실제 언어 변경, 관리자 공용 경로도 포함한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 번역 연결 부재/개정 미변경의 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 기존 커밋 후 실행 패턴으로 공유 서비스를 연결하고 본문 문자열이 다른 경우에만 언어·개정 상태를 갱신한다.
|
||||
소유권 조건을 유지한 잠금 조회로 동시 본문 수정의 개정을 직렬화한다.
|
||||
- [x] **GREEN 확인:** 테스트를 다시 실행하고 동일 본문/본문 외 변경은 감지·번역 요청 0건인지 확인한다.
|
||||
- [x] **REFACTOR:** 공용 서비스 호출자 회귀를 확인한다. 관리자 facade의 중복 번역 연결은 추가하지 않는다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
||||
```
|
||||
|
||||
#### P2-T1 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: focused 명령은 exit 1이었다. 생성 뒤 번역 요청 누락, 본문 변경 뒤 개정/언어 상태 미갱신의 assertion 실패를
|
||||
`CreatorCommunityServiceTest.kt:333`, `AiCharacterAdminCommunityPostCreateTest.kt:93`,
|
||||
`AiCharacterAdminCommunityPostUpdateTest.kt:91`에서 확인했다.
|
||||
- GREEN: `CreatorCommunityService`는 생성과 실제 본문 변경 뒤 기존 after-commit 패턴으로
|
||||
`requestTranslations(postId, null)`을 요청한다. 본문 변경은 `languageCode`를 NULL로 초기화하고
|
||||
`contentRevision`을 증가시키며, 수정 조회는 소유권 조건의 `PESSIMISTIC_WRITE` 잠금 후 refresh한다.
|
||||
- 동시성: 실제 H2/JPA 비관적 잠금 조회를 latch로 제어한 관리자 MockMvc 요청 두 건이 모두 200으로 종료하고,
|
||||
최종 revision 2와 번역 요청 2건을 확인했다.
|
||||
- focused: 위 명령과 `LegacyCommunityPostCharacterizationTest`를 포함한 실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
생성·수정·동일 본문·롤백·관리자 공용 경로·동시 수정과 변경된 수동 생성자 호출을 함께 확인했다.
|
||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: local acceptance 정책에 따라 Docker, 서버 HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
#### Task 2.R: P2-T1 spec-review 실제 트랜잭션 증거
|
||||
|
||||
**Goal ID / objective:** `P2-R1` — 실제 Spring transaction에서 생성 롤백과 본문 변경 뒤 stale 번역 폴백·감지 예약을 증명한다.
|
||||
|
||||
- 시작 조건: `P2-T1` 완료, spec-review의 실제 transaction 증거 보강 요구 확인.
|
||||
- 완료 증거: proxied `CreatorCommunityService` 생성 롤백 뒤 post/job/detection 미영속과, 한글→영문 수정 커밋 뒤
|
||||
revision·stale 번역 폴백·영문 감지·새 target job을 별도 transaction에서 확인한다.
|
||||
- 범위 밖: P2-T2 상세/목록 API, production 리팩터링, Docker·HTTP·실제 Papago/MySQL 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
|
||||
- [x] **TEST-FIRST:** 실제 outer transaction rollback 뒤 생성 post와 감지/번역 작업이 남지 않는 test를 작성한다.
|
||||
기존 한글 translation 행이 있는 post의 실제 영문 수정 뒤, 감지 전 stale 번역 폴백과 감지 후 언어·target job을 test한다.
|
||||
- [x] **TEST-FIRST 확인:** 첫 실행에서 async 감지 완료 전 `languageCode`를 읽은 assertion 실패를 확인하고, 기존 `await` 방식의 완료 조건으로 test 경계를 바로잡는다.
|
||||
- [x] **GREEN:** test가 production 결함을 드러내면 실패 test를 유지한 최소 수정만 적용하고, 아니면 실제 경계 증거만 보강한다.
|
||||
- [x] **GREEN 확인:** 실제 post-commit 경로가 완료될 때까지 latch/새 transaction으로 대기한 assertion을 통과시킨다.
|
||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, ktlint 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
||||
```
|
||||
|
||||
#### P2-R1 검증 기록 — 2026-09-10
|
||||
|
||||
- test-first: `CreatorCommunityLanguageDetectTest`에 실제 `CreatorCommunityService` bean을 등록하고 AOP proxy를 확인했다.
|
||||
JPA repository, listener, translation service, scheduler는 실제 bean을 사용하며 S3·FCM 연관 의존성과 external detector만 test 경계로 제어했다.
|
||||
- 생성 rollback: 실제 outer `TransactionTemplate`에서 `createCommunityPost`를 호출하고 rollback-only로 종료했다.
|
||||
새 transaction에서 생성 post 부재, 해당 post ID의 translation read-model 및 `translation_job` 0건, detector query 0건을 확인했다.
|
||||
- 영문 수정: 기존 한글 원문과 유효한 이전 영어 translation 행을 만들고 실제 `modifyCommunityPost`를 commit했다.
|
||||
차단된 detector가 시작된 뒤 새 transaction에서 `languageCode=NULL`, revision 1, 현재 영문 원문 반환과 이전 영어 translation 행의
|
||||
미사용을 확인했다. detector 해제 뒤 `languageCode=en`, `ko`·`ja` job 예약을 대기해 확인했다.
|
||||
- 첫 실행: rollback test는 통과했고 수정 test는 async `@TransactionalEventListener` 완료 전 state를 읽어 `expected en but was null`로 실패했다.
|
||||
이는 production 결함이 아니라 outer service future가 async 감지 완료를 기다리지 않는 test synchronization 문제였으며,
|
||||
기존 조건 기반 `await`로 post-commit 완료를 기다리도록 바로잡았다. production 파일은 변경하지 않았다.
|
||||
- focused: 위 P2-R1 명령에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'`
|
||||
에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 형식: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 범위: Docker, 서버 HTTP, detail/list API, 실제 Papago/MySQL 검증은 실행하거나 변경하지 않았다.
|
||||
|
||||
#### Task 2.R: P2-T1 quality-review 고정 상태 경쟁 회귀
|
||||
|
||||
**Goal ID / objective:** `P2-R2` — 관리자/레거시 고정 상태 변경이 최신 본문·개정·언어 상태를 덮어쓰지 않는다.
|
||||
|
||||
- 시작 조건: P2-T1 완료, quality-review의 stale flush 및 test pseudo-lock 지적 확정.
|
||||
- 완료 증거: stale admin read 뒤 동시 본문 변경을 commit하고 고정+본문 요청을 재개하는 실제 JPA race test에서
|
||||
최신 본문·revision·language와 fixed 상태를 확인한다. test는 production 소유권 조건 `PESSIMISTIC_WRITE` query를 사용한다.
|
||||
- 범위 밖: P2-T2/P3, API schema, `@DynamicUpdate`/`@Version`, dependency, Docker·HTTP·실제 MySQL 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt` |
|
||||
| 수정 | `docs/20260910_크리에이터커뮤니티게시물본문번역/plan-task.md` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityRepository.kt` |
|
||||
|
||||
- [x] **RED:** stale admin read 뒤 별도 transaction의 본문/개정/언어 변경을 commit하고, 재개한 fixed+content 요청이 최신 상태에서 적용돼야 한다는 실제 JPA race test를 작성한다.
|
||||
- [x] **RED 확인:** 현재 구현으로 최신 state 보존 assertion이 실패하는지 확인한다.
|
||||
- [x] **GREEN:** `updateCommunityPostFixed`가 소유권 조건 post lock과 refresh를 통해 최신 행을 얻은 뒤에만 고정 상태를 변경하도록 최소 수정한다.
|
||||
관리자 흐름의 member→post lock 순서는 유지한다.
|
||||
- [x] **GREEN 확인:** production repository lock query를 사용하는 test에서 두 번째 요청의 pre-lock/lock 도착을 latch로 제어하고 executor 종료까지 확인한다.
|
||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, ktlint, diff check 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostCreateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostUpdateTest' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.AiCharacterAdminCommunityPostConcurrencyTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
#### P2-R2 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `shouldApplyFixedAndContentUpdateAfterConcurrentContentCommit`는 admin facade의 활성 post read 뒤 첫 요청을 멈추고,
|
||||
별도 worker의 실제 `CreatorCommunityService.modifyCommunityPost`가 production `findByIdAndMemberIdForUpdate` 경로로
|
||||
`after concurrent` 본문·revision 1·language NULL을 commit한 뒤 mixed `isFixed=true`/`after fixed` 요청을 재개했다.
|
||||
현재 구현에서 `contentRevision` 2 assertion은 실제 1로 실패했다.
|
||||
- GREEN: `updateCommunityPostFixed`의 비잠금 조회를 소유권 조건 `findByIdAndMemberIdForUpdate`와
|
||||
`EntityManager.refresh(..., PESSIMISTIC_WRITE)`로 교체했다. facade의 기존 member lock 뒤 post lock 순서는 바꾸지 않았다.
|
||||
- 동시성 test: 기존 본문 race의 test-only JPQL lock 대체를 제거하고 두 worker start gate와 실제 production repository query를 사용한다.
|
||||
새 race는 concurrent content worker가 실제 lock 호출 직전임을 latch로 알리고 commit 완료 뒤 mixed request를 재개한다.
|
||||
모든 executor는 release 뒤 `shutdown`과 `awaitTermination`으로 종료를 확인한다.
|
||||
- focused: P2 명령에 `--rerun-tasks`를 추가해 재실행 — 67 tests, exit 0, `BUILD SUCCESSFUL`.
|
||||
- P1 회귀: P1 명령에 `--rerun-tasks`를 추가해 재실행 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 형식: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- diff: `GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- 범위: P2-T2/P3, API schema, `@DynamicUpdate`/`@Version`, dependency, Docker, HTTP, 실제 MySQL 검증은 변경하거나 실행하지 않았다.
|
||||
|
||||
- [x] **Task 2.2: 레거시·v2 상세의 저장 번역 표시와 누락 예약**
|
||||
|
||||
**Goal ID / objective:** `P2-T2` — 상세에서 원문을 즉시 제공하고 요청 언어의 누락 번역만 예약한다.
|
||||
|
||||
- 시작 조건: `P2-T1` 완료, PRD §5 상세 계약 확인.
|
||||
- 완료 증거: 두 상세 API의 기존 응답 구조·언어 처리·권한·별도 쓰기 트랜잭션·비동기 폴백 테스트 통과.
|
||||
- 범위 밖: 목록 연결, 댓글 API에서 번역 예약, 번역 상태/원문 필드 추가.
|
||||
- 소비 인터페이스: 권한 확인 후 `findDisplayContents`를 조회하고, 유효 번역이 없을 때만
|
||||
`requestTranslations(postId, langContext.lang.code)`를 호출한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/SelectCommunityPostResponse.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/application/CreatorChannelCommunityFacade.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/domain/CreatorChannelCommunityQueryPolicy.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` |
|
||||
| 생성 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt` |
|
||||
|
||||
- [x] **RED:** `Accept-Language: ja`에서 기존 언어 미확정 글의 최초 상세는 원문, 감지 후 일본어만 예약,
|
||||
완료 후 일본어 제공을 테스트한다. 같은 언어·반복 상세·차단·비활성·미구매·댓글 API도 포함한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령에서 저장 번역 미사용/누락 예약 부재를 확인한다.
|
||||
- [x] **GREEN:** 기존 권한 확인 뒤 상세에서 유효 번역이 없을 때만 공유 쓰기 진입점을 호출한다. 읽기 전용 부모 트랜잭션과 분리하고
|
||||
번역 선택은 기존 마스킹 전에 DTO/레코드 `copy(content = ...)`로 반영한다. 원문 엔티티는 조회 중 변경하지 않는다.
|
||||
- [x] **GREEN 확인:** 실제 HTTP 계약 테스트와 중복 상세 경합에서 정상 응답·중복 작업 방지·원문 즉시 응답을 확인한다.
|
||||
- [x] **REFACTOR:** 같은 내부 조회를 사용하는 댓글/답글이 예약 진입점을 호출하지 않는지 회귀 검사한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest'
|
||||
```
|
||||
|
||||
#### P2-T2 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: 상세 경로에 번역 선택과 예약 연결이 없던 상태에서 legacy/v2 focused test의 저장 번역 미사용과 누락 예약 assertion 실패를 확인했다.
|
||||
컴파일 오류는 RED 증거로 사용하지 않았으며, 새 통합 test의 `viewerId` named argument를 실제 `memberId` 계약에 맞춰 바로잡았다.
|
||||
- GREEN: `CreatorCommunityService`와 `CreatorChannelCommunityQueryService`는 권한/차단 확인 뒤
|
||||
`findDisplayContents(...)[postId] ?: content`를 선택하고 `requestTranslations(postId, 요청 언어)`를 호출한다.
|
||||
선택한 본문은 기존 유료 마스킹 전 DTO/record `copy(content = ...)`에만 반영하며, 엔티티 원문은 변경하지 않는다.
|
||||
- 통합: `CreatorCommunityTranslationEndToEndTest`는 저장된 번역의 유료 마스킹 전 선택과, 언어 NULL 원문의 즉시 폴백 및
|
||||
`targetLanguage=ja` 단일 감지 이벤트를 실제 JPA 경계에서 확인했다.
|
||||
- HTTP 계약: legacy/v2 detail test는 번역 존재/누락, 구매자·소유자의 전체 본문, 미구매자의 기존 마스킹,
|
||||
차단·비활성 경로, 댓글·답글의 예약 비진입을 확인했다. 같은 언어 예약 없음과 동시 예약의 유일성은 기존
|
||||
`CreatorCommunityTranslationServiceTest`/통합 test로 회귀했다.
|
||||
- focused: 위 명령 — exit 0, `BUILD SUCCESSFUL`.
|
||||
- P1 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceIntegrationTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
- 호출자 회귀: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.home.application.CreatorChannelHomeQueryServiceTest'`
|
||||
— exit 0, `BUILD SUCCESSFUL`.
|
||||
|
||||
#### Task 2.R: P2-T2 실제 상세 파이프라인 증거 보강
|
||||
|
||||
**Goal ID / objective:** `P2-R3` — 실제 Spring 프록시 상세 조회가 감지, 단일 작업 예약, 재료화와 유효 번역 재사용을 올바르게 연결한다.
|
||||
|
||||
- 시작 조건: `P2-T2` 완료, 상세 경로의 실제 transaction 증거 보강 요구 확인.
|
||||
- 완료 증거: 원문 즉시 반환, 감지 후 요청 언어 하나의 job, 반복·동시 상세의 단일 job, memory 재료화 후 번역 반환,
|
||||
유효 번역 상세의 예약 미호출을 확인한다.
|
||||
- 범위 밖: 목록/홈, 외부 감지·번역 API, Docker, live HTTP, 실제 MySQL 검증.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 확인 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
||||
|
||||
- [x] **RED:** 병렬 상세 응답 본문 검증은 `ExecutorService.submit { ... }`가 `Runnable` 오버로드를 선택해
|
||||
`Future.get()`이 `null`을 반환하며 실패하는 것을 확인한다. 이는 production 동작이 아닌 수용 테스트의 반환값 폐기 문제다.
|
||||
- [x] **GREEN:** `Callable`을 명시해 두 병렬 상세의 실제 원문 반환과 translation job 1개를 검증한다.
|
||||
- [x] **GREEN 확인:** 실제 `CreatorCommunityService`, `LanguageDetectListener`, `ResourceTranslationJobScheduler`,
|
||||
`TranslationReadModelMaterializer` 경로에서 원문 즉시 반환, `ko` job 하나, memory 재료화 후 번역 반환을 확인한다.
|
||||
legacy/v2 unit test는 유효 번역의 문자열이 원문과 같아도 `requestTranslations`를 호출하지 않음을 확인한다.
|
||||
- [x] **REFACTOR:** P2 focused, P1 번역/감지 회귀, Phase 2 Gate를 실행한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceIntegrationTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
||||
```
|
||||
|
||||
#### P2-R3 검증 기록 — 2026-09-10
|
||||
|
||||
- RED: 병렬 lambda의 반환값을 확인하는 assertion은 `expected: [detail source content, detail source content] but was: [null, null]`로 실패했다.
|
||||
`submit`의 `Runnable` 오버로드가 반환값을 폐기한 것이 원인이었고, production 상세 응답이나 DB 본문 손실은 아니었다.
|
||||
- GREEN: `Callable` 명시 뒤 실제 두 상세 응답은 원문을 반환하고, 감지 완료 후 `ko` translation job은 하나만 유지됐다.
|
||||
수동으로 저장한 translation memory를 실제 materializer가 적용한 뒤 상세는 번역문을 반환했다.
|
||||
- 유효 저장 번역: legacy/v2 상세 unit test는 `CreatorCommunityDisplayContent.hasValidTranslation`으로 판정하므로,
|
||||
번역문이 원문과 같아도 `requestTranslations`를 호출하지 않는다. 실제 상세 파이프라인 test는 재료화 뒤 번역문을 반환하고
|
||||
job 수가 증가하지 않음을 확인했다.
|
||||
- focused: 첫 번째 명령 — exit 0, `BUILD SUCCESSFUL` (41초).
|
||||
- P1 회귀: 두 번째 명령 — exit 0, `BUILD SUCCESSFUL` (26초).
|
||||
- Phase 2 Gate: 세 번째 명령 — exit 0, `BUILD SUCCESSFUL` (1분 30초).
|
||||
- latch RED: `CreatorCommunityLanguageDetectTest.shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned`에서 A를 멈춘 뒤 B가 다음 task의 latch를 준비하게 했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.shouldNotCompleteNextTaskWhenPreviousTaskFinishesAfterLatchIsReassigned'`는
|
||||
exit 1, `expected: <false> but was: <true>`로 실패했다. A 종료가 `completion.get()`으로 B의 latch를 감소시킨 것이 원인이었다.
|
||||
- latch GREEN: decorator 생성 시점의 `taskCompletion`을 캡처해 해당 task 종료에만 사용하도록 했고, latch regression과 상세 파이프라인 detector는 `finally`에서 release하도록 정리했다.
|
||||
`./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL` (20초).
|
||||
- P2 재회귀: 첫 번째 명령 — exit 0, `BUILD SUCCESSFUL` (44초).
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (17초).
|
||||
|
||||
#### Phase 2 Gate
|
||||
|
||||
- [x] **Goal `P2-GATE`: 생성·수정·기존 글 상세 흐름 판정**
|
||||
|
||||
- 시작 조건: Phase 2 두 Task 완료.
|
||||
- 완료 증거: 생성→커밋→감지→번역 저장과 기존 글 상세→요청 언어만 예약의 실제 통합 결과.
|
||||
- 범위 밖: 목록/홈 구현. TDD 예외 사유: 앞선 Task의 사용자 흐름 검증 Gate다.
|
||||
- 대체 검증: 테스트 HTTP 요청과 실제 커밋 후 DB 상태를 대조하고 롤백 케이스에 작업이 없는지 확인한다.
|
||||
- 확인 파일: Phase 2의 두 EndToEndTest, 서비스 테스트, PRD §4~5.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
||||
```
|
||||
|
||||
#### P2-GATE 검증 기록 — 2026-09-10
|
||||
|
||||
- gate: 위 명령 — exit 0, `BUILD SUCCESSFUL`. 생성·수정·기존 글 상세, 레거시 서비스/JPA 및 v2 MockMvc 계약, 관리자 공용 경로를 함께 확인했다.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- diff: `GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- 범위: local automatic 정책에 따라 Docker, live server/curl, 실제 Papago, 실제 MySQL DDL·동시성은 실행하지 않았다.
|
||||
HTTP 동작은 자동 MockMvc 통합 테스트로만 검증했다.
|
||||
|
||||
### Phase 3: 목록·홈·미리보기 번역과 최종 검증
|
||||
|
||||
#### 구현 항목
|
||||
|
||||
- [x] **Task 3.1: 레거시 목록과 v2 커뮤니티·채널 홈에 저장 번역 적용**
|
||||
|
||||
**Goal ID / objective:** `P3-T1` — 커뮤니티 목록과 채널 홈이 작업 예약 없이 저장된 번역을 제공한다.
|
||||
|
||||
- 시작 조건: `P2-GATE` 완료.
|
||||
- 완료 증거: 언어별 목록·최신 목록·고정/일반 채널 홈, 유료 마스킹, 추가 N+1 없음, 감지/작업 증가 0건.
|
||||
- 범위 밖: 추천/팔로잉 소식, 정렬·페이지·고정 정책 변경.
|
||||
- 소비 인터페이스: `findDisplayContents`만 호출. 레거시 DTO와 v2 레코드를 표시 직전에 복사해 기존 마스킹을 재사용한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/community/adapter/in/web/CreatorChannelCommunityEndToEndTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt` |
|
||||
|
||||
- [x] **RED:** 번역 존재/누락·이전 개정 혼합 페이지와 무료·유료·소유자·구매자 응답을 테스트한다.
|
||||
이모지 포함 유료 본문에서 기존 코드포인트 기준 미리보기를 확인한다.
|
||||
- [x] **RED 확인:** 아래 명령으로 번역 미사용 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 페이지별 번역 일괄 조회 후 기존 마스킹 전에 반영한다. 채널 홈은 이미 호출하는
|
||||
`findHomeCommunityPosts` 공통 경로를 통해 고정/일반 게시물을 모두 처리한다.
|
||||
- [x] **GREEN 확인:** 목록만 호출했을 때 감지 이벤트/번역 작업 생성 0건, 페이지·개수·순서 유지,
|
||||
페이지 크기에 비례한 추가 번역 SELECT 증가 없음까지 확인한다.
|
||||
- [x] **REFACTOR:** 기존 권한·홈 응답 회귀와 focused test 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'
|
||||
```
|
||||
|
||||
##### 실행·검증 기록 — 2026-09-10
|
||||
|
||||
- 무엇을: 레거시 목록·최신 목록, v2 커뮤니티 탭, 채널 홈의 고정/일반 게시물에 저장된 표시 번역을 한 번에 조회해 본문에 복사했다.
|
||||
상세 경로의 작업 예약 정책은 변경하지 않았고, 목록/홈에서는 `findDisplayContents`만 호출한다.
|
||||
- 왜: 목록 응답은 번역 누락 시 원문으로 안전하게 폴백하면서도 감지·번역 작업을 만들지 않아야 한다.
|
||||
- RED 확인: 새 테스트 6개를 대상으로 `./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldUseStoredTranslationsOnceForLegacyCommunityListBeforePaidMasking' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityServiceTest.shouldUseStoredTranslationsForVisibleLatestCommunityPostsWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest.shouldUseStoredTranslationsOnceForCommunityTabBeforePaidMasking' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.application.CreatorChannelCommunityQueryServiceTest.shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPostsWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest.shouldUseStoredTranslationsForCommunityTabWithoutScheduling' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest.shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPosts'`를 실행했다.
|
||||
exit 1의 기대된 assertion 실패로 번역 적용 전 원문이 반환됨을 확인했다.
|
||||
- GREEN/영향 범위 회귀: 위 focused 명령 — exit 0, `BUILD SUCCESSFUL` (1분 3초). 저장 번역·원문 폴백·이전 개정 무시,
|
||||
유료 미구매자/구매자/소유자 마스킹, 차단 필터 후 일괄 조회, 목록/홈 호출의 감지·번역 작업 미생성을 단위·MockMvc 통합 테스트로 확인했다.
|
||||
- 형식: `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (45초).
|
||||
- 전체 회귀와 수동 HTTP: 공통 번역 API 또는 모든 Phase를 변경하지 않았으므로 직접 영향받은 package/API 회귀까지만 실행했다.
|
||||
운영과 분리된 DB·Redis·S3·Papago 및 테스트 계정이 준비되지 않아 live server/curl·실제 Papago 검증은 실행하지 않았고,
|
||||
HTTP 응답은 MockMvc 통합 테스트로 검증했다.
|
||||
- 추가 실제 통합 증거: `CreatorCommunityTranslationEndToEndTest`는 실제 `CreatorCommunityTranslationService`와 H2 저장소를 사용한다.
|
||||
영속성 컨텍스트와 Hibernate 통계를 매 배치 전 초기화한 뒤 1개와 4개 ID를 각각 읽어, 게시글 `IN` 조회 1회와
|
||||
`creator_community_translation` 조회 1회의 합계인 prepared statement 2회를 동일하게 확인했다.
|
||||
따라서 페이지 크기는 번역 읽기 SELECT 수를 늘리지 않는다.
|
||||
- 추가 부작용 증거: 같은 실제 읽기 서비스를 레거시 목록의 번역 누락 게시물에 연결하고, 실제 `translation_job` 행 수의 전후 동일성과
|
||||
`ApplicationEventPublisher`·재료화기·resource scheduler의 무상호작용을 확인했다. 읽기 서비스 자체는 mock으로 대체하지 않았다.
|
||||
v2 탭의 mock 경계 테스트는 별도로 반환 50개 ID만 전달하고 51번째 `fetchLimit + 1` ID를 제외함을 확인한다.
|
||||
- TDD 예외: 이번 추가는 이미 올바른 production 동작을 직접 측정하는 증거 테스트뿐이므로 RED assertion을 만들 수 없었다.
|
||||
새 테스트는 즉시 통과했으며 production P3-T1 파일은 변경하지 않았다.
|
||||
- 재검증: `./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest'` — exit 0, `BUILD SUCCESSFUL` (1분 18초).
|
||||
`./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (17초).
|
||||
|
||||
- [x] **Task 3.2: 홈 추천과 팔로잉 소식 본문에 저장 번역 적용**
|
||||
|
||||
**Goal ID / objective:** `P3-T2` — 홈 추천·팔로잉 소식의 커뮤니티 본문과 파생 미리보기가 요청 언어로 표시된다.
|
||||
|
||||
- 시작 조건: `P3-T1` 완료.
|
||||
- 완료 증거: 두 API의 번역/원문 폴백·일괄 조회·작업 미생성·다른 소식 유형 불변 테스트.
|
||||
- 범위 밖: 추천 스냅샷/순위 변경, FCM·알림 이력·오디오 소식 번역.
|
||||
- 소비 인터페이스: `LangContext`의 언어와 `findDisplayContents`. 추천 상세 레코드/팔로잉 도메인의 커뮤니티 항목만 복사한다.
|
||||
|
||||
| 작업 | 정확한 파일 경로 |
|
||||
|---|---|
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt` |
|
||||
| 수정 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryService.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/adapter/out/persistence/DefaultHomeFollowingQueryRepository.kt` |
|
||||
| 확인 | `src/main/kotlin/kr/co/vividnext/sodalive/v2/home/following/domain/HomeFollowing.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/home/following/application/HomeFollowingQueryServiceTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacadeTest.kt` |
|
||||
| 수정 | `src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/following/adapter/in/web/HomeFollowingEndToEndTest.kt` |
|
||||
|
||||
- [x] **RED:** 일본어·영어 요청의 인기 커뮤니티/팔로잉 소식, 누락·이전 개정 폴백,
|
||||
비커뮤니티 항목 유지, 조회만으로 작업이 늘지 않는 테스트를 작성한다.
|
||||
- [x] **RED 확인:** 아래 focused 명령으로 해당 응답이 원문에 머무르는 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 이미 필터링된 게시물 ID를 모아 공유 읽기 메서드로 처리한다. 팔로잉 서비스에 기존 `LangContext`를 전달한다.
|
||||
본문은 DB 게시물의 현재 내용에서 가져오므로 과거 소식 발행 문자열을 번역 소스로 쓰지 않는다.
|
||||
- [x] **GREEN 확인:** 언어 전환·익명 허용 추천·인증된 팔로잉 흐름을 검증하고 기존 필터/추천 순서를 대조한다.
|
||||
- [x] **REFACTOR:** 변경된 생성자 호출 테스트를 갱신하고 두 기능의 직접 영향 회귀 결과를 기록한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.v2.recommendation.application.HomeRecommendationQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.home.following.application.HomeFollowingQueryServiceTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.HomeRecommendationControllerTest' --tests 'kr.co.vividnext.sodalive.v2.api.home.following.adapter.in.web.HomeFollowingEndToEndTest'
|
||||
```
|
||||
|
||||
##### 실행·검증 기록 — 2026-09-10
|
||||
|
||||
- 무엇을: 인기 커뮤니티 추천의 최종 노출 게시물과 팔로잉 최근 소식의 커뮤니티 게시물만 요청 언어의 저장 본문으로 복사했다.
|
||||
추천 중복 제거·limit와 팔로잉 필터·순서·다른 소식 유형은 변경하지 않았다.
|
||||
- RED 확인: 서비스·API 테스트 4개가 번역 연결 전 원문 본문 assertion으로 실패함을 확인했다.
|
||||
- GREEN/회귀: 관련 5개 테스트 클래스에 `--rerun-tasks`를 적용해 exit 0, `BUILD SUCCESSFUL` (2분 38초)을 확인했다.
|
||||
일본어 비회원 추천, 영어 인증 팔로잉, 현재 원문 폴백, 최종 ID 단일 배치, 비커뮤니티 불변을 검증했다.
|
||||
- 리뷰: 명세 리뷰 `APPROVED` (`ses_f7757315dffeoDl3Ef2bhCO684`), 품질 리뷰 `APPROVED` (`ses_f774b8bf4ffehbjDvQWo6yE84S`).
|
||||
- 형식/diff: `./gradlew ktlintCheck --rerun-tasks` — exit 0, `BUILD SUCCESSFUL` (26초).
|
||||
`GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- 범위: Docker, live HTTP, 실제 Papago/MySQL은 실행하지 않았으며 테스트 브랜치 배포 후 수동 체크리스트에서 확인한다.
|
||||
|
||||
#### Task P3-R1: DDL 해시 컬럼 타입 정합화
|
||||
|
||||
- [x] **Goal `P3-R1`: `source_hash` DDL을 Hibernate 엔티티 매핑과 일치시킨다**
|
||||
|
||||
- 시작 조건: `P3-GATE` 코드 품질 리뷰에서 `CHAR(64)`와 `VARCHAR(64)` 불일치가 차단 이슈로 확정됨.
|
||||
- 완료 증거: `schema.sql`의 `source_hash VARCHAR(64)` 확인, `./gradlew tasks --all`, `git diff --check`, 해당 delta 코드 품질 재검토 통과.
|
||||
- 범위 밖: 엔티티 매핑 변경, 실제 MySQL DDL 적용, 비차단 로그·테스트 종료 개선.
|
||||
- RED: `rg -n 'source_hash VARCHAR\(64\)' schema.sql`이 일치 항목 없이 실패하는지 확인한다.
|
||||
- GREEN: DDL 한 줄만 `VARCHAR(64)`로 변경하고 동일 명령이 성공하는지 확인한다.
|
||||
- REFACTOR: 추가 구조 변경 없이 문서 상태와 Gate 증거만 동기화한다.
|
||||
- 수정 파일: `docs/20260910_크리에이터커뮤니티게시물본문번역/schema.sql`, `prd.md`, `plan-task.md`.
|
||||
|
||||
##### 실행·검증 기록 — 2026-09-10
|
||||
|
||||
- RED: `rg -n 'source_hash VARCHAR\(64\)' schema.sql` — 일치 항목 없이 exit 1.
|
||||
- GREEN: `source_hash`를 엔티티의 `String` + `@Column(length = 64)`와 같은 `VARCHAR(64)`로 변경했고 동일 `rg`가 11행을 반환했다.
|
||||
- 회귀: `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`; `GIT_MASTER=1 git diff --check` — exit 0.
|
||||
- delta 품질 재검토: `PASS` (`ses_f772f064cffeKa532hsf1Pa3GU`). 실제 MySQL 적용과 `ddl-auto: validate` 기동 확인은 수동 검증에 유지한다.
|
||||
|
||||
#### Phase 3 Gate
|
||||
|
||||
- [x] **Goal `P3-GATE`: 전체 노출 표면과 비동기 정합성 최종 판정**
|
||||
|
||||
- 시작 조건: `P3-T1`, `P3-T2` 및 이전 Gate의 증거 완료.
|
||||
- 완료 증거: 아래 자동 검증, PRD `CCT-001~010` 대조 및 5개 리뷰 레인 통과. HTTP·실제 MySQL/Papago는 배포 후 수동 체크리스트로 분리한다.
|
||||
- 범위 밖: 테스트 삭제/완화, 운영 데이터 번역/배포, 다른 기능의 결함 수정.
|
||||
- TDD 예외 사유: 기능 추가가 아닌 전체 수용 기준 검증 Gate다.
|
||||
- 대체 검증: 자동 회귀, 격리된 실행 서버의 HTTP 응답/DB 작업 수 대조, 문서와 실제 동작 대조.
|
||||
- 확인 파일: 모든 Task의 변경 파일, `prd.md`, `plan-task.md`, `schema.sql`.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.i18n.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.home.*' --tests 'kr.co.vividnext.sodalive.v2.recommendation.*' --tests 'kr.co.vividnext.sodalive.v2.home.following.*' --tests 'kr.co.vividnext.sodalive.v2.api.home.*' --tests 'kr.co.vividnext.sodalive.v2.api.admin.aicharacter.community.*'
|
||||
./gradlew ktlintCheck
|
||||
./gradlew bootJar
|
||||
```
|
||||
|
||||
기본 최종 검증은 위 직접 영향 범위다. 이번 변경이 공통 번역 API의 기존 리소스 동작까지 바꾸거나,
|
||||
위 범위로 판정할 수 없는 실패가 발견되거나, 사용자/릴리스 기준이 전체 검증을 요구하면 `./gradlew test`를 추가한다.
|
||||
전체 테스트 생략 시 공통 번역 및 영향받은 각 기능을 위 명령으로 검증했다는 근거와 실제 결과를 기록한다.
|
||||
|
||||
##### 최종 Gate 기록 — 2026-09-10
|
||||
|
||||
- 직접 영향 테스트 — clean 후 `--no-parallel` 순차 실행, exit 0, `BUILD SUCCESSFUL` (5분 20초). 53개 클래스 493개 테스트, 실패·오류·skip 0을 XML에서 확인했다.
|
||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL` (20초). `./gradlew bootJar` — exit 0, `BUILD SUCCESSFUL` (1분 31초).
|
||||
- `GIT_MASTER=1 git diff --check` — exit 0. 최초 병렬 Gradle의 kapt 출력 경합은 clean 후 순차 성공으로 대체했다.
|
||||
- 최종 리뷰: 목표·제약 PASS, 자동 QA PASS, 보안 PASS, 문맥 PASS. 코드 품질의 DDL 타입 차단 이슈는 `P3-R1`로 수정 후 delta 재검토 PASS.
|
||||
- 비차단 관찰: 번역 예약 실패 로그 분류, 일부 경합 테스트 executor 종료 확인, 언어 감지 완료 전 반복 상세 요청의 호출 증폭은 이번 범위에서 변경하지 않았다.
|
||||
- 간접 소비자: `LiveApiService`, `ExplorerService`도 기존 목록 서비스를 통해 저장 번역을 사용하며 추가 예약은 하지 않는다.
|
||||
- 로컬 자동 Gate는 완료했다. 실제 DDL·MySQL 격리 수준·HTTP·Papago 검증은 아래 테스트 서버 수동 체크리스트에서 미완료로 유지한다.
|
||||
|
||||
### 테스트 브랜치 배포 후 수동 체크리스트
|
||||
|
||||
수동 HTTP 검증은 운영과 분리된 DB·Redis·S3/Papago 설정 및 테스트 계정이 준비된 테스트 서버에서 실행한다.
|
||||
기존 `./gradlew bootRun`을 사용하며 토큰·키·본문 전문은 기록물에서 제거한다.
|
||||
|
||||
1. `schema.sql`을 테스트 DB에 적용한다. 기존 행은 `language_code` NULL, `content_revision` 0, 원문이 유지되고,
|
||||
`creator_community_id + locale` 유일 키가 생성됐는지 확인한다.
|
||||
2. 같은 게시물·locale의 번역 요청을 동시에 보낸다. duplicate-key 오류나 API 실패 없이, 현재 원문과 일치하는 번역 행이 정확히 1개인지 확인한다.
|
||||
3. 언어 NULL인 기존 무료 게시물로 PRD §5의 모든 목록/홈 API를 조회한다. 원문 표시, 감지/번역 작업 증가 0건을 확인한다.
|
||||
4. 같은 글을 `Accept-Language: ja`로 상세 조회한다. 최초 원문 응답, 감지 후 일본어 작업만 예약,
|
||||
워커 완료 후 상세/목록/채널 홈/추천/팔로잉에서 저장 번역 사용을 확인한다.
|
||||
5. 한·영·일 원문의 신규 게시물과 실제 본문 수정을 실행한다. 다른 두 언어 생성,
|
||||
수정 직후 현재 원문 폴백, 지연된 이전 감지/번역 결과 미노출, A→B→A 메모리 복원을 확인한다.
|
||||
6. 유료 글을 미구매자/구매자/소유자로 조회하고 번역 선택 후 기존 마스킹이 적용되는지 확인한다.
|
||||
차단·성인·비활성·댓글/답글 노출과 미디어 URL 규칙은 기존 결과와 대조한다.
|
||||
7. 감지/번역 provider 실패와 반복 상세 요청을 재현한다. 원문 응답, 기존 재시도 정책, 작업 중복 방지,
|
||||
페이지 크기 증가 시 추가 번역 조회 수가 선형으로 늘지 않음을 확인한다.
|
||||
|
||||
#### P1-R4 실제 MySQL 수동 확인 · 2026-09-10 추가
|
||||
|
||||
- [ ] 테스트 서버가 실제 MySQL 8/InnoDB인지 확인하고 버전·테이블 엔진·세션 격리 수준을 기록한다.
|
||||
감지 캐시의 해시/provider/normalizationVersion 유일 키와 audit 컬럼의 타입·NULL/default 조건을 확인한다.
|
||||
native INSERT 후 생성/수정 시각이 채워지고 중복 upsert가 승자 감지값과 생성 시각을 덮어쓰지 않는지 대조한다.
|
||||
- [ ] 언어 NULL이고 해당 캐시 키가 없는 동일 한국어 게시물에 `ja`/`en` 상세 요청을 보낸다.
|
||||
테스트용 provider 제어로 두 miss와 provider 진입을 확인한 뒤 첫 감지 저장·커밋·`ja` 예약을 완료하고
|
||||
후발 감지를 해제한다. 실제 `REPEATABLE READ`에서 예외/rollback 없이 두 흐름이 끝나고 캐시 1행,
|
||||
`ja`/`en` 작업이 모두 남는지 확인한다. 단순 동시 HTTP 요청만으로 이 실행 순서를 증명했다고 보지 않는다.
|
||||
- [ ] 서로 다른 게시물을 동일 본문으로 생성하고 캐시 miss 두 건의 저장 순서를 같은 방식으로 제어한다.
|
||||
공통 캐시 1행과 승자 결과 재사용, 각 게시물의 원문 외 두 언어 작업 보존을 확인한다.
|
||||
- [ ] provider 감지 응답을 차단한 동안 별도 연결에서 해당 게시물 본문 수정을 커밋할 수 있는지 확인한다.
|
||||
수정 후 개정 증가·언어 초기화와 현재 원문 폴백을 확인하고 이전 감지를 해제한다. 오래된 결과가
|
||||
새 본문·개정·언어·번역을 덮어쓰거나 이전 원문의 작업을 새 개정에 적용하지 않는지 확인한다.
|
||||
|
||||
필요한 테스트 환경이 없으면 수동 검증은 미완료로 기록한다. MockMvc/provider 대체 테스트를 실제 Papago 검증으로 표기하지 않는다.
|
||||
실제 Papago 확인은 비민감한 테스트 문장을 사용하며 수동 검증 환경에서만 수행한다.
|
||||
|
||||
## Progress와 검증 기록
|
||||
|
||||
### 문서 작성 — 2026-09-10
|
||||
|
||||
- 상태: PRD·구현 계획 작성. 구현 Task/Gate는 모두 미실행.
|
||||
- 무엇을: 사용자 인터뷰 결정과 레거시/v2 생성·상세·목록·홈 경로를 이 계획의 6개 Task에 연결했다.
|
||||
- 왜: 구현 전 모호한 범위를 없애고 정확한 변경 위치·수용 기준·TDD 순서를 제공하기 위해서다.
|
||||
- 실행 확인: `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`. `test`, `bootRun`, `bootJar`, `ktlintCheck` task를 확인했다.
|
||||
- 관찰: 기존 Gradle 설정에서 Gradle 9.0 비호환 예정 deprecation 안내가 출력됐다. 이번 문서 범위에서 변경하지 않는다.
|
||||
- 미실행: 코드 구현, 신규 테스트 작성/실행, 서버 실행, Papago 호출, DDL 작성/적용, 운영 작업.
|
||||
- 테스트 생략 근거: 제품 코드를 변경하지 않는 문서 작업이다. 미래 테스트 명령을 계획에 적은 것은 테스트 통과 증거가 아니다.
|
||||
- 남은 실행 조건: 사용자의 별도 구현 요청과 각 Task의 검증 환경 확보.
|
||||
|
||||
### 문서 검증 — 2026-09-10
|
||||
|
||||
- `./gradlew tasks --all` — 문서 생성 후 재실행, exit 0, `BUILD SUCCESSFUL`.
|
||||
- 문서 경로·링크·템플릿 잔여값·공백 검사 — exit 0, `Document validation: PASS`.
|
||||
기존 파일 참조가 존재하며 미래 생성 파일 7개는 생성 항목으로 구분했다.
|
||||
- 요구사항/계획 대조 — 요구사항 10개, 구현 Task 6개, Gate 3개가 연결되어 있고 구현 완료 체크는 없다.
|
||||
- `git diff --check` — exit 0. 신규 미추적 문서의 공백은 별도 문서 검사로 확인했다.
|
||||
- `git -c core.quotePath=false status --short --untracked-files=all` — 이 작업 폴더의 `prd.md`, `plan-task.md` 두 파일만 추가됨을 확인했다.
|
||||
|
||||
### P2-R3 최종 검증 — 2026-09-10
|
||||
|
||||
- `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`; `test`, `ktlintCheck`, `build` task가 유효함을 확인했다.
|
||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`. P2 변경 테스트의 import 순서와 `Callable` 호출 줄바꿈을 정정했다.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler'`
|
||||
— exit 0, `BUILD SUCCESSFUL`. 실제 Spring 상세 파이프라인에서 원문 즉시 반환, 단일 job, 재료화 후 번역 반환을 확인했다.
|
||||
- `./gradlew build` — exit 0, `BUILD SUCCESSFUL` (8분 16초). 컴파일, 전체 테스트, ktlint, 패키징을 포함한다.
|
||||
- `GIT_MASTER=1 git diff --check` — exit 0. Docker, live HTTP, 실제 Papago, 실제 MySQL 검증은 실행하지 않았다.
|
||||
|
||||
### Phase별 후속 정적 리뷰 — 2026-09-10
|
||||
|
||||
- 기준: 인터뷰를 반영한 `prd.md`와 이 계획, 현재 source/test/DDL. HEAD와 작업 트리 지문은
|
||||
[리뷰 증거](reviews/review-evidence.md)에 기록했다.
|
||||
- Phase 1: 감지 캐시 최초 INSERT 경합으로 후발 target 예약이 누락되는 정적 경로 1건 확정.
|
||||
`REV-P1-007`을 신규 `P1-R4`로 추가했다. 기존 완료 기록과 이전 리뷰는 유지했다.
|
||||
- Phase 2: 생성·실제 본문 수정·상세 단일 언어 예약·권한/마스킹 기준 충족. 자체 추가 수정 사항 없음.
|
||||
Phase 1 결함의 상세 영향은 `P1-R4`에서 처리한다.
|
||||
- Phase 3: 목록·채널 홈·추천·팔로잉 저장 번역 및 비예약 기준 충족. 신규 Task 없음.
|
||||
- 실행 제약: 사용자 요청대로 테스트·컴파일·서버·HTTP·Papago·DB 재현은 실행하지 않았다.
|
||||
기존 XML 성공 기록은 과거 실행 증거로만 읽었다.
|
||||
- 검증 한계: 실제 캐시 저장 경합은 새 Task의 RED로 재현할 예정이다. 한·영·일 전체 생성→워커 통합 검증과
|
||||
실제 MySQL/Papago/HTTP는 기존 테스트 서버 수동 확인 범위로 남겼다. 구성요소 테스트 통과를 실환경 전체 흐름 통과로 확대하지 않는다.
|
||||
- 문서 검증: `./gradlew tasks --all` — exit 0(task 목록 조회만 수행, 테스트·컴파일 미실행).
|
||||
`git diff --check`와 문서 링크/공백 검사 통과. 리뷰 전후 source/test/DDL 32개 파일 지문이 같음을 확인했다.
|
||||
|
||||
### P1-R4 문서 동기화 · 2026-09-10
|
||||
|
||||
- 완료된 RED/GREEN·P1/P2 영향 회귀·spec/quality 리뷰를 P1-R4 실행 기록과 리뷰 문서 두 곳에 누적했다.
|
||||
기존 정적 리뷰 지문과 과거 기록은 당시 증거로 보존하며 실제 MySQL 수동 확인 네 항목은 미완료로 추가했다.
|
||||
- 문서 검증: `./gradlew tasks --all`, exit 0, `BUILD SUCCESSFUL` (2초). `test`, `ktlintCheck` task를 확인했다.
|
||||
`rg`로 P1-R4 Goal 및 RED/GREEN/REFACTOR 완료 체크, 실행 시간·리뷰 출처·실환경 검증 보류 표기를 대조했다.
|
||||
- 이번 작업은 지정된 계획·리뷰 문서 3개만 수정했다. PRD·source·test·schema 변경이나 제품 테스트 재실행은 하지 않았다.
|
||||
|
||||
## 변경 관리
|
||||
|
||||
기술 선택은 PRD `DEC-009~010`, 제품 결정은 `DEC-001~008`을 따른다.
|
||||
후속 요구사항은 PRD 결정 기록 → 요구사항/API 계약 → 이 계획의 파일·검증·체크박스 순서로 갱신한다.
|
||||
미래 구현에서는 각 Task 아래에 실행일·무엇/왜/어떻게·정확한 명령·결과·남은 항목을 누적한다.
|
||||
새 테스트 클래스를 만들 때는 이 문서의 focused 실행 예시가 실제 클래스와 일치하는지도 갱신한다.
|
||||
@@ -1,176 +0,0 @@
|
||||
# 크리에이터 커뮤니티 게시물 본문 번역 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 및 로컬 자동 검증 완료 · 테스트 서버 수동 검증 대기 |
|
||||
| 작성일 / 최종 수정일 | 2026-09-10 |
|
||||
| 결정권자 | 요청 사용자 |
|
||||
| 산출물 범위 | 코드·자동 테스트·MySQL DDL 작성. 실제 DDL 적용·배포·Papago/HTTP 검증은 테스트 서버에서 수행한다. |
|
||||
| API 계약 | 별도 파일을 만들지 않고 이 문서의 API 계약 절에 통합 |
|
||||
| 기준 템플릿 | `docs/sample/sample-prd.md` |
|
||||
|
||||
## 1. 목표와 현재 동작
|
||||
|
||||
크리에이터 커뮤니티 게시물의 본문을 한국어·영어·일본어로 제공한다. 작성한 원문은 보존하고,
|
||||
원문 이외의 지원 언어 번역을 저장한다. 이용자는 요청 언어에 맞는 본문을 상세·목록·미리보기에서 읽는다.
|
||||
|
||||
현재 코드에서 확인한 사실:
|
||||
|
||||
| 근거 파일 | 확인한 동작 |
|
||||
|---|---|
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/explorer/profile/creatorCommunity/CreatorCommunity.kt` | 원문 `content`는 있으나 원문 언어와 번역 저장 구조는 없다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt` | 상세 조회에서 요청 언어의 번역을 조회한다. 번역이 없으면 해당 언어 작업을 예약하고 원문 응답을 유지한다. 원문 언어가 없는 경우에는 이 예약 분기에 들어가지 않는다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt` | 커밋 후 비동기 언어 감지, 언어 저장, 번역 이벤트 발행 흐름이 있다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/ResourceTranslationJobScheduler.kt` | 리소스의 원문 언어를 제외한 지원 언어 전체 또는 지정한 한 언어의 작업을 예약한다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobScheduler.kt` | 원문 언어가 `ko`, `en`, `ja`인 비어 있지 않은 텍스트만 예약한다. 동일 리소스·필드·목표 언어·원문 해시 작업은 중복 생성하지 않는다. |
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/i18n/translation/TranslationJobWorker.kt` | 저장된 작업을 처리하고 번역 메모리와 조회용 번역을 저장한다. 주기 기본값은 600000ms이며 실제 환경 설정에 따라 달라진다. |
|
||||
|
||||
이번 기능은 오디오 상세의 누락 번역 예약 방식을 따른다. 다만 기존 커뮤니티 게시물은 언어 정보 자체가 없으므로,
|
||||
상세 조회에서 비동기 언어 감지까지 연결해야 한다. 오디오 상세가 현재 언어 미확정 데이터도 감지한다고 가정하지 않는다.
|
||||
|
||||
## 2. 포함 범위와 제외 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- 본문 `content`만 번역하며 무료·유료 게시물에 같은 언어 정책을 적용한다.
|
||||
- 신규 게시물 저장 후 자동 감지·번역, 실제 본문 변경 후 언어 재감지·번역 갱신.
|
||||
- 기존 게시물은 상세 조회를 계기로 필요한 언어를 감지하고 요청 언어의 누락 번역을 예약한다.
|
||||
- 기존 커뮤니티 API와 v2 상세·목록, 채널 홈의 고정/일반 게시물, 홈 추천 인기 커뮤니티, 팔로잉 소식의 본문·미리보기.
|
||||
- 공유 생성·수정 서비스를 사용하는 AI 캐릭터 관리자 작성 경로도 같은 저장 처리를 적용한다.
|
||||
이는 별도 관리자 기능 추가가 아니라 동일 게시물의 생성·수정 누락 방지다.
|
||||
|
||||
### 제외
|
||||
|
||||
- 댓글·답글·첫 댓글, 이미지 내부 문자, 첨부 오디오 음성의 번역.
|
||||
- 기존 게시물 일괄 번역, 목록·홈·미리보기·댓글 조회에서 언어 감지 또는 번역 작업 예약.
|
||||
- 번역 수동 편집·언어 선택 입력·원문 보기 토글·번역 상태 API·실시간 완료 알림.
|
||||
- 푸시 알림 문구·알림 이력의 번역, 정산 화면과 관리자 편집용 원문 응답 변경.
|
||||
- 공통 언어 지원 범위, 다른 콘텐츠의 수정 정책, 추천 순위/스냅샷 생성 방식의 변경.
|
||||
|
||||
## 3. 확정 요구사항
|
||||
|
||||
| ID | 요구사항 | 수용 기준 | 연결 Goal |
|
||||
|---|---|---|---|
|
||||
| `CCT-001` | 원문을 보존하고 본문만 지원 언어로 번역해 저장한다. | 한국어·영어·일본어 각각을 원문으로 작성하면 다른 두 언어의 본문 번역이 저장되고 원문은 유지된다. 댓글과 첨부 파일은 바뀌지 않는다. | `P1-T1`, `P1-T2`, `P2-T1` |
|
||||
| `CCT-002` | 원문 언어가 없으면 본문으로 자동 감지한다. | 요청자의 앱 언어를 원문 언어로 추정하지 않는다. 감지 결과를 저장하고 번역으로 이어진다. | `P1-T2` |
|
||||
| `CCT-003` | 신규 게시물은 저장 성공 후 원문 이외의 지원 언어 번역을 예약한다. | 저장 롤백에는 감지/번역이 실행되지 않는다. 저장 응답이 외부 번역 완료를 기다리지 않는다. | `P2-T1` |
|
||||
| `CCT-004` | 실제 본문 수정 시 언어를 다시 감지하고 번역을 갱신한다. | 한국어에서 영어로 변경해도 새 언어와 새 본문으로 처리한다. 동일 본문 제출·이미지·고정·댓글 허용 여부만 변경하면 재번역하지 않는다. | `P2-T1` |
|
||||
| `CCT-005` | 기존 게시물은 상세 조회에서만 누락 번역을 예약한다. | 번역 미완료 상세는 원문을 반환한다. 원문 언어가 없으면 감지 후 해당 상세 요청 언어만 예약한다. 언어가 같으면 번역 작업은 없다. | `P2-T2` |
|
||||
| `CCT-006` | 상세·목록·미리보기에서 저장된 유효한 번역을 사용한다. | 레거시 목록/최신 목록, v2 커뮤니티 목록, 채널 홈, 홈 추천, 팔로잉 소식에 같은 언어의 저장 번역이 적용된다. 목록 계열만 조회하면 감지/번역 작업은 0건이다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
||||
| `CCT-007` | 번역이 없거나 실패했거나 현재 본문에 맞지 않으면 현재 원문을 제공한다. | 수정 커밋 후 새로 시작한 요청에서는 수정 전 번역을 반환하지 않는다. 늦게 도착한 이전 감지/번역 결과도 새 본문을 덮어쓰지 않는다. | `P1-T1`, `P1-T2`, `P2-T1`, `P3-GATE` |
|
||||
| `CCT-008` | 기존 조회 권한과 유료 본문 제한을 번역문에도 적용한다. | 차단·성인·비활성 필터와 구매/소유자 판정은 유지한다. 번역문을 선택한 다음 기존 미리보기 제한을 적용해 미구매자에게 전체 본문이 노출되지 않는다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
||||
| `CCT-009` | 기존 API 구조와 언어 결정 방식을 유지한다. | `Accept-Language`를 처리하는 `LangContext`를 사용한다. 응답 본문 문자열의 언어만 달라지고 필드·상태 코드·인증·페이지 규칙은 유지한다. | `P2-T2`, `P3-T1`, `P3-T2` |
|
||||
| `CCT-010` | 기존 감지 캐시·번역 메모리·큐·워커를 재사용한다. | 반복 상세 조회나 이전 본문으로의 복원에서 불필요한 번역 호출을 만들지 않는다. 목록 번역 조회는 게시물별 쿼리 대신 일괄 조회한다. | `P1-T1`, `P1-T2`, `P3-GATE` |
|
||||
|
||||
## 4. 처리 흐름
|
||||
|
||||
### 4.1 신규 작성과 본문 수정
|
||||
|
||||
1. 기존 검증·소유권 확인을 거쳐 원문을 저장한다. 본문 변경 시 이전 언어를 비우고 본문 개정 번호를 증가시킨다.
|
||||
2. 저장 트랜잭션 커밋 후 해당 본문의 언어를 비동기로 감지한다. 원문 정보가 이미 있으면 감지를 생략한다.
|
||||
3. 감지 결과가 현재 본문의 결과임을 확인하고 원문 언어를 저장한다.
|
||||
4. 지원 언어 중 원문을 제외한 언어를 예약한다. 기존 메모리를 재사용할 수 있으면 조회용 번역을 복원한다.
|
||||
5. 워커가 완료하면 다음 조회부터 번역문을 제공한다. 완료 전에는 현재 원문을 제공한다.
|
||||
|
||||
### 4.2 기존 게시물 상세 조회
|
||||
|
||||
1. 기존 접근 권한과 노출 정책을 확인한다.
|
||||
2. 요청 언어의 현재 본문에 대응하는 번역이 있으면 사용한다.
|
||||
3. 번역이 없으면 원문 응답을 유지하고 누락 처리만 예약한다. 언어 미확정이면 감지 요청에 상세 요청 언어를 전달한다.
|
||||
4. 감지 후 원문과 요청 언어가 다를 때 해당 언어만 예약한다. 신규 작성의 전체 언어 예약과 구분한다.
|
||||
5. 외부 감지/번역은 HTTP 응답을 기다리게 하지 않는다. 다음 조회부터 완성된 번역을 사용한다.
|
||||
|
||||
### 4.3 목록·홈·미리보기
|
||||
|
||||
권한 필터와 페이지 선정 → 페이지 내 게시물의 유효한 번역 일괄 조회 → 번역 또는 현재 원문 선택 →
|
||||
기존 유료 미리보기·표시 규칙 적용 순서로 처리한다. 이 경로는 언어 감지, 메모리에서 조회 모델 재생성,
|
||||
번역 작업 예약을 하지 않는다. 저장된 번역이 없는 기존 게시물은 상세 조회 또는 본문 수정 전까지 원문으로 남는다.
|
||||
|
||||
## 5. API 계약과 조회 표면
|
||||
|
||||
공개 필드는 추가하지 않는다. `content`와 여기서 파생되는 게시물 미리보기 문자열을 요청 언어로 반환한다.
|
||||
언어는 `LangInterceptor` → `Lang.fromAcceptLanguage` → `LangContext`를 그대로 사용한다.
|
||||
지원 언어는 `ko`, `en`, `ja`이고 헤더 누락/미지원 언어 처리는 기존 한국어 기본값을 유지한다.
|
||||
자동 번역문을 원문 `CreatorCommunity.content`에 덮어쓰지 않는다.
|
||||
|
||||
| API | 대상 | 누락 처리 예약 |
|
||||
|---|---|---|
|
||||
| `POST /creator-community`, `PUT /creator-community` | 신규 작성·실제 본문 수정 후 처리. multipart 계약 유지 | 커밋 후 전체 목표 언어 |
|
||||
| `GET /creator-community/{id}` | 레거시 상세 `content` | 요청 언어만 |
|
||||
| `GET /creator-community`, `GET /creator-community/latest` | 레거시 목록·팔로우 최신 목록 `content` | 없음 |
|
||||
| `GET /api/v2/creator-channels/community-posts/{postId}` | v2 상세 게시물 `content` | 요청 언어만 |
|
||||
| `GET /api/v2/creator-channels/{creatorId}/community` | 커뮤니티 탭 게시물 `content` | 없음 |
|
||||
| `GET /api/v2/creator-channels/{creatorId}/home` | 고정/일반 커뮤니티 항목 `content` | 없음 |
|
||||
| `GET /api/v2/home/recommendations` | 인기 커뮤니티 항목 본문·파생 미리보기 | 없음 |
|
||||
| `GET /api/v2/home/following` | `recentNews`의 커뮤니티 게시물 본문 | 없음 |
|
||||
|
||||
관리자 게시물 조회는 편집 원문을 유지한다. 같은 작성 서비스를 호출하는 관리자 생성·수정은 `CCT-003~004`를 따른다.
|
||||
댓글 전용 조회와 구매 응답은 번역 예약 진입점으로 추가하지 않는다.
|
||||
|
||||
## 6. 데이터·비동기 정합성 설계 기준
|
||||
|
||||
다음 기술 설계는 현재 구현과 DDL에 반영됐다. 실제 MySQL 적용 여부는 테스트 서버 수동 검증에서 확인한다.
|
||||
|
||||
| 저장 대상 | 계획 |
|
||||
|---|---|
|
||||
| `creator_community` | nullable `language_code`, 본문 변경에만 증가하는 `content_revision`을 추가한다. 기존 행은 언어 NULL, 개정 번호 0으로 시작한다. |
|
||||
| `creator_community_translation` | 게시물 ID, locale, 번역 본문, 번역 기준 개정 번호, 원문 해시·언어를 저장한다. `(creator_community_id, locale)` 유일성을 보장한다. |
|
||||
| 공통 감지/번역 저장소 | 기존 감지 캐시와 번역 메모리의 정규화·키, `translation_job` 처리 흐름을 유지한다. 커뮤니티 대상 enum과 원문 추출/조회 모델 저장 분기를 추가한다. |
|
||||
|
||||
- 감지 이벤트에 본문 개정 번호와 필요한 경우 목표 언어를 전달한다. 본문이 변경되었거나 비활성화되면 이전 결과를 적용하지 않는다.
|
||||
- 번역문은 현재 본문의 개정 번호·원문 언어가 일치할 때만 조회한다. 오래된 작업의 결과에 현재 개정 번호를 임의로 붙이지 않는다.
|
||||
- 감지 결과 저장과 번역 upsert의 개정 확인은 DB 갱신과 원자적으로 처리한다. 외부 API 호출 동안 행 잠금을 유지하지 않는다.
|
||||
- `updatedAt`은 고정·이미지 등 수정에도 변하므로 본문 개정 판단에 사용하지 않는다.
|
||||
- 원문 A → B → A 복원 시 기존 COMPLETED 작업 때문에 번역이 영구 누락되지 않도록 현재 메모리로 조회 모델을 먼저 복원한다.
|
||||
- 기존 정규화는 공백·개행을 합친다. 원문 표시의 개행은 원문 그대로 보존하고, 번역 결과는 기존 번역기의 출력 정책을 따른다.
|
||||
|
||||
## 7. 예외·권한·성능
|
||||
|
||||
| 상황 | 처리 |
|
||||
|---|---|
|
||||
| 빈 본문 또는 공백만 있는 본문 | 기존 저장 검증은 바꾸지 않는다. 감지·번역은 생략하고 원문을 반환한다. |
|
||||
| 감지 실패 또는 지원하지 않는 원문 언어 | 원문 유지. 지원하지 않는 언어의 번역 작업은 기존 scheduler 정책에 따라 생성하지 않는다. 언어 미확정 게시물의 다음 상세에서 감지를 다시 시도할 수 있다. |
|
||||
| 번역 실패 | 원문 유지. 기존 워커의 재시도/최종 FAILED 정책을 재사용한다. 반복 상세 조회가 최종 실패 작업을 무한 재생성하지 않는다. |
|
||||
| 번역 도중 본문 변경·비활성화 | 오래된 결과를 현재 게시물의 번역으로 노출하지 않는다. 삭제/비활성 게시물은 기존 조회 정책대로 숨긴다. |
|
||||
| 유료 게시물 미구매 | 선택된 번역 또는 원문에 기존 코드포인트 기준 제한을 적용한다. 기존 15자 기준·짧은 본문 절반·말줄임 처리는 유지한다. |
|
||||
| 목록에 여러 게시물 | 페이지 단위 번역 일괄 조회. 기존 정렬·개수·페이지·추천 후보를 변경하지 않고 추가 N+1을 만들지 않는다. |
|
||||
|
||||
외부 감지/번역 실패는 작성·조회 성공을 번역 완료 여부에 종속시키지 않는다. 인증 실패나 DB 장애까지 성공으로 숨기지는 않는다.
|
||||
본문·자격증명·유료 콘텐츠 전문을 새 로그에 남기지 않는다. 기존 감지/번역 저장소의 텍스트 저장 정책은 재사용한다.
|
||||
번역 완료 시간 SLA나 워커 주기 조정은 이번 요청에 포함하지 않는다.
|
||||
|
||||
## 8. 성공 기준과 추적성
|
||||
|
||||
| 수용 시나리오 | 완료 증거 |
|
||||
|---|---|
|
||||
| 한·영·일 원문으로 신규 작성 후 다른 두 언어 제공 | `P2-GATE`의 생성→감지→작업→저장 통합 검증 |
|
||||
| 언어 없는 기존 글: 목록은 원문/작업 0건, 상세는 원문/감지 후 요청 언어 예약 | `P2-T2` 통합 테스트와 배포 후 수동 HTTP 검증 |
|
||||
| 한국어 본문을 영어로 수정하고 이전 비동기 작업을 늦게 완료 | 현재 원문 폴백과 오래된 결과 차단 테스트 |
|
||||
| 상세 번역 저장 후 목록·채널 홈·추천·팔로잉 모두 요청 언어 제공 | `P3-T1~T2`, `P3-GATE`의 표면별 검증 |
|
||||
| 미구매자는 번역 미리보기만, 구매자·소유자는 기존 권한에 따른 본문 제공 | 레거시/v2 권한·코드포인트 경계 회귀 검증 |
|
||||
| 언어 감지/번역 실패, 반복 조회, 본문 복원 | 원문 유지·작업 중복 방지·번역 메모리 재사용 검증 |
|
||||
|
||||
구현 완료는 위 자동 검증 증거가 기록된 뒤에 판정했다. 실제 MySQL·Papago·HTTP 검증은 테스트 서버 수동 검증으로 분리한다.
|
||||
|
||||
## 9. 인터뷰 결과와 Decision Log
|
||||
|
||||
최종 모호성 점수: **0.04**. 차원별 명확성: Goal 1.00, Scope 1.00, Constraints 0.95, Success 0.95, Context 0.85.
|
||||
이는 누락 확인용 판단 지표이며, 코드 검증이나 구현 완료를 의미하지 않는다.
|
||||
열린 제품 질문은 없다. 테스트 환경 자격증명과 DDL 적용 절차는 테스트 서버 배포 단계에서 확인할 실행 조건이다.
|
||||
|
||||
| ID | 상태 | 결정 | 근거 / 영향 |
|
||||
|---|---|---|---|
|
||||
| `DEC-001` | 확정 | 본문만 번역, 댓글 제외 | 사용자 인터뷰 / `CCT-001` |
|
||||
| `DEC-002` | 확정 | 기존 글은 오디오 상세처럼 누락 번역 예약 | 사용자 제안 및 후속 상세 한정 답변 / `CCT-005` |
|
||||
| `DEC-003` | 확정 | 목록 본문·미리보기도 저장된 번역 적용 | 사용자 답변 / `CCT-006` |
|
||||
| `DEC-004` | 확정 | 언어 정보가 없으면 자동 감지 | 사용자 답변 / `CCT-002` |
|
||||
| `DEC-005` | 확정 | 본문 수정 시 언어 재감지·번역 갱신 | 사용자 yes / `CCT-004` |
|
||||
| `DEC-006` | 확정 | 목록에서는 예약하지 않고 상세에서만 예약 | 사용자 답변 / `CCT-005~006` |
|
||||
| `DEC-007` | 확정 | 새 번역 전에는 수정된 원문 표시 | 사용자 yes / `CCT-007` |
|
||||
| `DEC-008` | 확정 | 채널 홈·홈 추천·팔로잉 소식 포함 | 사용자 yes / `CCT-006` |
|
||||
| `DEC-009` | 기술 설계 | 기존 API 구조·권한 유지, 언어별 저장 번역을 본문 필드에 적용 | 저장소 규칙 및 최소 변경 원칙 / `CCT-008~009` |
|
||||
| `DEC-010` | 기술 설계 | 본문 개정 번호와 기존 캐시·큐 재사용 | 수정 직후 원문 표시와 비동기 경합 방지 / `CCT-007`, `CCT-010` |
|
||||
|
||||
결정 날짜는 모두 2026-09-10이다. 범위 변경 시 이 결정 기록과 요구사항을 먼저 갱신하고 `plan-task.md`를 동기화한다.
|
||||
@@ -1,118 +0,0 @@
|
||||
# P1-T2 리뷰 보고서
|
||||
|
||||
## 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 리뷰 대상 | Phase 1 / `P1-T2`, `P1-GATE` |
|
||||
| 기준 working tree | P1-T2 구현 및 실제 Spring regression test 추가 상태 |
|
||||
| 리뷰 일자 | 2026-09-10 |
|
||||
| 리뷰어 | automated review |
|
||||
| 기준 문서 | `prd.md`, `plan-task.md` |
|
||||
| 리뷰 상태 | 추가 수정 및 회귀 검증 완료 |
|
||||
|
||||
## 범위
|
||||
|
||||
- 코드: `LanguageDetectEvent.kt`, `CreatorCommunityTranslationService.kt`
|
||||
- 테스트: `CreatorCommunityLanguageDetectTest.kt`
|
||||
- 제외: 다른 리소스 감지, 공개 API, Docker·HTTP·실제 Papago·MySQL 수동 검증
|
||||
|
||||
## 확정 발견 사항
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 후속 goal |
|
||||
|---|---|---|---|---|
|
||||
| `REV-P1-T2-001` | Blocker | 수정 완료 | 커밋 후 번역 예약이 독립 트랜잭션으로 커밋되지 않는다 | `P1-R1` |
|
||||
| `REV-P1-T2-002` | High | 수정 완료 | 잠금 조회가 stale managed 게시물 상태를 다시 읽지 않는다 | `P1-R1` |
|
||||
| `REV-P1-T2-003` | High | 수정 완료 | 이미 언어가 있는 이벤트가 요청 target을 버린다 | `P1-R1` |
|
||||
| `REV-P1-T2-004` | High | 수정 완료 | 이전 mock 검증은 실제 `translation_job` 저장을 증명하지 않는다 | `P1-R1` |
|
||||
| `REV-P1-T2-005` | High | 수정 완료 | 동시 언어 미확정 감지의 두 번째 target이 lock 뒤 유실된다 | `P1-R2` |
|
||||
| `REV-P1-T2-006` | Blocker | 수정 완료 | 동시 missing-memory 예약이 translation job unique key에 경합한다 | `P1-R3` |
|
||||
|
||||
## 근거와 판정
|
||||
|
||||
### REV-P1-T2-001
|
||||
|
||||
- 코드: `CreatorCommunityTranslationService.requestTranslations`는 기본 `@Transactional`이고, listener는 after-commit callback에서 이를 호출한다.
|
||||
- 재현: `shouldExposeProxiedRequiresNewTranslationRequestEntryPoint`는 `Propagation.REQUIRES_NEW`가 아닌 annotation으로 실패했다.
|
||||
- 영향: 감지 언어 저장 뒤에도 job 예약이 실제로 커밋되지 않아 번역 흐름이 진행되지 않는다.
|
||||
- 조치: 공유 서비스의 public 요청 진입점을 `REQUIRES_NEW`로 선언하고 실제 job 저장 test로 검증한다.
|
||||
|
||||
### REV-P1-T2-002
|
||||
|
||||
- 코드: listener는 `findByIdAndIsActiveTrueForUpdate` 뒤 persistence context의 entity를 refresh하지 않는다.
|
||||
- 재현: `shouldRejectStalePostAfterBlockedDetection`는 감지 동안 본문을 변경한 뒤에도 이전 언어가 저장되어 실패했다.
|
||||
- 영향: 오래된 감지 결과가 새 본문 언어로 저장될 수 있다.
|
||||
- 조치: 잠금 획득 뒤 최신 entity 상태를 refresh하고 revision/content를 재검사한다.
|
||||
|
||||
### REV-P1-T2-003
|
||||
|
||||
- 코드: listener는 이미 languageCode가 설정된 커뮤니티 이벤트를 감지 전 return한다.
|
||||
- 재현: `shouldContinueBothRequestedTargetsWhenLanguageIsAlreadyKnown`는 `ja`, `en` target job이 저장되지 않아 실패했다.
|
||||
- 영향: 상세 요청별 번역 예약 범위가 사라진다.
|
||||
- 조치: 현재 본문 검증 뒤 known-language 이벤트도 after-commit에서 원 target으로 shared service를 호출한다.
|
||||
|
||||
### REV-P1-T2-004
|
||||
|
||||
- 코드/테스트: 기존 테스트는 `CreatorCommunityTranslationService` mock의 호출만 검증했다.
|
||||
- 재현: 실제 Spring listener와 scheduler를 연결한 `CreatorCommunityLanguageDetectTest`에서 저장된 `translation_job` target을 확인하자 job이 없었다.
|
||||
- 영향: mock interaction만 통과해도 사용자 흐름의 persistence 실패를 놓친다.
|
||||
- 조치: 감지 provider만 제어하고 scheduler/repository는 실제 bean으로 사용한다.
|
||||
|
||||
### REV-P1-T2-005
|
||||
|
||||
- 코드: lock과 refresh 뒤 languageCode가 이미 있으면 revision/content 재검증 전에 return한다.
|
||||
- 재현: 같은 언어 미확정 본문으로 두 감지를 시작해 첫 감지를 먼저 커밋한 뒤 두 번째 감지를 진행하면 두 번째 target job이 없다.
|
||||
- 영향: 동시 상세 요청이 서로 다른 언어를 요청할 때 한 요청의 번역 예약이 유실된다.
|
||||
- 조치: 현재 revision/content가 유효하면 이미 저장된 언어를 보존하고, 두 번째 이벤트의 원 target을 after-commit에 등록한다.
|
||||
|
||||
### REV-P1-T2-006
|
||||
|
||||
- 코드: `requestTranslations`는 post lock 없이 materializer의 missing-memory 반환 뒤 scheduler의 job 존재 조회와 insert를 실행한다.
|
||||
- 재현: 같은 언어 확정 post와 target으로 두 request transaction을 동시에 시작하면 두 transaction이 missing job을 보고 insert를 시도할 수 있다.
|
||||
- 영향: job unique key 예외가 호출자 transaction까지 전파돼 상세 예약 흐름이 실패할 수 있다.
|
||||
- 조치: request transaction 시작에서 active post write lock과 refresh를 수행해 source extraction, memory lookup, job 존재 조회와 insert를 직렬화한다.
|
||||
|
||||
## 실행 증거
|
||||
|
||||
| 명령 | 결과 | 핵심 증거 |
|
||||
|---|---|---|
|
||||
| `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` | 실패 | 초기 test bean 보정 후 7개 중 5개 assertion 실패 |
|
||||
|
||||
## 결론
|
||||
|
||||
`REV-P1-T2-001~006`은 `plan-task.md`의 `P1-R1~R3` 회귀 수정 goal로 반영했고 모두 수정·검증을 완료했다.
|
||||
|
||||
## 수정 후 검증 기록
|
||||
|
||||
### 1차 수정 검증 — 2026-09-10
|
||||
|
||||
- 무엇을: `REQUIRES_NEW` 번역 요청, stale entity refresh, known-language target continuation과 실제 `translation_job` 검증을 추가했다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck bootJar` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: 사용자 정책상 Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 실행하지 않았다.
|
||||
|
||||
### 2차 수정 검증 — 2026-09-10
|
||||
|
||||
- 대상: `REV-P1-T2-005`.
|
||||
- RED: `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 1, 8개 중 1개 실패.
|
||||
두 감지가 언어 NULL에서 시작한 뒤 첫 감지의 `ja` job 저장 후 두 번째 `en` job이 누락됐다.
|
||||
- GREEN: lock과 refresh 뒤 revision/content가 유효하면 이미 저장된 languageCode를 유지하고,
|
||||
원 target을 after-commit에 등록하도록 수정했다.
|
||||
- 검증:
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest'` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --tests 'kr.co.vividnext.sodalive.i18n.translation.*' ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 사용자 정책상 실행하지 않았다.
|
||||
|
||||
### 3차 수정 검증 — 2026-09-10
|
||||
|
||||
- 대상: `REV-P1-T2-006`.
|
||||
- RED: `./gradlew test --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest'`
|
||||
— exit 1. 실제 `translation_job` insert가 H2 `SQLState 23505`,
|
||||
`uk_translation_job_resource_field_target_hash` unique index 충돌로 실패했다.
|
||||
- GREEN: `requestTranslations` transaction 시작에서 active post write lock과 `PESSIMISTIC_WRITE` refresh를 수행하도록 수정했다.
|
||||
- 검증:
|
||||
- 같은 focused test — exit 0, `BUILD SUCCESSFUL`; 두 호출 정상 종료와 job 1개 저장을 확인했다.
|
||||
- P1 직접 영향 회귀와 P1 Gate — exit 0, `BUILD SUCCESSFUL`.
|
||||
- `./gradlew ktlintCheck` — exit 0, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: Docker, HTTP, 실제 Papago, MySQL 수동 동시성 검증은 사용자 정책상 실행하지 않았다.
|
||||
@@ -1,134 +0,0 @@
|
||||
# Phase 1 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 대상 | Phase 1 `P1-T1`, `P1-T2`, `P1-R1~R4`, `P1-GATE` |
|
||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 목표·품질·보안·QA 증거 리뷰어 |
|
||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
||||
| 상태 | `P1-R4` 수정·자동 회귀·후속 리뷰 완료 · 실제 MySQL/Papago/HTTP 수동 검증 대기 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
§2~7의 정적 근거와 지문은 최초 리뷰 당시 기록이다. 수정 후 코드를 같은 지문으로 검증했다고 보지 않는다.
|
||||
현재 판정은 §8과 2026-09-10 후속 재판정에 기록하며, 아래 최초 리뷰의 실행 제약은 그대로 보존한다.
|
||||
|
||||
원문 언어 감지, 번역 저장·재사용, 본문 개정 정합성, 동시 요청의 목표 언어 보존을 기준 문서와 대조한다.
|
||||
코드·테스트 소스·기존 검증 기록만 확인한다. 사용자 지시에 따라 테스트/컴파일/서버/HTTP/DB 실행은 하지 않는다.
|
||||
현재 테스트 통과는 사용자 보고 및 기존 기록으로 구분하며 신규 실행 성공으로 표현하지 않는다.
|
||||
|
||||
## 3. 판정 기준
|
||||
|
||||
확정 요구사항과 실제 코드 경로가 일치하면 충족으로 판정한다. 결함 후보는 발생 순서·예외 전달·기존 테스트의 경계를
|
||||
대조한 후 확정/오탐/보류로 분리한다. 이번 확정 항목은 제한된 동시 요청 조건에서 발생하고 재조회로 복구할 수 있어
|
||||
**Medium**으로 판정한다. 단순 외부 감지 호출 중복 자체는 계획에서 허용한 범위이므로 결함으로 세지 않는다.
|
||||
|
||||
## 4. 검토 근거와 충족 항목
|
||||
|
||||
아래 source 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
||||
|
||||
| 요구사항 / 경계 | 코드 근거 | 판정 |
|
||||
|---|---|---|
|
||||
| `CCT-001` 원문과 번역 분리 | `v2/creator/channel/community/translation/adapter/out/persistence/CreatorCommunityTranslation.kt:17`, `schema.sql` | 충족 |
|
||||
| `CCT-007` 현재 개정·언어·해시 일치 | `v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt:95`, `i18n/translation/TranslationReadModelMaterializer.kt:194` | 충족 |
|
||||
| 이전 감지 결과 배제 | `content/LanguageDetectEvent.kt:383`, 감지 전 확인 및 잠금/refresh 후 재검사 | 충족 |
|
||||
| `CCT-010` A→B→A 메모리 복원 | `CreatorCommunityTranslationService.requestTranslations`에서 materialize 후 누락 예약 | 충족 |
|
||||
| 같은 게시물 작업/번역 행 저장 직렬화 | 요청 진입점의 post lock, materializer의 post/translation lock | 충족 |
|
||||
| 언어 미확정 상태의 실제 캐시 INSERT 경합 | 최초 근거: `content/LanguageDetectionCacheService.kt:21`, `:29` | 당시 수정 필요, `P1-R4` 자동 검증으로 수정 확인 (§9) |
|
||||
|
||||
읽은 테스트에는 `CreatorCommunityTranslationServiceTest`의
|
||||
`shouldReturnOnlyCurrentTranslationFromBatchedRead`, `shouldFallbackToOriginalWhenTranslationIsMissingOrDoesNotMatchCurrentSource`,
|
||||
`shouldRematerializeAtoBtoAIntoExistingTranslationRow`, `shouldSerializeConcurrentMissingMemoryTranslationJobScheduling`,
|
||||
`shouldUpdateExistingTranslationAfterRepeatableReadSnapshotWaitsForFirstWriter`가 포함된다.
|
||||
기존 P1 리뷰의 수정 사항을 신규 결함으로 중복 등록하지 않았다.
|
||||
|
||||
실행한 검증은 `git diff`, `rg`, `sed`, `nl`을 통한 정적 대조와 파일 SHA256 계산이다.
|
||||
기존 테스트 XML과 리뷰 레인 출처는 [증거 기록](review-evidence.md)에 있다. 새 테스트나 런타임 재현은 실행하지 않았다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
| ID | 심각도 | 상태 | 제목 | 소유 Task | 후속 Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| `REV-P1-007` | Medium | 수정 완료 · `P1-R4` 자동 증거 (§9), 최초 정적 확정 이력 유지 | 최초 감지 캐시 저장 경합으로 후발 목표 언어의 예약 유실 | `P1-T2`, `P1-R2` | `P1-R4` 완료, 실제 MySQL 수동 확인 |
|
||||
|
||||
## 6. REV-P1-007 상세
|
||||
|
||||
- 관련 요구사항: `CCT-005`, `CCT-010`, 계획 `P1-R2`의 각 요청 target 보존 목표.
|
||||
- 관련 계약: PRD §4.2 상세 요청 언어만 예약, §6 기존 감지 캐시 재사용.
|
||||
- 근거 파일:
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionCacheService.kt:21`: 캐시를 먼저 조회한다.
|
||||
- 같은 파일 `:28`, `:29`: 외부 감지 후 같은 키에 무조건 `save`한다.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectionResult.kt:15`: 해시/provider/version 유일 키.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/common/BaseEntity.kt:19`: IDENTITY 저장 식별자.
|
||||
- `src/main/kotlin/kr/co/vividnext/sodalive/content/LanguageDetectEvent.kt:398`: 캐시 예외가 후속 post lock보다 먼저 전파된다.
|
||||
- 같은 파일 `:411`, `:417`: 목표 언어 후속 예약은 감지 성공 이후에만 등록된다.
|
||||
|
||||
### 정적 검증 순서
|
||||
|
||||
이 순서는 코드를 따라 확인한 결함 조건이며, 이번 턴에서 실제로 실행한 재현 결과가 아니다.
|
||||
|
||||
1. 원문 언어 NULL이고 감지 캐시가 없는 동일 한국어 게시물에 일본어·영어 상세 요청을 보낸다.
|
||||
2. 두 감지 트랜잭션이 모두 언어 NULL과 캐시 miss를 읽은 뒤 각 detector 호출을 진행한다.
|
||||
3. 첫 detector가 반환하면 캐시 INSERT → 게시물 언어 저장 → 커밋 → 일본어 작업 예약이 진행된다.
|
||||
4. 뒤늦게 반환한 detector는 캐시를 다시 읽거나 원자적으로 합류하지 않고 같은 유일 키를 INSERT한다.
|
||||
5. 중복 키 예외로 두 번째 감지 트랜잭션이 종료되어 영어 목표를 예약하는 콜백에 도달하지 못한다.
|
||||
|
||||
기대 결과는 양쪽 목표 언어의 작업 보존이다. 코드 경로상 결과는 후발 목표의 작업 누락이다.
|
||||
다음 영어 상세 조회로 다시 예약할 수 있으므로 영구적인 데이터 손실이라고 주장하지 않는다.
|
||||
서로 다른 게시물을 같은 본문으로 동시에 신규 작성할 때도 동일한 전역 캐시 키 경합이 발생할 수 있다.
|
||||
|
||||
### 기존 테스트가 통과하는 이유
|
||||
|
||||
`src/test/kotlin/kr/co/vividnext/sodalive/content/CreatorCommunityLanguageDetectTest.kt:335`의
|
||||
`shouldPreserveSecondTargetAfterConcurrentDetectionSetsLanguage`는 `ControlledLanguageDetectionCacheService`를 사용한다.
|
||||
같은 파일 `:743`의 `detectWithCache` override는 실제 캐시 SELECT/INSERT 없이 제어된 언어를 반환한다.
|
||||
따라서 이 테스트의 목표 보존 성공은 실제 캐시 유일 키 저장 경합까지 보장하지 않는다.
|
||||
|
||||
### 권장 조치와 판정
|
||||
|
||||
동일 캐시 키 저장을 원자적으로 처리해 이미 저장한 결과로 수렴시키고, 후발 감지 트랜잭션이 rollback-only가 되지 않게 한다.
|
||||
이미 실패한 트랜잭션 내부에서 예외만 잡고 계속 진행하는 방식은 사용하지 않는다.
|
||||
외부 API 호출 동안 게시물 행 잠금을 잡지 않는 현재 기준을 유지한다.
|
||||
실제 캐시 service/repository를 사용하고 detector만 통제하는 두 cache miss 경합 회귀 테스트를 추가한다.
|
||||
|
||||
2026-09-10 — 품질 리뷰, 목표 리뷰, 루트 리뷰에서 위 정적 경로를 교차 확인하여 확정했다.
|
||||
실행 재현은 사용자 지시에 따라 수행하지 않았으며 `P1-R4`의 RED 단계로 남긴다.
|
||||
|
||||
## 7. 계획 전환
|
||||
|
||||
`plan-task.md`의 Phase 1에 신규 `P1-R4`를 추가한다. 기존 `P1-R2`와 `P1-GATE` 완료 체크 및 검증 기록은 유지한다.
|
||||
새 목표: 실제 최초 감지 캐시 경합에서도 두 요청 target을 보존하고 해당 회귀를 방지한다.
|
||||
상세 검증 명령·파일·RED/GREEN/REFACTOR·재검토 조건은 신규 Task에 기록한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| Phase 범위와 기존 수정 확인 | 충족 |
|
||||
| 후보 판정 | 확정 1건. afterCommit 후 재잠금 교착 후보는 DB 커밋 후 잠금 해제로 오탐 제외 |
|
||||
| 확정 항목 계획 반영 | 최초 `P1-R4` 신규 등록, 이후 구현·자동 검증·후속 리뷰 완료 |
|
||||
| 실행 범위 명시 | 최초 리뷰는 테스트 미실행. 이후 `P1-R4` 로컬 자동 검증 증거를 §9에 별도 누적 |
|
||||
|
||||
**최초 결론 기록: 수정 Goal 필요.** 당시 최초 감지 캐시 경합 수정과 수정 후 검증이 필요했다.
|
||||
**현재 최종 결론: `REV-P1-007`은 `P1-R4` 로컬 자동 검증 범위에서 수정 완료.**
|
||||
실제 MySQL/Papago/HTTP 검증은 기존 배포 후 체크리스트에서 미완료로 유지한다.
|
||||
|
||||
## 9. P1-R4 수정 후 재판정 · 2026-09-10
|
||||
|
||||
- RED는 실제 캐시 service/repository와 제어된 provider를 사용했다. 두 provider 진입 후 focused 명령이
|
||||
exit 1로 실패했고 `ExecutionException → DataIntegrityViolationException → H2 23505` 캐시 유일 키 충돌을 확인했다.
|
||||
- GREEN은 native MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`로 승자 결과에
|
||||
수렴시킨다. audit 시각을 명시하고 DB 예외를 잡아 같은 트랜잭션에서 계속하는 방식은 사용하지 않는다.
|
||||
- 같은 게시물 `ja`/`en` 이중 miss 및 다른 게시물의 동일 본문 경합에서 캐시 1행과 각 target 작업 보존을 확인했다.
|
||||
focused fresh 재실행은 `--rerun-tasks --no-parallel`로 `BUILD SUCCESSFUL` (5분 19초)이었다.
|
||||
- P1 listener/scheduler/materializer 회귀 (1분 09초), P2 상세/API 회귀 (1분 42초), `ktlintCheck` (44초)는
|
||||
모두 `BUILD SUCCESSFUL`이다. 정확한 명령은 [후속 증거](review-evidence.md)에 기록한다.
|
||||
- spec `PASS`: `ses_f7657d350ffe3YUYhEHvHPuikK`. quality `APPROVED`: `ses_f7656994dffeR2odTLswaw6RTo`.
|
||||
- `REV-P1-007`의 실제 캐시 경합 테스트 공백은 위 자동 증거로 보완됐다. H2 `MODE=MySQL`은 MySQL 8/InnoDB의
|
||||
실제 격리·잠금·DDL 증거가 아니다. 실제 MySQL의 순서 제어 경합, audit 컬럼, 감지 중 수정 가능 여부와 stale 차단,
|
||||
실제 Papago/HTTP는 [수동 체크리스트](../plan-task.md)에서 확인해야 한다.
|
||||
- 전체 `./gradlew test`는 생략했다. 공통 캐시 계약·실제 repository 경합·모든 직접 listener/scheduler/materializer
|
||||
테스트·P2 상세/API 소비자가 통과했고 별도의 미해결 공통 경계가 없다는 영향 범위 판단에 따른다.
|
||||
@@ -1,85 +0,0 @@
|
||||
# Phase 2 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 대상 | Phase 2 `P2-T1`, `P2-T2`, `P2-R1~R3`, `P2-GATE` |
|
||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 목표·품질·보안·QA 증거 리뷰어 |
|
||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
||||
| 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
생성 커밋 후 번역, 실제 본문 수정 시 재감지, 상세의 요청 언어 예약, 기존 권한/응답 보존을 점검한다.
|
||||
관리자 공용 mutation 경로와 고정 상태 경합의 기존 수정도 확인한다. 사용자 지시에 따라 테스트/컴파일/HTTP 실행은 하지 않는다.
|
||||
소스·테스트·과거 검증 기록을 기준으로 판단하며 새로운 기능을 요구하거나 미실행을 결함으로 만들지 않는다.
|
||||
|
||||
## 3. 판정 기준과 검토 근거
|
||||
|
||||
source 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/`, test 경로는 같은 패키지의 `src/test/kotlin/` 기준이다.
|
||||
|
||||
| 기준 | 근거 | 판정 |
|
||||
|---|---|---|
|
||||
| `CCT-003` 생성 커밋 후 예약 | `explorer/profile/creatorCommunity/CreatorCommunityService.kt:152`, `:175`, `:192` | 충족. 커밋 후 공유 쓰기 진입점을 호출하고 외부 감지는 비동기 listener에서 실행 |
|
||||
| `CCT-004` 실제 본문 변경만 재감지 | 같은 파일 `:214`, `:221` | 충족. 소유권 잠금 조회/refresh 후 문자열 비교, 언어 NULL, 개정 증가 |
|
||||
| 본문 외 고정 상태 변경의 정합성 | 같은 파일 `:264`의 잠금/refresh 및 관리자 공용 경로 | 기존 `P2-R2` 수정 반영 확인 |
|
||||
| `CCT-005` 상세만 요청 언어 예약 | 같은 파일 `:398`, `v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt:128` | 충족. 유효 번역이 없을 때 요청 locale만 전달 |
|
||||
| `CCT-007` 최신 원문 폴백 | 공유 조회 결과를 DTO에 복사하며 유효하지 않은 번역은 사용하지 않음 | 충족 |
|
||||
| `CCT-008` 권한·유료 미리보기 | 레거시 차단 확인 `CreatorCommunityService.kt:390`, v2 접근 검사 `CreatorChannelCommunityQueryService.kt:121`, 기존 마스킹 `:253` | 충족. 번역 선택 후 마스킹 |
|
||||
| `CCT-009` API/언어 규칙 | 기존 `LangContext`를 사용하고 공개 DTO에는 `content`만 복사 | 충족 |
|
||||
|
||||
관리자 생성/수정은 `v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostFacade.kt:47`, `:75`에서
|
||||
기존 공용 서비스를 호출한다. 관리용 조회는 원문을 유지한다. 댓글/답글/구매를 번역 예약 진입점으로 추가하지 않았다.
|
||||
|
||||
확인한 테스트:
|
||||
|
||||
- `content/CreatorCommunityLanguageDetectTest.kt:192` — `shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler`.
|
||||
- 같은 파일 `:433` — `shouldLeaveNoPostOrTranslationArtifactsWhenProxiedCreateRollsBack`.
|
||||
- 같은 파일 `:461` — `shouldHideStaleTranslationAndScheduleNewTargetsAfterCommittedEnglishModification`.
|
||||
- `explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt` — 동일 본문/본문 외 수정의 비예약, 유료·소유자·구매자·차단 조건.
|
||||
- `v2/api/admin/aicharacter/community/AiCharacterAdminCommunityPostConcurrencyTest.kt` — 관리자 공용 mutation과 최신 본문 보존.
|
||||
|
||||
## 4. 실행 및 증거 한계
|
||||
|
||||
`git diff`, `rg`, `sed`와 테스트 소스 읽기로 호출·트랜잭션·권한 경계를 확인했다.
|
||||
기존 XML 성공 기록은 [증거 기록](review-evidence.md)에 분리했다. 테스트/컴파일/서버/외부 호출은 실행하지 않았다.
|
||||
|
||||
`shouldRunDetailTranslationPipelineThroughProxyListenerAndScheduler`는 실제 프록시·listener·scheduler를 연결하지만
|
||||
번역 메모리를 직접 저장한 뒤 materializer를 호출한다. 워커 테스트는 별도의 mock 기반 구성요소 검증이다.
|
||||
따라서 한·영·일 각각의 신규 작성부터 실제 provider/워커/번역 행 저장까지 하나로 연결한 실환경 검증을 완료했다고 해석하지 않는다.
|
||||
이는 새 제품 결함으로 확정하지 않고 기존 테스트 서버 수동 체크리스트의 실행 범위로 유지한다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
**Phase 2 자체 구현의 추가 확정 발견 사항 없음.**
|
||||
|
||||
Phase 1의 `REV-P1-007`은 상세를 통한 최초 언어 감지에도 영향을 준다. 같은 원인을 Phase 2 결함으로 중복 등록하지 않고
|
||||
[Phase 1 리뷰](phase-1-review.md)의 `P1-R4`에서 수정한다. 이 의존성까지 포함한 전체 번역 흐름을 무조건 PASS로 표현하지 않는다.
|
||||
|
||||
## 6. 후보 판정
|
||||
|
||||
| 후보 | 판정 / 근거 |
|
||||
|---|---|
|
||||
| afterCommit의 REQUIRES_NEW 진입이 이전 post lock과 교착 | 오탐. DB commit 후 콜백이므로 이전 트랜잭션의 DB 잠금은 해제된 상태 |
|
||||
| 관리자 별도 생성/수정에 번역 연결 누락 | 오탐. 공용 레거시 서비스를 호출하므로 중복 연결 불필요 |
|
||||
| 테스트 재실행이 없으므로 구현 실패 | 해당 없음. 사용자 지시이며 정적 리뷰의 실행 한계로만 기록 |
|
||||
|
||||
## 7. 계획 전환
|
||||
|
||||
**전환 항목 없음.** 자체 수정 Task를 억지로 추가하지 않는다. Phase 1 `P1-R4`의 직접 영향 회귀에 상세 파이프라인을 포함한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| Phase 2 자체 요구사항 확인 | 충족 |
|
||||
| 추가 확정 결함 | 없음 |
|
||||
| 다른 Phase 의존 결함 기록 | `REV-P1-007` / `P1-R4` |
|
||||
| 새 수정 Task | 해당 없음 |
|
||||
| 검증 범위 명시 | 충족. 소스·기존 테스트/기록 대조, 직접 실행 없음 |
|
||||
|
||||
**최종 결론: Phase 2 자체 기준 충족, 추가 확정 발견 사항 없음.**
|
||||
남은 항목은 Phase 1 수정의 영향 재검토와 기존 테스트 서버 수동 확인이다. 이번 리뷰에서 수정한 제품 코드는 없다.
|
||||
@@ -1,84 +0,0 @@
|
||||
# Phase 3 리뷰 보고서
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 대상 | Phase 3 `P3-T1`, `P3-T2`, `P3-R1`, `P3-GATE` |
|
||||
| 기준 | HEAD `50409e41c0c469529572f5d77033c3cb23d67d22` + 현재 작업 트리 |
|
||||
| 작업 트리 지문 | `3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7` |
|
||||
| 일자 / 리뷰어 | 2026-09-10 / Codex 및 문맥·목표·보안·QA 증거 리뷰어 |
|
||||
| 기준 문서 | [PRD](../prd.md), [구현 계획](../plan-task.md), `docs/sample/sample-review.md` |
|
||||
| 상태 | 판정 완료 |
|
||||
|
||||
## 2. 목적과 범위
|
||||
|
||||
`CCT-006~010`의 목록·미리보기·홈 표면, 저장 번역만 읽는 경계, 권한·페이지·공개 계약 유지를 확인한다.
|
||||
기존 최종 Gate의 실제 실행 미완료 범위도 대조한다. 사용자 지시에 따라 테스트·컴파일·서버·HTTP는 실행하지 않는다.
|
||||
|
||||
## 3. 판정 기준과 코드 대조
|
||||
|
||||
아래 경로는 `src/main/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
||||
|
||||
| 표면 / 기준 | 코드 근거 | 판정 |
|
||||
|---|---|---|
|
||||
| 레거시 일반·최신 목록 | `explorer/profile/creatorCommunity/CreatorCommunityService.kt:305`, `:359`, `:641`, `:679` | 충족. 표시할 ID를 모아 번역 조회 후 본문만 복사 |
|
||||
| v2 탭 | `v2/creator/channel/community/application/CreatorChannelCommunityQueryService.kt:74` | 충족. 반환 페이지 확정 후 번역 조회, 다음 페이지 확인용 추가 행 제외 |
|
||||
| 채널 홈 고정·일반 | `v2/creator/channel/home/application/CreatorChannelHomeQueryService.kt:104`, `:133` → 공용 `findHomeCommunityPosts` | 충족. 두 그룹 모두 같은 읽기 연결 |
|
||||
| 홈 추천 | `v2/recommendation/application/HomeRecommendationQueryService.kt:158` | 충족. 후보 정렬·중복 제거·limit 이후 최종 ID만 처리 |
|
||||
| 팔로잉 소식 | `v2/home/following/application/HomeFollowingQueryService.kt:32`, `:60` | 충족. 커뮤니티 유형만 변경하고 다른 소식 유지 |
|
||||
| 목록 계열 예약 금지 | `v2/creator/channel/community/translation/application/CreatorCommunityTranslationService.kt:33` | 충족. `findDisplayContents`는 읽기 전용이며 감지·예약·materialize 호출 없음 |
|
||||
| 공개 스키마 | DTO/도메인 `copy(content = ...)` | 충족. 내부 `CreatorCommunityDisplayContent`를 공개 응답으로 추가하지 않음 |
|
||||
| DDL 타입 정합성 | `schema.sql:11`, `CreatorCommunityTranslation.kt`의 sourceHash 매핑 | 기존 `P3-R1`의 VARCHAR(64) 수정 반영 확인 |
|
||||
|
||||
`LiveApiService`와 `ExplorerService`의 간접 목록 호출도 기존 서비스에 연결되어 저장 번역을 사용한다.
|
||||
팔로잉 본문은 `DefaultHomeFollowingQueryRepository.kt:160`, `:251`에서 현재 게시물 내용을 읽으므로
|
||||
소식 발행 당시 저장한 미리보기를 번역 원문으로 사용하지 않는다.
|
||||
|
||||
## 4. 테스트·기록 대조
|
||||
|
||||
test 경로는 `src/test/kotlin/kr/co/vividnext/sodalive/` 기준이다.
|
||||
|
||||
| 검증 대상 | 읽은 기존 테스트 |
|
||||
|---|---|
|
||||
| 배치 크기 1/4에서 SELECT 2회 유지 | `explorer/profile/creatorCommunity/CreatorCommunityTranslationEndToEndTest.kt:66`, `shouldReadTranslationsWithConstantSelectCountAcrossBatchSizes` |
|
||||
| 목록 누락 번역의 작업/이벤트 미생성 | 같은 파일 `:98`, `shouldNotPublishOrScheduleWhenLegacyListReadsMissingTranslation` |
|
||||
| 레거시 필터·마스킹·일괄 조회 | `explorer/profile/creatorCommunity/CreatorCommunityServiceTest.kt:579` 이후 관련 테스트 |
|
||||
| v2 페이지 추가 행 제외·홈 두 그룹 | `v2/creator/channel/community/application/CreatorChannelCommunityQueryServiceTest.kt:180` 이후 관련 테스트 |
|
||||
| 채널 홈 저장 번역 | `v2/api/creator/channel/home/CreatorChannelHomeEndToEndTest.kt:89`, `shouldUseStoredTranslationsForPinnedAndNormalHomeCommunityPosts` |
|
||||
| 추천 최종 배치·팔로잉 커뮤니티 유형만 처리 | `v2/recommendation/application/HomeRecommendationQueryServiceTest.kt:705`, `v2/home/following/application/HomeFollowingQueryServiceTest.kt:123` |
|
||||
|
||||
`git diff`, `rg`, `sed`로 호출부와 테스트를 대조했다. 기존 XML과 레인 결과는 [증거 기록](review-evidence.md)에 있다.
|
||||
이 표는 테스트를 새로 실행했다는 의미가 아니다. 기존 계획의 로컬 성공 기록과 실제 MySQL/Papago/HTTP 미완료 표기는
|
||||
서로 구분되어 있으며, 후자를 완료한 것으로 평가하지 않는다.
|
||||
|
||||
## 5. 발견 사항 요약
|
||||
|
||||
**확정 발견 사항 없음.** 목록·채널 홈·추천·팔로잉의 저장 번역 적용은 인터뷰에서 확정한 범위에 부합한다.
|
||||
|
||||
## 6. 후보 판정
|
||||
|
||||
| 후보 | 판정 / 근거 |
|
||||
|---|---|
|
||||
| 추천·팔로잉 본문 복사가 유료 전체 본문을 노출 | 오탐. `DefaultHomeRecommendationQueryRepository.kt:893`, `DefaultHomeFollowingQueryRepository.kt:461`의 기존 무료 조건 유지 |
|
||||
| 레거시/v2 마스킹 전에 원문 길이를 사용 | 오탐. 번역 본문을 복사한 뒤 `SelectCommunityPostResponse.kt:44`, `CreatorChannelCommunityQueryService.kt:253`의 기존 마스킹 실행 |
|
||||
| 번역 배치 조회가 작성자별 N+1을 추가 | 오탐. `member` LAZY이며 읽기에서 접근하지 않는다. 서로 다른 작성자의 배치 크기 테스트가 기존에 존재 |
|
||||
| 목록 번역 누락을 처리하기 위해 자동 작업 예약 | 오탐. 공용 읽기 메서드만 사용하고 예약 진입점을 호출하지 않음 |
|
||||
|
||||
## 7. 계획 전환
|
||||
|
||||
**전환 항목 없음.** 추가 수정 Task는 만들지 않는다. Phase 1 결함으로 번역이 아직 생성되지 않은 경우에는
|
||||
이 Phase의 요구대로 원문을 표시한다. 예약 결함은 `P1-R4`에서 처리한다.
|
||||
|
||||
## 8. 리뷰 종료 판정
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 모든 합의된 목록·미리보기 표면 확인 | 충족 |
|
||||
| 확정 결함 | 없음 |
|
||||
| 후보 판정 완료 | 충족, 위 후보는 오탐 |
|
||||
| 확정 항목 계획 반영 | 해당 없음 |
|
||||
| 실행 범위·기존 미완료 검증 구분 | 충족 |
|
||||
|
||||
**최종 결론: Phase 3 기준 충족, 확정 발견 사항 없음.**
|
||||
실제 DDL·MySQL 동시성·HTTP·Papago 확인은 기존 수동 체크리스트대로 남긴다. 이번 리뷰에서 제품 코드를 수정하지 않았다.
|
||||
@@ -1,121 +0,0 @@
|
||||
# 2026-09-10 Phase별 정적 리뷰 증거
|
||||
|
||||
## 대상과 검증 범위
|
||||
|
||||
아래 지문부터 리뷰 산출물 검증까지는 최초 정적 리뷰 당시 기록이다. `P1-R4` 이후 변경에는 이 지문과 PASS를
|
||||
소급 적용하지 않는다. 현재 최종 판정과 후속 자동 증거는 문서 끝에 별도로 누적한다.
|
||||
|
||||
- 기준 HEAD: `50409e41c0c469529572f5d77033c3cb23d67d22`.
|
||||
- 리뷰 대상은 HEAD 자체가 아닌 현재 수정/미추적 source·test와 `schema.sql` 32개 파일이다.
|
||||
- 위 파일의 경로순 `경로 + NUL + SHA256 + LF`를 결합한 SHA256:
|
||||
`3bbb1d90869484073c3af681118a13f5f03c3ccecbe84de16f1568338acd66b7`.
|
||||
- 사용자 지시: 컴파일·테스트는 이미 통과했으므로 테스트를 직접 실행하지 않는다.
|
||||
- 이번 판정은 소스·테스트·기존 기록의 정적 대조다. 테스트/컴파일/서버/HTTP/Papago/MySQL 실행 판정이 아니다.
|
||||
- 이전 리뷰의 PASS를 재사용하지 않고 현재 작업 트리를 다시 읽었다. 아래 PASS는 정적 리뷰 범위에만 적용한다.
|
||||
|
||||
## 레인 기록
|
||||
|
||||
모든 기록의 기준은 위 전체 HEAD와 작업 트리 지문이다. 소스가 바뀌면 이 결과를 그대로 재사용하지 않는다.
|
||||
|
||||
| 레인 | 판정 | 근거 / 결과 출처 |
|
||||
|---|---|---|
|
||||
| 보안·권한 `security_review` | PASS | 현재 본문 정합성, 번역 선택 후 유료 마스킹, 추천/팔로잉 무료 필터, 관리자 원문 보존 확인. `/root/security_review` 최종 보고, 2026-09-10. |
|
||||
| 목표·제약 `goal_review` | 수정 필요 | Phase 1 캐시 경합으로 요청 target 유실을 독립 확인. Phase 2·3 추가 결함 없음. `/root/goal_review` 최종 보고, 2026-09-10. |
|
||||
| 코드 품질·동시성 `code_review` | 수정 필요 | 감지 캐시 최초 INSERT 경합으로 후발 target 예약 유실. `/root/code_review` 최종 보고, 2026-09-10. Phase 1 보고서에 상세 근거를 기록한다. |
|
||||
| 기존 QA 증거 `qa_evidence_review` | 정적 증거 확인 완료 | 기존 테스트 소스/XML 성공 기록을 확인했다. 실제 캐시 INSERT 경합과 한·영·일 전체 파이프라인의 직접 통합 증거 한계는 별도로 남긴다. `/root/qa_evidence_review` 최종 보고, 2026-09-10. |
|
||||
| 문맥·통합 `context_review` | PASS — Phase 3 범위 | 목록/홈 및 간접 소비자, 공개 스키마·무료 필터·읽기 전용 경계 확인. `/root/context_review` 최종 보고, 2026-09-10. Phase 1·2 전체 판정으로 확대하지 않는다. |
|
||||
|
||||
동적 QA/debugging 감사는 사용자 지시에 따라 재실행하지 않았다. 이를 PASS나 신규 실패로 분류하지 않는다.
|
||||
|
||||
## 기존 실행 증거 표본
|
||||
|
||||
아래는 `build/test-results/test/`의 기존 XML을 읽은 결과이며 현재 턴에서 실행한 테스트가 아니다.
|
||||
|
||||
| XML 파일명 | 테스트 수 | 실패 / 오류 / skip | 기록 timestamp |
|
||||
|---|---:|---|---|
|
||||
| `TEST-kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest.xml` | 12 | 0 / 0 / 0 | 2026-09-10T02:43:16 |
|
||||
| `TEST-kr.co.vividnext.sodalive.v2.creator.channel.community.translation.CreatorCommunityTranslationServiceTest.xml` | 16 | 0 / 0 / 0 | 2026-09-10T02:50:05 |
|
||||
| `TEST-kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.CreatorCommunityTranslationEndToEndTest.xml` | 4 | 0 / 0 / 0 | 2026-09-10T02:44:03 |
|
||||
| `TEST-kr.co.vividnext.sodalive.i18n.translation.TranslationJobWorkerTest.xml` | 7 | 0 / 0 / 0 | 2026-09-10T02:44:03 |
|
||||
| `TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.community.adapter.in.web.CreatorChannelCommunityEndToEndTest.xml` | 15 | 0 / 0 / 0 | 2026-09-10T02:48:44 |
|
||||
| `TEST-kr.co.vividnext.sodalive.v2.api.creator.channel.home.CreatorChannelHomeEndToEndTest.xml` | 2 | 0 / 0 / 0 | 2026-09-10T02:49:09 |
|
||||
|
||||
XML에 source 지문이 포함되어 있다고 가정하지 않는다. 사용자 보고와 계획의 과거 성공 기록을 보조하는 자료로만 사용한다.
|
||||
|
||||
## 최종 정적 판정
|
||||
|
||||
- [Phase 1](phase-1-review.md): 확정 수정 사항 1건 → `P1-R4`.
|
||||
- [Phase 2](phase-2-review.md): 자체 구현 범위 충족, 추가 확정 발견 사항 없음. Phase 1 결함의 상세 예약 영향은 남는다.
|
||||
- [Phase 3](phase-3-review.md): 저장 번역 표시·목록 비예약 범위 충족, 추가 확정 발견 사항 없음.
|
||||
- 이번 리뷰는 테스트·제품 코드·DDL을 변경하지 않았다. Phase 1 수정과 실제 실행 검증은 후속 작업이다.
|
||||
|
||||
## 리뷰 산출물 검증
|
||||
|
||||
- `./gradlew tasks --all` — exit 0, `BUILD SUCCESSFUL`. 저장소 문서 가이드에 따른 task 목록 확인만 수행했다.
|
||||
이 명령으로 컴파일이나 테스트를 실행하지 않았다.
|
||||
- `git diff --check` — exit 0. 새 미추적 리뷰 문서는 별도 공백/개행 검사로 확인했다.
|
||||
- 문서 링크·공백·템플릿 잔여값 검사 — 5개 문서 PASS. `P1-R4`는 Phase 1 안에 한 번만 미완료 Goal로 등록됐다.
|
||||
- 리뷰 전후 source/test/DDL 32개 파일 SHA256 대조 — 동일 지문으로 PASS. 제품 코드·테스트·DDL 수정 없음.
|
||||
|
||||
## P1-R4 수정 후 증거 · 2026-09-10
|
||||
|
||||
이 절은 완료된 구현의 RED/GREEN·자동 회귀·리뷰 결과를 전달받아 동기화한 기록이다.
|
||||
이번 문서 작업에서 제품 테스트를 다시 실행한 결과가 아니며, 위 최초 정적 리뷰의 지문을 새 코드의 지문으로 재사용하지 않는다.
|
||||
|
||||
### 구현과 RED/GREEN
|
||||
|
||||
- RED: 아래 focused 명령은 두 provider 진입 이후 exit 1로 실패했다.
|
||||
`ExecutionException → DataIntegrityViolationException → H2 23505`로 감지 캐시 유일 키 충돌을 확인했다.
|
||||
- GREEN: native MySQL `ON DUPLICATE KEY UPDATE id=id`와 scalar `SELECT ... FOR UPDATE`로 승자 행을
|
||||
현재 읽기한다. native INSERT에 audit 시각을 명시하며 DB 예외를 잡고 후속 예약을 계속하지 않는다.
|
||||
- 실제 캐시 service/repository를 통한 같은 게시물 `ja`/`en` 이중 miss와 다른 게시물의 동일 본문 경합을 검증했다.
|
||||
캐시 1행으로 수렴하고 각 요청의 목표 언어 작업을 보존한다.
|
||||
|
||||
### 자동 검증 명령과 결과
|
||||
|
||||
RED focused 명령 (exit 1, 위 유일 키 충돌):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest'
|
||||
```
|
||||
|
||||
GREEN focused fresh 재실행, `BUILD SUCCESSFUL` (5분 19초):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectionCacheConcurrencyTest' --tests 'kr.co.vividnext.sodalive.content.LanguageDetectionCacheServiceTest' --rerun-tasks --no-parallel
|
||||
```
|
||||
|
||||
P1 listener/scheduler/materializer 영향 회귀, `BUILD SUCCESSFUL` (1분 09초):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.content.CreatorCommunityLanguageDetectTest' --tests 'kr.co.vividnext.sodalive.v2.creator.channel.community.translation.*' --tests 'kr.co.vividnext.sodalive.i18n.translation.*'
|
||||
```
|
||||
|
||||
P2 상세/API 소비자 회귀, `BUILD SUCCESSFUL` (1분 42초):
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.explorer.profile.creatorCommunity.*' --tests 'kr.co.vividnext.sodalive.v2.api.creator.channel.community.*'
|
||||
```
|
||||
|
||||
형식 검사, `BUILD SUCCESSFUL` (44초):
|
||||
|
||||
```bash
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
전체 `./gradlew test`는 생략했다. 공통 캐시 계약, 실제 repository 경합, 모든 직접 listener/scheduler/materializer
|
||||
테스트와 P2 상세/API 소비자가 위 명령으로 통과했고 별도로 미해결된 공통 경계가 없었다.
|
||||
과거 전체 빌드 성공을 이번 변경의 전체 테스트 실행으로 간주하지 않는다.
|
||||
|
||||
### 수정 후 리뷰와 현재 최종 판정
|
||||
|
||||
| 리뷰 | 판정 | 출처 |
|
||||
|---|---|---|
|
||||
| spec | PASS | `ses_f7657d350ffe3YUYhEHvHPuikK` |
|
||||
| quality | APPROVED | `ses_f7656994dffeR2odTLswaw6RTo` |
|
||||
|
||||
**현재 최종 판정: 기존 Phase 1 결함 `REV-P1-007`은 `P1-R4` 로컬 자동 검증 범위에서 수정 완료다.**
|
||||
H2 `MODE=MySQL` 결과는 실제 MySQL 8/InnoDB의 유일 키·격리 수준·audit 컬럼·잠금 동작 검증이 아니다.
|
||||
테스트 서버의 순서 제어 이중 miss, 다른 게시물의 공유 캐시 수렴, provider 차단 중 본문 수정과 stale 결과 차단은
|
||||
[계획의 수동 체크리스트](../plan-task.md)에 미완료로 남긴다. 실제 MySQL/Papago/live HTTP, Docker, 배포는
|
||||
검증하지 않았으며 기존 정적 리뷰의 실환경 검증 한계도 유지한다.
|
||||
@@ -1,19 +0,0 @@
|
||||
ALTER TABLE creator_community
|
||||
ADD COLUMN language_code VARCHAR(10) NULL COMMENT '본문 원문 언어 코드',
|
||||
ADD COLUMN content_revision BIGINT NOT NULL DEFAULT 0 COMMENT '본문 개정 번호';
|
||||
|
||||
CREATE TABLE creator_community_translation (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '식별자',
|
||||
creator_community_id BIGINT NOT NULL COMMENT '크리에이터 커뮤니티 게시물 식별자',
|
||||
locale VARCHAR(10) NOT NULL COMMENT '표시 대상 언어 코드',
|
||||
content TEXT NOT NULL COMMENT '번역된 본문',
|
||||
source_revision BIGINT NOT NULL COMMENT '번역 기준 본문 개정 번호',
|
||||
source_hash VARCHAR(64) NOT NULL COMMENT '번역 기준 정규화 원문 해시',
|
||||
source_language VARCHAR(10) NOT NULL COMMENT '번역 기준 원문 언어 코드',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시각',
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시각',
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT uk_creator_community_translation_post_locale UNIQUE (creator_community_id, locale),
|
||||
CONSTRAINT fk_creator_community_translation_post
|
||||
FOREIGN KEY (creator_community_id) REFERENCES creator_community (id)
|
||||
) COMMENT = '크리에이터 커뮤니티 게시물 본문 번역';
|
||||
@@ -1,144 +0,0 @@
|
||||
# OCI Blue/Green 배포 검증 endpoint 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-09-14 |
|
||||
| 요구사항 기준 | `docs/20260914_배포검증readiness및deployment엔드포인트/prd.md` |
|
||||
| 현재 Phase | Phase 1 배포 검증 endpoint |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
배포 스크립트가 애플리케이션 포트에서 readiness를, management 포트에서 readiness와 배포 식별자를 확인할 수 있다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `4/4` | 없음 | 없음 |
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- `spring-boot-starter-actuator` 의존성 추가.
|
||||
- `application.yml`(main/test)의 포트·probe·exposure·deployment property 설정.
|
||||
- `DeploymentEndpoint` custom Actuator endpoint 구현.
|
||||
- `SecurityConfig`의 `/readyz` permitAll 최소 변경.
|
||||
- 위 동작을 검증하는 통합 test.
|
||||
|
||||
### 제외
|
||||
|
||||
- Kubernetes 의존성·설정.
|
||||
- 배포 스크립트(`scripts/`, `appspec.yml`) 변경.
|
||||
- 신규 추상화/라이브러리, 기존 API 변경.
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 기술 스택: Kotlin 1.6.21, Spring Boot 2.7.14, Gradle Kotlin DSL, JUnit 5.
|
||||
- 배포 식별자는 property placeholder만 사용하고 별도 환경변수 파싱·fallback 코드를 만들지 않는다.
|
||||
- 응답 필드명은 snake_case 고정(`application_artifact_version`, `config_commit`).
|
||||
- 검증: 신규 test 우선 실행 후 `./gradlew test` 전체 회귀(공통 보안·설정 변경이므로 전체 회귀 필요).
|
||||
|
||||
## Phase 1 배포 검증 endpoint
|
||||
|
||||
**Phase 결과:** readiness와 deployment endpoint가 지정된 포트 경계에 맞게 동작한다.
|
||||
|
||||
**선행조건:** 없음.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`~`P1-T4` 완료, `./gradlew test`와 `./gradlew bootJar` 성공 기록.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 Actuator 의존성과 기본 설정
|
||||
|
||||
**Goal 실행 `P1-T1`:** Actuator를 추가하고 포트·exposure·deployment property 계약을 설정에 반영한다.
|
||||
|
||||
- **시작 조건:** PRD `DEPLOY-001`, `DEPLOY-002`, `DEPLOY-005`.
|
||||
- **완료 증거:** 설정 파일 diff와 애플리케이션 컨텍스트 기동 test 통과.
|
||||
- **범위 밖:** endpoint 구현, security 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `build.gradle.kts`
|
||||
- Modify: `src/main/resources/application.yml`
|
||||
- Modify: `src/test/resources/application.yml`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointInjectedValueIntegrationTest.kt`
|
||||
|
||||
- [x] **RED:** `DeploymentEndpointIntegrationTest`에서 management 포트 readiness/deployment 호출 test를 작성했다.
|
||||
- [x] **RED 확인:** Actuator 부재로 `Could not resolve placeholder 'local.management.port'` 실패를 확인했다.
|
||||
- [x] **GREEN:** `spring-boot-starter-actuator`와 포트·exposure·deployment 설정을 추가했다.
|
||||
- [x] **GREEN 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.deployment.*'` 통과.
|
||||
- [x] **REFACTOR:** deprecated `LocalServerPort` import 정리, `./gradlew ktlintCheck` 성공.
|
||||
|
||||
#### Task 1.2 readiness 접근 허용
|
||||
|
||||
**Goal 실행 `P1-T2`:** `/readyz`와 management readiness가 인증 없이 200을 반환한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** readiness test 통과.
|
||||
- **범위 밖:** 기타 endpoint의 security 정책 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/configs/SecurityConfig.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
|
||||
|
||||
- [x] **RED:** `/readyz` 200 기대 test 작성(`DeploymentEndpointIntegrationTest`).
|
||||
- [x] **RED 확인:** Actuator/보안 미적용 상태 실패 확인.
|
||||
- [x] **GREEN:** `SecurityConfig`에 `/readyz` permitAll과 `EndpointRequest.toAnyEndpoint()` permitAll 추가.
|
||||
- [x] **GREEN 확인:** readiness test 2건 통과.
|
||||
- [x] **REFACTOR:** 추가 변경 없음.
|
||||
|
||||
#### Task 1.3 deployment custom Actuator endpoint
|
||||
|
||||
**Goal 실행 `P1-T3`:** `/actuator/deployment`가 snake_case 배포 식별자를 반환한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** 기본값·주입값 test 통과.
|
||||
- **범위 밖:** 추가 필드.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpoint.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
|
||||
|
||||
- [x] **RED:** 기본값(`local`, 40자리 zero)과 주입값 검증 test 작성.
|
||||
- [x] **RED 확인:** endpoint 미구현 실패 확인.
|
||||
- [x] **GREEN:** `@Endpoint(id = "deployment")` + `@ReadOperation` 구현.
|
||||
- [x] **GREEN 확인:** 기본값·주입값 test 통과.
|
||||
- [x] **REFACTOR:** `./gradlew ktlintCheck` 성공.
|
||||
|
||||
#### Task 1.4 포트 접근 경계 검증
|
||||
|
||||
**Goal 실행 `P1-T4`:** 애플리케이션 포트에서 `/actuator/deployment`가 노출되지 않음을 검증한다.
|
||||
|
||||
- **시작 조건:** `P1-T3` 완료.
|
||||
- **완료 증거:** 애플리케이션 포트 접근 test 통과.
|
||||
- **범위 밖:** 그 외 경로 노출 정책.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/deployment/DeploymentEndpointIntegrationTest.kt`
|
||||
|
||||
- [x] **RED:** 애플리케이션 포트 접근이 200이 아님을 기대하는 test 작성.
|
||||
- [x] **RED 확인:** 초기 404 기대 단정이 `401 UNAUTHORIZED`로 실패함을 확인.
|
||||
- [x] **GREEN:** management 포트 분리로 애플리케이션 포트에서는 endpoint가 매핑되지 않고 401로 차단됨을 확인, 단정을 "200 아님 + 배포 식별자 미포함" 계약으로 정정.
|
||||
- [x] **GREEN 확인:** 해당 test 통과.
|
||||
- [x] **REFACTOR:** `./gradlew test` 전체 회귀 성공.
|
||||
|
||||
### Phase Gate `P1-GATE`
|
||||
|
||||
- [x] `./gradlew test` 성공.
|
||||
- [x] `./gradlew bootJar` 성공 및 실행 가능한 JAR 생성 확인.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 2026-09-14 `./gradlew test --tests 'kr.co.vividnext.sodalive.deployment.*'` → RED 5 failed → GREEN 5 passed.
|
||||
- 2026-09-14 `./gradlew ktlintCheck` → BUILD SUCCESSFUL.
|
||||
- 2026-09-14 `./gradlew test` → BUILD SUCCESSFUL (전체 회귀).
|
||||
- 2026-09-14 `./gradlew bootJar` → `build/libs/sodalive-0.0.1-SNAPSHOT.jar`(141MB), `Main-Class: org.springframework.boot.loader.JarLauncher` 확인.
|
||||
- 참고: 기존 test 실행에는 `JWT_SECRET` 환경변수가 필요하며 이는 이번 변경 이전부터 동일한 전제다.
|
||||
- 참고: 애플리케이션 포트에서 `/actuator/deployment`는 404가 아니라 401로 차단된다. `EndpointRequest` matcher가 management 포트 분리 시 애플리케이션 포트 요청에 매칭되지 않기 때문이며, 노출되지 않는다는 요구는 충족한다.
|
||||
@@ -1,101 +0,0 @@
|
||||
# OCI Blue/Green 배포 검증용 readiness 및 deployment endpoint PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 완료 |
|
||||
| 작성일 | 2026-09-14 |
|
||||
| 최종 수정일 | 2026-09-14 |
|
||||
| 대상 제품 | sodalive server 배포 검증 endpoint |
|
||||
| 작성자·결정권자 | 요청 사용자 |
|
||||
| 관련 구현 계획 | `docs/20260914_배포검증readiness및deployment엔드포인트/plan-task.md` |
|
||||
| 관련 review | 없음 |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
OCI Blue/Green 배포에서 배포 스크립트가 새로 기동한 인스턴스의 기동 완료 여부와, 실제로 어떤 artifact/config가 올라갔는지를
|
||||
HTTP로 확인할 수 있어야 한다. 이를 위해 Spring Boot Actuator의 readiness probe와 배포 식별 정보를 반환하는 custom Actuator
|
||||
endpoint를 제공한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- 현재 프로젝트에는 Actuator 의존성과 health endpoint가 없어 배포 스크립트가 기동 완료를 판정할 방법이 없다.
|
||||
- 어떤 artifact 버전과 config commit이 배포되었는지 런타임에서 확인할 수 없다.
|
||||
- 모든 요청이 Spring Security의 `anyRequest().authenticated()`로 보호되어 있어 인증 없는 probe 호출이 불가능하다.
|
||||
|
||||
해결 판단 기준: 배포 스크립트가 인증 없이 `GET :8080/readyz`로 기동을 판정하고, 운영 전용 포트에서
|
||||
`GET :8082/actuator/deployment`로 배포 식별자를 확인할 수 있다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 애플리케이션 포트(8080)에서 인증 없이 readiness 확인 가능.
|
||||
- Management 포트(8082)에서 readiness와 배포 식별 정보 확인 가능.
|
||||
- 배포 식별자는 환경변수로 주입하고, 환경변수가 없어도 로컬/테스트 기동이 가능해야 한다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- Kubernetes 관련 의존성·설정 추가.
|
||||
- liveness 기반 자동 재기동 정책, 배포 스크립트 자체 변경.
|
||||
- health details/components 공개, 신규 추상화 계층 도입.
|
||||
|
||||
## 5. 권한
|
||||
|
||||
- 인증 주체: 없음(배포 스크립트/로드밸런서).
|
||||
- `/readyz`: 애플리케이션 포트에서 인증 없이 허용.
|
||||
- `/actuator/**`: management 포트(8082)에서만 노출. 애플리케이션 포트에서는 노출하지 않는다.
|
||||
- `/readyz` 응답에는 민감 정보나 배포 식별자를 포함하지 않는다.
|
||||
|
||||
## 6. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `DEPLOY-001` | 확정 | `spring-boot-starter-actuator` 의존성을 추가한다(중복 추가 금지). | `build.gradle.kts`에 1회만 존재 | `P1-T1` |
|
||||
| `DEPLOY-002` | 확정 | 애플리케이션 포트 8080, management 포트 8082로 분리한다. | `application.yml`에 `server.port: 8080`, `management.server.port: 8082` | `P1-T1` |
|
||||
| `DEPLOY-003` | 확정 | health probes와 additional path를 활성화해 `/readyz`를 제공한다. | `GET :8080/readyz` → 200 | `P1-T2` |
|
||||
| `DEPLOY-004` | 확정 | management 포트에서 readiness를 제공한다. | `GET :8082/actuator/health/readiness` → 200 | `P1-T2` |
|
||||
| `DEPLOY-005` | 확정 | web exposure는 `health`, `deployment`만 포함하고 health details/components는 공개하지 않는다. | `management.endpoints.web.exposure.include: health,deployment`, `show-details: never`, `show-components: never` | `P1-T1` |
|
||||
| `DEPLOY-006` | 확정 | `DEPLOY_ARTIFACT_VERSION`/`DEPLOY_CONFIG_COMMIT`을 property placeholder로 연결한다. | 환경변수 없으면 `local`, 40자리 zero commit | `P1-T3` |
|
||||
| `DEPLOY-007` | 확정 | `/actuator/deployment`를 custom Actuator endpoint로 구현한다. | REST controller가 아닌 `@Endpoint` 구현체 존재 | `P1-T3` |
|
||||
| `DEPLOY-008` | 확정 | 응답 필드는 정확히 `application_artifact_version`, `config_commit`이다. | snake_case JSON 검증 test 통과 | `P1-T3` |
|
||||
| `DEPLOY-009` | 확정 | 애플리케이션 포트에서 `/actuator/deployment`가 노출되지 않는다. | `GET :8080/actuator/deployment` → 200 아님 | `P1-T4` |
|
||||
| `DEPLOY-010` | 확정 | Security 정책 최소 변경으로 readiness가 401/403/redirect 되지 않는다. | `SecurityConfig`에 `/readyz` permitAll 추가 | `P1-T2` |
|
||||
|
||||
## 7. API 계약
|
||||
|
||||
### 7.1 `GET :8080/readyz`
|
||||
|
||||
- 인증 불필요. 응답 body는 Actuator 기본 health 응답(`{"status":"UP"}`), 세부 정보 비공개.
|
||||
|
||||
### 7.2 `GET :8082/actuator/health/readiness`
|
||||
|
||||
- 응답: `{"status":"UP"}`.
|
||||
|
||||
### 7.3 `GET :8082/actuator/deployment`
|
||||
|
||||
```json
|
||||
{
|
||||
"application_artifact_version": "local",
|
||||
"config_commit": "0000000000000000000000000000000000000000"
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 성공 기준
|
||||
|
||||
- [x] `GET :8080/readyz`가 200을 반환한다. (`DEPLOY-003`, `DEPLOY-010`)
|
||||
- [x] `GET :8082/actuator/health/readiness`가 200을 반환한다. (`DEPLOY-004`)
|
||||
- [x] `GET :8082/actuator/deployment`가 주입값을 snake_case JSON으로 반환한다. (`DEPLOY-006`~`DEPLOY-008`)
|
||||
- [x] `GET :8080/actuator/deployment`가 200을 반환하지 않는다(401로 차단). (`DEPLOY-009`)
|
||||
- [x] `./gradlew test`, `./gradlew bootJar` 성공.
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
없음.
|
||||
|
||||
## 10. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-14 | `DEC-001` | 확정 | Kubernetes 의존성 없이 Actuator 기본 probe만 사용 | 사용자 요구사항 범위 제한 | `DEPLOY-003`, `DEPLOY-004` |
|
||||
| 2026-09-14 | `DEC-002` | 확정 | 배포 식별자는 별도 파싱 코드 없이 Spring property placeholder로만 연결 | 사용자 요구사항, 단순성 유지 | `DEPLOY-006` |
|
||||
| 2026-09-14 | `DEC-003` | 확정 | `/actuator/deployment`는 `@Endpoint` 기반 custom Actuator endpoint로 구현 | 사용자 요구사항(REST controller 금지) | `DEPLOY-007` |
|
||||
@@ -1,388 +0,0 @@
|
||||
# 크리에이터 시작 DM 방 생성 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`로 task 단위 구현을 진행한다. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `POST /api/v2/user-creator-chat/rooms/create`가 기존 리스너 시작 DM 생성과 새 크리에이터 시작 DM 생성을 모두 지원한다.
|
||||
|
||||
**Architecture:** 기존 endpoint와 응답 DTO를 유지하고 request DTO만 `recipientId` 중심으로 확장한다. Controller에서 `request.recipientMemberId()`로 단일 상대 회원 ID를 산출하고, 기존 `UserCreatorChatService.createOrGetRoom(member, recipientId)` 흐름과 `validateRecipient` 정책을 재사용한다. 메시지 저장/전달/푸시는 기존 WebSocket 및 메시지 발송 흐름에 맡긴다.
|
||||
|
||||
**Tech Stack:** Kotlin, Spring Boot 2.7.14, Java 17, JUnit 5, Mockito, Gradle Wrapper
|
||||
|
||||
---
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | 구현 중 |
|
||||
| 작성일 | 2026-09-14 |
|
||||
| 요구사항 기준 | `docs/20260914_크리에이터_리스너_DM방_생성/prd.md` |
|
||||
| API 기준 | PRD §8 |
|
||||
| 현재 Phase | Phase 1 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
기존 `creatorId` 요청을 깨지 않으면서 신규 `recipientId` 요청으로 발신자 역할과 무관하게 유저-크리에이터 DM 방을 생성/조회할 수 있게 한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 진행 중 | `3/3` | `P1-GATE` | 전체 `./gradlew build`가 `:test` 단계에서 timeout 됨 |
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- `POST /api/v2/user-creator-chat/rooms/create` request DTO 확장
|
||||
- `recipientId` 표준 필드 추가
|
||||
- 기존 `creatorId` alias 유지
|
||||
- `recipientId`/`creatorId` 누락·불일치 validation
|
||||
- 기존 방 재사용 및 새 방 생성 회귀 검증
|
||||
- `/create`가 메시지를 만들지 않는다는 회귀 검증
|
||||
|
||||
### 제외
|
||||
|
||||
- 첫 메시지 발송 request 추가
|
||||
- WebSocket 프로토콜 변경
|
||||
- FCM 푸시 정책 변경
|
||||
- DB 스키마 변경
|
||||
- 새 endpoint 추가
|
||||
- 기존 `creatorId` alias 제거
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 공개 API 응답 `CreateUserCreatorChatRoomResponse(roomId)`는 변경하지 않는다.
|
||||
- 신규 클라이언트는 `recipientId`를 사용하지만, 기존 클라이언트의 `creatorId` 요청도 허용한다.
|
||||
- `recipientId`와 `creatorId`가 모두 있고 값이 다르면 `common.error.invalid_request`로 거부한다.
|
||||
- 권한/상태 검증은 기존 `UserCreatorChatService.validateRecipient` 정책을 재사용한다.
|
||||
- 새 공용 abstraction, 새 dependency, DB migration은 만들지 않는다.
|
||||
- 구현 Task는 RED → GREEN → REFACTOR 순서로 수행한다.
|
||||
|
||||
## 파일 구조 계획
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
|
||||
- `CreateUserCreatorChatRoomRequest`에 `recipientId` nullable 필드와 기존 `creatorId` nullable alias를 둔다.
|
||||
- 단일 상대 ID 산출 함수를 DTO 내부에 둔다.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
|
||||
- `request.recipientMemberId()` 결과를 `service.createOrGetRoom(member, recipientId)`에 전달한다.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
||||
- 함수 파라미터명만 의미에 맞게 `recipientId`로 바꾸는 것을 검토한다. 동작은 유지한다.
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||
- 새 방 생성, 기존 방 재사용, 메시지 미생성, validation 회귀를 검증한다.
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt`
|
||||
- request alias 해석 단위 테스트 또는 controller mapping 테스트를 보강한다.
|
||||
|
||||
## Phase 1: `/create` request 일반화
|
||||
|
||||
**Phase 결과:** 기존 클라이언트와 신규 클라이언트 모두 같은 `/create` endpoint로 DM 방을 생성/조회할 수 있다.
|
||||
|
||||
**선행조건:** PRD `DEC-001`~`DEC-003` 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`~`P1-T3`과 `P1-GATE` 완료, 검증 기록 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 request DTO alias 계약 추가
|
||||
|
||||
**Goal 실행 `P1-T1`:** `CreateUserCreatorChatRoomRequest`가 `recipientId`와 기존 `creatorId` alias에서 하나의 상대 회원 ID를 산출한다.
|
||||
|
||||
- **시작 조건:** PRD `DMROOM-001~004`, `DEC-003` 확인.
|
||||
- **완료 증거:** DTO/Controller focused test 통과와 request 계약 문서 일치.
|
||||
- **범위 밖:** 방 생성 repository 동작 변경, 메시지 발송.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatControllerMappingTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: request JSON `{ "recipientId": Long? }`, `{ "creatorId": Long? }`
|
||||
- Produces: `CreateUserCreatorChatRoomRequest.recipientMemberId(): Long`
|
||||
|
||||
- [x] **RED:** `UserCreatorChatControllerMappingTest`에 `recipientId`만 보낸 요청이 service에 해당 ID를 전달하는 실패 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun shouldCreateRoomWithRecipientId() {
|
||||
val service = Mockito.mock(UserCreatorChatService::class.java)
|
||||
val controller = UserCreatorChatController(service)
|
||||
val member = Member(email = "creator@test.com", password = "pw", nickname = "creator")
|
||||
member.id = 10L
|
||||
Mockito.`when`(service.createOrGetRoom(member, 20L))
|
||||
.thenReturn(CreateUserCreatorChatRoomResponse(roomId = 30L))
|
||||
|
||||
val response = controller.createOrGetRoom(
|
||||
member,
|
||||
CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = null)
|
||||
)
|
||||
|
||||
Mockito.verify(service).createOrGetRoom(member, 20L)
|
||||
assertEquals(30L, response.data!!.roomId)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED:** 기존 `creatorId`만 보낸 요청도 service에 해당 ID를 전달하는 실패 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun shouldCreateRoomWithLegacyCreatorId() {
|
||||
val request = CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = 20L)
|
||||
|
||||
assertEquals(20L, request.recipientMemberId())
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED:** 두 필드가 모두 없거나 서로 다르면 `common.error.invalid_request`가 발생하는 실패 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun shouldRejectMissingOrConflictingRecipientIds() {
|
||||
val missing = assertThrows(SodaException::class.java) {
|
||||
CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = null).recipientMemberId()
|
||||
}
|
||||
assertEquals("common.error.invalid_request", missing.messageKey)
|
||||
|
||||
val conflict = assertThrows(SodaException::class.java) {
|
||||
CreateUserCreatorChatRoomRequest(recipientId = 20L, creatorId = 21L).recipientMemberId()
|
||||
}
|
||||
assertEquals("common.error.invalid_request", conflict.messageKey)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest`를 실행해 `recipientId` 생성자 파라미터 또는 `recipientMemberId()` 부재로 실패하는 것을 확인한다.
|
||||
- [x] **GREEN:** DTO를 최소 확장한다.
|
||||
|
||||
```kotlin
|
||||
data class CreateUserCreatorChatRoomRequest(
|
||||
val recipientId: Long? = null,
|
||||
val creatorId: Long? = null
|
||||
) {
|
||||
fun recipientMemberId(): Long {
|
||||
if (recipientId != null && creatorId != null && recipientId != creatorId) {
|
||||
throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
return recipientId ?: creatorId ?: throw SodaException(messageKey = "common.error.invalid_request")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **GREEN:** Controller에서 `request.recipientMemberId()`를 service에 전달한다.
|
||||
|
||||
```kotlin
|
||||
ApiResponse.ok(service.createOrGetRoom(member, request.recipientMemberId()))
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 테스트 helper 중 이번 Task가 만든 중복만 정리하고 `./gradlew ktlintCheck` 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 1.2 service 방 생성 정책 회귀 보강
|
||||
|
||||
**Goal 실행 `P1-T2`:** 발신자 역할과 무관하게 기존 수신자 검증, 기존 방 재사용, 메시지 미생성 정책을 유지한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** `UserCreatorChatServiceTest` focused test 통과.
|
||||
- **범위 밖:** `validateRecipient` 정책 변경, WebSocket 발송 구현.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `createOrGetRoom(member: Member, recipientId: Long)`
|
||||
- Produces: `CreateUserCreatorChatRoomResponse(roomId: Long)`
|
||||
|
||||
- [x] **RED:** 크리에이터 역할 회원이 일반 회원에게 방을 생성할 수 있는 실패 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun shouldCreateRoomWhenCreatorStartsDmToUser() {
|
||||
val creator = member(1L, "creator").apply { role = MemberRole.CREATOR }
|
||||
val user = member(2L, "user")
|
||||
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user))
|
||||
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(null)
|
||||
Mockito.`when`(roomRepository.save(Mockito.any(UserCreatorChatRoom::class.java))).thenReturn(room(10L))
|
||||
|
||||
val response = service.createOrGetRoom(creator, 2L)
|
||||
|
||||
assertEquals(10L, response.roomId)
|
||||
Mockito.verify(participantRepository).save(Mockito.argThat { it.member == creator })
|
||||
Mockito.verify(participantRepository).save(Mockito.argThat { it.member == user })
|
||||
Mockito.verifyNoInteractions(messageRepository)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED:** 기존 활성 방이 있으면 새 방과 메시지를 만들지 않는 회귀 테스트를 작성한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun shouldReturnExistingRoomWithoutCreatingMessage() {
|
||||
val creator = member(1L, "creator").apply { role = MemberRole.CREATOR }
|
||||
val user = member(2L, "user")
|
||||
val existingRoom = room(10L)
|
||||
Mockito.`when`(memberRepository.findById(2L)).thenReturn(Optional.of(user))
|
||||
Mockito.`when`(roomRepository.findActiveRoomByParticipantMemberIds(1L, 2L)).thenReturn(existingRoom)
|
||||
|
||||
val response = service.createOrGetRoom(creator, 2L)
|
||||
|
||||
assertEquals(10L, response.roomId)
|
||||
Mockito.verify(roomRepository, Mockito.never()).save(Mockito.any(UserCreatorChatRoom::class.java))
|
||||
Mockito.verifyNoInteractions(messageRepository)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest`를 실행해 누락된 동작 또는 helper compile 실패를 확인한다.
|
||||
- [x] **GREEN:** 필요하면 `createOrGetRoom(member, creatorId)` 파라미터명을 `recipientId`로 변경한다. 함수 내부 로직은 기존 `memberRepository.findById`, `validateRecipient`, `findActiveRoomByParticipantMemberIds`, participant 저장 흐름을 유지한다.
|
||||
|
||||
```kotlin
|
||||
@Transactional
|
||||
fun createOrGetRoom(member: Member, recipientId: Long): CreateUserCreatorChatRoomResponse {
|
||||
val recipient = memberRepository.findById(recipientId).orElseThrow {
|
||||
SodaException(messageKey = "message.error.recipient_not_found")
|
||||
}
|
||||
validateRecipient(member, recipient)
|
||||
|
||||
val existingRoom = roomRepository.findActiveRoomByParticipantMemberIds(member.id!!, recipient.id!!)
|
||||
if (existingRoom != null) {
|
||||
return CreateUserCreatorChatRoomResponse(roomId = existingRoom.id!!)
|
||||
}
|
||||
|
||||
val room = roomRepository.save(UserCreatorChatRoom())
|
||||
participantRepository.save(UserCreatorChatParticipant(room, member))
|
||||
participantRepository.save(UserCreatorChatParticipant(room, recipient))
|
||||
return CreateUserCreatorChatRoomResponse(roomId = room.id!!)
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 파라미터명 변경으로 테스트/문서와 의미가 맞는지 확인하고 새 abstraction 없이 종료한다.
|
||||
|
||||
#### Task 1.3 통합 회귀 검증 보강
|
||||
|
||||
**Goal 실행 `P1-T3`:** 기존 리스너 시작 흐름과 신규 크리에이터 시작 흐름이 통합 환경에서 모두 동작한다.
|
||||
|
||||
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
|
||||
- **완료 증거:** 통합 테스트와 focused 회귀 명령 통과.
|
||||
- **범위 밖:** 전체 메시징 E2E, 푸시 발송 검증.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceIntegrationTest.kt`
|
||||
|
||||
- [x] **RED:** 리스너가 `creatorId` alias로 기존처럼 방을 만들 수 있는 통합 회귀 테스트를 작성한다.
|
||||
- [x] **RED:** 크리에이터가 일반 회원 ID로 방을 만들 수 있는 통합 테스트를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest`를 실행해 현재 계약 미지원 실패를 확인한다.
|
||||
- [x] **GREEN:** `P1-T1`, `P1-T2` 구현만으로 통합 테스트를 통과시킨다. 추가 production 코드를 만들지 않는다.
|
||||
- [x] **GREEN 확인:** 같은 통합 테스트를 다시 실행해 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 통합 테스트 fixture 중 이번 Task가 만든 중복만 정리한다.
|
||||
|
||||
### 완료 조건
|
||||
|
||||
- [x] `P1-T1`, `P1-T2`, `P1-T3`의 체크박스와 완료 증거가 모두 충족됐다.
|
||||
- [x] PRD `DMROOM-001~007`이 구현 또는 명시적 제외로 추적된다.
|
||||
- [x] 기존 `creatorId` 요청과 신규 `recipientId` 요청의 차이가 문서와 테스트에 남아 있다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** `/create` request 호환성, 방 생성 정책, 메시지 비생성 정책을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** Phase 1의 모든 Task goal 완료.
|
||||
- **완료 증거:** 아래 명령 통과와 Progress 기록.
|
||||
- **범위 밖:** 실패와 무관한 채팅방 목록, openRoom 응답, WebSocket 기능 수정.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest
|
||||
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** 모든 명령이 `BUILD SUCCESSFUL`이고, `/create` 호출만으로 메시지 저장/전달/푸시가 발생하지 않는 테스트가 통과한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | PRD 확정 | 아니요 | request 계약 재확인 |
|
||||
| 2 | `P1-T2` | `P1-T1` | 아니요 | 기존 service 정책 대조 |
|
||||
| 3 | `P1-T3` | `P1-T1`, `P1-T2` | 아니요 | 통합 fixture 보정 |
|
||||
| 4 | `P1-GATE` | Phase 1 Task 전체 | 아니요 | 실패 소유 Task로 회귀 수정 |
|
||||
|
||||
```text
|
||||
P1-T1 → P1-T2 → P1-T3 → P1-GATE
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- `/create`에서 메시지를 저장하거나 발송하지 않는다.
|
||||
- 기존 `creatorId` request를 제거하지 않는다.
|
||||
- `CreateUserCreatorChatRoomResponse` 필드를 바꾸지 않는다.
|
||||
- DB schema, WebSocket message type, FCM event 계약을 변경하지 않는다.
|
||||
- test를 삭제·skip·완화하지 않는다.
|
||||
- 요청 범위 밖 리팩터링과 공용 abstraction을 추가하지 않는다.
|
||||
|
||||
## Progress
|
||||
|
||||
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
|
||||
|
||||
### P1-T1 1차 실행 — 2026-09-14
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: `CreateUserCreatorChatRoomRequest`에 `recipientId` 표준 필드와 `creatorId` alias를 추가하고, controller가 `recipientMemberId()` 결과를 service에 전달하도록 구현했다.
|
||||
- 왜: `DMROOM-001~004`, `DEC-003` 기준으로 신규 클라이언트와 기존 클라이언트 request를 모두 지원하기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest` — RED 확인, `recipientId` 생성자 파라미터와 `recipientMemberId()` 부재로 `compileTestKotlin` 실패.
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest` — GREEN 확인, `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: `P1-T2` 진행.
|
||||
|
||||
### P1-T2 1차 실행 — 2026-09-14
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 크리에이터 시작 DM 방 생성, 기존 방 재사용, `/create` 메시지 미생성 정책을 `UserCreatorChatServiceTest`로 고정했다. production service는 파라미터명만 `recipientId`로 정리했다.
|
||||
- 왜: `DMROOM-005~007`, `DEC-001`, `DEC-002` 기준으로 기존 수신자 검증과 메시지 발송 분리 정책을 유지하기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest` — `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: `P1-T3` 진행.
|
||||
|
||||
### P1-T3 1차 실행 — 2026-09-14
|
||||
|
||||
- 상태: 완료
|
||||
- 무엇을: 리스너 시작 기존 흐름과 크리에이터 시작 신규 흐름을 `UserCreatorChatServiceIntegrationTest`에 추가했다.
|
||||
- 왜: 실제 JPA 통합 환경에서 양방향 방 생성과 메시지 미생성을 확인하기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` — `BUILD SUCCESSFUL`.
|
||||
- 남은 항목: 없음.
|
||||
- 다음 행동: `P1-GATE` 진행.
|
||||
|
||||
### P1-GATE 1차 실행 — 2026-09-14
|
||||
|
||||
- 상태: 차단 감사 중
|
||||
- 무엇을: Phase 1 focused test와 lint를 검증하고 전체 build를 시도했다.
|
||||
- 왜: `/create` request 호환성, 방 생성 정책, 메시지 비생성 정책과 공통 품질 기준을 판정하기 위해서다.
|
||||
- 어떻게:
|
||||
- `./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest` — `BUILD SUCCESSFUL`.
|
||||
- `./gradlew ktlintCheck` — 최초 import 정렬 오류로 실패 후 수정, 재실행 `BUILD SUCCESSFUL`.
|
||||
- `./gradlew build` — 120초 timeout, 재시도 600초 timeout. 두 번 모두 `:test` 단계에서 종료되지 않아 전체 build 성공 증거는 확보하지 못했다.
|
||||
- 남은 항목: 전체 build timeout 원인 분리 또는 별도 승인.
|
||||
- 다음 행동: 변경 범위 리뷰와 전체 suite hang 원인 보고.
|
||||
|
||||
## Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 Goal/문서 |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-14 | `DEC-001` | 확정 | `/create`는 방 생성/조회만 처리한다. | 사용자 선택 A | `P1-T2`, PRD `DEC-001` |
|
||||
| 2026-09-14 | `DEC-002` | 확정 | 수신자는 `MemberRole` 제한 없이 기존 수신자 검증 정책을 따른다. | 사용자 선택 B | `P1-T2`, PRD `DEC-002` |
|
||||
| 2026-09-14 | `DEC-003` | 확정 | `recipientId`를 표준 필드로 추가하고 `creatorId` alias를 유지한다. | 기존 클라이언트 호환 필요 | `P1-T1`, PRD `DEC-003` |
|
||||
|
||||
## 발견된 문제
|
||||
|
||||
| ID | 심각도 | 상태 | 발견 내용 | 영향 Goal | 처리 계획 |
|
||||
|---|---|---|---|---|---|
|
||||
| 없음 | - | - | - | - | - |
|
||||
@@ -1,159 +0,0 @@
|
||||
# PRD: 크리에이터 시작 DM 방 생성
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | 구현 기준 확정 |
|
||||
| 작성일 | 2026-09-14 |
|
||||
| 최종 수정일 | 2026-09-14 |
|
||||
| 대상 제품 | 유저-크리에이터 DM |
|
||||
| 작성자·결정권자 | 사용자 인터뷰 기준 |
|
||||
| 관련 구현 계획 | `docs/20260914_크리에이터_리스너_DM방_생성/plan-task.md` |
|
||||
| 관련 review | 없음 |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
현재 `POST /api/v2/user-creator-chat/rooms/create`는 일반 유저가 크리에이터에게 DM을 시작할 때 방을 생성하거나 기존 방을 반환한다. 이번 요구사항은 같은 DM 도메인에서 크리에이터도 상대 회원에게 먼저 DM 방을 만들 수 있게 하는 것이다.
|
||||
|
||||
첫 메시지 저장, 실시간 전달, 푸시는 이번 API에서 처리하지 않는다. 방 생성 후 메시지는 기존 WebSocket 텍스트 발송 또는 기존 메시지 발송 흐름을 사용한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- 현재 생성 요청 DTO는 `creatorId`만 받기 때문에 API 의미가 “일반 유저가 크리에이터에게 DM 시작”에 고정되어 있다.
|
||||
- 크리에이터가 먼저 상대 회원에게 연락하려면 같은 유저-크리에이터 DM 방을 만들 수 있는 서버 계약이 필요하다.
|
||||
- 기존 클라이언트는 이미 `creatorId`를 보내고 있으므로, 새 필드만 강제하면 기존 리스너 시작 DM 생성 흐름이 깨진다.
|
||||
|
||||
문제 해결 여부는 신규 클라이언트가 `recipientId`로 방을 만들 수 있고, 기존 클라이언트가 `creatorId`로 같은 API를 계속 사용할 수 있는지로 판단한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- `POST /api/v2/user-creator-chat/rooms/create`가 발신자 역할과 무관하게 상대 회원 ID로 DM 방을 생성하거나 기존 방을 반환한다.
|
||||
- 신규 표준 request 필드는 `recipientId`로 한다.
|
||||
- 기존 클라이언트 호환을 위해 `creatorId` request 필드는 alias로 유지한다.
|
||||
- 기존 리스너 → 크리에이터 방 생성 흐름은 계속 동작한다.
|
||||
- 크리에이터 → 상대 회원 방 생성 흐름도 같은 참여자/중복 방 정책을 따른다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- `/create`에서 첫 텍스트 메시지를 저장하거나 발송하지 않는다.
|
||||
- `/create`에서 WebSocket 메시지를 대신 보내지 않는다.
|
||||
- FCM 푸시 발송 정책은 변경하지 않는다.
|
||||
- DB 스키마를 변경하지 않는다.
|
||||
- 새 endpoint를 만들지 않는다.
|
||||
- 기존 `roomId` 응답 구조를 변경하지 않는다.
|
||||
- `creatorId` alias 제거 시점이나 클라이언트 마이그레이션 일정은 이번 범위에서 정하지 않는다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 주요 작업 | 사용 환경 |
|
||||
|---|---|---|---|
|
||||
| 리스너/일반 유저 | 크리에이터에게 먼저 DM 시작 | 상대 회원 지정 후 방 생성 | 모바일 클라이언트 |
|
||||
| 크리에이터 | 상대 회원에게 먼저 DM 시작 | 상대 회원 지정 후 방 생성 | 모바일 클라이언트 |
|
||||
|
||||
권한과 거부 조건:
|
||||
|
||||
- 인증 주체: 로그인 `Member`
|
||||
- 허용 역할: 별도 `MemberRole` 제한 없음
|
||||
- 수신자 조건: 활성 회원, AI 캐릭터 아님, 자기 자신 아님, 차단 정책 통과
|
||||
- 미인증: 기존처럼 `common.error.bad_credentials`
|
||||
- 수신자 없음 또는 AI 캐릭터: 기존 정책에 맞춰 `message.error.recipient_not_found`
|
||||
- 비활성 수신자: 기존처럼 `message.error.recipient_inactive`
|
||||
- 자기 자신: 기존처럼 `common.error.invalid_request`
|
||||
- 상대가 발신자를 차단한 경우: 기존처럼 `message.error.blocked_by_recipient`
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 인증 회원이 DM을 시작할 상대 회원을 선택한다.
|
||||
2. 클라이언트는 `POST /api/v2/user-creator-chat/rooms/create`에 `recipientId`를 보낸다.
|
||||
3. 서버는 기존 alias인 `creatorId`만 온 요청도 허용한다.
|
||||
4. 서버는 발신자와 수신자 사이의 활성 DM 방이 있으면 기존 `roomId`를 반환한다.
|
||||
5. 활성 DM 방이 없으면 방과 두 참여자를 생성하고 새 `roomId`를 반환한다.
|
||||
6. 클라이언트는 반환된 `roomId`로 기존 방 입장 및 메시지 발송 흐름을 진행한다.
|
||||
|
||||
## 7. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `DMROOM-001` | 확정 | `/create`는 `recipientId`를 표준 상대 회원 ID로 받아 방을 생성/조회한다. | `recipientId`만 보낸 요청이 기존 `roomId` 응답을 받는다. | `P1-T1` |
|
||||
| `DMROOM-002` | 확정 | 기존 클라이언트 호환을 위해 `creatorId`만 보낸 요청도 계속 허용한다. | 기존 `creatorId` request가 깨지지 않고 동일한 서비스 흐름을 탄다. | `P1-T1` |
|
||||
| `DMROOM-003` | 확정 | `recipientId`와 `creatorId`가 모두 없으면 잘못된 요청으로 거부한다. | `common.error.invalid_request`로 실패한다. | `P1-T1` |
|
||||
| `DMROOM-004` | 확정 | 두 필드가 모두 있고 값이 다르면 모호한 요청으로 거부한다. | `common.error.invalid_request`로 실패하고 방을 만들지 않는다. | `P1-T1` |
|
||||
| `DMROOM-005` | 확정 | 수신자 권한/상태 검증은 기존 `validateRecipient` 정책을 재사용한다. | 비활성, AI 캐릭터, 자기 자신, 차단 관계의 기존 오류 key가 유지된다. | `P1-T2` |
|
||||
| `DMROOM-006` | 확정 | 기존 활성 방이 있으면 새 방을 만들지 않고 기존 `roomId`를 반환한다. | 같은 두 회원으로 두 번 호출해도 활성 방은 1개다. | `P1-T2` |
|
||||
| `DMROOM-007` | 확정 | `/create`는 첫 메시지 저장/전달/푸시를 수행하지 않는다. | 방 생성 후 `UserCreatorChatMessage`가 생성되지 않는다. | `P1-T2` |
|
||||
|
||||
## 8. API 계약
|
||||
|
||||
### 8.1 Endpoint
|
||||
|
||||
- Method: `POST`
|
||||
- Path: `/api/v2/user-creator-chat/rooms/create`
|
||||
- Auth: 로그인 회원 필수
|
||||
- Success envelope: 기존 `ApiResponse.ok(...)`
|
||||
|
||||
### 8.2 Request
|
||||
|
||||
```json
|
||||
{
|
||||
"recipientId": 200,
|
||||
"creatorId": 200
|
||||
}
|
||||
```
|
||||
|
||||
- `recipientId`: 신규 표준 필드. 발신자 반대편 회원 ID다.
|
||||
- `creatorId`: 기존 클라이언트 호환용 alias. 신규 클라이언트는 `recipientId`를 사용한다.
|
||||
- 둘 중 하나만 보내는 요청을 허용한다.
|
||||
- 둘 다 보내는 경우 값이 같으면 허용한다.
|
||||
- 둘 다 보내는 경우 값이 다르면 `common.error.invalid_request`로 거부한다.
|
||||
- 둘 다 없으면 `common.error.invalid_request`로 거부한다.
|
||||
|
||||
### 8.3 Response
|
||||
|
||||
```json
|
||||
{
|
||||
"roomId": 123
|
||||
}
|
||||
```
|
||||
|
||||
- 응답 DTO `CreateUserCreatorChatRoomResponse`의 필드는 변경하지 않는다.
|
||||
|
||||
## 9. 보안과 데이터 취급
|
||||
|
||||
- 수신자 ID는 요청 본문 외 로그에 별도 기록하지 않는다.
|
||||
- 차단 정책은 기존 `BlockMemberRepository.isBlocked(blockedMemberId = sender.id, memberId = recipient.id)` 기준을 유지한다.
|
||||
- AI 캐릭터용 `Member`와의 DM 방은 계속 생성하지 않는다.
|
||||
- 공개 API 스키마에서 기존 필드 제거는 금지한다.
|
||||
|
||||
## 10. 성공 기준
|
||||
|
||||
- [x] 신규 `recipientId` 요청으로 크리에이터가 상대 회원과 DM 방을 만들 수 있다.
|
||||
- [x] 기존 `creatorId` 요청으로 리스너가 크리에이터와 DM 방을 만들 수 있다.
|
||||
- [x] 같은 두 회원의 중복 요청은 같은 활성 `roomId`를 반환한다.
|
||||
- [x] `/create` 호출만으로 메시지, WebSocket 전달, FCM 푸시가 발생하지 않는다.
|
||||
- [x] 오류 key는 기존 정책과 호환된다.
|
||||
|
||||
## 11. Open Questions
|
||||
|
||||
없음. 인터뷰로 아래 결정을 확정했다.
|
||||
|
||||
## 12. 요구사항 추적표
|
||||
|
||||
| 요구사항 범위 | 계획 Phase | Goal | 자동 검증 | 수동 검증 |
|
||||
|---|---:|---|---|---|
|
||||
| `DMROOM-001~004` | 1 | `P1-T1` | `UserCreatorChatControllerMappingTest`, `UserCreatorChatServiceTest` | request alias 계약 대조 |
|
||||
| `DMROOM-005~007` | 1 | `P1-T2` | `UserCreatorChatServiceTest`, `UserCreatorChatServiceIntegrationTest` | 기존 메시지 발송 흐름 분리 확인 |
|
||||
|
||||
## 13. Decision Log
|
||||
|
||||
| 날짜 | ID | 상태 | 결정 | 근거 | 영향 요구사항·계약·Goal |
|
||||
|---|---|---|---|---|---|
|
||||
| 2026-09-14 | `DEC-001` | 확정 | `/create`는 방 생성/조회만 처리하고 첫 메시지는 발송하지 않는다. | 사용자 선택 A | `DMROOM-007`, `P1-T2` |
|
||||
| 2026-09-14 | `DEC-002` | 확정 | 수신자는 `MemberRole`로 제한하지 않고 활성/AI 아님/자기 자신 아님/차단 정책으로 제한한다. | 사용자 선택 B, 기존 `validateRecipient` 정책 | `DMROOM-005`, `P1-T2` |
|
||||
| 2026-09-14 | `DEC-003` | 확정 | 신규 표준 필드는 `recipientId`로 하고 기존 `creatorId`는 alias로 유지한다. | 기능 의미는 A가 맞지만 기존 클라이언트 동작 보존 필요 | `DMROOM-001~004`, `P1-T1` |
|
||||
|
||||
## 14. 변경 관리
|
||||
|
||||
- 코드 구현 전 `plan-task.md`의 체크박스와 Goal 단위를 따른다.
|
||||
- 구현 중 API 계약이 바뀌면 이 PRD의 Decision Log를 먼저 갱신한다.
|
||||
- 기존 `creatorId` alias를 제거하려면 별도 PRD와 클라이언트 마이그레이션 계획을 작성한다.
|
||||
@@ -1,357 +0,0 @@
|
||||
# 라이브 크리에이터 입장 제한 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | Phase 1~3 구현 및 검증 완료 |
|
||||
| 작성일 | `2026-09-17` |
|
||||
| 요구사항 기준 | `docs/20260917_라이브_크리에이터_입장제한/prd.md` |
|
||||
| API 기준 | 기존 API 응답 스키마 변경 없음 |
|
||||
| 현재 Phase | 완료 |
|
||||
| 현재 활성 Goal | 없음 |
|
||||
| 다음 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
다른 크리에이터가 `isAvailableJoinCreator = false` 라이브 방을 볼 수도, 직접 입장할 수도 없게 하고, 입장 가능 성별(`genderRestriction`)도 조회와 입장 경계에서 일관되게 적용한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `2/2` | 없음 | 없음 |
|
||||
| 2 | 완료 | `1/1` | 없음 | 없음 |
|
||||
| 3 | 완료 | `2/2` | 없음 | 없음 |
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- `v2` 홈 추천/온에어 라이브 조회의 `isAvailableJoinCreator` 및 `genderRestriction` 필터 누락 보정.
|
||||
- `v2` 크리에이터 채널 라이브 탭의 조회자 크리에이터 판정 보정.
|
||||
- `/live/room/enter`에서 다른 크리에이터의 제한 방 직접 입장 차단과 기존 성별 입장 차단 회귀 검증.
|
||||
- focused test와 직접 영향 회귀 검증.
|
||||
- 후속 리뷰 `REV-001`: `/live/room/info/{id}`의 토큰 발급 전 크리에이터·성별 제한 적용.
|
||||
- 후속 리뷰 `REV-002`: 채널 소유자 본인의 제한 방 조회를 repository 회귀 테스트로 고정.
|
||||
|
||||
### 제외
|
||||
|
||||
- 공개 API DTO 필드 추가/삭제.
|
||||
- DB schema 변경.
|
||||
- 기존 성인/성별/차단/강퇴/비공개/결제 정책 재설계.
|
||||
- 관리자용 라이브 조회 정책 변경.
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 기술 스택: Kotlin, Spring Boot 2.7.14, QueryDSL, Gradle Wrapper.
|
||||
- 기존 QueryDSL 조건 패턴을 재사용하고 새 abstraction은 만들지 않는다.
|
||||
- 크리에이터 제한 조건은 `isViewerCreator && memberId != null`일 때만 적용하며, 방 생성자는 `liveRoom.member.id.eq(memberId)`로 예외 처리한다.
|
||||
- 성별 제한 조건은 기존 조회 경로와 같은 정책을 따른다. `Gender.MALE`은 `ALL`, `MALE_ONLY`, `Gender.FEMALE`은 `ALL`, `FEMALE_ONLY`, `Gender.NONE` 또는 비로그인/null 유효 성별은 필터 없음이다.
|
||||
- 방 생성자 본인은 성별 제한 조건에서도 `liveRoom.member.id.eq(memberId)`로 예외 처리한다.
|
||||
- 모든 production 변경 Task는 `RED → GREEN → REFACTOR` 순서로 진행한다.
|
||||
- 공개 API schema 변경은 금지한다.
|
||||
|
||||
## Phase 1: 리스트 노출 제한 보정
|
||||
|
||||
**Phase 결과:** 다른 크리에이터가 문제 의심 리스트 경로에서 제한 방을 받지 않고, 성별 제한에 맞지 않는 사용자가 v2 홈 추천/온에어 라이브에서 제한 방을 받지 않는다.
|
||||
|
||||
**선행조건:** PRD 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1`, `P1-T2`, `P1-GATE` 완료 및 검증 기록 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 v2 홈 추천/온에어 라이브 필터 적용
|
||||
|
||||
**Goal 실행 `P1-T1`:** `findLiveRecommendations()` 계열 호출에서 조회자 크리에이터 여부와 유효 성별을 전달하고 QueryDSL 필터를 적용한다.
|
||||
|
||||
- **시작 조건:** `LCR-001` 확정.
|
||||
- **완료 증거:** 다른 크리에이터가 제한 방을 받지 않고, 성별 제한에 맞지 않는 사용자가 제한 방을 받지 않으며, 방 생성자 본인은 예외인 focused test 통과.
|
||||
- **범위 밖:** `/live/room/enter` 입장 차단.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/port/out/HomeRecommendationQueryPort.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/application/HomeRecommendationQueryService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepository.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/application/HomeRecommendationFacade.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/live/application/HomeOnAirLiveFacade.kt`
|
||||
- Test: repository/service 기존 테스트 위치를 우선 확인하고, 없으면 `src/test/kotlin/kr/co/vividnext/sodalive/v2/recommendation/adapter/out/persistence/DefaultHomeRecommendationQueryRepositoryTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- `findLiveRecommendations(offset, limit, memberId, includeAdultLives, isViewerCreator, effectiveViewerGender)` 형태로 파라미터를 추가한다.
|
||||
- `isViewerCreator` 기본값은 `false`로 둬 snapshot refresh 등 기존 호출을 보존한다.
|
||||
- `effectiveViewerGender` 기본값은 `null`로 둬 비로그인/성별 미설정과 snapshot refresh 등 기존 호출을 보존한다.
|
||||
- `HomeRecommendationFacade`와 `HomeOnAirLiveFacade`는 `member.auth?.gender` 우선, 없으면 `member.gender`로 기존 경로와 같은 유효 성별을 계산해 전달한다.
|
||||
|
||||
- [x] **RED:** 크리에이터 조회자에게 `isAvailableJoinCreator = false` 라이브가 제외되는 테스트, 성별 제한에 맞지 않는 조회자에게 `genderRestriction` 제한 라이브가 제외되는 테스트, 방 생성자 본인은 포함되는 테스트를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest'`를 실행해 필터 미구현으로 크리에이터 제한 방 또는 성별 제한 방이 포함되는 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** `HomeRecommendationQueryPort`, `HomeRecommendationQueryService`, `DefaultHomeRecommendationQueryRepository`, `HomeRecommendationFacade`, `HomeOnAirLiveFacade`에 `isViewerCreator`, `effectiveViewerGender` 전달과 조건을 최소 추가한다.
|
||||
- [x] **GREEN 확인:** `./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest'`를 다시 실행해 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 이번 Task가 만든 중복만 정리하고 `./gradlew ktlintCheck` 및 focused test 결과를 Progress에 기록한다.
|
||||
|
||||
#### Task 1.2 v2 크리에이터 채널 라이브 탭 판정 보정
|
||||
|
||||
**Goal 실행 `P1-T2`:** 크리에이터 채널 라이브 탭에서 “조회자가 크리에이터인지” 기준으로 제한 방 노출을 막는다.
|
||||
|
||||
- **시작 조건:** `LCR-002` 확정.
|
||||
- **완료 증거:** 다른 크리에이터는 제한 방을 받지 않고 방 생성자 본인은 받을 수 있는 focused test 통과.
|
||||
- **범위 밖:** 크리에이터 채널 홈은 이미 `viewer.role == MemberRole.CREATOR`를 사용하므로 변경하지 않는다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryService.kt`
|
||||
- Test: 기존 테스트 위치를 우선 확인하고, 없으면 `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/application/CreatorChannelLiveQueryServiceTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- `queryPort.findCurrentLive(..., isViewerCreator = viewer.role == MemberRole.CREATOR, ...)`로 전달한다.
|
||||
- repository의 기존 `creatorJoinLiveCondition(viewerId, isViewerCreator)`와 본인 예외는 유지한다.
|
||||
|
||||
- [x] **RED:** 다른 크리에이터 viewer가 `isAvailableJoinCreator = false` 현재 라이브를 받지 않는 service test를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests '*CreatorChannelLiveQueryServiceTest'`를 실행해 기존 `viewerId == creatorId` 판정 때문에 제한 방이 반환되는 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** `CreatorChannelLiveQueryService`의 `isViewerCreator` 계산을 `viewer.role == MemberRole.CREATOR`로 변경한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 중복 정리 없이 끝낼 수 있으면 그대로 두고 focused test 및 `./gradlew ktlintCheck` 결과를 Progress에 기록한다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 1 Gate
|
||||
|
||||
**Goal 실행 `P1-GATE`:** 리스트 노출 제한과 성별 제한 경로가 PRD 요구사항과 일치하는지 판정한다.
|
||||
|
||||
- **시작 조건:** `P1-T1`, `P1-T2` 완료.
|
||||
- **완료 증거:** 아래 명령 통과와 수동 대조 기록.
|
||||
- **범위 밖:** 입장 차단 구현.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest'
|
||||
./gradlew ktlintCheck
|
||||
```
|
||||
|
||||
**Expected:** focused test와 ktlint가 0 exit code로 종료한다.
|
||||
|
||||
수동 대조:
|
||||
|
||||
- [x] `HomeFollowingQueryService`와 `CreatorChannelHomeQueryService`의 기존 정상 조건은 변경하지 않았음을 확인한다.
|
||||
- [x] `HomeRecommendationFacade.getHomeRecommendations`, `HomeRecommendationFacade.getLives`, `HomeOnAirLiveFacade.getOnAirLives`가 모두 조회자 크리에이터 여부와 유효 성별을 전달함을 확인한다.
|
||||
|
||||
## Phase 2: 직접 입장 차단
|
||||
|
||||
**Phase 결과:** 다른 크리에이터가 제한 방 ID를 알고 있어도 `/live/room/enter`로 입장할 수 없고, 성별 제한에 맞지 않는 사용자의 직접 입장도 기존 정책대로 차단된다.
|
||||
|
||||
**선행조건:** `P1-GATE` 완료.
|
||||
|
||||
**Phase 완료 조건:** `P2-T1`, `P2-GATE` 완료 및 검증 기록 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 2.1 `/live/room/enter` 크리에이터 제한 차단 및 성별 제한 회귀 검증
|
||||
|
||||
**Goal 실행 `P2-T1`:** `LiveRoomService.enterLive()`에서 다른 크리에이터의 제한 방 입장을 결제/roomInfo 변경 전 차단하고, 기존 성별 제한 입장 차단을 회귀 테스트로 고정한다.
|
||||
|
||||
- **시작 조건:** `LCR-003` 확정, `P1-GATE` 완료.
|
||||
- **완료 증거:** 다른 크리에이터는 예외, 성별 제한에 맞지 않는 사용자는 `live.room.gender_restricted` 예외, 방 생성자 본인과 일반 유저는 기존 정책 유지 test 통과.
|
||||
- **범위 밖:** error envelope schema 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt`
|
||||
- Test: 기존 테스트 위치를 우선 확인하고, 없으면 `src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt`
|
||||
- Optional Modify: 메시지 key 추가가 필요하다고 확인될 때만 `src/main/resources/messages*.properties` 계열 파일
|
||||
|
||||
**Implementation rule:**
|
||||
|
||||
`enterLive()`에서 room 조회 후, 결제나 `LiveRoomInfo` 변경 전에 아래 의미의 조건을 추가한다.
|
||||
|
||||
```kotlin
|
||||
if (
|
||||
member.role == MemberRole.CREATOR &&
|
||||
room.member!!.id!! != member.id!! &&
|
||||
!room.isAvailableJoinCreator
|
||||
) {
|
||||
throw SodaException(messageKey = "live.room.not_found")
|
||||
}
|
||||
```
|
||||
|
||||
기존 message key 재사용이 부적절하다고 확인되면 PRD와 plan-task를 먼저 갱신한 뒤 새 message key를 추가한다.
|
||||
|
||||
성별 제한은 현재 구현된 아래 의미의 조건을 유지한다. 이 작업에서는 해당 조건을 약화하지 말고 회귀 테스트로 고정한다.
|
||||
|
||||
```kotlin
|
||||
if (room.member!!.id!! != member.id!! && !member.canEnter(room.genderRestriction)) {
|
||||
throw SodaException(messageKey = "live.room.gender_restricted")
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **RED:** 다른 크리에이터가 `isAvailableJoinCreator = false` 방에 입장하면 예외가 발생하고 `roomInfoRepository.save`와 `canPaymentService.spendCan`이 호출되지 않는 테스트를 작성한다. 성별 제한에 맞지 않는 사용자가 입장하면 `live.room.gender_restricted` 예외가 발생하는 회귀 테스트도 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests '*LiveRoomServiceTest'`를 실행해 기존 구현이 입장을 허용하거나 후속 저장을 호출하는 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** `LiveRoomService.enterLive()`에 최소 조건을 추가한다.
|
||||
- [x] **GREEN 확인:** 같은 focused test를 다시 실행해 성공을 확인한다.
|
||||
- [x] **REFACTOR:** 방 생성자 본인, 일반 유저, `Gender.NONE` 사용자 회귀 테스트를 함께 실행하고 `./gradlew ktlintCheck` 결과를 Progress에 기록한다.
|
||||
|
||||
### 검증 방법
|
||||
|
||||
#### Phase 2 Gate
|
||||
|
||||
**Goal 실행 `P2-GATE`:** 입장 차단, 리스트 노출 차단, 성별 제한 전체 흐름을 최종 판정한다.
|
||||
|
||||
- **시작 조건:** `P2-T1` 완료.
|
||||
- **완료 증거:** 아래 명령 통과와 계획 문서 Progress 기록.
|
||||
- **범위 밖:** 실패 test 삭제·완화, 무관한 리팩터링.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*LiveRoomServiceTest'
|
||||
./gradlew ktlintCheck
|
||||
./gradlew test
|
||||
```
|
||||
|
||||
**Expected:** focused test, ktlint, 전체 test가 0 exit code로 종료한다. 크리에이터 제한과 성별 제한이 모두 조회/입장 경계에서 검증된다. 전체 test가 환경 문제로 실패하면 실패 원인과 focused/영향 범위 대체 검증을 Progress에 기록한다.
|
||||
|
||||
## Phase 3: 후속 리뷰 항목 구현
|
||||
|
||||
**근거:** `reviews/현재변경사항-review.md`의 `REV-001`, `REV-002` 및 두 항목을 후속 구현사항으로 추가하라는 사용자 요청.
|
||||
|
||||
**Phase 결과:** 제한 사용자는 방 정보 API로 토큰을 발급받지 못하고, 채널 소유자 본인의 제한 방 조회는 실제 repository 테스트로 보장된다.
|
||||
|
||||
**선행조건:** Phase 1~2 완료 및 후속 리뷰 확인. 기존 완료 체크박스와 검증 기록은 유지한다.
|
||||
|
||||
**Phase 완료 조건:** `P3-T1`, `P3-T2`, `P3-GATE` 완료와 실행 증거 기록.
|
||||
|
||||
### Task 3.1 토큰 발급 경계의 입장 제한 보강
|
||||
|
||||
- [x] **Task 3.1 완료**
|
||||
|
||||
**Goal 실행 `P3-T1`:** `REV-001`에 따라 `getRoomInfo()`에서 토큰 생성 전에 크리에이터·성별 제한을 적용하고 허용 사용자 회귀를 검증한다.
|
||||
|
||||
- **시작 조건:** `REV-001`의 제한 검사 누락 확인. 실제 RTC 우회 접속은 아직 재현되지 않았으므로 서버의 토큰 생성 호출 여부부터 검증한다.
|
||||
- **완료 증거:** 제한 사용자에 대한 실패 테스트 → 최소 수정 후 예외 key 및 RTC/RTM 생성기 무호출 검증 → 소유자·허용 사용자 회귀 통과.
|
||||
- **범위 밖:** 새로운 입장 완료 여부 검사, 성인/비공개/결제 정책 재설계, 기존 발급 토큰 회수, Agora SDK 변경, API/DB 스키마 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/20260917_라이브_크리에이터_입장제한/prd.md` — LCR-003 및 성공 기준에 방 정보 API의 토큰 발급 경계를 명시.
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomService.kt` — `getRoomInfo()`.
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomServiceTest.kt`.
|
||||
- Read: `src/main/kotlin/kr/co/vividnext/sodalive/live/room/LiveRoomController.kt`, `src/main/kotlin/kr/co/vividnext/sodalive/member/Member.kt`.
|
||||
|
||||
**구현 기준:** 기존 방/roomInfo 존재 및 상호 차단 검사를 유지하고 첫 토큰 생성 호출 전에 `/enter`와 동일한 조건을 적용한다. 다른 크리에이터의 제한 방 접근은 `live.room.not_found`, 다른 사용자의 성별 불일치는 `live.room.gender_restricted`로 거절한다. 두 제한이 겹치면 크리에이터 제한을 먼저 판정한다. 생성자는 두 제한의 예외이며 성별은 `Member.canEnter()`를 재사용해 인증 성별 우선 및 `Gender.NONE` 허용 정책을 유지한다. RTC, RTM, v2v 토큰 모두 제한 검사를 통과한 뒤에만 생성한다.
|
||||
|
||||
- [x] **문서 정합성:** production 수정 전에 기존 PRD의 LCR-003, 관련 흐름·성공 기준에 토큰 발급 제한을 반영하고 후속 검증 항목은 미완료로 둔다.
|
||||
- [x] **RED:** roomInfo와 room이 존재하고 차단 관계가 없는 fixture에서 다른 크리에이터의 제한 방 접근 및 성별 불일치 접근을 테스트한다. 토큰 생성기는 테스트용 문자열을 반환하도록 준비하고 정상 응답 구성에 필요한 의존성도 설정해, 무관한 null 오류가 아닌 예상 예외 미발생으로 실패하는지 확인한다.
|
||||
- [x] **GREEN:** 위 조건을 최소 추가하고 예외 key, `rtcTokenBuilder`와 `rtmTokenBuilder`의 무호출을 검증한다. `/enter` 거절 후 `/info/{id}`에 해당하는 서비스 호출도 토큰을 생성하지 않는지 확인한다.
|
||||
- [x] **회귀:** 소유자는 크리에이터·성별 제한이 모두 걸려도 허용되고, 일반 사용자와 허용 방의 다른 크리에이터는 성별이 맞으면 응답을 받는다. 인증 성별 우선, `Gender.NONE` 허용, 기존 상호 차단 거절을 확인한다.
|
||||
- [x] **REFACTOR/기록:** 불필요한 추상화 없이 기존 스타일을 유지하고 아래 명령의 결과와 무엇을/왜/어떻게 검증했는지를 이 Task 아래에 누적한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest'
|
||||
```
|
||||
|
||||
**Expected:** RED 단계는 제한 검사 누락으로 실패하고, GREEN 이후는 exit 0. 차단 경로에서는 토큰이 생성되지 않는다.
|
||||
|
||||
**검증 기록 (2026-09-17):** `LiveRoomServiceTest`에 정상 응답 fixture와 제한·허용 회귀 10건을 추가했다. RED에서 다른 크리에이터, 성별 불일치, 중첩 제한, `/enter` 거절 후 정보 조회 테스트가 모두 예상 `SodaException` 미발생으로 실패했다. `getRoomInfo()`의 기존 상호 차단 검사 뒤와 모든 RTC/RTM/v2v 토큰 생성 앞에 `/enter`와 동일한 크리에이터·성별 조건을 추가한 뒤 `./gradlew test --rerun-tasks --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest'`가 20건 모두 통과하며 `BUILD SUCCESSFUL`로 종료했다. 거절 경로의 token builder 무호출과 소유자·성별 일치 사용자·허용 방 크리에이터·인증 성별 우선·`Gender.NONE`·기존 상호 차단 회귀를 확인했다. 실제 HTTP/RTC 연결은 검증하지 않았다. P3-T1 리뷰 결과 Blocker는 0건이다.
|
||||
|
||||
### Task 3.2 채널 소유자 제한 방 조회 회귀 테스트
|
||||
|
||||
- [x] **Task 3.2 완료**
|
||||
|
||||
**Goal 실행 `P3-T2`:** `REV-002`에 따라 채널 소유자 본인이 `isAvailableJoinCreator = false`인 현재 방을 조회할 수 있음을 H2 repository 테스트로 고정한다.
|
||||
|
||||
- **시작 조건:** `REV-002` 확인. 기본 실행 순서는 `P3-T1` 이후이며 파일 변경은 독립적이다.
|
||||
- **완료 증거:** 실제 repository의 `findCurrentLive()` 결과가 생성한 제한 방 ID와 일치하고 기존 타인 필터 테스트도 통과한다.
|
||||
- **범위 밖:** production 조회 조건 수정, 채널 API/DTO 변경, 조회 정책 재설계.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepositoryTest.kt`.
|
||||
- Read: `src/main/kotlin/kr/co/vividnext/sodalive/v2/creator/channel/live/adapter/out/persistence/DefaultCreatorChannelLiveQueryRepository.kt`.
|
||||
|
||||
**TDD 예외 사유:** 소유자 예외는 이미 구현돼 있고 이번 Task는 누락된 테스트만 추가한다. RED를 만들기 위해 정상 production 코드를 변경하지 않는다. 대체 검증은 실제 H2 조회 결과 assertion과 기존 타인 제외 테스트의 동시 통과다.
|
||||
|
||||
- [x] **테스트 추가:** 기존 fixture로 활성·비성인·유효 channelName의 현재 방을 생성하고 `isAvailableJoinCreator = false`로 설정한다. `flushAndClear()` 후 `viewerId == creatorId`, `isViewerCreator = true`, 성별 제한과 일치하는 유효 성별로 조회해 방 ID를 단정한다.
|
||||
- [x] **GREEN:** 아래 focused test로 소유자 조회와 기존 타인 제외 동작이 함께 통과하는지 확인한다.
|
||||
- [x] **REFACTOR/기록:** 기존 fixture를 재사용하고 이 Task 아래에 명령과 검증 결과를 누적한다. 예상 밖 실패는 원인을 판정한 뒤 production 수정이 필요하면 먼저 Task 범위를 갱신한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest'
|
||||
```
|
||||
|
||||
**Expected:** exit 0. 소유자에게 제한 방이 반환되고 다른 크리에이터에게는 기존대로 제외된다.
|
||||
|
||||
**검증 기록 (2026-09-17):** production 조건은 변경하지 않고 `DefaultCreatorChannelLiveQueryRepositoryTest.shouldReturnRestrictedCurrentLiveForChannelOwner`를 추가했다. 활성·비성인·유효 channelName·`isAvailableJoinCreator = false`·`MALE_ONLY` 방을 실제 H2에 저장하고 `viewerId == creatorId`, `isViewerCreator = true`, `Gender.MALE`로 조회해 생성한 방 ID를 단정했다. `./gradlew test --rerun-tasks --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest'`가 기존 다른 크리에이터 제외 테스트와 함께 `BUILD SUCCESSFUL`로 종료했다. `git diff --check`도 통과했다.
|
||||
|
||||
### Phase 3 Gate
|
||||
|
||||
**Goal 실행 `P3-GATE`:** 후속 두 항목과 기존 목록·입장 정책의 회귀 검증을 완료한다.
|
||||
|
||||
- **시작 조건:** `P3-T1`, `P3-T2` 완료.
|
||||
- **완료 증거:** 아래 명령 exit 0, 토큰 생성 전 제한 검사 수동 대조, 소유자 조회 assertion 확인, PRD·리뷰·계획의 상태 및 검증 기록 갱신.
|
||||
- **범위 밖:** 실제 RTC 접속을 검증하지 않고 검증했다고 기록하는 행위, 무관한 리팩터링.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest' --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest'
|
||||
./gradlew ktlintCheck
|
||||
./gradlew tasks --all
|
||||
```
|
||||
|
||||
- [x] 위 명령을 실행하고 각 Task의 완료 증거를 대조한다.
|
||||
- [x] 실제 HTTP/RTC 연결 검증 여부와 환경 한계를 별도로 기록한다. 서비스 테스트 통과를 실제 RTC 접속 검증으로 간주하지 않는다.
|
||||
- [x] 전체 회귀는 공통 인증·예외·설정까지 변경하거나 focused test로 영향 범위를 판단할 수 없는 경우 또는 사용자 요청 시 실행한다. 생략 시 이유와 위 대체 회귀 명령을 Progress에 남긴다.
|
||||
- [x] `reviews/현재변경사항-review.md`에 수정 후 검증을 누적하고, PRD의 후속 성공 기준 및 Phase 3 상태를 실제 결과에 맞춰 갱신한다.
|
||||
|
||||
## 실행 순서와 의존성
|
||||
|
||||
| 순서 | Goal | 선행조건 | 병행 가능 | 차단 시 다음 행동 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | `P1-T1` | PRD 확정 | 아니요 | 호출부/port signature 대조 후 문서 갱신 |
|
||||
| 2 | `P1-T2` | PRD 확정 | 예, `P1-T1`과 파일 충돌 없음 | service test 작성 방식 확정 |
|
||||
| 3 | `P1-GATE` | `P1-T1`, `P1-T2` | 아니요 | 실패 소유 Task로 회귀 수정 |
|
||||
| 4 | `P2-T1` | `P1-GATE` | 아니요 | 오류 message key 결정 후 문서 갱신 |
|
||||
| 5 | `P2-GATE` | `P2-T1` | 아니요 | 실패 소유 Task로 회귀 수정 |
|
||||
| 6 | `P3-T1` | `P2-GATE`, `REV-001` | 기본 순차 | 토큰 생성 전 거절 재현과 PRD 정합성 확인 |
|
||||
| 7 | `P3-T2` | `REV-002` | `P3-T1`과 파일 독립 | 기존 fixture·현재 방 조건 확인 |
|
||||
| 8 | `P3-GATE` | `P3-T1`, `P3-T2` | 아니요 | 실패 소유 Task로 회귀 수정 |
|
||||
|
||||
```text
|
||||
P1-T1 ─┐
|
||||
├→ P1-GATE → P2-T1 → P2-GATE
|
||||
P1-T2 ─┘
|
||||
|
||||
P2-GATE → P3-T1 ─┐
|
||||
P3-T2 ─┴→ P3-GATE
|
||||
```
|
||||
|
||||
## 변경 금지 항목
|
||||
|
||||
- 공개 API 응답 DTO를 변경하지 않는다.
|
||||
- `isAvailableJoinCreator`의 DB schema나 기본값을 변경하지 않는다.
|
||||
- 기존 성인/성별/차단/강퇴/비공개/결제 조건을 완화하지 않는다.
|
||||
- 테스트를 삭제·skip·완화하지 않는다.
|
||||
- `as any`, 타입 억제, 불필요한 새 abstraction을 만들지 않는다.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-09-17: PRD와 계획 문서 작성. 구현은 아직 시작하지 않음.
|
||||
- 2026-09-17: v2 홈 추천/온에어 라이브의 `genderRestriction` 조회 필터와 `/live/room/enter` 성별 제한 회귀 검증을 PRD/계획에 반영. 구현은 아직 시작하지 않음.
|
||||
- 2026-09-17: `P1-T1` RED 확인. 신규 인자 추가 전 `compileTestKotlin`에서 `isViewerCreator`/`effectiveViewerGender` 미정의로 실패했고, 시그니처 연결 후 repository focused test 79건 중 크리에이터 제한 1건과 성별 제한 2건이 assertion 실패했다. facade 전달 전 `HomeRecommendationControllerTest.shouldLogHomeRecommendationPageFailure`도 신규 인자 불일치로 실패했다.
|
||||
- 2026-09-17: `P1-T1` GREEN 확인. `./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*HomeRecommendationQueryServiceTest' --tests '*HomeRecommendationFacadeTest' --tests '*HomeOnAirLiveFacadeTest'`가 `BUILD SUCCESSFUL`로 종료했다. 컴파일 경고는 기존 deprecated API 및 기존 테스트 경고이며 신규 실패는 없다.
|
||||
- 2026-09-17: `P1-T1` REFACTOR 확인. 추가 추상화 없이 기존 QueryDSL 패턴을 재사용했고 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 종료했다.
|
||||
- 2026-09-17: `P1-T2` RED 확인. 다른 ID의 `MemberRole.CREATOR` viewer 테스트에서 `currentLiveIsViewerCreator`가 기대값 `true`, 실제값 `false`로 실패했다.
|
||||
- 2026-09-17: `P1-T2` GREEN 확인. `CreatorChannelLiveQueryService`가 `viewer.role == MemberRole.CREATOR`를 전달하도록 최소 수정한 뒤 `./gradlew test --rerun-tasks --tests '*CreatorChannelLiveQueryServiceTest'`가 `BUILD SUCCESSFUL`로 종료했다.
|
||||
- 2026-09-17: `P1-T2` REFACTOR 확인. 별도 추상화 없이 기존 역할 enum을 사용했고 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 종료했다.
|
||||
- 2026-09-17: `P1-GATE` 자동 검증. `./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*HomeRecommendationQueryServiceTest' --tests '*HomeRecommendationFacadeTest' --tests '*HomeOnAirLiveFacadeTest' --tests '*CreatorChannelLiveQueryServiceTest'`와 `./gradlew ktlintCheck`가 모두 `BUILD SUCCESSFUL`로 종료했다. 출력된 deprecated API 및 unchecked cast 경고는 변경 범위 밖의 기존 경고다.
|
||||
- 2026-09-17: `P1-GATE` 수동 대조. `HomeFollowingQueryService`와 `CreatorChannelHomeQueryService`는 변경하지 않았고, 홈 추천·라이브·온에어 facade 세 경로가 조회자 역할과 유효 성별을 전달함을 확인했다.
|
||||
- 2026-09-17: `P1-GATE` 리뷰 승인. Phase 1 전체 변경 범위의 PRD/계획 위반과 기존 필터 약화가 없음을 확인했으며 Blocker는 0건이다.
|
||||
- 2026-09-17: `P2-T1` RED 확인. 다른 크리에이터의 제한 방 입장 테스트가 `SodaException`을 기대했지만 예외가 발생하지 않아 실패했다.
|
||||
- 2026-09-17: `P2-T1` GREEN 확인. `LiveRoomService.enterLive()`의 결제 및 room-info 접근 전에 역할·방 생성자·`isAvailableJoinCreator` 조건을 추가한 뒤 `./gradlew test --rerun-tasks --tests '*LiveRoomServiceTest'`가 5건 모두 통과하며 `BUILD SUCCESSFUL`로 종료했다.
|
||||
- 2026-09-17: `P2-T1` REFACTOR 확인. 방 생성자, 일반 사용자, `Gender.NONE`, 성별 불일치 회귀 테스트와 차단 경로의 무부수효과 검증을 유지했고 `./gradlew ktlintCheck`가 `BUILD SUCCESSFUL`로 종료했다.
|
||||
- 2026-09-17: `P2-GATE` 자동 검증. `./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*LiveRoomServiceTest'`, `./gradlew ktlintCheck`, `./gradlew test --rerun-tasks`가 모두 `BUILD SUCCESSFUL`로 종료했다.
|
||||
- 2026-09-17: `P2-GATE` 최종 리뷰 승인. 구현·테스트·문서의 PRD/계획 일치와 API/스키마 유지 여부를 재검토했으며 Blocker는 0건이다.
|
||||
- 2026-09-17: 현재 변경사항 후속 리뷰. focused 140건 및 직접 영향 회귀 42건, 총 182건 통과. `./gradlew ktlintCheck tasks --all` 성공(ktlint UP-TO-DATE). 소스 수정 없이 목록·입장 경계 중심의 검토이므로 전체 회귀는 재실행하지 않았다. 명시된 구현 범위는 적합하나, 기존 `/live/room/info/{id}` 토큰 발급 경계의 제한 누락과 채널 소유자 조회 테스트 보완점을 `reviews/현재변경사항-review.md`에 기록했다. 실제 HTTP/RTC 연결은 미검증이며, 제품 전체의 입장 차단 완결성 승인은 보류한다. 기존 완료 기록은 유지한다.
|
||||
- 2026-09-17: 사용자 요청에 따라 `REV-001`을 토큰 발급 제한 보강 `P3-T1`, `REV-002`를 채널 소유자 조회 테스트 `P3-T2`로 구체화하고 `P3-GATE`를 추가했다. 파일·조건·예외 key·회귀 시나리오·명령·완료 증거를 명시했으며 기존 Phase 1~2 완료 기록은 유지했다. `./gradlew tasks --all`은 exit 0, `BUILD SUCCESSFUL`; `git diff --check`도 통과했다. 이번 변경은 계획 문서만 갱신하므로 코드 테스트는 실행하지 않았다. 후속 구현은 미착수이며 다음 Goal은 `P3-T1`이다.
|
||||
- 2026-09-17: `P3-T1` 시작 전 문서 정합성 완료. PRD의 관련 review, 문제·목표·사용자 흐름, `LCR-003`, API 계약, 보안 기준과 미완료 성공 기준에 `/live/room/info/{id}` 토큰 발급 경계 및 `REV-002` 회귀 검증을 반영했다.
|
||||
- 2026-09-17: `P3-GATE` 자동 검증. `./gradlew test --rerun-tasks --tests '*LiveRoomServiceTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest' --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*DefaultHomeRecommendationQueryRepositoryTest'`가 119건, 실패·오류·skip 0으로 `BUILD SUCCESSFUL` 종료했다. `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`도 모두 성공했다.
|
||||
- 2026-09-17: 토큰 발급 경계는 보안 관련 변경이므로 조건부 전체 회귀를 실행했다. `./gradlew test --rerun-tasks`가 `BUILD SUCCESSFUL`로 종료했다. 실제 HTTP 인증/직렬화 및 Agora RTC/RTM 연결은 환경상 실행하지 않았고, 실제 서비스 메서드 호출과 token builder 무호출 assertion 및 H2 repository 조회를 대체 실행 증거로 사용했다.
|
||||
- 2026-09-17: `P3-GATE` 최종 reviewer gate 승인. Phase 3 구현·테스트·PRD·계획·후속 리뷰 해소 기록을 전체 diff 기준으로 검토했으며 Blocker는 0건이다.
|
||||
@@ -1,115 +0,0 @@
|
||||
# 라이브 크리에이터 입장 제한 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | Phase 1~3 구현 및 검증 완료 |
|
||||
| 작성일 | `2026-09-17` |
|
||||
| 최종 수정일 | `2026-09-17` |
|
||||
| 대상 제품 | 라이브 방 목록 노출 및 입장 제한 |
|
||||
| 작성자·결정권자 | Sisyphus / 사용자 |
|
||||
| 관련 API Contract | 기존 API 응답 스키마 변경 없음 |
|
||||
| 관련 구현 계획 | `docs/20260917_라이브_크리에이터_입장제한/plan-task.md` |
|
||||
| 관련 review | `reviews/현재변경사항-review.md` (`REV-001`, `REV-002`) |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
라이브 방 생성자가 `isAvailableJoinCreator = false`로 설정한 방은 다른 크리에이터 계정에게 노출되거나 입장 가능하면 안 된다. 또한 `genderRestriction`으로 입장 가능 성별이 제한된 방은 조회 단계에서도 입장 가능한 사용자에게만 노출돼야 한다. 기존 `/live/room` 계열 조회는 대부분 이 정책을 적용하지만, v2 홈 추천/온에어 라이브와 일부 크리에이터 채널 라이브 경로에서 정책 누락 가능성이 확인됐다. 후속 리뷰에서 `/live/room/info/{id}`가 같은 제한을 확인하지 않고 RTC/RTM 토큰을 발급하는 경로도 확인됐다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- `v2` 홈 추천/온에어 라이브 조회는 `findLiveRecommendations()`에서 조회자의 크리에이터 여부와 유효 성별을 받지 않아 `isAvailableJoinCreator = false`인 방도 크리에이터에게 노출될 수 있고, 성별 제한 방도 입장 불가능한 사용자에게 노출될 수 있다.
|
||||
- `v2` 크리에이터 채널 라이브 탭은 조회자가 크리에이터인지가 아니라 조회자가 해당 채널 주인인지로만 `isViewerCreator`를 계산해, 다른 크리에이터에게 제한 방이 노출될 수 있다.
|
||||
- `/live/room/enter`는 성인, 비공개, 차단, 강퇴, 성별, 정원, 결제 조건은 확인하지만 크리에이터 입장 제한을 최종 차단하지 않는다.
|
||||
- `/live/room/info/{id}`는 방 정보와 상호 차단만 확인한 뒤 RTC/RTM 토큰을 생성하므로 `/enter`에서 거절된 사용자도 토큰 발급을 요청할 수 있다.
|
||||
|
||||
문제를 해결했다는 판단은 다른 크리에이터가 `isAvailableJoinCreator = false` 방을 리스트에서 보지 못하고, 성별 제한에 맞지 않는 사용자가 제한 방을 리스트에서 보지 못하며, 직접 `enter` 요청을 보내도 차단되는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 다른 크리에이터 계정은 `isAvailableJoinCreator = false` 라이브 방을 문제 의심 경로에서 볼 수 없다.
|
||||
- 다른 크리에이터 계정은 `isAvailableJoinCreator = false` 라이브 방에 직접 입장할 수 없다.
|
||||
- 입장 가능 성별이 제한된 라이브 방은 성별이 맞는 사용자 또는 방 생성자 본인에게만 조회된다.
|
||||
- 입장 가능 성별이 제한된 라이브 방은 성별이 맞는 사용자 또는 방 생성자 본인만 입장할 수 있다.
|
||||
- 방을 만든 크리에이터 본인은 예외로 계속 조회 및 입장 가능하다.
|
||||
- 제한 사용자는 `/live/room/info/{id}`에서 RTC/RTM/v2v 토큰을 발급받을 수 없다.
|
||||
- 기존 응답 DTO와 공개 API 스키마는 변경하지 않는다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- `isAvailableJoinCreator` 필드명, 의미, DB 스키마 변경은 하지 않는다.
|
||||
- 일반 유저, 관리자, 봇, 에이전트의 기존 노출/입장 정책은 변경하지 않는다.
|
||||
- 기존 성인/성별/차단/강퇴/비공개/결제 정책은 재설계하지 않는다.
|
||||
- `Gender.NONE` 사용자의 기존 정책은 변경하지 않는다. 기존 `Member.canEnter()`와 리스트 조건처럼 성별 미설정 사용자는 성별 제한을 통과한다.
|
||||
- 새 API endpoint나 새 응답 필드는 만들지 않는다.
|
||||
|
||||
## 5. Target Users and Permissions
|
||||
|
||||
| 사용자 | 목표 | 정책 |
|
||||
|---|---|---|
|
||||
| 방 생성 크리에이터 | 본인이 만든 방 조회 및 입장 | `isAvailableJoinCreator = false`여도 허용 |
|
||||
| 다른 크리에이터 | 크리에이터 입장 제한 방 접근 방지 | `isAvailableJoinCreator = false`면 목록 미노출 및 입장 차단 |
|
||||
| 일반 유저 | 기존 라이브 조회 및 입장 | `genderRestriction`에 맞으면 허용, `Gender.NONE`은 기존처럼 허용 |
|
||||
|
||||
## 6. 핵심 사용자 흐름
|
||||
|
||||
1. 일반 유저 또는 방 생성자가 `isAvailableJoinCreator = false` 방을 조회하면 기존 정책에 따라 표시된다.
|
||||
2. 성별 제한에 맞지 않는 사용자가 `genderRestriction` 제한 방을 v2 홈 추천/온에어 라이브에서 조회하면 표시되지 않는다.
|
||||
3. 다른 크리에이터가 같은 방을 v2 홈 추천/온에어 라이브 또는 크리에이터 채널 라이브 경로에서 조회하면 표시되지 않는다.
|
||||
4. 성별 제한에 맞지 않는 사용자가 방 ID를 알고 `/live/room/enter`를 호출하면 입장이 거부된다.
|
||||
5. 다른 크리에이터가 방 ID를 알고 `/live/room/enter`를 호출하면 입장이 거부된다.
|
||||
6. 방 생성 크리에이터가 본인 방에 입장하면 기존처럼 허용된다.
|
||||
7. `/enter`에서 거절되는 다른 크리에이터 또는 성별 불일치 사용자가 `/live/room/info/{id}`를 호출하면 토큰 생성 전에 같은 정책으로 거절된다.
|
||||
8. 방 생성자, 성별이 맞는 일반 사용자, 크리에이터 입장이 허용된 방의 다른 크리에이터는 기존처럼 방 정보와 토큰을 받는다.
|
||||
|
||||
## 7. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `LCR-001` | 확정 | v2 홈 추천/온에어 라이브 조회에서 다른 크리에이터에게 `isAvailableJoinCreator = false` 방을 숨기고, 성별 제한에 맞지 않는 사용자에게 `genderRestriction` 제한 방을 숨긴다. | `HomeRecommendationQueryService.findLiveRecommendations()` 호출 경로가 조회자 크리에이터 여부와 유효 성별을 전달한다. repository는 `liveRoom.isAvailableJoinCreator.isTrue.or(liveRoom.member.id.eq(memberId))`와 `Gender.MALE -> ALL/MALE_ONLY`, `Gender.FEMALE -> ALL/FEMALE_ONLY`, `Gender.NONE/null -> 필터 없음` 조건을 적용한다. | `P1-T1` |
|
||||
| `LCR-002` | 확정 | v2 크리에이터 채널 라이브 탭에서 다른 크리에이터에게 제한 방을 숨긴다. | `CreatorChannelLiveQueryService`가 `viewer.role == MemberRole.CREATOR` 기준으로 조회자 크리에이터 여부를 전달한다. 방 생성자 본인은 예외다. | `P1-T2` |
|
||||
| `LCR-003` | 확정 | `/live/room/enter`와 `/live/room/info/{id}`에서 다른 크리에이터의 제한 방 입장·토큰 발급과 성별 제한에 맞지 않는 접근을 차단한다. | 다른 크리에이터 제한은 `live.room.not_found`, 성별 제한은 `live.room.gender_restricted`로 결제·입장 상태 변경 또는 RTC/RTM/v2v 토큰 생성 전에 거절한다. 생성자는 예외이며 성별 판정은 `Member.canEnter()`를 재사용한다. | `P2-T1`, `P3-T1` |
|
||||
| `LCR-004` | 확정 | 기존 정상 경로는 유지한다. | 일반 유저와 방 생성자 본인의 조회/입장 동작은 유지되고 기존 테스트 또는 신규 회귀 테스트로 확인된다. | `P2-GATE` |
|
||||
|
||||
## 8. API 계약
|
||||
|
||||
- 변경되는 공개 request/response 필드는 없다.
|
||||
- `/live/room/enter`는 기존 오류 envelope를 사용한다.
|
||||
- `/live/room/info/{id}`도 기존 오류 envelope와 message key를 재사용하며 응답 필드는 변경하지 않는다.
|
||||
- 신규 message key 추가 여부는 구현 단계에서 기존 `live.room.gender_restricted`, `common.error.invalid_request`, `live.room.not_found` 패턴을 확인해 결정한다. 새 key가 필요하면 메시지 리소스와 테스트를 함께 갱신한다.
|
||||
|
||||
## 9. 보안과 데이터 취급
|
||||
|
||||
- 숨김 정책은 클라이언트 표시만으로 끝내지 않고 서버 입장 경계에서 한 번 더 차단한다.
|
||||
- 제한 방 존재 여부를 다른 크리에이터에게 과도하게 노출하지 않는 오류 메시지를 우선한다.
|
||||
- 민감정보, 결제 정보, password, token은 로그나 테스트 fixture에 기록하지 않는다.
|
||||
- 제한 검사는 RTC, RTM, v2v 토큰 생성 호출보다 먼저 수행한다.
|
||||
|
||||
## 10. 성공 기준
|
||||
|
||||
- [x] 다른 크리에이터는 `isAvailableJoinCreator = false` 방을 v2 홈 추천/온에어 라이브에서 받지 않는다. (`LCR-001`)
|
||||
- [x] 성별 제한에 맞지 않는 사용자는 `genderRestriction` 제한 방을 v2 홈 추천/온에어 라이브에서 받지 않는다. (`LCR-001`)
|
||||
- [x] 다른 크리에이터는 `isAvailableJoinCreator = false` 방을 v2 크리에이터 채널 라이브 탭에서 받지 않는다. (`LCR-002`)
|
||||
- [x] 다른 크리에이터는 `isAvailableJoinCreator = false` 방에 직접 입장할 수 없다. (`LCR-003`)
|
||||
- [x] 성별 제한에 맞지 않는 사용자는 제한 방에 직접 입장할 수 없다. (`LCR-003`)
|
||||
- [x] 방 생성 크리에이터 본인은 본인 방을 조회하고 입장할 수 있다. (`LCR-001`~`LCR-003`)
|
||||
- [x] 공개 API 스키마 변경 없이 focused test와 영향 범위 회귀가 통과한다.
|
||||
- [x] 다른 크리에이터와 성별 불일치 사용자는 `/live/room/info/{id}`에서 토큰 생성 전에 거절되고 토큰 생성기가 호출되지 않는다. (`LCR-003`)
|
||||
- [x] 방 생성자, 성별이 맞는 일반 사용자, 크리에이터 입장이 허용된 방의 다른 크리에이터, `Gender.NONE` 사용자는 기존처럼 방 정보와 토큰을 받는다. (`LCR-003`, `LCR-004`)
|
||||
- [x] 채널 소유자 본인은 `isAvailableJoinCreator = false`인 현재 방을 repository 조회에서 받는다. (`LCR-002`, `LCR-004`)
|
||||
|
||||
## 11. Decision Log
|
||||
|
||||
| 일시 | 결정 | 근거 |
|
||||
|---|---|---|
|
||||
| 2026-09-17 | `isAvailableJoinCreator = false`는 다른 크리에이터만 제한하고 방 생성자 본인은 예외로 둔다. | 기존 QueryDSL 조건들이 `liveRoom.member.id.eq(viewerId)` 예외를 두는 패턴과 사용자 선택 A |
|
||||
| 2026-09-17 | 이 작업은 문서가 필요한 구현 작업으로 분류한다. | 저장소 `AGENTS.md`와 `docs/agent-guides/작업절차.md`가 모든 구현 작업 전 PRD와 plan-task를 요구 |
|
||||
| 2026-09-17 | v2 홈 추천/온에어 라이브에도 기존 조회 경로와 같은 `genderRestriction` 필터를 적용한다. | `/live/room`, 팔로잉, 크리에이터 채널 조회 경로가 이미 성별 조건을 조회 단계에서 적용하고 있으며 사용자가 문서 반영을 요청 |
|
||||
| 2026-09-17 | `/live/room/enter`의 성별 제한은 기존 `Member.canEnter()` 정책을 유지한다. | 현재 `LiveRoomService.enterLive()`가 이미 `room.member.id != member.id && !member.canEnter(room.genderRestriction)`을 차단하고, `Gender.NONE`은 `canEnter()`에서 허용됨 |
|
||||
| 2026-09-17 | `/live/room/info/{id}`에서도 `/enter`와 같은 크리에이터·성별 제한을 토큰 생성 전에 적용한다. | `REV-001`에서 `/enter` 거절 후에도 RTC/RTM/v2v 토큰 생성 경로가 열려 있음을 코드로 확인 |
|
||||
| 2026-09-17 | 채널 소유자의 제한 방 조회는 production 변경 없이 H2 repository 회귀 테스트로 고정한다. | `REV-002`와 기존 `creatorJoinLiveCondition()`의 소유자 예외 |
|
||||
|
||||
## 12. 열린 질문
|
||||
|
||||
- 없음. 구현 중 기존 메시지 key 재사용이 부적절하다고 확인되면 plan-task 범위를 먼저 갱신한다.
|
||||
@@ -1,114 +0,0 @@
|
||||
# 라이브 크리에이터 입장 제한 현재 변경사항 리뷰
|
||||
|
||||
## 1. 리뷰 정보
|
||||
|
||||
- 일자: 2026-09-17
|
||||
- 기준: `62c834d28f0d142e81f2d303273eb1220cab0611` 위의 현재 staged/working tree 변경사항. commit 자체에 대한 승인이 아니다.
|
||||
- 소스 diff SHA-256 (`git diff HEAD -- src`): `7b951c79f1575832ac65870a479005909da22e640772c5609a3cbdd983209b98`
|
||||
- 기준 문서: `../prd.md`, `../plan-task.md`
|
||||
- 범위: LCR-001~004, 변경 production 7개 파일과 관련 테스트, 기존 조회/입장 정책.
|
||||
- 상태: 판정 완료. production 및 테스트 코드는 수정하지 않았다.
|
||||
|
||||
## 2. 검토 기준 및 근거
|
||||
|
||||
- 홈 추천·라이브·온에어의 역할/인증 성별 전달, QueryDSL 필터와 생성자 예외를 대조한다.
|
||||
- 크리에이터 채널 라이브의 역할 판정과 기존 repository 조건을 대조한다.
|
||||
- 직접 입장 거부가 결제 및 roomInfo 접근보다 먼저 발생하는지 확인한다.
|
||||
- 공개 DTO, 일반 사용자, Gender.NONE, 기존 성인/차단/결제 정책의 회귀 여부를 확인한다.
|
||||
- `docs/sample/sample-review.md` 기준으로 확정 결함과 검증 한계를 구분한다.
|
||||
|
||||
## 3. 검토별 증거 기록
|
||||
|
||||
| 검토 | 기준 HEAD | 판정 | 근거 |
|
||||
|---|---|---|---|
|
||||
| 코드 품질 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/quality`: SQL WHERE 적용 후 페이지네이션, 호출 3곳 인자 전달, 기본값 및 생성자 예외, 부수효과 전 차단 확인. 이전 XML은 이번 실행 증거로 사용하지 않음. |
|
||||
| 요구사항 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/requirements`: LCR-001~004 구현 일치. 채널 소유자 조회의 직접 회귀 테스트는 보완 여지 있음. |
|
||||
| 주변 문맥 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/context`: 기존 팔로잉·채널 홈 조건 및 Member.canEnter와 일치. 기존 상세 조회의 제한 미검사는 이번 diff 밖임. |
|
||||
| 보안 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | FAIL | `/root/security`: getRoomInfo의 기존 RTC 토큰 발급 경로가 입장 제한을 검사하지 않음. 코드 추적 근거이며 실제 RTC 연결은 미검증. |
|
||||
| 자동 QA | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | PASS | `/root/qa`: focused 140건 + 인접 회귀 42건 통과. 실제 HTTP/RTC QA는 INCONCLUSIVE. |
|
||||
| 런타임 감사 | `62c834d28f0d142e81f2d303273eb1220cab0611` + 현재 변경 | INCONCLUSIVE | H2 쿼리 결과, 서비스 거부 예외 및 무부수효과 assertion은 실행 확인. 실제 RTC 토큰을 사용한 연결 우회는 실행하지 않음. |
|
||||
|
||||
## 4. 실행 검증
|
||||
|
||||
환경: macOS, Java 17, Gradle Wrapper, repository 테스트 H2. 전체 회귀 대신 변경 파일 focused test 및 직접 영향받는 기능 회귀를 실행한다. 공개 DTO·공통 인증·설정 변경은 없으며 이 리뷰에서 소스를 수정하지 않아 전체 테스트를 재실행하지 않았다. 기존 계획의 전체 회귀 성공 기록은 이번 실행 결과로 간주하지 않는다.
|
||||
|
||||
```bash
|
||||
./gradlew test --rerun-tasks --tests '*DefaultHomeRecommendationQueryRepositoryTest' --tests '*CreatorChannelLiveQueryServiceTest' --tests '*LiveRoomServiceTest' --tests '*HomeRecommendationQueryServiceTest' --tests '*HomeRecommendationFacadeTest' --tests '*HomeOnAirLiveFacadeTest'
|
||||
```
|
||||
|
||||
- `/root/qa` 실행: BUILD SUCCESSFUL, 140건, 실패/오류/skip 0, 3분 19초.
|
||||
- `./gradlew ktlintCheck tasks --all`: BUILD SUCCESSFUL. ktlint 작업은 UP-TO-DATE이며 강제 재실행은 하지 않았다.
|
||||
- `git diff --check`: exit 0.
|
||||
- `./gradlew test --tests '*HomeRecommendationControllerTest' --tests '*DefaultCreatorChannelLiveQueryRepositoryTest' --tests '*HomeOnAirLiveControllerTest' --tests '*LiveRoomServiceAdultVisibilityPolicyTest'`: exit 0, 42건, 실패/오류/skip 0, 41초.
|
||||
- 합계: 182건 통과. 추천 repository 81, 추천 service 35, 추천 facade 6, 온에어 facade 4, 채널 service 9, 입장 service 5, 추천 controller 27, 온에어 controller 2, 채널 repository 8, 기존 성인 정책 5.
|
||||
- 실제 HTTP/RTC 연결은 미검증. localhost:8080에 실행 중 서버가 없고 운영 데이터·외부 서비스에 연결하지 않았다. 서비스 Mockito 테스트 및 H2 repository 테스트는 입장 거부/조회 필터의 실행 증거이며 실제 RTC 우회 재현 증거는 아니다.
|
||||
|
||||
## 5. 발견 사항 및 종료 판정
|
||||
|
||||
### REV-001 — 기존 방 정보 API의 토큰 발급 경계에 제한 검사 없음
|
||||
|
||||
- 심각도: High. 상태: 제한 검사 누락은 코드로 확정, 실제 RTC 접속 우회는 미검증.
|
||||
- 관련 목표: PRD §3의 다른 크리에이터·성별 불일치 사용자의 입장 차단.
|
||||
- 이번 diff가 도입한 결함은 아니며, 계획에 명시된 목록 및 `/enter` 수정 범위 밖의 기존 경로다.
|
||||
- `LiveRoomController.kt:129`의 `GET /live/room/info/{id}`는 로그인 확인 후 `getRoomInfo()`를 호출한다.
|
||||
- `LiveRoomService.kt:972`는 roomInfo·room 존재와 상호 차단만 검사한다. 크리에이터 제한, 성별 제한, 호출자의 입장 완료 여부는 확인하지 않는다.
|
||||
- 같은 파일 `:991`부터 RTC 토큰 생성기를 호출하고 `:1065`부터 채널명과 토큰을 응답에 넣는다. `RtcTokenBuilder.kt:83`은 JoinChannel 권한을 추가한다.
|
||||
- 확인 시나리오: roomInfo가 존재하는 제한 방에서, 호스트와 차단 관계가 없는 다른 크리에이터가 방 ID로 `/info/{id}`를 요청한다. `/enter`에서 거절됐더라도 이 경로의 토큰 발급 전 제한 검사가 없다.
|
||||
- 권장 조치: 토큰 발급 경계에도 생성자 예외를 포함한 크리에이터·성별 제한을 적용하고, 제한 사용자는 토큰 생성기가 호출되지 않는 회귀 테스트로 고정한다. 전체 입장 정책 재설계는 별도 범위다.
|
||||
|
||||
### REV-002 — 채널 소유자 조회의 직접 회귀 테스트 보완
|
||||
|
||||
- 심각도: Low, 비차단 검증 보완점.
|
||||
- `CreatorChannelLiveQueryServiceTest.kt:67`은 다른 크리에이터의 `isViewerCreator` 전달을 검증한다.
|
||||
- `DefaultCreatorChannelLiveQueryRepositoryTest.kt:206`은 다른 크리에이터 조회 필터를 검증하지만, 소유자 본인의 제한 방 반환을 직접 검증하지 않는다.
|
||||
- 소유자 허용 조건 자체는 `DefaultCreatorChannelLiveQueryRepository.kt:353`에 존재하므로 구현 누락으로 판정하지 않는다.
|
||||
- 권장 조치: viewerId == creatorId이고 isAvailableJoinCreator == false인 현재 방이 반환되는 repository 회귀 테스트 1건으로 P1-T2 완료 증거를 보강한다.
|
||||
|
||||
## 6. 후속 처리
|
||||
|
||||
코드 수정은 수행하지 않았다. 명시된 구현 범위의 기능 결함은 발견하지 못했으나, 제품 전체의 입장 차단 완료 판정에는 REV-001 확인·보강이 필요하다. 구현을 진행할 경우 기존 계획에 토큰 발급 경계 보강 Task를 추가한 뒤 실패 재현 → 최소 수정 → 회귀 검증 순서로 진행한다. 기존 완료 체크박스와 검증 기록은 유지한다.
|
||||
|
||||
**종합 판정:** 명시된 목록·`/enter` 구현 범위는 적합하다. 보안 검토 FAIL 및 실제 API/RTC 검증 한계 때문에 제품 전체의 입장 차단에 대한 무조건 승인은 보류한다. 이번 diff에서 새로 도입된 확정 결함은 발견하지 못했다.
|
||||
|
||||
**다음 행동:** REV-001의 토큰 발급 경계를 후속 검증·수정 범위로 반영한다.
|
||||
|
||||
## 7. Phase 3 후속 구현 검증
|
||||
|
||||
- 일자: 2026-09-17
|
||||
- 대상: `REV-001`, `REV-002` 후속 구현과 Phase 3 Gate.
|
||||
- [x] `REV-001` 해소: `LiveRoomService.getRoomInfo()`가 기존 room/roomInfo 존재 및 상호 차단 검사 뒤, RTC/RTM/v2v 토큰 생성 전에 다른 크리에이터와 성별 불일치 사용자를 각각 `live.room.not_found`, `live.room.gender_restricted`로 거절한다.
|
||||
- [x] `REV-001` 회귀: 제한 경로의 token builder 무호출, `/enter` 거절 후 정보 조회 거절, 소유자·성별 일치 사용자·허용 방 크리에이터·인증 성별 우선·`Gender.NONE` 허용, 기존 상호 차단 거절을 서비스 테스트로 확인했다.
|
||||
- [x] `REV-002` 해소: 실제 H2 repository 테스트가 채널 소유자에게 `isAvailableJoinCreator = false`인 본인 현재 방 ID를 반환하고, 기존 다른 크리에이터 제외 테스트도 통과했다.
|
||||
- [x] Phase 3 focused 회귀: 5개 테스트 클래스 119건이 실패·오류·skip 0으로 통과했다.
|
||||
- [x] 품질 및 명령 검증: `./gradlew ktlintCheck`, `./gradlew tasks --all`, `git diff --check`가 성공했다.
|
||||
- [x] 전체 회귀: 보안 경계 변경을 반영해 `./gradlew test --rerun-tasks`를 실행했고 `BUILD SUCCESSFUL`로 종료했다.
|
||||
- [x] 검증 한계 기록: 실제 HTTP 인증/직렬화 및 Agora RTC/RTM 연결은 실행하지 않았으며 서비스 메서드 호출, token builder 무호출 assertion, 실제 H2 조회를 대체 실행 증거로 사용했다.
|
||||
- [x] 최종 reviewer gate: Phase 3 구현·테스트·문서 정합성을 전체 diff 기준으로 재검토했고 Blocker 0건으로 승인됐다.
|
||||
|
||||
**후속 판정:** `REV-001`, `REV-002`는 구현 및 회귀 검증으로 해소됐다. 기존 리뷰의 보안 FAIL은 토큰 발급 전 서버 제한 검사 누락 기준에서 해소됐으며, 실제 RTC 연결 검증을 수행했다는 주장은 하지 않는다.
|
||||
|
||||
## 8. Phase 1~3 문서·코드 정적 재대조 — 2026-09-17
|
||||
|
||||
- 사용자 요청: 전체 자동 테스트는 이미 수행됐으므로 재실행하지 않고 문서와 실제 코드의 스펙 일치 여부를 확인한다.
|
||||
- 기준 HEAD: `62c834d28f0d142e81f2d303273eb1220cab0611` 및 현재 staged/working tree 변경.
|
||||
- 소스 diff SHA-256 (`git diff HEAD -- src`): `4d90f90b2a262d1ee081c8e9fc78085ce6d04199d9c50a3d5a7506ceaeed0fbe`.
|
||||
- 범위: LCR-001~004, P1~P3, REV-001/002, 호출 경로와 테스트 assertion. 이번 검토에서 테스트·애플리케이션은 실행하지 않는다. 기존 실행 성공 기록과 이번 정적 검토 판정은 구분한다.
|
||||
|
||||
| 검토 | 정적 판정 | 근거 |
|
||||
|---|---|---|
|
||||
| 요구사항 | PASS | `/root/spec_v3`: 전체 호출 3곳 역할·성별 전달, 목록 필터 및 생성자 예외, 입장/토큰 발급 전 거절, 채널 소유자 repository assertion까지 LCR-001~004 구현 확인. |
|
||||
| 보안 경계 | PASS | `/root/security_v3`: LiveRoomService.kt:986~995 검사 뒤 :1001/:1009/:1016 토큰 생성. 기존 존재·양방향 차단 검사 및 /enter의 결제 전 차단 유지. |
|
||||
| 코드 품질 | PASS | `/root/quality_v3`: 모든 호출부 인자 전달, WHERE 필터 후 페이지네이션, 내부 기본값 및 공개 API/DB 스키마 유지. |
|
||||
| 테스트 소스 | PASS | `/root/tests_v3`: LiveRoomServiceTest.kt:180~326 제한/허용/우선순위/무호출 assertion, :368 세 토큰 응답 및 호출 횟수, 채널 repository 테스트 :244~266 실제 소유자 방 ID assertion 확인. 실행 QA 판정은 아님. |
|
||||
| 문서·주변 정책 | PASS | `/root/context_v3`: Member.canEnter 및 기존 채널 조건과 일치. 이전 FAIL/미착수는 보존된 이력이며 후속 완료 기록으로 해소됨. |
|
||||
|
||||
위 판정은 명시된 HEAD와 소스 diff 조합에 한정한다.
|
||||
|
||||
**요구사항 대조:**
|
||||
|
||||
- LCR-001: 홈 추천·라이브·온에어의 역할/유효 성별 전달과 조회 필터 일치. 생성자 예외와 NONE/null 정책 유지.
|
||||
- LCR-002: 채널 조회자의 실제 CREATOR 역할 판정 및 생성자 예외 일치. REV-002의 소유자 H2 테스트 추가 확인.
|
||||
- LCR-003: /enter는 결제·상태 변경 전, /info는 모든 토큰 생성 전에 동일 제한 검사. 예외 key·우선순위·생성자 예외·Member.canEnter 재사용 일치. REV-001 해소 확인.
|
||||
- LCR-004: 허용 사용자·생성자·인증 성별 우선·Gender.NONE 회귀 assertion 존재. 공개 DTO/DB 스키마 및 기존 제한 조건 유지.
|
||||
|
||||
**최종 정적 판정: PASS.** 문서에 명시된 Phase 1~3 구현 및 후속 두 항목의 누락·스펙 위반은 발견하지 못했다. 새 수정 Task로 전환할 확정 항목은 없다. 코드·테스트는 변경하지 않았으며 이 절만 누적했다. 사용자 요청에 따라 테스트와 Gradle 작업을 재실행하지 않고 문서·소스·diff를 대조했다. 전체 자동 테스트 성공은 사용자 확인 및 기존 문서 기록으로 구분하며, 이번에 HTTP/RTC 실행 또는 과거 RED→GREEN을 재검증했다고 주장하지 않는다.
|
||||
@@ -1,299 +0,0 @@
|
||||
# 성인 콘텐츠 노출 정책 Deprecated 함수 제거 구현 계획
|
||||
|
||||
| 문서 항목 | 내용 |
|
||||
|---|---|
|
||||
| 상태 | Phase 1~3 완료, P3-R1 구현 완료·실행 검증 대기 |
|
||||
| 작성일 | `2026-09-17` |
|
||||
| 요구사항 기준 | `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/prd.md` |
|
||||
| API 기준 | 기존 API 응답 스키마 변경 없음 |
|
||||
| 현재 Phase | Phase 3 후속 실행 검증 대기 |
|
||||
| 현재 활성 Goal | `P3-R1` 실행 검증 |
|
||||
| 다음 Goal | 없음 |
|
||||
|
||||
## 목표
|
||||
|
||||
`MemberContentPreferencePolicy.isAdultVisibleByPolicy(...)` / `resolveCountryCodeByPolicy(...)` Deprecated 경로를 `MemberContentPreferenceService` 정식 경로로 이전하고, Deprecated 함수와 전용 테스트를 제거한다. 사용자가 확정한 JP 강제 매핑의 `2L` 제거를 제외하고 성인 콘텐츠 노출 판정 결과는 유지한다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| Phase | 상태 | 완료 Task | 활성/다음 Goal | 차단 또는 남은 조건 |
|
||||
|---:|---|---:|---|---|
|
||||
| 1 | 완료 | `1/1` | 없음 | 없음 |
|
||||
| 2 | 완료 | `4/4` | 없음 | 없음 |
|
||||
| 3 | 완료 | `1/1` | 없음 | 없음 |
|
||||
| 3 후속 | 진행 중 | `0/1` | 활성: `P3-R1` 실행 검증 | 회귀 테스트 구현·소스 검토 완료, 자동 실행 대기 |
|
||||
|
||||
## 범위
|
||||
|
||||
### 포함
|
||||
|
||||
- `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)` 추가.
|
||||
- production 12개 파일 27곳의 `isAdultVisibleByPolicy(...)` 호출 치환 및 `MemberContentPreferenceService` 생성자 주입.
|
||||
- `MemberContentPreferencePolicy.kt`, `MemberContentPreferencePolicyTest.kt` 제거와 고유 검증 이전.
|
||||
- 영향 범위 focused test 및 ktlint 검증.
|
||||
|
||||
### 제외
|
||||
|
||||
- `isAdult` 판정 알고리즘 변경 및 JP 강제 매핑의 `2L` 제거 외 국가 강제 매핑 회원 ID 변경.
|
||||
- 각 서비스 public 메서드 시그니처 변경, 공개 API 스키마 변경.
|
||||
- `CanController`의 통화 강제 로직.
|
||||
|
||||
## 기술적 제약
|
||||
|
||||
- 기술 스택: Kotlin, Spring Boot 2.7.14, Gradle Wrapper, ktlint.
|
||||
- 신규 abstraction을 만들지 않는다. 기존 `MemberContentPreferenceService`의 `resolveCountryCode` + `calculateIsAdultForQuery`를 재사용한다.
|
||||
- `CountryContext`는 `@RequestScope`이므로 마이그레이션 대상은 웹 요청 경로에서만 호출되는 서비스로 한정한다(`RISK-001`).
|
||||
- 모든 production 변경 Task는 `RED → GREEN → REFACTOR` 순서로 진행한다.
|
||||
- 테스트 실행은 변경 범위 focused test를 우선하고, 전체 회귀는 실행하지 않으며 생략 근거를 검증 기록에 남긴다.
|
||||
|
||||
## Phase 1: 정식 경로 단일 진입점 확보
|
||||
|
||||
**Phase 결과:** `MemberContentPreferenceService.isAdultVisibleForQuery(...)`가 Deprecated 함수와 동일한 판정을 제공한다.
|
||||
|
||||
**선행조건:** PRD 확정.
|
||||
|
||||
**Phase 완료 조건:** `P1-T1` 완료 및 검증 기록 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 1.1 `isAdultVisibleForQuery` 추가
|
||||
|
||||
**Goal 실행 `P1-T1`:** `MemberContentPreferenceService`에 접속 국가 계산과 성인 노출 판정을 묶은 단일 메서드를 추가한다.
|
||||
|
||||
- **시작 조건:** `DEPREM-001` 확정.
|
||||
- **완료 증거:** KR + 미인증 → `false`, KR + 인증 → 전달값, 비KR → 전달값, 강제 매핑 회원은 헤더보다 강제 국가 우선인 focused test 통과.
|
||||
- **범위 밖:** 호출부 치환.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceService.kt`
|
||||
- Test: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- `fun isAdultVisibleForQuery(member: Member, isAdultContentVisible: Boolean): Boolean`
|
||||
- 구현은 `calculateIsAdultForQuery(member, resolveCountryCode(member), isAdultContentVisible)`로 한다. DB 조회를 추가하지 않는다.
|
||||
|
||||
- [x] **RED:** `MemberContentPreferenceServiceTest`에 `isAdultVisibleForQuery` 판정 테스트 4건(KR 미인증/KR 인증/비KR/강제 매핑)을 작성한다.
|
||||
- [x] **RED 확인:** `MemberContentPreferenceServiceTest` 실행 결과 `Unresolved reference: isAdultVisibleForQuery` 컴파일 실패를 확인했다.
|
||||
- [x] **GREEN:** `MemberContentPreferenceService`에 `isAdultVisibleForQuery`를 추가했다.
|
||||
- [x] **GREEN 확인:** `MemberContentPreferenceServiceTest` 25/25 통과.
|
||||
- [x] **REFACTOR:** `resolveCountryCode` + `calculateIsAdultForQuery` 재사용만으로 구현해 중복 계산이 없음을 확인했고 ktlint 통과.
|
||||
|
||||
## Phase 2: production 호출부 마이그레이션
|
||||
|
||||
**Phase 결과:** production 코드에서 `isAdultVisibleByPolicy(...)` 호출이 0건이 된다.
|
||||
|
||||
**선행조건:** Phase 1 완료.
|
||||
|
||||
**Phase 완료 조건:** `P2-T1`~`P2-T4`와 `P2-GATE` 완료 및 검증 기록 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 2.1 메인 탭 서비스 7개 치환
|
||||
|
||||
**Goal 실행 `P2-T1`:** 콘텐츠 메인 탭 서비스 7개가 `MemberContentPreferenceService`로 성인 노출 여부를 계산한다.
|
||||
|
||||
- **시작 조건:** `P1-T1` 완료.
|
||||
- **완료 증거:** 7개 파일에서 `isAdultVisibleByPolicy` import/호출 0건, 컴파일 및 영향 범위 테스트 통과.
|
||||
- **범위 밖:** `AudioContentService`, `ContentSeriesService`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/asmr/AudioContentMainTabAsmrService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/replay/AudioContentMainTabLiveReplayService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/series/AudioContentMainTabSeriesService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/alarm/AudioContentMainTabAlarmService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/content/AudioContentMainTabContentService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/free/AudioContentMainTabFreeService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/tab/home/AudioContentMainTabHomeService.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- 각 서비스 생성자에 `private val memberContentPreferenceService: MemberContentPreferenceService`를 추가한다.
|
||||
- `isAdultVisibleByPolicy(member, isAdultContentVisible)` → `memberContentPreferenceService.isAdultVisibleForQuery(member, isAdultContentVisible)`.
|
||||
- `AudioContentMainTabHomeService`의 nullable member 처리(`member?.let { ... } ?: false`)는 유지한다.
|
||||
|
||||
- [x] **RED:** 7개 서비스가 각각 대응 `*Controller`에서만 주입됨을 확인해 `RISK-001` 위반 경로가 없음을 검증했다.
|
||||
- [x] **GREEN:** 7개 파일의 호출부와 생성자를 치환했다.
|
||||
- [x] **GREEN 확인:** 테스트 컴파일과 `src/test/kotlin/kr/co/vividnext/sodalive/content` 42/42 통과로 확인했다.
|
||||
- [x] **REFACTOR:** `isAdultVisibleByPolicy` import를 `MemberContentPreferenceService` import로 교체했고 ktlint 통과.
|
||||
|
||||
#### Task 2.2 메인/큐레이션/테마 서비스 치환
|
||||
|
||||
**Goal 실행 `P2-T2`:** `AudioContentMainService`, `AudioContentCurationService`, `AudioContentThemeService`를 정식 경로로 치환한다.
|
||||
|
||||
- **시작 조건:** `P2-T1` 완료.
|
||||
- **완료 증거:** 3개 파일에서 `isAdultVisibleByPolicy` 0건, 컴파일 및 영향 범위 테스트 통과.
|
||||
- **범위 밖:** 다른 Task 대상 파일.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/AudioContentMainService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/main/curation/AudioContentCurationService.kt`
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/theme/AudioContentThemeService.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- `P2-T1`과 동일한 치환 규칙을 적용한다.
|
||||
|
||||
- [x] **RED:** 3개 서비스의 치환 대상이 모두 `member`를 받는 웹 조회 메서드임을 확인했다.
|
||||
- [x] **GREEN:** 3개 파일을 치환했다.
|
||||
- [x] **GREEN 확인:** 테스트 컴파일 성공 및 `content` 패키지 회귀 통과.
|
||||
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
|
||||
|
||||
#### Task 2.3 `AudioContentService` 치환
|
||||
|
||||
**Goal 실행 `P2-T3`:** `AudioContentService`의 3곳을 정식 경로로 치환한다.
|
||||
|
||||
- **시작 조건:** `P2-T2` 완료.
|
||||
- **완료 증거:** `AudioContentServiceTest`가 갱신된 생성자로 통과한다.
|
||||
- **범위 밖:** `ContentSeriesService`.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/AudioContentService.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/content/AudioContentServiceTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- `P2-T1`과 동일한 치환 규칙을 적용하고, 테스트에서는 `MemberContentPreferenceService`를 mock으로 주입한다.
|
||||
|
||||
- [x] **RED:** `AudioContentServiceTest.kt:118`에서 `No value passed for parameter 'memberContentPreferenceService'` 컴파일 실패를 확인했다.
|
||||
- [x] **GREEN:** `getDetail`, `getLatestCreatorAudioContent`, `getAudioContentList` 3곳을 치환하고 테스트에 mock을 주입했다.
|
||||
- [x] **GREEN 확인:** `AudioContentServiceTest` 16/16 통과.
|
||||
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
|
||||
|
||||
#### Task 2.4 `ContentSeriesService` 치환
|
||||
|
||||
**Goal 실행 `P2-T4`:** `ContentSeriesService`의 5곳을 정식 경로로 치환한다.
|
||||
|
||||
- **시작 조건:** `P2-T3` 완료.
|
||||
- **완료 증거:** 시리즈 관련 영향 범위 테스트 통과, `isAdultVisibleByPolicy` 0건.
|
||||
- **범위 밖:** Deprecated 함수 제거.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/kotlin/kr/co/vividnext/sodalive/content/series/ContentSeriesService.kt`
|
||||
- Test: 필요 시 `src/test/kotlin/kr/co/vividnext/sodalive/content/series/main/SeriesMainControllerTest.kt` 등 영향 테스트 갱신
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- `P2-T1`과 동일한 치환 규칙을 적용한다.
|
||||
|
||||
- [x] **RED:** 테스트 컴파일을 실행해 `ContentSeriesService`를 직접 생성하는 테스트가 없어 추가 수정이 필요 없음을 확인했다.
|
||||
- [x] **GREEN:** 5곳(`getSeriesList`, `getSeriesListByGenre`, `getSeriesDetail`, `getSeriesContentList`, `getRecommendSeriesList`)을 치환했다.
|
||||
- [x] **GREEN 확인:** 테스트 컴파일 성공 및 Spring 컨텍스트 로딩 테스트 통과.
|
||||
- [x] **REFACTOR:** import 정리 후 ktlint 통과.
|
||||
|
||||
### Phase 2 Gate
|
||||
|
||||
**Goal 실행 `P2-GATE`:** production에서 Deprecated 호출이 0건임을 확인한다.
|
||||
|
||||
- [x] `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 검색 결과가 `src/main/kotlin`에서 0건이다.
|
||||
- [x] Phase 2 대상 파일의 영향 범위 테스트가 통과한다(`content` 42/42).
|
||||
|
||||
## Phase 3: Deprecated 함수 제거와 테스트 정리
|
||||
|
||||
**Phase 결과:** Deprecated 함수와 전용 테스트가 제거되고 고유 검증이 정식 경로 테스트에 남는다.
|
||||
|
||||
**선행조건:** Phase 2 완료.
|
||||
|
||||
**Phase 완료 조건:** `P3-T1`, `P3-GATE` 완료 및 검증 기록 누적.
|
||||
|
||||
### 구현 항목
|
||||
|
||||
#### Task 3.1 Deprecated 정의 제거와 검증 이전
|
||||
|
||||
**Goal 실행 `P3-T1`:** `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 제거하고 고유 검증을 이전한다.
|
||||
|
||||
- **시작 조건:** `P2-GATE` 완료.
|
||||
- **완료 증거:** 두 파일이 삭제되고 `member/contentpreference` 테스트가 모두 통과한다.
|
||||
- **범위 밖:** `MemberContentPreferenceCountryResolver.kt` 변경.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Delete: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt`
|
||||
- Delete: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicyTest.kt`
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- 이전 대상 검증: 헤더 공백/소문자 정규화(`" us "` → `US`), 국가 컨텍스트 없음 → `KR` fallback, 비KR에서 저장 `countryCode` 무시.
|
||||
- 이미 정식 경로 테스트에 존재하는 검증은 중복 추가하지 않는다.
|
||||
|
||||
- [x] **RED:** 로그인 회원의 헤더 공백/소문자 정규화와 KR fallback 검증을 `MemberContentPreferenceServiceTest`에 추가했다. 나머지 항목(비로그인 정규화, 저장 `countryCode` 미사용)은 기존 테스트가 이미 커버함을 확인해 중복 추가하지 않았다.
|
||||
- [x] **GREEN:** `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 삭제했다.
|
||||
- [x] **GREEN 확인:** `member/contentpreference` 36/36 통과.
|
||||
- [x] **REFACTOR:** ktlint 통과 및 잔여 참조 0건 확인.
|
||||
|
||||
### Phase 3 Gate
|
||||
|
||||
**Goal 실행 `P3-GATE`:** 전체 정리 상태를 확인한다.
|
||||
|
||||
- [x] `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 참조가 `src/main/kotlin`, `src/test/kotlin`에서 0건이다.
|
||||
- [x] `member/contentpreference` 테스트와 Phase 2 영향 범위 테스트가 통과한다.
|
||||
- [x] `./gradlew ktlintMainSourceSetCheck ktlintTestSourceSetCheck`가 통과한다.
|
||||
- [x] PRD 성공 기준 체크박스를 갱신했다.
|
||||
|
||||
## Phase 3 후속: 리뷰 검증 보강
|
||||
|
||||
### Task 3.R1 저장 국가 무시 검증 이전
|
||||
|
||||
**Goal 실행 `P3-R1`:** 저장 국가가 KR인 미인증 회원이 US에서 조회할 때 저장 국가를 무시하는 기존 정책을 정식 서비스 테스트로 검증한다.
|
||||
|
||||
- **시작 조건:** DEPREM-004 및 `reviews/문서대비구현-review.md`의 REV-002 확정. REV-001은 사용자 확인으로 결함 판정 철회; `2L` 제거 유지.
|
||||
- **완료 증거:** 아래 테스트 추가 및 조건·assertion 소스 검토, 후속 실행이 허용된 경우 focused test 결과 기록. 실행하지 않았으면 실행 검증 대기로 남기고 완료 처리하지 않는다.
|
||||
- **범위 밖:** production 정책·resolver·매핑 변경, API 변경, 테스트 helper 리팩터링, 전체 회귀 실행.
|
||||
- **TDD 예외:** 현재 production은 올바르게 저장 국가를 무시하므로 신규 테스트는 처음부터 통과하는 것이 정상이다. 실패를 만들려고 정상 구현을 변경하지 않는다. 삭제된 테스트와 새 테스트의 입력·assertion 대조로 누락 검증 이전을 확인한다.
|
||||
- **실행 제약:** 이번 문서 갱신에서는 테스트를 실행하지 않는다. 기존 자동 테스트 미실행 지시가 유지되는 동안 후속 작업도 소스 검토까지만 진행하고 실행 상태를 별도로 남긴다.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceServiceTest.kt`
|
||||
- Update: `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/plan-task.md`
|
||||
- Update: `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/reviews/문서대비구현-review.md`
|
||||
|
||||
**추가할 테스트:** 기존 `createMember`, `countryContext`, `service`, JUnit import를 재사용한다.
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
@DisplayName("비KR 요청에서는 회원의 저장 국가와 무관하게 전달한 성인 노출값을 사용한다")
|
||||
fun shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr() {
|
||||
val member = createMember(id = 2401L, withAuth = false).apply {
|
||||
countryCode = "KR"
|
||||
}
|
||||
countryContext.setCountryCode("US")
|
||||
|
||||
assertEquals("US", service.resolveCountryCode(member))
|
||||
assertTrue(service.isAdultVisibleForQuery(member, isAdultContentVisible = true))
|
||||
assertFalse(service.isAdultVisibleForQuery(member, isAdultContentVisible = false))
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **누락 확인:** 삭제된 정책 테스트와 비교해 저장 KR·요청 US·미인증 조건이 기존 정식 테스트에 없음을 확인했다(TDD 예외 적용).
|
||||
- [x] **최소 구현:** 위 테스트 1건만 추가했다. 강제 매핑이 없는 ID 2401을 사용하고 production은 변경하지 않았다.
|
||||
- [x] **소스 검토:** 국가 결과 US 및 노출 전달값 true/false 보존 assertion을 확인했다. 저장 국가 KR을 우선하면 국가 assertion과 `true` 전달값 assertion이 실패한다.
|
||||
- [ ] **실행 검증:** 자동 테스트 실행이 허용되는 후속 단계에서 아래 focused test와 ktlint를 실행하고 결과를 기록한다. 미실행이면 완료 표시하지 않는다.
|
||||
- [x] **기록 갱신:** P3-T1의 기존 완료 기록을 유지하고, REV-002 보강 결과와 실행 대기 상태를 본 Task와 리뷰에 누적했다.
|
||||
|
||||
**후속 실행용 명령(이번에는 미실행):** 저장소와 호환되는 JDK 환경에서 실행한다.
|
||||
|
||||
```bash
|
||||
./gradlew test --tests 'kr.co.vividnext.sodalive.member.contentpreference.MemberContentPreferenceServiceTest'
|
||||
./gradlew ktlintTestSourceSetCheck
|
||||
```
|
||||
|
||||
기대 결과: 신규 테스트를 포함한 서비스 테스트 및 테스트 소스 ktlint 성공. 테스트 한 파일의 검증 보강이므로 전체 회귀는 생략한다.
|
||||
|
||||
## 검증 기록
|
||||
|
||||
- 2026-09-17 Phase 3 후속 P3-R1: 삭제된 정책 테스트와 현재 서비스 테스트를 대조해 저장 KR·요청 US·미인증 조건의 누락을 확인하고 `MemberContentPreferenceServiceTest.shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr` 1건을 추가했다. 국가 결과 US와 전달값 true/false 보존 assertion을 소스로 검토했다. production은 변경하지 않았으며 기존 자동 테스트 미실행 지시에 따라 focused test와 ktlint는 실행하지 않아 Task 완료 처리는 보류했다.
|
||||
- 2026-09-17 리뷰 후속 문서 갱신: 사용자가 JP 매핑의 `2L` 제거를 의도한 변경으로 확정했다. REV-002 검증 누락은 `P3-R1`로 추가하고 REV-003 호출 수는 27곳으로 정정했다. 문서와 테스트 소스의 식별자·조건을 대조했으며 사용자 지시에 따라 자동 테스트 및 Gradle 명령은 실행하지 않았다.
|
||||
|
||||
- 2026-09-17 Phase 1: `MemberContentPreferenceServiceTest` 실행 → RED에서 `Unresolved reference: isAdultVisibleForQuery` 확인, GREEN 후 25/25 통과. 검증 이유: 신규 판정 메서드가 Deprecated 함수와 동일한 결과를 내는지 확인.
|
||||
- 2026-09-17 Phase 2: `AudioContentServiceTest.kt:118` 컴파일 실패로 생성자 변경 영향을 확인한 뒤 mock 주입. `src/test/kotlin/kr/co/vividnext/sodalive/content` 42/42 통과, `AudioContentServiceTest` 16/16 통과.
|
||||
- 2026-09-17 Phase 3: `src/test/kotlin/kr/co/vividnext/sodalive/member/contentpreference` 36/36 통과(Deprecated 테스트 5건 삭제, 신규 3건 추가 반영).
|
||||
- 2026-09-17 빈 주입 회귀: `SpringBootIntegrationSampleTest` 1/1 통과로 12개 서비스에 추가된 `MemberContentPreferenceService` 주입이 애플리케이션 컨텍스트에서 정상 해석됨을 확인(순환 의존 없음).
|
||||
- 2026-09-17 스타일: `JAVA_HOME=<Android Studio JBR 21> ./gradlew ktlintMainSourceSetCheck ktlintTestSourceSetCheck` BUILD SUCCESSFUL. JetBrains JBR 25는 Gradle 8.1.1과 비호환이어서 Java 21 JBR을 사용했다.
|
||||
- 2026-09-17 전체 회귀 생략: `./gradlew test` 전체 회귀는 실행하지 않았다. 근거는 변경이 `isAdult` 계산 위임 경로 치환으로 한정되고 공개 스키마/DB/보안 설정 변경이 없으며, 대신 변경 범위 focused test(`content` 42, `member/contentpreference` 36, `AudioContentServiceTest` 16)와 컨텍스트 로딩 테스트, ktlint 전체 소스셋 검사를 실행했다.
|
||||
@@ -1,132 +0,0 @@
|
||||
# 성인 콘텐츠 노출 정책 Deprecated 함수 제거 PRD
|
||||
|
||||
## 문서 정보
|
||||
|
||||
| 항목 | 내용 |
|
||||
|---|---|
|
||||
| 문서 상태 | Phase 1~3 완료, 리뷰 후 고유 검증 보강 대기 |
|
||||
| 작성일 | `2026-09-17` |
|
||||
| 최종 수정일 | `2026-09-17` |
|
||||
| 대상 제품 | 성인 콘텐츠 노출 여부 / 접속 국가 코드 계산 정책 |
|
||||
| 작성자·결정권자 | Junie / 사용자 |
|
||||
| 관련 API Contract | 기존 API 응답 스키마 변경 없음 |
|
||||
| 관련 구현 계획 | `docs/20260917_성인콘텐츠노출정책_Deprecated함수_제거/plan-task.md` |
|
||||
| 관련 review | `reviews/문서대비구현-review.md` |
|
||||
|
||||
## 1. Overview
|
||||
|
||||
성인 콘텐츠 노출 여부(`isAdult`)와 접속 국가 코드 계산 정책은 현재 두 경로에 중복 구현되어 있다. 하나는 `@Deprecated`로 표시된 top-level 함수 `MemberContentPreferencePolicy.isAdultVisibleByPolicy(...)` / `resolveCountryCodeByPolicy(...)`이고, 다른 하나는 정식 경로인 `MemberContentPreferenceService`다. Deprecated 경로가 production 12개 파일에서 여전히 27곳 호출되고 있어 정책 변경 시 두 곳을 모두 고쳐야 하는 위험이 남아 있다. 이 작업은 Deprecated 경로 호출부를 `MemberContentPreferenceService`로 이전하고 Deprecated 함수와 그 전용 테스트를 제거한다.
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
- `MemberContentPreferencePolicy.kt`의 두 함수는 `docs/20260623_메인_콘텐츠_추천_탭_API/plan-task.md`에서 `@Deprecated` 처리만 되고 호출부 이전은 완료되지 않았다.
|
||||
- 접속 국가 판정 기준이 두 경로에서 다르게 조달된다. Deprecated 경로는 `RequestContextHolder`에서 `CloudFront-Viewer-Country` 헤더를 직접 읽고, 정식 경로는 `CountryContext`(요청 스코프)에서 읽는다. 값 출처(같은 헤더, `CountryInterceptor`가 주입)는 같지만 경로가 둘이다.
|
||||
- Deprecated 함수의 유일한 테스트인 `MemberContentPreferencePolicyTest`의 5개 검증은 `MemberContentPreferenceServiceTest` / `MemberContentPreferenceIntegrationTest`에 이미 동등하게 존재해, 회원 ID 강제 매핑 정책을 변경할 때마다 같은 상수를 3~4개 파일에서 함께 고쳐야 한다.
|
||||
- 결과적으로 정책 변경(예: 국가 강제 매핑 회원 ID 추가/삭제)이 중복 지점 누락으로 회귀를 만들 수 있다.
|
||||
|
||||
문제를 해결했다는 판단은 `isAdultVisibleByPolicy` / `resolveCountryCodeByPolicy` 참조가 `src/main/kotlin`과 `src/test/kotlin`에서 0건이 되고, 기존 legacy 콘텐츠 조회 API의 `isAdult` 계산 결과가 그대로 유지되는 것으로 한다.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- 성인 콘텐츠 노출 여부 계산의 단일 진입점을 `MemberContentPreferenceService`로 만든다.
|
||||
- `MemberContentPreferencePolicy.kt`의 Deprecated 함수 2개와 파일, 전용 테스트를 제거한다.
|
||||
- 기존 legacy 콘텐츠 조회 API의 `isAdult` / 국가 코드 계산 동작을 유지하되, 사용자가 확정한 JP 강제 매핑의 `2L` 제거는 예외로 반영한다.
|
||||
- 공개 API request/response 스키마를 변경하지 않는다.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- `isAdult` 판정 정책 자체(KR + 본인인증 여부, 해외 전달값 사용)를 변경하지 않는다.
|
||||
- JP 강제 매핑의 `2L` 제거 외에는 회원 ID 국가 강제 매핑(`FORCED_KR_MEMBER_IDS`, `FORCED_JP_MEMBER_IDS`) 값을 변경하지 않는다.
|
||||
- `isAdultContentVisible`을 서비스 파라미터에서 제거하거나 저장값 조회로 대체하는 리팩터링은 하지 않는다. 호출부 시그니처는 유지한다.
|
||||
- `CanController`의 통화(currency) 강제 지정 로직은 국가 강제 매핑과 무관하므로 건드리지 않는다.
|
||||
- `MemberContentPreferenceCountryResolver.kt`의 강제 매핑 알고리즘은 유지하고, JP 대상에서 `2L`만 제외한다.
|
||||
|
||||
## 5. 영향 범위
|
||||
|
||||
### 5.1 Deprecated 함수 정의
|
||||
|
||||
| 파일 | 대상 |
|
||||
|---|---|
|
||||
| `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferencePolicy.kt` | `resolveCountryCodeByPolicy(member)`, `isAdultVisibleByPolicy(member, isAdultContentVisible)` |
|
||||
|
||||
### 5.2 production 호출부
|
||||
|
||||
| 파일 | 호출 수 |
|
||||
|---|---:|
|
||||
| `content/series/ContentSeriesService.kt` | 5 |
|
||||
| `content/AudioContentService.kt` | 3 |
|
||||
| `content/main/AudioContentMainService.kt` | 2 |
|
||||
| `content/main/curation/AudioContentCurationService.kt` | 2 |
|
||||
| `content/theme/AudioContentThemeService.kt` | 2 |
|
||||
| `content/main/tab/free/AudioContentMainTabFreeService.kt` | 4 |
|
||||
| `content/main/tab/alarm/AudioContentMainTabAlarmService.kt` | 2 |
|
||||
| `content/main/tab/content/AudioContentMainTabContentService.kt` | 2 |
|
||||
| `content/main/tab/home/AudioContentMainTabHomeService.kt` | 2 |
|
||||
| `content/main/tab/asmr/AudioContentMainTabAsmrService.kt` | 1 |
|
||||
| `content/main/tab/replay/AudioContentMainTabLiveReplayService.kt` | 1 |
|
||||
| `content/main/tab/series/AudioContentMainTabSeriesService.kt` | 1 |
|
||||
|
||||
12개 파일, 총 27곳이다. 어떤 파일도 현재 `MemberContentPreferenceService`를 주입받지 않는다.
|
||||
|
||||
### 5.3 테스트 호출부
|
||||
|
||||
| 파일 | 처리 |
|
||||
|---|---|
|
||||
| `src/test/kotlin/.../member/contentpreference/MemberContentPreferencePolicyTest.kt` | 삭제. 동등 검증이 `MemberContentPreferenceServiceTest`, `MemberContentPreferenceIntegrationTest`에 이미 존재하며, 부족한 항목은 `MemberContentPreferenceServiceTest`로 이전한다. |
|
||||
|
||||
## 6. 기능 요구사항
|
||||
|
||||
| ID | 상태 | 요구사항 | 수용 기준 | 계약/Goal 연결 |
|
||||
|---|---|---|---|---|
|
||||
| `DEPREM-001` | 확정 | `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)`를 추가해 `resolveCountryCode(member)` + `calculateIsAdultForQuery(...)` 조합을 단일 메서드로 제공한다. | KR + `auth == null`이면 `false`, KR + `auth != null`이면 전달값, 비KR이면 전달값. 회원 ID 강제 매핑이 접속 국가 헤더보다 우선한다. | `P1-T1` |
|
||||
| `DEPREM-002` | 확정 | production 12개 파일의 `isAdultVisibleByPolicy(...)` 27곳을 `memberContentPreferenceService.isAdultVisibleForQuery(...)`로 치환하고 `MemberContentPreferenceService`를 생성자 주입한다. | 각 파일에서 `isAdultVisibleByPolicy` import와 호출이 사라지고, `isAdult` 계산 결과가 기존과 동일하다. | `P2-T1`~`P2-T4` |
|
||||
| `DEPREM-003` | 확정 | `MemberContentPreferencePolicy.kt`와 `MemberContentPreferencePolicyTest.kt`를 제거한다. | `isAdultVisibleByPolicy|resolveCountryCodeByPolicy` 검색 결과가 `src/main/kotlin`, `src/test/kotlin`에서 0건이다. | `P3-T1` |
|
||||
| `DEPREM-004` | 확정 | Deprecated 테스트에서 제거되는 검증 항목 중 정식 경로 테스트에 없는 것은 `MemberContentPreferenceServiceTest`로 이전한다. | 헤더 공백/소문자 정규화(`" us "` → `US`), 국가 컨텍스트 없음 → `KR` fallback, 비KR에서 저장 `countryCode` 무시 검증이 정식 경로 테스트에 존재한다. | `P3-T1` |
|
||||
| `DEPREM-005` | 확정 | 기존 legacy 콘텐츠 조회 동작을 회귀 없이 유지한다. | `AudioContentServiceTest` 등 영향 범위 테스트와 `member/contentpreference` 테스트가 모두 통과하고 ktlint가 통과한다. | `P3-GATE` |
|
||||
|
||||
## 7. API 계약
|
||||
|
||||
- 변경되는 공개 request/response 필드는 없다.
|
||||
- 신규 message key, 신규 endpoint는 없다.
|
||||
- 각 서비스의 public 메서드 시그니처(`isAdultContentVisible` 파라미터 포함)는 유지한다. 변경되는 것은 생성자 의존성뿐이다.
|
||||
|
||||
## 8. 보안과 데이터 취급
|
||||
|
||||
- 접속 국가 판정은 계속 `CloudFront-Viewer-Country` 헤더를 `CountryInterceptor`가 `CountryContext`에 주입한 값만 사용한다.
|
||||
- 성인 콘텐츠 노출은 KR에서 본인인증(`member.auth != null`)이 있는 경우에만 허용하는 기존 정책을 유지한다.
|
||||
- 회원 ID, 이메일 등 개인정보는 로그에 추가하지 않는다.
|
||||
|
||||
## 9. 리스크
|
||||
|
||||
| ID | 리스크 | 대응 |
|
||||
|---|---|---|
|
||||
| `RISK-001` | `CountryContext`는 `@RequestScope`이므로 비웹 스레드에서 호출되면 예외가 발생할 수 있다. Deprecated 함수는 `RequestContextHolder`가 없을 때 `KR`로 fallback했다. | 대상 12개 서비스가 controller 요청 경로에서만 호출되는지 Phase 2에서 파일별로 확인한다. 스케줄러/비동기 호출 경로가 발견되면 해당 Task를 진행하지 않고 계획을 먼저 갱신한다. |
|
||||
| `RISK-002` | 생성자 파라미터 추가로 기존 서비스 테스트가 컴파일 실패할 수 있다. | 해소됨. `AudioContentServiceTest` 1곳만 영향받아 mock 주입으로 해결했다. |
|
||||
| `RISK-003` | 순환 의존 발생 가능성. | `MemberContentPreferenceService`는 `MemberContentPreferenceRepository`, `MemberRepository`, `CountryContext`, `CacheManager`만 의존하므로 콘텐츠 서비스와 순환이 발생하지 않음을 확인했다. |
|
||||
|
||||
## 10. 성공 기준
|
||||
|
||||
- [x] `MemberContentPreferenceService.isAdultVisibleForQuery(...)`가 기존 `isAdultVisibleByPolicy(...)`와 동일한 판정을 한다. (`DEPREM-001`)
|
||||
- [x] production 12개 파일 27곳이 모두 정식 경로를 호출한다. (`DEPREM-002`)
|
||||
- [x] `MemberContentPreferencePolicy.kt`, `MemberContentPreferencePolicyTest.kt`가 제거되고 참조가 0건이다. (`DEPREM-003`)
|
||||
- [x] Deprecated 테스트의 고유 검증이 `MemberContentPreferenceServiceTest`에 남아 있다. (`DEPREM-004`)
|
||||
- [x] 영향 범위 focused test와 ktlint가 통과한다. (`DEPREM-005`)
|
||||
|
||||
## 11. Decision Log
|
||||
|
||||
기존 성공 기준 체크박스는 당시 완료 기록으로 유지한다. 후속 리뷰에서 DEPREM-004의 저장 국가 충돌 검증 누락을 확인했으며, `plan-task.md`의 `P3-R1` 완료 전까지 해당 검증 보강은 미완료다.
|
||||
|
||||
| 일시 | 결정 | 근거 |
|
||||
|---|---|---|
|
||||
| 2026-09-17 | Deprecated 함수 제거를 위해 production 호출부를 모두 마이그레이션하는 방식(선택지 3)을 택한다. | 사용자 선택. 테스트만 정식 경로로 바꾸면 production에서 쓰이는 Deprecated 경로가 커버리지 0이 되므로 부적절 |
|
||||
| 2026-09-17 | 호출부 1:1 치환을 위해 `MemberContentPreferenceService`에 `isAdultVisibleForQuery(member, isAdultContentVisible)` wrapper를 추가한다. | 호출부마다 `resolveCountryCode(...)` + `calculateIsAdultForQuery(...)` 2단계를 반복하면 중복이 27곳으로 늘어남 |
|
||||
| 2026-09-17 | `MemberContentPreferencePolicyTest`는 삭제하고 고유 검증만 `MemberContentPreferenceServiceTest`로 이전한다. | 5개 검증 중 대부분이 정식 경로 테스트와 중복이며, 함수 제거 후에는 테스트 대상이 사라짐 |
|
||||
| 2026-09-17 | Phase를 서비스 묶음 단위로 나눠 진행한다. | 27곳 동시 변경 시 실패 원인 추적이 어려움. 저장소 규칙("작은 단위로 안전하게 수정") 준수 |
|
||||
| 2026-09-17 | `RISK-001`은 해소로 판정한다. | 치환한 27곳이 모두 controller 진입 조회 경로임을 파일별로 확인했고, 스케줄러(`Recommendation/Ranking/ChargeEvent`)는 이 경로를 호출하지 않음 |
|
||||
|
||||
- 2026-09-17 사용자 확정: JP 강제 매핑에서 `2L`을 제거한 것은 의도된 정책 변경이다. REV-001은 결함 판정을 철회하며 `2L`을 복원하지 않는다. 나머지 매핑은 유지한다.
|
||||
- 2026-09-17 후속 계획: REV-002의 저장 국가 무시 검증을 `P3-R1`로 추가한다. 이번 작업은 문서 갱신이며 테스트 구현·실행은 하지 않는다.
|
||||
|
||||
## 12. 열린 질문
|
||||
|
||||
- 없음. Phase 2에서 비웹 호출 경로(`RISK-001`)가 발견되면 계획을 먼저 갱신하고 사용자에게 확인한다.
|
||||
@@ -1,65 +0,0 @@
|
||||
# 문서 대비 구현 리뷰
|
||||
|
||||
## 리뷰 정보와 범위
|
||||
|
||||
- 일자: 2026-09-17
|
||||
- 기준: HEAD `8c87286acd69174f6024d30a267458e838e35cf5` 및 리뷰 시작 시 존재한 미커밋 변경
|
||||
- 요구사항: `../prd.md`, `../plan-task.md`의 Phase 1~3
|
||||
- 방법: 문서, production 코드, 테스트 소스, 현재 diff와 호출 경로 정적 대조
|
||||
- 사용자 지시에 따라 자동 테스트·빌드·ktlint·애플리케이션 실행은 하지 않았다. 기존 실행 성공 기록을 재검증한 것으로 취급하지 않는다.
|
||||
- 구현 및 기존 PRD/계획은 수정하지 않았다. 아래 판정은 현재 작업 트리에 대한 것이며 변경 작성자나 의도를 추정하지 않는다.
|
||||
|
||||
## 발견 사항
|
||||
|
||||
### REV-001 — 강제 JP 매핑 값이 변경됨
|
||||
|
||||
- 심각도: High / 상태: 확정
|
||||
- 요구사항: PRD §3 기존 동작 유지, §4 강제 매핑 값 변경 금지; 계획 P3-T1의 resolver 변경 제외
|
||||
- 근거: `src/main/kotlin/kr/co/vividnext/sodalive/member/contentpreference/MemberContentPreferenceCountryResolver.kt:6`에서 `FORCED_JP_MEMBER_IDS`의 `2L`이 제거됐다.
|
||||
- 코드 추적: 회원 ID 2, 인증 없음, 요청 국가 KR 또는 헤더 없음, `isAdultContentVisible=true`이면 기존 JP 판정/성인 노출 true에서 KR 판정/false로 바뀐다.
|
||||
- 관련 테스트도 `MemberContentPreferenceServiceTest.kt:64`, `MemberContentPreferenceIntegrationTest.kt:187`에서 ID 2를 29721로 교체해 기존 매핑 회귀를 검출하지 못한다.
|
||||
- 권장 조치: 문서대로라면 기존 매핑과 해당 테스트 대상을 복원한다. 별도로 의도한 정책 변경이라면 해당 결정과 범위를 문서에 명시해야 한다.
|
||||
|
||||
### REV-002 — 저장 국가 무시 검증이 이전되지 않음
|
||||
|
||||
- 심각도: Medium / 상태: 확정
|
||||
- 요구사항: DEPREM-004, P3-T1
|
||||
- 근거: 삭제된 PolicyTest는 저장 `member.countryCode="KR"`, 요청 국가 US 조건을 검증했다. 현재 `MemberContentPreferenceServiceTest.kt:469`의 해외 노출 테스트와 `:536`의 회원 생성 함수는 저장 국가를 설정하지 않는다. IntegrationTest에도 이 충돌 조건이 없다.
|
||||
- 영향: 현재 resolver는 저장 국가를 참조하지 않아 구현 자체는 맞지만, 문서에서 이전하도록 요구한 고유 회귀 검증이 빠졌다. `plan-task.md:224`의 기존 테스트가 이미 커버한다는 기록은 부정확하다.
|
||||
- 권장 조치: 일반 미인증 회원의 저장 국가를 KR로 설정하고 요청 국가는 US로 두어, 정식 진입점의 국가 US/성인 노출 true를 확인하는 검증을 추가한다.
|
||||
|
||||
### REV-003 — 호출부 합계 오기
|
||||
|
||||
- 심각도: Low / 상태: 확정
|
||||
- 근거: PRD §5.2 표의 합계와 실제 치환은 모두 12개 파일 27곳이다. PRD와 계획에는 반복해서 24곳으로 기록돼 있다.
|
||||
- 영향: 호출 치환 누락은 없으며 문서 수치가 틀렸다.
|
||||
- 권장 조치: 두 문서의 합계를 27곳으로 정정한다.
|
||||
|
||||
## 충족 항목과 한계
|
||||
|
||||
| 항목 | 정적 검토 결과 |
|
||||
|---|---|
|
||||
| DEPREM-001 단일 진입점 | 기존 resolveCountryCode + calculateIsAdultForQuery 재사용, 추가 DB 조회 없음 |
|
||||
| DEPREM-002 호출부 이전 | 12개 파일 27곳 이전, 인자 및 홈의 member=null → false 유지 |
|
||||
| DEPREM-003 정의/참조 제거 | 두 파일 삭제, src/main/kotlin 및 src/test/kotlin의 두 Deprecated 함수 이름 검색 0건 |
|
||||
| DEPREM-004 고유 검증 이전 | 정규화/null 국가값 fallback은 존재, 저장 국가 충돌 검증 누락 |
|
||||
| DEPREM-005 실행 검증 | 사용자 요청에 따라 재실행 제외 |
|
||||
| API 계약 | 기존 공개 메서드 인자와 request/response 스키마 변경 없음 |
|
||||
| 요청 경로/의존성 | 변경된 조회 메서드의 비웹 호출 및 새 순환 의존 발견 없음 |
|
||||
|
||||
국가값 null 검증은 요청 스코프 자체가 없는 상황과 다르다. 새 경로는 요청 스코프와 회원 ID를 요구하므로 모든 입력에서 구 함수와 동등하다고 일반화할 수 없다. 다만 검토한 변경 호출 경로에서 해당 비웹/ID 없는 입력은 확인하지 못해 별도 운영 결함으로 확정하지 않았다.
|
||||
|
||||
## 리뷰 종료 판정과 후속 작업
|
||||
|
||||
요구사항, 테스트 소스, 코드 품질, 보안, 주변 호출 맥락의 검토 결과를 종합했다. 확정 불일치 3건으로 문서와 완전히 일치하는 구현이라고 판정할 수 없다.
|
||||
|
||||
이번 요청은 검토이므로 수정 goal은 실행하지 않았다. 후속 구현 시 기존 완료 기록을 유지하면서 REV-001 매핑 일치, REV-002 고유 검증 이전, REV-003 문서 수치 정정을 별도 회귀 Task로 계획에 추가한다. 자동 검증은 이번 검토에서 수행하지 않았다.
|
||||
|
||||
## 후속 판정 정정 — 2026-09-17
|
||||
|
||||
위 내용은 최초 검토 당시 기록이며, 사용자 확인 이후 현재 판정은 다음과 같다.
|
||||
|
||||
- **REV-001: 결함 판정 철회.** 사용자가 `2L` 제거를 의도한 변경으로 확정했다. PRD·계획에 해당 예외를 반영했으며 매핑과 테스트 대상을 복원하지 않는다.
|
||||
- **REV-002: 구현 완료 / 실행 검증 대기.** `MemberContentPreferenceServiceTest.shouldIgnoreStoredCountryCodeWhenRequestCountryIsNotKr`를 추가해 저장 KR·요청 US·미인증 회원 조건에서 국가 결과 US와 전달값 true/false 보존을 검증한다. production은 변경하지 않았고, 기존 자동 테스트 미실행 지시에 따라 focused test와 ktlint는 실행하지 않았다.
|
||||
- **REV-003: 문서 정정 완료.** PRD·계획의 24곳 표기를 실제 합계 27곳으로 정정했다.
|
||||
- **현재 결론:** `P3-R1` 테스트 구현과 소스 검토는 완료했으며, 남은 항목은 focused test와 ktlint 실행 검증이다.
|
||||
@@ -1,62 +0,0 @@
|
||||
# 캔 조회 회원 2 강제 통화 제거 구현 계획
|
||||
|
||||
- 상태: 구현 완료
|
||||
- 작성일: 2026-09-18
|
||||
- 요구사항: [prd.md](prd.md)
|
||||
- 현재 Phase: 1 완료
|
||||
- 활성 Goal: 없음
|
||||
- 실행 순서: CAN-P1-T1 → CAN-P1-GATE
|
||||
|
||||
### Phase 1: 회원 2의 기본 통화 적용과 회귀 확인
|
||||
|
||||
- [x] **Task 1.1: 회원 2의 강제 통화 제거** (`CAN-P1-T1`)
|
||||
- Objective: 회원 2가 다른 일반 회원처럼 요청 국가에 따른 통화의 캔 상품을 조회한다.
|
||||
- 시작 조건: PRD 확정, 기존 컨트롤러·서비스 및 테스트 확인 완료.
|
||||
- 완료 증거: RED/GREEN 결과, 실제 컨트롤러·서비스·저장소 통합 테스트와 캔 패키지 회귀 통과.
|
||||
- 범위 밖: 다른 회원 예외 제거, 국가 결정·API 스키마·서비스 리팩터링.
|
||||
- 수정: `src/main/kotlin/kr/co/vividnext/sodalive/can/CanController.kt`
|
||||
- 생성: `src/test/kotlin/kr/co/vividnext/sodalive/can/CanControllerIntegrationTest.kt`
|
||||
- 확인: `src/main/kotlin/kr/co/vividnext/sodalive/can/CanService.kt`, `src/test/kotlin/kr/co/vividnext/sodalive/can/CanServiceTest.kt`
|
||||
- [x] **RED:** 회원 2의 KRW/USD, 유지할 예외·일반 회원·비로그인·국가 누락 통합 테스트를 작성한다.
|
||||
- [x] **RED 확인:** `./gradlew test --tests 'kr.co.vividnext.sodalive.can.CanControllerIntegrationTest'`로 회원 2의 JPY 반환 assertion 실패를 확인한다.
|
||||
- [x] **GREEN:** 컨트롤러 조건에서 `member.id == 2L ||`만 제거한다.
|
||||
- [x] **GREEN 확인:** 동일 focused test가 통과하는지 확인한다.
|
||||
- [x] **REFACTOR:** 불필요한 정리 없이 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.*'` 및 변경 파일 lint로 회귀를 확인한다.
|
||||
|
||||
#### Task 검증 기록
|
||||
- 2026-09-18: 기존 `getCans` 및 `/can` 직접 테스트가 없음을 확인했다. 새 테스트는 `@SpringBootTest`와 H2, 요청 범위의 실제 CountryContext를 사용하고 트랜잭션 롤백으로 상품 데이터를 격리한다.
|
||||
- 2026-09-18 RED: 테스트 작성 중 `Member.password` 필수 인자 누락을 수정한 뒤 production/test 컴파일이 통과했다. 이후 공통 JWT 기본 키의 길이 부족으로 발생한 컨텍스트 로딩 실패는 테스트 실행에만 임시 키를 제공하여 해결했다. 이 두 실패는 동작 RED에 포함하지 않았다.
|
||||
- 2026-09-18 RED 확인: 아래 환경 설정 후 `./gradlew test --tests 'kr.co.vividnext.sodalive.can.CanControllerIntegrationTest' --console=plain` 실행. 13개 중 회원 2의 4개만 assertion 실패(exit 1): KR은 KRW, US/JP/국가 누락은 USD 기대였으나 실제 JPY. 나머지 9개 통과.
|
||||
- 2026-09-18 GREEN 확인: 조건 제거 후 같은 focused 명령으로 13개 전부 통과(exit 0), production/test 컴파일 성공.
|
||||
- 2026-09-18 회귀 확인: `./gradlew test --tests 'kr.co.vividnext.sodalive.can.*' --console=plain` 성공(exit 0). 신규 13개와 기존 14개, 총 5개 클래스 27개 테스트 통과(실패·오류·skip 0). 기존 테스트를 완화하거나 수정할 필요가 없었다.
|
||||
|
||||
- [x] **Phase Gate: 변경 범위와 검증 기록 확인** (`CAN-P1-GATE`)
|
||||
- Objective: 요구사항과 최소 변경, 검증 증거가 일치함을 확인한다.
|
||||
- 시작 조건: CAN-P1-T1 완료.
|
||||
- 완료 증거: 캔 패키지 테스트 통과, 문서 명령·diff 확인 및 PRD 체크박스 완료.
|
||||
- 범위 밖: 전체 서비스 실행과 관련 없는 기능 수정.
|
||||
- 확인 파일: 이 문서, `docs/20260918_캔조회_회원2_강제통화제거/prd.md`, 위 Task의 코드·테스트.
|
||||
- TDD 예외 사유: 구현 없는 최종 검증 단계.
|
||||
- 대체 검증 방법: `./gradlew tasks --all`, `git diff --check`, 변경 파일 대조.
|
||||
|
||||
## 검증 범위
|
||||
- 단일 루트 프로젝트이며 변경은 컨트롤러의 조건 하나에 한정된다. 전체 회귀 실행 조건(공통 코드·여러 도메인 변경·영향 불명확 실패·사용자 명시 요청)이 발생하지 않으면 전체 테스트는 생략하고 캔 패키지 전체를 실행한다.
|
||||
- 테스트 실행으로 production/test 컴파일을 함께 확인한다. 새 API나 실행 환경 변경이 없어 별도 애플리케이션 구동은 하지 않는다.
|
||||
|
||||
## Progress
|
||||
- 2026-09-18: 요구사항 및 TDD 계획 작성, CAN-P1-T1 준비.
|
||||
- 2026-09-18: CAN-P1-T1 RED 확인 완료, 회원 2 조건 제거 진행.
|
||||
- 2026-09-18: CAN-P1-T1 완료. 조건 한 항만 제거했고 신규 13개 및 기존 14개 테스트가 통과했다.
|
||||
- 2026-09-18: CAN-P1-GATE 완료. API 스키마·다른 회원 예외가 유지되는 diff와 PRD 수용 기준, 검증 기록을 대조했다. 남은 작업 없음.
|
||||
|
||||
## 공통 검증 기록
|
||||
- 셸에 기본 Java가 없어 기존 Gradle 캐시의 Java 17을 사용했다. 설정 파일은 변경하지 않았다.
|
||||
- 테스트 JWT 기본값은 디코딩 후 키 길이가 부족하므로 실행 프로세스에만 임시 키를 전달했다. 키 값은 저장·출력하지 않는다. 아래 환경 설정 뒤 문서의 검증 명령을 실행한다.
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=~/.gradle/jdks/eclipse_adoptium-17-x86_64-os_x.2/jdk-17.0.19+10/Contents/Home
|
||||
export JWT_SECRET="$(openssl rand -base64 64 | tr -d '\n')"
|
||||
```
|
||||
|
||||
- 2026-09-18: `./gradlew tasks --all --console=plain`, `git diff --check` 성공(exit 0). 변경 Kotlin 파일의 IDE 정적 검사에서 오류 없음. 신규 테스트는 경고도 없으며 기존 컨트롤러의 수정하지 않은 인증 SpEL 식에는 변수 해석 경고 8개가 있다.
|
||||
- 2026-09-18: 전체 테스트·전체 ktlint 및 별도 서버 실행은 생략했다. 변경은 컨트롤러 조건 한 곳이고 다른 도메인·공통 코드·하위 모듈 변경이 없어 focused 통합 테스트, 캔 패키지 전체 회귀, 변경 파일 정적 검사로 검증했다.
|
||||
@@ -1,27 +0,0 @@
|
||||
# 캔 조회 회원 2 강제 통화 제거
|
||||
|
||||
## 문서 정보
|
||||
- 상태: 구현 완료
|
||||
- 작성일: 2026-09-18
|
||||
- 관련 계획: [plan-task.md](plan-task.md)
|
||||
|
||||
## 문제와 목표
|
||||
`GET /can`의 `getCans`는 회원 2·4·44144에게 JPY를 강제 적용한다.
|
||||
사용자 요청에 따라 회원 2만 제외하여 기존 국가별 통화 선택을 따르게 한다.
|
||||
|
||||
## 확정 요구사항과 수용 기준
|
||||
| ID | 요구사항 | 수용 기준 | Goal |
|
||||
|---|---|---|---|
|
||||
| CAN-CURRENCY-001 | 회원 2의 JPY 강제 조건 제거 | KR 요청은 KRW, 그 외 국가는 USD 상품 반환 | CAN-P1-T1 |
|
||||
| CAN-CURRENCY-002 | 나머지 동작 유지 | 회원 4·44144는 JPY, 일반 회원·비로그인은 기존 국가별 통화 적용 | CAN-P1-T1 |
|
||||
| CAN-CURRENCY-003 | 관련 테스트 검증 | 변경 전 실패 재현, 변경 후 캔 패키지 테스트 통과 | CAN-P1-GATE |
|
||||
|
||||
## 범위 밖
|
||||
- 다른 진입점의 회원 ID 조건, 국가 판정 및 성인콘텐츠 정책 변경.
|
||||
- 서비스의 KR → KRW / 그 외 → USD 정책 변경.
|
||||
- 공개 API 요청·응답 스키마, 인증 및 상품 판매 상태 필터 변경.
|
||||
|
||||
## 성공 기준
|
||||
- [x] 회원 2에 대한 회귀 테스트가 변경 전 실패하고 변경 후 통과한다.
|
||||
- [x] 회원 4·44144, 일반 회원, 비로그인 및 국가 누락의 기존 동작이 유지된다.
|
||||
- [x] 관련 기존 테스트가 통과하고 검증 결과를 계획 문서에 기록한다.
|
||||
@@ -1,549 +0,0 @@
|
||||
# 선물 관리자 페이지 구현 프롬프트
|
||||
|
||||
이 문서는 관리자 페이지의 선물 관련 4개 화면을 프론트엔드 구현 에이전트에게 전달하기 위한 프롬프트 모음이다.
|
||||
|
||||
공통 전제:
|
||||
|
||||
- 관리자 메뉴에는 parent 메뉴 `선물함 관리` 아래에 다음 4개 하위 메뉴가 있다.
|
||||
- `선물함 리스트` → `/gift/list`
|
||||
- `받을 주소` → `/gift/mailbox`
|
||||
- `선물 카테고리` → `/gift/category`
|
||||
- `선물 사이즈` → `/gift/size`
|
||||
- 모든 API 응답은 기존 공통 envelope를 사용한다. 화면에서는 `response.data`를 실제 payload로 사용한다.
|
||||
- 인증/권한 처리는 기존 관리자 페이지의 API 클라이언트, 토큰 저장 방식, 에러 처리 방식을 따른다.
|
||||
- 새 디자인 시스템을 만들지 말고 기존 관리자 페이지의 테이블, 필터, 버튼, 모달, 폼, 토스트, 확인창 패턴을 재사용한다.
|
||||
- 화면 문구는 한국어로 작성한다.
|
||||
- API 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 선물함 리스트 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물함 리스트 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물함 리스트`에서 전체 선물 신청 목록을 조회한다.
|
||||
- 상태, 신청번호, 닉네임으로 필터링할 수 있다.
|
||||
- 목록 row에서 선물 상세를 확인하고, 현재 상태에서 가능한 운영 액션을 실행할 수 있다.
|
||||
- 기존 관리자 페이지의 테이블/필터/페이지네이션/모달/확인창 스타일을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/list`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물함 리스트`
|
||||
- 필터 영역:
|
||||
- 상태 select: 전체, 접수 완료, 발송 확인, 사서함 도착, 검수 완료, 전달 완료, 전달 불가, 신청 취소
|
||||
- 신청번호 검색 input: `applicationNo` 부분 검색
|
||||
- 닉네임 검색 input: 발송인 또는 수취인 닉네임 부분 검색
|
||||
- 검색 버튼, 초기화 버튼
|
||||
- 목록 테이블 컬럼:
|
||||
- 신청번호 `applicationNo`
|
||||
- 발송인 닉네임 `senderNickname`
|
||||
- 수취인 닉네임 `recipientNickname`
|
||||
- 사이즈 `sizeName` / `sizeCode`
|
||||
- 카테고리 `categoryName`
|
||||
- 분류번호 `classificationNumber`
|
||||
- 택배사 `courierCompanyName`
|
||||
- 운송장번호 `trackingNumber`
|
||||
- 상태 `statusName`
|
||||
- 액션 버튼
|
||||
- 페이지네이션:
|
||||
- 서버의 `page`, `size`, `totalCount`, `hasNext` 기준으로 기존 관리자 페이지 패턴에 맞춘다.
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gifts`
|
||||
- Query:
|
||||
- `status?: GiftStatus`
|
||||
- `applicationNo?: string`
|
||||
- `nickname?: string`
|
||||
- `page?: number` 기본 `0`
|
||||
- `size?: number` 기본 `20`
|
||||
- 필터 조합:
|
||||
- 선택 필터는 AND 조건이다.
|
||||
- `nickname`은 발송인/수취인 닉네임 중 하나에 포함되면 매칭된다.
|
||||
|
||||
목록 조회 response `data`:
|
||||
```ts
|
||||
type AdminGiftListResponse = {
|
||||
totalCount: number;
|
||||
items: AdminGiftListItemResponse[];
|
||||
page: number;
|
||||
size: number;
|
||||
hasNext: boolean;
|
||||
};
|
||||
|
||||
type AdminGiftListItemResponse = {
|
||||
applicationNo: string;
|
||||
senderNickname: string;
|
||||
recipientNickname: string;
|
||||
sizeCode: string;
|
||||
sizeName: string;
|
||||
categoryName: string;
|
||||
classificationNumber: string;
|
||||
courierCompanyName: string | null;
|
||||
trackingNumber: string | null;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
availableActions: AdminGiftAction[];
|
||||
};
|
||||
```
|
||||
|
||||
상태 enum:
|
||||
```ts
|
||||
type GiftStatus =
|
||||
| "RECEIVED"
|
||||
| "TRACKING_REGISTERED"
|
||||
| "ARRIVED_AT_MAILBOX"
|
||||
| "INSPECTION_COMPLETED"
|
||||
| "DELIVERED"
|
||||
| "UNDELIVERABLE"
|
||||
| "CANCELED";
|
||||
```
|
||||
|
||||
상태 표시명:
|
||||
- `RECEIVED`: `접수 완료`
|
||||
- `TRACKING_REGISTERED`: `발송 확인`
|
||||
- `ARRIVED_AT_MAILBOX`: `사서함 도착`
|
||||
- `INSPECTION_COMPLETED`: `검수 완료`
|
||||
- `DELIVERED`: `전달 완료`
|
||||
- `UNDELIVERABLE`: `전달 불가`
|
||||
- `CANCELED`: `신청 취소`
|
||||
|
||||
상세 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}`
|
||||
- 용도:
|
||||
- 목록 row 클릭 또는 `상세` 버튼 클릭 시 상세 모달/상세 패널을 연다.
|
||||
|
||||
상세 response `data`:
|
||||
```ts
|
||||
type AdminGiftDetailResponse = {
|
||||
applicationNo: string;
|
||||
senderInfo: AdminGiftMemberInfoResponse;
|
||||
recipientInfo: AdminGiftMemberInfoResponse;
|
||||
productInfo: AdminGiftProductInfoResponse;
|
||||
inboundDeliveryInfo: AdminGiftInboundDeliveryInfoResponse;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
availableActions: AdminGiftAction[];
|
||||
};
|
||||
|
||||
type AdminGiftMemberInfoResponse = {
|
||||
nickname: string;
|
||||
name: string;
|
||||
phoneNumber: string;
|
||||
address: string;
|
||||
};
|
||||
|
||||
type AdminGiftProductInfoResponse = {
|
||||
sizeCode: string;
|
||||
sizeName: string;
|
||||
categoryName: string;
|
||||
classificationNumber: string;
|
||||
};
|
||||
|
||||
type AdminGiftInboundDeliveryInfoResponse = {
|
||||
courierCompanyName: string | null;
|
||||
trackingNumber: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
상세 표시 요구사항:
|
||||
- 발송인 정보: 닉네임, 이름, 전화번호, 주소
|
||||
- 수취인 정보: 닉네임, 이름, 전화번호, 주소
|
||||
- 상품 정보: 사이즈, 카테고리, 분류번호
|
||||
- 입고 배송 정보: 택배사, 운송장번호
|
||||
- 현재 상태와 가능한 액션
|
||||
- 수취인이 배송지를 아직 입력하지 않은 경우 `recipientInfo.nickname`은 표시하고 `name`, `phoneNumber`, `address`는 빈 문자열로 온다. 화면에서는 `미입력`으로 표시해도 된다.
|
||||
|
||||
운영 액션 enum:
|
||||
```ts
|
||||
type AdminGiftAction =
|
||||
| "ARRIVE_MAILBOX"
|
||||
| "COMPLETE_INSPECTION"
|
||||
| "COMPLETE_DELIVERY"
|
||||
| "MARK_UNDELIVERABLE";
|
||||
```
|
||||
|
||||
상태별 `availableActions`:
|
||||
- `RECEIVED`: 없음
|
||||
- `TRACKING_REGISTERED`: `ARRIVE_MAILBOX`, `MARK_UNDELIVERABLE`
|
||||
- `ARRIVED_AT_MAILBOX`: `COMPLETE_INSPECTION`, `MARK_UNDELIVERABLE`
|
||||
- `INSPECTION_COMPLETED`: `COMPLETE_DELIVERY`, `MARK_UNDELIVERABLE`
|
||||
- `DELIVERED`: 없음
|
||||
- `UNDELIVERABLE`: 없음
|
||||
- `CANCELED`: 없음
|
||||
|
||||
액션 버튼 문구:
|
||||
- `ARRIVE_MAILBOX`: `사서함 도착 처리`
|
||||
- `COMPLETE_INSPECTION`: `검수 완료 처리`
|
||||
- `COMPLETE_DELIVERY`: `전달 완료 처리`
|
||||
- `MARK_UNDELIVERABLE`: `전달 불가 처리`
|
||||
|
||||
액션 API:
|
||||
- 사서함 도착 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/arrive-mailbox`
|
||||
- Body: 없음
|
||||
- 검수 완료 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-inspection`
|
||||
- Body: 없음
|
||||
- 전달 완료 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/complete-delivery`
|
||||
- Body: 없음
|
||||
- 전달 불가 처리
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gifts/{applicationNo}/mark-undeliverable`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftMarkUndeliverableRequest = {
|
||||
reason: string;
|
||||
};
|
||||
```
|
||||
|
||||
액션 response `data`:
|
||||
```ts
|
||||
type AdminGiftOperationStatusResponse = {
|
||||
applicationNo: string;
|
||||
status: GiftStatus;
|
||||
statusName: string;
|
||||
occurredAt: string; // UTC ISO string, e.g. 2026-09-30T12:00:00Z
|
||||
};
|
||||
```
|
||||
|
||||
액션 UX:
|
||||
- 모든 상태 변경 액션은 확인창을 띄운다.
|
||||
- `MARK_UNDELIVERABLE`은 사유 입력 모달을 띄운다.
|
||||
- 전달 불가 사유는 공백만 입력할 수 없고 255자 이내로 제한한다.
|
||||
- 액션 성공 후:
|
||||
- 상세 모달이 열려 있으면 상세를 다시 조회한다.
|
||||
- 목록도 현재 필터/페이지 기준으로 다시 조회한다.
|
||||
- 성공 토스트를 표시한다.
|
||||
- 액션 실패 시 기존 관리자 페이지 에러 토스트/알림 패턴을 따른다.
|
||||
|
||||
빈 값 표시:
|
||||
- `courierCompanyName`, `trackingNumber`가 null이면 `-`로 표시한다.
|
||||
- 수취인 개인정보가 빈 문자열이면 `미입력`으로 표시한다.
|
||||
|
||||
테스트/검증:
|
||||
- API 클라이언트 함수 단위 테스트 또는 페이지 테스트에서 query parameter가 올바르게 전달되는지 확인한다.
|
||||
- `availableActions`에 따라 버튼 노출이 달라지는지 확인한다.
|
||||
- 전달 불가 사유 빈 값 validation을 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 받을 주소 설정 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물 받을 주소 설정 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 받을 주소`에서 팬이 선물을 보낼 전역 단일 주소를 조회하고 저장한다.
|
||||
- 이 주소는 팬의 보낸 선물 상세 API에서 정상 진행 상태 중 전달 완료 전(`RECEIVED`, `TRACKING_REGISTERED`, `ARRIVED_AT_MAILBOX`, `INSPECTION_COMPLETED`)일 때 `mailbox`로 노출된다.
|
||||
- 기존 관리자 페이지의 폼, 저장 버튼, 토스트, 에러 표시 패턴을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/mailbox`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `받을 주소`
|
||||
- 설명 문구: `팬이 선물을 발송할 때 확인하는 받을 주소입니다.`
|
||||
- 입력 폼:
|
||||
- 받을 사람 이름 `name`
|
||||
- 연락처 `phoneNumber`
|
||||
- 우편번호 `zipCode`
|
||||
- 주소 `address`
|
||||
- 상세주소 `addressDetail`
|
||||
- 저장 버튼: `저장`
|
||||
|
||||
초기 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gift-mailbox`
|
||||
- response `data`:
|
||||
```ts
|
||||
type AdminGiftMailboxResponse = {
|
||||
name: string;
|
||||
address: string;
|
||||
phoneNumber: string;
|
||||
} | null;
|
||||
```
|
||||
- `data`가 null이면 빈 폼을 표시한다.
|
||||
|
||||
저장 API:
|
||||
- Method: `PUT`
|
||||
- URL: `/api/v2/admin/gift-mailbox`
|
||||
- Request:
|
||||
```ts
|
||||
type AdminGiftMailboxRequest = {
|
||||
name: string;
|
||||
phoneNumber: string;
|
||||
zipCode: string;
|
||||
address: string;
|
||||
addressDetail: string | null;
|
||||
};
|
||||
```
|
||||
- Response `data`:
|
||||
```ts
|
||||
type AdminGiftMailboxResponse = {
|
||||
name: string;
|
||||
address: string;
|
||||
phoneNumber: string;
|
||||
};
|
||||
```
|
||||
|
||||
Validation:
|
||||
- `name`, `phoneNumber`, `zipCode`, `address`는 필수다.
|
||||
- 공백만 입력할 수 없다.
|
||||
- 저장 실패 시 기존 관리자 페이지의 공통 에러 표시 방식을 따른다.
|
||||
|
||||
저장 성공 UX:
|
||||
- 성공 토스트를 표시한다.
|
||||
- 저장 API response 기준으로 화면 값을 갱신한다.
|
||||
- `address`는 서버가 조립한 `(우편번호) 주소, 상세주소` 형식으로 표시해도 되고, 입력 필드는 사용자가 입력한 값을 유지해도 된다.
|
||||
|
||||
테스트/검증:
|
||||
- 초기 조회 시 null이면 빈 폼을 표시하는지 확인한다.
|
||||
- 저장 시 PUT body가 정확히 전달되는지 확인한다.
|
||||
- 필수값 공백 validation을 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 선물 카테고리 CRUD 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물 카테고리 CRUD 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물 카테고리`에서 선물 카테고리를 조회, 등록, 수정, 비활성화한다.
|
||||
- 기존 관리자 페이지의 CRUD 테이블, 등록/수정 모달, 삭제 확인창, 토스트 패턴을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/category`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물 카테고리`
|
||||
- 상단 우측 `카테고리 등록` 버튼
|
||||
- 목록 테이블 컬럼:
|
||||
- 카테고리 ID `categoryId`
|
||||
- 분류번호 `classificationNumber`
|
||||
- 카테고리 코드 `categoryCode`
|
||||
- 카테고리명 `name`
|
||||
- 접수 코드 `receiptCode`
|
||||
- 대표 품목 `representativeItem`
|
||||
- 파손면책 동의 필요 여부 `requiresDamageWaiver`
|
||||
- 활성 여부 `isActive`
|
||||
- 관리 버튼: 수정, 비활성화
|
||||
- 등록/수정 폼 필드:
|
||||
- `classificationNumber`
|
||||
- `categoryCode`
|
||||
- `name`
|
||||
- `receiptCode`
|
||||
- `representativeItem`
|
||||
- `requiresDamageWaiver`
|
||||
- `isActive`
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gift-categories`
|
||||
- Request: 없음
|
||||
- Response `data`:
|
||||
```ts
|
||||
type AdminGiftCategoryResponse = {
|
||||
categoryId: number;
|
||||
classificationNumber: string;
|
||||
categoryCode: string;
|
||||
name: string;
|
||||
receiptCode: string;
|
||||
representativeItem: string;
|
||||
requiresDamageWaiver: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
등록 API:
|
||||
- Method: `POST`
|
||||
- URL: `/api/v2/admin/gift-categories`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftCategoryRequest = {
|
||||
classificationNumber: string;
|
||||
categoryCode: string;
|
||||
name: string;
|
||||
receiptCode: string;
|
||||
representativeItem: string;
|
||||
requiresDamageWaiver: boolean;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
|
||||
수정 API:
|
||||
- Method: `PUT`
|
||||
- URL: `/api/v2/admin/gift-categories/{categoryId}`
|
||||
- Body: `AdminGiftCategoryRequest`
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
|
||||
비활성화 API:
|
||||
- Method: `DELETE`
|
||||
- URL: `/api/v2/admin/gift-categories/{categoryId}`
|
||||
- Body: 없음
|
||||
- Response `data`: `AdminGiftCategoryResponse`
|
||||
- 서버는 실제 삭제가 아니라 `isActive=false` 논리 삭제로 처리한다.
|
||||
|
||||
폼 validation:
|
||||
- `classificationNumber`: 필수, 숫자 3자리. 예: `100`
|
||||
- `categoryCode`: 필수, 50자 이하. 예: `DOLL`
|
||||
- `name`: 필수, 50자 이하. 예: `인형`
|
||||
- `receiptCode`: 필수, 영문 대문자 1~9자. 예: `A`
|
||||
- `representativeItem`: 필수, 100자 이하. 예: `피규어`
|
||||
- `requiresDamageWaiver`: boolean
|
||||
- `isActive`: boolean
|
||||
|
||||
UX 요구사항:
|
||||
- 등록 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 수정 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 비활성화 버튼은 확인창을 띄운 뒤 호출한다.
|
||||
- 이미 비활성화된 row는 비활성화 버튼을 disabled 처리한다.
|
||||
- boolean 값은 기존 관리자 페이지 표현에 맞춰 `예/아니오`, `활성/비활성` 또는 badge로 표시한다.
|
||||
- `categoryCode`는 수정 폼에 표시하되 서버 쪽 엔티티는 코드 변경을 실제로 반영하지 않을 수 있으므로, 가능하면 수정 시 읽기 전용으로 두고 나머지 운영 필드만 수정하도록 UX를 구성한다.
|
||||
|
||||
테스트/검증:
|
||||
- 목록 조회가 `GET /api/v2/admin/gift-categories`를 호출하는지 확인한다.
|
||||
- 등록/수정 body가 `AdminGiftCategoryRequest` 형태와 일치하는지 확인한다.
|
||||
- validation 실패 시 API를 호출하지 않고 폼 에러를 표시한다.
|
||||
- 비활성화 성공 후 해당 row의 `isActive=false`가 반영되는지 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 선물 사이즈 가격 CRUD 페이지 프롬프트
|
||||
|
||||
```text
|
||||
관리자 선물 사이즈 가격 CRUD 페이지를 구현해줘.
|
||||
|
||||
목표:
|
||||
- 관리자 메뉴 `선물함 관리 > 선물 사이즈`에서 선물 사이즈별 기본가/판매가/활성 여부를 조회하고 수정한다.
|
||||
- 현재 API는 사이즈 가격의 목록 조회와 수정만 제공한다. 신규 생성과 물리 삭제 기능은 만들지 않는다.
|
||||
- 기존 관리자 페이지의 테이블, 수정 모달, 저장 확인/토스트 패턴을 그대로 따른다.
|
||||
|
||||
라우트:
|
||||
- `/gift/size`
|
||||
|
||||
필수 화면 구성:
|
||||
- 상단 제목: `선물 사이즈`
|
||||
- 목록 테이블 컬럼:
|
||||
- 사이즈 코드 `sizeCode`
|
||||
- 사이즈명 `name`
|
||||
- 기본가 `basePriceCan`
|
||||
- 판매가 `salePriceCan`
|
||||
- 활성 여부 `isActive`
|
||||
- 관리 버튼: 수정
|
||||
- 수정 폼 필드:
|
||||
- `sizeCode`: 읽기 전용
|
||||
- `name`: 읽기 전용
|
||||
- `basePriceCan`: number input
|
||||
- `salePriceCan`: number input
|
||||
- `isActive`: checkbox/switch
|
||||
|
||||
목록 조회 API:
|
||||
- Method: `GET`
|
||||
- URL: `/api/v2/admin/gift-size-prices`
|
||||
- Request: 없음
|
||||
- Response `data`:
|
||||
```ts
|
||||
type AdminGiftSizePriceResponse = {
|
||||
sizeCode: GiftSize;
|
||||
name: string;
|
||||
basePriceCan: number;
|
||||
salePriceCan: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
수정 API:
|
||||
- Method: `PUT`
|
||||
- URL: `/api/v2/admin/gift-size-prices/{sizeCode}`
|
||||
- Body:
|
||||
```ts
|
||||
type AdminGiftSizePriceRequest = {
|
||||
basePriceCan: number;
|
||||
salePriceCan: number;
|
||||
isActive: boolean;
|
||||
};
|
||||
```
|
||||
- Response `data`: `AdminGiftSizePriceResponse`
|
||||
|
||||
사이즈 enum:
|
||||
```ts
|
||||
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
|
||||
```
|
||||
|
||||
서버 validation:
|
||||
- `salePriceCan`은 0보다 커야 한다.
|
||||
- `salePriceCan`은 `basePriceCan`보다 클 수 없다.
|
||||
|
||||
프론트 validation:
|
||||
- `basePriceCan`: 필수, 1 이상 정수
|
||||
- `salePriceCan`: 필수, 1 이상 정수
|
||||
- `salePriceCan <= basePriceCan`
|
||||
- validation 실패 시 API 호출을 막고 필드 에러를 표시한다.
|
||||
|
||||
UX 요구사항:
|
||||
- 가격은 숫자 입력이지만 테이블 표시에서는 기존 관리자 페이지의 숫자 포맷을 따른다.
|
||||
- 수정 버튼 클릭 시 현재 row 값을 폼 초기값으로 넣는다.
|
||||
- 저장 전 확인창은 기존 관리자 페이지 패턴이 있으면 따른다.
|
||||
- 저장 성공 후 목록을 다시 조회하고 모달을 닫는다.
|
||||
- 신규 생성/삭제 버튼은 만들지 않는다.
|
||||
|
||||
테스트/검증:
|
||||
- 목록 조회가 `GET /api/v2/admin/gift-size-prices`를 호출하는지 확인한다.
|
||||
- 수정 저장 시 `PUT /api/v2/admin/gift-size-prices/{sizeCode}`와 request body가 정확한지 확인한다.
|
||||
- `salePriceCan > basePriceCan`일 때 API를 호출하지 않고 validation 메시지를 표시하는지 확인한다.
|
||||
- 저장 성공 후 목록 refresh가 일어나는지 확인한다.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 시 참고할 공통 타입
|
||||
|
||||
```ts
|
||||
type ApiResponse<T> = {
|
||||
success: boolean;
|
||||
data: T;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type GiftStatus =
|
||||
| "RECEIVED"
|
||||
| "TRACKING_REGISTERED"
|
||||
| "ARRIVED_AT_MAILBOX"
|
||||
| "INSPECTION_COMPLETED"
|
||||
| "DELIVERED"
|
||||
| "UNDELIVERABLE"
|
||||
| "CANCELED";
|
||||
|
||||
type AdminGiftAction =
|
||||
| "ARRIVE_MAILBOX"
|
||||
| "COMPLETE_INSPECTION"
|
||||
| "COMPLETE_DELIVERY"
|
||||
| "MARK_UNDELIVERABLE";
|
||||
|
||||
type GiftSize = "SMALL" | "MEDIUM" | "LARGE";
|
||||
```
|
||||
|
||||
## 구현 순서 추천
|
||||
|
||||
1. API client 함수와 TypeScript 타입을 먼저 추가한다.
|
||||
2. `/gift/list` 선물함 리스트와 상세/상태변경 모달을 구현한다.
|
||||
3. `/gift/mailbox` 받을 주소 설정 화면을 구현한다.
|
||||
4. `/gift/category` 카테고리 CRUD를 구현한다.
|
||||
5. `/gift/size` 사이즈 가격 수정 화면을 구현한다.
|
||||
6. 기존 관리자 메뉴의 `선물함 관리` 하위 route와 연결되는지 확인한다.
|
||||
7. 각 페이지별 loading/empty/error/success 상태를 확인한다.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user