1. 동기화 메커니즘의 역할

병행 프로그래밍에서 여러 고루틴이 리소스를 공유할 때 경쟁 조건을 방지하려면 하나의 고루틴만 리소스에 접근할 수 있도록 보장해야 합니다. 이를 위해서 동기화 메커니즘이 필요합니다. 동기화 메커니즘은 서로 다른 고루틴의 리소스 접근 순서를 조정하여, 병행 환경에서 데이터 일관성과 상태 동기화를 보장할 수 있습니다.

Go 언어는 다음과 같은 다양한 동기화 메커니즘을 제공합니다(포함된 것만으로 제한되지 않음):

  • 뮤텍스 (sync.Mutex) 및 읽기-쓰기 뮤텍스 (sync.RWMutex)
  • 채널 (Channels)
  • 웨이트그룹 (WaitGroups)
  • 원자 함수 (atomic package)
  • 조건 변수 (sync.Cond)

2. 동기화 기본 요소

2.1 뮤텍스 (sync.Mutex)

2.1.1 뮤텍스의 개념과 역할

뮤텍스는 공유 리소스에 대한 안전한 작업을 보장하기 위해 한 번에 하나의 고루틴만이 잠금을 보유하여 공유 리소스에 액세스할 수 있도록 하는 동기화 메커니즘입니다. 뮤텍스는 LockUnlock 메서드를 통해 동기화를 달성합니다. Lock 메서드 호출은 잠금이 해제될 때까지 블록되며, 이때 다른 고루틴들은 잠금을 얻으려고 대기합니다. Unlock을 호출하면 잠금이 해제되어 다른 대기 중인 고루틴이 이를 획득할 수 있게 됩니다.

var mu sync.Mutex

func criticalSection() {
    // 공유 리소스를 독점적으로 액세스하기 위해 잠금 획득
    mu.Lock()
    // 이곳에서 공유 리소스에 액세스
    // ...
    // 다른 고루틴들이 잠금을 획득할 수 있도록 잠금 해제
    mu.Unlock()
}

2.1.2 뮤텍스의 실제 활용

전역 카운터를 유지해야 하고 여러 고루틴이 그 값을 증가시켜야 한다고 가정해봅시다. 뮤텍스를 사용하면 카운터의 정확성을 보장할 수 있습니다.

var (
    mu      sync.Mutex
    counter int
)

func increment() {
    mu.Lock()         // 카운터를 수정하기 전에 잠금
    counter++         // 안전하게 카운터 증가
    mu.Unlock()       // 작업 후 잠금 해제하여 다른 고루틴이 카운터에 액세스할 수 있도록 함
}

func main() {
    for i := 0; i < 10; i++ {
        go increment()  // 카운터 값을 증가시키기 위해 여러 고루틴 시작
    }
    // 어느 정도 시간을 기다림 (실제로는 WaitGroup 또는 다른 방법을 사용하여 모든 고루틴이 완료될 때까지 기다리는 것이 좋음)
    time.Sleep(1 * time.Second)
    fmt.Println(counter)  // 카운터의 값 출력
}

2.2 읽기-쓰기 뮤텍스 (sync.RWMutex)

2.2.1 읽기-쓰기 뮤텍스의 개념

RWMutex는 여러 고루틴이 동시에 공유 리소스를 읽을 수 있지만 쓰기 작업은 배타적이어야 하는 특수한 종류의 잠금입니다. 뮤텍스와 비교하여 읽기-쓰기 잠금은 다중 리더 시나리오에서 성능을 향상시킬 수 있습니다. 그것은 RLock, RUnlock을 사용하여 읽기 작업을 잠그고 해제하고, Lock, Unlock을 사용하여 쓰기 작업을 잠그고 해제합니다.

2.2.2 읽기-쓰기 뮤텍스의 실용적인 사용 사례

데이터베이스 응용 프로그램에서 읽기 작업이 쓰기 작업보다 훨씬 빈번할 수 있습니다. 읽기-쓰기 잠금을 사용하면 여러 고루틴이 동시에 읽을 수 있어 시스템 성능을 향상시킬 수 있습니다.

var (
    rwMu  sync.RWMutex
    data  int
)

func readData() int {
    rwMu.RLock()         // 읽기 잠금 획득, 다른 읽기 작업이 동시에 진행될 수 있도록 함
    defer rwMu.RUnlock() // defer를 사용하여 잠금이 해제되도록 함
    return data          // 안전하게 데이터 읽기
}

