Spring MVC Redirect에서 OutOfMemory 문제 해결하기

Spring MVC Redirect에서 OutOfMemory 문제 해결하기

들어가며

운영 환경에서 KMC 본인인증 서비스 운영 중에 OutOfMemory(OOM)이 발생하여 서버가 종료되는 장애가 발생했다. 다행히 무거운 트래픽의 API가 아니고 HA(High Availability)로 구성되어 있어 큰 문제는 없었지만, 즉시 트러블슈팅이 필요했다.

이 문제는 Spring MVC Controller에서 redirect 처리를 할 때 동적으로 생성되는 URL을 부적절하게 캐싱하여 발생하는 잘 알려진 이슈였다.

문제 상황

문제가 된 코드

@RequestMapping("/request")
public String requestForm(@ModelAttribute @Valid AwesomeRequest request) {
  log.info("{}", request);
  String cert = 한국모바일인증클라이언트.encryptRequest(request.get사용자ID());
  return "redirect:" + "어떤 URL" + cert;
}

이 API는 사용자가 KMC(한국모바일인증)에 요청하면 모바일 인증 페이지로 리다이렉트(302 응답코드)를 응답하는 기능이다. 문제는 매번 다른 cert이 생성되면서 고유한 redirect URL이 만들어진다는 점이었다.

원인 분석

Spring Framework의 뷰 해결 메커니즘

Spring Framework의 뷰 해결 메커니즘은 AbstractCachingViewResolver를 여러 뷰 리졸버의 기본 클래스로 사용한다. 이 리졸버는 뷰 이름과 로케일을 기반으로 한 키를 사용하여 HashMap에 뷰를 캐시한다.

public View resolveViewName(String viewName, Locale locale) throws Exception {
	if (!isCache()) {
		return createView(viewName, locale);
	}
	else {
		Object cacheKey = getCacheKey(viewName, locale);
		synchronized (this.viewCache) {
			View view = this.viewCache.get(cacheKey);
			if (view == null && (!this.cacheUnresolved || !this.viewCache.containsKey(cacheKey))) {
				// Ask the subclass to create the View object.
				view = createView(viewName, locale);
				if (view != null || this.cacheUnresolved) {
					this.viewCache.put(cacheKey, view); // <- 메모리 누수 발생!!
					if (logger.isTraceEnabled()) {
						logger.trace("Cached view [" + cacheKey + "]");
					}
				}
			}
			return view;
		}
	}
}

메모리 누수의 핵심

캐시 키(key)를 작성할 때 viewName을 사용하는데, 이 viewName뷰를 요청할 때마다 다르다면 메모리에 축적된다.

실제로는 다음과 같은 과정을 거친다:

  1. Controller에서 return "redirect:" + dynamicUrl 형태로 리턴
  2. View 클래스로 변환 작업 진행
  3. org.springframework.beans.factory.config.BeanPostProcessor 구현체 동작
  4. 그 중 하나인 AnnotationAwareAspectJAutoProxyCreator 클래스가 ConcurrentHashMap<Object, Boolean> 타입 객체에 key: viewName, value: 필요 여부(boolean) 형태로 갯수 제한 없이 저장

힙 덤프 분석 결과

Memory Analyzer Tool (MAT)로 확인한 결과:

MAT Analyzer

Map의 키값들을 확인해보니 RedirectView가 많이 생성되어 있었고, 각각 다른 동적 URL을 가지고 있었다. MAT Analyzer 2

해결 방법

올바른 구현 방식

이 문제는 302 리다이렉트 URL을 요청마다 다르게 보내야 한다면, RedirectView 혹은 ModelAndView 객체를 사용해야 한다.

방법 1: RedirectView 사용

@RequestMapping("/request")
public String requestForm(@ModelAttribute @Valid AwesomeRequest request) {
	log.info("{}", request);
  	String cert = 한국모바일인증클라이언트.encryptRequest(request.get사용자ID());
  	return new RedirectView(String.format(리다이렉트URLTemplate, cert));
}

방법 2: ModelAndView 사용

@RequestMapping("/request")
public ModelAndView requestForm(@ModelAttribute @Valid AwesomeRequest request) {
	log.info("{}", requestVo);
	String cert = 한국모바일인증클라이언트.encryptRequest(request.get사용자ID());
	ModelAndView modelAndView = new ModelAndView();
	RedirectView redirectView = new RedirectView();
	redirectView.setUrl(String.format(리다이렉트URLTemplate, cert));
	modelAndView.setView(redirectView);
	
	return modelAndView;

성능 개선 결과

개선 후 동일한 환경에서 테스트한 결과:

기존 방식 (문제 있는 코드)

개선 방식 (RedirectView/ModelAndView 사용)

추가 고려사항

언제 이 문제가 발생하는가?

실제 운영 환경에서의 위험성

// 위험한 코드 예시
@GetMapping("/link/{key}")
public String redirectLinkPage(@PathVariable String key) {
  	String dynamicUrl = generateDynamicUrl(key); // 매번 다른 URL 생성
	return "redirect:" + dynamicUrl; // <- 메모리 누수 위험!
}

이런 코드가 대량의 트래픽을 받으면:

  1. 순간적으로 많은 고유 URL이 생성됨
  2. 각 URL이 캐시에 저장됨
  3. 캐시 크기 제한이 없어 메모리 사용량 급증
  4. OutOfMemory 발생으로 서비스 장애

모니터링 포인트

운영 환경에서는 다음을 모니터링해야 한다:

결론

Spring MVC에서 동적 URL로 redirect할 때는 반드시 RedirectView 또는 ModelAndView를 사용해야 한다. 단순해 보이는 "redirect:" + dynamicUrl 방식은 메모리 누수를 일으켜 심각한 서비스 장애로 이어질 수 있다[4][5].

특히 다음과 같은 상황에서는 더욱 주의해야 한다:

이 문제는 Spring Framework의 잘 알려진 이슈이지만, 여전히 많은 개발자들이 놓치기 쉬운 부분이다. 코드 리뷰와 성능 테스트를 통해 이런 잠재적 위험을 미리 발견하고 대응하는 것이 중요하다.

참고 자료