Java 멀티스레드 환경에서 타이머 구현하기

1. 타이머의 개념과 필요성

타이머는 특정 시점이 되면 지정된 동작을 수행하도록 예약하는 메커니즘입니다. Java의 java.util.Timer 클래스가 대표적인 구현체이며, schedule() 메서드를 통해 작업을 예약합니다.

실무에서는 제한 없는 대기를 피하기 위해 필수적으로 사용됩니다. 예를 들어, 웹 브라우저의 요청 타임아웃(504 Gateway Timeout), 데이터베이스 백업, 캐시 갱신, 예약 메일 발송 등 다양한 곳에서 활용됩니다.

2. Timer 클래스 기본 활용

2.1 생성 및 간단한 예제

import java.util.Timer;
import java.util.TimerTask;

public class BasicTimerDemo {
    public static void main(String[] args) {
        Timer alarm = new Timer();
        
        alarm.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("타이머 작동!");
            }
        }, 2000);  // 2초 후 실행
        
        System.out.println("즉시 출력됨");
    }
}

실행 시 "즉시 출력됨"이 먼저 표시되고, 2초 경과 후 "타이머 작동!"이 출력됩니다. 주목할 점은 프로그램이 즉시 종료되지 않는다는 것인데, 이는 Timer 내에서 데몬이 아닌 일반 스레드를 생성하여 실행하기 때문입니다.

2.2 schedule() 메서드 오버로딩

메서드 시그니처설명
schedule(TimerTask t, long delay)delay(ms) 후 단일 실행
schedule(TimerTask t, Date when)특정 시점 단일 실행
schedule(TimerTask t, long delay, long period)delay 후 시작, period 간격 반복
schedule(TimerTask t, Date first, long period)특정 시점 시작, period 간격 반복

TimerTaskRunnable을 구현한 추상 클래스이므로, run() 메서드를 재정의하여 실행할 작업을 정의합니다.

2.3 다중 작업 관리

public class MultiTaskExample {
    public static void main(String[] args) {
        Timer scheduler = new Timer();
        String[] messages = {"알파", "베타", "감마", "델타", "엡실론"};
        
        for (int i = 0; i < messages.length; i++) {
            final int idx = i;
            scheduler.schedule(new TimerTask() {
                @Override
                public void run() {
                    System.out.println(messages[idx]);
                }
            }, (i + 1) * 1000L);
        }
    }
}

수많은 작업을 각각의 스레드로 처리하면 자원 낭비가 심합니다. Timer는 내부적으로 단일 워커 스레드로 작업을 관리하며, 실행 시점을 기준으로 정렬하여 순차적으로 처리합니다.

3. 커스텀 타이머 직접 구현

3.1 핵심 설계 고려사항

직접 구현 시 다음 두 가지가 핵심입니다:

  • 다중 작업의 효율적 관리: 실행 시점이 가장 임박한 작업을 빠르게 찾아야 함 → 우선순위 큐(Heap) 활용
  • 스레드 안전성 보장: 다중 스레드 환경에서의 동시 접근 처리 → PriorityBlockingQueue 활용

3.2 기본 골격 구현

import java.util.concurrent.PriorityBlockingQueue;

public class CustomTimer {
    // 작업 정보를 담는 클래스
    private static class Job implements Comparable<Job> {
        private final Runnable action;
        private final long executeAt;  // 절대 시간(밀리초)
        
        Job(Runnable action, long delayMillis) {
            this.action = action;
            this.executeAt = System.currentTimeMillis() + delayMillis;
        }
        
        @Override
        public int compareTo(Job other) {
            return Long.compare(this.executeAt, other.executeAt);
        }
    }
    
    private final PriorityBlockingQueue<Job> taskQueue = new PriorityBlockingQueue<>();
    
    public CustomTimer() {
        Thread worker = new Thread(this::processLoop);
        worker.start();
    }
    
    public void register(Runnable task, long afterMillis) {
        taskQueue.put(new Job(task, afterMillis));
    }
    
    private void processLoop() {
        while (true) {
            try {
                Job current = taskQueue.take();
                long remaining = current.executeAt - System.currentTimeMillis();
                
                if (remaining <= 0) {
                    current.action.run();
                } else {
                    taskQueue.put(current);  // 아직 시간 안 됨, 다시 큐에 삽입
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            }
        }
    }
}

3.3 개선: CPU 점유율 문제 해결

위 구현의 processLoop()는 작업 시간이 아닐 때도 반복적으로 큐를 확인하며 busy-waiting 현상을 일으킵니다. 이를 wait/notify로 개선합니다:

import java.util.concurrent.PriorityBlockingQueue;

public class OptimizedTimer {
    private static class Job implements Comparable<Job> {
        private final Runnable action;
        private final long triggerTime;
        
        Job(Runnable action, long delay) {
            this.action = action;
            this.triggerTime = System.currentTimeMillis() + delay;
        }
        
        @Override
        public int compareTo(Job other) {
            return Long.compare(this.triggerTime, other.triggerTime);
        }
    }
    
    private final Object guard = new Object();
    private final PriorityBlockingQueue<Job> queue = new PriorityBlockingQueue<>();
    
    public OptimizedTimer() {
        Thread executor = new Thread(this::runExecutor);
        executor.start();
    }
    
    public void addTask(Runnable work, long postponeMillis) {
        Job newJob = new Job(work, postponeMillis);
        queue.put(newJob);
        
        synchronized (guard) {
            guard.notify();  // 새 작업이 더 빨리 실행될 수 있으므로 깨움
        }
    }
    
    private void runExecutor() {
        while (true) {
            synchronized (guard) {
                try {
                    Job earliest = queue.take();
                    long now = System.currentTimeMillis();
                    long gap = earliest.triggerTime - now;
                    
                    if (gap <= 0) {
                        earliest.action.run();
                    } else {
                        queue.put(earliest);
                        guard.wait(gap);  // 계산된 시간만큼 대기, CPU 양보
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }
            }
        }
    }
}

3.4 동기화 범위의 중요성

addTask() 내에서 queue.put()guard.notify() 사이의 동기화 범위를 주의 깊게 설정해야 합니다. 다음과 같이 잘못된 구조는 Race Condition을 유발합니다:

// ❌ 잘못된 예: notify가 wait 이전에 실행될 수 있음
public void addTaskWrong(Runnable work, long delay) {
    synchronized (guard) {
        queue.put(new Job(work, delay));
    }
    // 임계영역 밖에서 notify → notify가 먼저 실행되고 wait에 갇힐 수 있음
    guard.notify();
}

올바른 방식은 put()notify()동일한 synchronized 블록 내에서 수행하여, 워커 스레드가 wait()에 진입하기 전에 notify()가 누락되는 상황을 방지하는 것입니다.

4. 최종 검증 코드

public class TimerVerification {
    public static void main(String[] args) {
        OptimizedTimer timer = new OptimizedTimer();
        
        timer.addTask(() -> System.out.println("4초 작업 완료"), 4000);
        timer.addTask(() -> System.out.println("1초 작업 완료"), 1000);
        timer.addTask(() -> System.out.println("3초 작업 완료"), 3000);
        timer.addTask(() -> System.out.println("2초 작업 완료"), 2000);
        
        System.out.println("모든 작업 예약 완료");
    }
}

실행 결과: "모든 작업 예약 완료" 즉시 출력 후, 1초, 2초, 3초, 4초 순으로 각 작업이 실행됩니다.

태그: java PriorityBlockingQueue Timer Multi-threading wait-notify

6월 8일 16:28에 게시됨