본문으로 건너뛰기

time

이 챕터에서 다루는 것

time 패키지는 두 개의 함정으로 유명하다. 하나는 레이아웃 문자열이고, 다른 하나는 time.Time이 사실 두 개의 시계를 담고 있다는 사실이다.

여기에 타임존, Timer 누수, 그리고 "시간에 의존하는 코드를 어떻게 테스트하는가"를 더한다. 마지막 항목은 8-7에서 배운 testing/synctest가 그대로 답이다.

문제 — 왜 시간이 어려운가

시간은 단순해 보이지만 실제로는 서로 다른 세 가지 개념이 겹쳐 있다.

  1. 순간(instant) — 절대적인 한 시점. UTC로 표현되고 전 세계가 같은 값을 본다.
  2. 벽시계 표현(wall clock) — 그 순간을 사람이 읽는 형태. 타임존에 따라 다르다.
  3. 경과 시간(elapsed) — 두 시점 사이의 거리. NTP 보정이나 서머타임과 무관해야 한다.

Go는 time.Time 하나에 1과 2를 담고, 3을 위해 단조 시계 판독값을 몰래 붙여 둔다. time.Duration은 3만 담는다. 이 구조를 알면 대부분의 함정이 설명된다.

Duration — 나노초를 담은 정수

time.Durationint64의 이름 있는 타입이고 단위는 나노초다. 그래서 산술이 그냥 된다.

examples/09-standard-library/03-time/main.go
// demoDuration은 Duration 산술과 서식이다.
func demoDuration() {
d := 90 * time.Minute
fmt.Printf("90분: %v / %.1f시간 / %d초\n", d, d.Hours(), int(d.Seconds()))

parsed, err := time.ParseDuration("1h30m")
if err != nil {
fmt.Fprintln(os.Stderr, "ParseDuration:", err)
return
}
fmt.Printf("ParseDuration(\"1h30m\") == 90*time.Minute: %t\n", parsed == d)

// 흔한 실수: 정수 변수에 곱하려면 형 변환이 필요하다.
seconds := 30
fmt.Printf("time.Duration(seconds)*time.Second = %v\n", time.Duration(seconds)*time.Second)
fmt.Printf("잘못: time.Duration(seconds) = %v (30나노초)\n\n", time.Duration(seconds))
}
90분: 1h30m0s / 1.5시간 / 5400초
ParseDuration("1h30m") == 90*time.Minute: true
time.Duration(seconds)*time.Second = 30s
잘못: time.Duration(seconds) = 30ns (30나노초)

마지막 두 줄이 중요하다. 90 * time.Minute이 되는 이유는 902-1에서 배운 타입 없는 상수라서 time.Duration으로 변환되기 때문이다. 반면 int 변수는 자동으로 변환되지 않는다.

seconds := 30
time.Sleep(seconds) // 컴파일 에러
time.Sleep(time.Duration(seconds)) // 30나노초 — 컴파일은 되고 의미가 틀렸다
time.Sleep(time.Duration(seconds) * time.Second) // 이게 맞다

두 번째 줄이 진짜 위험하다. 컴파일러가 잡아 주지 않는다. 설정 파일에서 읽은 timeout_seconds: 30을 이렇게 쓰면, 타임아웃이 30초가 아니라 30나노초가 되어 모든 요청이 즉시 실패한다. 설정 값은 처음부터 time.ParseDuration("30s")으로 읽는 편이 안전하다.

레이아웃 문자열 — 기준 시각을 쓴 것

다른 언어의 %Y-%m-%dyyyy-MM-dd 대신, Go는 하나의 기준 시각을 원하는 형식으로 적어서 형식을 지정한다.

2006-01-02 15:04:05 -0700 MST
1 2 3 4 5 6 7

연도가 2006, 월이 01, 일이 02, 시가 15(24시간제), 분이 04, 초가 05, 타임존 오프셋이 -0700이다. 순서를 1-2-3-4-5-6-7로 외운다. 12시간제는 03이다.

