2026-01-04
이 글에서는 Go time 패키지를 내부 동작 원리부터 실무 패턴까지 체계적으로 다룹니다. 레퍼런스 시간 공식의 비밀, wall clock과 monotonic clock의 차이, Duration 파싱, 타임존 처리 시 흔한 함정, Timer와 Ticker를 고루틴과 함께 사용하는 패턴, 그리고 DB 저장 시 시간 타입 설계까지 — 실제 서비스를 운영하면서 마주치는 모든 시간 처리 시나리오를 다룹니다.
Go의 time.Time은 내부적으로 두 가지 시계를 함께 저장합니다.
t1 := time.Now() // wall + monotonic 모두 포함
time.Sleep(100 * time.Millisecond)
t2 := time.Now()
// 경과 시간 측정: monotonic 사용 (더 정확)
elapsed := t2.Sub(t1)
fmt.Println(elapsed) // 약 100ms
// time.Since는 time.Now().Sub(t)와 동일
elapsed2 := time.Since(t1)
fmt.Println(elapsed2)
t.Sub(u) 또는 time.Since(t) 같은 경과 시간 측정은 monotonic clock을 사용하므로 NTP 동기화나 일광절약시간 전환에 영향을 받지 않습니다.
반면 t.Format(), t.Equal(), JSON 직렬화는 wall clock을 사용합니다. t.Round(0)을 호출하면 monotonic 부분이 제거되어 wall clock만 남습니다. DB에 저장하거나 네트워크로 전송하기 전에 t.UTC().Round(0)을 사용하는 것이 안전합니다.
// monotonic 제거 — DB 저장이나 직렬화 전 권장
cleanTime := t.UTC().Round(0)
Go의 시간 포맷팅이 yyyy, MM, dd 같은 기호를 쓰지 않고 2006-01-02 15:04:05를 쓰는 것은 Go만의 독특한 설계입니다. 이 레퍼런스 시간은 다음과 같은 의미를 가집니다.
Mon Jan 2 15:04:05 MST 2006
1 2 3 4 5 6 7
월(1) 일(2) 시(3=15) 분(4) 초(5) 년(6) 타임존오프셋(7=-07)
즉, 레퍼런스 시간은 “1월 2일, 오후 3시 4분 5초, 2006년, UTC-7” — 순서대로 1, 2, 3, 4, 5, 6, 7입니다. 이 숫자를 기억하면 어떤 포맷이든 만들 수 있습니다.
now := time.Now()
// 표준 상수
fmt.Println(now.Format(time.RFC3339)) // 2026-01-04T15:04:05+09:00
fmt.Println(now.Format(time.RFC3339Nano)) // 2026-01-04T15:04:05.999999999+09:00
fmt.Println(now.Format(time.RFC1123)) // Sun, 04 Jan 2026 15:04:05 KST
fmt.Println(now.Format(time.DateTime)) // 2026-01-04 15:04:05 (Go 1.20+)
fmt.Println(now.Format(time.DateOnly)) // 2026-01-04 (Go 1.20+)
fmt.Println(now.Format(time.TimeOnly)) // 15:04:05 (Go 1.20+)
// 커스텀 포맷
fmt.Println(now.Format("2006년 01월 02일")) // 2026년 01월 04일
fmt.Println(now.Format("15:04:05.000")) // 밀리초 포함
fmt.Println(now.Format("2006-01-02T15:04:05Z")) // UTC 리터럴 Z (주의: 타임존 변환 안 됨)
🛠️
"Z"는 타임존 자리표시자가 아니라 리터럴 문자입니다. UTC를 나타내는Z를 출력하려면 먼저.UTC()로 변환 후 포맷하거나time.RFC3339를 사용하세요.
// time.Parse는 타임존 정보 없으면 UTC로 처리
t1, _ := time.Parse("2006-01-02", "2026-01-04")
fmt.Println(t1.Location()) // UTC
// 특정 타임존 기준으로 파싱하려면 time.ParseInLocation 사용
seoul, _ := time.LoadLocation("Asia/Seoul")
t2, _ := time.ParseInLocation("2006-01-02 15:04:05", "2026-01-04 00:00:00", seoul)
fmt.Println(t2.Location()) // Asia/Seoul
fmt.Println(t2.UTC()) // 2026-01-03 15:00:00 UTC (KST = UTC+9)
time.Duration은 int64 기반의 나노초 단위 기간입니다.
// 내장 상수
fmt.Println(time.Nanosecond) // 1ns
fmt.Println(time.Microsecond) // 1µs
fmt.Println(time.Millisecond) // 1ms
fmt.Println(time.Second) // 1s
fmt.Println(time.Minute) // 1m0s
fmt.Println(time.Hour) // 1h0m0s
// Duration 계산
timeout := 30 * time.Second
halfTimeout := timeout / 2 // 15s
doubled := timeout * 2 // 1m0s
// 분 단위로 변환
minutes := timeout.Minutes() // 0.5 (float64)
seconds := timeout.Seconds() // 30.0
milliseconds := timeout.Milliseconds() // 30000 (int64)
fmt.Printf("%.0f분 %d초\n", minutes, int(seconds)%60)
설정 파일이나 환경 변수에서 ”30s”, “5m”, “2h30m” 형태의 문자열을 파싱할 때 사용합니다.
// time.ParseDuration: "300ms", "1.5h", "2h45m" 등 파싱
d1, _ := time.ParseDuration("30s")
d2, _ := time.ParseDuration("5m30s")
d3, _ := time.ParseDuration("2h30m")
d4, _ := time.ParseDuration("100ms")
d5, _ := time.ParseDuration("1.5h") // 90분
fmt.Println(d1) // 30s
fmt.Println(d3) // 2h30m0s
// 환경 변수에서 타임아웃 설정 읽기
func loadTimeout(envKey string, defaultVal time.Duration) time.Duration {
s := os.Getenv(envKey)
if s == "" {
return defaultVal
}
d, err := time.ParseDuration(s)
if err != nil {
slog.Warn("잘못된 Duration 설정, 기본값 사용",
"key", envKey, "value", s, "default", defaultVal)
return defaultVal
}
return d
}
// 사용: HTTP_TIMEOUT=10s ./server
httpTimeout := loadTimeout("HTTP_TIMEOUT", 30*time.Second)
now := time.Now()
// Add: Duration 기반 — 정확한 나노초 단위
tomorrow := now.Add(24 * time.Hour) // "내일"이 항상 정확히 24시간 후
nextHour := now.Add(time.Hour)
// AddDate: 달력 기반 — 월/년 단위는 이것이 올바름
nextMonth := now.AddDate(0, 1, 0) // 다음 달 같은 날짜
nextYear := now.AddDate(1, 0, 0)
tomorrow2 := now.AddDate(0, 0, 1) // 달력 기준 다음날 (일광절약시간 안전)
🛠️
Add(24 * time.Hour)는 항상 정확히 86400초 후입니다. 일광절약시간(DST) 전환일에는 “내일 같은 시간”이 23시간 또는 25시간 차이일 수 있습니다. 사용자에게 보여주는 “다음 날”은AddDate(0, 0, 1)을 사용하세요.
createdAt := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
deadline := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC)
now := time.Now()
// time.Since(t) == now.Sub(t)
age := time.Since(createdAt)
fmt.Printf("생성된 지 %.1f일 경과\n", age.Hours()/24)
// time.Until(t) == t.Sub(now)
remaining := time.Until(deadline)
fmt.Printf("마감까지 %.1f일 남음\n", remaining.Hours()/24)
// 두 시간의 차이
diff := deadline.Sub(createdAt) // 9 * 24 * time.Hour
fmt.Println(diff) // 216h0m0s
// 오늘의 시작 (00:00:00)
func startOfDay(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
// 이번 주 월요일
func startOfWeek(t time.Time) time.Time {
weekday := int(t.Weekday())
if weekday == 0 {
weekday = 7 // 일요일을 7로
}
monday := t.AddDate(0, 0, -(weekday - 1))
return startOfDay(monday)
}
// 이번 달의 마지막 날
func lastDayOfMonth(t time.Time) time.Time {
// 다음 달 1일에서 하루 빼기
return time.Date(t.Year(), t.Month()+1, 0, 23, 59, 59, 0, t.Location())
}
// time.Local: 운영 서버의 로컬 타임존 (예측 불가 — 피하세요)
// time.UTC: 항상 UTC
// time.LoadLocation: 명시적 IANA 타임존
// ❌ 서버 환경마다 다르게 동작
t := time.Now().Local()
// ✅ 명시적으로 타임존 지정
seoul, err := time.LoadLocation("Asia/Seoul")
if err != nil {
// Docker/scratch 이미지에서 tzdata 없으면 실패!
log.Fatal("타임존 로드 실패:", err)
}
t := time.Now().In(seoul)
scratch 또는 alpine 이미지를 사용하면 time.LoadLocation이 실패합니다. 해결책은 두 가지입니다.
# 방법 1: Dockerfile에 tzdata 추가 (alpine)
FROM alpine:3.19
RUN apk add --no-cache tzdata
// 방법 2: go:embed로 tzdata 내장 (Go 1.15+)
import _ "time/tzdata" // 이 import만으로 tzdata가 바이너리에 포함됨
// go.mod에 추가 설정 불필요, 단 바이너리 크기 약 450KB 증가
🛠️ 서버 애플리케이션에서는
import _ "time/tzdata"를main.go에 추가하는 것을 권장합니다. 배포 환경에 관계없이 타임존 데이터가 항상 사용 가능합니다.
time.LoadLocation은 파일 시스템 읽기를 수반하므로 매 요청마다 호출하면 성능에 영향을 줍니다.
// ✅ 패키지 수준에서 한 번만 로드
var (
locationSeoul *time.Location
locationUTC = time.UTC
)
func init() {
var err error
locationSeoul, err = time.LoadLocation("Asia/Seoul")
if err != nil {
// 바이너리에 tzdata 내장하면 절대 실패 안 함
panic("Asia/Seoul 타임존 로드 실패: " + err.Error())
}
}
// AfterFunc: 지정 시간 후 고루틴에서 f 실행
timer := time.AfterFunc(5*time.Second, func() {
fmt.Println("5초 후 실행")
})
// 취소 가능
if someCondition {
timer.Stop()
}
// Reset: 타이머 재설정 (Stop 후 드레인 필요)
if !timer.Stop() {
<-timer.C // 이미 발동된 경우 채널 비우기
}
timer.Reset(10 * time.Second)
// 주기적 작업 — context로 취소 가능
func runPeriodicTask(ctx context.Context, interval time.Duration, task func()) {
ticker := time.NewTicker(interval)
defer ticker.Stop() // 반드시 Stop() 호출 — 고루틴 누수 방지
for {
select {
case <-ctx.Done():
return
case t := <-ticker.C:
slog.Info("주기 작업 실행", "time", t.Format(time.DateTime))
task()
}
}
}
// 사용 예: 1분마다 캐시 갱신
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go runPeriodicTask(ctx, time.Minute, func() {
cache.Refresh()
})
// time.After: 편리하지만 GC 전까지 채널이 유지됨 (취소 불가 누수 위험)
select {
case result := <-ch:
process(result)
case <-time.After(5 * time.Second): // ❌ 루프 내에서 반복 사용 시 누수
fmt.Println("타임아웃")
}
// time.NewTimer: 취소 가능, 루프 내 재사용에 적합
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case result := <-ch:
timer.Stop()
process(result)
case <-timer.C: // ✅ Stop()으로 정리 가능
fmt.Println("타임아웃")
}
🛠️
select루프 외부에서 한 번만 사용하는time.After는 문제없습니다. 하지만for+select조합에서 매 반복마다time.After를 호출하면, 이전 타이머 채널이 GC될 때까지 메모리를 점유합니다. 루프에서는time.NewTimer를 사용하고defer timer.Stop()을 붙이세요.
type Session struct {
ID string
UserID int64
CreatedAt time.Time
ExpiresAt time.Time
mu sync.Mutex
}
func NewSession(userID int64, duration time.Duration) *Session {
now := time.Now().UTC()
return &Session{
ID: generateSessionID(),
UserID: userID,
CreatedAt: now,
ExpiresAt: now.Add(duration),
}
}
func (s *Session) IsExpired() bool {
return time.Now().UTC().After(s.ExpiresAt)
}
// 슬라이딩 만료: 접근할 때마다 연장
func (s *Session) Refresh(duration time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
s.ExpiresAt = time.Now().UTC().Add(duration)
}
// 남은 시간
func (s *Session) TimeRemaining() time.Duration {
remaining := time.Until(s.ExpiresAt)
if remaining < 0 {
return 0
}
return remaining
}
type RateLimiter struct {
rate int // 초당 허용 요청 수
tokens int
lastRefill time.Time
mu sync.Mutex
}
func NewRateLimiter(rate int) *RateLimiter {
return &RateLimiter{
rate: rate,
tokens: rate,
lastRefill: time.Now(),
}
}
func (r *RateLimiter) Allow() bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
elapsed := now.Sub(r.lastRefill)
// 경과 시간에 비례하여 토큰 충전
newTokens := int(elapsed.Seconds()) * r.rate
if newTokens > 0 {
r.tokens = min(r.rate, r.tokens+newTokens)
r.lastRefill = now
}
if r.tokens > 0 {
r.tokens--
return true
}
return false
}
func measure(name string) func() {
start := time.Now()
return func() {
elapsed := time.Since(start)
slog.Info("성능 측정",
"name", name,
"elapsed_ms", elapsed.Milliseconds(),
"elapsed", elapsed.String(),
)
}
}
// 사용
func processOrder(orderID int64) error {
defer measure("processOrder")()
// ... 처리 로직
return nil
}
type Article struct {
ID int64 `db:"id"`
Title string `db:"title"`
PublishedAt *time.Time `db:"published_at"` // NULL 허용 (미발행 상태)
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
// DB에서 읽을 때 UTC로 통일
func scanArticle(row *sql.Row) (*Article, error) {
var a Article
var publishedAt sql.NullTime
err := row.Scan(&a.ID, &a.Title, &publishedAt, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, err
}
if publishedAt.Valid {
t := publishedAt.Time.UTC()
a.PublishedAt = &t
}
// 모든 시간을 UTC로 정규화
a.CreatedAt = a.CreatedAt.UTC()
a.UpdatedAt = a.UpdatedAt.UTC()
return &a, nil
}
// ❌ 문자열 저장 — 파싱 오류, 정렬 불가
type Event struct {
StartTime string `json:"start_time"` // "2026-01-04 15:00:00"
}
// ✅ time.Time 사용
type Event struct {
StartTime time.Time `json:"start_time"` // RFC3339 자동 직렬화
}
t1 := time.Now()
t2 := t1.In(time.UTC) // 같은 시점, 다른 타임존
fmt.Println(t1 == t2) // ❌ false (monotonic + location 모두 비교)
fmt.Println(t1.Equal(t2)) // ✅ true (wall clock만 비교)
time.Time은 구조체이므로 ==는 내부 모든 필드(wall, ext, loc 포인터)를 비교합니다. 항상 t.Equal(u)를 사용하세요.
// ❌ context 취소와 연동 불가
time.Sleep(5 * time.Second)
doWork()
// ✅ context.WithTimeout 사용 (취소 가능)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
doWorkWithContext(ctx)
// ❌ 테스트 시 시간 고정 불가
func IsExpired(expiresAt time.Time) bool {
return time.Now().After(expiresAt) // 항상 실제 시간 사용
}
// ✅ 시간 주입 패턴
type Clock interface {
Now() time.Time
}
type RealClock struct{}
func (RealClock) Now() time.Time { return time.Now() }
type MockClock struct{ FixedTime time.Time }
func (m MockClock) Now() time.Time { return m.FixedTime }
func IsExpiredWithClock(clock Clock, expiresAt time.Time) bool {
return clock.Now().After(expiresAt)
}
time.Since(), 달력 시간은 Format()2006-01-02 15:04:05 = 1,2,3,4,5,6 순서time.DateTime, time.DateOnly, time.TimeOnly로 코드 간소화import _ "time/tzdata": Docker scratch/alpine 환경에서 tzdata 내장AddDate, 정확한 기간은 Adddefer ticker.Stop() 필수 — 누락 시 고루틴 누수NewTimer 사용== 연산자 금지, 항상 .Equal() 사용다음 글에서는 Go env 파일 활용을 다룹니다. 환경 변수 관리, .env 파일 로딩, 설정 구조체 패턴, 운영/개발 환경 분리까지 살펴보겠습니다.