sync.Once: Go의 안전한 동시성 패턴
Grace Collins
Solutions Engineer · Leapcell

🔍 Go 동시성 핵심: sync.Once 패밀리에 대한 종합 가이드
Go 동시성 프로그래밍에서 특정 연산이 한 번만 실행되도록 보장하는 것은 일반적인 요구 사항입니다. 표준 라이브러리의 경량 동기화 기본 요소인 sync.Once는 매우 간단한 디자인으로 이 문제를 해결합니다. 이 문서에서는 이 강력한 도구의 사용법과 원리를 깊이 이해하도록 안내합니다.
🎯 sync.Once란 무엇입니까?
sync.Once는 Go 언어의 sync 패키지에 있는 동기화 기본 요소입니다. 핵심 기능은 프로그램 수명 주기 동안 특정 연산이 여러 고루틴에서 동시에 호출되더라도 한 번만 실행되도록 보장하는 것입니다.
공식 정의는 간결하고 강력합니다.
Once는 특정 연산이 한 번만 수행되도록 보장하는 객체입니다. Once 객체를 처음 사용한 후에는 복사해서는 안 됩니다. f 함수의 반환은 once.Do(f)에 대한 모든 호출의 반환보다 "먼저 동기화"됩니다.
마지막 요점은 f가 실행을 완료한 후 해당 결과가 once.Do(f)를 호출하는 모든 고루틴에 표시되어 메모리 일관성을 보장한다는 의미입니다.
💡 일반적인 사용 시나리오
- 싱글톤 패턴: 데이터베이스 연결 풀, 구성 로딩 등이 한 번만 초기화되도록 보장합니다.
- 지연 로딩: 필요할 때만 리소스를 로드하고 한 번만 로드합니다.
- 동시 안전한 초기화: 다중 고루틴 환경에서 안전한 초기화
🚀 빠른 시작
sync.Once는 사용하기 매우 간단하며 하나의 핵심 Do 메서드만 있습니다.
package main import ( "fmt" "sync" ) func main() { var once sync.Once onceBody := func() { fmt.Println("Only once") } // Start 10 goroutines to call concurrently done := make(chan bool) for i := 0; i < 10; i++ { go func() { once.Do(onceBody) done <- true }() } // Wait for all goroutines to complete for i := 0; i < 10; i++ { <-done } }
실행 결과는 항상 다음과 같습니다.
Only once
단일 고루틴에서 여러 번 호출되더라도 결과는 동일합니다. 함수는 한 번만 실행됩니다.
🔍 심층 소스 코드 분석
sync.Once의 소스 코드는 매우 간결하지만(주석 포함 78줄) 정교한 디자인이 포함되어 있습니다.
type Once struct { done atomic.Uint32 // 연산이 실행되었는지 여부를 식별합니다. m Mutex // 뮤텍스 잠금 } func (o *Once) Do(f func()) { if o.done.Load() == 0 { o.doSlow(f) // 느린 경로, 빠른 경로 인라이닝 허용 } } func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if o.done.Load() == 0 { defer o.done.Store(1) f() } }
디자인 하이라이트:
-
이중 점검 잠금:
- 첫 번째 점검(잠금 없이): 실행되었는지 여부를 빠르게 결정합니다.
- 두 번째 점검(잠금 후): 동시 안전을 보장합니다.
-
성능 최적화:
- done 필드는 포인터 오프셋 계산을 줄이기 위해 struct의 시작 부분에 배치됩니다.
- 빠른 경로와 느린 경로를 분리하면 빠른 경로의 인라이닝 최적화가 가능합니다.
- 잠금은 첫 번째 실행에만 필요하며 후속 호출은 오버헤드가 없습니다.
-
CAS로 구현하지 않는 이유는 무엇입니까?: 주석은 명확하게 설명합니다. 간단한 CAS는 f가 실행을 완료한 후에만 결과가 반환되도록 보장할 수 없으므로 다른 고루틴이 완료되지 않은 결과를 얻을 수 있습니다.
⚠️ 주의 사항
-
복사 불가능: Once에는 noCopy 필드가 포함되어 있으며 처음 사용 후 복사하면 정의되지 않은 동작이 발생합니다.
// 잘못된 예 var once sync.Once once2 := once // 컴파일은 오류를 보고하지 않지만 런타임 중에 문제가 발생할 수 있습니다.
-
재귀 호출을 피하십시오: f에서 once.Do(f)를 다시 호출하면 교착 상태가 발생합니다.
-
패닉 처리: f에서 패닉이 발생하면 실행된 것으로 간주되며 후속 호출은 더 이상 f를 실행하지 않습니다.
✨ Go 1.21의 새로운 기능
Go 1.21은 sync.Once 패밀리에 세 가지 실용적인 기능을 추가하여 기능을 확장했습니다.
1. OnceFunc: 패닉 처리 기능이 있는 단일 실행 함수
func OnceFunc(f func()) func()
특징:
- f를 한 번만 실행하는 함수를 반환합니다.
- f가 패닉되면 반환된 함수는 각 호출에서 동일한 값으로 패닉합니다.
- 동시 안전
예시:
package main import ( "fmt" "sync" ) func main() { // 한 번만 실행되는 함수 만들기 initialize := sync.OnceFunc(func() { fmt.Println("Initialization completed") }) // 동시 호출 var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() initialize() }() } wg.Wait() }
기본 once.Do와 비교: f가 패닉되면 OnceFunc는 각 호출에서 동일한 값을 다시 패닉하는 반면 기본 Do는 처음 한 번만 패닉합니다.
2. OnceValue: 단일 계산 및 반환 값
func OnceValue[T any](f func() T) func() T
결과를 계산하고 캐시해야 하는 시나리오에 적합합니다.
package main import ( "fmt" "sync" ) func main() { // 한 번만 계산하는 함수 만들기 calculate := sync.OnceValue(func() int { fmt.Println("Start complex calculation") sum := 0 for i := 0; i < 1000000; i++ { sum += i } return sum }) // 여러 번 호출, 첫 번째 계산만 var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() fmt.Println("Result:", calculate()) }() } wg.Wait() }
3. OnceValues: 두 개의 값 반환 지원
func OnceValues[T1, T2 any](f func() (T1, T2)) func() (T1, T2)
(값, 오류)를 반환하는 Go 함수의 관용구에 완벽하게 적응합니다.
package main import ( "fmt" "os" "sync" ) func main() { // 파일을 한 번만 읽기 readFile := sync.OnceValues(func() ([]byte, error) { fmt.Println("Reading file") return os.ReadFile("config.json") }) // 동시 읽기 var wg sync.WaitGroup for i := 0; i < 3; i++ { wg.Add(1) go func() { defer wg.Done() data, err := readFile() if err != nil { fmt.Println("Error:", err) return } fmt.Println("File length:", len(data)) }() } wg.Wait() }
🆚 기능 비교
기능 | 특징 | 적용 가능한 시나리오 |
---|---|---|
Once.Do | 기본 버전, 반환 값 없음 | 간단한 초기화 |
OnceFunc | 패닉 처리 기능 포함 | 오류 처리가 필요한 초기화 |
OnceValue | 단일 값 반환 지원 | 결과 계산 및 캐싱 |
OnceValues | 두 개의 값 반환 지원 | 오류 반환이 있는 연산 |
더 나은 오류 처리와 더 직관적인 인터페이스를 제공하므로 새로운 기능을 먼저 사용하는 것이 좋습니다.
🎬 실제 애플리케이션 사례
1. 싱글톤 패턴 구현
type Database struct { // 데이터베이스 연결 정보 } var ( dbInstance *Database dbOnce sync.Once ) func GetDB() *Database { dbOnce.Do(func() { // 데이터베이스 연결 초기화 dbInstance = &Database{ // 구성 정보 } }) return dbInstance }
2. 구성 지연 로딩
type Config struct { // 구성 항목 } var loadConfig = sync.OnceValue(func() *Config { // 파일 또는 환경 변수에서 구성 로드 data, _ := os.ReadFile("config.yaml") var cfg Config _ = yaml.Unmarshal(data, &cfg) return &cfg }) // 사용법 func main() { cfg := loadConfig() // 구성 사용... }
3. 리소스 풀 초기화
var initPool = sync.OnceFunc(func() { // 연결 풀 초기화 pool = NewPool( WithMaxConnections(10), WithTimeout(30*time.Second), ) }) func GetResource() (*Resource, error) { initPool() // 풀이 초기화되었는지 확인 return pool.Get() }
🚀 성능 고려 사항
sync.Once는 뛰어난 성능을 제공합니다. 첫 번째 호출의 오버헤드는 주로 뮤텍스 잠금에서 발생하며 후속 호출은 오버헤드가 거의 없습니다.
- 첫 번째 호출: 약 50-100ns(잠금 경쟁에 따라 다름)
- 후속 호출: 약 1-2ns(원자적 로딩 작업만)
고concurrency 시나리오에서 다른 동기화 방법(예: 뮤텍스 잠금)과 비교하여 성능 손실을 크게 줄일 수 있습니다.
📚 요약
sync.Once는 매우 간단한 디자인으로 동시 환경에서 단일 실행 문제를 해결하며 핵심 아이디어는 배울 가치가 있습니다.
- 최소한의 오버헤드로 스레드 안전성 구현
- 성능을 최적화하기 위해 빠른 경로와 느린 경로 분리
- 명확한 메모리 모델 보장
Go 1.21에 추가된 세 가지 새로운 기능은 실용성을 더욱 향상시켜 단일 실행 로직을 더욱 간결하고 강력하게 만듭니다.
sync.Once 패밀리를 마스터하면 동시 초기화 및 싱글톤 패턴과 같은 시나리오를 쉽게 처리하고 더 우아하고 효율적인 Go 코드를 작성할 수 있습니다.
Leapcell: 최고의 서버리스 웹 호스팅
마지막으로 Go 서비스를 배포하기에 가장 적합한 플랫폼인 **Leapcell**을 추천합니다.
🚀 좋아하는 언어로 빌드
JavaScript, Python, Go 또는 Rust로 쉽게 개발하십시오.
🌍 무료로 무제한 프로젝트 배포
사용한 만큼만 지불하십시오. 요청도 없고 요금도 없습니다.
⚡ 사용한 만큼만 지불하고 숨겨진 비용 없음
유휴 요금 없이 원활한 확장성만 제공합니다.
🔹 트위터에서 팔로우하세요: @LeapcellHQ