examples/09-standard-library/03-time/main.go
// 기준 시각은 2006-01-02 15:04:05 -0700 MST = 1월 2일 오후 3시 4분 5초, 2006년.
// 1 2 3 4 5 6 7 순서를 외우면 된다.
layouts := []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05Z07:00", // == time.RFC3339
"2006/01/02 03:04:05 PM",
"Mon Jan _2 15:04:05 2006",
"2006-01-02 15:04:05.000",
}
for _, l := range layouts {
fmt.Printf("%-28s → %s\n", l, t.Format(l))
}
fmt.Printf("%-28s → %s\n", "time.RFC3339 상수", t.Format(time.RFC3339))
2006-01-02 15:04:05 → 2026-08-11 17:05:03
2006-01-02T15:04:05Z07:00 → 2026-08-11T17:05:03+09:00
2006/01/02 03:04:05 PM → 2026/08/11 05:05:03 PM
Mon Jan _2 15:04:05 2006 → Tue Aug 11 17:05:03 2026
2006-01-02 15:04:05.000 → 2026-08-11 17:05:03.000
time.RFC3339 상수 → 2026-08-11T17:05:03+09:00

세부 규칙 몇 개.

  • Z07:00은 "UTC면 Z, 아니면 오프셋"이다. -07:00은 UTC에서도 +00:00을 쓴다.
  • _2는 한 자리 날짜 앞에 공백을 넣는다. 020을 넣는다. 2는 안 넣는다.
  • .000은 소수점 이하를 항상 세 자리로, .999는 뒤의 0을 지운다.
  • 직접 쓰지 말고 상수를 쓴다: time.RFC3339, time.RFC3339Nano, time.DateOnly, time.TimeOnly, time.DateTime.

:::warning 존재하지 않는 레이아웃은 조용히 통과한다 "2006-13-02"처럼 실수해도 컴파일 에러가 아니다. 13은 인식되지 않는 문자열로 취급되어 결과에 그대로 찍힌다. Format 결과를 눈으로 한 번 확인하는 습관이 필요하다. :::

Parse와 ParseInLocation — 같은 문자열, 다른 순간

time.Parse는 문자열에 오프셋 정보가 없으면 UTC로 간주한다. "우리 서비스는 한국 시간을 쓴다"고 생각하고 쓴 코드가 여기서 9시간 어긋난다.

examples/09-standard-library/03-time/main.go
// Parse는 오프셋 정보가 없으면 UTC로 해석한다.
utc, err := time.Parse(layout, input)
if err != nil {
return fmt.Errorf("Parse: %w", err)
}
// ParseInLocation은 지정한 위치의 벽시계로 해석한다.
local, err := time.ParseInLocation(layout, input, seoul)
if err != nil {
return fmt.Errorf("ParseInLocation: %w", err)
}

fmt.Printf("Parse: %s (Unix %d)\n", utc.Format(time.RFC3339), utc.Unix())
fmt.Printf("ParseInLocation: %s (Unix %d)\n", local.Format(time.RFC3339), local.Unix())
fmt.Printf("차이: %v — 같은 문자열, 다른 순간\n", utc.Sub(local))

// 레이아웃이 안 맞으면 어떤 에러가 나는지.
_, err = time.Parse(layout, "2026-08-11T17:05:03Z")
fmt.Printf("형식 불일치: %v\n\n", err)
Parse: 2026-08-11T17:05:03Z (Unix 1786467903)
ParseInLocation: 2026-08-11T17:05:03+09:00 (Unix 1786435503)
차이: 9h0m0s — 같은 문자열, 다른 순간
형식 불일치: parsing time "2026-08-11T17:05:03Z" as "2006-01-02 15:04:05": cannot parse "T17:05:03Z" as " "

입력 문자열에 오프셋이 없다면 ParseInLocation을 쓴다. 어떤 위치로 해석할지는 데이터의 출처가 결정하는 문제이지, 프로그램이 도는 기계가 결정할 일이 아니다.

time.Local의 함정

time.Local프로세스가 도는 기계의 타임존이다. TZ 환경 변수와 /etc/localtime이 결정한다.

t := time.Date(2026, 8, 11, 9, 0, 0, 0, time.Local)

