Go의 time 패키지는 일정 시간이 지난 뒤 한 번 동작하는 Timer와 일정 간격마다 반복해서 동작하는 Ticker를 제공합니다.
두 기능은 모두 시간이 되었음을 채널로 전달하지만, 사용하는 목적은 다릅니다.
| 구분 | time.Timer |
time.Ticker |
|---|---|---|
| 신호 발생 | 한 번 | 일정 간격으로 반복 |
| 주요 용도 | 제한 시간, 지연 실행, 재시도 대기 | 상태 확인, 주기적 갱신, 반복 실행 |
| 생성 | time.NewTimer |
time.NewTicker |
| 신호 채널 | timer.C |
ticker.C |
| 중지 | timer.Stop() |
ticker.Stop() |
| 주기 변경 | timer.Reset() |
ticker.Reset() |
디렉터리 구조는 다음과 같이 구성합니다.
sources/
└── timers-tickers/
└── main.gosources 디렉터리에서 실행합니다.
cd sources
go run ./timers-tickers
Go 1.23 이후에는 프로그램에서 더 이상 참조하지 않는 Timer와 Ticker를 가비지 컬렉터가 회수할 수 있습니다. 따라서
Stop이 가비지 컬렉션을 위한 필수 조건은 아닙니다. 다만 불필요한 시간 신호나 반복 실행을 즉시 중단하고 프로그램의 종료 조건을 명확히 하기 위해, 더 이상 사용하지 않는 Timer와 Ticker는Stop하는 편이 좋습니다.
Timer는 한 번 신호를 보냅니다
time.NewTimer는 지정한 시간이 지난 뒤 timer.C 채널로 현재 시각을 한 번 전달합니다.
package main
import (
"fmt"
"time"
)
func main() {
timer := time.NewTimer(2 * time.Second)
fmt.Println("작업을 기다립니다.")
firedAt := <-timer.C
fmt.Println("Timer 실행 시각:", firedAt.Format("15:04:05"))
}
실행 흐름은 다음과 같습니다.
time.NewTimer(2 * time.Second)가 Timer를 생성합니다.timer.C에서 값을 받을 때까지 현재 goroutine이 대기합니다.- 약 2초가 지나면
time.Time값이 채널로 전달됩니다. - Timer는 한 번 신호를 보낸 뒤 다시 신호를 보내지 않습니다.
time.Sleep도 일정 시간 동안 현재 goroutine을 멈추지만, Timer는 채널을 제공하므로 select에서 다른 작업이나 취소 신호와 함께 기다릴 수 있다는 차이가 있습니다.
select에서 제한 시간 처리하기
package main
import (
"fmt"
"time"
)
func main() {
resultCh := make(chan string, 1)
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
go func() {
time.Sleep(1 * time.Second)
resultCh <- "작업 완료"
}()
select {
case result := <-resultCh:
fmt.Println(result)
case <-timer.C:
fmt.Println("제한 시간을 초과했습니다.")
}
}
이 예제에서는 작업 결과와 제한 시간을 동시에 기다립니다.
작업이 2초 안에 끝나면 resultCh의 case가 실행되고, 작업이 늦으면 timer.C의 case가 실행됩니다. select는 선택된 case 하나를 실행한 뒤 종료되므로 반복해서 기다리려면 for와 함께 사용해야 합니다.
Timer를 만료 전에 중지하기
package main
import (
"fmt"
"time"
)
func main() {
timer := time.NewTimer(3 * time.Second)
stopped := timer.Stop()
fmt.Println("만료 전에 중지했는가:", stopped)
select {
case <-timer.C:
fmt.Println("Timer가 실행되었습니다.")
case <-time.After(1 * time.Second):
fmt.Println("Timer 신호가 오지 않았습니다.")
}
}
Timer.Stop은 Timer가 아직 실행되지 않아 성공적으로 중지되면 true를 반환합니다. 이미 만료되었거나 중지된 Timer라면 false를 반환할 수 있습니다.
Stop은 timer.C 채널을 닫지 않습니다. 따라서 Timer를 중지한 뒤 아래처럼 채널이 닫히기를 기대하면서 range를 사용하면 안 됩니다.
for firedAt := range timer.C {
fmt.Println(firedAt)
}
Timer 채널은 자동으로 닫히지 않으므로 위 반복문은 종료되지 않을 수 있습니다.
Timer를 다시 사용하기
Reset을 사용하면 기존 Timer를 새로운 대기 시간으로 다시 설정할 수 있습니다.
package main
import (
"fmt"
"time"
)
func main() {
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
timer.Reset(1 * time.Second)
<-timer.C
fmt.Println("재설정한 1초 후 실행되었습니다.")
}
Go 1.23 이후의 채널 기반 Timer에서는 Reset이 반환된 뒤 이전 Timer 설정에서 발생한 오래된 값이 전달되지 않도록 보장됩니다. 현재 학습 기준인 Go 1.26.1에서는 Timer를 중지하거나 만료된 뒤 다시 설정하는 코드가 이전 버전보다 단순해졌습니다.
Ticker는 일정 간격으로 반복 신호를 보냅니다
time.NewTicker는 지정한 간격마다 ticker.C 채널로 현재 시각을 전달합니다.
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for count := 1; count <= 3; count++ {
tickedAt := <-ticker.C
fmt.Printf("%d번째 실행: %s\n", count, tickedAt.Format("15:04:05"))
}
}
Ticker는 직접 중지하거나 더 이상 참조하지 않을 때까지 반복 신호를 만들 수 있습니다. 반복 횟수가 정해져 있다면 반복문이 끝난 뒤 Stop되도록 defer ticker.Stop()을 사용하는 방식이 읽기 쉽습니다.
종료 신호와 함께 사용하기
실제 프로그램에서는 Ticker만 기다리기보다 종료 신호나 Context와 함께 사용하는 경우가 많습니다.
package main
import (
"context"
"fmt"
"time"
)
func runStatusCheck(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case tickedAt := <-ticker.C:
fmt.Println("상태 확인:", tickedAt.Format("15:04:05"))
case <-ctx.Done():
fmt.Println("상태 확인 종료:", ctx.Err())
return
}
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
3500*time.Millisecond,
)
defer cancel()
runStatusCheck(ctx, 1*time.Second)
}
예상되는 흐름은 다음과 같습니다.
상태 확인: 10:00:01
상태 확인: 10:00:02
상태 확인: 10:00:03
상태 확인 종료: context deadline exceeded
for가 select를 반복 실행하고, ctx.Done()이 닫히면 함수가 종료됩니다. 이때 defer ticker.Stop()이 실행되어 더 이상 tick이 생성되지 않습니다.
작업이 interval보다 오래 걸리면 tick이 누락될 수 있습니다
Ticker는 작업을 저장하는 queue가 아닙니다.
예를 들어 Ticker 간격은 1초인데 각 작업이 2500밀리초 걸린다고 가정합니다.
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
startedAt := time.Now()
for count := 1; count <= 3; count++ {
tickedAt := <-ticker.C
fmt.Printf(
"%d번째 tick: 예정 시각 기준 %v 후\n",
count,
tickedAt.Sub(startedAt).Round(time.Millisecond),
)
time.Sleep(2500 * time.Millisecond)
}
}
Ticker의 내부 채널은 느린 receiver를 위해 모든 tick을 계속 저장하지 않습니다. receiver가 늦으면 Ticker는 시간 간격을 조정하거나 일부 tick을 버릴 수 있습니다.
따라서 위 코드는 다음 의미가 아닙니다.
1초마다 작업 요청이 하나씩 queue에 쌓인다.
실제 의미는 다음에 가깝습니다.
작업을 받을 수 있는 시점에 최근의 주기 신호를 확인한다.
Ticker의 채널로 받은
time.Time은 코드를 실제로 실행하기 시작한 시각과 다를 수 있습니다. receiver가 늦었다면 채널 값은 이미 지나간 tick의 예정 시각일 수 있으므로, 정확한 작업 시작 시각이 필요하면 case 안에서time.Now()를 별도로 호출해야 합니다.
작업 종료 후 일정 시간을 기다리고 싶다면
작업이 끝난 시점부터 정확히 일정 시간을 기다린 뒤 다음 작업을 시작하려면 Ticker보다 Timer 또는 time.Sleep이 더 자연스럽습니다.
package main
import (
"fmt"
"time"
)
func main() {
for count := 1; count <= 3; count++ {
fmt.Println("작업 시작:", count)
time.Sleep(1500 * time.Millisecond)
fmt.Println("작업 완료:", count)
if count < 3 {
time.Sleep(1 * time.Second)
}
}
}
이 구조에서는 각 실행 시점이 다음과 같이 결정됩니다.
이전 작업 완료 시각 + 대기 시간
반면 Ticker는 일반적으로 다음 기준으로 신호를 생성합니다.
Ticker를 시작한 시각 + 고정된 interval
모든 작업 요청을 보존해야 한다면
모든 실행 요청을 빠짐없이 처리해야 한다면 Ticker 자체를 작업 queue로 사용하지 말고 별도의 queue나 channel을 두어야 합니다.
package main
import (
"fmt"
"time"
)
func main() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
jobCh := make(chan int, 10)
doneCh := make(chan struct{})
go func() {
defer close(doneCh)
for jobID := range jobCh {
fmt.Println("작업 처리 시작:", jobID)
time.Sleep(1500 * time.Millisecond)
fmt.Println("작업 처리 완료:", jobID)
}
}()
for jobID := 1; jobID <= 3; jobID++ {
<-ticker.C
jobCh <- jobID
}
close(jobCh)
<-doneCh
}
이 예제에서 Ticker는 작업 생성 시점을 알리는 역할만 합니다. 생성된 작업은 jobCh에 저장되고 worker goroutine이 순서대로 처리합니다.
다만 queue가 가득 찼을 때 producer를 대기시킬지, 작업을 버릴지, 디스크나 메시지 브로커에 저장할지는 시스템 요구사항에 따라 별도로 결정해야 합니다.
time.After와 context.WithTimeout 구분하기
단순히 select에서 한 번 제한 시간을 기다릴 때는 time.After를 사용할 수 있습니다.
select {
case result := <-resultCh:
fmt.Println(result)
case <-time.After(2 * time.Second):
fmt.Println("제한 시간을 초과했습니다.")
}
time.After(d)는 내부적으로 time.NewTimer(d).C와 같은 형태의 채널을 반환합니다. Timer 객체를 직접 중지하거나 재설정할 필요가 없다면 간단하게 사용할 수 있습니다.
반면 하나의 작업과 그 작업에서 파생된 여러 goroutine, 데이터베이스 호출, HTTP 요청을 함께 취소해야 한다면 context.WithTimeout이 더 적합합니다.
package main
import (
"context"
"errors"
"fmt"
"time"
)
func process(ctx context.Context) error {
select {
case <-time.After(3 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
1*time.Second,
)
defer cancel()
err := process(ctx)
if errors.Is(err, context.DeadlineExceeded) {
fmt.Println("작업 제한 시간을 초과했습니다.")
return
}
if err != nil {
fmt.Println("작업 실패:", err)
return
}
fmt.Println("작업 완료")
}
time.After는 시간이 지났다는 신호를 한 위치에 전달하는 기능입니다. context.WithTimeout은 제한 시간뿐 아니라 취소 신호를 함수 호출 경계와 여러 goroutine에 전달하는 lifecycle 제어 기능입니다.
| 상황 | 적합한 방식 |
|---|---|
현재 select에서 한 번 timeout 확인 |
time.After |
| Timer를 중지하거나 다시 설정해야 함 | time.NewTimer |
| 일정 간격으로 반복 신호 필요 | time.NewTicker |
| 여러 함수와 goroutine에 timeout·취소 전달 | context.WithTimeout |
| 이전 작업 완료 후 일정 간격 대기 | time.Sleep 또는 Timer |
| 모든 반복 작업 요청을 보존 | 별도 queue 또는 channel |
코드를 해석할 때 알아둘 점
Timer와 Ticker를 이해할 때는 API 이름을 따로 외우기보다, 코드에서 무엇을 기다리는지, 신호가 몇 번 발생하는지, 작업이 늦어졌을 때 어떻게 되는지를 기준으로 보면 이해하기 쉽습니다.
채널 수신 코드는 같은 방식으로 해석합니다
아래 표현은 모두 채널에서 값을 받는 수신 연산입니다.
<-timer.C
<-ticker.C
<-doneCh
<-ctx.Done()
<- 자체가 Timer나 Ticker 전용 문법은 아닙니다. Go 채널에서 값을 받는 연산자입니다.
| 표현 | 기다리는 대상 |
|---|---|
<-timer.C |
Timer가 만료되어 한 번 전달하는 시간 값 |
<-ticker.C |
Ticker가 다음 주기에 전달하는 시간 값 |
<-doneCh |
완료 신호 또는 채널 종료 |
<-ctx.Done() |
Context 취소 또는 제한 시간 만료 |
채널이 열려 있고 받을 값이 없으면 현재 goroutine은 대기합니다. 반대로 이미 받을 값이 있거나 채널이 닫혀 있으면 즉시 진행합니다.
ch := make(chan int)
close(ch)
value, ok := <-ch
fmt.Println(value) // 0
fmt.Println(ok) // false
닫힌 채널에서는 해당 타입의 zero value가 반환되고 ok는 false가 됩니다. 채널이 열려 있는데 값이 없는 경우에는 false가 반환되는 것이 아니라 값이 올 때까지 기다립니다.
<-ch는 “대기 명령”이 아니라 채널 수신 연산입니다. 받을 값이 아직 없을 때 그 결과로 goroutine이 대기합니다.
Timer와 Ticker의 차이는 대기 방식이 아니라 신호 횟수입니다
Timer와 Ticker 모두 채널에서 값을 기다리는 방식은 같습니다.
timer := time.NewTimer(2 * time.Second)
<-timer.C
Timer는 약 2초 후 한 번만 값을 전달합니다. 같은 Timer에서 다시 수신하면 새로운 값이 없기 때문에 계속 기다릴 수 있습니다.
timer := time.NewTimer(2 * time.Second)
<-timer.C
fmt.Println("첫 번째 실행")
<-timer.C // Reset하지 않았으므로 계속 대기할 수 있습니다.
Ticker는 interval마다 반복해서 값을 전달합니다.
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for count := 1; count <= 3; count++ {
<-ticker.C
fmt.Println("작업 실행:", count)
}
첫 tick은 Ticker를 생성하자마자 발생하지 않습니다. 첫 interval이 지난 뒤 전달됩니다. 즉시 한 번 실행한 뒤 반복하려면 작업을 먼저 호출해야 합니다.
checkStatus()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
checkStatus()
}
Stop은 채널 종료가 아니라 시간 신호 중지입니다
Timer.Stop()과 Ticker.Stop()은 이후의 시간 신호 생성을 중지합니다. 하지만 C 채널을 닫지는 않습니다.
ticker := time.NewTicker(1 * time.Second)
ticker.Stop()
<-ticker.C // 채널이 닫힌 것이 아니므로 계속 대기할 수 있습니다.
따라서 아래 코드는 ticker.Stop()만으로 종료되지 않습니다.
for tickedAt := range ticker.C {
fmt.Println(tickedAt)
}
반복을 끝내려면 Context, done channel, return 같은 별도의 종료 조건이 필요합니다.
for {
select {
case <-ticker.C:
checkStatus()
case <-ctx.Done():
return
}
}
Timer.Stop()의 반환값도 채널 종료 여부가 아닙니다.
timer := time.NewTimer(3 * time.Second)
stopped := timer.Stop()
fmt.Println(stopped)
Timer가 만료되기 전에 성공적으로 중지되면 일반적으로 true를 반환합니다. 이미 만료되었거나 중지된 상태라면 false를 반환할 수 있습니다.
Ticker는 실행 횟수를 보장하는 작업 queue가 아닙니다
Ticker가 1초마다 100번의 실행 시점을 만들더라도, 수신 측 작업이 오래 걸리면 실제 작업이 100번 실행된다고 보장할 수 없습니다.
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for range ticker.C {
fmt.Println("작업 시작")
time.Sleep(3 * time.Second)
}
이 코드는 1초마다 작업이 queue에 하나씩 쌓이는 구조가 아닙니다. 작업 중에 여러 tick 시점이 지나가더라도 Ticker는 느린 receiver를 위해 모든 tick을 보존하지 않습니다.
Ticker interval: 1초
작업 시간: 3초
1초: 첫 tick 수신 후 작업 시작
2초: 다음 tick 시점
3초: 다음 tick 시점
4초 부근: 첫 작업 종료 후 다음 수신 가능
2초와 3초에 해당하는 모든 tick이 각각 별도 작업으로 보존되는 것은 아닙니다. 따라서 Ticker는 상태 확인이나 주기적 갱신처럼 일부 시점이 건너뛰어도 되는 작업에 적합합니다.
모든 작업을 반드시 실행해야 한다면 별도의 queue나 channel에 작업 자체를 저장해야 합니다.
jobCh := make(chan int, 100)
for jobID := 1; jobID <= 100; jobID++ {
jobCh <- jobID
}
close(jobCh)
for jobID := range jobCh {
process(jobID)
}
이 구조에서는 실행 요청이 jobCh에 저장되므로 worker가 느리더라도 channel 용량과 producer 제어 범위 안에서 작업을 순서대로 처리할 수 있습니다.
3초마다 실행과 작업 후 3초 대기는 다릅니다
time.Ticker는 Ticker를 시작한 시점을 기준으로 주기적인 신호를 만듭니다.
시작 시각 + 3초
시작 시각 + 6초
시작 시각 + 9초
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for range ticker.C {
doWork()
}
작업이 3초보다 오래 걸리면 일부 tick이 누락될 수 있습니다.
반면 아래 코드는 작업이 끝난 뒤 3초를 기다립니다.
for {
doWork()
time.Sleep(3 * time.Second)
}
작업에 2초가 걸리면 다음 작업 시작까지 약 5초가 걸립니다.
작업 2초 + 대기 3초 = 약 5초 간격
즉, 두 방식은 다음처럼 구분해야 합니다.
| 필요한 동작 | 기준 |
|---|---|
| 정해진 시작 시각을 기준으로 3초마다 신호 | time.NewTicker |
| 이전 작업이 끝난 뒤 3초 후 다시 실행 | time.Sleep 또는 Timer |
| 모든 실행 요청을 빠짐없이 처리 | 별도의 queue 또는 channel |
채널에서 받은 시간과 실제 작업 시작 시간은 다를 수 있습니다
ticker.C에서 받는 값은 tick이 발생한 기준 시각입니다. receiver가 늦게 값을 받았다면 실제 작업 시작 시각보다 과거일 수 있습니다.
case tickedAt := <-ticker.C:
startedAt := time.Now()
fmt.Println("tick 시각:", tickedAt)
fmt.Println("실제 작업 시작 시각:", startedAt)
주기 기준 시각이 필요하면 tickedAt을 사용하고, 코드가 실제로 실행된 시각이 필요하면 time.Now()를 별도로 호출합니다.
기능은 실행 목적에 따라 선택합니다
| 필요한 동작 | 적합한 기능 |
|---|---|
| 일정 시간 뒤 한 번 신호 | time.NewTimer 또는 time.After |
| Timer를 중지하거나 재설정 | time.NewTimer |
| 일정한 주기로 반복 신호 | time.NewTicker |
| 작업 완료 후 일정 시간 대기 | time.Sleep 또는 Timer |
| 여러 함수와 goroutine에 timeout 전달 | context.WithTimeout |
| 완료 또는 종료 신호 전달 | chan struct{} 또는 Context |
| 모든 반복 요청을 빠짐없이 보존 | 별도의 queue 또는 channel |
time.After와 context.WithTimeout도 역할이 다릅니다. time.After는 현재 위치에 한 번의 시간 신호를 전달하고, context.WithTimeout은 하나의 작업과 그 하위 함수·goroutine에 취소와 제한 시간을 전달합니다.
select {
case result := <-resultCh:
fmt.Println(result)
case <-time.After(2 * time.Second):
fmt.Println("시간 초과")
}
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
동적으로 Ticker interval을 전달받는 경우에는 0보다 큰 값인지 확인해야 합니다. time.NewTicker(0)이나 음수 interval은 panic을 발생시킵니다.
if interval <= 0 {
return fmt.Errorf("interval은 0보다 커야 합니다: %s", interval)
}
반면 time.NewTimer(0)이나 음수 duration의 Timer는 가능한 한 즉시 만료됩니다.
전체 예제
아래 예제는 Timer의 단발 실행, Ticker의 반복 실행, Context를 이용한 종료를 함께 보여줍니다.
sources/timers-tickers/main.go:
package main
import (
"context"
"fmt"
"time"
)
func waitForTimer() {
timer := time.NewTimer(500 * time.Millisecond)
defer timer.Stop()
fmt.Println("Timer 대기 시작")
<-timer.C
fmt.Println("Timer 실행 완료")
}
func runTicker(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
count := 0
for {
select {
case tickedAt := <-ticker.C:
count++
fmt.Printf(
"Ticker %d번째 실행: %s\n",
count,
tickedAt.Format("15:04:05.000"),
)
case <-ctx.Done():
fmt.Println("Ticker 종료:", ctx.Err())
return
}
}
}
func main() {
waitForTimer()
ctx, cancel := context.WithTimeout(
context.Background(),
2200*time.Millisecond,
)
defer cancel()
runTicker(ctx, 500*time.Millisecond)
}
실행합니다.
cd sources
go run ./timers-tickers
출력되는 시각과 정확한 반복 횟수는 실행 환경과 scheduler 상태에 따라 약간 달라질 수 있지만, 일반적으로 다음과 비슷하게 출력됩니다.
Timer 대기 시작
Timer 실행 완료
Ticker 1번째 실행: 10:00:00.500
Ticker 2번째 실행: 10:00:01.000
Ticker 3번째 실행: 10:00:01.500
Ticker 4번째 실행: 10:00:02.000
Ticker 종료: context deadline exceeded
정리
time.Timer는 미래의 한 시점에 한 번 신호를 보내며, timeout이나 지연 실행에 적합합니다.
time.Ticker는 일정 간격마다 반복 신호를 보내며, 상태 확인이나 주기적 갱신에 적합합니다. 하지만 느린 receiver를 위해 모든 tick을 보존하지 않으므로 작업 queue로 사용하면 안 됩니다.
작업 시간이 interval보다 길 수 있다면 다음 중 어떤 동작이 필요한지 먼저 정해야 합니다.
- 일부 실행 시점을 건너뛰어도 되는 주기 확인 작업인지
- 이전 작업 완료 후 일정 시간을 기다려야 하는지
- 모든 작업 요청을 queue에 보존해야 하는지
- 여러 goroutine과 하위 작업을 함께 취소해야 하는지
단순 timeout은 time.After, 재설정이나 중지가 필요한 단발 대기는 time.NewTimer, 반복 신호는 time.NewTicker, 작업 lifecycle 전체의 timeout과 취소는 context.WithTimeout으로 구분하면 이해하기 쉽습니다.
참고 자료
- Go
time패키지 공식 문서: https://pkg.go.dev/time - Go
time.Ticker소스 코드: https://go.dev/src/time/tick.go - Go
context패키지 공식 문서: https://pkg.go.dev/context - Go Blog - Go Concurrency Patterns: Context: https://go.dev/blog/context
'프로그래밍 > Go' 카테고리의 다른 글
| Go 공유 상태 관리: atomic, Mutex, owner goroutine (0) | 2026.07.27 |
|---|---|
| Go Worker Pool, WaitGroup, Rate Limiting (0) | 2026.07.27 |
| Go panic과 recover (0) | 2026.07.23 |
| Go defer (0) | 2026.07.22 |
| Go context (0) | 2026.07.21 |
댓글