본문 바로가기
Develop/Spring+JPA

[Spring] '/', 문자열인가 경로인가 그것이 문제로다

by 연로그 2023. 5. 12.
반응형

🤔 문제의 시작

API를 호출하면 json이 아닌 html 코드로 응답이 오고 있다는 이슈를 제보받았습니다. 해당 html 코드를 열어보니 아래와 같이 HTTP 상태 코드 400을 보여주고 있었습니다. 상태 코드 400은 Bad Request라는 의미로, 클라이언트의 요청이 잘못되어 서버가 요청을 처리할 수 없거나 처리하지 않을 것임을 나타내는 코드입니다.

 

🧐 원인 분석

문제가 발생하는 정확한 케이스를 파악하기 위해 현상 재현을 시도하였습니다. API를 호출해봤더니 애플리케이션에 요청이 들어오지도 않는 것처럼 보였습니다. 400이라는 응답값만 생각해보면 어디선가 예외가 발생했을 것 같은데, 예외가 발생했다는 에러 로그가 남지 않았거든요.

 

어떤 API인지 설명하기 위해 가상의 예제 API를 만들어보았습니다. 아래는 특정 스터디의 정보를 조회하는 API입니다. (일반적으로는 스터디의 고유 id를 통해 조회하겠지만, url에 문자열을 넣기 위해 스터디 이름을 통해 조회한다는 다소 억지스러운 예제를 만들었습니다.)

  • GET /api/studies/{keyword}

 

위 API에서 항상 문제가 발생하는 것은 아니었습니다. 어쩔 때는 성공해서 멀쩡한 데이터를 담은 json을 응답하고, 어쩔 때는 400이라는 HTTP 상태 코드에 대한 html 코드를 응답했습니다. 실패하는 케이스들의 공통점을 살펴보니 {keyword}에 '/'가 포함하고 있었습니다. url에서 '/'는 경로의 의미로 사용됩니다. 그래서 한가지 가설을 세워보았습니다. {keyword}에서 입력받은 '/'를 문자열이 아닌 경로로 인식했기 때문에 API를 찾지 못한게 아닐까? 클라이언트가 존재하지도 않는 API를 호출 시도했다고 인지한걸까?

 

예를 들어 'my/study'라는 이름의 스터디를 조회한다고 가정해봅시다. 클라이언트는 "GET /api/studies/my/study"를 호출합니다.

  클라이언트가 호출했다고 생각한 API 서버가 생각한 API
API 형식 GET /api/studies/{keyword} GET /api/studies/{keyword1}/{keyword2}

원래 의도하고자한 것은 {keyword} 부분에서 'my/study'라는 값을 넣는 것입니다. 하지만 서버에서 요청을 받을 때 '/'를 경로로 인식해버려 'my'와 'study'를 분리해서 인식한게 아닐까 라는 생각이 들었습니다.

 

헌데 한 가지 걸리는 점이 있었습니다. API 호출 시 url을 encode하도록 설정되어 있기 때문에 '/'는 인코딩 문자인 '%2F'로 변환된 상태로 넘어오고 있었습니다. 위 예제라면 서버는 요청을 "GET /api/studies/my%2Fstudy"같은 형식으로 전달받습니다. 인코딩 된 상태인 '%2F'로 넘어오면 경로라는 의미의 '/'가 아닌 문자열 그대로 인식해야 하지 않을까요?

 

 

🤓 Spring의 '/' 처리

아래 글은 Spring Docs - 1.1.6. Path Matching의 일부를 간략화하여 쉬운 말로 옮겨놓았습니다. 주관적인 생각이 포함되어있으며 정확한 의미는 원문을 읽는 것을 추천합니다.

 

Spring은 요청 경로를 읽어들일 때 디코딩 과정을 거쳐야만 합니다. 디코딩 과정을 거쳐야하는 이유는 servlet이 매핑되는 방식에 따라 요청 경로를 contextPath, servletPath, pathInfo 등으로 구분하는 것과 관련이 있습니다. servletPath와 pathInfo는 디코딩된 상태이기 때문에 전체 요청 경로와 직접 비교할 수 없는데요. lookupPath를 만들어내기 위해서는 requestURI를 디코딩하는 과정을 거쳐야만 비교가 가능해집니다. 해당 과정을 회피하기 위해 몇 가지 설정을 바꿀수도 있지만, 다른 문제를 야기할 수 있으므로 항상 제대로 작동한다는 보장은 하지 못한다고 합니다. 

 

