Sleep 메서드와 동기화의 함정

JV-CO-02calendar_today2026-02-03 23:23#Java #Level1

Thread.sleep의 역할

Thread.sleep(ms)는 스레드를 잠깐 멈추는 가장 기초적인 메서드입니다.

  • 기본 동작
    • 호출한 스레드를 TIMED_WAITING 상태로 보냅니다.
    • 시간이 지나면 다시 RUNNABLE로 돌아옵니다.
    • 즉, “멈췄다가 시간이 되면 실행 가능한 상태로 복귀”입니다.
  • 핵심 포인트
    • sleep()은 “언제 다시 CPU를 받을지”를 보장하지 않습니다.
    • “최소한 그 시간만큼은 안 깨어난다”에 가깝습니다.
    • “그 시간이 지나면 즉시 실행”은 아닙니다.

Sleep이 정확하지 않은 이유

sleep()은 설정한 시간보다 더 오래 쉬는 경우가 흔합니다.

  • 이유
    • OS 스케줄러가 CPU를 누구에게 줄지 결정합니다.
    • 컨텍스트 스위칭 비용이 발생합니다.
    • 시스템 부하가 높으면 재스케줄링이 늦어집니다.
  • 결론
    • 정밀한 타이밍이 필요한 로직에는 부적합합니다.
    • “딱 1000ms 뒤에 실행” 같은 요구에는 맞지 않습니다.

우연에 맡기는 코드 Programming by Coincidence

sleep()로 순서를 맞추려는 코드는 불안정합니다.

  • 대표 패턴
    • “대충 1초 쉬면 저쪽 작업 끝났겠지”라는 가정입니다.
    • 내 PC에서는 되는데 운영 서버에서는 깨질 수 있습니다.
    • 스레드 스케줄링은 환경마다 결과가 달라집니다.
  • 왜 위험한가
    • 성공과 실패가 실행 타이밍에 의해 결정됩니다.
    • 버그가 재현이 어렵습니다.
    • 테스트가 통과해도 실제에서 터질 수 있습니다.

폴링 Polling 방식의 문제

루프를 돌면서 sleep()하고 상태를 확인하는 방식도 흔한 함정입니다.

  • 전형적인 형태
    • while(!done) { Thread.sleep(10); } 같은 코드입니다.
  • 문제점
    • 불필요하게 CPU를 깨웠다 재우는 작업이 반복됩니다.
    • 이벤트가 발생해도 다음 체크 타이밍까지 반응이 늦습니다.
    • 지연과 낭비를 동시에 만들기 쉽습니다.
  • 대안 방향
    • “상태를 계속 확인”하지 말고 “신호를 기다리는 구조”로 바꿉니다.
    • 즉, wait notify, join, 락과 조건, 동시성 유틸로 해결합니다.

올바른 종료는 interrupt로 설계합니다

sleep()보다 중요한 건 “멈추는 방법”이 아니라 “안전하게 깨우고 종료시키는 방법”입니다.

  • interrupt가 하는 일
    • sleep() 중인 스레드를 interrupt()로 깨울 수 있습니다.
    • 깨워지면서 InterruptedException이 발생합니다.
    • 이 예외를 잡는 지점이 “종료 신호 처리 지점”이 됩니다.
  • 왜 flag보다 나은가
    • boolean flag는 스레드가 잠들어 있으면 즉시 반응하지 못합니다.
    • interrupt()는 잠든 상태를 즉시 깨우는 트리거가 됩니다.
    • 종료를 “폴링”이 아니라 “이벤트”로 처리할 수 있습니다.
  • 권장 패턴
    • InterruptedException을 잡으면
      • 작업 종료 루틴으로 빠지거나
      • 인터럽트 상태를 복원하고(Thread.currentThread().interrupt())
      • 상위 로직에 종료 의도를 전달합니다.

스레드의 속성과 생명주기

스레드 주요 속성

스레드는 실행 단위이며 다음 속성을 가집니다.

  • 식별자
    • ID
    • Name
  • 우선순위
    • Priority
  • 상태
    • NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
  • 관계
    • ThreadGroup

우선순위와 스케줄링의 현실

자바는 우선순위를 제공하지만, 절대적인 실행 순서를 보장하지 않습니다.

  • 자바 우선순위 범위
    • MIN_PRIORITY(1)
    • NORM_PRIORITY(5)
    • MAX_PRIORITY(10)
  • 주의
    • 실제 스케줄링은 JVM이 아니라 OS가 결정합니다.
    • JVM은 힌트를 줄 뿐이고 OS 정책이 최종입니다.
    • “우선순위 높이니까 먼저 실행”은 위험한 기대입니다.

