Jvm 타임존을 필터에서 바꾸도록 한다면?

seoyoon jungseoyoon jung
3 min read

개요

내가 개발했던 시스템 중 하나가 서버는 서울에 있지만 글로벌하게 운영할 서비스였다.
그래서 요구사항 중 하나가 접속한 사용자의 지역 시간에 맞춰 보여줘야한다는 내용이었다.

예를 들어:

  • 미국에서 접속한 사용자는 America/New_York 기준으로 날짜를 보여줘야하고,

  • 한국 사용자는 Asia/Seoul 기준으로 데이터를 보여주면 되는 것.

그 당시 일단 계정에 등록된 국가 정보를 기준으로 사용자 타임존을 판단하고, 이 타임존을 기반으로 날짜 데이터를 표시하려 했고

이거를 Spring Filter에서 아래처럼 전역 JVM 타임존(TimeZone.setDefault) 을 변경하도록 해두었다.

@Component
class TimezoneFilter : OncePerRequestFilter() {
    override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, filterChain: FilterChain) {
        val userTimeZone = resolveUserTimeZone(request)
        TimeZone.setDefault(TimeZone.getTimeZone(userTimeZone))
        try {
            filterChain.doFilter(request, response)
        } finally {
            TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")) // 기본 복원
        }
    }
}

2년 정도 지난 시점에.. 리뉴얼 하면서 알게된 문제는 바로..

JVM의 TimeZone.setDefault()가 프로세스 전역 설정이고, 한 요청에서 타임존을 변경하면 다른 스레드에서도 적용된다는 점..!

즉, America/New_York 미국 사용자 A가 요청, 동시에 한국 사용자 B도 같이 요청하는 순간 한쪽의 타임존이 안맞게 되어버린다는 사실을 알게 되었다..

물론 국가에 따라 타임존을 나눈다는거 자체도 지금 문제가 있긴 하지만..(미국은 주 state나 지역에 따라 시간이 다르기 때문;;) 나중에 완전 코드적으로 프론트와 백엔드 분리 작업을 할때 좀 더 고려해보는걸로..하고 우선 해당 이슈를 수정해야 함..!

수정 방법

요즘 AI가 잘 되어 있어서 서치하는 시간이 확 줄었다, 사내 adier와 주니, 챗 지피티 모두 같은 의견으로 그 방법으로 수정하기로 결정 ㅋㅋ

바아로~ Spring이 제공하는 LocaleContextHolder를 활용해, 스레드 로컬(ThreadLocal) 기반으로 Locale과 TimeZone을 처리하는 방향을 공통적으로 추천…

이 방식은 요청별로 타임존을 안전하게 유지할 수 있고, 다른 스레드에 영향을 주지 않는다고 한다.

그래서 내 코드는 이렇게 변했다..! 그리고 사파리,크롬,크롬 시크릿 모드 3개로 다른 국가로 설정된 계정으로 로그인 해서 테스트 해본 결과 그 타임존 시간에 맞게 변환된 것을 확인.

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    try {
        String country = SessionManager.getCountry();
        ZoneId zoneId = getZoneIdByCountry(country);

        // ThreadLocal에 저장 (전역 상태 변경 X)
        ZoneIdContextHolder.setZoneId(zoneId);

        chain.doFilter(request, response);

    } finally {
        // 요청 완료 후 정리 (메모리 누수 방지)
        ZoneIdContextHolder.clear();
    }
}
  • java spring 프로젝트에 신규 작업건은 코틀린으로 작업하고있어 코드가 섞여있습니다.

추가 이슈

엑셀 다운로드 기능 개선 하며 테스트 중에 갑자기 두 종류의 에러를 발견..

SpelEvaluationException: EL1026E: The string has '0' characters, index '0' is invalid
IllegalStateException: No thread-bound request found

특히 두번째 이슈.. 이건 Spring이 현재 HttpServletRequest를 찾지 못할 때 발생하는데, 비동기 스레드에서 WebContext를 읽으려 했기 때문에 발생했다는 것이다.

문제가 되었던 그 코드는 바로 병럴처리하는 부분.
요청을 받았던 원래의 스레드와는 다른 스레드에서 RequestContextHolder를 참조하려고 했기 때문에 발생한 것으로 확인되었다.

특히 요청 컨텍스트의 로케일(locale) 기반으로 처리되도록(다국어 처리때문에) 구현한 valueCodeList 요것이 문제였다.

병렬 스트림 전에 초기화 하던가 병렬처리를 빼고 stream으로 변경하라고 AI가 추천해줬는데 병렬처리는 포기 하기싫었다.ㅠ

이 코드를 잘 보니 다른 부분 개선하면서 다국어 처리할때 MessagePatternResolver를 활용토록 바꿔놔서 valueCodeList 값이 굳이 필요가 없었다.

originalPage.getContent().parallelStream()
    .map { dto ->
        ~~ 로직
        TicketExcelFormResponseDTO.from(변수, 변수, valueCodeList, 변수)
    }.toList()

알게된 점

  1. TimeZone.setDefault()는 전체 애플리케이션에 영향을 주므로(JVM 전체 설정을 바꾸는 것이라 다른 사용자 요청까지 다른 시간대로 처리될 수있음) 사용자의 요청마다 시간대를 설정할 때 쓰면 안된다.

  2. 따라서 사용자마다 다른 시간대를 적용하고 싶다면 LocaleContextHolder를 사용해서 요청 스레드 수준내에서 처리되도록 하자.

  3. parallelStream이나 @Async 같은 병렬 처리에서는 스레드가 달라져서 LocaleContextHolder 같은 요청 정보가 사라지므로 사용할거면 요청 정보를 같이 넘기거나, 병렬 작업 전에 필요한 값을 변수로 꺼내서 넘기자.

0
Subscribe to my newsletter

Read articles from seoyoon jung directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

seoyoon jung
seoyoon jung