diff --git a/gateway-service/src/main/resources/application-aws.yml b/gateway-service/src/main/resources/application-aws.yml index 1cbf98e..a663a87 100644 --- a/gateway-service/src/main/resources/application-aws.yml +++ b/gateway-service/src/main/resources/application-aws.yml @@ -48,7 +48,7 @@ spring: - id: user-service-protected uri: http://user-service:9000 predicates: - - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/password/change, /api/auth/withdraw, /api/members/**, /api/notices/** + - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/password/change, /api/auth/withdraw, /api/members/**, /api/notices/**, /api/v1/notices/** filters: - AuthorizationHeaderFilter diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index 0038458..4ab6c71 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -49,7 +49,7 @@ spring: - id: user-service-protected uri: http://localhost:9000 predicates: - - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/password/change, /api/auth/withdraw, /api/members/**, /api/notices/** + - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/password/change, /api/auth/withdraw, /api/members/**, /api/notices/**, /api/v1/notices/** filters: - AuthorizationHeaderFilter diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java index e9587de..303a7d7 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java @@ -1,5 +1,6 @@ package com.comatching.user.domain.admin.user.service; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -34,6 +35,7 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class AdminMemberServiceImpl implements AdminMemberService { + private static final int INVENTORY_BATCH_SIZE = 100; private final MemberRepository memberRepository; private final ItemAdminClient itemAdminClient; @@ -51,14 +53,24 @@ public PagingResponse getUsers(String keyword, Pageabl .map(this::toAdminUserProfileDto); List users = userPage.getContent(); - Map inventoryCountsByMemberId = - users.isEmpty() - ? Map.of() - : itemAdminClient.getInventoryCounts( - users.stream() - .map(AdminUserProfileDto::id) - .toList() - ); + Map inventoryCountsByMemberId; + if (users.isEmpty()) { + inventoryCountsByMemberId = Map.of(); + } else { + List memberIds = users.stream() + .map(AdminUserProfileDto::id) + .toList(); + + if (memberIds.size() <= INVENTORY_BATCH_SIZE) { + inventoryCountsByMemberId = itemAdminClient.getInventoryCounts(memberIds); + } else { + inventoryCountsByMemberId = new HashMap<>(); + for (int start = 0; start < memberIds.size(); start += INVENTORY_BATCH_SIZE) { + int end = Math.min(start + INVENTORY_BATCH_SIZE, memberIds.size()); + inventoryCountsByMemberId.putAll(itemAdminClient.getInventoryCounts(memberIds.subList(start, end))); + } + } + } List summaries = users.stream() .map(user -> AdminUserSummaryResponse.from( diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java index 8eb3ad9..addb75f 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java @@ -71,7 +71,7 @@ public ResponseEntity> deleteNotice( @RequireRole({MemberRole.ROLE_USER, MemberRole.ROLE_ADMIN}) @Operation(summary = "활성 공지사항 조회", description = "현재 시각 기준으로 노출 기간에 포함된 공지사항 목록을 조회합니다.") - @GetMapping("/notices/active") + @GetMapping("/v1/notices/active") public ResponseEntity>> getActiveNotices( @CurrentMember MemberInfo memberInfo ) { diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml index 06efdb0..e751b72 100644 --- a/user-service/src/main/resources/application.yml +++ b/user-service/src/main/resources/application.yml @@ -58,6 +58,9 @@ spring: redis: host: localhost port: 6380 + web: + pageable: + max-page-size: 10000 kafka: bootstrap-servers: localhost:9092 @@ -149,4 +152,4 @@ management: http.server.requests: true spring.kafka.listener: true slo: - http.server.requests: 50ms,100ms,200ms,500ms,1s,2s \ No newline at end of file + http.server.requests: 50ms,100ms,200ms,500ms,1s,2s diff --git a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java index 5410fc8..61d36e6 100644 --- a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java @@ -7,10 +7,13 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.times; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.LongStream; import com.comatching.user.domain.admin.user.service.AdminMemberServiceImpl; import org.junit.jupiter.api.DisplayName; @@ -58,6 +61,46 @@ class AdminMemberServiceTest { @Mock private ItemAdminClient itemAdminClient; + @Test + @DisplayName("조회된 사용자가 0명이면 item-service를 호출하지 않는다") + void shouldNotCallItemServiceWhenNoUsersFound() { + // given + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "id")); + given(memberRepository.searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable)) + .willReturn(new PageImpl<>(List.of(), pageable, 0)); + + // when + PagingResponse result = adminMemberService.getUsers(null, pageable); + + // then + assertThat(result.content()).isEmpty(); + then(itemAdminClient).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("조회된 사용자가 20명이면 item-service를 정확히 한 번 호출한다") + void shouldCallItemServiceOnceFor20Users() { + assertInventoryBatching(20, 1, List.of(20)); + } + + @Test + @DisplayName("조회된 사용자가 100명이면 item-service를 정확히 한 번 호출한다") + void shouldCallItemServiceOnceFor100Users() { + assertInventoryBatching(100, 1, List.of(100)); + } + + @Test + @DisplayName("조회된 사용자가 101명이면 100명과 1명으로 나눠 호출하고 결과를 병합한다") + void shouldCallItemServiceTwiceFor101Users() { + assertInventoryBatching(101, 2, List.of(100, 1)); + } + + @Test + @DisplayName("조회된 사용자가 250명이면 100명, 100명, 50명으로 나눠 호출하고 결과를 병합한다") + void shouldCallItemServiceThreeTimesFor250Users() { + assertInventoryBatching(250, 3, List.of(100, 100, 50)); + } + @Test @DisplayName("사용자 목록과 인벤토리 수량을 함께 조회한다") void shouldReturnUsersWithInventoryCounts() { @@ -110,6 +153,27 @@ void shouldFallbackToEmptyInventoryWhenMissing() { assertThat(result.content().get(0).optionTicketCount()).isZero(); } + @Test + @DisplayName("사용자 상세 조회는 기존처럼 단일 사용자 ID로 item-service를 한 번 호출한다") + void shouldGetUserDetailWithSingleInventoryCall() { + // given + Long memberId = 7L; + Member member = createMemberWithProfile(memberId, "user7@test.com", "상세사용자", "닉네임7", Gender.FEMALE, "https://img7"); + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + given(itemAdminClient.getInventoryCounts(List.of(memberId))) + .willReturn(Map.of(memberId, new AdminInventoryCounts(4L, 2L))); + + // when + var result = adminMemberService.getUserDetail(memberId); + + // then + assertThat(result.id()).isEqualTo(memberId); + assertThat(result.matchingTicketCount()).isEqualTo(4L); + assertThat(result.optionTicketCount()).isEqualTo(2L); + then(itemAdminClient).should(times(1)).getInventoryCounts(List.of(memberId)); + } + @Test @DisplayName("keyword 앞뒤 공백을 제거해서 저장소로 전달한다") void shouldTrimKeywordBeforeQuery() { @@ -295,6 +359,44 @@ private static Request testRequest() { ); } + private void assertInventoryBatching(int userCount, int expectedCalls, List expectedBatchSizes) { + PageRequest pageable = PageRequest.of(0, userCount, Sort.by(Sort.Direction.DESC, "id")); + List members = LongStream.rangeClosed(1, userCount) + .mapToObj(id -> createMemberWithProfile( + id, + "user" + id + "@test.com", + "사용자" + id, + "닉네임" + id, + Gender.MALE, + "https://img" + id + )) + .toList(); + List> receivedBatches = new ArrayList<>(); + + given(memberRepository.searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable)) + .willReturn(new PageImpl<>(members, pageable, userCount)); + given(itemAdminClient.getInventoryCounts(anyList())).willAnswer(invocation -> { + List memberIds = List.copyOf(invocation.getArgument(0)); + receivedBatches.add(memberIds); + return memberIds.stream().collect(java.util.stream.Collectors.toMap( + id -> id, + id -> new AdminInventoryCounts(id, id + 1) + )); + }); + + PagingResponse result = adminMemberService.getUsers(null, pageable); + + then(itemAdminClient).should(times(expectedCalls)).getInventoryCounts(anyList()); + assertThat(receivedBatches).extracting(List::size).containsExactlyElementsOf(expectedBatchSizes); + assertThat(receivedBatches).allSatisfy(batch -> assertThat(batch).hasSizeLessThanOrEqualTo(100)); + assertThat(receivedBatches).flatExtracting(batch -> batch) + .containsExactlyElementsOf(LongStream.rangeClosed(1, userCount).boxed().toList()); + assertThat(result.content()).hasSize(userCount).allSatisfy(summary -> { + assertThat(summary.matchingTicketCount()).isEqualTo(summary.id()); + assertThat(summary.optionTicketCount()).isEqualTo(summary.id() + 1); + }); + } + private static Member createMemberWithProfile( Long id, String email, diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java index 01c5a56..84399bd 100644 --- a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java +++ b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java @@ -1,6 +1,7 @@ package com.comatching.user.infra.controller; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -57,8 +58,10 @@ class AdminMemberControllerTest { @BeforeEach void setUp() { + PageableHandlerMethodArgumentResolver pageableResolver = new PageableHandlerMethodArgumentResolver(); + pageableResolver.setMaxPageSize(10_000); mockMvc = MockMvcBuilders.standaloneSetup(adminMemberController) - .setCustomArgumentResolvers(new MemberInfoArgumentResolver(), new PageableHandlerMethodArgumentResolver()) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver(), pageableResolver) .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) .build(); } @@ -91,6 +94,39 @@ void getUsers_success() throws Exception { then(adminMemberService).should().getUsers(eq(null), any(Pageable.class)); } + @Test + @DisplayName("GET /api/v1/admin/users?size=10000 - 통계 요청의 페이지 크기와 정렬을 그대로 전달한다") + void getUsers_statsRequestWithMaxPageSize() throws Exception { + // given + PagingResponse response = + new PagingResponse<>(List.of(), 0, 10_000, 250, 1, false, false); + given(adminMemberService.getUsers(eq(null), any(Pageable.class))).willReturn(response); + + // when & then + mockMvc.perform(get("/api/v1/admin/users") + .param("page", "0") + .param("size", "10000") + .param("sort", "id,desc") + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(SUCCESS_CODE)) + .andExpect(jsonPath("$.data.content.length()").value(0)) + .andExpect(jsonPath("$.data.currentPage").value(0)) + .andExpect(jsonPath("$.data.size").value(10_000)) + .andExpect(jsonPath("$.data.totalElements").value(250)) + .andExpect(jsonPath("$.data.totalPages").value(1)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.hasPrevious").value(false)); + + then(adminMemberService).should().getUsers(eq(null), argThat(pageable -> + pageable.getPageNumber() == 0 + && pageable.getPageSize() == 10_000 + && pageable.getSort().getOrderFor("id") != null + && pageable.getSort().getOrderFor("id").isDescending() + )); + } + @Test @DisplayName("GET /api/v1/admin/users?keyword= - 키워드를 서비스로 그대로 전달한다") void getUsers_withKeyword() throws Exception {