Files
sodalive-backend-spring-boot/docs/20260610_openRoom_상대방_프로필_닉네임/plan-task.md

9.3 KiB

openRoom 응답 상대방 프로필/닉네임 추가 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development 또는 superpowers:executing-plans로 task 단위 구현을 진행한다. 각 단계는 체크박스(- [ ])로 진행 상태를 갱신한다.

Goal: GET /api/v2/user-creator-chat/rooms/{roomId}/open 응답에 현재 로그인 회원 기준 상대방 닉네임과 프로필 이미지 URL을 추가한다.

Architecture: 기존 UserCreatorChatService.openRoom 흐름을 유지하고, 이미 존재하는 UserCreatorChatParticipantRepository.findActiveOpponent(roomId, memberId)로 상대방 참여자를 조회한다. 응답 DTO인 UserCreatorChatRoomOpenResponseopponentNickname, opponentProfileImageUrl만 추가하며, 메시지 조회/페이징/참여자 검증 정책은 변경하지 않는다.

Tech Stack: Kotlin, Spring Boot 2.7.14, Java 17, JUnit 5, Mockito, Gradle Wrapper


0. 구현 전 확정 사항

  • 대상 PRD: docs/20260610_openRoom_상대방_프로필_닉네임/prd.md
  • 대상 API: GET /api/v2/user-creator-chat/rooms/{roomId}/open
  • 응답 DTO: UserCreatorChatRoomOpenResponse
  • 신규 응답 필드:
    • opponentNickname: String
    • opponentProfileImageUrl: String
  • 상대방 기준: 현재 로그인 회원을 제외한 활성 참여자
  • 프로필 이미지 URL 정책: "$cloudFrontHost/$profilePath"
  • 기본 프로필 이미지 경로: profile/default-profile.png
  • 기존 응답 필드(roomId, messages, hasMore, nextCursor)는 제거/이름 변경하지 않는다.
  • createOrGetRoom, getMessages, 메시지 발송 API 응답은 변경하지 않는다.
  • DB 스키마 변경은 하지 않는다.

1. 파일 구조 계획

수정 대상

  • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt
    • UserCreatorChatRoomOpenResponse에 상대방 표시 필드 2개를 추가한다.
  • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt
    • openRoom에서 상대방 참여자를 조회하고 응답 필드를 채운다.
    • 기존 메시지 DTO와 같은 기본 프로필 이미지 URL 조합 정책을 유지한다.
  • Modify: src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt
    • openRoom 응답에 상대방 닉네임/프로필 이미지 URL이 포함되는지 검증한다.
    • 상대방 프로필 이미지가 null일 때 기본 이미지 URL을 반환하는지 검증한다.

신규 파일

  • 없음.

Phase 1: openRoom 응답 확장 TDD

  • Task 1.1: openRoom 응답에 상대방 닉네임과 프로필 이미지 URL 추가
    • Files:
      • Modify: src/test/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/UserCreatorChatServiceTest.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/dto/UserCreatorChatDtos.kt
      • Modify: src/main/kotlin/kr/co/vividnext/sodalive/v2/usercreatorchat/service/UserCreatorChatService.kt
    • RED: 기존 UserCreatorChatServiceTest에 아래 테스트 2개를 추가한다.
      • shouldOpenRoomWithOpponentProfileWhenOpponentHasProfileImage
      • shouldOpenRoomWithDefaultOpponentProfileWhenOpponentProfileImageIsNull
    • RED 테스트 코드 예시:
@Test
@DisplayName("방 입장 응답은 상대방 닉네임과 프로필 이미지 URL을 포함한다")
fun shouldOpenRoomWithOpponentProfileWhenOpponentHasProfileImage() {
    val user = member(1L, "user")
    val creator = member(2L, "creator").apply {
        profileImage = "profile/creator.png"
    }
    val room = room(10L)
    val userParticipant = participant(100L, room, user)
    val creatorParticipant = participant(101L, room, creator)
    Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
    Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant)
    Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(creatorParticipant)
    Mockito.`when`(
        messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(
            room,
            PageRequest.of(0, 20)
        )
    ).thenReturn(emptyList())

    val response = service.openRoom(user, roomId = 10L)

    assertEquals(10L, response.roomId)
    assertEquals("creator", response.opponentNickname)
    assertEquals("https://cdn.test/profile/creator.png", response.opponentProfileImageUrl)
    assertEquals(emptyList<UserCreatorChatMessageItemDto>(), response.messages)
    assertFalse(response.hasMore)
    assertEquals(null, response.nextCursor)
    Mockito.verify(participantRepository).findActiveOpponent(10L, 1L)
}

