Java

[Java]SimpleDateFormat을 쓰면 안된다고? (feat.Thread-Safe)

Woonys 2022. 8. 29. 01:34
반응형

Introduction

현재 사내 툴에 들어갈 간단한 기능을 개발하는 작업을 받아 진행하는 중이다(

그 간단한 걸 3주나 하고 있다고…

). 클라이언트가 어떤 문서를 제출했을 때, 제출한 날짜를 어드민에서 수정할 수 있도록 하는 기능이다. 간단해보이지만 이걸 스프링에서 짜려니 레이어별로 책임을 분리하고 DTO를 만들고 등등 이것저것 할 게 많았다.

어찌어찌 개발을 끝내고 PR을 올렸더니..

코멘트 61개 달린 거 실환가..그 와중에 이렇게 라인 바이 라인으로 디테일하게 피드백 받을 수 있어 엄청 행복했다. (코드리뷰에 진심인 우리 회사 오세요 여러분)

피드백 중에 SimpleDateFormat을 쓰지 말라는 코멘트를 받았다.

소중한 code review

기존 코드에서는 서비스 레이어에서 받아온 String을 SimpleDateFormat을 이용해 Timestamp 타입으로 변환하는 작업을 거쳤다. 여기서 왜 SimpleDateFormat을 쓰면 안되는지, 대안으로 나온 DateTimeFormat은 무엇이고 어떻게 쓰는지에 대해서 알아보도록 하자. 이번 글은 실제로 작업해볼 수 있는 예제와 함께 올라간다. 예제 링크는 여기 깃허브에 올려뒀다.

1. What is SimpleDateFormat?

SimpleDateFormat은 String 타입을 Date 타입으로 파싱 또는 Date 타입을 String으로 포매팅하는데 이를 locale-sensitive 한 방법으로 구현하는 클래스이다.

여기서 Locale은 저마다 다르게 관습화된 도메인 규칙(날짜 형식, 화폐 단위 등)을 말하며 Locale-sensitive는 특정 기능을 수행하기 위해 Locale 객체(여기서는 Date가 이에 해당)를 요구하는 동작을 뜻한다.(설명 링크)

Docs 설명은 아래와 같다.

SimpleDateFormat 는 날짜를 파싱 또는 포매팅할 때 사용하는 구현 클래스이다. 포매팅(date → text) 및 파싱(text → date), 그리고 정규화를 하는데 쓰인다.

SimpleDateFormatis a concrete class for formatting and parsing dates in a locale-sensitive manner. It allows for formatting (date -> text), parsing (text -> date), and normalization.

기능을 개발할 당시에는 먼저 서비스 레이어에서 SimpleDateFormat 객체를 생성했다.

이후, DTO로 받은 String 객체를 SimpleDateFormat을 이용해 Timestamp 객체로 변환해줬다. 여기서는 간단하게 Date 타입으로 변환하는 과정으로 예제를 진행해보겠다.

2. 예제 구현

환경 세팅

환경 세팅은 String initializr에서 아래와 같은 조건으로 진행했다.

  • gradle
  • SpringBoot 2.7.1
  • Java 1.8

build.gradle

plugins {
    id 'org.springframework.boot' version '2.7.1'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group 'org.example.dataformat'
version '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1'
}

tasks.named('test') {
    useJUnitPlatform()
}

환경세팅을 마쳤다면 본격적으로 SimpleDateFormat 클래스의 문제점을 살펴보도록 하자.

Issue 1: Thread-unsafe

SimpleDateFormat 클래스의 가장 큰 문제 중 하나는 멀티스레드 환경에서 Thread-safe를 보장하지 않는다는 점이다.

Thread-safe란, 멀티스레드 환경에서 어떤 함수/변수/객체에 여러 스레드가 동시에 접근하더라도 프로그램의 실행에 문제가 없음을 뜻하는 용어이다.

즉, 하나의 함수/변수/객체가 한 스레드로부터 호출되어 실행 중일 때, 다른 스레드가 그 함수/변수/객체를 호출하여 동시에 함께 실행하더라도 각 스레드에서의 수행 결과가 동일하게 & 올바르게 나오는 것으로 정의한다.

따라서 Thread-safe하지 않다는 건 여러 스레드가 같은 작업을 수행했음에도 예상 결과와 제각기 다른 결과가 나온다는 것을 의미한다.

긴 말 할 것 없이 바로 코드를 살펴보자. 10개 스레드를 생성한 뒤, 이들이 각각 하나의 SimpleDateFormat 객체를 parser로 사용해 String(dateStr)을 파싱하는 작업을 수행하는 코드이다.

Code

