자바는 정말 Write Once, Run Anywhere 일까?

머랭·2026년 6월 1일
post-thumbnail

안녕하세요 머랭입니다.
자바에는 "Write Once, Run Anywhere", 줄여서 WORA라는 잘 알려진 슬로건이 있습니다.

한 번 작성한 코드가 어디서나 똑같이 동작한다는 뜻입니다.
자바 코드는 특정 OS에 맞춰 컴파일되지 않고 바이트코드라는 중간 형태로 컴파일되며, 이 바이트코드를 각 OS에 설치된 JVM이 해석해 실행합니다.
OS마다 다른 부분은 JVM이 대신 처리하므로, 개발자는 코드를 한 번만 작성해도 Windows든 Linux든 macOS든 똑같이 동작할 것이라고 기대합니다.

이번 포스팅에서는 자바가 내세우는 WORA의 진정한 의미를 Thread.sleep() 의 사례로 짚어 보겠습니다.

이 포스팅은 Hotspot JVM을 기준으로 작성되었습니다.

1. Thread.sleep()

코드를 작성하다 보면 Thread.sleep()을 사용하는 일이 종종 있습니다.

  1. 외부 API 호출이 실패했을 때 간격을 두고 재시도
  2. 작업이 끝났는지 일정 간격으로 폴링
  3. 요청 사이에 간격을 두어 요청 속도를 제한
  4. 비동기 결과를 기다리는 테스트를 작성할 때(권장되지는 않습니다)
// 재시도 사이에 100ms 간격을 둘 때
for (int attempt = 0; attempt < MAX_RETRY; attempt++) {
    if (callExternalApi()) break;
    Thread.sleep(100);
}

// 조건이 만족될 때까지 50ms 간격으로 폴링할 때
while (!job.isDone()) {
    Thread.sleep(50);
}

Thread.sleep(100)을 호출하면 우리는 보통 "100ms 뒤에 다시 실행되겠지"라고 생각합니다.

그런데 정말 우리가 지정한 시간만큼 스레드의 실행이 멈출까요?


2. 같은 코드, 다른 결과

다음은 10ms씩 1,000번 실행을 멈췄다가 재개하는 동작을 반복하면서, 전체 시간이 얼마나 걸리는지 측정하는 간단한 벤치마크입니다.

