refactor: [ALT-279] 채팅방 목록·정보 응답 개선 및 그룹 채팅방 노출 결함 수정 - #100
Merged
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 Approval pendingCodeRabbit has no unresolved comments, but it skipped the latest review. Use the checkbox below to review the latest commit. CodeRabbit will approve the changes if it finds no blocking issues.
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 |
hodoon
commented
Aug 20, 2026
hodoon
commented
Aug 20, 2026
hodoon
commented
Aug 20, 2026
buildParticipantCondition()이 participant 컬럼 조건과 상관 EXISTS를 OR로 묶고 있어 Postgres가 인덱스를 타지 못하고 chat_rooms를 seq scan하며 행마다 서브쿼리를 돌았다. V11이 기존 방을 멤버 2행으로 백필했고 신규 DIRECT 방도 멤버 행을 생성하므로 participant 컬럼을 함께 볼 필요가 없어 OR를 제거하고 활성 멤버 EXISTS 단독 조건으로 정리했다. 목록/count/상세 조회가 모두 이 조건을 공유한다. 테스트도 실제로 발생하지 않는 상황(멤버 행 없는 DIRECT 방이 participant 컬럼만으로 목록에 포함)을 고정하던 케이스를 걷어내고, 멤버 행 기준으로 포함되는 경우와 멤버 행이 없으면 제외되는 경우로 교체했다.
- 목록/상세 조회 모두 상대방 이름이 가려지는 경우(비활성 상태) 프로필 이미지도 함께 숨기도록 수정 - GetChatRoomInfo, ManagerGetChatRoom의 동일 로직을 AbstractGetChatRoomUseCase로 공통화
ysw789
requested changes
Aug 20, 2026
…입 제거 - 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건으로 대체
ysw789
reviewed
Aug 22, 2026
참여 판정이 findByIdAndParticipant(활성 멤버 EXISTS)로 통일되어 participant 컬럼 기반 in-memory 판정 메서드가 어디서도 쓰이지 않게 됨. 남겨두면 현재는 틀린 규칙이 코드에 남으므로 삭제.
…변환 축약 포트를 Result 반환으로 바꾸면서 User·Manager × 목록·메시지 컨트롤러 4곳에 같은 stream().map().toList() + of(page, data) 블록이 반복됨. record에 map(Function) 하나를 두고 각 엔드포인트를 한 줄로 정리.
- 상대 이름이 빈 문자열이면 목록은 "알 수 없음"+프로필 null인데 상세는 null 체크만 해서 응답이 갈렸음. ObjectUtils.isNotEmpty로 조건 통일. - 중복 ATTACHED 프로필 파일은 가장 오래된 건을 골라 옛 사진이 노출됐음. 경합으로 2건 남은 경우 사용자 의도는 새 파일이므로 최신 1건으로 변경. 목록은 NOT EXISTS 비교 방향을 뒤집고, 상세는 asc 리스트 마지막 원소를 취함(findAllByTargetTypeAndTargetIdIn 정렬은 메시지·댓글 첨부 순서에 공유되므로 유지).
Member
|
빠른 머지가 필요하옵니다..🥲 |
ysw789
requested changes
Aug 24, 2026
- 커서 목록 포트 4개가 입력 CursorPageRequestDto·반환 CursorPaginatedApiResponse (adapter DTO)를 써서 domain → adapter 의존이 남아 있었음. domain/common/pagination에 CursorPageQuery·CursorPageResult<T>를 신설해 포트 경계를 domain 타입으로 전환하고, request DTO → 쿼리 매핑과 CursorPaginatedApiResponse.from(result, mapper) 변환은 컨트롤러 책임으로 이동 (호출부가 사라진 map() 인스턴스 메서드는 삭제). 응답 JSON 구조 불변. - ChatRoomListResult/ChatMessageResult가 outbound 조회 모델을 import하는 from() 팩토리를 가져 같은 방향 역전이 있었음. 변환 로직을 Abstract UseCase의 toResult private 메서드로 이동해 Result record를 adapter-independent하게 정리.
ysw789
requested changes
Aug 24, 2026
공통 CursorPaginatedApiResponse에 추가했던 from(CursorPageResult, mapper)는 소비자가 chat뿐인데 전 도메인 공용 DTO가 domain 타입을 알게 만들었음(리뷰 지적). from()과 해당 테스트를 제거해 공통 DTO를 PR 이전 상태로 되돌리고, 정규화는 각 채팅 컨트롤러의 private toPaginatedResponse helper에서 기존 팩토리(CursorPageResponseDto.of + CursorPaginatedApiResponse.of) 조합으로 수행.
ysw789
approved these changes
Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
요약
채팅방 목록/정보 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)로 매핑합니다. 커서 목록 포트는 domain 타입(CursorPageQuery입력,CursorPageResult<T>반환,domain/common/pagination/)만 사용하고, request DTO →CursorPageQuery매핑과CursorPageResult→CursorPaginatedApiResponse변환(CursorPaginatedApiResponse.from)은 컨트롤러가 담당합니다. domain Result가 outbound 조회 모델에 의존하지 않도록 조회 모델 → Result 변환은 application UseCase로 이동했습니다. 응답 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전체 통과 (380건, 중복 테스트 정리 후 기준).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접근 불가)latestMessageContent도 조회 시점 완성으로 이동 검토EXPLAIN ANALYZE확인ChatRoomListResponseDto의type/opponentProfileImageUrl에@Schemaexample 추가