19 KiB
크리에이터 시작 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/createrequest DTO 확장recipientId표준 필드 추가- 기존
creatorIdalias 유지 recipientId/creatorId누락·불일치 validation- 기존 방 재사용 및 새 방 생성 회귀 검증
/create가 메시지를 만들지 않는다는 회귀 검증
제외
- 첫 메시지 발송 request 추가
- WebSocket 프로토콜 변경
- FCM 푸시 정책 변경
- DB 스키마 변경
- 새 endpoint 추가
- 기존
creatorIdalias 제거
기술적 제약
- 공개 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.ktCreateUserCreatorChatRoomRequest에recipientIdnullable 필드와 기존creatorIdnullable alias를 둔다.- 단일 상대 ID 산출 함수를 DTO 내부에 둔다.
- Modify:
src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/controller/UserCreatorChatController.ktrequest.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 -
RED:
UserCreatorChatControllerMappingTest에recipientId만 보낸 요청이 service에 해당 ID를 전달하는 실패 테스트를 작성한다.
@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)
}
- RED: 기존
creatorId만 보낸 요청도 service에 해당 ID를 전달하는 실패 테스트를 작성한다.
@Test
fun shouldCreateRoomWithLegacyCreatorId() {
val request = CreateUserCreatorChatRoomRequest(recipientId = null, creatorId = 20L)
assertEquals(20L, request.recipientMemberId())
}
- RED: 두 필드가 모두 없거나 서로 다르면
common.error.invalid_request가 발생하는 실패 테스트를 작성한다.
@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)
}
- RED 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatControllerMappingTest를 실행해recipientId생성자 파라미터 또는recipientMemberId()부재로 실패하는 것을 확인한다. - GREEN: DTO를 최소 확장한다.
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")
}
}
- GREEN: Controller에서
request.recipientMemberId()를 service에 전달한다.
ApiResponse.ok(service.createOrGetRoom(member, request.recipientMemberId()))
- GREEN 확인: 같은 focused test를 다시 실행해 성공을 확인한다.
- REFACTOR: 테스트 helper 중 이번 Task가 만든 중복만 정리하고
./gradlew ktlintCheck결과를 Progress에 기록한다.
Task 1.2 service 방 생성 정책 회귀 보강
Goal 실행 P1-T2: 발신자 역할과 무관하게 기존 수신자 검증, 기존 방 재사용, 메시지 미생성 정책을 유지한다.
- 시작 조건:
P1-T1완료. - 완료 증거:
UserCreatorChatServiceTestfocused 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) -
RED: 크리에이터 역할 회원이 일반 회원에게 방을 생성할 수 있는 실패 테스트를 작성한다.
@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)
}
- RED: 기존 활성 방이 있으면 새 방과 메시지를 만들지 않는 회귀 테스트를 작성한다.
@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)
}
- RED 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest를 실행해 누락된 동작 또는 helper compile 실패를 확인한다. - GREEN: 필요하면
createOrGetRoom(member, creatorId)파라미터명을recipientId로 변경한다. 함수 내부 로직은 기존memberRepository.findById,validateRecipient,findActiveRoomByParticipantMemberIds, participant 저장 흐름을 유지한다.
@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!!)
}
- GREEN 확인: 같은 focused test를 다시 실행해 성공을 확인한다.
- 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 -
RED: 리스너가
creatorIdalias로 기존처럼 방을 만들 수 있는 통합 회귀 테스트를 작성한다. -
RED: 크리에이터가 일반 회원 ID로 방을 만들 수 있는 통합 테스트를 작성한다.
-
RED 확인:
./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceIntegrationTest를 실행해 현재 계약 미지원 실패를 확인한다. -
GREEN:
P1-T1,P1-T2구현만으로 통합 테스트를 통과시킨다. 추가 production 코드를 만들지 않는다. -
GREEN 확인: 같은 통합 테스트를 다시 실행해 성공을 확인한다.
-
REFACTOR: 통합 테스트 fixture 중 이번 Task가 만든 중복만 정리한다.
완료 조건
P1-T1,P1-T2,P1-T3의 체크박스와 완료 증거가 모두 충족됐다.- PRD
DMROOM-001~007이 구현 또는 명시적 제외로 추적된다. - 기존
creatorId요청과 신규recipientId요청의 차이가 문서와 테스트에 남아 있다.
검증 방법
Phase 1 Gate
Goal 실행 P1-GATE: /create request 호환성, 방 생성 정책, 메시지 비생성 정책을 최종 판정한다.
- 시작 조건: Phase 1의 모든 Task goal 완료.
- 완료 증거: 아래 명령 통과와 Progress 기록.
- 범위 밖: 실패와 무관한 채팅방 목록, openRoom 응답, WebSocket 기능 수정.
./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로 회귀 수정 |
P1-T1 → P1-T2 → P1-T3 → P1-GATE
변경 금지 항목
/create에서 메시지를 저장하거나 발송하지 않는다.- 기존
creatorIdrequest를 제거하지 않는다. CreateUserCreatorChatRoomResponse필드를 바꾸지 않는다.- DB schema, WebSocket message type, FCM event 계약을 변경하지 않는다.
- test를 삭제·skip·완화하지 않는다.
- 요청 범위 밖 리팩터링과 공용 abstraction을 추가하지 않는다.
Progress
기존 기록을 삭제하거나 덮어쓰지 않고 실제 실행 결과를 차수별로 누적한다.
P1-T1 1차 실행 — 2026-09-14
- 상태: 완료
- 무엇을:
CreateUserCreatorChatRoomRequest에recipientId표준 필드와creatorIdalias를 추가하고, 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를 시도했다.
- 왜:
/createrequest 호환성, 방 생성 정책, 메시지 비생성 정책과 공통 품질 기준을 판정하기 위해서다. - 어떻게:
./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 | 처리 계획 |
|---|---|---|---|---|---|
| 없음 | - | - | - | - | - |