Go 언어 타이밍 제어: Timer, Ticker 및 이벤트 루프 설계 패턴

서론: Go 의 시간 기반 제어 메커니즘

Go 프로그램에서 시간 관련 로직을 구현할 때 time 패키지는 두 가지 주요 접근 방식을 제공합니다. 첫 번째는 특정 시간이 경과한 후 단 한 번만 실행되는 Timer, 두 번째는 설정된 간격으로 지속적으로 이벤트를 발생시키는 Ticker입니다. 이 둘은 채널 (Channel) 기반의 통신 모델을 사용하여 고루틴 간의 동기화를 가능하게 합니다.

Timer: 일회성 대기 및 리셋 전략

Timer 는 지정한 지속 시간 (Duration) 이 끝난 시점에 채널을 통해 신호를 보냅니다. 기본적으로 한 번만 작동하지만, Reset 메서드를 호출하여 다시 설정함으로써 주기적인 동작을 흉내 낼 수 있습니다.

내부 구조와 동작 원리

time.Timer 타입은 크게 타임스탬프를 수신받는 읽기 전용 채널 (C) 과 실제 스케줄링을 담당하는 런타임 구조체 (runtimeTimer) 로 구성됩니다. 내부적으로 운영체제의 시계 인터럽트보다는, Go 런타임이 최적화된 최소 힙 (Min-Heap) 구조를 사용하여 만료 시간을 가진 타이머들을 정렬하고 관리합니다. 이 방식은 효율적인 우선순위 기반 처리를 보장하며, 별도의 스레드인 timerproc 에서 타이머 상태 변화를 모니터링합니다.

실제 적용 예시

아래 코드는 3 초마다 작업을 수행하되, 명시적으로 Reset 을 호출하여 타이머를 재활용하는 패턴을 보여줍니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	// 3 초 지연된 타이머 초기화
	timer := time.NewTimer(3 * time.Second)
	counter := 0
	
	select {
	case <-timer.C:
		// 첫 번째 트리거
		counter++
		fmt.Printf("초기 트리거 실행 (카운트: %d)\n", counter)
		
		// 주기화를 위해 타이머 리셋
		// 이전 상태가 이미 실행되었으므로 안전한 리셋
		if !timer.Stop() {
			select {
			case <-timer.C:
			default:
			}
		}
		timer.Reset(3 * time.Second)
		
		select {
		case <-timer.C:
			// 리셋된 상태에서의 두 번째 트리거
			counter++
			fmt.Printf("리셋된 트리거 실행 (카운트: %d)\n", counter)
			
			// 더 이상 필요 없으므로 정리
			timer.Stop()
		}
	}
}

참고로 Stop() 호출 시 이미 타임아웃되어 채널에 값이 있다면 값을 먼저 비워주는 패턴이 필요합니다. 그렇지 않으면 다음 Reset 전에 남은 데이터가 있을 경우 중복 실행될 위험이 있습니다.

AfterFunc: 콜백 함수 활용

time.AfterFunc 는 일정 시간이 지난 후 특정 함수를 다른 고루틴에서 실행하게 하는 유틸리티입니다. NewTimer 와 유사하지만 결과값 대신 즉시 함수 호출을 반환한다는 점이 다릅니다.

package main

import (
	"fmt"
	"time"
)

func main() {
	duration := 2 * time.Second
	
	task := func() {
		fmt.Println("지정 시간 후 콜백이 호출됨")
	}
	
	// 지연 후 실행 예약
	t := time.AfterFunc(duration, task)
	
	// 작업 취소 필요 시 (예: 1 초 전에 중단)
	time.Sleep(1 * time.Second)
	if t.Stop() {
		fmt.Println("예약된 작업이 성공적으로 취소되었습니다.")
	} else {
		fmt.Println("작업이 이미 실행되었거나 취소 대상이 아닙니다.")
	}
	
	// 메인 루틴 대기
	time.Sleep(3 * time.Second)
}

Ticker: 지속적인 스트림 생성

Ticker 는 Timer 와 달리 설정된 주기로 무한히 이벤트를 발생시킵니다. 이는 채널을 통해 현재 시간 정보를 주기적으로 발송하는 형태이며, 수신자가 값을 받아야만 다음 주기를 처리할 수 있는 동시성 모델입니다.

주기와 대기 시간 충돌 처리

작업 소요 시간이 타이커 간격보다 길어질 경우, 다음 틱이 도달했을 때 채널 버퍼의 상황에 따라 동작이 달라집니다. 일반적인 NewTicker 은 채널 버퍼 크기가 1 이며, 수신자가 읽지 않은 상태에서 새로운 틱이 들어오면 이전 틱이 덮어써지거나 버려질 수 있습니다. 따라서 중요한 사건 누락을 방지하기 위해서는 비활성 기간 동안 틱을 누적하지 않도록 주의해야 합니다.

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	// 1 초 간격 틱커 생성
	intervaler := time.NewTicker(1 * time.Second)
	defer intervaler.Stop() // 자원 방출
	
	done := make(chan struct{})
	var wg sync.WaitGroup
	
	// 종료 신호를 기다리는 별도 고루틴
	wg.Add(1)
	go func() {
		defer wg.Done()
		time.Sleep(5 * time.Second)
		close(done)
	}()
	
	count := 0
Loop:
	for {
		select {
		case _, ok := <-done:
			if !ok {
				fmt.Println("프로그램 종료 시점 도래")
				break Loop
			}
		case now := <-intervaler.C:
			count++
			fmt.Printf("[%d] 시간 업데이트: %s\n", count, now.Format("15:04:05"))
			
			// 작업 처리 시뮬레이션
			// 만약 여기에 긴 처리 로직이 있으면 다음 틱이 버릴 수 있음
		}
	}
	
	wg.Wait()
	fmt.Println("모든 작업 완료")
}

자원 관리 및 주의사항

  • Stop() 의 역할: Stop 을 호출하면 이후 틱 발생이 중지되지만, 채널 자체는 닫히지 않습니다. 이는 다른 고루틴이 읽음 중일 경우 채널 닫힘으로 인한 패닉 (Panic) 을 방지하기 위한 설계입니다.
  • 초기 지연: NewTicker 호출 시점은 바로 첫 번째 틱이 발생하는 것이 아니라, 지정된 기간만큼 먼저 흐른 후에 시작됩니다.
  • 리소스 누수 방지: 타이머나 틱커가 더 이상 불필요해진 경우 반드시 Stop() 을 호출하여 시스템 리소스를 해제해야 합니다.

태그: go Golang time package concurrency channel

6월 2일 16:00에 게시됨