이 코드는 개발자의 노트북에서는 KST 09:00, 컨테이너에서는 UTC 09:00을 만든다. 같은 소스가 다른 순간을 가리킨다. 로그 시각이 어긋나거나, 배치 작업이 9시간 늦게 도는 사고가 여기서 나온다.

규칙은 단순하다.

  • 저장하고 전송하는 것은 언제나 UTC 또는 오프셋이 붙은 RFC 3339다.
  • 사용자에게 보여 줄 때만 In(loc)으로 변환한다.
  • loc은 명시적으로 time.LoadLocation("Asia/Seoul")로 얻는다. time.Local은 "이 기계 기준"이라는 뜻이 필요한 CLI 도구에서만 쓴다.
examples/09-standard-library/03-time/main.go
instant := time.Date(2026, time.August, 11, 17, 5, 3, 0, seoul)
for _, loc := range []*time.Location{time.UTC, seoul, ny} {
in := instant.In(loc)
name, offset := in.Zone()
fmt.Printf("%-18s %s (zone=%s offset=%ds)\n", loc, in.Format(time.RFC3339), name, offset)
}
fmt.Printf("같은 순간인가: %t\n", instant.In(ny).Equal(instant))
fmt.Printf("== 로 비교하면: %t (Location 포인터까지 비교한다)\n\n", instant.In(ny) == instant)
UTC 2026-08-11T08:05:03Z (zone=UTC offset=0s)
Asia/Seoul 2026-08-11T17:05:03+09:00 (zone=KST offset=32400s)
America/New_York 2026-08-11T04:05:03-04:00 (zone=EDT offset=-14400s)
같은 순간인가: true
== 로 비교하면: false (Location 포인터까지 비교한다)

마지막 두 줄이 핵심이다. time.Time==로 비교하지 않는다. time.Time은 벽시계·단조 판독값·*Location 세 필드를 가진 구조체라 ==는 그 셋을 전부 비교한다. 순간이 같은지 물으려면 Equal을 쓴다.

:::tip tzdata를 바이너리에 넣기 time.LoadLocation은 시스템의 타임존 데이터베이스를 읽는다. FROM scratch 컨테이너에는 그게 없어서 런타임에 실패한다.

import _ "time/tzdata"

이 한 줄이 tzdata를 바이너리에 박아 넣는다. 크기가 약간 늘지만 "컨테이너에서만 unknown time zone 에러"라는 사고를 없앤다. 이 챕터의 예제도 그렇게 하고 있다. :::

단조 시계 — Time 안의 두 번째 시계

time.Now()가 반환하는 Time에는 벽시계 값과 함께 단조 시계 판독값이 숨어 있다. 벽시계는 NTP 보정으로 뒤로 갈 수 있지만 단조 시계는 그렇지 않다.

Sub, Since, Until은 양쪽 값에 단조 판독값이 있으면 그것을 쓴다. 그래서 경과 시간이 음수가 되거나 갑자기 튀는 일이 없다.

examples/09-standard-library/03-time/main.go
// demoMonotonic은 단조 시계 판독값이 붙고 떨어지는 지점을 보여 준다.
func demoMonotonic() {
now := time.Now()
stripped := now.Round(0) // 단조 판독값을 떼어낸다
roundTripped, err := time.Parse(time.RFC3339Nano, now.Format(time.RFC3339Nano))
if err != nil {
fmt.Fprintln(os.Stderr, "Parse:", err)
return
}

// 값 자체는 실행마다 다르므로 "m=이 붙어 있는가"만 본다.
fmt.Printf("time.Now()에 단조 판독값이 있는가: %t\n", strings.Contains(now.String(), " m="))
fmt.Printf("Round(0) 뒤에도 있는가: %t\n", strings.Contains(stripped.String(), " m="))
fmt.Printf("포맷 후 재파싱한 값에 있는가: %t\n", strings.Contains(roundTripped.String(), " m="))

// Sub와 Since는 단조 판독값이 있으면 그것을 쓴다. NTP 보정에 영향받지 않는다.
fmt.Printf("now.Sub(stripped) == 0: %t (같은 순간이지만 한쪽만 단조)\n",
now.Sub(stripped) == 0)
fmt.Printf("now.Equal(stripped): %t\n", now.Equal(stripped))
fmt.Printf("now == stripped: %t ← == 로 Time을 비교하지 않는다\n", now == stripped)
}
time.Now()에 단조 판독값이 있는가: true
Round(0) 뒤에도 있는가: false
포맷 후 재파싱한 값에 있는가: false
now.Sub(stripped) == 0: true (같은 순간이지만 한쪽만 단조)
now.Equal(stripped): true
now == stripped: false ← == 로 Time을 비교하지 않는다

