ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [java, kotlin] DecimalFormat은 Thread safe하지 않다.
    프로그래밍 언어/자바 & 코틀린 2023. 12. 9. 14:16

    회사에서 외부로 api 요청을 하는데, 말도 안되는 값이 들어가서 외부 서비스에서 sql 에러가 발생했다.

     

    LocalDate로 타입을 아예 정해뒀는데, 로그를 확인해보니 들어간 값이 예를 들어, "-E3498230"로 들어간 것이다.

     

    응? 타입이 정해져있는데 이런 값 자체가 어떻게 넘어갔지하고 디버깅, 테스트, 구글링해보니 원인은 DecimalFormat에 있었다.

     

    결론부터 말하면 DecimalFormat(NumberFormat 또한 같다)은 제목에 적힌것처럼 Thread safe하지 않다. 동시성 이슈는 재연이 어려워서 놓치기 쉽고 발견도 쉽지 않다.

     

    다행인건 빨리 찾아서 이슈를 해결했다는 것이었다.


    재연해보기

    당시 이슈가 생긴 코드

    object ExampleFormatUtils
    
    // top-level variable
    private val decimalFormat = DecimalFormat()
    
    // 의도: 텍스트에서 숫자만 찾아서 뽑아낸다.
    fun String.extractInt(): Int = decimalFormat.parse(this.filter { it.isDigit() }).toInt()

     

    나중에 리팩토링했다. 굳이 DecimalFormat이 없어도 숫자만 뽑아낼 수 있다. 예시를 위해서 그때 당시 코드를 그대로 사용한다.

    fun String.extraceInt(): Int = this.filter { it.isDigit() }.toInt()

    (리팩토링한 코드)

     

    테스트 코드

    import io.kotest.core.spec.style.BehaviorSpec
    import kotlinx.coroutines.*
    
    internal class DecimalFormatTest: BehaviorSpec({
    
        Given("날짜 개념이 담긴 문자열") {
            val message = "2024년 02월 23일 부터 2024년 04월 25일까지 입니다."
            val words = message.split(" ")
    
            When("문자열에서 숫자를 뽑아내서 LocalDate로 만든다") {
                launch {
                    (1..10).map {
                        // 스레드 경합이 일어날 수 있도록 코루틴(Dispatchers.IO)를 사용했다.
                        // java에서는 여러 스레드를 만들어서 테스트해볼 수 있다.
                        async(Dispatchers.IO) {
                            val years = words.filter { it.contains("년") }.map { it.extractInt() }
                            val months = words.filter { it.contains("월") }.map { it.extractInt() }
                            val days = words.filter { it.contains("일") }.map { it.extractInt() }
    
                            println("startDate year: ${years[0]}, month: ${months[0]}, day: ${days[0]}")
                            println("endDate year: ${years[1]}, month: ${months[1]}, day: ${days[1]}")
                        }
                    }.awaitAll()
                }
                Then("DecimalFormat을 Thread safe하지 않다.")
            }
        }
    })

     

     

    결과

     

    문제가 있다는걸 쉽게 확인할 수 있다.  의도대로라면 startDate는 2024년 2월 23일, endDate는 2024년 4월 25일이 나와야한다.

     

    그렇지만 로그를 보면 year 값이 4가 나오고, 2가 나오기도 한다. day에는 2525가 들어가있기도 하다.

     

    결국에는 Int로 변환할 수 없는 값이 나오면서 에러가 발생하고 실행을 멈춘다.


    해결하기

    1. 지역변수로 DecimalFormat을 쓰고 있다면 이슈는 생기지 않는다.

     

    DecimalFormat 같이 공유해서 쓰기 좋은 클래스는 자원 낭비없이 유틸성으로 빼두는게 좋지 않을까란 생각으로 로컬 변수로 쓰지 않았었다. 그래서 동시성 이슈가 발생했지만 :-(

     

    예시

    internal class DecimalFormatTest: BehaviorSpec({
    
        Given("날짜 개념이 담긴 문자열") {
            val message = "2024년 02월 23일 부터 2024년 04월 25일까지 입니다."
            val words = message.split(" ")
    
            When("문자열에서 숫자를 뽑아내서 LocalDate로 만든다") {
                launch {
                    (1..10).map {
                        async(Dispatchers.IO) {
                            // 로컬 변수로 사용한다
                            val decimalFormat = DecimalFormat()
    
                            val years = words
                                .filter { it.contains("년") }
                                .map { word -> decimalFormat.parse(word.filter { it.isDigit() }).toInt() }
                            val months = words
                                .filter { it.contains("월") }
                                .map { word -> decimalFormat.parse(word.filter { it.isDigit() }).toInt() }
                            val days = words
                                .filter { it.contains("일") }
                                .map { word -> decimalFormat.parse(word.filter { it.isDigit() }).toInt() }
    
                            println("startDate year: ${years[0]}, month: ${months[0]}, day: ${days[0]}")
                            println("endDate year: ${years[1]}, month: ${months[1]}, day: ${days[1]}")
                        }
                    }.awaitAll()
                }
                Then("DecimalFormat을 Thread safe하다.")
            }
        }
    })

     

    원하는 값 그대로 나온다

     

    2. 스레드마다 가지고 있도록 DecimalFormat을 스레드 로컬 변수로 만든다.

    // 해결한 코드
    object ExampleFormatUtils
    
    // top-level variable
    private val decimalFormat = ThreadLocal.withInitial { DecimalFormat() }
    
    fun String.extractInt(): Int = decimalFormat.get().parse(this.filter { it.isDigit() }).toInt()

     

    참고로 get()까지 호출해서 전역 또는 top-level 변수로 두지 않아야한다.

    호출하는 쪽에서 get()을 사용해야한다.

     

    제시한 해결책으로 코드를 변경하고 위에서 사용했던 테스트 코드를 돌려보면 올바른 결과가 나온다.

     

    나의 경우, 2번으로 해결했다. 1번에 말한 사용하지 않으려했던 이유로 2번을 선택했다.

    (예시가 된 코드를 리팩토링 했지만, 그외에도 DecimalFormat을 쓰고 있는 부분들이 있었으므로 해결은 해야했다.)

     

    자바는 아래처럼 선언하면 쓸 수 있다.

    private static ThreadLocal<DecimalFormat> decimalFormat = ThreadLocal.withInitial(() -> new DecimalFormat());

     


    공식 레퍼런스에도 설명이 나와있다.

    Synchronization
    Decimal formats are generally not synchronized. It is recommended to create separate format instances for each thread. If multiple threads access a format concurrently, it must be synchronized externally.

     

    공식 레퍼런스

     

    대략 번역하면, Decimal formats들은 전반적으로 동기화가 안된다. 각 스레드 당 인스턴스를 생성하길 추천한다. 만약 다수의 스레드가 하나의 format에 동시에 접근하면, 동기화는 외부에서 해야한다.

     

    번외

    DecimalFormat의 format 함수는 테스트 코드를 돌려봤는데, 동시성 이슈가 발생하지 않았다.

    내부적으로 StringBuffer(혹시 모르는 사람들을 위해: StringBuffer는 Thread safe하다)를 쓰고 있어서 동시성 이슈가 생기지 않는 것으로 추정된다.

     

    그렇지만 내부구현은 언제든 바뀔 수 있는거니 ThreadLocal이나 지역변수로 미리 처리해두면 나쁠 건 없겠다.

Designed by Tistory.