위 문제는 AntPathMatcher 대신 PathPatternParser를 이용하면 해결됩니다. PathPattern은 경로 세그먼트 값을 개별적으로 디코딩하고 검사할 수 있기 때문에 가능하다고 합니다. Spring MVC 5.3 버전부터 사용할 수 있고, 6.0 버전부터는 디폴트로 설정되어 있습니다.

 

 

🔻 (번외) @PathVariable은 url에서 어떻게 값을 가져올까요?

더보기
Spring 5.2.8을 기준으로 디버깅한 결과입니다.

 

@PathVariable은 PathVariableMethodArgumentResolver를 이용해 값을 가져옵니다.

 

PathVariableMethodArgumentResolver의 resolveArgument 메서드를 호출하는데 해당 메서드는 AbstractNamedValueMethodArgumentResolver 클래스에 존재합니다. (A~는 P~의 상위 클래스이다.)

 

PathVariableMethodArgumentResolver의 resolveStringValue 메서드

 

request의 getAttribute를 통해 값을 가져오는 것으로 보입니다.

 

저 시점에서는 이미 request에 attribute 값이 세팅되어있었습니다. 그래서 이번에는 {keyword}에 해당하는 값이 setAttribute 메서드를 통해 request의 attribute에 추가되는 시점을 찾아보았습니다. RequestMappingInfoHandlerMapping의 handleMatch를 통해 해당 코드를 발견할 수 있었습니다.

 

AntPathMatcher의 extractUriTemplateVariables 메서드를 통해 uri에 포함된 값들을 추출하고 있었습니다. (Spring 6.0부터는 AntPathMatcher가 아닌 PathPatterParser가 디폴트라는 말이 있는데 사실 확인이 필요합니다.)

 

 

🥳 해결 방법

위 문제를 해결하기 위해서는 url에 /를 넣지 않기 또는 UrlPathHelper를 통해 설정 바꿔주기 중에 선택하면 됩니다. 후자의 경우에는 설정이 모든 API에 영향을 미칠 수 있어서 선택하지 않았기 때문에 예제 코드는 링크로 대체하겠습니다.

 

url에 /를 넣지 않는 방법도 여러가지가 있습니다.

  1. {studyName}에 /가 들어가지 않도록 협의하기
  2. {studyName}을 query parameter로 받아오기
  3. {studyName}을 message body에 담기

1 같이 스터디 이름에 대한 제약사항을 추가하게 된다면 여러 유관 부서와 협의를 봐야하며 기존의 데이터를 수정하고, 기존 API에서 제약사항에 대한 검증 로직을 추가해야하는 등 번거로운 경우가 많이 생깁니다. 따라서 저는 2안으로 선택했습니다.

 

 

🔸 번외 - 협업을 위한 돌아가기

문제 해결 방법은 찾았지만, API의 구조가 달라지는 문제라 변경에 대한 고민이 생겼습니다. 사용하지 않는 API를 남겨두는 것이 찜찜했지만 변경을 하자니 프론트와 작업이 동시에 일어나야만 했습니다. 이때 팀원분이 서버-프론트 간의 배포 간격을 메우기 위해 API를 새로 만들면 좋을 것 같다는 제안을 해주셨습니다. 만약 프론트 배포가 늦어지게 된다면, 운영 서버에서는 계속 수정 전 API를 호출하고 있을 겁니다. 서버가 먼저 배포되어 수정 후 API만 존재하고 있는 경우에 에러가 발생할 수도 있습니다. 반대로 프론트가 더 빨리 배포된다면, 배포 후 수정된 API를 호출했으나 서버 배포가 완료되지 않았기 때문에 에러가 발생할 수 있습니다. 그래서 API를 별개로 만들자는 팀원분의 의견에 동의하게 되었습니다.

 

이후에 프론트와 협의할 때 프론트는 당장 개발 진행이 어려운 상황이라 다음 스프린트에 진행하겠다고 말씀해주셨습니다. API를 별개로 만들어서 진행하기로 했기 때문에 백엔드에서는 프론트가 언제 작업을 해도 전혀 문제가 되지 않았습니다. 마음에 걸렸던, '사용하지 않는 API를 삭제하는 작업'은 프론트 작업이 종료되고 배포까지 완료된 후에 따로 진행하기로 했습니다. 이제 배포 도중에 문제가 발생하지 않으면서도 제가 원하는 결과를 만들어내는 협업 과정이 만들어졌습니다.

 


참고

반응형