본문으로 건너뛰기

종합 실습: 인메모리 재고 관리

이 챕터에서 다루는 것

Part 3에서 배운 것을 하나의 프로그램으로 합친다. 재고 관리라는 흔한 소재를 쓰지만 초점은 기능이 아니라 자료 구조 선택이다. 왜 여기는 값이고 저기는 포인터인지, 왜 snapshot이 복사본을 돌려주는지가 이 챕터의 내용이다.

만들 것

  • 품목을 SKU로 등록·조회·삭제하고 수량을 조정한다.
  • 실패는 전부 error로 알린다. 패닉을 내지 않는다.
  • 현재 상태를 정렬된 순서로 출력한다.
  • 호출자가 결과를 고쳐도 내부 상태가 오염되지 않는다.

메서드와 인터페이스는 Part 4의 주제이므로 여기서는 함수와 구조체만 쓴다. Part 4를 마치면 이 코드가 자연스럽게 메서드로 정리되는 것을 보게 될 것이다.

설계 결정 네 가지

코드를 보기 전에 왜 이렇게 만들었는지부터 정한다. 각 결정이 Part 3의 어느 챕터에서 왔는지도 함께 적는다.

1. Product는 값, Inventory는 포인터

type Product struct {
SKU string
Name string
Price int
Qty int
}

Product는 문자열 둘(16바이트씩)과 int 둘(8바이트씩)이라 48바이트다. 작고, 복사해도 의미가 자연스럽다. 그래서 함수 인자와 반환값에 값으로 쓴다.

Inventory는 반대다. 상태를 들고 있고 고쳐야 하므로 *Inventory로 넘긴다. 3-5의 첫 번째 기준 — 고쳐야 하는가 — 에 따른 것이다.

Priceint인 것도 결정이다. 돈에 float64를 쓰면 0.1 + 0.2 != 0.3이 그대로 금액 오차가 된다(2-2). 최소 단위 정수로 센다.

2. items는 포인터 맵

type Inventory struct {
items map[string]*Product
log []string
}

map[string]Product(값 맵)였다면 수량을 고칠 때마다 꺼내서 → 고치고 → 다시 넣기 세 줄이 필요하다(3-3). adjust가 이 프로그램에서 가장 자주 불리는 연산이므로 포인터 맵을 택했다.

대가는 없는 키가 nil을 준다는 것이다. 그래서 모든 조회를 comma-ok로 받고, nil을 역참조하는 경로를 남기지 않는다.

3. 맵은 생성자에서 반드시 만든다

func newInventory() *Inventory {
return &Inventory{
items: make(map[string]*Product),
log: make([]string, 0, 16),
}
}

이 함수의 존재 이유가 첫 줄이다. Inventory{}를 그냥 쓰면 itemsnil이고 첫 add에서 assignment to entry in nil map이 난다(3-3). 생성자를 두는 것만으로 이 함정이 구조적으로 사라진다.

lognil이어도 append가 되므로 꼭 만들 필요는 없다. cap을 16으로 잡아 둔 것은 초기 재할당을 몇 번 줄이려는 것이고(3-2), 없어도 정상 동작한다.

4. snapshot은 복사본을 돌려준다

내부 슬라이스나 포인터 맵의 값을 그대로 내보내면 호출자가 그것을 고쳐서 내부 상태를 오염시킬 수 있다. snapshot*Product를 역참조해 값 슬라이스를 새로 만든다. 3-2의 백킹 배열 공유를 원천 차단하는 방식이다.

전체 코드

examples/03-composite-types/07-inventory/main.go
package main

import (
"errors"
"fmt"
"maps"
"slices"
"strings"
)

// Product는 재고 한 품목이다. 값으로 주고받기에 충분히 작다.
type Product struct {
SKU string
Name string
Price int // 원 단위 정수. 돈에 float64를 쓰지 않는다.
Qty int
}

// Inventory는 SKU를 키로 품목을 들고 있다.
// items를 포인터 맵으로 둔 것은 수량을 제자리에서 고치기 위해서다.
type Inventory struct {
items map[string]*Product
log []string
}

var (
errDuplicate = errors.New("이미 등록된 SKU")
errNotFound = errors.New("등록되지 않은 SKU")
errNegative = errors.New("수량이 음수가 된다")
)

// newInventory는 맵을 반드시 만들어 둔다. nil 맵을 남기지 않는 것이 이 함수의 핵심이다.
func newInventory() *Inventory {
return &Inventory{
items: make(map[string]*Product),
log: make([]string, 0, 16),
}
}