func writeData(newValue int) {
    rwMu.Lock()          // 쓰기 잠금 획득, 이 시간에는 다른 읽기 또는 쓰기 작업을 방지함
    data = newValue      // 새 값 안전하게 쓰기
    rwMu.Unlock()        // 쓰기 완료 후 잠금 해제
}

func main() {
    go writeData(42)     // 쓰기 작업을 수행하는 고루틴 시작
    fmt.Println(readData()) // 메인 고루틴에서 읽기 작업 수행
    // 모든 고루틴이 완료될 수 있도록 WaitGroup 또는 다른 동기화 방법을 사용
}

위의 예제에서 여러 리더는 readData 함수를 동시에 실행할 수 있지만, writeData를 실행하는 작가는 새 리더와 다른 작가를 차단할 것입니다. 이 기능은 쓰기보다 읽기가 많은 시나리오에서 성능적 이점을 제공합니다.

2.3 조건 변수 (sync.Cond)

2.3.1 조건 변수의 개념

Go 언어의 동기화 메커니즘에서 조건 변수는 동기화 원시로서 특정 조건 변경을 기다리거나 통지하는 데 사용됩니다. 조건 변수는 항상 조건 자체의 일관성을 보호하는 데 사용되는 뮤텍스(sync.Mutex)와 함께 사용됩니다.

조건 변수의 개념은 운영 체제 영역에서 온 것으로, 고루틴 그룹이 특정 조건이 충족될 때까지 기다릴 수 있게 합니다. 구체적으로 말하면, 고루틴은 특정 조건이 충족될 때까지 실행을 일시 중단할 수 있고, 다른 고루틴이 조건 변수를 사용하여 조건을 변경한 후 다른 고루틴을 통지하여 실행을 다시 시작할 수 있습니다.

Go 표준 라이브러리에서 조건 변수는 sync.Cond 유형을 통해 제공되며, 주요 메서드는 다음과 같습니다:

  • Wait: 이 메서드를 호출하면 보유한 잠금을 해제하고, 동일한 조건 변수에서 다른 고루틴이 Signal 또는 Broadcast를 호출하여 깨울 때까지 차단된 후 다시 잠금을 얻으려고 시도합니다.
  • Signal: 이 조건 변수를 기다리는 하나의 고루틴을 깨웁니다. 대기 중인 고루틴이 없으면 이 메서드를 호출해도 효과가 없습니다.
  • Broadcast: 이 조건 변수를 기다리는 모든 고루틴을 깨웁니다.

조건 변수는 복사되지 않아야하기 때문에 일반적으로 특정 구조체의 포인터 필드로 사용됩니다.

2.3.2 조건 변수의 실제 적용 사례

다음은 조건 변수를 사용한 생산자-소비자 모델을 보여주는 예제입니다:

package main

import (
    "fmt"
    "sync"
    "time"
)

// SafeQueue는 뮤텍스로 보호되는 안전한 큐입니다.
type SafeQueue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    queue []interface{}
}

// Enqueue는 큐 끝에 요소를 추가하고 대기 중인 고루틴을 통지합니다.
func (sq *SafeQueue) Enqueue(item interface{}) {
    sq.mu.Lock()
    defer sq.mu.Unlock()

    sq.queue = append(sq.queue, item)
    sq.cond.Signal() // 큐가 비어 있지 않음을 대기 중인 고루틴에게 통지합니다
}

// Dequeue는 큐에서 요소를 제거하고 큐가 비어 있으면 기다립니다.
func (sq *SafeQueue) Dequeue() interface{} {
    sq.mu.Lock()
    defer sq.mu.Unlock()

    // 큐가 비어 있을 때 기다립니다.
    for len(sq.queue) == 0 {
        sq.cond.Wait() // 조건 변경을 기다립니다.
    }

    item := sq.queue[0]
    sq.queue = sq.queue[1:]
    return item
}

func main() {
    queue := make([]interface{}, 0)
    sq := SafeQueue{
        mu:    sync.Mutex{},
        cond:  sync.NewCond(&sync.Mutex{}),
        queue: queue,
    }

    // 생산자 고루틴
    go func() {
        for i := 0; i < 5; i++ {
            time.Sleep(1 * time.Second)         // 생산 시간 시뮬레이션
            sq.Enqueue(fmt.Sprintf("아이템%d", i)) // 요소 생성
            fmt.Println("생산:", i)
        }
    }()

    // 소비자 고루틴
    go func() {
        for i := 0; i < 5; i++ {
            item := sq.Dequeue() // 요소 소비, 큐가 비어 있으면 기다림
            fmt.Printf("소비: %v\n", item)
        }
    }()

    // 모든 생산과 소비가 완료되도록 충분한 시간을 기다립니다
    time.Sleep(10 * time.Second)
}