:::note 이 출력의 절대값은 실행마다 다르다 time.Now()가 주는 실제 시각은 당연히 매번 다르다. 그래서 예제는 값을 찍지 않고 "단조 판독값이 붙어 있는가"라는 참/거짓만 찍는다. 이 참/거짓은 결정적이다. :::

단조 판독값이 떨어지는 지점을 외워 두면 좋다.

  • t.Round(0), t.Truncate(d), t.UTC(), t.In(loc), t.Local()
  • FormatParse 왕복 (직렬화하면 사라진다)
  • time.Date, time.Unix로 만든 값에는 애초에 없다
  • JSON으로 나갔다 들어오면 없다

fmt.Println(t)m=+0.000123처럼 붙는 꼬리가 그 판독값이다. 로그나 응답에 이게 보이면 Format을 쓰지 않고 %v로 찍고 있다는 뜻이다.

Timer와 Ticker — 옛 규칙이 바뀐 자리

timer := time.NewTimer(d) // 한 번
ticker := time.NewTicker(d) // 반복

인터넷에 있는 Go 자료 대부분은 이렇게 말한다. "time.Aftertime.Tick은 누수다. 반드시 NewTimer/NewTicker를 쓰고 Stop을 불러라."

이 조언은 Go 1.23부터 낡았다. go doc time.After가 직접 그렇게 말한다.

Before Go 1.23, this documentation warned that the underlying Timer would not be recovered by the garbage collector until the timer fired... As of Go 1.23, the garbage collector can recover unreferenced, unstopped timers. There is no reason to prefer NewTimer when After will do.

time.Tick도 같다. 참조가 끊기면 만료 전이라도 회수된다. 같은 릴리스에서 타이머 채널이 버퍼 없는 채널로 바뀌어서, Stop이나 Reset 뒤에 낡은 시각이 흘러나오던 문제도 사라졌다.

그렇다면 Stop은 이제 언제 부르는가. GC를 돕기 위해서가 아니라, 지금 당장 끄기 위해서다.

examples/09-standard-library/03-time/heartbeat/heartbeat.go
// Run은 ctx가 끝날 때까지 interval마다 fn을 호출하고 호출 횟수를 반환한다.
//
// defer t.Stop()은 GC를 돕기 위한 게 아니다(Go 1.23부터 참조가 끊긴 Ticker는
// Stop 없이도 회수된다). 함수가 반환하는 그 순간 발화를 멈추게 하는 것이 목적이다.
// GC 시점까지 기다리지 않는다.
func Run(ctx context.Context, interval time.Duration, fn func(time.Time)) int {
t := time.NewTicker(interval)
defer t.Stop()

n := 0
for {
select {
case <-ctx.Done():
return n
case tick := <-t.C:
n++
fn(tick)
}
}
}
examples/09-standard-library/03-time/heartbeat/heartbeat.go
// WaitFor는 ch에서 값 하나를 기다리되 d 안에 오지 않으면 포기한다.
//
// 여기서는 time.After(d)를 써도 똑같이 동작한다. Go 1.23부터 참조가 끊긴
// 타이머는 만료 전이라도 회수되기 때문이다. NewTimer를 쓴 이유는 딱 하나 —
// 반환하는 즉시 타이머를 끄기 위해서다. Reset이 필요하면 그때는 선택의 여지가 없다.
func WaitFor[T any](ch <-chan T, d time.Duration) (T, bool) {
timer := time.NewTimer(d)
defer timer.Stop()

select {
case v := <-ch:
return v, true
case <-timer.C:
var zero T
return zero, false
}
}

