# 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인 `UserCreatorChatRoomOpenResponse`에 `opponentNickname`, `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 - [x] **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 테스트 코드 예시: ```kotlin @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(), 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 구현 방향: ```kotlin data class UserCreatorChatRoomOpenResponse( val roomId: Long, val opponentNickname: String, val opponentProfileImageUrl: String, val messages: List, val hasMore: Boolean, val nextCursor: Long? ) ``` - GREEN: `UserCreatorChatService.openRoom`에서 기존 참여자 검증 후 상대방을 조회해 응답에 채운다. - GREEN 구현 방향: ```kotlin @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 후보: ```kotlin 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`.