서론: 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()을 호출하여 시스템 리소스를 해제해야 합니다.