Go의 비밀스러운 삶: Atomic Operations

발행: (2025년 12월 19일 오후 01:40 GMT+9)
14 min read
원문: Dev.to

Source: Dev.to

분할 불가능한 순간

화요일 아침, 허드슨 강에서 불어오는 날카롭고 차가운 바람이 도서관 보관소의 오래된 이중창을 흔들어 놓았다. 이선은 코트에 낀 추위를 털어내며 두 잔의 커피와 작은 왁스‑페이퍼 봉지를 담은 골판지 트레이를 들고 도착했다.

엘리노어는 이미 책상에 앉아 있었고, 코끝에 올려 둔 독서 안경을 쓰고 인쇄물 더미를 검토하고 있었다. 그녀는 공기를 킁킁 sniff했다.

Eleanor: “블루베리?”
Ethan: “가까워. 웨스트 빌리지에 있는 곳에서 온 허클베리 스콘이야. 그리고 게이샤 핸드드립, 블랙.”

엘리노어는 드물게 진심 어린 미소를 지으며 커피를 받아들였다.

Eleanor: “훌륭해. 커피에서도 정확성이 코드만큼 중요하다는 걸 배우고 있구나.”

이선은 의자를 끌어당겼다.

Ethan: “정확성 얘기가 나와서 말인데, 지난 주를 생각해봤어. 우리는 카운터의 데이터 레이스를 뮤텍스로 고쳤지. 작동은 했지만…”
Eleanor: “하지만?” (그녀는 스콘 조각을 부수며)
Ethan: “무게감이 느껴졌어. 락을 걸고, 풀고, 연기하고… 숫자 하나를 더한다는 이유만으로? 더 빠른 방법은 없을까? 뭔가… 더 작게?”

엘리노어는 노트북에서 부스러기를 닦아냈다.

Eleanor: “있어. 하지만 프로그래머처럼가 아니라 CPU처럼 생각해야 해.”

그녀는 노트북을 열었다.

Eleanor:counter++가 한 단계가 아니라는 걸 얘기했지? 세 단계야: 값을 읽고, 하나를 더하고, 다시 쓰는 거. 뮤텍스는 그 세 단계를 모두 보호하기 위해 주변 세계를 멈추게 하지. 하지만 현대 프로세서는 그 세 단계를 즉시—분할 불가능하게—수행할 수 있는 명령을 가지고 있어.”
Ethan: “분할 불가능하게?”
Eleanor: “원자적으로. 그리스어 atomos—잘라낼 수 없는. 중단될 수 없는 연산.”

엘리노어는 메모지 여백에 간단한 다이어그램을 스케치했다.

NORMAL EXECUTION                ATOMIC EXECUTION
[CPU Core 1]                    [CPU Core 1]
    |                               |
    | Read 0                        | AtomicAdd(1)
    | Add 1                         | (Bus Locked 🔒)
    | Write 1                       | (Value becomes 1)
    |                               | (Bus Unlocked 🔓)
[CPU Core 2]                    [CPU Core 2]
    |                               |
    | (Interferes!)                 | (Must Wait)

Eleanor: “보이죠? 원자 버전에서는 코어 2가 물리적으로 방해할 수 없어요. 하드웨어가 이를 막아줍니다.”

이선은 고개를 끄덕였다.

Ethan: “이론은 알겠어요. Go에서는 어떻게 보이나요?”

Go 예제 – 원자적 추가

package main

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

func main() {
	var counter int64
	var wg sync.WaitGroup

	start := time.Now()
	// ... rest of the example ...
}

Eleanor: “Mutex가 필요 없어. atomic.AddInt64는 방금 그린 하드웨어 명령을 사용해. 가장 원시적인 동기화 형태야.”

Ethan looked at the code.

Ethan: “간단해 보이네. 왜 모든 곳에 쓰지 않는 거야?”
Eleanor: “왜냐면 이것은 단순한 자료형—정수, 포인터, 불리언에만 동작하기 때문이야. 슬라이스에 원자적으로 추가하거나 맵을 업데이트할 수 없어. 하지만 카운터, 게이지, 플래그 같은 경우에는 수십 배 빠르지. Mutex는 약 30 ns 정도 걸릴 수 있는데, 원자적 추가는? 1~2 나노초 정도야.”
Eleanor: “그리고 절대 잠들지 않아. Mutex는 Go 런타임 스케줄러와 연관돼서 goroutine을 잠재울 수도 있어. 원자적 연산은 완전히 하드웨어에서 일어나. 성공하거나 스핀할 뿐이야.”