// add는 새 품목을 등록한다. p는 값으로 받고, 맵에는 그 복사본의 주소를 넣는다.
func add(inv *Inventory, p Product) error {
if _, ok := inv.items[p.SKU]; ok {
return fmt.Errorf("add %s: %w", p.SKU, errDuplicate)
}
stored := p
inv.items[p.SKU] = &stored
inv.log = append(inv.log, fmt.Sprintf("add %s x%d", p.SKU, p.Qty))
return nil
}

// adjust는 수량을 delta만큼 더한다. 결과가 음수면 아무것도 바꾸지 않는다.
func adjust(inv *Inventory, sku string, delta int) error {
p, ok := inv.items[sku]
if !ok {
return fmt.Errorf("adjust %s: %w", sku, errNotFound)
}
if p.Qty+delta < 0 {
return fmt.Errorf("adjust %s (%d%+d): %w", sku, p.Qty, delta, errNegative)
}
p.Qty += delta
inv.log = append(inv.log, fmt.Sprintf("adjust %s %+d", sku, delta))
return nil
}

// remove는 품목을 지운다. delete는 없는 키에도 안전하지만 여기서는 명시적으로 알린다.
func remove(inv *Inventory, sku string) error {
if _, ok := inv.items[sku]; !ok {
return fmt.Errorf("remove %s: %w", sku, errNotFound)
}
delete(inv.items, sku)
inv.log = append(inv.log, "remove "+sku)
return nil
}

// snapshot은 현재 상태를 값 슬라이스로 복사해 돌려준다.
// 호출자가 결과를 고쳐도 재고에 영향이 없다.
func snapshot(inv *Inventory) []Product {
out := make([]Product, 0, len(inv.items))
for _, sku := range slices.Sorted(maps.Keys(inv.items)) {
out = append(out, *inv.items[sku])
}
return out
}

// totalValue는 재고 평가액을 구한다. 읽기만 하므로 값 슬라이스를 받는다.
func totalValue(items []Product) int {
total := 0
for _, p := range items {
total += p.Price * p.Qty
}
return total
}

// lowStock은 기준 미만인 품목만 골라낸다.
// 입력 슬라이스를 재사용하지 않고 새로 만든다 — 백킹 배열 공유를 피하기 위해서다.
func lowStock(items []Product, threshold int) []Product {
var out []Product
for _, p := range items {
if p.Qty < threshold {
out = append(out, p)
}
}
return out
}

// countByPrefix는 SKU의 하이픈 앞부분으로 품목 수를 센다. 맵의 제로값 덕에 += 없이 ++만 쓴다.
func countByPrefix(items []Product) map[string]int {
counts := make(map[string]int)
for _, p := range items {
prefix, _, ok := strings.Cut(p.SKU, "-")
if !ok {
prefix = "기타"
}
counts[prefix]++
}
return counts
}

func printTable(items []Product) {
fmt.Printf("%-8s %-10s %8s %5s\n", "SKU", "이름", "단가", "수량")
for _, p := range items {
fmt.Printf("%-8s %-10s %8d %5d\n", p.SKU, p.Name, p.Price, p.Qty)
}
}

func main() {
inv := newInventory()

seed := []Product{
{SKU: "FR-001", Name: "사과", Price: 1500, Qty: 20},
{SKU: "FR-002", Name: "바나나", Price: 900, Qty: 5},
{SKU: "VG-001", Name: "당근", Price: 700, Qty: 40},
{SKU: "VG-002", Name: "감자", Price: 600, Qty: 3},
}
for _, p := range seed {
if err := add(inv, p); err != nil {
fmt.Println("초기화 실패:", err)
return
}
}

// 값으로 넘겼으므로 seed를 고쳐도 재고는 그대로다.
seed[0].Qty = 9999
fmt.Println("seed 수정 후 FR-001 수량:", inv.items["FR-001"].Qty)

// 정상 조정과 실패 조정
for _, step := range []struct {
sku string
delta int
}{
{"FR-002", +15},
{"VG-002", -10},
{"XX-999", +1},
{"VG-001", -35},
} {
if err := adjust(inv, step.sku, step.delta); err != nil {
fmt.Println("경고:", err)
}
}

if err := remove(inv, "FR-001"); err != nil {
fmt.Println("경고:", err)
}

items := snapshot(inv)
fmt.Println()
printTable(items)

// snapshot 결과는 복사본이다.
items[0].Qty = -1
fmt.Println("\nsnapshot 수정 후 실제 수량:", inv.items[items[0].SKU].Qty)

fmt.Println("평가액:", totalValue(snapshot(inv)))

low := lowStock(snapshot(inv), 10)
fmt.Print("재고 부족: ")
for _, p := range low {
fmt.Printf("%s(%d) ", p.Name, p.Qty)
}
fmt.Println()

counts := countByPrefix(snapshot(inv))
fmt.Print("분류별 품목 수: ")
for _, k := range slices.Sorted(maps.Keys(counts)) {
fmt.Printf("%s=%d ", k, counts[k])
}
fmt.Println()

fmt.Println("\n작업 로그:")
for _, line := range inv.log {
fmt.Println(" ", line)
}

// 에러 종류로 분기할 수 있다.
err := adjust(inv, "없는SKU", 1)
fmt.Println("\nerrors.Is(err, errNotFound):", errors.Is(err, errNotFound))
}
go run ./07-inventory
seed 수정 후 FR-001 수량: 20
경고: adjust VG-002 (3-10): 수량이 음수가 된다
경고: adjust XX-999: 등록되지 않은 SKU