이 예제에서는 내부 큐와 조건 변수를 가진 SafeQueue 구조체를 정의했습니다. 소비자가 Dequeue 메서드를 호출하고 큐가 비어 있다면 Wait 메서드를 사용하여 대기합니다. 생산자가 Enqueue 메서드를 호출하여 새 요소를 큐에 넣을 때 큐가 비어 있다면 Signal 메서드를 사용하여 대기 중인 소비자를 깨웁니다.

2.4 WaitGroup

2.4.1 WaitGroup의 개념과 사용 방법

sync.WaitGroup은 고루틴 그룹이 완료될 때까지 기다리는 동기화 메커니즘입니다. 고루틴을 시작할 때 Add 메서드를 호출하여 카운터를 증가시킬 수 있으며, 각 고루틴은 작업이 완료되면 Done 메서드를 호출할 수 있습니다 (실제로는 Add(-1)을 수행함). 메인 고루틴은 Wait 메서드를 호출하여 카운터가 0이 될 때까지 블록될 수 있으며, 이는 모든 고루틴이 작업을 완료했음을 나타냅니다.

WaitGroup을 사용할 때 다음 사항에 유의해야 합니다:

  • Add, Done, Wait 메서드는 스레드 안전하지 않으며 여러 고루틴에서 동시에 호출해서는 안 됩니다.
  • Add 메서드는 새로 생성된 고루틴이 시작하기 전에 호출되어야 합니다.

2.4.2 WaitGroup의 실제 사용 사례

WaitGroup을 사용한 예제입니다.

package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {
	defer wg.Done() // 완료 시 WaitGroup에 알림

	fmt.Printf("Worker %d 시작\n", id)
	time.Sleep(time.Second) // 시간이 많이 소요되는 작업 모의
	fmt.Printf("Worker %d 완료\n", id)
}

func main() {
	var wg sync.WaitGroup

	for i := 1; i <= 5; i++ {
		wg.Add(1) // 고루틴 시작 전 카운터 증가
		go worker(i, &wg)
	}

	wg.Wait() // 모든 워커 고루틴이 완료될 때까지 대기
	fmt.Println("모든 워커 완료")
}

이 예제에서 worker 함수는 작업 실행을 모의화합니다. main 함수에서는 다섯 개의 worker 고루틴을 시작합니다. 각 고루틴을 시작하기 전에 wg.Add(1)를 호출하여 WaitGroup에 새 작업 실행을 알립니다. 각 워커 함수가 완료되면 defer wg.Done()을 호출하여 WaitGroup에 작업 완료를 알립니다. 고루틴을 모두 시작한 후, main 함수는 wg.Wait()에서 모든 워커가 완료할 때까지 블록됩니다.

2.5 원자적 연산 (sync/atomic)

2.5.1 원자적 연산의 개념

원자적 연산은 동시 프로그래밍에서 다른 연산에 의해 실행 중간에 방해받지 않는, 즉 쪼갤 수 없는 연산을 의미합니다. 여러 고루틴에서 원자적 연산을 사용하면 잠금이 필요 없이 데이터 일관성과 상태 동기화를 보장할 수 있습니다. Go 언어에서 sync/atomic 패키지는 저수준의 원자적 메모리 연산을 제공합니다. int32, int64, uint32, uint64, uintptr, 그리고 pointer와 같은 기본 데이터 유형에 대해 sync/atomic 패키지의 메서드를 사용하여 안전한 동시 작업을 할 수 있습니다. 원자적 연산의 중요성은 다른 동시성 기본 요소(잠금 및 조건 변수와 같은)를 구축하는 기초가 되며 종종 잠금 메커니즘보다 효율적입니다.

2.5.2 원자적 연산의 실제 사용 사례

웹사이트의 동시 방문자 수를 추적해야 하는 시나리오를 고려해보겠습니다. 간단한 카운터 변수를 사용하여 방문자가 도착할 때 카운터를 증가시키고, 떠날 때 감소시키는 방식으로 진행할 것입니다. 그러나 동시 환경에서 이러한 접근 방법은 데이터 경쟁을 야기할 것입니다. 따라서 sync/atomic 패키지를 사용하여 카운터를 안전하게 조작할 수 있습니다.

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

