ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [레거시 코드와 데이트] SimpleDateFormat의 함정
    JAVA 2025. 2. 21. 00:35
    728x90
    반응형

    Java에서 날짜와 시간을 포맷하거나 파싱 할 때 흔히 사용하는 SimpleDateFormat은 편리하지만 멀티스레드 환경에서 공유 자원으로 사용할 경우 동시성 문제를 일으킬 수 있습니다.

    이는 SimpleDateFormatNon-Thread-Safe로 설계되었기 때문인데 현재 회사 레거시 프로젝트에서 SimpleDateFormat을 공유 자원으로 사용하고 있는 코드를 발견했는데 이 문제를 해결하기 위해 동시성 문제의 원인을 분석하고 수정이 필요한 이유와 대안을 정리해 보았습니다.

    동시성 문제 발생 원인

    SimpleDateFormat의 동시성 문제는 주로 내부적으로 공유되는 Calendar 객체와 pattern 문자열의 상태 변경에서 비롯되는데 이 클래스는 날짜를 포맷하거나 파싱 할 때 Calendar 객체를 사용하며 format이나 parse 메서드를 호출할 때마다 패턴에 따라 내부 상태를 갱신하기 때문에 멀티스레드 환경에서 여러 스레드가 동일한 SimpleDateFormat 인스턴스를 동시에 사용하면 이 과정에서 상태 충돌이 발생할 수 있습니다.

    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
    
    // Thread 1:
    dateFormat.parse("2025-02-20");
    
    // Thread 2:
    dateFormat.applyPattern("yyyy/MM/dd");
    dateFormat.parse("2025/02/21");
    
    // Thread 3:
    dateFormat.format(new Date());

    위 코드에서 3개의 스레드가 동일한 SimpleDateFormat 인스턴스를 공유하며 Thread 1이 날짜를 파싱 하는 동안 Thread 2가 패턴을 변경하고 Thread 3이 현재 날짜를 포맷하게 되면 Calendar와 pattern의 상태가 서로 간섭하여 예기치 않은 결과나 예외를 유발할 수 있습니다.

    이제 각 메서드에서 문제가 발생하는 이유를 구체적으로 살펴보겠습니다.

    1. parse 메서드

    parse 메서드는 날짜 문자열을 파싱 하며 내부 Calendar 객체의 상태를 변경합니다.

    아래는 parse 메서드의 핵심 로직 일부입니다.

    public Date parse(String source, ParsePosition pos) {
        // ...
        Date parsedDate;
        try {
            parsedDate = calb.establish(calendar).getTime();
            // If the year value is ambiguous,
            // then the two-digit year == the default start year
            if (ambiguousYear[0]) {
                if (parsedDate.before(defaultCenturyStart)) {
                    parsedDate = calb.addYear(100).establish(calendar).getTime();
                }
            }
        }
        // ...
    }
    
    Calendar establish(Calendar cal) {
        // ...
        cal.clear();
        // ...
    }

    establish 메서드에서 Calendar 객체의 상태를 초기화하고 갱신하는데 이 객체가 스레드 간에 공유되면 한 스레드가 상태를 변경하는 동안 다른 스레드가 이를 덮어쓰게 되기 때문에 결과적으로 파싱 된 날짜가 의도와 달라지거나 예외가 발생할 수 있습니다.

    2. applyPattern 메서드

    applyPattern 메서드는 날짜 포맷 패턴을 동적으로 변경합니다.

    public void applyPattern(String pattern) {
        applyPatternImpl(pattern); 
    }
    
    private void applyPatternImpl(String pattern) {
        compiledPattern = compile(pattern);
        this.pattern = pattern; 
    }

    이 과정에서 pattern 필드가 갱신되는데 멀티스레드 환경에서 한 스레드가 패턴을 변경하면 다른 스레드의 작업에 영향을 미칩니다.

    예를 들어 Thread 1이 "yyyy-MM-dd"로 파싱 중일 때 Thread 2가 "yyyy/MM/dd"로 패턴을 바꾸면 Thread 1의 결과가 엉망이 될 수 있습니다.

    3. format 메서드

    format 메서드 역시 Calendar 객체의 상태를 변경하며 동시성 문제를 일으킵니다.

    private StringBuffer format(Date date, StringBuffer toAppendTo, FieldDelegate delegate) {
        // Convert input date to time field list
        calendar.setTime(date);
    
        boolean useDateFormatSymbols = useDateFormatSymbols();
    
        for (int i = 0; i < compiledPattern.length; ) {
            int tag = compiledPattern[i] >>> 8;
            int count = compiledPattern[i++] & 0xff;
            if (count == 255) {
                count = compiledPattern[i++] << 16;
                count |= compiledPattern[i++];
            }
    
            switch (tag) {
            case TAG_QUOTE_ASCII_CHAR:
                toAppendTo.append((char)count);
                break;
    
            case TAG_QUOTE_CHARS:
                toAppendTo.append(compiledPattern, i, count);
                i += count;
                break;
    
            default:
                subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
                break;
            }
        }
        return toAppendTo;
    }

    calendar.setTime(date) 호출로 Calendar 상태가 갱신되는데 여러 스레드가 동시에 이 메서드를 실행하면 Calendar의 상태가 충돌하여 잘못된 문자열이 반환될 수 있습니다.

    동시성 문제 해결 방안

    가장 간단한 해결책은 SimpleDateFormat 인스턴스를 공유하지 않고 스레드마다 새로 생성하는 것이지만 가장 간단한 방법이기 때문에 생략하겠습니다.

    1. ThreadLocal

    ThreadLocal을 사용하면 각 스레드가 독립적인 SimpleDateFormat 인스턴스를 가지게 되어 동시성 문제를 피할 수 있습니다.

    ThreadLocal<SimpleDateFormat> threadLocalDateFormat = new ThreadLocal<SimpleDateFormat>() {
        @Override
        protected SimpleDateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd");
        }
    };

    2. DateTimeFormatter [v]

    Java 8부터 도입된 DateTimeFormatter는 불변(immutable) 객체로 설계되어 멀티스레드 환경에서 안전합니다.

    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    String formattedDate = LocalDate.now().format(formatter);

    LocalDate를 사용하므로 Date로 변환하려면 추가 작업이 필요하지만 코드 안정성과 현대적인 설계 측면에서 추천됩니다.

    3. DateFormatUtils

    Apache Commons Lang 라이브러리의 DateFormatUtils는 ThreadLocal을 내부적으로 사용하여 스레드 안전성을 보장합니다.

    String formattedDate = DateFormatUtils.format(new Date(), "yyyy-MM-dd");

    이 방법은 의존성 추가(org.apache.commons:commons-lang3)가 필요합니다.

    결론

    DateTimeFormatter를 선택한 이유는 몇 가지 장점 때문인데 첫째 Java 8에서 도입된 DateTimeFormatter는 불변(immutable) 객체로 설계되어 멀티스레드 환경에서 안전하게 사용할 수 있으며 둘째 Java의 표준 API로 추가적인 외부 의존성을 최소화할 수 있습니다.

    이를 통해 유지보수성과 코드 안정성을 높일 수 있으며 최신 Java 버전에서 제공하는 기능을 활용하여 효율적인 날짜 및 시간 처리 작업을 할 수 있으며 DateTimeFormatter는 LocalDate, LocalDateTime과 같은 현대적인 날짜 및 시간 API와 잘 통합되어 있어 날짜 처리에서 더 나은 성능과 확장성을 제공합니다.

    728x90
    반응형
Designed by Tistory.