Go에서 sync.Once 이해
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
특정 시나리오에서는 싱글톤 객체, 구성 등과 같은 일부 리소스를 초기화해야 합니다. 패키지 수준 변수를 정의하거나 init
함수 또는 main
함수에서 초기화하는 등 리소스 초기화를 구현하는 방법은 여러 가지가 있습니다. 세 가지 접근 방식 모두 프로그램이 시작될 때 동시성 안전과 완전한 리소스 초기화를 보장할 수 있습니다.
그러나 때로는 리소스가 실제로 필요할 때만 초기화되는 지연 초기화를 사용하는 것을 선호합니다. 이를 위해서는 동시성 안전이 필요하며, 이러한 경우 Go의 sync.Once
는 우아하고 스레드 안전한 솔루션을 제공합니다. 이 기사에서는 sync.Once
를 소개합니다.
sync.Once의 기본 개념
sync.Once란 무엇인가
sync.Once
는 특정 작업 또는 함수가 동시 환경에서 한 번만 실행되도록 보장하는 Go의 동기화 기본 요소입니다. Do
라는 하나의 내보내기된 메서드만 노출하며, 이 메서드는 함수를 매개변수로 허용합니다. Do
메서드를 호출한 후에는 제공된 함수가 실행되며, 여러 고루틴이 동시에 호출하더라도 한 번만 실행됩니다.
sync.Once의 적용 시나리오
sync.Once
는 주로 다음 시나리오에서 사용됩니다.
- 싱글톤 패턴: 중복된 리소스 생성을 방지하여 하나의 글로벌 인스턴스 객체만 존재하도록 보장합니다.
- 지연 초기화: 프로그램 실행 중 필요할 때
sync.Once
를 통해 리소스를 동적으로 초기화할 수 있습니다. - 한 번만 실행해야 하는 작업: 예를 들어, 한 번만 실행해야 하는 구성 로딩, 데이터 정리 등입니다.
sync.Once의 적용 예제
싱글톤 패턴
싱글톤 패턴에서는 구조체가 한 번만 초기화되도록 해야 합니다. sync.Once
를 사용하면 이 목표를 쉽게 달성할 수 있습니다.
package main import ( "fmt" "sync" ) type Singleton struct{} var ( instance *Singleton once sync.Once ) func GetInstance() *Singleton { once.Do(func() { instance = &Singleton{} }) return instance } func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) go func() { defer wg.Done() s := GetInstance() fmt.Printf("Singleton instance address: %p\n", s) }() } wg.Wait() }
위 코드에서 GetInstance
함수는 instance
가 한 번만 초기화되도록 once.Do()
를 사용합니다. 동시 환경에서 여러 고루틴이 동시에 GetInstance
를 호출하면 하나의 고루틴만 instance = &Singleton{}
를 실행하고 모든 고루틴은 동일한 인스턴스 s
를 받습니다.
지연 초기화
때로는 특정 리소스가 필요할 때만 초기화하고 싶을 수 있습니다. 이는 sync.Once
를 사용하여 달성할 수 있습니다.
package main import ( "fmt" "sync" ) type Config struct { config map[string]string } var ( config *Config once sync.Once ) func GetConfig() *Config { once.Do(func() { fmt.Println("init config...") config = &Config{ config: map[string]string{ "c1": "v1", "c2": "v2", }, } }) return config } func main() { // 구성이 처음 필요할 때 구성이 초기화됩니다. cfg := GetConfig() fmt.Println("c1: ", cfg.config["c1"]) // 두 번째로 구성은 이미 초기화되어 다시 초기화되지 않습니다. cfg2 := GetConfig() fmt.Println("c2: ", cfg2.config["c2"]) }
이 예제에서는 일부 구성 설정을 보유하기 위해 Config
구조체가 정의되었습니다. GetConfig
함수는 Config
구조체를 처음 호출될 때 초기화하기 위해 sync.Once
를 사용합니다. 이렇게 하면 Config
는 실제로 필요할 때만 초기화되어 불필요한 오버헤드를 피할 수 있습니다.
sync.Once의 구현 원리
type Once struct { // 작업이 수행되었는지 여부를 나타냅니다. done uint32 // 하나의 고루틴만 작업을 수행하도록 보장하는 뮤텍스 m Mutex } func (o *Once) Do(f func()) { // 완료가 0인지 확인합니다. 즉, f가 아직 실행되지 않았습니다. if atomic.LoadUint32(&o.done) == 0 { // Do에서 빠른 경로 인라이닝을 허용하기 위해 느린 경로를 호출합니다. o.doSlow(f) } } func (o *Once) doSlow(f func()) { // 잠금 o.m.Lock() defer o.m.Unlock() // f를 여러 번 실행하지 않도록 이중 확인합니다. if o.done == 0 { // 함수를 실행한 후 완료를 설정합니다. defer atomic.StoreUint32(&o.done, 1) // 함수를 실행합니다. f() } }
sync.Once
구조체에는 done
과 m
이라는 두 개의 필드가 있습니다.
done
은 작업이 이미 수행되었는지 여부를 나타내는 데 사용되는uint32
변수입니다.m
은 동시에 액세스할 때 하나의 고루틴만 작업을 수행할 수 있도록 하는 데 사용되는 뮤텍스입니다.
sync.Once
는 Do
와 doSlow
라는 두 가지 메서드를 제공합니다. Do
메서드가 핵심입니다. 함수 f
를 허용합니다. 먼저 원자적 작업 atomic.LoadUint32
를 사용하여 done
값을 확인합니다(동시성 안전 보장). done
이 0
이면 함수 f
가 아직 실행되지 않았음을 의미하고 doSlow
가 호출됩니다.
doSlow
메서드 내부에서는 먼저 뮤텍스 잠금 m
을 획득하여 한 번에 하나의 고루틴만 f
를 실행할 수 있도록 합니다. 그런 다음 done
변수에 대한 두 번째 검사를 수행합니다. done
이 여전히 0
이면 잠금을 획득하는 동안 다른 고루틴이 함수를 실행하지 않았는지 확인합니다.
이중 검사는 대부분의 경우 잠금 경합을 피하고 성능을 향상시키는 데 도움이 됩니다.
별도의 doSlow
메서드가 있는 이유는 무엇입니까?
doSlow
메서드는 주로 성능 최적화를 위해 존재합니다. Do
메서드에서 느린 경로 로직을 분리함으로써 Do
의 빠른 경로는 컴파일러에 의해 인라인될 수 있어 성능이 향상됩니다.
이중 검사를 사용하는 이유는 무엇입니까?
소스 코드에서 볼 수 있듯이 done
값은 두 번 확인됩니다.
- 첫 번째 확인: 잠금을 획득하기 전에
atomic.LoadUint32
를 사용하여done
을 확인합니다. 값이1
이면 작업이 이미 수행되었음을 의미하므로doSlow
가 건너뛰어져 불필요한 잠금 경합을 피할 수 있습니다. - 두 번째 확인: 잠금을 획득한 후
done
을 다시 확인합니다. 이렇게 하면 잠금을 획득하는 동안 다른 고루틴이 함수를 실행하지 않았는지 확인합니다.
향상된 sync.Once
sync.Once
에서 제공하는 Do
메서드는 값을 반환하지 않습니다. 즉, 전달된 함수가 오류를 반환하고 초기화에 실패하면 Do
에 대한 후속 호출은 초기화를 다시 시도하지 않습니다. 이 문제를 해결하기 위해 sync.Once
와 유사한 사용자 지정 동기화 기본 요소를 구현할 수 있습니다.
package main import ( "sync" "sync/atomic" ) type Once struct { done uint32 m sync.Mutex } func (o *Once) Do(f func() error) error { if atomic.LoadUint32(&o.done) == 0 { return o.doSlow(f) } return nil } func (o *Once) doSlow(f func() error) error { o.m.Lock() defer o.m.Unlock() var err error if o.done == 0 { err = f() // 오류가 발생하지 않은 경우에만 완료를 설정합니다. if err == nil { atomic.StoreUint32(&o.done, 1) } } return err }
위의 코드는 향상된 Once
구조체를 구현합니다. 표준 sync.Once
와 달리 이 버전에서는 Do
메서드에 전달된 함수가 오류를 반환할 수 있습니다. 함수가 오류를 반환하지 않으면 done
이 설정되어 함수가 성공적으로 실행되었음을 나타냅니다. 후속 호출에서는 이전에 성공적으로 완료된 경우에만 함수가 건너뛰어져 처리되지 않은 초기화 실패를 방지합니다.
sync.Once의 주의 사항
데드락
sync.Once
소스 코드를 분석한 결과 뮤텍스 필드 m
이 포함되어 있음을 알 수 있습니다. 다른 Do
호출 내부에서 Do
를 재귀적으로 호출하면 동일한 잠금을 여러 번 획득하려고 시도하게 됩니다. 뮤텍스는 재진입이 아니므로 데드락이 발생합니다.
func main() { once := sync.Once{} once.Do(func() { once.Do(func() { fmt.Println("init...") }) }) }
초기화 실패
여기서 초기화 실패는 Do
에 전달된 함수를 실행하는 동안 오류가 발생하는 것을 의미합니다. 표준 sync.Once
는 이러한 실패를 감지하는 방법을 제공하지 않습니다. 이 문제를 해결하기 위해 앞에서 언급한 오류 처리를 지원하고 조건부 재시도를 지원하는 향상된 Once
를 사용할 수 있습니다.
결론
이 기사에서는 Go 프로그래밍 언어의 sync.Once
에 대한 기본 정의, 사용 시나리오, 적용 예제 및 소스 코드 분석을 포함하여 자세한 소개를 제공했습니다.
실제 개발에서는 sync.Once
가 싱글톤 패턴 및 지연 초기화를 구현하는 데 자주 사용됩니다.
sync.Once
는 간단하고 효율적이지만 잘못 사용하면 예기치 않은 문제가 발생할 수 있으므로 주의해서 사용해야 합니다.
요약하자면 sync.Once
는 Go에서 매우 유용한 동시성 기본 요소로, 개발자가 다양한 동시 시나리오에서 스레드로부터 안전한 작업을 수행하는 데 도움이 됩니다. 작업이 한 번만 초기화되어야 하는 상황이 발생할 때마다 sync.Once
가 훌륭한 선택입니다.
Go 프로젝트 호스팅을 위한 최고의 선택, Leapcell입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발합니다.
무료로 무제한 프로젝트 배포
- 사용량에 대해서만 지불합니다. 요청도 없고 요금도 없습니다.
탁월한 비용 효율성
- 사용한 만큼 지불하고 유휴 요금이 없습니다.
- 예: $25로 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅
손쉬운 확장성 및 고성능
- 쉬운 동시성 처리를 위한 자동 크기 조정
- 운영 오버헤드가 없으므로 구축에만 집중하세요.
설명서에서 자세히 알아보세요!
X에서 팔로우하세요: @LeapcellHQ