본문으로 건너뛰기
개발 머꼬
개발 노트Java
hohyeon.dev23

정적 SimpleDateFormat 때문에 가끔 엉뚱한 날짜가 나오고 예외까지 난 이유

  • #Debugging
  • #Engineering Note
  • #Java

문제 발생

포맷터를 상수로 빼서 재사용했습니다. 흔한 최적화라고 생각했습니다.

private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

public static String format(Date date) {
    return FORMAT.format(date);
}

평소에는 멀쩡하다가 트래픽이 몰리면 날짜가 뒤섞이고, 파싱 쪽에서는 이런 예외가 났습니다.

java.lang.NumberFormatException: For input string: ""

원인 분석

SimpleDateFormat은 스레드 안전하지 않습니다. javadoc의 Synchronization 절이 짧고 분명합니다 — 날짜 포맷은 동기화되지 않습니다. 스레드마다 별도의 포맷 인스턴스를 만드는 것을 권장합니다. 여러 스레드가 하나의 포맷에 동시에 접근한다면 외부에서 동기화해야 합니다.

이유는 구현에 있습니다. DateFormat은 작업 중 내부 Calendar 필드를 변경하면서 포맷·파싱을 진행합니다. 두 스레드가 같은 인스턴스에 들어오면 한쪽이 세팅한 중간 상태를 다른 쪽이 읽습니다. 그래서 예외가 나기도 하고, 더 나쁘게는 예외 없이 다른 시각이 조용히 나오기도 합니다.

부하가 낮을 때 재현되지 않는 것도 이 때문입니다. 겹치는 순간이 있어야 드러납니다.

javadoc은 대안도 직접 지목합니다 — 불변이고 스레드 안전한 대안으로 DateTimeFormatter를 고려하라는 API 노트입니다.

해결 방안

  1. java.time으로 옮깁니다. 지금 새로 쓰는 코드라면 이게 정답입니다.
private static final DateTimeFormatter FORMATTER =
    DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.of("Asia/Seoul"));

public static String format(Instant instant) {
    return FORMATTER.format(instant);   // 불변, 공유 안전
}
  1. 레거시를 당장 못 걷어내면 인스턴스를 공유하지 않습니다. 호출마다 새로 만드는 비용은 대부분의 서비스에서 문제가 되지 않습니다. 측정 없이 "느릴 것 같아서" 상수로 빼는 것이 애초에 이 버그의 출발점이었습니다.

  2. 스레드마다 하나가 필요하면 ThreadLocal을 쓰되 수명을 관리합니다. 스레드 풀에서 ThreadLocal은 정리하지 않으면 그대로 남습니다.

  3. 같은 성격의 다른 클래스도 함께 점검합니다. Calendar, DateFormat 계열은 모두 가변입니다. 반대로 DateTimeFormatter, Instant, LocalDate는 불변이라 공유해도 됩니다.

  4. 정적 필드에 가변 객체를 두는 습관 자체를 의심합니다. 이 버그는 코드 리뷰에서 "상수니까 괜찮다"로 통과하기 쉽습니다. 상수인 것은 참조이지 그 객체의 내부 상태가 아닙니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.