← 기술 지식

기술 지식 / 프로그래밍 언어 / Go

Go Map의 동시 접근과 복합 상태 변경

서로 다른 키의 내부 구조 공유와 mutex·owner goroutine·불변 map·sync.Map의 연산별 원자 경계를 설명합니다.

언어·런타임 · 원리·구현·운영
기술 지식 트리

Go의 일반 map은 업무 키가 다르더라도 내부 테이블을 공유하는 하나의 가변 자료구조입니다. 따라서 안전성은 키의 분리 여부가 아니라 자료구조 접근과 복합 상태 변경을 어떤 동기화 경계에 넣었는지로 결정됩니다. 연산 하나의 원자성과 여러 연산으로 이루어진 불변식의 원자성을 나누어 추적합니다.

서로 다른 키와 공유 내부 저장 구조

goroutine A가 사용자 1을 갱신하고 B가 사용자 2를 갱신하면 키가 다르니 잠금이 필요 없다고 생각하기 쉽습니다. 그러나 일반 map은 키와 값을 담는 버킷이나 테이블 같은 내부 저장 단위, 필요할 때 이를 늘리는 확장 작업, 그 상태를 기록하는 내부 메타데이터를 두 goroutine이 공유합니다. 따라서 키가 다르다는 업무 조건만으로 자료구조 내부의 동시 쓰기 안전성이 생기지 않으며, 동시 읽기·쓰기에도 적절한 동기화가 필요합니다.

런타임이 concurrent map 오류를 탐지할 수 있어도 항상 탐지된다는 보장은 아닙니다. fatal 메시지가 없거나 한 번의 테스트가 통과했다고 안전한 사용이 되는 것은 아닙니다.

복합 불변식과 공통 잠금 경계

type Counter struct {
    mu sync.Mutex
    values map[string]int
}
func (c *Counter) Add(key string, delta int) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.values[key] += delta // values는 생성 시 make로 준비
    return c.values[key]
}

불변식은 한 번의 변경이 끝난 뒤에도 함께 맞아야 하는 상태의 관계입니다. 예를 들어 현재 값을 읽고 delta를 더해 다시 저장하는 Add는 값 읽기·계산·쓰기를 같은 임계 구역(잠금으로 보호하는 구간)에 둬야 합니다. map에서 포인터를 꺼내는 읽기만 RLock으로 보호한 뒤 잠금 밖에서 그 포인터의 필드를 바꾸면 대상 필드의 변경은 보호되지 않을 수 있으므로, 다른 API가 raw map이나 가변 value 참조를 반환하게 하지 않습니다.

구조 장점 별도 비용·조건
Mutex·RWMutex 복합 불변식 표현이 직접적 경합·긴 임계 구역
owner goroutine 명령 순서·상태 소유 명확 bounded 큐·종료·두 owner 조정
불변 map snapshot 읽기 경로 단순화 복사·깊은 불변·게시·다중 writer
sync.Map 특정 동시 접근 패턴에 적합 연산별 계약·복합 변경·Range 한계

Sync.Map의 Load·Store와 원자적 선택 경계

예를 들어 A와 B가 같은 키를 거의 동시에 조회하면, 두 Load가 모두 ‘없음’을 반환해 각자 객체를 만든 뒤 Store할 수 있습니다. 저장 결과를 하나로 고르려면 LoadOrStore, 해당 Go 버전이 지원하는 CAS 연산, 또는 별도 잠금으로 ‘어느 후보가 저장되는가’를 하나의 원자 경계로 묶어야 합니다. 다만 LoadOrStore의 인자를 만들기 위해 비싼 생성 함수를 먼저 실행했다면 저장은 한 번이어도 생성과 그 부수 효과는 두 번 일어날 수 있으므로, 생성 자체의 중복도 별도로 다룹니다.