package org.example.dataformat.domain;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SingleDateFormatThreadUnsafeExample {
    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss a", Locale.ENGLISH);

    public static void main(String[] args) {
        String dateStr = "08-Aug-2022 12:58:47 PM";

        ExecutorService executorService = Executors.newFixedThreadPool(10);
        Runnable task = new Runnable() {
            @Override
            public void run() {
                parseDate(dateStr);
            }
        };

        for (int i = 0; i < 10; i++) {
            executorService.submit(task);
        }
        executorService.shutdown();
    }

    private static void parseDate(String dateStr) {
        try {
            Date date = simpleDateFormat.parse(dateStr);
            System.out.println("Successfully Parsed Date " + date);
        } catch (ParseException e) {
            System.out.println("ParseError " + e.getMessage());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

결과는 아래와 같다. 보다시피 input String이 비었다는 NumberFormatException도 발생할 뿐더러 성공적으로 파싱한 값 역시 예상과 달리 저마다 다른 값을 뱉어내고 있음을 볼 수 있다. 즉, Thread-safe하지 않다.

Output

Successfully Parsed Date Thu Oct 27 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Wed Oct 19 12:58:47 KST 2022
Successfully Parsed Date Tue Aug 08 12:58:47 KST 58
Successfully Parsed Date Sat Jan 04 12:58:47 KST 2025
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
java.lang.NumberFormatException: For input string: ""
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
    at java.lang.Long.parseLong(Long.java:601)
    at java.lang.Long.parseLong(Long.java:631)
    at java.text.DigitList.getLong(DigitList.java:195)
    at java.text.DecimalFormat.parse(DecimalFormat.java:2084)
    at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
    at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
    at java.text.DateFormat.parse(DateFormat.java:364)
    at org.example.dataformat.domain.SingleDateFormatExample.parseDate(SingleDateFormatExample.java:32)
    at org.example.dataformat.domain.SingleDateFormatExample.access$000(SingleDateFormatExample.java:10)
    at org.example.dataformat.domain.SingleDateFormatExample$1.run(SingleDateFormatExample.java:20)
    at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:750)
java.lang.NumberFormatException: For input string: ""
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
    at java.lang.Long.parseLong(Long.java:601)
    at java.lang.Long.parseLong(Long.java:631)
    at java.text.DigitList.getLong(DigitList.java:195)
    at java.text.DecimalFormat.parse(DecimalFormat.java:2084)
    at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
    at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
    at java.text.DateFormat.parse(DateFormat.java:364)
    at org.example.dataformat.domain.SingleDateFormatExample.parseDate(SingleDateFormatExample.java:32)
    at org.example.dataformat.domain.SingleDateFormatExample.access$000(SingleDateFormatExample.java:10)
    at org.example.dataformat.domain.SingleDateFormatExample$1.run(SingleDateFormatExample.java:20)
    at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511)
    at java.util.concurrent.FutureTask.run(FutureTask.java:266)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
    at java.lang.Thread.run(Thread.java:750)

이렇게 발생하는 이유는 SimpleDateFormat의 구현 방식에 있다. SimpleDateFormat은 내부적으로 Calender 클래스를 인스턴스화해서 사용한다.

public SimpleDateFormat(String pattern, DateFormatSymbols formatSymbols) {
        ...
        if (pattern != null && formatSymbols != null) {
            ...
            this.locale = Locale.getDefault(Category.FORMAT);
            this.initializeCalendar(this.locale);
               ...
    }

private void initializeCalendar(Locale loc) {
        if (this.calendar == null) {
            assert loc != null;

            this.calendar = Calendar.getInstance(loc);
        }

    }

이때 날짜 포맷팅을 하려고 parse()를 호출하면 내부에서는

calender.clear();
calender.add();

가 순차적으로 호출된다. 이 상황에서 멀티스레드 간 충돌을 방지해주는 어떤 장치도 없기 때문에 인스턴스에서 충돌이 일어나게 되는 것이다.

그러면 이를 어떻게 해결할 수 있을지 살펴보자.

Solution 1: new 연산자로 매번 새로운 SimpleDateFormat 인스턴스 생성! 그런데 expensive를 곁들인..?

이에 대한 가장 간단한 해결책은 위와 같이 SimpleDateFormat을 싱글톤으로 사용하지 않고 매번 새로운 인스턴스를 생성하는 것이다. 실제 doc에서도 이를 추천한다.

It is recommended to create separate format instances for each thread. -javadoc

한 스레드마다 하나의 SimpleDateFormat을 쥐어주면 서로 다른 스레드가 하나의 SimpleDateFormat을 쓰지 않아도 되니 애초에 synchronization 문제가 발생하지 않을 것이다. 예제를 보자.

Code

package org.example.dataformat.domain;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SimpleDateFormatThreadSafeWithNewExample {

    public static void main(String[] args) {
        String dateStr = "08-Aug-2022 12:58:47 PM";

        ExecutorService executorService = Executors.newFixedThreadPool(10);
        Runnable task = new Runnable() {
            @Override
            public void run() {
                parseDate(dateStr);
            }
        };

        for (int i = 0; i < 10; i++) {
            executorService.submit(task);
        }
        executorService.shutdown();
    }

    private static void parseDate(String dateStr) {
        try {
            // 매번 새 객체 생성 -> But 비용이 비싸다는 단점
            SimpleDateFormat simpleDateFormat = new SimpleDateFormat("dd-MMM-yyyy HH:mm:ss a", Locale.ENGLISH);
            Date date = simpleDateFormat.parse(dateStr);
            System.out.println("Successfully Parsed Date " + date);
        } catch (ParseException e) {
            System.out.println("ParseError " + e.getMessage());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

예상과 동일하게, 정확히 나오는 것을 알 수 있다.

Output

Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022

하지만 위 방법은 엄밀히는 좋지 않은 해결 방식이다. 이는 SimpleDateFormat이 자원을 많이 차지하는, 즉 비싼 객체이기 때문이다. SimpleDateFormat의 경우, 생성자가 date-time 패턴을 파싱하는데 각 파싱 작업마다 새 객체를 할당할 경우 CPU에 그만큼 부담이 커진다.(참고 링크)

여기서 헷갈리면 안되는 게, 그러면 매번 새 객체를 만드는 행위 자체가 비싼 건가 하면 그것은 아니다. 객체가 만들어질 때마다 allocator가 각 객체에 메모리를 할당해준다. 예전에는 이 allocator가 객체에 메모리를 제공하는 데 오랜 시간이 걸렸지만 요즘은 많이 정교해졌기에 자원을 거의 쓰지 않는다고 봐도 된다. 위의 SimpleDateFormat은 String을 일일이 쪼개서 date-time 패턴으로 변환하는 과정에 드는 CPU 작업이 많기 때문이라고 이해해도 될 것 같다.(참고 링크)

그래서 새 객체를 생성하는 게 비싸다면, 어떤 방법을 쓸 수 있을까? 애초부터 다른 객체를 사용하면 된다. 바로 DateTimeFormatter이다.

Solution 2: 애초부터 DateTimeFormatter를 사용하자!

Java 8에서부터는 날짜 포매팅 관련해 새로운 클래스인 DateTimeFormatter을 사용할 것을 권장하고 있다. SimpleDateFormat과 DateTimeFormatter의 가장 큰 차이점은, DateTimeFormatter의 경우 Thread-safe하다는 점이다! 바로 예제를 보자.

code

package org.example.dataformat.domain;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class DateTimeFormatThreadSafeExample {

    private static DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder()
        .parseCaseInsensitive()
        .appendPattern("dd-MMM-yyyy HH:mm:ss a")
        .toFormatter(Locale.ENGLISH);

    public static void main(String[] args) {
        String dateStr = "08-Aug-2022 12:58:47 PM";
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        Runnable task = new Runnable() {
            @Override
            public void run() {
                parseDate(dateStr);
            }
        };
        for(int i=0; i< 10; i++) {
            executorService.submit(task);
        }
        executorService.shutdown();
    }

    private static void parseDate(String dateStr) {
        try {
            LocalDateTime date = LocalDateTime.from(dateTimeFormatter.parse(dateStr));
            System.out.println("Successfully Parsed Date " + date);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

Output

Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022
Successfully Parsed Date Mon Aug 08 12:58:47 KST 2022

맨 처음에 소개한 것과 같이 싱글톤으로 DateTimeFormatter를 구현했다. 그럼에도 결과가 정확하게 일치하며 스레드 간의 값이 동일한 것을 확인할 수 있다.

3. Conclusion

  • SimpleDateFormat은 Thread-safe하지 않기 때문에 멀티스레드 환경에서는 사용하지 말 것.
  • Java 8부터 새로 도입된 DateTimeFormatter를 사용하자! 얘는 Thread-safe를 보장한다.
  • 생성이 비싼 객체의 경우 thread-safe를 보장받는 전제 하에 싱글톤으로 구현할 것.

4. Reference

https://gompangs.tistory.com/entry/OS-Thread-Safe란

https://www.baeldung.com/java-simple-date-format#2-thread-safety

https://javacrafters.com/simpledateformat-vs-datetimeformatter-in-java/

https://coderanch.com/t/526276/java/Object-creation-costly

반응형