이번에 MDC와 Logback을 활용해 Discord Webhook 연동을 구현하면서 두 가지 주요 문제가 발생했다. 이 글을 통해 문제를 해결하기 위해 고민했던 방법들과 최종적인 해결책을 함께 담아보려 한다.
1. Failed to parse multipart servlet request 오류 해결
MDC (Mapped Diagnostic Context)와 Logback을 활용하여 로그 데이터를 Discord 웹훅으로 연동하는 작업을 진행하고 있었다. 로그를 보다 효과적으로 관리하고 문제 발생 시 빠르게 알림을 받을 수 있도록 하는 것이 목적이었다. 완료한 뒤 테스트 과정에서 예상치 못한 문제가 있었다. 이 문제의 원인과 해결 방법을 공유하고자 한다.
Multipart를 사용하는 API를 활용할 경우 다음과 같은 에러가 났다. Filter에서 난 에러 같아서 확인해 봤다.
RequestBodyWrapper 클래스는 모든 요청의 본문을 읽어 로그에 기록하는 역할을 하고 있었다. Discord 웹훅을 통해 중요한 요청의 본문을 실시간으로 전달받기 위해서는 요청의 내용을 읽어야 했기 때문에, 요청 본문을 문자열로 변환하여 로그에 기록하는 것이 필요했다.
특히, RequestBodyWrapper 클래스는 getReader()를 이용해 요청의 본문을 문자열로 처리하고 있었는데, 여기서 문제가 발생했다. 우리 프로젝트에서는 Multipart를 활용한 API가 있는데, Multipart 요청은 일반 텍스트가 아닌 파일 데이터와 텍스트 데이터가 함께 전송되는 바이너리 형태의 구조를 가지고 있다. 따라서 이 API가 실행될 때 이를 일반 텍스트로 읽으려 시도하면서 IOException이 발생했던 것이다. (사진 관련 API에서만 에러가 발생했다.)
Multipart 요청은 파일 업로드를 포함할 수 있는 구조이기 때문에, 일반 텍스트로 변환하려고 BufferedReader로 읽는 것은 적절하지 않다. 특히, 파일 데이터가 포함된 요청을 무리하게 BufferedReader로 읽으려고 하면 예외가 발생할 수밖에 없다.
이를 해결하기 위해, 먼저 요청의 Content-Type을 확인하여 multipart로 시작하는 경우 본문을 읽지 않도록 수정했다. 이를 통해 Multipart 요청에서는 본문을 기록하지 않고, "Multipart 요청, 본문을 기록하지 않음"이라는 메시지를 설정하여 로그에 남기도록 했다. 이렇게 하면 본문을 무리하게 읽으려다 발생하는 IOException을 피할 수 있다.
Multipart를 사용하는 API를 활용할 경우 다음과 같은 에러가 났다. Filter에서 난 에러 같아서 확인해 봤다.
RequestBodyWrapper 클래스는 모든 요청의 본문을 읽어 로그에 기록하는 역할을 하고 있었다. Discord 웹훅을 통해 중요한 요청의 본문을 실시간으로 전달받기 위해서는 요청의 내용을 읽어야 했기 때문에, 요청 본문을 문자열로 변환하여 로그에 기록하는 것이 필요했다.
특히, RequestBodyWrapper 클래스는 getReader()를 이용해 요청의 본문을 문자열로 처리하고 있었는데, 여기서 문제가 발생했다. 우리 프로젝트에서는 Multipart를 활용한 API가 있는데, Multipart 요청은 일반 텍스트가 아닌 파일 데이터와 텍스트 데이터가 함께 전송되는 바이너리 형태의 구조를 가지고 있다. 따라서 이 API가 실행될 때 이를 일반 텍스트로 읽으려 시도하면서 IOException이 발생했던 것이다. (사진 관련 API에서만 에러가 발생했다.)
Multipart 요청은 파일 업로드를 포함할 수 있는 구조이기 때문에, 일반 텍스트로 변환하려고 BufferedReader로 읽는 것은 적절하지 않다. 특히, 파일 데이터가 포함된 요청을 무리하게 BufferedReader로 읽으려고 하면 예외가 발생할 수밖에 없다.
이를 해결하기 위해, 먼저 요청의 Content-Type을 확인하여 multipart로 시작하는 경우 본문을 읽지 않도록 수정했다. 이를 통해 Multipart 요청에서는 본문을 기록하지 않고, "Multipart 요청, 본문을 기록하지 않음"이라는 메시지를 설정하여 로그에 남기도록 했다. 이렇게 하면 본문을 무리하게 읽으려다 발생하는 IOException을 피할 수 있다.
아래는 수정된 코드의 일부이다.
public RequestBodyWrapper(HttpServletRequest request) {
super(request);
if (request.getContentType() != null && request.getContentType().startsWith("multipart/")) {
requestBody = "Multipart 요청, 본문을 기록하지 않음";
return;
}
StringBuilder stringBuilder = new StringBuilder();
try (BufferedReader bufferedReader = request.getReader()) {
char[] charBuffer = new char[128];
int bytesRead;
while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
stringBuilder.append(charBuffer, 0, bytesRead);
}
} catch (IOException e) {
throw new BadRequestException(FailureCode.BAD_REQUEST);
}
requestBody = stringBuilder.toString();
}
이렇게 수정함으로써, Multipart 요청에서 발생하는 IOException 문제를 해결할 수 있었다. Multipart 요청은 일반 텍스트와는 다른 구조를 가지므로, 별도로 처리해 주는 것이 중요하다. 이를 통해 오류 없이 모든 요청의 본문을 적절히 기록할 수 있었으며, 시스템 안정성도 향상되었다.
이번 경험을 통해 배운 점은, 요청을 읽어오는 Filter 처리는 맨 앞단에서 발생되기 때문에 신중하게 생각하고 테스트를 충분히 해봐야 한다는 것이다. 다양한 요청 타입이 있을 수 있으니, 각 상황에 맞는 Filter 처리를 커스텀하여 적용하는 것이 중요하다. 추가로, Multipart 외에도 다른 형태의 요청 본문 구조가 있을 수 있음을 유의해야 한다. 예를 들어, application/octet-stream 타입의 요청은 바이너리 데이터를 포함할 수 있으며, 이 역시 일반 텍스트로 처리하려 할 경우 문제가 발생할 수 있다. 또한, application/json이나 application/xml 타입의 요청도 그 구조에 맞는 적절한 파싱 방법이 필요하다. 이처럼 요청의 Content-Type에 따라 적절한 처리를 해주는 것이 매우 중요하다.
2. 중요 에러 식별을 위한 "/" URL 요청 핸들링 최적화
로깅 시스템을 만들어놓으니 "/" URL로 불특정 다수의 요청이 들어오며, 특히 해외 IP에서 하루에 수십 건 이상 반복적으로 접속하는 사례가 발생했다. 이러한 요청들은 서비스와 무관한 경우였고, 이로 인해 NoResourceFoundException이 발생하면서 디스코드 알림 시스템을 통해 불필요한 에러 알림이 쌓이는 문제가 생겼다. 특히, 중요한 에러와 정상적인 서비스 동작과 무관한 에러가 뒤섞여 실제로 중요한 문제를 식별하기 어려운 상황이 자주 발생했다.
처음에는 "/" URL로 들어오는 불필요한 요청을 해결할 겸, 동시에 해외 IP를 통한 잠재적인 공격까지 차단하기 위해 nginx 레벨에서 GeoIP를 활용한 해외 IP 차단 방식을 적용했다. GeoIP 데이터베이스를 활용해 해외에서 들어오는 모든 요청을 차단함으로써 불필요한 요청과 보안 위협을 한꺼번에 해결하려는 의도였다.
GeoIP는 IP 주소를 기반으로 사용자의 지리적 위치(국가, 지역, 도시 등)를 식별하는 기술이다. nginx는 이러한 GeoIP 정보를 활용하여 특정 지역에서 들어오는 요청을 허용하거나 차단할 수 있다. nginx 설정에서 GeoIP 데이터베이스를 다운로드한 후, IP 주소와 국가 정보를 매핑하여 특정 국가의 요청을 차단하는 규칙을 추가했다. IP 주소가 요청 시 GeoIP 데이터베이스와 비교되어, 차단된 국가의 요청일 경우 자동으로 접속을 차단하는 원리다.
우리 애플리케이션은 데이트 코스를 추천해 주는 서비스로, 현재 우리나라의 수도권 지역에 한정된 서비스를 제공하고 있었다. 이러한 특성상 해외 IP는 대부분 정상적인 요청이 아니라고 판단하여, 해외 IP를 완전히 차단해도 무방하다고 생각했다.
하지만 GeoIP를 활용한 차단에는 몇 가지 단점이 있었다. GeoIP 데이터는 정기적으로 업데이트해야 하며, 업데이트를 놓치면 더 많은 정상 요청이 차단될 위험이 있었다. 하지만 급한 상황에서는 이 방법이 가장 현실적인 해결책이라 판단했고, 주기적으로 데이터를 업데이트하면 문제가 없을 것이라 생각해 우선적으로 설정을 적용했다. (적용 방법이 궁금하다면 https://velog.io/@c1typ0p/Nginx-GeoIP-%ED%95%B4%EC%99%B8-IP-%EC%B0%A8%EB%8B%A8%ED%95%98%EA%B8%B0 를 참고하면 좋을 것 같다.)
그러나 설정 후, 원인을 알 수 없는 문제가 발생했다. 일부 사용자, 특히 특정 휴대폰에서 배포된 애플리케이션에 접근할 수 없다는 피드백이 들어왔고, 아무리 확인해 봐도 이 문제가 GeoIP 설정 이후 발생한 것으로 보였다. 이로 인해 GeoIP 데이터베이스의 정확도가 충분하지 않다는 결론을 내렸다.
원인을 명확히 파악할 수는 없었지만, 사용자 경험에 영향을 주는 것을 방지하기 위해 우선적으로 nginx의 GeoIP 설정을 해제했다.
nginx 설정을 해제한 이후에도 / URL로 해외에서 들어오는 불필요한 요청은 계속 발생했다. 이는 또다시 NoResourceFoundException을 유발하며 디스코드 알림 시스템에 에러가 쌓이는 문제가 생겼다. 중요한 에러와 불필요한 에러가 뒤섞이며 에러 로깅 시스템이 제 기능을 하지 못하게 되었다. (한동안 에러로깅 디스코드 알람을 꺼놓기도 했었다..ㅎㅎ)
하지만 언제까지 방치할 수만은 없기 때문에 다시 문제를 해결하기 위해 다양한 방법을 검토했다.
- nginx 레벨에서 특정 경로(/ URL)만 차단
nginx 설정에서 / 경로만 별도로 차단하도록 규칙을 추가하는 방식. - 애플리케이션 레벨에서 요청 필터링
User-Agent나 특정 헤더를 기준으로 / 요청을 무시하는 로직을 추가.
크게 생각한 것은 "이 요청 차단 문제를 웹 서버(Nginx)에서 처리할지, 애플리케이션(Spring Security) 레벨에서 처리할지"였다.
Nginx에서 처리하면 요청을 애플리케이션에 도달하기 전에 차단할 수 있어 성능 최적화와 보안 강화라는 장점이 있지만, 정적 규칙 기반이라 유연성이 부족하고, 잘못 처리되었을 때 로깅의 한계로 인해 원인 분석이 어렵다는 단점이 있을 것이고,
반면, Application(Spring Security)에서 처리하면 요청 정보를 로깅하고, 동적 조건에 따라 유연하게 요청을 필터링할 수 있다는 장점이 있지만, 애플리케이션 부하가 증가하고 응답 속도가 느려질 수 있다는 단점이 있을 것이라 생각했다.
이미 한 번 Nginx를 통한 처리를 시도했었고, 잘못 처리했을 때 로깅이 제한적이라 문제 원인을 명확히 파악하기 어려웠기 때문에 이번에는 좀 더 안정적이고 유연한 처리 방식을 선택하기 위해 Application 레벨에서의 처리를 선택하게 되었다.
기존 Security Config에서 .antMatchers("/").denyAll()를 통해 특정 경로에 대한 요청을 차단하는 방식으로 진행하려 했다. 하지만 생각보다 간단한 해결책이 있었다. 기존엔 Spring Security의 whitelist에서 "/" URL경로가 있었는데 이를 제외하는 것이다.
이 설정을 통해 / URL로 요청이 들어오면 Spring Security의 기본 인증 실패 처리 흐름에 따라 JwtAuthenticationEntryPoint에서 401 Unauthorized 응답을 반환하도록 변경했다.
이 방식으로 "/" 요청이 더 이상 디스코드 알림 시스템으로 전송되지 않게 되었고, 중요한 에러를 효과적으로 식별할 수 있는 환경을 마련할 수 있었다. 결과적으로 nginx 레벨에서 GeoIP를 사용한 차단 대신 Spring Security를 활용한 간단한 설정 변경으로 문제를 해결할 수 있었다.
'Spring' 카테고리의 다른 글
[Spring/DB] 실제 운영 서버에서 ddl-auto update의 문제점과 Flyway 도입 (ddl-auto, Flyway) (0) | 2025.02.28 |
---|---|
[Spring/Test] 분산락을 통한 동시성 제어 - Troubleshooting (3) | 2024.12.08 |
[Spring] MDC + Logback을 활용한 Discord Webhook 연동하기(1) (0) | 2024.11.19 |
[Spring] JPA 지연 로딩으로 인한 equals 비교 오류 해결 - Troubleshooting (0) | 2024.10.04 |