SKU 이름 단가 수량
FR-002 바나나 900 20
VG-001 당근 700 5
VG-002 감자 600 3

snapshot 수정 후 실제 수량: 20
평가액: 23300
재고 부족: 당근(5) 감자(3)
분류별 품목 수: FR=1 VG=2

작업 로그:
add FR-001 x20
add FR-002 x5
add VG-001 x40
add VG-002 x3
adjust FR-002 +15
adjust VG-001 -35
remove FR-001

errors.Is(err, errNotFound): true

출력에서 확인할 것

1. 값 전달이 재고를 지켰다

seed 수정 후 FR-001 수량: 20

seed[0].Qty = 9999로 고쳤는데 재고는 20이다. add(inv, p)Product값으로 받았고, 그 안에서 stored := p로 한 번 더 복사한 뒤 그 주소를 저장했기 때문이다.

stored := p가 왜 필요한지가 이 프로그램에서 가장 미묘한 지점이다. 매개변수 p 자체도 복사본이니 &p를 저장해도 동작은 한다. 하지만 "매개변수의 주소를 맵에 넣는다"는 코드는 읽는 사람을 멈칫하게 만든다. 이름을 하나 두는 편이 의도가 분명하다.

:::warning 이 자리에서 흔히 나는 사고 seed를 순회하면서 &p를 저장하는 코드를 생각해 보자.

for _, p := range seed {
inv.items[p.SKU] = &p // Go 1.22 이전이었다면 전부 같은 곳을 가리켰다
}

2-6에서 본 대로 Go 1.22부터 루프 변수가 반복마다 새로 만들어지므로 이제는 의도대로 동작한다. 하지만 오래된 코드에서 이 패턴을 보면 버그일 가능성이 높다. 그리고 여전히 &seed[i]를 저장하는 것은 위험하다 — seed의 백킹 배열을 가리키게 되고, seed가 재할당되거나 수정되면 재고가 따라 바뀐다. :::

2. 실패한 조정은 아무것도 바꾸지 않았다

경고: adjust VG-002 (3-10): 수량이 음수가 된다
경고: adjust XX-999: 등록되지 않은 SKU

VG-002는 수량 3에서 10을 빼려다 거부됐고, 표에서 여전히 3이다. 검사를 수정 전에 하는 것이 포인터 맵을 쓸 때 특히 중요하다. p.Qty += delta를 먼저 하고 나중에 되돌리는 방식이었다면, 되돌리는 코드에 버그가 생기는 순간 상태가 깨진다.

VG-001은 40에서 35를 빼 5가 됐고, FR-002는 5에서 15를 더해 20이 됐다. FR-001은 삭제돼 표에 없다.

3. snapshot은 진짜 복사본이다

snapshot 수정 후 실제 수량: 20

items[0].Qty = -1로 고쳤는데 재고는 20 그대로다. snapshot*inv.items[sku]역참조해서 값을 담았기 때문이다. 만약 []*Product를 반환했다면 이 한 줄이 내부 상태를 오염시켰을 것이다.

4. 정렬된 출력

snapshotslices.Sorted(maps.Keys(inv.items))로 키를 정렬해서 담으므로 표와 분류 집계가 항상 같은 순서로 나온다. 3-3에서 본 관용구다. 여러 번 실행해도 이 출력은 바뀌지 않는다.

5. errors.Is로 에러 종류를 구분한다

errors.Is(err, errNotFound): true

fmt.Errorf%w로 감쌌기 때문에, 맥락(adjust 없는SKU: )을 붙이고도 원래 에러를 식별할 수 있다. 에러 설계 전반은 Part 4의 주제다. 지금은 %w로 감싸면 안쪽 에러를 잃지 않는다는 것만 알면 된다.