var visitorCount int32

func incrementVisitorCount() {
	atomic.AddInt32(&visitorCount, 1)
}

func decrementVisitorCount() {
	atomic.AddInt32(&visitorCount, -1)
}

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			incrementVisitorCount()
			time.Sleep(time.Second) // 방문자의 체류 시간
			decrementVisitorCount()
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Printf("현재 방문자 수: %d\n", visitorCount)
}

이 예제에서는 100개의 고루틴을 생성하여 방문자의 도착과 떠남을 모의화합니다. atomic.AddInt32() 함수를 사용하여 카운터의 증가 및 감소가 원자적으로 이루어지도록하여 고도로 동시적인 상황에서도 카운터의 정확성을 보장합니다.

2.6 채널 동기화 메커니즘

2.6.1 채널의 동기화 특성

채널은 Go 언어에서 고루틴 간에 통신하는 방법입니다. 채널은 데이터를 보내고 받는 기능을 제공합니다. 고루틴이 채널에서 데이터를 읽으려고 하면 데이터가 없을 때까지 블록됩니다. 마찬가지로 채널이 가득 차 있으면(버퍼가 없는 채널의 경우 데이터가 이미 채워져 있음)데이터를 보내려는 고루틴도 쓰기 공간이 생길 때까지 블록됩니다. 이러한 특성으로 인해 채널은 고루틴 간 동기화에 매우 유용합니다.

2.6.2 채널 동기화의 사용 사례

여러 고루틴이 각각 하위 작업을 처리하고 모든 하위 작업의 결과를 집계해야 하는 작업이 있다고 가정해보겠습니다. 우리는 채널을 사용하여 모든 고루틴이 완료될 때까지 기다릴 수 있습니다.

package main

import (
    "fmt"
    "sync"
)

func worker(id int, wg *sync.WaitGroup, resultChan chan<- int) {
    defer wg.Done()
    // 어떤 작업 수행...
    fmt.Printf("Worker %d 시작\n", id)
    // 하위 작업 결과를 해당 고루틴의 id로 가정
    resultChan <- id
    fmt.Printf("Worker %d 완료\n", id)
}

func main() {
    var wg sync.WaitGroup
    numWorkers := 5
    resultChan := make(chan int, numWorkers)

    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go worker(i, &wg, resultChan)
    }

    go func() {
        wg.Wait()
        close(resultChan)
    }()

    // 모든 결과 수집
    for result := range resultChan {
        fmt.Printf("결과 수신: %d\n", result)
    }
}

이 예에서는 5개의 고루틴을 시작하여 작업을 수행하고 resultChan 채널을 통해 결과를 수집합니다. 메인 고루틴은 모든 작업이 별도의 고루틴에서 완료될 때까지 기다린 후 결과 채널을 닫습니다. 그 후 메인 고루틴은 resultChan 채널을 통해 모든 고루틴의 결과를 수집하고 출력합니다.

2.7 한 번만 실행하기 (sync.Once)

sync.Once는 프로그램 실행 중에 한 번만 특정 작업이 실행되도록 보장하는 동기화 기본 요소입니다. sync.Once의 전형적인 사용 사례는 싱글톤 객체의 초기화 또는 지연된 초기화가 필요한 시나리오입니다. 이 작업을 호출하는 고루틴의 수에 상관없이 Do 함수의 이름처럼 한 번만 실행됩니다.

sync.Once는 동시성 문제와 실행 효율성을 완벽하게 균형잡아줘서 반복적인 초기화로 인한 성능 문제에 대한 우려를 제거합니다.

sync.Once의 사용법을 보여주는 간단한 예시입니다:

package main

import (
    "fmt"
    "sync"
)

var once sync.Once
var instance *Singleton

type Singleton struct{}

func Instance() *Singleton {
    once.Do(func() {
        fmt.Println("단일 인스턴스 생성 중.")
        instance = &Singleton{}
    })
    return instance
}

func main() {
    for i := 0; i < 10; i++ {
        go Instance()
    }
    fmt.Scanln() // 출력을 보기 위해 대기
}

이 예시에서는 Instance 함수가 여러 번 동시에 호출되더라도 Singleton 인스턴스의 생성은 한 번만 발생합니다. 이후의 호출은 최초로 생성된 싱글톤 인스턴스를 직접 반환하여 인스턴스의 고유성을 보장합니다.

2.8 ErrGroup

