Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService;
import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceInviteList;
import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceSave;
import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfoWithoutAuthority;
import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceInfoWithoutAuthority;
import org.tuna.zoopzoop.backend.domain.space.space.service.SpaceService;
import org.tuna.zoopzoop.backend.global.rsData.RsData;
import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails;
Expand Down Expand Up @@ -81,8 +81,8 @@ public RsData<ResBodyForSpaceInviteList> getMyInvites(

// 멤버십(초대) 목록 조회
List<Membership> invitations = membershipService.findByMember(member, "PENDING");
List<SpaceMembershipInfoWithoutAuthority> invitationInfos = invitations.stream()
.map(membership -> new SpaceMembershipInfoWithoutAuthority(
List<SpaceInfoWithoutAuthority> invitationInfos = invitations.stream()
.map(membership -> new SpaceInfoWithoutAuthority(
membership.getSpace().getId(),
membership.getSpace().getName(),
membership.getSpace().getThumbnailUrl()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,17 @@
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.tuna.zoopzoop.backend.domain.member.entity.Member;
import org.tuna.zoopzoop.backend.domain.space.membership.dto.etc.SpaceMemberInfo;
import org.tuna.zoopzoop.backend.domain.space.membership.entity.Membership;
import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority;
import org.tuna.zoopzoop.backend.domain.space.membership.enums.JoinState;
import org.tuna.zoopzoop.backend.domain.space.membership.service.MembershipService;
import org.tuna.zoopzoop.backend.domain.space.space.dto.req.ReqBodyForSpaceSave;
import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceInfo;
import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceList;
import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfo;
import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceInfo;
import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceListPage;
import org.tuna.zoopzoop.backend.domain.space.space.dto.res.ResBodyForSpaceSave;
import org.tuna.zoopzoop.backend.domain.space.space.entity.Space;
Expand All @@ -30,7 +29,6 @@

import java.nio.file.AccessDeniedException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
Expand Down Expand Up @@ -131,6 +129,7 @@ public RsData<Void> updateSpaceThumbnail(
public RsData<ResBodyForSpaceListPage> getAllSpaces(
@AuthenticationPrincipal CustomUserDetails userDetails,
@RequestParam(required = false) JoinState state,
@RequestParam(defaultValue = "false") boolean includeMembers,
@PageableDefault(size = 10, sort = "createDate", direction = Sort.Direction.DESC) Pageable pageable
) {
// 현재 로그인한 사용자 정보 가져오기
Expand All @@ -141,13 +140,32 @@ public RsData<ResBodyForSpaceListPage> getAllSpaces(
Page<Membership> membershipsPage = membershipService.findByMember(member, stateStr, pageable);

// Page<Membership>를 Page<SpaceMembershipInfo>로 변환
// Page 객체의 map() 메서드를 사용하면 페이징 정보는 그대로 유지하면서 내용물만 쉽게 바꿀 수 있습니다.
Page<SpaceMembershipInfo> spaceInfosPage = membershipsPage.map(membership -> new SpaceMembershipInfo(
membership.getSpace().getId(),
membership.getSpace().getName(),
membership.getSpace().getThumbnailUrl(),
membership.getAuthority()
));
Page<SpaceInfo> spaceInfosPage = membershipsPage.map(membership -> {
Space space = membership.getSpace();
List<SpaceMemberInfo> memberInfos = null;

if (includeMembers) {
// 스페이스에 속한 멤버 목록 조회 (가입 상태만)
List<Membership> spaceMemberships = membershipService.findMembersBySpace(space);
// 멤버 목록을 DTO로 변환
memberInfos = spaceMemberships.stream()
.map(spaceMembership -> new SpaceMemberInfo(
spaceMembership.getMember().getId(),
spaceMembership.getMember().getName(),
spaceMembership.getMember().getProfileImageUrl(),
spaceMembership.getAuthority()
))
.collect(Collectors.toList());
}
Comment on lines +147 to +159
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implementation causes an N+1 query problem. For each space in the page, a separate query is made to fetch members. Consider using a single query with JOIN FETCH or batch loading to retrieve all space members at once.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

@Kimgooner Kimgooner Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코파일럿 리뷰에도 적혀 있는데, 위 쿼리처럼 작동하면 아래와 같은 방식으로 작동될 것 같습니다.

//pseudo code
spaces = query(member) // 사용자 소속 스페이스 목록 조회 (N=20이라고 가정.), 쿼리 1 회
for (space in spaces) {
    list<membership> m = membership.query(space) // 조회된 스페이스 마다 쿼리 실행, 쿼리 N 회

즉, 사용자가 참가한 스페이스가 N개 일 경우 쿼리를 N+1번 실행하는 형태라, 대용량의 데이터 처리 시에는 유의미한 문제가 발생할 것 같습니다.

batch 쿼리 방식 등을 고려해보는 것도 좋을 것 같아요.


return new SpaceInfo(
space.getId(),
space.getName(),
space.getThumbnailUrl(),
membership.getAuthority(),
memberInfos // 조회된 멤버 목록 (null일 수도 있음)
);
});

// 새로운 응답 DTO 생성
ResBodyForSpaceListPage resBody = new ResBodyForSpaceListPage(spaceInfosPage);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package org.tuna.zoopzoop.backend.domain.space.space.dto.etc;

import com.fasterxml.jackson.annotation.JsonInclude;
import org.tuna.zoopzoop.backend.domain.space.membership.dto.etc.SpaceMemberInfo;
import org.tuna.zoopzoop.backend.domain.space.membership.enums.Authority;

import java.util.List;

@JsonInclude(JsonInclude.Include.NON_NULL)
public record SpaceInfo(
Integer id,
String name,
String thumbnailUrl,
Authority authority,
List<SpaceMemberInfo> members
) {
public SpaceInfo(Integer id, String name, String thumbnailUrl, Authority authority) {
this(id, name, thumbnailUrl, authority, null);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package org.tuna.zoopzoop.backend.domain.space.space.dto.etc;

public record SpaceMembershipInfoWithoutAuthority(
public record SpaceInfoWithoutAuthority(
Integer id,
String name,
String thumbnailUrl
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package org.tuna.zoopzoop.backend.domain.space.space.dto.res;

import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfoWithoutAuthority;
import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceInfoWithoutAuthority;

import java.util.List;

public record ResBodyForSpaceInviteList(
List<SpaceMembershipInfoWithoutAuthority> spaces
List<SpaceInfoWithoutAuthority> spaces
) {
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package org.tuna.zoopzoop.backend.domain.space.space.dto.res;

import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfo;
import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceInfo;

import java.util.List;

public record ResBodyForSpaceList(
List<SpaceMembershipInfo> spaces
List<SpaceInfo> spaces
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@

import lombok.Getter;
import org.springframework.data.domain.Page;
import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceMembershipInfo;
import org.tuna.zoopzoop.backend.domain.space.space.dto.etc.SpaceInfo;

import java.util.List;

@Getter
public class ResBodyForSpaceListPage {
private final List<SpaceMembershipInfo> spaces; // 현재 페이지의 데이터
private final List<SpaceInfo> spaces; // 현재 페이지의 데이터
private final int page; // 현재 페이지 번호 (0부터 시작)
private final int size; // 페이지 크기
private final long totalElements; // 전체 요소 수
private final int totalPages; // 전체 페이지 수
private final boolean isLast; // 마지막 페이지 여부

public ResBodyForSpaceListPage(Page<SpaceMembershipInfo> page) {
public ResBodyForSpaceListPage(Page<SpaceInfo> page) {
this.spaces = page.getContent();
this.page = page.getNumber();
this.size = page.getSize();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.tuna.zoopzoop.backend.testSupport.ControllerTestSupport;

import static org.hamcrest.Matchers.nullValue;
import static org.hamcrest.Matchers.startsWith;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
Expand Down Expand Up @@ -81,6 +82,12 @@ void setUpMembership() {
spaceService.findByName("기존 스페이스 1_forSpaceControllerTest"),
Authority.PENDING
);
// test3 -> 스페이스 1 가입 (READ_ONLY)
membershipService.addMemberToSpace(
memberService.findByKakaoKey("sc3333"),
spaceService.findByName("기존 스페이스 1_forSpaceControllerTest"),
Authority.READ_ONLY
);
// test1 -> 스페이스 2 가입 (PENDING)
membershipService.addMemberToSpace(
memberService.findByKakaoKey("sc1111"),
Expand Down Expand Up @@ -408,6 +415,45 @@ void getMySpaces_Success() throws Exception {
.andExpect(jsonPath("$.data.spaces[1].thumbnailUrl").value("thumbnailUrl2"));
}

@Test
@WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
@DisplayName("나의 스페이스 전체 조회 (멤버 포함) - 성공")
void getMySpaces_withMembers_Success() throws Exception {
// Given
String url = "/api/v1/space?includeMembers=true";

// When
ResultActions resultActions = performGet(url);

// Then
expectOk(
resultActions,
"스페이스 목록이 조회됐습니다."
);
resultActions
.andExpect(jsonPath("$.data.spaces").isArray())
.andExpect(jsonPath("$.data.spaces.length()").value(2));

// 첫 번째 스페이스 (기존 스페이스 1) 검증
resultActions
.andExpect(jsonPath("$.data.spaces[0].name").value("기존 스페이스 1_forSpaceControllerTest"))
.andExpect(jsonPath("$.data.spaces[0].authority").value("ADMIN"))
.andExpect(jsonPath("$.data.spaces[0].members").isArray())
.andExpect(jsonPath("$.data.spaces[0].members.length()").value(2)) // PENDING 제외 2명
.andExpect(jsonPath("$.data.spaces[0].members[0].name", startsWith("spaceControllerTester1")))
.andExpect(jsonPath("$.data.spaces[0].members[0].authority").value("ADMIN"))
.andExpect(jsonPath("$.data.spaces[0].members[1].name", startsWith("spaceControllerTester3")))
.andExpect(jsonPath("$.data.spaces[0].members[1].authority").value("READ_ONLY"));


// 두 번째 스페이스 (기존 스페이스 2) 검증
resultActions
.andExpect(jsonPath("$.data.spaces[1].name").value("기존 스페이스 2_forSpaceControllerTest"))
.andExpect(jsonPath("$.data.spaces[1].authority").value("PENDING"))
.andExpect(jsonPath("$.data.spaces[1].members").isArray())
.andExpect(jsonPath("$.data.spaces[1].members.length()").value(0)); // 가입된 멤버 없음
}

@Test
@WithUserDetails(value = "KAKAO:sc1111", setupBefore = TestExecutionEvent.TEST_METHOD)
@DisplayName("초대받은 스페이스 전체 조회 - 성공")
Expand Down Expand Up @@ -544,7 +590,7 @@ void getSpace_Fail_NotFound() throws Exception {
@DisplayName("스페이스 단건 조회 - 실패 : 스페이스 멤버가 아닌 사용자")
void getSpace_Fail_NotMember() throws Exception {
// Given
Space space = spaceService.findByName("기존 스페이스 1_forSpaceControllerTest");
Space space = spaceService.findByName("기존 스페이스 2_forSpaceControllerTest");
Integer spaceId = space.getId();
String url = String.format("/api/v1/space/%d", spaceId);

Expand Down