최근 황금연휴가 시작되고 난 이후, 갑자기 심한 번아웃이 찾아왔다. SSAFY 다닐 때도 번아웃이 온 적이 있었지만, 이 때는 동기들과 같이 카공하면서 잘 넘길 수 있었다. 하지만 지금은 같이 카공 갈 동기들도 없고, 연휴도 회사에서 2일 전에 강제로 휴가 쓰라고 지침이 내려와서 생긴 거라 더욱 대처할 방법을 찾기 힘들었다.
그래서 무기력하게 침대에 누워서 나의 핸드폰과 함께 우울증을 간접 체험하던 도중 이 짤을 다시 보게 되었다.
그래서 생각하지 말고 그냥 개발이나 하기로 했고, 그 결과 오류 하나를 잡을 수 있었다!!!!
덕분에 번아웃도 조금은 탈출..
이번엔 그 해결과정을 공유하고자 이 글을 쓴다.
먼저 나는 프로젝트 성능 개선의 일환으로 API 응답 캐싱을 적용하기로 했다. 각 API에 알맞는 캐시 만료 시간, 크기 기반 제거 등
설정을 하기 위해선 먼저 성능을 수치화해야겠다고 생각했고, 그 효과를 측정하기 위해 k6 부하 테스트 도구를 사용하기로 했다. 이것에 대한 구현 방안으로 캐시 히트율을 정확히 파악하려면 서버가 응답 헤더에 캐시 상태(HIT/MISS/BYPASS/SKIP)를 알려주는 커스텀 헤더, 예를 들어 X-Cache-Status
를 추가해주면 좋겠다고 생각했다.
Spring Boot 환경에서 Caffeine 캐시를 사용하기로 하고, 캐시 히트/미스 여부를 추적하기 위해 CacheResolver
와 CacheOperationListeners
를 커스텀하여 캐시 상태를 ThreadLocal
변수에 저장하는 로직까지는 구현했다.
// CacheStatusInterceptor.java (초기 시도 - afterCompletion)
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class CacheStatusInterceptor implements HandlerInterceptor {
// 요청 스레드별 캐시 상태 저장을 위한 ThreadLocal
private static final ThreadLocal<Boolean> cacheHitThreadLocal = new ThreadLocal<>();
// --- preHandle, postHandle 등 다른 메서드 생략 ---
/**
* 요청 처리가 완료된 후 (뷰 렌더링 후) 호출
* 초기 시도: 여기서 헤더를 설정
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
try {
// 현재 스레드의 캐시 상태(Hit/Miss) 가져오기
Boolean isHit = cacheHitThreadLocal.get();
// 캐시 상태 문자열 결정 (예: "HIT", "MISS")
String cacheStatus = determineCacheStatus(request, isHit);
// 문제 지점: 여기서 헤더를 설정해도 이미 응답이 커밋되었을 수 있음
response.setHeader("X-Cache-Status", cacheStatus);
log.debug("최종 캐시 상태 헤더 설정 시도 (afterCompletion): {}={}", "X-Cache-Status", cacheStatus);
} finally {
// ThreadLocal 값 정리 (메모리 누수 방지)
cacheHitThreadLocal.remove();
}
}
// 예시: 캐시 상태 문자열 결정 로직 (실제 구현 필요)
private String determineCacheStatus(HttpServletRequest request, Boolean isHit) {
// 요청 경로, isHit 값 등을 기반으로 HIT/MISS/BYPASS/SKIP 결정
if (isHit == null) {
// 캐시 대상이 아니거나, 캐시 상태를 알 수 없는 경우
return "SKIP"; // 또는 "BYPASS" 등 상황에 맞게
}
return isHit ? "HIT" : "MISS";
}
// 서비스 로직 등 다른 곳에서 캐시 상태를 설정하기 위한 static 메서드
public static void setCacheHit(boolean hit) {
cacheHitThreadLocal.set(hit);
}
// ResponseBodyAdvice 등 다른 컴포넌트에서 캐시 상태를 읽기 위한 static 메서드
public static Boolean getCurrentCacheHitStatus() {
return cacheHitThreadLocal.get();
}
// --- 기타 필요한 메서드 (isCacheablePath 등) 생략 ---
}
문제는 이 ThreadLocal
값을 읽어 실제 응답 헤더에 X-Cache-Status
를 추가하는 부분이었다. 처음에는 당연히 HandlerInterceptor
의 afterCompletion
메서드가 가장 적절하다고 생각했다. 요청 처리가 모두 완료된 후 호출되니, 최종 캐시 상태를 헤더에 담아 보내면 될 것이라고 예상했다.
Spring MVC 설정 (WebMvcConfigurer
)에 이 인터셉터를 등록하고 k6 테스트를 실행했다. 하지만 k6 로그에는 X-Cache-Status
헤더가 전혀 보이지 않았다. 서버 로그에는 분명히 afterCompletion
에서 헤더를 설정했다는 로그가 찍히는데도 말이다.
Jmeter와 Locust 보다 훨씬 적은 리소스로 많은 요청을 테스트 할 수 있기 때문에 선택하기로 했다.
보통 로컬 캐시의 기본은 ehcache
를 많이 생각하지만, 캐시 관련해서 공부하다 보니 Caffeine Cache가 Local Cache의 King이라고 할 정도의 성능 제거 알고리즘
을 보여준다고 해서 도입해보게 되었다.
처음에는 내가 뭔가 간단한 실수를 했으리라 생각했다.
SecurityConfig
를 열어 CORS 설정의 setExposedHeaders()
에 X-Cache-Status
를 명시적으로 추가했다. 그래도 헤더는 보이지 않았다. 혹시 Security 필터 체인이 헤더를 기본값으로 덮어쓰나 싶어 .headers().cacheControl().disable()
도 추가해봤지만, 여전히 실패.@Order
)를 조정해봤지만 소용없었다.CacheStatusInterceptor
구현 재검토: ThreadLocal
관리는 제대로 되는지, setCacheHit
는 제대로 호출되는지 로그를 찍어가며 확인했다. 인터셉터 자체의 로직에는 문제가 없어 보였다.postHandle
이라면 괜찮을까?내가 아는 지식선에서 계속 트라이 했지만.. 답이 보이지가 않았다.
그래서 http의 요청 흐름을 더 자세히 공부하고 다시 도전해보기로 했다.
(위 이미지를 클릭하면 Mermaid Live Editor에서 다이어그램을 확대해서 볼 수 있다.)
공부 한 이후에 다시 보니, 혹시 afterCompletion
메서드 시점은 너무 늦은 건 아닐까 하는 생각이 들었다. afterCompletion
은 뷰 렌더링까지 끝난 후에 호출되니, 이미 응답이 클라이언트로 전송되기 시작한 후일 수도 있겠다고 짐작했다.
그래서 헤더 설정 로직을 postHandle
메서드로 옮겨봤다. postHandle
은 컨트롤러 실행 직후, 뷰 렌더링(또는 @ResponseBody
처리) 전에 호출되니, 이때는 헤더 설정이 가능할 것이라고 기대했다.
// CacheStatusInterceptor.java (두 번째 시도 - postHandle)
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class CacheStatusInterceptor implements HandlerInterceptor {
private static final ThreadLocal<Boolean> cacheHitThreadLocal = new ThreadLocal<>();
// --- preHandle 등 다른 메서드 생략 ---
/**
* 컨트롤러 실행 직후, 뷰 렌더링 또는 @ResponseBody 처리 전에 호출
* 두 번째 시도: 여기서 헤더를 설정
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 응답이 커밋(전송 시작)되었는지 확인
if (response.isCommitted()) {
log.warn("응답이 이미 커밋되어 헤더를 설정할 수 없습니다: {}", request.getRequestURI());
return; // 커밋되었으면 헤더 설정 불가
}
try {
Boolean isHit = cacheHitThreadLocal.get();
String cacheStatus = determineCacheStatus(request, isHit);
// 문제 지점: 여기서도 응답 본문 크기 등에 따라 커밋될 수 있음
response.setHeader("X-Cache-Status", cacheStatus);
log.debug("캐시 상태 헤더 설정 시도 (postHandle): {}={}", "X-Cache-Status", cacheStatus);
} catch (Exception e) {
log.error("postHandle에서 헤더 설정 중 오류 발생", e);
}
// ThreadLocal 정리는 afterCompletion에서 수행 (아래 코드 참고)
}
/**
* 요청 처리 완료 후 호출. (성공/예외 무관)
* postHandle에서 헤더 설정을 시도하므로, 여기서는 ThreadLocal 정리만 수행
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
cleanUpThreadLocals(); // ThreadLocal 정리 메서드 호출
}
// ThreadLocal 정리 메서드
private void cleanUpThreadLocals() {
cacheHitThreadLocal.remove();
log.trace("ThreadLocal cleanup executed."); // 필요시 로깅
}
// --- determineCacheStatus, setCacheHit, getCurrentCacheHitStatus 등 생략 ---
}
다시 k6 테스트를 실행했다. 이번에는 서버 로그에 충격적인 경고 메시지가 나타났다.
WARN [...] c.q.c.c.i.CacheStatusInterceptor : 응답이 이미 커밋되어 캐시 상태 헤더를 설정할 수 없습니다: /api/quizzes/8
postHandle 시점조차도 이미 늦었다는 의미였다. 컨트롤러가 반환한 데이터를 HttpMessageConverter
(JSON 변환 등)가 처리하여 응답 버퍼에 쓰는 과정에서 버퍼가 가득 차거나 다른 이유로 인해 이미 응답이 커밋되어 버린 것이다.
여기서 '응답 커밋'이라는 개념에 대해 깊이 파고들게 되었다.
response.flushBuffer()
를 명시적으로 호출했을 때@ResponseBody
를 사용하는 내 API의 경우, 컨트롤러 메서드가 결과를 반환하면 Spring의 HttpMessageConverter
가 이를 JSON 문자열로 변환하여 응답 버퍼에 쓴다. 이 과정에서 데이터 양이 많으면 버퍼가 가득 차서 postHandle
이 실행되기도 전에 응답이 커밋될 수 있었던 것이다.
afterCompletion
은 당연히 너무 늦고, postHandle
조차도 응답 커밋 이후일 수 있다는 사실을 깨달았다. 그렇다면 헤더를 추가하기에 가장 확실하고 안전한 시점은 언제일까?
ResponseBodyAdvice
의 등장해답은 ResponseBodyAdvice
인터페이스에 있었다.
ResponseBodyAdvice
란? 컨트롤러가 반환한 객체를 HttpMessageConverter
가 응답 본문으로 변환하기 직전에 호출되는 컴포넌트다.beforeBodyWrite
메서드: 이 메서드는 HttpMessageConverter
가 변환된 데이터를 응답 버퍼에 쓰기 바로 전에 실행된다. 즉, 응답 버퍼가 채워지기 전, 응답이 커밋되기 전에 헤더를 조작할 수 있는 이상적인 시점이다.나는 CacheStatusResponseBodyAdvice
클래스를 만들고 @ControllerAdvice
어노테이션을 붙여 이 로직을 구현했다.
// CacheStatusResponseBodyAdvice.java (최종 해결책)
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@ControllerAdvice // 모든 @Controller 에 적용되는 AOP 컴포넌트
public class CacheStatusResponseBodyAdvice implements ResponseBodyAdvice<Object> {
/**
* 어떤 응답에 이 Advice를 적용할지 결정
* 여기서는 모든 응답에 적용하도록 true를 반환
* 특정 어노테이션이나 컨트롤러 타입에만 적용하도록 조건을 추가
*/
@Override
public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
return true; // 모든 응답 본문 쓰기 전에 실행
}
/**
* HttpMessageConverter가 응답 본문을 쓰기 직전에 호출
* 여기가 헤더를 추가하기 가장 안전한 시점
*/
@Override
public Object beforeBodyWrite(Object body, // 컨트롤러가 반환한 원본 응답 본문
MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, // HttpServletRequest 래퍼
ServerHttpResponse response) { // HttpServletResponse 래퍼
// ServerHttpResponse를 ServletServerHttpResponse로 캐스팅하여 Servlet API 접근
if (response instanceof ServletServerHttpResponse servletResponse) {
// 만약을 위해 여기서도 커밋 여부 확인 (거의 발생하지 않음)
if (servletResponse.getServletResponse().isCommitted()) {
log.warn("ResponseBodyAdvice: 응답이 이미 커밋됨: {}", request.getURI());
return body; // 이미 커밋되었으면 아무것도 하지 않음
}
try {
// CacheStatusInterceptor의 static 메서드를 이용해 ThreadLocal 값 가져오기
Boolean isHit = CacheStatusInterceptor.getCurrentCacheHitStatus();
// 실제 determineCacheStatus 구현 필요 (Interceptor와 동일 로직 사용 가능)
String cacheStatus = determineCacheStatus(request.getURI().getPath(), isHit);
// ✨ 헤더 추가! ✨
response.getHeaders().add("X-Cache-Status", cacheStatus);
log.debug("ResponseBodyAdvice: 캐시 상태 헤더 설정 완료: {}={}", "X-Cache-Status", cacheStatus);
} catch (Exception e) {
log.error("ResponseBodyAdvice에서 헤더 설정 중 오류 발생", e);
}
} else {
log.warn("Unexpected ServerHttpResponse type: {}", response.getClass().getName());
}
// 원본 응답 본문(body)을 그대로 반환 (본문 수정이 필요하면 여기서 수정 후 반환)
return body;
}
// 예시: 캐시 상태 문자열 결정 로직 (Interceptor의 것과 동일하게 사용 가능)
private String determineCacheStatus(String path, Boolean isHit) {
// 경로, isHit 값 등을 기반으로 HIT/MISS/BYPASS/SKIP 결정
// CacheStatusInterceptor의 static 메서드를 호출하거나 동일 로직 구현
if (isHit == null) {
return "SKIP";
}
return isHit ? "HIT" : "MISS";
}
}
그리고 기존 CacheStatusInterceptor
에서는 헤더 설정 로직을 완전히 제거하고, ThreadLocal
값 설정(setCacheHit
) 및 정리(afterCompletion
), 그리고 ResponseBodyAdvice
에서 호출할 getCurrentCacheHitStatus
static 메서드 제공 역할만 하도록 수정했다.
// CacheStatusInterceptor.java (수정 후 - 헤더 설정 로직 제거)
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Component
public class CacheStatusInterceptor implements HandlerInterceptor {
private static final ThreadLocal<Boolean> cacheHitThreadLocal = new ThreadLocal<>();
// ... (PATH_CACHE_MAPPING 등 필요한 다른 변수)
/**
* 요청 처리 전에 호출됩니다. ThreadLocal 값을 초기화
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
cacheHitThreadLocal.remove(); // 이전 요청 값 제거 (안전을 위해)
// 필요시 여기서 요청 경로 기반으로 캐시 대상 여부 판단 등 초기 작업 수행
return true; // 계속 진행
}
/**
* 컨트롤러 실행 후 호출
* 헤더 설정은 ResponseBodyAdvice에서 하므로 여기서는 특별한 로직 없음.
* 필요시 로깅 등 추가 가능.
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, org.springframework.web.servlet.ModelAndView modelAndView) throws Exception {
// No operation needed here for header setting
}
/**
* 요청 처리 완료 후 호출 (성공/예외 무관)
* ThreadLocal 값을 정리.
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
cacheHitThreadLocal.remove(); // ThreadLocal 정리
log.trace("CacheStatusInterceptor: ThreadLocal cleaned up.");
}
// 서비스 로직 등 다른 곳에서 캐시 상태를 설정하기 위한 static 메서드
public static void setCacheHit(boolean hit) {
cacheHitThreadLocal.set(hit);
log.trace("Cache status set in ThreadLocal: {}", hit);
}
// ResponseBodyAdvice 등 다른 컴포넌트에서 캐시 상태를 읽기 위한 static 메서드
public static Boolean getCurrentCacheHitStatus() {
return cacheHitThreadLocal.get();
}
// --- 기타 필요한 메서드 (isCacheablePath, shouldSkipCache 등) 생략 ---
}
드디어 k6 테스트를 다시 실행했을 때, 응답 헤더에 X-Cache-Status: HIT
또는 X-Cache-Status: MISS
가 선명하게 나타나는 것을 확인할 수 있었다!
이번 경험을 통해 몇 가지 중요한 교훈을 얻었다.
HandlerInterceptor
의 postHandle
, afterCompletion
은 헤더를 추가하기에 안전한 시점이 아닐 수 있다. 특히 @ResponseBody
를 사용하고 응답 본문 크기가 클 가능성이 있다면 더욱 그렇다.ResponseBodyAdvice
는 응답이 커밋되기 전에 헤더나 본문을 수정해야 할 때 매우 유용한 도구이다.단순히 커스텀 헤더 하나 추가하는 작업이라고 생각했는데, 예상치 못한 난관에 부딪히고 깊은 내용까지 파고들게 된 경험이었다. 이 삽질(?)을 하면서 Spring MVC와 HTTP 응답 처리 과정에 대해 더 깊이 이해할 수 있게 되었다. 역시 기본기가 제일 중요하다는 걸 다시 한번 명심하고 초심으로 돌아가야겠다..