개별 연산의 안전성과 전체 변경의 원자성을 나눕니다A Load · 없음. B Load · 없음. LoadOrStore 또는 잠금: 하나의 저장 결과 선택. 생성 부수 효과는 별도 관리후보 A후보 B저장 원자성의 범위A Load · 없음B Load · 없음LoadOrStore 또는 잠금하나의 저장 결과 선택생성 부수 효과는 별도 관리
개별 연산의 안전성과 전체 변경의 원자성을 나눕니다 화살표는 경쟁 가능한 실행 단계입니다. 두 Load가 모두 없음이어도 하나의 원자 선택 경계에서 승자를 정해야 하며 생성 부수 효과는 별도입니다.
그림을 글로 읽기
  • A Load · 없음
  • B Load · 없음
  • LoadOrStore 또는 잠금: 하나의 저장 결과 선택
  • 생성 부수 효과는 별도 관리
  1. A Load · 없음 → LoadOrStore 또는 잠금: 후보 A
  2. B Load · 없음 → LoadOrStore 또는 잠금: 후보 B
  3. LoadOrStore 또는 잠금 → 생성 부수 효과는 별도 관리: 저장 원자성의 범위

sync.Map의 Range를 전체 map의 단일 시점 snapshot으로 가정하지 않습니다. 여러 키 합계·이체·용량 제한 같은 불변식은 더 넓은 동기화가 필요합니다. 지원 메서드와 비교 가능한 값의 조건도 실제 Go 버전에 맞게 확인합니다.

불변 Snapshot의 깊은 불변과 안전한 게시

새 map을 완전히 만든 뒤 atomic.Value나 적절한 atomic pointer·잠금으로 루트를 게시하고 이후 수정하지 않는 방식은 읽기에 적합할 수 있습니다. map 내부의 slice·포인터가 여전히 공유 가변 객체를 가리키면 전체 snapshot은 불변이 아닙니다. 독자는 같은 루트를 한 번 얻어 관련 필드를 읽습니다.

여러 writer가 같은 옛 snapshot을 읽고 복사해 각각 Store하면, 나중에 끝난 writer의 저장이 앞 writer의 변경을 덮어쓰는 lost update가 생길 수 있습니다. writer를 직렬화하거나 CAS가 실패했을 때 최신 상태를 다시 읽어 재계산하는 계약이 필요합니다. 샤딩은 키별 경합을 줄일 뿐 여러 샤드에 걸친 원자성을 자동으로 제공하지 않으므로, 여러 샤드를 함께 바꿀 때는 잠금 순서와 공통 조정 경계를 정합니다.

검증에서는 두 worker가 동시에 Load에서 없음에 도달하도록 장벽을 둔 뒤 LoadOrStore와 비싼 생성 함수를 조합해 저장 승자 수와 생성 부수 효과 수를 따로 세십시오. 예상 결과는 저장된 객체는 하나지만 생성 함수는 둘 호출될 수 있다는 것입니다. go test -race가 통과해도 여러 키 합계나 lost update가 자동으로 안전해지는 것은 아니므로 논리 불변식을 별도 검사합니다.

Race Detector와 논리 경쟁 시험

동시 갱신·조회·삭제를 go test -race로 검사하고, 두 worker가 없음 조회를 동시에 마치도록 장벽을 둬 중복 생성·불변식 위반을 확인합니다. 원자 연산만 사용해 data race가 없어도 lost update·중복 부수 효과가 생길 수 있습니다.

읽기·쓰기 비율·map 크기·키 분포·다중 키 작업을 고정해 선택 구조를 비교합니다. 이 노트는 안전한 상태 관리 설명이며 실제 운영 map의 성능 측정이나 모든 동시성 경로를 검증한 결과는 아닙니다.

후속·관련 개념

함께 읽을 문서

전체 기술 지식
선택 연습 · 관련 질문 1개

질문은 이해를 확인하는 보조 자료입니다. 학습 경로와 문서의 범위는 질문 수와 독립적입니다.