public class SleepBenchmark {
    public static void main(String[] args) {
        final int iterations = 1_000;

        long start = System.nanoTime();
        for (int i = 0; i < iterations; i++) {
            try {
                Thread.sleep(10); // 10ms씩 멈추면 합계는 10초가 나와야 정상
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        long elapsedMs = (System.nanoTime() - start) / 1_000_000;

        System.out.printf("총 소요: %d ms%n", elapsedMs);
        System.out.printf("sleep 1회 평균: %.2f ms%n", elapsedMs / (double) iterations);
    }
}

이론대로라면 10ms × 1,000회 = 10,000ms, 즉 10초 정도면 끝나야 합니다.

그런데 실제로 실행해 보면 결과가 다릅니다.

Linux 환경에서 실험한 결과

총 소요: 10125 ms
sleep 1회 평균: 10.13 ms

Windows 환경에서 실험한 결과

총 소요: 15944 ms
sleep 1회 평균: 15.94 ms

똑같은 바이트코드인데 OS가 달라졌다는 이유만으로 실행 시간이 달랐습니다. 앞에서 설명한 WORA의 기대와 어긋나는 결과입니다.

참고 1: 구체적인 수치는 OS 버전, 하드웨어 설정 등에 따라 달라집니다.
Windows도 설정 변경을 통해 차이를 줄일 수 있습니다.
중요한 것은 절대적인 수치가 아니라, OS에 따라 결과가 달라진다는 점입니다.

참고 2: Windows 환경에서 실행되는 Thread.sleep()은 매개변수로 받은 ms 가 10의 배수가 아닌 경우에는 OS 타이머 인터럽트 주기를 1ms로 조정한 후 sleep 을 시작합니다.
따라서, ms 가 10의 배수가 아닌 경우에는 Windows 와 Linux가 동일하게 동작합니다.
이번 포스팅에서는 OS에 따라 결과가 다르다는 예시를 보여드리기 위해 10ms 로 설정했습니다.


3. 원인은 OS의 타이머 인터럽트 주기

Thread.sleep()이 얼마나 정확한지는 자바가 정하지 않습니다.
자바는 OS에 "이만큼 멈췄다가 다시 실행해 달라"고 요청할 뿐이고, 실제로 언제 재개되는지는 OS 타이머 인터럽트 주기에 좌우됩니다.

타이머 인터럽트란?

인터럽트는 실행 중인 흐름을 잠시 멈추고 CPU 코어를 미리 정해진 처리 루틴으로 분기시키는 신호입니다.
실행 중인 스레드가 시스템 콜이나 예외로 스스로 커널에 진입하지 않는 한, OS가 그 스레드를 강제로 멈추려면 타이머 인터럽트 같은 신호가 필요합니다.
그래서 OS는 프로그래밍 가능한 하드웨어 타이머가 일정 간격으로 인터럽트를 발생시키도록 설정해 두고, 그 신호가 올 때마다 제어권을 돌려받아 시간 관리와 스케줄링 같은 일을 처리합니다.
이렇게 주기적으로 발생하는 인터럽트를 tick이라고 부릅니다.

tick이 발생하면 다음 작업이 수행됩니다.

  • 실행 중이던 스레드가 잠시 멈추고 제어권이 커널로 넘어갑니다.
  • 커널이 시스템 시간을 갱신하고, 만료된 타이머가 있는지 확인합니다.
  • 대기 중인 스레드 중 재개 시점(sleep deadline)이 지난 것이 있으면 실행 대기열(런큐)로 옮겨 다시 실행될 수 있게 합니다.

Linux와 Windows의 타이머 인터럽트 주기는 다르다

Linux
보통 1~4ms 정도입니다.
다만 HotSpot은 sleep을 고해상도 타이머(hrtimer)로 처리하므로, 타이머 인터럽트 주기와 무관하게 요청한 시간과 거의 비슷하게 sleep 합니다.
(실제로 tick이 4ms인 환경에서도 sleep(10)이 ~10ms로 정확했습니다.)

Windows
보통 15ms입니다.
Thread.sleep(10)을 호출해도 다음 tick은 최대 15ms 뒤에야 옵니다. 그래서 10ms sleep을 호출해도 최대 16ms 정도 중단됩니다.

스레드 관점에서는 동일하게 실행을 멈췄을 뿐인데, 그 스레드를 다시 실행할지 점검하는 주기는 OS마다 다릅니다.
JVM은 OS에 "이 스레드를 10ms 뒤에 다시 실행해 달라"고 요청할 뿐이고, 실제로 언제 재개할지는 OS가 자신의 타이머 인터럽트 주기에 맞춰 결정합니다.


4. JDK 문서

Thread.sleep()의 명세를 보면 표현이 조심스럽습니다.

현재 실행 중인 스레드의 실행을 지정된 밀리초 동안 일시 중단한다(temporarily cease execution).
단, 시스템 타이머와 스케줄러의 정밀도 및 정확도에 따른다(subject to the precision and accuracy of system timers and schedulers).

핵심은 "시스템 타이머와 스케줄러의 정밀도에 따른다"는 조건입니다.

명세가 보장하는 것은 "최소 N ms 이상 멈춘다"는 하한선뿐이고, 그보다 얼마나 더 멈춰 있을지는 OS의 타이머 인터럽트 주기에 달려 있습니다.
그래서 "정확히 N ms" 가 아니라 "적어도 N ms" 라는 최소한의 보장만 제공하는 것입니다.

Windows 환경에서 15ms 동안 멈춰 있던 것은 버그가 아니라 명세대로 동작한 결과입니다.
"최소 10ms"라는 약속을 어긴 것은 아니기 때문입니다. 다만 우리가 "10ms를 요청하면 정확히 10ms일 것"이라고 기대했을 뿐입니다.


5. WORA가 보장하는 것과 보장하지 않는 것

JVM은 OS에 의존하는 영역(스레드 스케줄링, 타이머 정밀도, 파일 시스템 동작, 줄바꿈 문자, 시그널 처리)은 추상화하지 못합니다.

Thread.sleep(10)은 그 차이를 단적으로 드러내는 예시입니다.
한 줄의 코드가 OS에 따라 다르게 동작한다는 사실은, "한 번 작성하면 어디서나 실행된다"와 "어디서나 똑같이 동작한다"가 서로 다른 말임을 보여 줍니다.

WORA는 "바이트코드가 어디서나 실행된다"는 약속이지, "모든 실행 동작이 똑같다"는 약속은 아닙니다.
JVM은 OS 위에서 동작하는 추상화 계층일 뿐 OS 자체를 대체하지는 못합니다. 그래서 OS 영역이 동작하는 지점에서는 차이가 생깁니다.

비슷한 사례

  • 문자 인코딩
    • Java 18 이전에는 인코딩을 지정하지 않고 파일을 읽거나 쓰면 OS와 로케일에 따라 기본 charset이 달라졌습니다.
    • 이로 인해, 같은 코드라도 Linux(UTF-8)에서는 정상적으로 표시되던 한글이 Windows(MS949)에서는 깨지는 경우가 있었습니다.
    • 이 문제는 Java 18에서 기본 charset을 UTF-8로 통일하면서 해소되었습니다.
  • GUI
    • Swing이나 AWT로 만든 화면은 폰트, DPI 처리, 적용한 룩앤필에 따라 OS마다 조금씩 다르게 보입니다.
    • 특히 플랫폼 네이티브 룩앤필을 사용하면 버튼이나 입력창이 각 OS의 기본 모양을 따르므로, 같은 레이아웃 코드라도 화면이 동일하지는 않습니다.

마무리

"Write Once, Run Anywhere"는 대체로 사실이고, 충분히 강력한 약속입니다.
JVM이 책임지는 부분은 어느 OS에서나 동일하게 동작합니다.

문제는 OS나 하드웨어에 크게 종속되는 순간 발생합니다.
Thread.sleep의 정밀도는 OS의 타이머 인터럽트 주기에 달려 있고, 기본 인코딩이나, 타임존, 파일 경로 규칙도 환경마다 다릅니다.

정리하면, WORA가 보장하는 계층은 바이트코드 실행과 언어 의미론까지입니다.
OS나 하드웨어의 차이가 드러나는 영역에서는 같은 코드가 다른 결과를 낼 수 있습니다.

"Write Once, Run Anywhere"는 "한 번 작성하면 어디서나 실행된다"는 뜻이지, "어디서나 똑같이 동작한다"는 뜻은 아닙니다.

profile
동작보단 원리를 좋아하는 머랭의 블로그입니다.

2개의 댓글

comment-user-thumbnail
2026년 6월 10일

포스팅 잘 읽었습니다! WORA에 대해 배워가는게 많네요

1개의 답글