정리하면 이렇다.

상황지금의 답
한 번 기다렸다 포기time.After로 충분하다
즉시 끄고 싶다 / 재사용한다NewTimer + Stop/Reset
오래 도는 루프 안의 반복 틱NewTicker + defer Stop
살아 있는 참조가 남는 TickerStop이 필수다 — 참조가 있으면 GC가 못 가져간다

마지막 줄이 여전히 진짜 누수가 나는 자리다. 구조체 필드에 Ticker를 넣어 두고 그 구조체가 계속 살아 있으면, Stop 없이는 프로세스가 끝날 때까지 깨어난다.

:::note GODEBUG로 옛 동작 되돌리기 GODEBUG=asynctimerchan=1이 1.23 이전 동작(만료 전 GC 없음, 버퍼 있는 채널)을 되살린다. 다만 이 설정은 Go 1.27 이후 제거될 예정이다. 새 코드가 기대면 안 된다. :::

시간에 의존하는 코드를 테스트하기

두 가지 도구를 쓴다.

1. 시계 주입. 8-3에서 정한 패턴이다. time.Now를 직접 부르지 않고 now func() time.Time 필드를 지난다. 9-8의 Store가 그 형태다.

2. testing/synctest. Sleep이나 Ticker가 실제로 들어간 코드는 시계 주입만으로 안 된다. 버블 안에서는 시간이 가상이라 1시간 대기가 즉시 끝난다.

examples/09-standard-library/03-time/heartbeat/heartbeat_test.go
// 가상 시간이라 10초짜리 테스트가 0.00초에 끝난다.
func TestRunTicks(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

var seen []time.Time
n := heartbeat.Run(ctx, time.Second, func(tick time.Time) {
seen = append(seen, tick)
})

// 10초 동안 1초 간격이면 1..9초 시점의 9번. 10초 시점에는
// ctx.Done과 틱이 동시에 준비되고 select가 어느 쪽을 고를지 모른다.
if n < 9 || n > 10 {
t.Fatalf("호출 횟수 = %d, want 9 또는 10", n)
}
if len(seen) != n {
t.Fatalf("fn 호출 = %d, 반환값 = %d", len(seen), n)
}
// 틱 간격은 가상 시간에서 정확히 1초다.
for i := 1; i < len(seen); i++ {
if got := seen[i].Sub(seen[i-1]); got != time.Second {
t.Errorf("틱 %d 간격 = %v, want 1s", i, got)
}
}
})
}

주목할 곳은 got != time.Second라는 정확한 등호다. 실제 시간이었다면 1s ± 5ms 같은 허용 범위를 써야 했을 것이고, 그런 테스트는 CI가 바쁠 때 깨진다. 가상 시간에서는 등호가 성립한다.

n에 범위를 준 것은 시간 정밀도 문제가 아니라 select의 무작위 선택 때문이다. 10초 시점에 ctx.Done()과 틱이 둘 다 준비되면 어느 쪽이 뽑힐지 정해져 있지 않다 — 7-4에서 본 성질이다.

타임아웃 쪽도 같은 방식으로 정확히 단언한다.

examples/09-standard-library/03-time/heartbeat/heartbeat_test.go
func TestWaitForTimesOut(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan string)
start := time.Now()

v, ok := heartbeat.WaitFor(ch, 3*time.Second)
if ok {
t.Fatalf("WaitFor() = (%q, true), want 타임아웃", v)
}
if v != "" {
t.Errorf("타임아웃 시 값 = %q, want 제로값", v)
}
if got := time.Since(start); got != 3*time.Second {
t.Errorf("경과 = %v, want 정확히 3s", got)
}
})
}

흔한 실수

1. time.Time==로 비교한다

Location 포인터와 단조 판독값까지 비교된다. Equal을 쓴다. time.Time을 맵 키로 쓰는 것도 같은 이유로 위험하다.

2. int 변수를 time.Duration으로 형 변환만 한다

time.Duration(30)은 30나노초다. 컴파일도 되고 테스트도 대충 통과한다. 설정 값은 time.ParseDuration("30s")로 읽는다.