She took a sip of the Geisha.

Eleanor: “하지만 한 가지 주의점이 있어. 데이터도 원자적으로 읽어야 해. 다른 goroutine이 쓰는 중에 counter를 바로 출력하면 ‘찢어진 읽기’가 발생할 수 있어—값의 절반은 이전 값, 절반은 새로운 값이 섞인 상태가 되지.”

Source:

Go 예제 – 원자적 로드

package main

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

func main() {
	var value int64

	// Writer
	go func() {
		for {
			atomic.AddInt64(&value, 1)
			time.Sleep(time.Millisecond)
		}
	}()

	// Reader
	for i := 0; i < 10; i++ {
		fmt.Println(atomic.LoadInt64(&value))
		time.Sleep(500 * time.Millisecond)
	}
}

Eleanor:atomic.LoadInt64를 주목하세요. 원자적으로 써야 하는 것처럼, 원자적으로 읽어야 합니다. 이는 메모리의 일관된 스냅샷을 보장합니다.”

Ethan이 천천히 고개를 끄덕였다.

Ethan: “그러니까, 쓰기는 Add, 읽기는 Load. 그 외에는 뭐가 있나요?”
Eleanor:Store, 그리고 가장 강력한 Compare‑and‑Swap(CAS)입니다.”

Source:

Compare‑and‑Swap (CAS)

package main

import (
	"fmt"
	"sync/atomic"
)

func main() {
	var status int32 = 0 // 0 = idle, 1 = busy

	// Try to set status to busy
	swapped := atomic.CompareAndSwapInt32(&status, 0, 1)

	if swapped {
		fmt.Println("Success: Status changed from 0 to 1")
	} else {
		fmt.Println("Failure: Status was not 0")
	}

	// Try again
	swapped = atomic.CompareAndSwapInt32(&status, 0, 1)

	if swapped {
		fmt.Println("Success: Status changed from 0 to 1")
	} else {
		fmt.Println("Failure: Status was already 1")
	}
}

Output

Success: Status changed from 0 to 1
Failure: Status was already 1

Eleanor:CompareAndSwap—줄여서 CAS—는 ‘값이 0이라고 생각한다. 만약 그렇다면 1로 바꾸고, 아니라면 알려줘.’ 라는 의미다. 이를 통해 락‑프리 데이터 구조와 알고리즘을 만들 수 있다.”

대화는 계속 이어졌고, 메모리 순서, atomic.Value 타입, 그리고 언제 뮤텍스 대신 원자 연산을 사용해야 하는지에 대해 더 깊이 파고들었다. 하지만 그 이야기는 다음 장에 남겨두자.

Ethan, 여기 규칙이 있다:
채널을 사용해 오케스트레이션한다.
뮤텍스를 사용해 복잡한 상태를 보호한다.
원자 연산은 단순 카운터나 상태 플래그처럼 순수 속도가 필요할 때만 사용한다.

“그럼 영웅이 되지 말라는 거야?”
“맞아. 영리한 코드는 디버깅하기 어렵고, 원자 코드는 디버깅이 거의 불가능해.”

그녀는 노트북에 마지막 예제를 입력했다.

안전하고 매우 유용한 고수준 원자 도구가 하나 있다: atomic.Value.
숫자뿐 아니라 어떤 타입도 다룰 수 있다. 서버가 실행 중일 때 설정을 업데이트하는 데 완벽하다.

package main

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

type Config struct {
	APIKey  string
	Timeout time.Duration
}

func main() {
	var config atomic.Value

	// Initial config
	config.Store(Config{APIKey: "INITIAL", Timeout: time.Second})

	// Background updater
	go func() {
		for {
			time.Sleep(100 * time.Millisecond)
			// Atomically replace the entire struct
			config.Store(Config{
				APIKey:  "UPDATED",
				Timeout: 2 * time.Second,
			})
		}
	}()

	// Reader loop
	for i := 0; i < 10; i++ {
		cfg := config.Load().(Config)
		fmt.Printf("APIKey=%s Timeout=%s\n", cfg.APIKey, cfg.Timeout)
		time.Sleep(200 * time.Millisecond)
	}
}

