Conversation
사용자별 멤버십 정보를 추가하여 그룹 목록 응답을 더욱 풍부하게 만듭니다. 이를 통해 프런트엔드는 각 그룹의 멤버십 상태(예: "가입됨", "대기 중", "가입하지 않음")를 표시할 수 있으며, 더욱 개인화된 정보를 제공하여 사용자 경험을 향상시킵니다. 사용자가 로그인하지 않은 경우에도 원활하게 처리합니다.
|
Caution Review failedThe pull request is closed. 요약이 풀 리퀘스트는 그룹 목록 조회 API에 사용자별 멤버십 정보를 추가합니다. 컨트롤러에서 인증된 사용자의 ID를 추출하여 서비스로 전달하고, 서비스에서 저장소의 새로운 쿼리 메서드를 통해 사용자 멤버십 데이터를 조회한 후 응답 DTO에 포함시킵니다. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Controller as GroupV2Controller
participant Service as GroupV2Service
participant UserRepo as GroupUserV2Repository
participant Mapper as DTO Factory
User->>Controller: GET /groups?keyword=...&size=20<br/>(with Auth Token)
activate Controller
Controller->>Controller: Extract userId from<br/>@AuthenticationPrincipal
Controller->>Service: getGroupListV2(userId, keyword,<br/>cursor, size, filter, ...)
deactivate Controller
activate Service
Service->>Service: Query groups with filters
Service->>Service: Extract groupIds from results
alt userId is not null
Service->>UserRepo: findMyMembershipsByGroupIds<br/>(userId, groupIds)
activate UserRepo
UserRepo-->>Service: List<GroupUserV2><br/>(myMembershipMap)
deactivate UserRepo
else userId is null
Service->>Service: myMembershipMap = empty
end
Service->>Mapper: For each group row,<br/>call GroupListItemV2Response.of()<br/>with myMembership from map
activate Mapper
Mapper-->>Service: GroupListItemV2Response<br/>(includes myMembership)
deactivate Mapper
Service-->>Service: Build GetGroupListV2Response
deactivate Service
Controller->>User: 200 OK<br/>GetGroupListV2Response<br/>(groups with myMembership)
예상 코드 리뷰 소요 시간🎯 3 (Moderate) | ⏱️ ~20 minutes 관련 가능성 있는 PR
시
✨ Finishing touches
📜 Recent review detailsConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro 📒 Files selected for processing (7)
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 |
There was a problem hiding this comment.
Pull request overview
This PR enhances the group list API (V2) to include user-specific membership information in the response. The primary change allows the frontend to display personalized membership status for each group (e.g., "joined", "pending", "not joined") by adding a myMembership field to each group item. The implementation gracefully handles both authenticated and unauthenticated users, returning null when users are not logged in or have no membership in a group.
Key changes:
- Added optional
CustomUserDetailsparameter to the group list endpoint to support both authenticated and unauthenticated access - Implemented new repository query to fetch user memberships across multiple groups efficiently
- Enhanced
GroupListItemV2Responseto includeMyMembershipdata with status, role, and join timestamps
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
GroupV2Controller.java |
Added @AuthenticationPrincipal CustomUserDetails parameter and passes userId to service layer |
GroupV2ControllerDocs.java |
Updated API documentation interface to include userDetails parameter |
GroupUserV2Repository.java |
Added findMyMembershipsByGroupIds query method to fetch user memberships for multiple groups |
GroupV2Service.java |
Implemented logic to fetch and map user memberships to group list items |
GetGroupListV2Response.java |
Added myMembership field to GroupListItemV2Response |
chat-test.html |
Added WebSocket/STOMP chat testing utility (unrelated to PR purpose) |
v2-group-attend-approval-required.http |
Added test cases for second member and group list verification |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <!doctype html> | ||
| <html lang="ko"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"/> | ||
| <title>Chat WS Test (SockJS + STOMP)</title> | ||
|
|
||
| <!-- SockJS --> | ||
| <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script> | ||
| <!-- STOMP --> | ||
| <script src="https://cdn.jsdelivr.net/npm/@stomp/stompjs@7/bundles/stomp.umd.min.js"></script> | ||
|
|
||
| <style> | ||
| body { font-family: system-ui, -apple-system, Segoe UI, Roboto, "Noto Sans KR", sans-serif; margin: 16px; } | ||
| .row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; } | ||
| input, button, textarea { padding: 8px; font-size: 14px; } | ||
| input, textarea { width: 100%; max-width: 680px; } | ||
| .box { border: 1px solid #ddd; padding: 12px; border-radius: 10px; max-width: 720px; } | ||
| .log { height: 320px; overflow: auto; background: #0b1020; color: #d7e2ff; padding: 10px; border-radius: 10px; white-space: pre-wrap; } | ||
| .tag { font-size: 12px; opacity: .8; } | ||
| button { cursor: pointer; } | ||
| .status { font-weight: 700; } | ||
| </style> | ||
| </head> | ||
|
|
||
| <body> | ||
| <h2>Chat WebSocket Test (SockJS + STOMP)</h2> | ||
|
|
||
| <div class="box"> | ||
| <div class="row"> | ||
| <div style="flex:1; min-width:260px;"> | ||
| <div class="tag">SockJS URL</div> | ||
| <input id="wsUrl" value="http://localhost:8080/ws-chat" /> | ||
| </div> | ||
| <div style="flex:1; min-width:260px;"> | ||
| <div class="tag">Access Token (Bearer 제외)</div> | ||
| <input id="token" placeholder="eyJhbGciOiJIUzI1NiJ9..." /> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="row"> | ||
| <div style="flex:1; min-width:180px;"> | ||
| <div class="tag">roomId</div> | ||
| <input id="roomId" type="number" value="1" /> | ||
| </div> | ||
| <div style="flex:1; min-width:180px;"> | ||
| <div class="tag">userId (개인 알림용)</div> | ||
| <input id="userId" type="number" value="1" /> | ||
| </div> | ||
| <div style="display:flex; align-items:flex-end; gap:8px;"> | ||
| <button id="btnConnect">Connect</button> | ||
| <button id="btnDisconnect" disabled>Disconnect</button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="row"> | ||
| <div class="status">Status: <span id="statusText">DISCONNECTED</span></div> | ||
| </div> | ||
|
|
||
| <hr/> | ||
|
|
||
| <div class="row"> | ||
| <div style="flex:1; min-width:260px;"> | ||
| <div class="tag">Message Content</div> | ||
| <input id="msg" placeholder="안녕하세요!" /> | ||
| </div> | ||
| <div style="display:flex; align-items:flex-end; gap:8px;"> | ||
| <button id="btnSend" disabled>Send</button> | ||
| <button id="btnClear">Clear Log</button> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div class="tag">Log</div> | ||
| <div id="log" class="log"></div> | ||
| </div> | ||
|
|
||
| <script> | ||
| const $ = (id) => document.getElementById(id); | ||
|
|
||
| let stompClient = null; | ||
| let roomSub = null; | ||
| let userSub = null; | ||
|
|
||
| function log(...args) { | ||
| const line = args.map(a => typeof a === 'string' ? a : JSON.stringify(a, null, 2)).join(' '); | ||
| const el = $('log'); | ||
| el.textContent += line + "\n"; | ||
| el.scrollTop = el.scrollHeight; | ||
| console.log(...args); | ||
| } | ||
|
|
||
| function setStatus(s) { | ||
| $('statusText').textContent = s; | ||
| } | ||
|
|
||
| function setUiConnected(connected) { | ||
| $('btnConnect').disabled = connected; | ||
| $('btnDisconnect').disabled = !connected; | ||
| $('btnSend').disabled = !connected; | ||
| } | ||
|
|
||
| function connect() { | ||
| const wsUrl = $('wsUrl').value.trim(); | ||
| const token = $('token').value.trim(); | ||
| const roomId = Number($('roomId').value); | ||
| const userId = Number($('userId').value); | ||
|
|
||
| if (!wsUrl) return alert("wsUrl을 입력하세요."); | ||
| if (!token) return alert("accessToken을 입력하세요."); | ||
|
|
||
| setStatus("CONNECTING..."); | ||
| log("[CONNECT] wsUrl =", wsUrl); | ||
|
|
||
| // SockJS 연결 | ||
| const socket = new SockJS(wsUrl); | ||
|
|
||
| // STOMP client 생성 | ||
| stompClient = new StompJs.Client({ | ||
| webSocketFactory: () => socket, | ||
| connectHeaders: { | ||
| Authorization: `Bearer ${token}`, | ||
| }, | ||
| debug: (str) => log("[STOMP]", str), | ||
| reconnectDelay: 5000, | ||
| heartbeatIncoming: 4000, | ||
| heartbeatOutgoing: 4000, | ||
| }); | ||
|
|
||
| stompClient.onConnect = (frame) => { | ||
| setStatus("CONNECTED"); | ||
| setUiConnected(true); | ||
| log("[CONNECTED]", frame.headers); | ||
|
|
||
| // room 구독 | ||
| const roomDest = `/sub/chat/room/${roomId}`; | ||
| roomSub = stompClient.subscribe(roomDest, (message) => { | ||
| try { | ||
| const payload = JSON.parse(message.body); | ||
| log(`[ROOM ${roomId}]`, payload); | ||
| } catch (e) { | ||
| log(`[ROOM ${roomId}] raw:`, message.body); | ||
| } | ||
| }); | ||
| log("[SUBSCRIBE]", roomDest); | ||
|
|
||
| // user 알림 구독 | ||
| const userDest = `/sub/user/${userId}`; | ||
| userSub = stompClient.subscribe(userDest, (message) => { | ||
| try { | ||
| const payload = JSON.parse(message.body); | ||
| log(`[USER ${userId}]`, payload); | ||
| } catch (e) { | ||
| log(`[USER ${userId}] raw:`, message.body); | ||
| } | ||
| }); | ||
| log("[SUBSCRIBE]", userDest); | ||
| }; | ||
|
|
||
| stompClient.onDisconnect = () => { | ||
| setStatus("DISCONNECTED"); | ||
| setUiConnected(false); | ||
| log("[DISCONNECTED]"); | ||
| }; | ||
|
|
||
| stompClient.onStompError = (frame) => { | ||
| log("[STOMP ERROR]", frame.headers, frame.body); | ||
| }; | ||
|
|
||
| stompClient.onWebSocketError = (evt) => { | ||
| log("[WS ERROR]", evt); | ||
| }; | ||
|
|
||
| stompClient.onWebSocketClose = (evt) => { | ||
| log("[WS CLOSED]", evt.code, evt.reason); | ||
| setStatus("DISCONNECTED"); | ||
| setUiConnected(false); | ||
| }; | ||
|
|
||
| stompClient.activate(); | ||
| } | ||
|
|
||
| function disconnect() { | ||
| if (roomSub) { roomSub.unsubscribe(); roomSub = null; } | ||
| if (userSub) { userSub.unsubscribe(); userSub = null; } | ||
|
|
||
| if (stompClient) { | ||
| log("[DISCONNECT] deactivating..."); | ||
| stompClient.deactivate(); | ||
| stompClient = null; | ||
| } | ||
| setStatus("DISCONNECTED"); | ||
| setUiConnected(false); | ||
| } | ||
|
|
||
| function sendMessage() { | ||
| if (!stompClient || !stompClient.connected) { | ||
| alert("연결되어 있지 않습니다."); | ||
| return; | ||
| } | ||
| const roomId = Number($('roomId').value); | ||
| const content = $('msg').value; | ||
|
|
||
| const body = JSON.stringify({ chatRoomId: roomId, content }); | ||
| stompClient.publish({ | ||
| destination: "/pub/chat/message", | ||
| body, | ||
| }); | ||
| log("[SEND]", body); | ||
| } | ||
|
|
||
| $('btnConnect').addEventListener('click', connect); | ||
| $('btnDisconnect').addEventListener('click', disconnect); | ||
| $('btnSend').addEventListener('click', sendMessage); | ||
| $('btnClear').addEventListener('click', () => $('log').textContent = ""); | ||
|
|
||
| window.addEventListener('beforeunload', () => { | ||
| try { disconnect(); } catch (_) {} | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html> |
There was a problem hiding this comment.
This chat WebSocket test HTML file appears to be unrelated to the PR's stated purpose of adding membership information to group list responses. Consider moving this to a separate PR focused on chat/WebSocket functionality to maintain clearer change history and easier code review.
| where gu.user.id = :userId | ||
| and g.id in :groupIds | ||
| """) | ||
| List<GroupUserV2> findMyMembershipsByGroupIds(Long userId, List<Long> groupIds); |
There was a problem hiding this comment.
The query parameters are missing @param annotations. JPA requires @param annotations for named parameters in @query when using JPQL. Add @param("userId") and @param("groupIds") to the method parameters to ensure proper parameter binding.
| List<GroupUserV2> findMyMembershipsByGroupIds(Long userId, List<Long> groupIds); | |
| List<GroupUserV2> findMyMembershipsByGroupIds(@Param("userId") Long userId, | |
| @Param("groupIds") List<Long> groupIds); |
| @Query(""" | ||
| select gu | ||
| from GroupUserV2 gu | ||
| join fetch gu.group g |
There was a problem hiding this comment.
The query should eager fetch the user entity to avoid potential LazyInitializationException. While the group is already being fetched, the user relationship might be accessed when creating MyMembership objects outside the transaction context. Consider adding "join fetch gu.user u" to ensure the user entity is loaded with the GroupUserV2.
| join fetch gu.group g | |
| join fetch gu.group g | |
| join fetch gu.user u |
| .collect(java.util.stream.Collectors.toMap( | ||
| gu -> gu.getGroup().getId(), | ||
| MyMembership::from, | ||
| (a, b) -> a |
There was a problem hiding this comment.
The merge function "(a, b) -> a" in the toMap collector silently discards duplicate entries. If a user has multiple memberships for the same group (which shouldn't happen but could occur due to data inconsistency), only the first one will be kept without any logging or warning. Consider adding validation or logging to detect such cases, or use a more explicit merge strategy.
| (a, b) -> a | |
| (a, b) -> { | |
| log.warn("Duplicate group membership detected for userId={} in group list; keeping the first membership and discarding the others.", userIdOrNull); | |
| return a; | |
| } |
| {} No newline at end of file | ||
| {} | ||
|
|
||
| ### 6-1. V2 목록 첫 페이지 (기본: filter=ACTIVE, keyword 없음) |
There was a problem hiding this comment.
The test case numbering is inconsistent. Test "6-1" appears after tests "7" and "8", which breaks the logical sequence. Consider renumbering this to "9" or reorganizing the test cases to maintain sequential order.
| ### 6-1. V2 목록 첫 페이지 (기본: filter=ACTIVE, keyword 없음) | |
| ### 9. V2 목록 첫 페이지 (기본: filter=ACTIVE, keyword 없음) |
| final Map<Long, MyMembership> myMembershipMap = | ||
| (userIdOrNull == null) | ||
| ? Map.of() | ||
| : groupUserV2Repository.findMyMembershipsByGroupIds(userIdOrNull, groupIds) | ||
| .stream() | ||
| .collect(java.util.stream.Collectors.toMap( | ||
| gu -> gu.getGroup().getId(), | ||
| MyMembership::from, | ||
| (a, b) -> a | ||
| )); |
There was a problem hiding this comment.
The new functionality to include user membership information in the group list response lacks automated test coverage. Consider adding tests to verify: 1) membership information is correctly populated for authenticated users, 2) null is returned for unauthenticated users, 3) null is returned when user has no membership in a group, and 4) the correct membership details (status, role, joinedAt) are mapped from GroupUserV2 entities.
📝 Pull Request
📌 PR 종류
해당하는 항목에 체크해주세요.
✨ 변경 내용
사용자별 멤버십 정보를 추가하여 그룹 목록 응답을 더욱 풍부하게 만듭니다.
이를 통해 프런트엔드는 각 그룹의 멤버십 상태(예: "가입됨", "대기 중", "가입하지 않음")를 표시할 수 있으며,
더욱 개인화된 정보를 제공하여 사용자 경험을 향상시킵니다.
사용자가 로그인하지 않은 경우에도 원활하게 처리합니다.
🔍 관련 이슈
🧪 테스트
변경된 기능에 대한 테스트 범위 또는 테스트 결과를 작성해주세요.
🚨 확인해야 할 사항 (Checklist)
PR을 제출하기 전에 아래 항목들을 확인해주세요.
🙋 기타 참고 사항
리뷰어가 참고하면 좋을 만한 추가 설명이 있다면 적어주세요.
Summary by CodeRabbit
Release Notes
New Features
Tests
✏️ Tip: You can customize this high-level summary in your review settings.