스레드 상태 State 정리

스레드는 생성부터 종료까지 상태를 옮겨 다닙니다.

  • NEW
    • 객체만 만들어진 상태입니다.
    • start()가 호출되지 않았습니다.
  • RUNNABLE
    • 실행 중이거나 실행 가능한 상태입니다.
    • OS 스케줄러가 CPU를 줄 수 있는 후보입니다.
    • “항상 CPU를 받고 있다”는 뜻은 아닙니다.
  • TERMINATED
    • run()이 끝나서 종료된 상태입니다.
  • BLOCKED
    • 락을 얻으려고 대기하는 상태입니다.
    • synchronized 진입 시 락이 이미 점유 중이면 발생합니다.
    • 신호가 아니라 “락이 풀리는 순간”이 핵심입니다.
  • WAITING
    • 신호를 기다리는 상태입니다.
    • wait(), join() 등에서 진입합니다.
    • notify()나 대상 스레드 종료 같은 이벤트가 필요합니다.
  • TIMED_WAITING
    • 시간을 정해두고 기다립니다.
    • sleep(ms), wait(ms), join(ms)가 대표입니다.
  • BLOCKED vs WAITING 차이
    • BLOCKED는 락을 기다림입니다.
    • WAITING은 신호를 기다림입니다.
    • 멈추는 이유가 다르기 때문에 튜닝 포인트도 달라집니다.

멀티스레드가 필요한 이유

멀티스레드는 “CPU를 더 쓰기 위해서”만이 아니라 “멈추지 않는 UX”를 위해 필요합니다.

단일 스레드의 문제

I/O 작업은 느립니다.

  • 느린 작업 예
    • 파일 읽기
    • 네트워크 통신
    • DB 요청
  • UI에서의 문제
    • UI 스레드가 I/O를 하면 이벤트 루프가 멈춥니다.
    • 화면이 굳는 프리징이 발생합니다.

해결은 스레드 분리입니다

역할을 나눕니다.

  • UI 스레드
    • 입력 처리
    • 화면 갱신
    • 이벤트 루프 유지
  • 작업 스레드
    • 오래 걸리는 I/O 처리
    • 백그라운드 작업 수행
  • 효과
    • UX가 유지됩니다.
    • 사용자 입장에서는 “앱이 멈추지 않음”이 가장 큰 가치입니다.

데몬 스레드 Daemon Thread

데몬 스레드는 “프로그램이 살아있는 동안만 의미가 있는 보조 작업”에 적합합니다.

  • 특징
    • 메인 스레드가 종료되면 데몬도 함께 종료됩니다.
    • JVM이 데몬 스레드를 기다려주지 않습니다.
    • 즉, 작업이 남아 있어도 강제 종료될 수 있습니다.
  • 사용법
    • 반드시 start() 전에 setDaemon(true)를 호출합니다.
    • 시작 이후에는 변경할 수 없습니다.
  • 예시
    • 가비지 컬렉터
    • 로그 정리
    • 주기적 자동 저장(데이터 손실 허용 여부를 먼저 검토해야 합니다)

Join 메서드와 JVM 스레드 모델

join은 확실한 순서 제어입니다

순서를 “추측”하지 말고 “기다림으로 보장”합니다.

  • 동작
    • threadA.join()을 호출하면
    • 호출한 스레드는 threadA가 끝날 때까지 기다립니다.
  • 의미
    • 비동기 작업의 완료 시점을 명확히 다룹니다.
    • “끝났을 것 같다”가 아니라 “끝날 때까지 기다린다”입니다.
  • 주의
    • 대상 스레드가 끝나지 않으면 무한 대기합니다.
    • InterruptedException 처리가 필요합니다.
    • 필요하면 join(timeout)을 사용합니다.

JVM과 OS 스레드 관계를 알아야 하는 이유

자바는 편하지만, 성능을 보려면 매핑 구조를 알아야 합니다.

  • 추상화
    • 자바 코드는 사용자 영역에서 스레드를 다룹니다.
    • 실제 스케줄링은 커널 영역에서 이뤄집니다.
  • 흔한 구조
    • 최신 JVM은 보통 자바 스레드와 OS 스레드를 1대1로 매핑