“보였어? config.Load()는 최신 전체 설정을 반환한다. config.Store()는 전체 구조체를 교체한다. 락도 없고, 블로킹되는 리더도 없다. 리더는 항상 완전하고 유효한 Config 객체—이전 것이든 새 것이든—를 보게 되며, 절대로 섞인 상태를 보지 않는다. 단, 타입 어설션에 주의하라—atomic.Valueany를 저장하므로, Go는 런타임까지 타입을 체크하지 않는다.”

Ethan은 스콘을 한 입 베어 물었다. “정말 깔끔하네요. 설정에는 atomic.Value, 메트릭에는 atomic.Add.”

“바로 그거야,” Eleanor가 노트북을 닫으며 말했다. “너는 이제 도구 상자를 만들고 있어, Ethan. 채널은 전동 드릴, 뮤텍스는 클램프, 원자는 메스. 메스를 가지고 집을 짓지는 않지만, 수술이 필요할 때는 그 어떤 것보다도 필수지.”

그녀는 컵을 들어 올렸다. 커피는 식어가고 있었지만, 그녀는 오래된 한 모금을 천천히 마셨다.

“다음 번엔 하드웨어를 뒤로하고, 코드를 어떻게 구조화할지 얘기해 보자—패키지, 가시성, 그리고 자체 무게에 무너지지 않는 프로그램을 만드는 방법.”

Ethan은 쓰레기를 정리하며 물었다. “프로젝트 구조라구요?”

“프로젝트 구조. Go 애플리케이션의 아키텍처야. 이제 스크립트만 쓰는 걸 그만하고, 시스템을 구축할 때야.”

10장 주요 개념

  • Atomic Operations – 원자적으로 실행되며 다른 goroutine이나 CPU 명령에 의해 중단될 수 없습니다. sync/atomic 패키지에 포함되어 있습니다.
  • atomic.AddInt64 – 변수를 원자적으로 증가시킵니다. 단순 카운터의 경우 Mutex보다 훨씬 빠른데, 이는 OS‑레벨 락이 아닌 CPU 하드웨어 명령을 사용하기 때문입니다.
  • atomic.Load / atomic.Store – 락 없이 값을 안전하게 읽고 쓸 수 있습니다. 부분적으로 기록된 데이터를 볼 수 있는 “torn read”를 방지합니다.
  • Compare and Swap (CAS)atomic.CompareAndSwapInt32(&addr, old, new). 현재 값이 “old”와 일치할 때만 원자적으로 값을 새로운 값으로 업데이트합니다. 락‑프리 알고리즘의 기반이 됩니다.
  • atomic.Value – 어떤 타입이든(구조체, 맵 등) 원자적으로 로드하고 저장할 수 있습니다. 전역 설정을 업데이트하는 등 “읽기‑많고, 쓰기‑드물게” 하는 상황에 이상적입니다.
  • Type Safetyatomic.Valueany(interface{})를 저장합니다. 로드할 때는 항상 타입 어설션을 확인하세요; 타입이 맞지 않으면 런타임 패닉이 발생합니다.

동기화 계층 구조

  1. Channels – 데이터 통신 및 소유권 전달에 사용합니다.
  2. Mutex – 복잡한 공유 상태와 중요한 섹션을 보호하는 데 사용합니다.
  3. Atomic – 단순 카운터, 플래그, 고성능 메트릭 등에 사용합니다.

메모리 안전성

  • 같은 변수에 대해 atomic 접근과 non‑atomic 접근을 섞어 사용하지 마세요.
  • atomic.Store를 사용했다면 읽을 때는 반드시 atomic.Load를 사용해야 합니다.

다음 장: 프로젝트 구조와 패키지 – 여기서 Eleanor가 코드 조직 방법, 내부 디렉터리 구조, 그리고 깔끔한 API 설계에 대해 설명합니다.


Aaron Rose는 tech-reader.blog에서 소프트웨어 엔지니어이자 기술 작가이며, Think Like a Genius의 저자입니다.

Back to Blog

관련 글

더 보기 »

Go 서버에서 고성능 SQLite 읽기

워크로드 가정 이 권장 사항은 다음을 전제로 합니다: - 읽기가 쓰기보다 우세하고 쓰기는 드물거나 오프라인 - 단일 서버 프로세스가 데이터베이스를 소유함 - 다중 goroutine…