From 3fe190a07ed96b8718e7c8f3898b91af51cffed2 Mon Sep 17 00:00:00 2001 From: whqtker Date: Tue, 11 Aug 2026 14:28:24 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20page=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=EA=B0=80=201=20=EB=AF=B8=EB=A7=8C=EC=9D=B4=EB=A9=B4?= =?UTF-8?q?=20400=EC=9C=BC=EB=A1=9C=20=EA=B1=B0=EB=B6=80=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /admin/host-universities 등에서 page=0/1이 모두 내부 오프셋 0으로 클램프되어 첫 페이지가 중복 조회되고 마지막 페이지가 영구 누락되는 문제(#829)의 근본 원인은 fetch join이 아니라, CustomPageableHandlerMethod ArgumentResolver의 1-indexed 계약을 0-indexed로 오사용한 클라이언트였다. page<1 요청을 조용히 기본값으로 클램프하는 대신 400으로 명시적으로 거부해 향후 동일한 오사용이 재발해도 즉시 드러나도록 한다. ingest_universities.py의 임시 keyword 우회 방식도 1-indexed 벌크 fetch로 복원. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WTCqRi8V2TJ1ZqHpfiQ1vy --- .../scripts/ingest_universities.py | 20 +++++++++-- .../common/exception/ErrorCode.java | 1 + ...PageableHandlerMethodArgumentResolver.java | 35 +++++++++++++++++++ ...ableHandlerMethodArgumentResolverTest.java | 26 ++++++++++++-- 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/.claude/skills/load-universities/scripts/ingest_universities.py b/.claude/skills/load-universities/scripts/ingest_universities.py index 760124a86..d3a579c99 100644 --- a/.claude/skills/load-universities/scripts/ingest_universities.py +++ b/.claude/skills/load-universities/scripts/ingest_universities.py @@ -375,18 +375,27 @@ def fetch_all_home_universities(api: ApiClient) -> dict[str, dict[str, Any]]: def fetch_all_host_universities(api: ApiClient) -> dict[str, dict[str, Any]]: + # GET /admin/host-universities uses a 1-indexed page contract (page=1 is + # the first page), unlike Spring Data's usual 0-indexed default. size is + # capped server-side at 50 regardless of what's requested here. by_name: dict[str, dict[str, Any]] = {} - page = 0 + page = 1 while True: - response = api.request_json("GET", "/admin/host-universities", query={"page": page, "size": 100}) + response = api.request_json("GET", "/admin/host-universities", query={"page": page, "size": 50}) for item in response.get("content", []): for name_key in ("koreanName", "englishName", "formatName"): name = item.get(name_key) if name: by_name[name] = item + # parse_rows() always strips field values via clean(), so a DB + # record whose name has stray whitespace (a data-entry artifact) + # would otherwise never match row.host_korean_name/english_name. + stripped = name.strip() + if stripped and stripped != name: + by_name[stripped] = item total_pages = int(response.get("totalPages", 0)) page += 1 - if page >= total_pages: + if page > total_pages: break return by_name @@ -617,6 +626,11 @@ def verify_row(api: ApiClient, row: ParsedRow, apply_info_id: int, term_id: int, actual = fetched.get(key) if key == "languageRequirements": actual = sorted(actual or [], key=lambda lr: (lr.get("languageTestType"), lr.get("minScore"))) + if key == "koreanName" and isinstance(actual, str) and actual.strip() == expected_value: + # parse_rows() always strips field values via clean(), so a DB record whose + # name has stray whitespace (a data-entry artifact) would otherwise always + # mismatch against the stripped row.host_korean_name. + continue if actual != expected_value: mismatches.append({"field": key, "expected": expected_value, "actual": actual}) if mismatches: diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 75180f9b0..1e614bdb2 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -192,6 +192,7 @@ public enum ErrorCode { INVALID_MARKDOWN_FORMAT(HttpStatus.BAD_REQUEST.value(), "올바른 마크다운 표 형식이 아닙니다."), // general + INVALID_PAGE_PARAMETER(HttpStatus.BAD_REQUEST.value(), "유효하지 않은 페이지 번호입니다. page는 1 이상의 정수여야 합니다."), JSON_PARSING_FAILED(HttpStatus.BAD_REQUEST.value(), "JSON 파싱을 할 수 없습니다."), JWT_EXCEPTION(HttpStatus.BAD_REQUEST.value(), "JWT 토큰을 처리할 수 없습니다."), INVALID_INPUT(HttpStatus.BAD_REQUEST.value(), "값을 입력할 수 없습니다."), diff --git a/src/main/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolver.java b/src/main/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolver.java index ecb8bc75b..ef8bf589b 100644 --- a/src/main/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolver.java +++ b/src/main/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolver.java @@ -1,8 +1,16 @@ package com.example.solidconnection.common.resolver; +import static com.example.solidconnection.common.exception.ErrorCode.INVALID_PAGE_PARAMETER; + +import com.example.solidconnection.common.exception.CustomException; +import org.springframework.core.MethodParameter; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; @Component public class CustomPageableHandlerMethodArgumentResolver extends PageableHandlerMethodArgumentResolver { @@ -10,10 +18,37 @@ public class CustomPageableHandlerMethodArgumentResolver extends PageableHandler private static final int DEFAULT_PAGE = 0; private static final int MAX_SIZE = 50; private static final int DEFAULT_SIZE = 10; + private static final int MIN_ONE_INDEXED_PAGE = 1; public CustomPageableHandlerMethodArgumentResolver() { setMaxPageSize(MAX_SIZE); setOneIndexedParameters(true); setFallbackPageable(PageRequest.of(DEFAULT_PAGE, DEFAULT_SIZE)); } + + @Override + public Pageable resolveArgument( + MethodParameter methodParameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + validatePageParameter(methodParameter, webRequest); + return super.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory); + } + + private void validatePageParameter(MethodParameter methodParameter, NativeWebRequest webRequest) { + String parameterName = getParameterNameToUse(getPageParameterName(), methodParameter); + String pageParameter = webRequest.getParameter(parameterName); + if (pageParameter == null || pageParameter.isBlank()) { + return; + } + try { + if (Integer.parseInt(pageParameter) < MIN_ONE_INDEXED_PAGE) { + throw new CustomException(INVALID_PAGE_PARAMETER); + } + } catch (NumberFormatException e) { + // 숫자로 파싱할 수 없는 값은 기존과 동일하게 상위 리졸버가 기본값으로 대체한다. + } + } } diff --git a/src/test/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolverTest.java b/src/test/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolverTest.java index 9df860078..47d7ceb52 100644 --- a/src/test/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolverTest.java +++ b/src/test/java/com/example/solidconnection/common/resolver/CustomPageableHandlerMethodArgumentResolverTest.java @@ -1,7 +1,10 @@ package com.example.solidconnection.common.resolver; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.support.TestContainerSpringBootTest; import java.lang.reflect.Method; import java.util.stream.Stream; @@ -39,12 +42,17 @@ static Stream provideInvalidParameters() { return Stream.of( Arguments.of("null", null), Arguments.of("빈 문자열", ""), - Arguments.of("0", "0"), - Arguments.of("음수", "-1"), Arguments.of("문자열", "invalid") ); } + static Stream provideOutOfRangePageParameters() { + return Stream.of( + Arguments.of("0", "0"), + Arguments.of("음수", "-1") + ); + } + @BeforeEach void setUp() throws NoSuchMethodException { request = new MockHttpServletRequest(); @@ -110,6 +118,20 @@ void setUp() throws NoSuchMethodException { assertThat(pageable.getPageNumber()).isEqualTo(DEFAULT_PAGE); } + @ParameterizedTest(name = "{0}") + @MethodSource("provideOutOfRangePageParameters") + void 페이지_파라미터가_1_미만이면_예외를_던진다(String testName, String pageParam) { + // given + request.setParameter(PAGE_PARAMETER, pageParam); + + // when & then + assertThatThrownBy(() -> customPageableHandlerMethodArgumentResolver + .resolveArgument(parameter, null, webRequest, null)) + .isInstanceOf(CustomException.class) + .extracting(exception -> ((CustomException) exception).getErrorCode()) + .isEqualTo(ErrorCode.INVALID_PAGE_PARAMETER); + } + @ParameterizedTest(name = "{0}") @MethodSource("provideInvalidParameters") void 사이즈_파라미터가_유효하지_않으면_기본_값을_사용한다(String testName, String sizeParam) { From 4d460611cb77665be98dee64d7d02de8160ad837 Mon Sep 17 00:00:00 2001 From: whqtker Date: Tue, 11 Aug 2026 14:40:46 +0900 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WTCqRi8V2TJ1ZqHpfiQ1vy --- .../skills/load-universities/scripts/ingest_universities.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/.claude/skills/load-universities/scripts/ingest_universities.py b/.claude/skills/load-universities/scripts/ingest_universities.py index d3a579c99..b3c11a0ef 100644 --- a/.claude/skills/load-universities/scripts/ingest_universities.py +++ b/.claude/skills/load-universities/scripts/ingest_universities.py @@ -375,9 +375,6 @@ def fetch_all_home_universities(api: ApiClient) -> dict[str, dict[str, Any]]: def fetch_all_host_universities(api: ApiClient) -> dict[str, dict[str, Any]]: - # GET /admin/host-universities uses a 1-indexed page contract (page=1 is - # the first page), unlike Spring Data's usual 0-indexed default. size is - # capped server-side at 50 regardless of what's requested here. by_name: dict[str, dict[str, Any]] = {} page = 1 while True: From 1a6e7054b4f9825f993764a258ca3a117ff467ff Mon Sep 17 00:00:00 2001 From: whqtker Date: Tue, 11 Aug 2026 14:44:09 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EB=82=B4=20=EB=82=98=EB=A8=B8=EC=A7=80=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WTCqRi8V2TJ1ZqHpfiQ1vy --- .../skills/load-universities/scripts/ingest_universities.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.claude/skills/load-universities/scripts/ingest_universities.py b/.claude/skills/load-universities/scripts/ingest_universities.py index b3c11a0ef..21cd95e2a 100644 --- a/.claude/skills/load-universities/scripts/ingest_universities.py +++ b/.claude/skills/load-universities/scripts/ingest_universities.py @@ -384,9 +384,6 @@ def fetch_all_host_universities(api: ApiClient) -> dict[str, dict[str, Any]]: name = item.get(name_key) if name: by_name[name] = item - # parse_rows() always strips field values via clean(), so a DB - # record whose name has stray whitespace (a data-entry artifact) - # would otherwise never match row.host_korean_name/english_name. stripped = name.strip() if stripped and stripped != name: by_name[stripped] = item @@ -624,9 +621,6 @@ def verify_row(api: ApiClient, row: ParsedRow, apply_info_id: int, term_id: int, if key == "languageRequirements": actual = sorted(actual or [], key=lambda lr: (lr.get("languageTestType"), lr.get("minScore"))) if key == "koreanName" and isinstance(actual, str) and actual.strip() == expected_value: - # parse_rows() always strips field values via clean(), so a DB record whose - # name has stray whitespace (a data-entry artifact) would otherwise always - # mismatch against the stripped row.host_korean_name. continue if actual != expected_value: mismatches.append({"field": key, "expected": expected_value, "actual": actual})