Go

Go 언어 time.Timer#Reset() 완벽 가이드: 올바른 사용법 알아볼까요?

드리프트2 2024. 9. 19. 22:42

Go 언어 time.Timer#Reset() 완벽 가이드: 올바른 사용법 알아볼까요?

Go 언어에서 time.Timer를 사용하고 계신가요?

 

매우 기본적인 요소인데도 올바르게 사용하는 것은 의외로 어렵습니다.

 

얼마 전에 직접 겪은 사례와 함께 그 올바른 사용법을 소개하려고 합니다.


TL;DR

  • time.Timer#Stop() 후에는 time.Timer.C를 읽어서 남은 값을 버려야 할 때가 있습니다.
  • ti := time.NewTimer(5 * time.Second) // ... if !ti.Stop() { <-ti.C }
  • time.Timer#Reset()은 타이머가 정지된 상태에서 호출해야 합니다.
  • // ... if !ti.Stop() { <-ti.C } ti.Reset(3 * time.Second) // 3초 더 기다립니다.
  • 타이머에 대한 이러한 조작은 동시에 실행하면 안 됩니다.

Stop()의 올바른 사용법

앞서 보신 것처럼 time.TimerStop()을 호출할 때의 올바른 방법은 다음과 같습니다.

if !ti.Stop() {
    <-ti.C
}

 

Stop()의 반환값은 이 호출로 타이머가 멈췄는지 여부를 나타냅니다.

 

이미 멈춰있다면 false를 반환합니다.

 

이때, 타이머는 이미 발행되었고 C <-chan Time에 데이터가 들어 있습니다. 이를 소비하여 남은 값을 버려주어야 합니다.

 

그렇지 않으면 예상치 못한 동작이 발생할 수 있습니다.


Reset()의 올바른 사용법

Reset()은 타이머의 시간을 재설정하는 메서드입니다. 사용 예시는 다음 두 가지가 있습니다.

  1. 이미 발행된 타이머를 재사용하기
  2. 아직 발행되지 않은 타이머의 시간을 재설정하거나 연장하기

특히 두 번째 경우, 현재 동작 중인 타이머를 일단 멈추고 새로운 시간을 설정합니다.

 

실제로 Reset() 내부에서는 저수준 타이머를 멈추는 절차가 있지만, Reset()을 호출하고 저수준 타이머가 멈출 때까지의 사이에 발행될 수 있습니다.

 

이를 무시하면 지정된 시간이 지나지 않았는데도 타이머가 발행된 것처럼 되어버리니, 다음과 같은 코드로 확실히 멈춰주어야 합니다.

if !ti.Stop() {
    <-ti.C
}
ti.Reset(3 * time.Second) // 3초 더 기다립니다.

 

하지만 이 코드를 첫 번째 사용 예시인 이미 발행되었고 ti.C를 이미 소비한 경우에 사용하면 <-ti.C 부분에서 대기 상태에 빠질 수 있습니다.

 

특히 타이머 발행을 별도의 고루틴에서 감시하는 경우에 주의해야 합니다.

 

잘못된 코드 예시:

ti := time.NewTimer(5 * time.Second)

go func() {
    <-ti.C
    // 타이머가 발행되었을 때 처리...
}()

// ... 타이머의 배경에서 작업 수행 ...

// 어떤 조건에서 타이머를 3초 연장
if someCondition {
    if !ti.Stop() {
        <-ti.C // 여기서 대기 상태에 빠질 수 있음
    }
    ti.Reset(3 * time.Second)
}

 

위 코드는 잘못된 예시이니 그대로 복사해서 사용하지 마세요.

 

올바른 타이머 Reset() 조작은 다음과 같습니다.

ti := time.NewTimer(5 * time.Second)

go func() {
    <-ti.C
    // 타이머가 발행되었을 때 처리...
}()

// ... 타이머의 배경에서 작업 수행 ...

// 어떤 조건에서 타이머를 3초 연장
if someCondition {
    if !ti.Stop() {
        select {
        case <-ti.C:
        default:
        }
    }
    ti.Reset(3 * time.Second)
}

동시 조작 금지

여기까지 time.Timer를 살펴봤는데요, 비슷한 기능으로 time.Ticker가 있습니다.

Ticker는 일정한 간격으로 반복 발행하는 타이머지만, 발행 간격 면에서는 유연성이 부족합니다.

 

예를 들어 30초 간격으로 반복되는 작업을 특정 시점에서 30초 연장하고 싶을 때, 구체적으로는 KeepAlive의 PING을 보내다가 다른 작업을 하면 PING 타이머를 리셋하는 용도로는 Ticker를 사용할 수 없습니다.

 

하지만 Timer라면 가능합니다.

 

여기에 비동기적인 Stop() 조작을 더하면 더욱 복잡해집니다.

 

앞서 보았듯이 time.Timer에 대한 동시 조작은 예상치 못한 동작을 유발할 수 있습니다.

 

따라서 동시 조작을 하지 않는 것이 중요하며, 공식 문서에도 다음과 같이 기재되어 있습니다.

 

    • time.Timer#Reset 문서 중:
    • This should not be done concurrent to other receives from the Timer’s channel.

타이머의 채널에서 다른 수신과 동시에 수행해서는 안 됩니다.

 

    • time.Timer#Stop 문서 중:
    • This cannot be done concurrent to other receives from the Timer’s channel.

타이머의 채널에서 다른 수신과 동시에 수행할 수 없습니다.

 

이를 고려하면 타이머에 대한 조작은 하나의 고루틴에 모아서 처리하는 것이 좋을 것 같습니다.

 

조금 작성해보면 다음과 같습니다.

// 타이머의 생명주기를 관리
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

var mu sync.Mutex
rst := make(chan time.Duration)

go func() {
    ti := time.NewTimer(30 * time.Second)
    running := true
    stop := func() {
        if running && !ti.Stop() {
            <-ti.C
        }
        running = false
    }
    for {
        select {
        case <-ctx.Done():
            mu.Lock()
            close(rst)
            rst = nil
            mu.Unlock()
            stop()
            return
        case d := <-rst:
            stop()
            ti.Reset(d)
            running = true
        case <-ti.C:
            running = false
            // 타이머가 발행되었을 때 처리...
        }
    }
}()

reset := func(d time.Duration) {
    mu.Lock()
    if rst != nil {
        rst <- d
    }
    mu.Unlock()
}

// cancel() - 타이머 취소
// reset(30 * time.Second) - 30초 후에 다시 타이머 발행

 

이 예제에서는 Stop() 시에 select으로 <-ti.C를 감싸는 대신 변수 running으로 가드를 해보았습니다.

 

타이머에 대한 접근이 고루틴 내부로 모였기 때문에 간단한 방법으로도 충분합니다.


정리하며

아주 기본적인 time.Timer지만, 제대로 사용하려면 고려해야 할 점이 많다는 것을 알게 되었습니다.

 

방심하지 말고 항상 문서를 꼼꼼히 읽어 적절한 코드를 작성해야 합니다.