:::note 표가 삐뚤어진 이유 %-10s로 정렬했는데 한글 열이 안 맞는다. Printf의 폭은 바이트 수를 세고, 한글은 UTF-8에서 글자당 3바이트이기 때문이다 (2-3). 표시 폭에 맞추려면 utf8.RuneCountInString으로 직접 세거나 golang.org/x/text/width 같은 패키지가 필요하다. 이 프로그램에서는 고치지 않고 남겨 뒀다 — 실제로 자주 마주치는 현상이라 알아 두는 편이 낫다. :::

직접 확장해 보기

아래는 순서대로 하면 난이도가 올라간다. 답을 적지 않았으니 직접 해 보자.

1. nil 맵 함정을 다시 불러오기

newInventory 대신 &Inventory{}로 만들어 add를 불러 보자. 어떤 메시지로 죽는가? 그다음 add 안에서 if inv.items == nil { inv.items = make(...) }로 방어하도록 고쳐 보자. 생성자를 두는 것과 이 방어 코드 중 어느 쪽이 나은가? 상황에 따라 답이 다른가?

2. snapshot[]*Product로 바꿔 보기

반환 타입만 바꾸고 나머지는 그대로 두자. 그다음 items[0].Qty = -1 줄이 어떻게 동작하는지 확인해 보자. 이 변경으로 얻는 것(복사 비용 절감)과 잃는 것(캡슐화)을 저울질해 보자. 어느 쪽을 택하겠는가?

3. lowStock이 입력 슬라이스를 재사용하게 만들어 보기

func lowStock(items []Product, threshold int) []Product {
out := items[:0] // 백킹 배열을 재사용한다
for _, p := range items {
if p.Qty < threshold {
out = append(out, p)
}
}
return out
}

할당이 사라지는 대신 입력 슬라이스가 파괴된다. snapshot(inv)의 결과를 바로 넘기면 문제가 없지만, 호출자가 결과를 계속 쓰려 했다면 사고다. 실제로 그런 호출 코드를 써서 어떤 값이 나오는지 확인해 보자. 이 최적화를 쓴다면 문서에 무엇을 적어야 하는가?

4. 카테고리별 인덱스 추가하기

InventorybyCategory map[string][]string 필드를 더해 접두사별 SKU 목록을 유지하자. add, remove에서 함께 갱신해야 한다. 힌트: 슬라이스에서 특정 값을 지우는 것은 slices.Deleteslices.Index의 조합이다. remove 후에 빈 슬라이스가 남은 카테고리는 어떻게 처리할 것인가?

5. 배치 조정을 원자적으로 만들기

여러 adjust를 받아 하나라도 실패하면 전부 취소하는 adjustAll(inv *Inventory, steps map[string]int) error를 써 보자. 힌트: 두 가지 접근이 있다. (a) 먼저 전부 검증하고 나서 적용하기, (b) 적용하면서 되돌릴 기록을 남기기. 포인터 맵을 쓰고 있다는 점이 (b)를 어렵게 만든다. 왜 그런가?

6. Part 4를 미리 보기

이 프로그램의 함수들은 전부 첫 인자가 *Inventory다. Part 4에서 메서드를 배우면 inv.Add(p), inv.Adjust(sku, delta)로 쓸 수 있게 된다. 지금 코드의 어느 함수가 메서드가 되어야 하고 어느 것은 그냥 함수로 남아야 할지 미리 나눠 보자. 힌트: totalValue, lowStock, countByPrefixInventory를 받지 않는다.

Part 3 정리

일곱 챕터를 관통하는 것은 하나다. Go에서는 무엇이 복사되고 무엇이 공유되는지가 타입에 다 적혀 있다.

타입복사되는 것공유되는 것
배열 [N]T전부없음
슬라이스 []T헤더 24바이트백킹 배열
map[K]V포인터 8바이트해시 테이블
구조체모든 필드필드 안의 포인터가 가리키는 것
포인터 *T주소 8바이트대상

이 표만 손에 쥐고 있으면 Part 3의 함정 대부분이 예측 가능해진다.

  • append가 원본을 오염시킬지는 그 순간 cap이 남았는지로 결정된다.
  • nil 맵에 쓰면 패닉인 것은 대입문이 새 테이블을 돌려줄 방법이 없기 때문이다.
  • 맵 요소의 주소를 못 얻는 것은 테이블이 자라며 항목이 이사하기 때문이다.
  • 지역 변수의 주소를 반환해도 안전한 것은 컴파일러가 그것을 힙에 두기 때문이다.

Part 4에서는 이 타입들에 메서드를 붙이고, 인터페이스로 동작을 추상화하고, error를 제대로 설계한다. "값 리시버냐 포인터 리시버냐"라는 질문이 바로 나오는데, 그건 3-5에서 세운 판단 기준의 확장판이다.