ErrGroup은 복수의 고루틴을 동기화하고 그들의 에러를 수집하는 라이브러리입니다. 이는 "golang.org/x/sync/errgroup" 패키지의 일부로, 병행 작업에서의 에러 시나리오를 처리하기 위한 간결한 방법을 제공합니다.

2.8.1 ErrGroup의 개념

ErrGroup의 핵심 아이디어는 관련 작업 그룹을 묶어서 (보통 병행으로 실행됨) 하나의 작업이 실패하면 전체 그룹의 실행을 취소한다는 것입니다. 동시에 이러한 병행 작업 중 어떤 것이라도 에러를 반환하면 ErrGroup이 이를 캡처하고 반환합니다.

ErrGroup을 사용하려면 먼저 패키지를 임포트합니다:

import "golang.org/x/sync/errgroup"

그런 다음 ErrGroup의 인스턴스를 생성합니다:

var g errgroup.Group

그 후에는 클로저 형태로 작업을 ErrGroup에 전달하고 Go 메서드를 호출하여 새로운 고루틴을 시작할 수 있습니다:

g.Go(func() error {
    // 특정 작업 수행
    // 모든 것이 잘 되면
    return nil
    // 에러가 발생하면
    // return fmt.Errorf("에러 발생")
})

마지막으로, Wait 메서드를 호출합니다. 이 메서드는 모든 작업이 완료될 때까지 블록되어 기다립니다. 이러한 작업 중 어떤 것이라도 에러를 반환하면 Wait는 해당 에러를 반환합니다:

if err := g.Wait(); err != nil {
    // 에러 처리
    log.Fatalf("작업 실행 에러: %v", err)
}

2.8.2 ErrGroup의 실전 활용 사례

세 가지 다른 데이터 소스에서 동시에 데이터를 가져와야 하며, 만약 이들 중 하나가 실패하면 다른 데이터 가져오기 작업을 즉시 취소하고 싶다고 가정해봅시다. 이러한 작업은 ErrGroup을 사용하여 쉽게 수행할 수 있습니다:

package main

import (
    "fmt"
    "golang.org/x/sync/errgroup"
)

func fetchDataFromSource1() error {
    // 소스 1로부터 데이터를 가져오는 시뮬레이션
    return nil // 또는 오류를 반환하여 실패를 시뮬레이트합니다
}

func fetchDataFromSource2() error {
    // 소스 2로부터 데이터를 가져오는 시뮬레이션
    return nil // 또는 오류를 반환하여 실패를 시뮬레이트합니다
}

func fetchDataFromSource3() error {
    // 소스 3로부터 데이터를 가져오는 시뮬레이션
    return nil // 또는 오류를 반환하여 실패를 시뮬레이트합니다
}

func main() {
    var g errgroup.Group

    g.Go(fetchDataFromSource1)
    g.Go(fetchDataFromSource2)
    g.Go(fetchDataFromSource3)

    // 모든 고루틴의 작업이 완료될 때까지 기다린 후 오류를 수집합니다
    if err := g.Wait(); err != nil {
        fmt.Printf("데이터를 가져오는 동안 오류 발생: %v\n", err)
        return
    }

    fmt.Println("모든 데이터가 성공적으로 가져와졌습니다!")
}

이 예제에서 fetchDataFromSource1, fetchDataFromSource2, fetchDataFromSource3 함수는 각기 다른 데이터 소스에서 데이터를 가져오는 것을 시뮬레이션합니다. 이들은 g.Go 메서드에 전달되어 별도의 고루틴에서 실행됩니다. 이들 중 하나의 함수가 오류를 반환하면, g.Wait가 즉시 해당 오류를 반환하여 오류 발생 시 적절한 오류 처리가 가능합니다. 모든 함수가 성공적으로 실행될 경우, g.Wait는 모든 작업을 성공적으로 완료했음을 나타내는 nil을 반환합니다.

또한 ErrGroup의 또 다른 중요한 기능은 고루틴 중 하나가 패닉을 일으키면, 이를 복구하고 이를 오류로 반환하려고 시도합니다. 이는 다른 동시에 실행 중인 고루틴들이 우아하게 종료되지 않도록 돕습니다. 물론 작업을 외부 취소 신호에 대응하려면 errgroupWithContext 함수를 context 패키지와 결합하여 취소 가능한 컨텍스트를 제공할 수 있습니다.

이렇게 함으로써, ErrGroup은 Go의 동시 프로그래밍 실무에서 매우 실용적인 동기화 및 오류 처리 메커니즘으로 거듭납니다.