종합 실습: 인메모리 재고 관리
이 챕터에서 다루는 것
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의 첫 번째 기준 — 고쳐야 하는가 — 에 따른 것이다.
Price가 int인 것도 결정이다. 돈에 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{}를 그냥 쓰면 items가 nil이고
첫 add에서 assignment to entry in nil map이 난다(3-3).
생성자를 두는 것만으로 이 함정이 구조적으로 사라진다.
log는 nil이어도 append가 되므로 꼭 만들 필요는 없다. cap을 16으로 잡아 둔 것은
초기 재할당을 몇 번 줄이려는 것이고(3-2),
없어도 정상 동작한다.
4. snapshot은 복사본을 돌려준다
내부 슬라이스나 포인터 맵의 값을 그대로 내보내면 호출자가 그것을 고쳐서 내부 상태를
오염시킬 수 있다. snapshot은 *Product를 역참조해 값 슬라이스를 새로 만든다.
3-2의 백킹 배열 공유를 원천 차단하는 방식이다.
전체 코드
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. 정렬된 출력
snapshot이 slices.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. 카테고리별 인덱스 추가하기
Inventory에 byCategory map[string][]string 필드를 더해 접두사별 SKU 목록을 유지하자.
add, remove에서 함께 갱신해야 한다. 힌트: 슬라이스에서 특정 값을 지우는 것은
slices.Delete와 slices.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, countByPrefix는 Inventory를 받지 않는다.
Part 3 정리
일곱 챕터를 관통하는 것은 하나다. Go에서는 무엇이 복사되고 무엇이 공유되는지가 타입에 다 적혀 있다.
| 타입 | 복사되는 것 | 공유되는 것 |
|---|---|---|
배열 [N]T | 전부 | 없음 |
슬라이스 []T | 헤더 24바이트 | 백킹 배열 |
맵 map[K]V | 포인터 8바이트 | 해시 테이블 |
| 구조체 | 모든 필드 | 필드 안의 포인터가 가리키는 것 |
포인터 *T | 주소 8바이트 | 대상 |
이 표만 손에 쥐고 있으면 Part 3의 함정 대부분이 예측 가능해진다.
append가 원본을 오염시킬지는 그 순간cap이 남았는지로 결정된다.nil맵에 쓰면 패닉인 것은 대입문이 새 테이블을 돌려줄 방법이 없기 때문이다.- 맵 요소의 주소를 못 얻는 것은 테이블이 자라며 항목이 이사하기 때문이다.
- 지역 변수의 주소를 반환해도 안전한 것은 컴파일러가 그것을 힙에 두기 때문이다.
Part 4에서는 이 타입들에 메서드를 붙이고, 인터페이스로 동작을 추상화하고,
error를 제대로 설계한다. "값 리시버냐 포인터 리시버냐"라는 질문이 바로 나오는데,
그건 3-5에서 세운 판단 기준의 확장판이다.