3. 오프셋 없는 문자열을 time.Parse로 읽는다

UTC로 해석된다. ParseInLocation을 쓴다.

4. time.Local에 의존한다

노트북과 컨테이너가 다르게 동작한다. 저장·전송은 UTC, 표시할 때만 변환.

5. 낡은 time.After 조언을 그대로 따른다

"time.After는 누수다"는 Go 1.23 이전 이야기다. 지금은 참조가 끊기면 회수된다. 쓸데없이 NewTimer + Stop으로 코드를 늘릴 이유가 없다.

6. 살아 있는 구조체에 Ticker를 넣고 Stop을 빠뜨린다

이건 지금도 진짜 누수다. 참조가 남아 있으면 GC가 가져가지 못하고 프로세스가 끝날 때까지 깨어난다.

7. 테스트에서 time.Sleep으로 타이밍을 맞춘다

8-7에서 다룬 그대로다. 짧으면 플래키하고 길면 느리다. synctest를 쓴다.

8. 날짜 계산을 Add로 한다

t.Add(24 * time.Hour)는 "24시간 뒤"이지 "내일 같은 시각"이 아니다. 서머타임이 있는 지역에서는 다르다. 달력 계산은 AddDate(0, 0, 1)을 쓴다.

정리

  • Duration은 나노초 정수다. 90 * time.Minute이 되는 것은 타입 없는 상수 덕분이고, int 변수는 time.Duration(n) * time.Second로 써야 한다.
  • 레이아웃은 기준 시각 2006-01-02 15:04:05 -0700 MST를 원하는 모양으로 적은 것이다. 인식되지 않는 문자는 그대로 출력되므로 조용히 틀린다. time.RFC3339 같은 상수를 쓴다.
  • time.Parse는 오프셋이 없으면 UTC로 해석한다. 데이터의 출처가 정한 위치가 있다면 ParseInLocation을 쓴다.
  • time.Local은 기계가 정한다. 저장·전송은 UTC, 표시할 때만 In(loc). 스크래치 컨테이너용으로는 import _ "time/tzdata".
  • time.Now()에는 단조 판독값이 붙어 있고, Round(0)·Format/Parse 왕복·JSON 직렬화에서 떨어진다. Sub/Since는 그것을 쓰므로 NTP 보정에 영향받지 않는다. 비교는 Equal로 한다.
  • "time.After는 누수"는 Go 1.23부터 사실이 아니다. 참조가 끊긴 타이머와 티커는 Stop 없이도 회수되고, 타이머 채널은 버퍼 없는 채널이 됐다. Stop은 이제 "GC를 돕기 위해서"가 아니라 "지금 끄기 위해서" 부른다. 살아 있는 구조체가 참조하는 Ticker는 여전히 Stop이 필수다.
  • **시간 의존 테스트는 시계 주입(8-3) 또는 synctest(8-7)**로 한다. 가상 시간에서는 elapsed != 3*time.Second 같은 정확한 등호가 성립한다.

연습문제

  1. heartbeat.WaitFortime.After를 쓰는 3줄짜리로 다시 써 보자. 테스트는 그대로 통과하는가? 그렇다면 NewTimer 버전이 남길 만한 가치가 있는 상황은 무엇인가? (힌트: Reset, 그리고 "언제 꺼지는가"를 정확히 알아야 하는 경우.)

  2. Runfn이 interval보다 오래 걸리면 어떻게 되는가? fn 안에 time.Sleep(3 * time.Second)를 넣고 interval을 1초로 둔 synctest 테스트를 써서 실제 호출 횟수를 확인해 보자. Ticker는 밀린 틱을 쌓아 두는가, 버리는가?

  3. "2026-03-08 02:30:00"America/New_York에서 ParseInLocation으로 읽어 보자. 그날 그 시각은 서머타임 전환으로 존재하지 않는다. Go는 어떤 값을 돌려주는가? 에러인가? 그 결과를 다시 Format하면 무엇이 나오는가? 이 동작을 알고 나면 "사용자가 입력한 시각"을 어떻게 검증해야 하는가?