refactor: [ALT-279] 채팅방 목록·정보 응답 개선 및 그룹 채팅방 노출 결함 수정 - #100
Conversation
GROUP 방(participant 컬럼 null)이 목록·상세 조회에서 누락되던 문제를 해결하기 위해 조회 수단을 추가한다. 응답 반영은 후속 작업에서 진행한다. - ChatRoomType.describe() 추가 - ChatRoomListWithOpponentResponse에 type/workspaceName/opponentProfileImageUrl/memberCount 필드 추가 - buildParticipantCondition에 chat_room_members 기반 조건을 OR로 추가해 GROUP 방과 상세 조회(404) 함께 해결 - getChatRoomListWithOpponent 프로젝션에 type, workspaceName(Workspace leftJoin) 추가 - ChatRoomQueryRepository.countChatRoomsByParticipant 카운트 쿼리 추가 - ChatRoomMemberQueryRepository에 countActiveByRoom/countActiveByRoomIds(배치 집계) 추가
- ChatRoomListResponseDto: type, roomName, memberCount, opponentProfileImageUrl 필드 추가
- AbstractGetMyChatRoomListUseCase: totalCount를 count 쿼리 결과로 채우고, 활성 멤버 수와 상대방 프로필 이미지를 일괄 조회하도록 변경
- GROUP 방은 opponentName 기본값("알 수 없음") 처리를 적용하지 않음
- GetMyChatRoomList/ManagerGetMyChatRoomList 생성자에 신규 의존성 반영
- ChatRoomResponseDto에 type/roomName/memberCount/opponentProfileImageUrl 추가, from() → of()로 교체하고 상대방 결정 로직을 UseCase로 이동 - GetChatRoomInfo, ManagerGetChatRoom에서 GROUP 방일 때 participant 컬럼(null) 접근 없이 업장명으로 roomName 조회 - DIRECT 방은 기존대로 상대방 프로필 이미지 URL까지 포함해 응답 - Swagger 설명에 그룹 채팅방 지원 문구 반영
GetMyChatRoomList: DIRECT/GROUP 방 필드 매핑, totalCount가 count 쿼리 결과로 채워지는지, count 0일 때 목록 조회를 생략하는지 검증 GetChatRoomInfo: GROUP/DIRECT 방 조회 성공, 비참여자 NOT_FOUND 검증
- 목록 API의 상대방 이름 조회 leftJoin에 status=ACTIVE 조건 누락으로 비활성 사용자 이름이 그대로 노출되던 문제 수정 (정보 API는 이미 ACTIVE로 필터링) - 정보 API(GetChatRoomInfo, ManagerGetChatRoom)의 DIRECT 방에서 상대방을 찾지 못하면 opponentName이 null로 응답되던 것을 목록 API와 동일하게 "알 수 없음"으로 대체 (GROUP 방은 null 유지) - buildParticipantCondition()의 participant 컬럼/멤버 테이블 OR 조건이 Mock 테스트에서는 검증되지 않아 ChatRoomQueryRepositoryImplTests(DataJpaTest) 신규 추가
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
buildParticipantCondition()이 participant 컬럼 조건과 상관 EXISTS를 OR로 묶고 있어 Postgres가 인덱스를 타지 못하고 chat_rooms를 seq scan하며 행마다 서브쿼리를 돌았다. V11이 기존 방을 멤버 2행으로 백필했고 신규 DIRECT 방도 멤버 행을 생성하므로 participant 컬럼을 함께 볼 필요가 없어 OR를 제거하고 활성 멤버 EXISTS 단독 조건으로 정리했다. 목록/count/상세 조회가 모두 이 조건을 공유한다. 테스트도 실제로 발생하지 않는 상황(멤버 행 없는 DIRECT 방이 participant 컬럼만으로 목록에 포함)을 고정하던 케이스를 걷어내고, 멤버 행 기준으로 포함되는 경우와 멤버 행이 없으면 제외되는 경우로 교체했다.
- 목록/상세 조회 모두 상대방 이름이 가려지는 경우(비활성 상태) 프로필 이미지도 함께 숨기도록 수정 - GetChatRoomInfo, ManagerGetChatRoom의 동일 로직을 AbstractGetChatRoomUseCase로 공통화
…입 제거 - getChatRoomListWithOpponent에 participant1/2File leftJoin 추가로 상대방 프로필 URL을 쿼리 단계에서 채움 - memberCount를 스칼라 서브쿼리로 projection에 편입 (usecase 배치 조회 제거) - AbstractGetMyChatRoomListUseCase에서 파일/멤버수 배치 조회 및 관련 의존성(FileQueryRepository, FileUrlService, ChatRoomMemberQueryRepository) 제거 - 마스킹/폴백 판정을 ChatRoomListResponseDto.from()으로 일원화 (DIRECT 이름 비면 마스킹+URL null, GROUP workspaceName 비면 마스킹) - ChatRoomListWithOpponentResponse의 클래스 레벨 @Setter 제거, latestMessageContent만 필드 레벨 @Setter 유지 - 관련 테스트 보강 (활성/비활성 상대 프로필 URL, memberCount 정합성, DTO 폴백 케이스)
- fileUrlService.resolveUrlByTarget(fetchOne) 대신 findAllByTargetTypeAndTargetIdIn으로 상대방 프로필 조회, ATTACHED 중복 시 500 방지 - GROUP 채팅방 workspaceId null 시 업장 조회 생략, 업장 미조회 시 roomName '알 수 없음' 폴백 - GetChatRoomInfoTests에 GROUP 업장 없음/workspaceId null/프로필 파일 중복 케이스 테스트 추가
DIRECT 방에서 메시지 조회·전송이 chatRoom.isParticipant를 사용해 나간 멤버도 계속 읽고 보낼 수 있던 문제를 findByIdAndParticipant(활성 멤버 EXISTS 기준)로 교체해 목록/상세 조회와 판정 경로를 일원화. 불용 의존성(chatRoomMemberQueryRepository, ChatRoomType) 제거.
ChatRoom 상세 조회(GetChatRoomInfo/ManagerGetChatRoom), 채팅방 생성(CreateOrGetChatRoom/ ManagerCreateOrGetChatRoom) UseCase가 adapter DTO(ChatRoomResponseDto, CreateChatRoomResponseDto) 대신 domain Result(ChatRoomResult, CreateChatRoomResult)를 반환하도록 변경. 응답 DTO 매핑은 컨트롤러에서 XxxResponseDto.from(result)로 수행. ChatRoomResult는 nullable String 필드가 여럿(roomName, opponentName, opponentProfileImageUrl)이라 위치 인자 스왑 위험을 없애기 위해 @builder로 생성.
…lt>로 전환 - ChatRoomListResult, ChatMessageResult, ChatAttachmentResult 신규 추가 - GetMyChatRoomListUseCase, ManagerGetMyChatRoomListUseCase, GetChatMessagesUseCase, ManagerGetChatMessagesUseCase 요소 타입을 Result로 변경 - Task1에서 DTO에 있던 상대방 이름/이미지 마스킹·폴백 로직을 ChatRoomListResult.from()으로 이동 - 컨트롤러는 UseCase 결과의 data를 DTO로 매핑해 CursorPaginatedApiResponse 재조립 - FileResponseDto에 (fileId, url) 팩토리 추가 (ChatAttachmentResult -> DTO 변환용)
- ChatAttachmentResult를 순수 record(fileId, url)로 변경, FileResponseDto import 제거 - FileResponseDto -> ChatAttachmentResult 매핑을 AbstractGetChatMessagesUseCase(application 계층)로 이동 - ChatMessageResult.from()은 이미 매핑된 List<ChatAttachmentResult>를 파라미터로 받도록 시그니처 조정
- ManagerGetChatRoom/ManagerGetMyChatRoomList: ManagerActor.getUserId()가 participantId로 전달되는지 검증 (ManagerUser.id 오용 회귀 방지) - ChatRoomQueryRepositoryImplTests: MANAGER scope를 조회 주체로 한 findByIdAndParticipant/getChatRoomListWithOpponent/countChatRoomsByParticipant H2 케이스 추가
leftJoin 2개 + CaseBuilder로 상대방 프로필 이미지를 붙이면 동일 대상에 ATTACHED 파일이 2건 이상 있을 때 방 행이 복제되어 목록 중복, limit 소모, totalCount 불일치가 발생했다. memberCount와 동일한 스칼라 서브쿼리 방식으로 교체하고, 가장 오래된 파일 1건을 선택하도록 했다(상세 조회 경로와 동일 semantics). JPQL 서브쿼리는 LIMIT을 지원하지 않아 '더 오래된 행이 없다'는 NOT EXISTS로 1건만 선택했다.
Abstract UseCase로 공통화한 뒤 App/Manager 양쪽에 같은 테스트가 남아 있었고, UseCase 레벨에서는 구분 불가능한 DIRECT/GROUP NOT_FOUND 케이스가 mock만 다른 채 반복됐다. - Send/GetChatMessages: findByIdAndParticipant → empty 로 귀결되는 NOT_FOUND 중복 제거 - ManagerSendChatMessage: SendChatMessageTests와 동일한 공통 로직 테스트 3건 제거 - GetChatRoomInfo: findFirst()만 타서 실패할 수 없는 프로필 2건 테스트 제거 - ChatRoomQueryRepositoryImpl: 포함 관계인 테스트 3건 제거, 2건 테스트에 opponentName assert 추가 - ManagerGetChatRoom/ManagerGetMyChatRoomList 테스트 파일을 AbstractChatUseCaseTests 1건으로 대체
|
|
||
| User opponentUser = userQueryRepository.findById(opponentId) | ||
| .orElse(null); | ||
| if (opponentUser != null) { |
There was a problem hiding this comment.
빈 문자열 이름에서 목록과 상세가 갈립니다.
지난 리뷰의 마스킹 조건 불일치를 목록 쪽은 ObjectUtils.isEmpty 기준으로 고쳤는데(ChatRoomListResult.from:35), 상세는 아직 opponentUser != null만 봅니다.
users.name이 ""인 ACTIVE 상대면:
| opponentName | roomName | opponentProfileImageUrl | |
|---|---|---|---|
GET /chat-rooms |
"알 수 없음" |
"알 수 없음" |
null |
GET /chat-rooms/{id} |
"" |
"" |
실제 URL |
| if (opponentUser != null) { | |
| if (opponentUser != null && ObjectUtils.isNotEmpty(opponentUser.getName())) { |
(org.apache.commons.lang3.ObjectUtils import이 필요합니다.)
넓게 보면 상대방 이름 마스킹 규칙이 목록(ChatRoomListResult.from)과 상세 두 곳에 따로 구현돼 있는 게 원인입니다. 지금은 한 줄로 맞춰도 되지만, 규칙이 더 늘어나면 한 곳으로 모으는 편이 낫습니다.
| // 1. 채팅방 존재 확인 | ||
| ChatRoom chatRoom = chatRoomQueryRepository.findById(chatRoomId) | ||
| // 1. 채팅방 존재 확인 및 참여자 검증 (활성 멤버 EXISTS 기준) | ||
| ChatRoom chatRoom = chatRoomQueryRepository.findByIdAndParticipant(chatRoomId, senderId, senderScope) |
There was a problem hiding this comment.
참여 판정을 여기서 findByIdAndParticipant로 옮기면서 ChatRoom.isParticipant가 아무 데서도 안 쓰이게 됐습니다.
src/main, src/test 전체를 훑어도 ChatRoom.java:91의 선언 말고는 호출부가 없습니다. 이 PR이 마지막 두 호출부(여기와 AbstractGetChatMessagesUseCase)를 걷어낸 결과이므로, 이 PR에서 같이 지우는 게 맞습니다. 남겨 두면 "DIRECT는 participant 컬럼으로 판정한다"는 지금은 틀린 규칙이 메서드와 주석 형태로 코드에 남습니다.
participant1/2_* 컬럼 자체는 상대방 식별에 계속 쓰이므로 컬럼을 지우자는 얘기는 아닙니다.
| qOlderFile.targetId.eq(qFile.targetId), | ||
| qOlderFile.createdAt.lt(qFile.createdAt) | ||
| .or(qOlderFile.createdAt.eq(qFile.createdAt) | ||
| .and(qOlderFile.id.lt(qFile.id))) |
There was a problem hiding this comment.
중복 ATTACHED 파일 중 가장 오래된 것을 고르는데, 이 경우 사용자에게는 옛날 프로필 사진이 보입니다.
UpdateUserProfileImage는 기존 건을 삭제 표시한 뒤 새 건을 붙입니다. 그러니 ATTACHED가 2건 남는 상황은 그 사이에서 경합이 났거나 attach가 중간에 실패한 경우이고, 그때 사용자가 의도한 현재 이미지는 새 파일 쪽입니다. 지금 방향이면 그 사용자는 프로필을 다시 바꾸기 전까지 옛 사진이 계속 노출됩니다.
부등호를 뒤집으면(gt + id.gt) 최신 1건이 됩니다. 상세 경로도 findAllByTargetTypeAndTargetIdIn이 createdAt asc라 같이 맞춰야 합니다.
결정론적이라는 점은 지금도 충족하니, 방향만 다시 봐주세요. 어느 쪽으로 정하든 목록·상세가 같아야 하는데 그건 이미 맞습니다.
| List<ChatRoomListResponseDto> data = result.data().stream() | ||
| .map(ChatRoomListResponseDto::from) | ||
| .toList(); | ||
| return ResponseEntity.ok(CursorPaginatedApiResponse.of(result.page(), data)); |
There was a problem hiding this comment.
CursorPaginatedApiResponse<Result> → CursorPaginatedApiResponse<Dto> 변환이 컨트롤러 4곳(User·Manager × 목록·메시지)에 같은 모양으로 반복됩니다.
List<XDto> data = result.data().stream().map(XDto::from).toList();
return ResponseEntity.ok(CursorPaginatedApiResponse.of(result.page(), data));CursorPaginatedApiResponse에 매핑 메서드를 하나 두면 각 엔드포인트가 한 줄로 줄고, 새 커서 API마다 같은 블록을 복사하지 않아도 됩니다.
public <R> CursorPaginatedApiResponse<R> map(Function<T, R> mapper) {
return CursorPaginatedApiResponse.of(page(), data().stream().map(mapper).toList());
}이러면 호출부는 return ResponseEntity.ok(result.map(ChatRoomListResponseDto::from));가 됩니다. 포트를 Result로 바꾼 이번 변경이 이 변환을 처음 만들어낸 것이라, 지금 정리해 두면 뒤따를 도메인들이 복사하지 않습니다.
요약
채팅방 목록/정보 API 응답에 채팅방 타입·방 이름·참여 인원수·상대방 프로필 이미지를 추가하고, 그룹 채팅방(업장 단톡방)이 두 API에 전혀 노출되지 않던 결함과 커서 페이지네이션
totalCount오류를 함께 수정했습니다. 리뷰 반영으로 참여 판정 기준 통일, chat 도메인 포트의 domain Result 전환, 목록 쿼리 성능 개선이 추가됐습니다.DB 스키마 변경 없음 (마이그레이션 파일 없음).
변경 내용
응답 필드 추가 (목록 · 정보 공통)
typeDescribedEnumDto<ChatRoomType>— DIRECT("개인 채팅") / GROUP("그룹 채팅")roomNameWorkspace.businessName, 없으면"알 수 없음"), DIRECT면 상대방 이름memberCountchat_room_members활성 멤버(left_at IS NULL) 수opponentProfileImageUrlfiles(targetType=USER_PROFILE, ATTACHED) 중 가장 오래된 1건. 목록은 쿼리 projection의file_url, 상세는FileUrlService경유(PRIVATE 버킷이면 presigned)정보 조회 응답은 목록 응답과 동일한 필드셋(최근 메시지 제외)으로 맞췄습니다.
결함 수정
participant1/2_*컬럼이 null이고 참여자를chat_room_members로 관리하는데, 조회 조건이 participant 컬럼만 보고 있었습니다. 목록 쿼리와findByIdAndParticipant()가 공유하는buildParticipantCondition()을 활성 멤버 EXISTS 단독으로 정리했습니다. V11 마이그레이션이 기존 방을 전부 멤버 2행으로 백필했고 신규 DIRECT 방도 생성 시 멤버 행을 만들므로 participant 컬럼 OR는 불필요합니다 (등치 술어만 있는 상관 EXISTS라idx_chat_room_members_member인덱스를 탈 수 있는 형태).chatRoom.isParticipant()(컬럼 비교)를 사용해, 멤버 행이 없거나 나간 사용자가 메시지를 계속 주고받을 수 있었습니다. 4경로(목록/상세/메시지 조회/전송) 모두findByIdAndParticipant()단일 기준으로 통일했습니다.totalCount가 현재 페이지 건수 —countChatRoomsByParticipant()count 쿼리 결과로 교체했습니다."알 수 없음"폴백으로 통일했고, 비활성 상대는 이름과 함께 프로필 이미지도null로 가립니다.workspaceId가 null인 GROUP 방도 500/null 없이"알 수 없음"을 반환합니다.fetchOne은 NonUniqueResultException 500).구조 개선
domain/chat/result/의 record 5개(ChatRoomResult,CreateChatRoomResult,ChatRoomListResult,ChatMessageResult,ChatAttachmentResult)를 포트가 반환하고, 컨트롤러에서XxxResponseDto.from(result)로 매핑합니다. 커서 목록은 래퍼(CursorPaginatedApiResponse) 조립을 기존 커서 패턴대로 UseCase에 두고 요소 타입만 Result입니다. 응답 JSON 구조 불변.GetChatRoomInfo/ManagerGetChatRoom중복 본문을AbstractGetChatRoomUseCase<A>로 공통화, 목록/메시지 계열과 같은 구조.ChatRoomListWithOpponentResponse의 setter 후주입 제거 —opponentProfileImageUrl/memberCount는 쿼리 projection에서 완성(latestMessageContent만 배치 후주입 잔존).countActiveByRoom()을countActiveByRoomIds()위임으로 정리.성능
목록 요청당 쿼리 3개 고정 (count / 목록 / 최신 메시지 배치) — 멤버수와 프로필 URL은 목록 쿼리 projection의 스칼라 서브쿼리로 흡수했습니다. 프로필 URL을 상대별로 resolve하던 Redis/S3 왕복(방 개수 비례)이 제거됐습니다.
프론트 영향 (Breaking 주의)
opponentId/opponentName/opponentScope/opponentProfileImageUrl이 모두null이므로, 방 제목은opponentName대신roomName을 사용해야 합니다.테스트
./gradlew clean test전체 통과 (387건).GetMyChatRoomListTests/GetChatRoomInfoTests— DIRECT/GROUP 매핑, 마스킹·폴백, totalCount, 비활성 상대 이미지 미노출GetChatMessagesTests/SendChatMessageTests— 멤버 행 없는/나간 DIRECT 사용자 NOT_FOUNDManagerGetChatRoomTests/ManagerGetMyChatRoomListTests— 매니저 조회 주체(ManagerActor.getUserId()=manager_users.user_id해석 회귀 방어)ChatRoomQueryRepositoryImplTests(H2 실 DB) — 파일 서브쿼리·memberCount·중복 ATTACHED 1행 보장·MANAGER scope 조회 주체ChatRoomMemberRepositoryImplTests—countActiveByRoomIds집계후속 과제
files (target_type, target_id)ATTACHED partial unique index — 중복 ATTACHED 근본 차단USER_PROFILE업로드 시 PUBLIC 버킷 강제 검토 (PRIVATE이면 목록의 rawfile_url접근 불가)ChatRoom.isParticipant()dead code 삭제latestMessageContent도 조회 시점 완성으로 이동 검토EXPLAIN ANALYZE확인ChatRoomListResponseDto의type/opponentProfileImageUrl에@Schemaexample 추가