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
2 changes: 1 addition & 1 deletion gateway-service/src/main/resources/application-aws.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion gateway-service/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.comatching.user.domain.admin.user.service;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

Expand Down Expand Up @@ -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;
Expand All @@ -51,14 +53,24 @@ public PagingResponse<AdminUserSummaryResponse> getUsers(String keyword, Pageabl
.map(this::toAdminUserProfileDto);

List<AdminUserProfileDto> users = userPage.getContent();
Map<Long, AdminInventoryCounts> inventoryCountsByMemberId =
users.isEmpty()
? Map.of()
: itemAdminClient.getInventoryCounts(
users.stream()
.map(AdminUserProfileDto::id)
.toList()
);
Map<Long, AdminInventoryCounts> inventoryCountsByMemberId;
if (users.isEmpty()) {
inventoryCountsByMemberId = Map.of();
} else {
List<Long> 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<AdminUserSummaryResponse> summaries = users.stream()
.map(user -> AdminUserSummaryResponse.from(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public ResponseEntity<ApiResponse<Void>> deleteNotice(

@RequireRole({MemberRole.ROLE_USER, MemberRole.ROLE_ADMIN})
@Operation(summary = "활성 공지사항 조회", description = "현재 시각 기준으로 노출 기간에 포함된 공지사항 목록을 조회합니다.")
@GetMapping("/notices/active")
@GetMapping("/v1/notices/active")
public ResponseEntity<ApiResponse<List<ActiveNoticeResponse>>> getActiveNotices(
@CurrentMember MemberInfo memberInfo
Comment on lines 72 to 76

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

getActiveNotices 의 매핑을 /notices/active/v1/notices/active 로 바꾸면서 이전 경로에 대한 alias 가 전혀 없습니다. 클래스 레벨이 @RequestMapping("/api") 이므로 실제 경로는 /api/notices/active/api/v1/notices/active 로 바뀌는데, 이 PR 은 gateway 설정(application.yml, application-aws.yml)의 Path predicate 에 /api/v1/notices/**추가만 하고 기존 /api/notices/** 는 그대로 남겨뒀습니다. 즉 gateway 는 여전히 구 경로 요청을 user-service 로 넘기지만, user-service 에는 더 이상 이를 처리하는 핸들러가 없어 이미 배포된 클라이언트(앱/웹)가 이 배포 직후 404 를 받게 됩니다.

이 저장소는 서비스 경계를 넘는 변경의 하위 호환성을 특히 신경 쓰는데(CLAUDE.md 참고), 이번 건은 gateway ↔ user-service 배포 순서와 무관하게 클라이언트 배포 시점이 어긋나면 곧바로 깨지는 케이스입니다. 전환 기간 동안은 두 경로를 함께 열어두고, 클라이언트 전환이 끝난 뒤 구 경로와 gateway 의 /api/notices/** predicate 를 함께 제거하는 순서를 권장합니다.

Suggested change
@RequireRole({MemberRole.ROLE_USER, MemberRole.ROLE_ADMIN})
@Operation(summary = "활성 공지사항 조회", description = "현재 시각 기준으로 노출 기간에 포함된 공지사항 목록을 조회합니다.")
@GetMapping("/notices/active")
@GetMapping("/v1/notices/active")
public ResponseEntity<ApiResponse<List<ActiveNoticeResponse>>> getActiveNotices(
@CurrentMember MemberInfo memberInfo
@GetMapping({"/notices/active", "/v1/notices/active"})

해당 코드:

@RequireRole({MemberRole.ROLE_USER, MemberRole.ROLE_ADMIN})
@Operation(summary = "활성 공지사항 조회", description = "현재 시각 기준으로 노출 기간에 포함된 공지사항 목록을 조회합니다.")
@GetMapping("/v1/notices/active")
public ResponseEntity<ApiResponse<List<ActiveNoticeResponse>>> getActiveNotices(
@CurrentMember MemberInfo memberInfo

) {
Expand Down
5 changes: 4 additions & 1 deletion user-service/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ spring:
redis:
host: localhost
port: 6380
web:
pageable:
max-page-size: 10000

kafka:
bootstrap-servers: localhost:9092
Expand Down Expand Up @@ -149,4 +152,4 @@ management:
http.server.requests: true
spring.kafka.listener: true
slo:
http.server.requests: 50ms,100ms,200ms,500ms,1s,2s
http.server.requests: 50ms,100ms,200ms,500ms,1s,2s
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AdminUserSummaryResponse> 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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -295,6 +359,44 @@ private static Request testRequest() {
);
}

private void assertInventoryBatching(int userCount, int expectedCalls, List<Integer> expectedBatchSizes) {
PageRequest pageable = PageRequest.of(0, userCount, Sort.by(Sort.Direction.DESC, "id"));
List<Member> members = LongStream.rangeClosed(1, userCount)
.mapToObj(id -> createMemberWithProfile(
id,
"user" + id + "@test.com",
"사용자" + id,
"닉네임" + id,
Gender.MALE,
"https://img" + id
))
.toList();
List<List<Long>> 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<Long> 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<AdminUserSummaryResponse> 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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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<AdminUserSummaryResponse> 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 {
Expand Down
Loading