Files

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/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

  • 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 완료.
  • 완료 증거: 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)

  • 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: 리스너가 creatorId alias로 기존처럼 방을 만들 수 있는 통합 회귀 테스트를 작성한다.

  • 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에서 메시지를 저장하거나 발송하지 않는다.
  • 기존 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 처리 계획
없음 - - - - -