문제 발견
JWT Bearer 토큰을 이용하여 인증을 전제하는 기초적인 Get 요청에 대하여 테스트는 성공하는데 Postman을 활용한 실제 응답에서 403 Forbidden 응답이 발생했다.

아래는 import 부분만 제거한 실제 테스트 코드다
@DisplayName("예산 시나리오 테스트")
@Transactional
@AutoConfigureMockMvc
@SpringBootTest
class BudgetControllerTest extends AbstractControllerWithAuthTest {
private static final String BASE_URI = "/api/budgets";
@Autowired
public BudgetControllerTest(MockMvc mockMvc, ObjectMapper mapper) {
super(mockMvc, mapper);
}
@DisplayName("카테고리 조회")
@Nested
class GetCategories {
private static final String URI = BASE_URI + "/categories";
@DisplayName("성공")
@Test
void success() throws Exception {
// given
String accessToken = joinAndLogin(EMAIL, PASSWORD);
String bearerToken = JwtAuthenticationFilter.BEARER_TOKEN_PREFIX + accessToken;
// when
MvcResult result = mockMvc.perform(get(URI)
.header(HttpHeaders.AUTHORIZATION, bearerToken)
.accept(MediaType.APPLICATION_JSON)
)
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$").isArray())
.andReturn();
// then
String responseBody = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
List<String> actualCategories = mapper.readValue(responseBody, new TypeReference<List<String>>() {});
List<String> expectedCategories = Arrays.stream(BudgetCategoryType.values())
.map(BudgetCategoryType::getDesc)
.toList();
assertThat(actualCategories).isEqualTo(expectedCategories);
}
}
}

문제 해결
원인 분석이 아니라 그냥 바로 해결책부터 공개한다. 정상적인 문제해결 과정으로는 원인을 알아내기 어렵고 번거롭기 때문이다.

포스트맨으로는 보이지 않는 특수문자가 삽입되어있었다. 포스트맨에서 budgets 앞에서 백스페이스를 눌러보니 제자리에 있었다.. 아마 특수문자가 삭제된 것 같다. 아스키코드 8번은 찾아보니까 BS인데 백스페이스..? 왠지 모르겠다;;

보이지 않는 특수문자를 지워주고 재요청했더니 성공!
문제 원인 분석
403 Forbidden 발생 지점 탐색
검색해보니 403 Forbidden
은 리소스는 존재하지만 인가되지 않았을 때 발생한다고 한다.

이 지점에서 추적해본다. 확인해본 결과 이때 이미 request URI는 /error
로, 디스패처 타입은 ERROR
로 변경되어있었다.

한 줄씩 실행해보면 별도의 AuthenticationException
은 발생하지 않았기 때문에 136번 조건문에서 진입 후에 securityException
변수가 초기화되고 147번줄로 진행한다.

결과적으로 Http403ForbiddenEntryPoint
에서 응답이 생성되는 것으로 확인되었다.
최초 예외 발생 지점 탐색

인가 필터 인가 검증부터 다시 확인해본다. 그런데 정말 웃기게도 처음은 100번 줄로 바로 넘어간다. 계속 확인해보자.

파고파고 들어가다보면 필터를 무사히 지나서 디스패처서블릿까지 진입한다. 그리고 최초에 404 Not Found
응답이 발생해서 톰캣까지 다시 내려갔다가 톰캣에서 /error
로 재요청해서 AuthorizationFilter
에 걸리고 최종적으로 403 Forbidden
으로 변환된 것으로 보인다.
그런데 여기서 디스패처 서블릿이 획득한 핸들러가 RequestMappingHandlerMapping
이 아닌 ResourceHttpRequestHandler
인 것이 문제 해결의 키포인트인데, 이는 요청 URI에 대응되는 RequestMapping URI가 없기 때문인 것으로 유추할 수 있고, 이 지점에서 요청 URI가 잘못된 것은 아닌지 의심해볼 여지가 생긴다.
끝..