@Test
@DisplayName("상대방 프로필 이미지가 없으면 방 입장 응답은 기본 프로필 이미지 URL을 반환한다")
fun shouldOpenRoomWithDefaultOpponentProfileWhenOpponentProfileImageIsNull() {
    val user = member(1L, "user")
    val creator = member(2L, "creator")
    val room = room(10L)
    val userParticipant = participant(100L, room, user)
    val creatorParticipant = participant(101L, room, creator)
    Mockito.`when`(roomRepository.findByIdAndIsActiveTrue(10L)).thenReturn(room)
    Mockito.`when`(participantRepository.findActiveByRoomIdAndMemberId(10L, 1L)).thenReturn(userParticipant)
    Mockito.`when`(participantRepository.findActiveOpponent(10L, 1L)).thenReturn(creatorParticipant)
    Mockito.`when`(
        messageRepository.findByChatRoomAndIsActiveTrueOrderByIdDesc(
            room,
            PageRequest.of(0, 20)
        )
    ).thenReturn(emptyList())

    val response = service.openRoom(user, roomId = 10L)

    assertEquals("creator", response.opponentNickname)
    assertEquals("https://cdn.test/profile/default-profile.png", response.opponentProfileImageUrl)
}
  • 실패 확인:
    • Run: ./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest
    • Expected: opponentNickname 또는 opponentProfileImageUrl 프로퍼티가 없어 컴파일 실패한다.
  • GREEN: UserCreatorChatRoomOpenResponse 생성자에 opponentNickname, opponentProfileImageUrl을 추가한다.
  • GREEN 구현 방향:
data class UserCreatorChatRoomOpenResponse(
    val roomId: Long,
    val opponentNickname: String,
    val opponentProfileImageUrl: String,
    val messages: List<UserCreatorChatMessageItemDto>,
    val hasMore: Boolean,
    val nextCursor: Long?
)
  • GREEN: UserCreatorChatService.openRoom에서 기존 참여자 검증 후 상대방을 조회해 응답에 채운다.
  • GREEN 구현 방향:
@Transactional
fun openRoom(member: Member, roomId: Long, limit: Int = 20): UserCreatorChatRoomOpenResponse {
    val room = findRoom(roomId)
    requireParticipant(roomId, member.id!!)
    val opponentParticipant = participantRepository.findActiveOpponent(roomId, member.id!!)
        ?: throw SodaException(messageKey = "chat.room.invalid_access")
    val opponent = opponentParticipant.member
    val opponentProfilePath = opponent.profileImage ?: "profile/default-profile.png"
    val page = getMessages(member, roomId, cursor = null, limit = limit)
    return UserCreatorChatRoomOpenResponse(
        roomId = room.id!!,
        opponentNickname = opponent.nickname,
        opponentProfileImageUrl = "$cloudFrontHost/$opponentProfilePath",
        messages = page.messages,
        hasMore = page.hasMore,
        nextCursor = page.nextCursor
    )
}
  • 통과 확인:
    • Run: ./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest
    • Expected: BUILD SUCCESSFUL
  • REFACTOR: 기본 프로필 이미지 문자열 중복이 과하다고 판단되면 UserCreatorChatService 안에 private 함수로만 정리한다. 새 공용 유틸이나 별도 추상화는 만들지 않는다.
  • REFACTOR 후보:
private fun profileImageUrl(profileImage: String?): String {
    val profilePath = profileImage ?: "profile/default-profile.png"
    return "$cloudFrontHost/$profilePath"
}
  • 회귀 확인:
    • Run: ./gradlew test
    • Expected: BUILD SUCCESSFUL
    • Run: ./gradlew ktlintCheck
    • Expected: BUILD SUCCESSFUL
  • 기대 결과:
    • 메시지가 없는 방에서도 opponentNickname, opponentProfileImageUrl이 반환된다.
    • 상대방 프로필 이미지가 있으면 CloudFront URL로 반환된다.
    • 상대방 프로필 이미지가 없으면 https://cdn.test/profile/default-profile.png 형식으로 반환된다.
    • 기존 메시지 조회, 페이징, 참여자 검증 동작은 유지된다.

2. 구현 후 검증 기록

  • ./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest
    • 목적: RED 확인. openRoom 상대방 닉네임/프로필 이미지 URL 테스트를 먼저 추가한 뒤 실행.
    • 결과: opponentNickname, opponentProfileImageUrl 미정의 컴파일 오류로 실패.
  • ./gradlew test --tests kr.co.vividnext.sodalive.v2.usercreatorchat.UserCreatorChatServiceTest
    • 목적: GREEN 확인. DTO/서비스 최소 구현 후 UserCreatorChatServiceTest 회귀 검증.
    • 결과: BUILD SUCCESSFUL.
  • ./gradlew ktlintCheck
    • 목적: Kotlin lint 회귀 검증.
    • 결과: BUILD SUCCESSFUL.
  • ./gradlew test
    • 목적: 전체 테스트 회귀 검증.
    • 결과: BUILD SUCCESSFUL.