Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Go 언어 프로그래밍 학습 노트

Go 언어를 처음 접하는 사람을 위한 학습 문서. 문법 기초부터 동시성, 자료구조, 실전 패턴까지 단계적으로 다룬다.

각 챕터는 앞 챕터에서 다룬 개념만 사용하도록 의존성을 정리했다. 아직 배우지 않은 개념이 갑자기 튀어나오는 일은 없다.


1부. Go 시작하기

1장. Go 언어 소개

  • Go란 무엇인가
  • 특징과 장점
  • 어디에 쓰이는가
  • 다른 언어와의 비교

2장. 개발 환경 준비

  • Go 설치 (macOS / Windows / Linux)
  • 에디터 선택
  • 첫 프로그램: Hello, World
  • go rungo build 의 차이

3장. Go 프로그램의 기본 구조

  • package, import, func main 의 역할
  • 세미콜론과 중괄호 규칙
  • 주석 쓰기
  • gofmt 로 코드 정리

2부. 기본 데이터 다루기

4장. 변수와 자료형

  • 변수 선언 (var, :=)
  • 기본 자료형 (정수, 실수, 불리언, 문자열)
  • 상수 (const)
  • 타입 변환 (int(x), string(y))
  • 제로값 개념

5장. 연산자

  • 산술 / 비교 / 논리 연산자
  • 대입 연산자
  • 연산자 우선순위

6장. 문자열 다루기 기초

  • 문자열의 본질 (불변, UTF-8)
  • 연결 (+) 과 비교
  • 길이 구하기 (len)
  • 인덱싱과 슬라이싱
  • byterune 맛보기
  • 심화 내용은 27장에서

7장. fmt 패키지로 입출력

  • 출력 함수
    • Print, Println, Printf
  • 자주 쓰는 포맷 동사
    • %d, %f, %s, %t, %v, %T
  • 너비 / 자릿수 지정
  • 문자열 만들기 (Sprintf)
  • 사용자 입력 받기
    • Scan, Scanln
  • 미니 실습: 이름과 나이 입력받아 인사 출력

3부. 프로그램 흐름 제어

8장. 제어문

  • 조건문 if / else if / else
  • 분기문 switch
  • 반복문 for (세 가지 형태)
  • break, continue
  • 중첩 반복과 라벨

4부. 함수로 코드 묶기

9장. 함수

  • 함수 정의와 호출
  • 매개변수와 반환값
  • 다중 반환값
  • 명명된 반환값
  • 가변 인자
  • defer 의 동작 원리
  • 익명 함수와 클로저

10장. 변수의 범위 (Scope)

  • 변수의 범위란 무엇인가
  • 블록과 중괄호 { } 의 관계
  • 세 가지 범위
    • 패키지 수준 변수 (전역)
    • 함수 수준 변수 (지역)
    • 블록 수준 변수 (if, for, switch 안)
  • 함수 매개변수의 범위
  • 변수 가리기 (shadowing)
    • 안쪽 블록에서 같은 이름을 다시 선언하면?
    • := 때문에 생기는 흔한 함정
  • 전역 변수를 자제해야 하는 이유

5부. 여러 데이터 묶어 다루기

11장. 배열과 슬라이스

  • 배열의 한계
  • 슬라이스 선언과 사용
  • append, copy, make
  • 길이와 용량
  • 슬라이스 순회 (for range)

12장. 맵 (Map)

  • 맵 선언과 초기화
  • 값 추가 / 조회 / 삭제
  • 키 존재 여부 확인
  • 맵 순회

13장. 구조체 (Struct)

  • 구조체 정의와 필드
  • 초기화 방법들
  • 중첩 구조체
  • 익명 구조체

6부. 포인터와 추상화

14장. 포인터

  • 포인터가 왜 필요한가
  • &* 의 의미
  • 함수 인자로 포인터 넘기기
  • 구조체와 포인터

15장. 메서드

  • 메서드란
  • 값 리시버 vs 포인터 리시버
  • 언제 어느 쪽을 쓰는가

16장. 인터페이스

  • 인터페이스 기초
  • 암묵적 구현
  • 빈 인터페이스 any
  • 타입 단언과 타입 스위치

17장. 제네릭 (Generics)

  • 제네릭이 왜 필요한가
    • 같은 함수를 타입마다 복사하는 문제
    • 빈 인터페이스 any 의 한계
  • 타입 매개변수 문법
    • func Sum[T int | float64](nums []T) T
  • 타입 제약 (Constraints)
    • 내장 제약: any, comparable
    • 인터페이스를 제약으로 쓰기
    • 타입 집합 int | float64
    • ~ (underlying type) 의미
  • 제네릭 함수 예제
    • Map, Filter, Reduce 직접 만들어 보기
  • 제네릭 타입 맛보기
  • 언제 쓰고 언제 안 써야 하는가

7부. 자료구조 깊이 이해하기

18장. 리스트, 큐, 스택, 링

  • Go 표준 자료구조 한눈에 보기
  • 메모리 구조부터 이해하기
    • 슬라이스 (연속 메모리)
    • 연결 리스트 (포인터로 이어진 노드)
    • 어떤 차이가 성능 차이를 만드는가
  • 이중 연결 리스트: container/list
  • 큐 (FIFO) 구현하기
    • 슬라이스로 구현 (주의점 포함)
    • container/list 로 구현
  • 스택 (LIFO) 구현하기
    • 슬라이스로 깔끔하게
  • 원형 자료구조: container/ring
  • 우선순위 큐: container/heap 맛보기
  • 연산별 속도 비교표 (Big-O)

19장. 맵 깊이 이해하기

  • 해시 테이블이란 무엇인가
    • 키를 인덱스로 바꾸는 원리
    • 해시 함수의 역할
  • Go 맵의 내부 구조
    • 버킷(bucket) 배열
    • 충돌 해결 방식 (chaining)
    • 로드 팩터와 동적 확장
  • 맵의 흥미로운 특성들
    • 왜 순회 순서가 매번 바뀌는가
    • 왜 동시 접근에 안전하지 않은가
    • nil 맵의 함정
  • 맵 vs 슬라이스 검색 속도 실험
  • 맵을 잘 쓰는 패턴
    • 집합(Set) 흉내 내기
    • 카운터로 활용하기

8부. 코드 구성과 에러 처리

20장. 패키지와 모듈

  • 패키지 만들기 / 가져오기
  • 대문자 / 소문자 export 규칙
  • go.mod, go.sum
  • 외부 패키지 사용 (go get)

21장. 에러 처리

  • error 인터페이스
  • 에러 반환 관례
  • 에러 래핑 (fmt.Errorf, %w)
  • panicrecover

9부. 동시성

22장. 고루틴과 채널 기초

  • 고루틴이란
    • 스레드와 무엇이 다른가
    • 가벼움 (수십만 개도 가능)
  • 고루틴 시작하기 (go f())
  • main 종료와 고루틴의 관계
  • 채널 기초
    • 송수신 문법
    • 방향 채널
    • 버퍼 vs 언버퍼
  • 채널 닫기 (close) 와 range 로 받기
  • select 문 기초
  • sync.WaitGroup 으로 끝 기다리기

23장. 동시성 문제와 뮤텍스

  • 무엇이 잘못될 수 있는가
    • 경쟁 조건의 실제 예시
    • 데이터 레이스와 경쟁 조건의 차이
  • 탐지하기: go run -race
  • 락 기반 해결
    • sync.Mutex 사용법
    • sync.RWMutex (읽기/쓰기 분리)
  • 락 함정들
    • 데드락 / 라이브락
    • 락을 든 채 블로킹하는 안티패턴
    • 락 순서 일관성 문제
  • 원자 연산 sync/atomic 맛보기

24장. 락 없이 동시성 설계하기

  • Go 의 철학
    • “공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라”
  • 락이 답이 아닌 경우들
  • 소유권 이전 패턴
    • 데이터를 한 번에 한 고루틴만 가지게 하기
  • 단일 작성자 패턴
    • 쓰기는 한 고루틴만, 읽기는 여러 고루틴
  • 영역 나누기 (Sharding / Partitioning)
    • 키별로 고루틴 분배
    • 처음부터 충돌을 없애기
  • 역할 나누기 (생산자 / 소비자)
  • 불변 데이터로 공유하기
  • 언제 락이 낫고 언제 채널이 나은가

25장. 동시성 패턴과 도구 모음

  • 실전 패턴
    • 파이프라인 (단계별 처리)
    • Fan-out / Fan-in
    • 워커 풀 정식 구현
  • context 로 취소와 타임아웃
    • context 의 등장 이유
    • 트리 형태의 전파
  • 그 밖의 동기화 도구
    • sync.Once
    • sync.Map
    • sync.Cond (간단히)
  • 고루틴 누수 (goroutine leak)
    • 어떻게 새는가
    • 어떻게 막는가
  • 동시성 디버깅 팁

10부. 효율적인 Go 프로그래밍

26장. 대용량 데이터와 메모리 효율

  • 슬라이스의 메모리 동작 이해
    • 슬라이스는 배열을 가리키는 “창문”
    • append 가 일으키는 재할당
    • make([]T, 0, cap) 으로 cap 미리 잡기
    • 부분 슬라이스가 큰 배열을 붙잡는 함정
    • copy 로 메모리 끊어내기
  • 값 전달 vs 포인터 전달
    • 큰 구조체의 복사 비용
    • 포인터 전달이 능사가 아닌 이유 (GC, 힙 할당)
    • 스택과 힙 (escape analysis 개념)
    • 언제 값, 언제 포인터?
  • 대용량 데이터 처리 패턴
    • 한 번에 다 메모리에 올리지 않기
    • DB 결과를 페이징 / 커서 / 배치로 가져오기
    • io.Reader 로 스트림 처리
    • 채널로 파이프라인 만들기
    • bufio.Scanner 로 줄 단위 처리
  • 자주 쓰이는 최적화 기법
    • strings.Builder 로 문자열 누적
    • 맵 / 슬라이스 크기 미리 지정
    • sync.Pool 로 객체 재사용
  • 성능을 직접 재 보기
    • testing.B 로 벤치마크 작성
    • pprof 맛보기 (CPU, 메모리)

11부. 표준 라이브러리 활용

27장. 문자열 다루기 심화

  • strings 패키지
  • strconv 로 변환하기

28장. 시간 다루기

  • time 패키지 기초
  • 시간 포맷팅과 파싱

29장. 파일 입출력

  • os 로 파일 열기 / 쓰기
  • bufio 로 한 줄씩 읽기
  • defer 로 안전하게 닫기

30장. JSON 다루기

  • 구조체와 JSON 매핑
  • 인코딩 / 디코딩

31장. 간단한 HTTP 서버

  • net/http 로 서버 띄우기
  • 핸들러 등록
  • JSON 응답 보내기

12부. 마무리

32장. 테스트 작성하기

  • testing 패키지
  • 단위 테스트 작성
  • go test 사용법

33장. 미니 프로젝트

  • CLI 할 일 관리 도구
  • JSON API 서버

부록

A. 자주 쓰는 go 명령어 정리

B. 초보가 자주 만나는 에러와 해결

C. 더 공부할 자료 / 공식 문서 안내

1장. Go 언어 소개

본격적으로 문법을 배우기 전에, Go 가 어떤 언어인지 큰 그림을 먼저 잡아 둔다.

이 장의 목표는 세 가지다.

  • Go 가 어떤 언어인지 한 문장으로 말할 수 있게 되기
  • 왜 많은 회사가 Go 를 쓰는지 이해하기
  • 내가 만들고 싶은 것에 Go 가 어울리는지 판단하기

1.1 Go 란 무엇인가

Go 는 2009년 구글에서 공개한 프로그래밍 언어다. 공식 이름은 “Go” 지만, 검색 편의상 흔히 Golang이라고도 부른다.

만든 사람도 면면이 화려하다.

  • Robert Griesemer — V8 JavaScript 엔진 개발 참여
  • Rob Pike — Plan 9 OS 설계, UTF-8 공동 설계
  • Ken Thompson — Unix, C 언어, B 언어를 만든 사람

이 세 사람이 모여 만든 만큼, 시스템 프로그래밍의 경험이 언어 구석구석에 녹아 있다.

어떤 종류의 언어인가

한 문장으로 요약하면 이렇다.

컴파일 방식의, 정적 타입을 가진, 가비지 컬렉터가 있는 동시성 프로그래밍 언어.

각각 풀어 보면,

  • 컴파일 방식
    • 코드를 미리 기계어로 바꿔 실행 파일을 만든다
    • 실행 속도가 빠르다
    • Python 처럼 매번 해석하는 인터프리터 방식과 다르다
  • 정적 타입
    • 변수의 타입이 컴파일 시점에 정해진다
    • 컴파일 단계에서 많은 실수를 미리 잡아낸다
  • 가비지 컬렉터(GC)
    • 사용하지 않는 메모리를 알아서 회수해 준다
    • C / C++ 처럼 직접 free 를 호출할 필요가 없다
  • 언어 차원의 동시성 지원
    • 외부 라이브러리 없이도 동시 작업을 깔끔하게 표현할 수 있다

왜 만들어졌나

구글 내부에서는 다음과 같은 문제가 있었다.

  • C++ 컴파일이 너무 느려 개발 속도가 떨어진다
  • 거대한 코드베이스의 의존성이 복잡해 관리가 어렵다
  • 멀티코어 시대인데 동시성 코드 작성이 너무 까다롭다

Go 는 이 세 문제를 한꺼번에 해결하기 위해 만들어졌다. 그래서 빠른 컴파일, 단순한 문법, 쉬운 동시성이 언어의 핵심 가치가 됐다.


1.2 Go 의 특징과 장점

문법이 단순하다

Go 의 키워드는 단 25개뿐이다.

break    default      func    interface    select
case     defer        go      map          struct
chan     else         goto    package      switch
const    fallthrough  if      range        type
continue for          import  return       var

Java(50여 개), C++(80개 이상) 와 비교하면 매우 적다. 한 주 안에 모든 문법을 훑어볼 수 있을 정도다.

컴파일이 매우 빠르다

수십만 줄짜리 프로젝트도 수 초 안에 빌드된다. “코드 수정 → 빌드 → 실행” 의 반복이 빠르다. 이는 곧 개발 생산성으로 이어진다.

가비지 컬렉터가 있다

메모리 할당과 해제를 직접 신경 쓰지 않아도 된다. 저수준 언어의 성능과 고수준 언어의 편의를 함께 누리는 셈이다.

동시성이 일급 시민이다

go 키워드 하나로 “이 함수를 동시에 실행해” 라고 표현할 수 있다.

go doSomething()

이렇게 시작한 가벼운 실행 단위를 고루틴(goroutine) 이라 부른다. 일반적인 OS 스레드보다 훨씬 가벼워서 수십만 개를 띄워도 멀쩡히 돌아간다.

단일 실행 파일로 배포된다

빌드 결과물이 외부 런타임 의존성이 없는 단일 바이너리다. 서버에 던져 놓고 실행만 하면 끝이다.

  • Java 처럼 JVM 을 설치할 필요가 없다
  • Python 처럼 인터프리터와 패키지를 맞춰 줄 필요도 없다
  • Docker 이미지가 매우 가벼워진다

크로스 컴파일이 쉽다

내 Mac 에서 명령어 한 줄로 Linux용 / Windows용 실행 파일을 만들 수 있다.

GOOS=linux GOARCH=amd64 go build
GOOS=windows GOARCH=amd64 go build

표준 라이브러리가 풍부하다

웹 서버, HTTP 클라이언트, JSON 처리, 암호화 등 실무에서 자주 쓰는 기능 대부분이 기본 라이브러리에 들어 있다. 외부 패키지에 대한 의존을 최소화할 수 있다.

코드 스타일이 통일된다

gofmt 라는 공식 포매터가 함께 배포된다. “들여쓰기는 탭이냐 스페이스냐”, “중괄호 위치는 어디냐” 같은 논쟁이 처음부터 의미가 없다. 모든 Go 코드는 똑같이 생겼다.


1.3 어디에 쓰이는가

Go 가 가장 빛나는 영역은 다음과 같다.

서버와 백엔드

  • HTTP API 서버
  • 마이크로서비스
  • gRPC 기반 통신 서버

성능이 좋고 동시성 처리가 강해서 높은 트래픽을 받는 서비스에 잘 어울린다.

클라우드와 인프라 도구

이 영역의 대부분이 Go 로 쓰여 있다.

도구용도
Kubernetes컨테이너 오케스트레이션
Docker컨테이너 런타임
Terraform인프라 자동화
Prometheus모니터링
etcd분산 키-값 저장소
HelmKubernetes 패키지 관리

클라우드 네이티브 진영의 사실상 공용어가 Go 다.

CLI 도구

  • 단일 실행 파일로 배포 가능
  • 크로스 컴파일이 쉬움
  • 시작 속도가 빠름

이 세 가지 특성 때문에 CLI 도구 제작에 자주 쓰인다. gh (GitHub CLI), hugo (정적 사이트 생성기) 등이 대표적인 예다.

네트워크 프로그래밍

표준 라이브러리만으로 TCP, UDP, HTTP, WebSocket 등을 쉽게 다룰 수 있다. 동시성 모델이 네트워크 I/O 와 궁합이 잘 맞는다.

어떤 영역엔 잘 안 어울리는가

만능 언어는 없다. 다음 영역에서는 다른 선택지가 보통 더 낫다.

  • GUI 데스크톱 앱 — 생태계가 약하다
  • 모바일 앱 — Swift / Kotlin 이 표준
  • 게임 개발 — 실시간 그래픽은 C++ / C# 이 우세
  • 데이터 과학 / 머신러닝 — Python 생태계가 압도적
  • 임베디드 펌웨어 — GC 가 부담될 수 있다

1.4 다른 언어와의 비교

이미 다른 언어를 알고 있다면 비교가 가장 빠른 이해 방법이다.

언어강점Go 와의 차이
C / C++최고의 성능, 저수준 제어메모리 직접 관리 필요. Go 는 GC 가 있음
Java거대한 생태계, 안정성JVM 필요. Go 는 단일 바이너리
Python매우 빠른 개발 속도인터프리터로 느림. Go 는 컴파일로 빠름
Node.js비동기 I/O, JS 생태계싱글 스레드. Go 는 멀티 코어 활용
Rust메모리 안전 + 최고 성능학습 곡선 가파름. Go 는 훨씬 단순

한 줄 요약

  • C / C++ 가 너무 어렵게 느껴진다면 Go 가 좋은 대안이다
  • Python 이 너무 느려 답답하다면 Go 로 옮기면 속도 차이가 체감된다
  • Java 의 무거움이 부담스럽다면 Go 는 훨씬 가볍다
  • Rust 가 너무 가파르다면 Go 부터 시작해도 좋다

1.5 정리

이 장에서 살펴본 내용:

  • Go 는 구글이 만든 컴파일 언어다
  • 단순한 문법, 빠른 컴파일, 강력한 동시성이 핵심 가치다
  • 서버 / 클라우드 인프라 / CLI 도구 영역에서 강하다
  • 메모리를 직접 관리하지 않고도 빠른 성능을 얻을 수 있다

큰 그림이 그려졌으면 충분하다. 세부 문법은 4장부터 차근차근 다룬다.

다음 장에서는 실제로 손을 움직여 본다. Go 를 설치하고, 첫 프로그램 Hello, World 를 띄워 보자.

2장. 개발 환경 준비

본격적인 Go 학습을 위해 개발 환경을 갖춘다. 이 장의 끝에서는 본인 컴퓨터에서 첫 Go 프로그램을 실행할 수 있게 된다.

목표:

  • Go 컴파일러 설치하기
  • 에디터 준비하기
  • Hello, World 직접 띄워 보기
  • go rungo build 의 차이 이해하기

2.1 Go 설치

macOS

가장 쉬운 방법은 Homebrew 다.

brew install go

Homebrew 가 없다면 공식 사이트에서 .pkg 파일을 받아 설치한다.

  • 공식 다운로드: https://go.dev/dl/

Windows

공식 사이트에서 .msi 인스톨러를 다운로드한다.

  • https://go.dev/dl/

다운로드한 파일을 실행해 안내에 따라 진행하면 끝이다. PATH 환경변수도 자동으로 설정된다.

Linux

배포판 패키지 매니저(apt, dnf 등)를 써도 되지만, 공식 최신 버전을 직접 받는 것을 추천한다.

wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz

# PATH 추가
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
source ~/.bashrc

설치 확인

설치가 끝났다면 터미널에서 다음 명령을 실행한다.

go version

다음과 같은 출력이 나오면 성공이다.

go version go1.22.0 darwin/amd64

GOPATH / GOROOT 에 대해

옛날 자료를 보면 GOPATH 환경변수 얘기가 자주 나온다. Go 1.11 이후 모듈 시스템이 등장하면서 GOPATH 를 직접 설정할 일은 거의 사라졌다. 이 책은 모듈 방식만 다루므로 그냥 무시해도 된다.

용어설명신경 써야 하나?
GOROOTGo 자체가 설치된 경로아니오 (자동)
GOPATH옛날 코드 저장 경로거의 아니오

2.2 에디터 선택

VS Code + Go 확장 (가장 추천)

가장 무난하고 사용자도 많다.

  1. https://code.visualstudio.com/ 에서 VS Code 설치
  2. 확장 마켓플레이스에서 “Go” 검색 후 설치
    • 제공자: Go Team at Google
  3. 처음 .go 파일을 열면 부속 도구 자동 설치 안내가 뜬다
    • 모두 설치한다 (Install All)

자동 완성, 정의로 이동, 리팩터링, 디버깅이 전부 작동한다.

GoLand

JetBrains 의 유료 IDE 다. 회사에서 Go 를 본격적으로 쓴다면 투자할 가치가 있다.

Vim / Neovim

vim-go 또는 gopls 기반 LSP 플러그인을 쓰면 된다. 이미 Vim 에 익숙하다면 굳이 옮길 필요는 없다.

이 책은 VS Code 기준으로 설명한다.


2.3 첫 프로그램: Hello, World

작업 디렉터리 만들기

원하는 위치에 디렉터리를 하나 만든다.

mkdir hello
cd hello

모듈 초기화

go mod init example.com/hello

이 명령을 실행하면 디렉터리 안에 go.mod 파일이 생긴다.

module example.com/hello

go 1.22
  • module 은 이 프로젝트의 이름
  • 자리에는 보통 GitHub 주소 같은 걸 적는다
  • 아직 외부 패키지를 받지 않으므로 example.com/hello 처럼 임의 이름을 써도 된다

모듈 자체에 대한 자세한 내용은 20장에서 다룬다. 지금은 “Go 프로젝트는 모듈로 시작한다” 정도만 알면 충분하다.

main.go 작성

main.go 파일을 만들고 다음 내용을 적는다.

package main

import "fmt"

func main() {
	fmt.Println("Hello, World!")
}

실행

go run main.go

다음과 같은 출력이 나오면 성공이다.

Hello, World!

코드 한 줄씩 미리 보기

이 짧은 코드에 Go 의 기본 구조가 다 들어 있다. 자세한 설명은 다음 장(3장)에서 다루지만 살짝 미리 본다.

의미
package main이 파일은 main 패키지에 속한다
import "fmt"표준 라이브러리의 fmt 패키지를 가져온다
func main()프로그램이 시작되는 함수
fmt.Println(...)화면에 한 줄 출력

2.4 go run 과 go build 의 차이

같은 코드를 두 가지 방식으로 다룰 수 있다.

go run

go run main.go
  • 빌드와 실행을 한 번에 한다
  • 결과 실행 파일은 임시 경로에 만들어지고 명령이 끝나면 사라진다
  • 개발 중 빠르게 확인할 때 쓴다

go build

go build
  • 실행 파일을 현재 디렉터리에 만든다
    • macOS / Linux: hello
    • Windows: hello.exe
  • 만들어진 파일은 이후에도 그대로 실행 가능
  • 배포할 때 쓴다
./hello

한 줄 요약

명령언제 쓰나
go run코드 짜면서 즉시 확인
go build배포용 실행 파일을 만들 때

둘 다 내부적으로는 컴파일을 수행한다. 결과물을 남기느냐 마느냐만 다르다.


2.5 정리

  • Go 컴파일러를 설치하고 go version 으로 확인했다
  • VS Code 와 Go 확장을 준비했다
  • 첫 프로그램 Hello, World 를 띄워 보았다
  • 모듈 초기화 (go mod init) 와 실행 흐름을 익혔다
  • go rungo build 의 차이를 이해했다

손이 풀렸으면 됐다. 다음 장에서는 방금 짠 Hello, World 코드의 각 줄이 정확히 무엇을 의미하는지 들여다본다.

3장. Go 프로그램의 기본 구조

2장에서 짠 Hello, World 의 다섯 줄을 다시 펼쳐 본다. 짧지만 Go 프로그램의 골격이 모두 들어 있다.

목표:

  • 모든 Go 파일이 갖는 공통 구조 이해하기
  • 어떤 줄이 왜 필요한지 설명할 수 있게 되기
  • gofmt 로 코드 스타일을 통일하기

3.1 가장 작은 Go 프로그램 다시 보기

package main

import "fmt"

func main() {
	fmt.Println("Hello, World!")
}

이 안에는 세 종류의 요소가 있다.

  1. 패키지 선언 (package main)
  2. 임포트 (import "fmt")
  3. 함수 정의 (func main() { ... })

이 셋이 모든 Go 파일의 뼈대다. 하나씩 풀어 본다.


3.2 package 선언

package main

모든 Go 파일은 첫 줄에 반드시 자신이 속한 패키지를 선언한다.

  • 빠지면 컴파일 에러
  • 한 디렉터리의 모든 .go 파일은 같은 패키지 이름을 써야 한다

main 패키지의 특별함

main 이라는 이름의 패키지는 특별한 의미가 있다.

  • 실행 가능한 프로그램임을 뜻한다
  • func main() 이 프로그램의 시작점이다
  • go build 하면 실행 파일이 만들어진다

다른 이름의 패키지

main 이 아닌 이름이면 라이브러리 패키지가 된다.

package math

라이브러리 패키지는 단독 실행되지 않는다. 다른 패키지가 import 해서 사용한다.

패키지를 직접 만드는 방법은 20장에서 다룬다. 지금은 “내가 짜는 실행 프로그램은 항상 package main” 정도만 기억하면 된다.


3.3 import 와 표준 라이브러리

import "fmt"

다른 패키지의 기능을 가져다 쓸 때 임포트한다. fmt 는 Go 가 기본 제공하는 표준 라이브러리 중 하나다.

여러 패키지 가져오기

여러 개를 임포트할 땐 괄호로 묶는다.

import (
	"fmt"
	"strings"
	"time"
)

안 쓰는 패키지를 임포트하면?

컴파일 에러가 난다.

imported and not used: "strings"

Go 는 “쓰지도 않을 거 import 해 두는” 습관을 처음부터 막는다.

자주 쓰는 표준 라이브러리

패키지용도
fmt입출력, 문자열 포매팅
strings문자열 처리
strconv문자열-숫자 변환
time시간
os운영체제 (파일, 환경변수 등)
net/httpHTTP 서버 / 클라이언트
encoding/jsonJSON 처리

3.4 func main 의 역할

func main() {
	fmt.Println("Hello, World!")
}
  • func 는 함수를 정의하는 키워드
  • main 이라는 이름의 함수가 프로그램 시작점
  • 매개변수도, 반환값도 없다
  • 중괄호 { } 안에 실행될 코드를 적는다

main 의 제약

  • package main 안에 정확히 하나만 있어야 한다
  • 매개변수도 반환값도 가질 수 없다
  • 라이브러리 패키지에는 main 을 두지 않는다

함수 자체에 대한 자세한 설명은 9장에서 다룬다. 여기선 “프로그램이 여기서 시작한다” 정도만 알면 충분하다.


3.5 세미콜론과 중괄호 규칙

세미콜론이 안 보이는 이유

C / Java / JavaScript 같은 언어에선 줄 끝마다 세미콜론 ; 을 쓴다. Go 도 내부적으로는 세미콜론을 쓰지만, 컴파일러가 줄바꿈을 보고 자동으로 넣어 준다.

그래서 우리가 직접 적을 일은 거의 없다.

중괄호 위치 규칙

이 규칙은 Go 에서 굉장히 엄격하다.

// OK
func main() {
}

// 컴파일 에러
func main()
{
}

여는 중괄호 { 는 같은 줄에 있어야 한다.

세미콜론 자동 삽입 규칙 때문에 그렇다. func main() 다음에 줄바꿈이 오면 컴파일러가 그 자리에 세미콜론을 넣어 버려서 { 가 따로 떨어진 문법 에러가 된다.


3.6 주석 쓰기

Go 의 주석은 두 가지다.

한 줄 주석

// 이 줄은 주석이다.
fmt.Println("Hello")  // 줄 끝에 붙여도 된다.

여러 줄 주석

/*
여러 줄에 걸친 주석.
이 안은 컴파일러가 무시한다.
*/

문서 주석 (godoc) 관례

Go 에선 함수나 패키지 바로 위에 적는 주석이 자동으로 공식 문서가 된다.

// Greet 는 주어진 이름으로 인사말을 만든다.
func Greet(name string) string {
	return "Hello, " + name
}

godoc 또는 https://pkg.go.dev 에서 이 주석이 그대로 문서로 노출된다. 이름과 함께 시작하는 한 문장 요약이 관례다.


3.7 gofmt 로 코드 정리

Go 에는 공식 코드 포매터 gofmt 가 함께 배포된다.

사용법

gofmt -w main.go
  • -w 는 정리된 결과를 파일에 덮어쓰라는 뜻
  • 옵션 없이 실행하면 결과를 화면에만 보여준다

전체 디렉터리를 한 번에 정리할 수도 있다.

gofmt -w .

VS Code 에서는 자동으로

VS Code 의 Go 확장은 저장할 때마다 자동으로 gofmt 를 돌려 준다. 손으로 명령을 칠 일은 거의 없다.

왜 모든 Go 코드가 똑같이 생겼나

gofmt 가 강제하는 규칙은 다음과 같다.

  • 들여쓰기는 탭 (스페이스 X)
  • 줄 끝의 잉여 공백 제거
  • import 자동 정렬
  • 연산자 주변 공백 정리
  • 중괄호 위치 통일

Go 커뮤니티에서는 “코드 스타일 회의” 자체가 사라졌다. 모두가 gofmt 결과를 따르기 때문이다.


3.8 정리

  • 모든 Go 파일은 package 선언으로 시작한다
  • 실행 프로그램은 package main + func main() 조합
  • 외부 기능은 import 로 가져온다
  • 세미콜론은 자동 삽입되고, 중괄호 위치는 엄격하다
  • 주석은 ///* */ 두 가지
  • gofmt 가 코드 스타일을 자동으로 통일해 준다

이제 Go 프로그램의 골격은 다 봤다. 다음 장부터 본격적으로 데이터를 다루기 시작한다. 4장에서는 변수와 자료형부터 출발한다.

4장. 변수와 자료형

프로그램은 결국 “값을 다루는 일” 이다. 값을 담아 둘 그릇이 변수고, 그 그릇이 어떤 모양인지를 정하는 게 자료형이다.

이 장에서 Go 의 변수 선언법과 기본 자료형을 익힌다.

목표:

  • var:= 의 차이를 설명할 수 있게 되기
  • Go 의 기본 자료형 종류를 알기
  • 타입 변환은 항상 명시적이어야 한다는 점 이해하기

4.1 변수 선언

var 키워드

가장 기본은 var 키워드다. 세 가지 형태를 모두 지원한다.

var x int          // 타입만 지정 (값은 제로값)
var y int = 10     // 타입과 값 모두 지정
var z = 10         // 타입은 추론 (값으로부터)

var z = 10 처럼 타입을 생략하면 컴파일러가 우변의 값으로부터 타입을 추론한다. 이 경우 z 는 자동으로 int 가 된다.

:= 짧은 선언

함수 안에서는 := 로 더 짧게 쓸 수 있다.

x := 10
name := "Go"
  • var x = 10 과 동일한 의미다
  • var 와 타입 둘 다 생략된다
  • 단, 함수 바깥(패키지 수준)에서는 못 쓴다

둘의 차이와 언제 쓰는가

구분var:=
사용 가능 위치어디서나함수 안에서만
타입 명시가능불가 (항상 추론)
초기값 없이 선언가능 (var x int)불가

보통 함수 안에서는 := 를 쓰고, 패키지 수준 변수에는 var 를 쓴다. 초기값 없이 그릇만 만들고 싶을 때도 var 가 필요하다.

여러 변수 한 번에 선언

같은 타입이라면 콤마로 묶을 수 있다.

var a, b int             // 둘 다 int, 값은 제로값
var x, y int = 1, 2      // 둘 다 int, 각각 1과 2
a, b := 10, 20           // 짧은 선언으로도 가능

심지어 타입이 달라도 된다.

name, age := "Alice", 30

이 기능은 함수가 여러 값을 반환할 때 유용하게 쓰인다. (다중 반환은 9장에서 다룬다.)

var 블록 선언

여러 변수를 묶어서 깔끔하게 선언할 때 쓴다.

var (
    name    string
    age     int
    isAdmin bool
)

가독성도 좋고, 패키지 수준 변수를 모아 둘 때 자주 쓰는 패턴이다.

값까지 함께 줄 수도 있다.

var (
    name    = "Alice"
    age     = 30
    isAdmin = false
)

4.2 기본 자료형

Go 의 기본 자료형은 크게 네 그룹이다.

  • 정수 (integer)
  • 실수 (float)
  • 불리언 (bool)
  • 문자열 (string)

정수

이름에 비트 수가 그대로 붙는다.

타입크기범위
int81바이트-128 ~ 127
int162바이트-32,768 ~ 32,767
int324바이트약 ±21억
int648바이트약 ±9.2 × 10¹⁸
uint81바이트0 ~ 255
uint162바이트0 ~ 65,535
uint324바이트0 ~ 약 42억
uint648바이트0 ~ 약 1.8 × 10¹⁹

u 가 붙은 쪽은 unsigned, 즉 음수가 없다.

intuint 는 별도다.

  • 플랫폼에 따라 32비트 또는 64비트가 된다
  • 요즘 대부분의 환경에서는 64비트
  • 특별한 이유가 없다면 그냥 int 를 쓰면 된다

byte 와 rune

이 두 타입은 사실 정수의 별명(alias) 이다.

  • byte = uint8
  • rune = int32

byte 는 “한 바이트” 라는 의미를 강조할 때, rune 은 “한 글자(유니코드)” 라는 의미를 강조할 때 쓴다. 자세한 내용은 6장에서 다룬다.

실수

타입크기비고
float324바이트정밀도 약 7자리
float648바이트정밀도 약 15자리

특별한 이유가 없다면 float64 를 쓴다.

var pi float64 = 3.14159
e := 2.71828           // 실수 리터럴은 기본 float64

불리언

참(true) / 거짓(false) 두 값만 가진다.

var ok bool = true
done := false

C 와 달리 정수 0/1 과 자동으로 교환되지 않는다. 조건문에는 반드시 bool 만 들어간다.

문자열

문자열은 string 타입이다.

var greeting string = "Hello"
name := "Go"

큰따옴표로 감싸야 하며, 작은따옴표는 다른 의미다 (단일 문자 = rune). 자세한 내용은 6장에서 다룬다.

빠른 요약

그룹대표 타입예시 값
정수int42, -7
실수float643.14, -0.5
불리언booltrue, false
문자열string"Hello"

4.3 상수

값이 한 번 정해지면 절대 바뀌지 않는 것은 상수(constant) 다. const 키워드로 선언한다.

const Pi = 3.14159
const Greeting = "Hello"
  • 컴파일 시점에 값이 고정된다
  • 실행 중에 바꿀 수 없다
  • 함수 호출 결과처럼 런타임에야 정해지는 값은 못 쓴다

타입을 지정할 수도 있다

const MaxUsers int = 100

지정하지 않으면 “타입 없는 상수” 로 남는다. 필요한 곳에서 자동으로 알맞은 타입으로 바뀌어 유연하게 동작한다.

const 블록

var 와 마찬가지로 블록으로 묶을 수 있다.

const (
    Pi       = 3.14159
    Greeting = "Hello"
    MaxUsers = 100
)

iota 맛보기

연속된 상수를 깔끔하게 정의하는 방법이 있다. iota 라는 특별한 식별자다.

const (
    Sunday    = iota  // 0
    Monday            // 1
    Tuesday           // 2
    Wednesday         // 3
    Thursday          // 4
    Friday            // 5
    Saturday          // 6
)
  • iotaconst 블록 안에서 0부터 시작
  • 한 줄 내려갈 때마다 1씩 증가
  • 다른 언어의 enum (열거형) 흉내를 낼 때 자주 쓴다

지금은 “이런 게 있다” 정도만 알아 두면 된다.


4.4 타입 변환

Go 는 다른 언어와 비교해 타입 변환에 매우 엄격하다.

묵시적 변환이 전혀 없다. 다른 타입끼리 섞으려면 반드시 직접 변환해야 한다.

명시적 변환

타입(값) 형태로 변환한다.

var i int = 10
var f float64 = float64(i)   // int → float64
var u uint = uint(f)         // float64 → uint

이걸 빠뜨리면 컴파일 에러가 난다.

var i int = 10
var f float64 = i  // 에러: cannot use i (int) as float64

같은 정수 계열이라도 그렇다.

var a int32 = 10
var b int64 = a  // 에러: int32 와 int64 는 다른 타입

처음엔 번거롭게 느껴지지만, “의도하지 않은 변환으로 인한 버그” 가 원천 차단된다.

정수 ↔ 실수 변환

실수를 정수로 바꾸면 소수점 아래는 잘려 나간다. 반올림이 아니라 그냥 버린다.

var f float64 = 3.9
var i int = int(f)   // i 는 3 (4가 아님)

음수도 마찬가지로 0 쪽으로 잘린다.

var f float64 = -3.9
var i int = int(f)   // i 는 -3

string(z) — 헷갈리는 케이스

정수에서 문자열로 변환할 때 특히 주의해야 한다.

var n int = 65
var s string = string(n)

s 는 무엇이 될까? 직관적으로는 "65" 가 될 것 같지만, 실제로는 "A" 가 된다.

Go 는 string(정수) 를 “그 정수를 유니코드 코드 포인트로 해석” 한다. 65 는 알파벳 A 의 유니코드 값이다.

숫자를 문자열로 바꾸고 싶을 땐 다른 도구를 써야 한다.

import "strconv"

n := 65
s := strconv.Itoa(n)   // s 는 "65"

strconv 패키지는 27장에서 자세히 다룬다. 지금은 “string(숫자) 는 위험하다” 정도만 기억하자.


4.5 제로값 (zero value)

Go 에서는 변수를 선언만 하고 값을 주지 않아도 “쓰레기 값” 이 들어가지 않는다.

타입별로 정해진 기본값이 자동으로 들어간다. 이걸 제로값 이라고 부른다.

타입별 제로값

타입제로값
정수 (int, int64 등)0
실수 (float32, float64)0.0
불리언 (bool)false
문자열 (string)"" (빈 문자열)
포인터, 인터페이스, 함수, 채널, 맵, 슬라이스nil
var n int       // n 은 0
var f float64   // f 는 0.0
var ok bool     // ok 는 false
var s string    // s 는 ""

C/C++ 의 쓰레기값 문제가 없다

C 나 C++ 에서는 지역 변수를 초기화 없이 선언하면 이전에 그 메모리에 남아 있던 값이 그대로 보인다. “운 좋으면 0, 운 나쁘면 이상한 숫자” 라 디버깅하기 어려운 버그의 단골 원인이었다.

Go 는 이 문제를 언어 차원에서 막는다. 선언만 했다면 항상 동일한 제로값이 들어 있다.

var count int
count++          // 안전. count 는 1

처음 만났을 때 어색할 수 있지만, 하나만 기억하면 된다.

선언했다면 이미 값이 있다. 그 값은 그 타입의 “비어 있는 상태” 다.


4.6 정리

  • 변수 선언은 var 또는 := 로 한다
    • := 는 함수 안에서만 쓸 수 있다
  • 기본 자료형은 정수 / 실수 / 불리언 / 문자열 네 그룹
    • 특별한 이유가 없으면 int, float64 를 쓴다
  • 상수는 const 로 선언, 컴파일 시점에 고정된다
  • 타입 변환은 항상 명시적이다 (int(x), float64(y))
    • string(숫자) 는 유니코드 코드 포인트로 해석되니 주의
  • 초기화하지 않은 변수는 자동으로 제로값을 가진다

자료형이 갖춰졌으면 그 값들을 가지고 계산을 해 봐야 한다. 다음 장에서는 산술, 비교, 논리 같은 연산자들을 다룬다.

5장. 연산자

변수에 값을 담았다면 이제 그 값으로 계산을 해야 한다. Go 의 연산자는 C 계열 언어를 써 본 사람에게 거의 익숙하지만, 몇 가지 함정과 특이한 점이 있다.

목표:

  • 산술 / 비교 / 논리 연산자의 동작 방식 익히기
  • 정수 나눗셈과 오버플로우의 함정 이해하기
  • 연산자 우선순위를 큰 그림으로 잡기

5.1 산술 연산자

기본 다섯 가지다.

연산자의미
+덧셈3 + 2 → 5
-뺄셈3 - 2 → 1
*곱셈3 * 2 → 6
/나눗셈7 / 2 → ?
%나머지7 % 2 → 1
a := 10
b := 3

fmt.Println(a + b)  // 13
fmt.Println(a - b)  // 7
fmt.Println(a * b)  // 30
fmt.Println(a / b)  // 3
fmt.Println(a % b)  // 1

정수 나눗셈의 함정

위 표에서 7 / 2 의 답을 3.5 로 생각했다면 함정에 빠진 거다.

정수끼리의 나눗셈은 결과도 정수다. 소수점 아래는 그냥 잘려 나간다.

x := 7 / 2          // x 는 3, 3.5 아님
y := 7.0 / 2.0      // y 는 3.5
z := float64(7) / 2 // z 는 3.5

실수 결과가 필요하면 피연산자 중 하나라도 실수여야 한다.

음수 나머지 동작

% 연산자는 음수를 만나면 직관과 살짝 다를 수 있다. Go 에서는 결과의 부호가 좌변(피제수) 을 따른다.

fmt.Println( 7 %  3)  //  1
fmt.Println(-7 %  3)  // -1
fmt.Println( 7 % -3)  //  1
fmt.Println(-7 % -3)  // -1

수학적으로 “양수 나머지” 를 원할 때는 ((a % b) + b) % b 같은 식이 필요하다.

오버플로우는 wrap-around

정수형은 표현할 수 있는 범위가 정해져 있다. 그 범위를 넘으면 어떻게 될까?

C 처럼 그냥 한 바퀴 돌아 버린다. (wrap-around)

var n int8 = 127
n = n + 1
fmt.Println(n)   // -128

int8 의 최대값은 127이고, 거기에 1 을 더하면 정수가 한 바퀴 돌아 최소값 -128 이 된다.

런타임 에러가 나는 게 아니다. 그냥 조용히 잘못된 값이 생긴다. “수치가 클 수 있다” 싶으면 int64 같은 넉넉한 타입을 쓰자.

부호 / 부호 없음

산술 연산자는 정수와 실수 모두에 쓴다. 하지만 +, -, *, / 만 실수에 쓸 수 있고, % (나머지) 는 정수에만 쓸 수 있다.

fmt.Println(7.5 % 2.0)  // 컴파일 에러

5.2 비교 연산자

두 값을 비교해서 bool (참 / 거짓) 을 돌려준다.

연산자의미
==같다
!=다르다
<작다
>크다
<=작거나 같다
>=크거나 같다
a, b := 3, 5
fmt.Println(a == b)  // false
fmt.Println(a != b)  // true
fmt.Println(a < b)   // true
fmt.Println(a >= b)  // false

문자열도 비교 가능

문자열은 사전식으로 비교된다. 앞에서부터 한 글자씩 코드값을 비교해 가는 방식이다.

fmt.Println("apple" == "apple")  // true
fmt.Println("apple" < "banana")  // true
fmt.Println("apple" < "Banana")  // false ('B' 가 'a' 보다 작음)

대문자가 소문자보다 코드값이 작다는 점에 주의.

서로 다른 타입은 비교 불가

비교 연산자도 같은 타입끼리만 쓸 수 있다.

var a int = 3
var b int64 = 3
fmt.Println(a == b)  // 컴파일 에러

비교하려면 한쪽을 변환해야 한다.

fmt.Println(int64(a) == b)  // true

이 점이 답답해 보일 수 있지만, “의도하지 않은 비교” 로 생기는 버그를 막아 준다.


5.3 논리 연산자

bool 값들끼리 조합할 때 쓴다.

연산자의미
&&그리고 (AND)
||또는 (OR)
!부정 (NOT)
a, b := true, false

fmt.Println(a && b)  // false
fmt.Println(a || b)  // true
fmt.Println(!a)      // false

단축 평가 (short-circuit)

&&|| 는 왼쪽부터 평가하다가 결과가 확정되면 오른쪽은 아예 보지 않는다.

// && 의 단축 평가
//   왼쪽이 false 면 오른쪽은 보지 않는다
if x != 0 && 100/x > 5 {
    ...
}

위 코드에서 x 가 0 이면 x != 0 이 false 라서 오른쪽 100/x 는 실행되지 않는다. 0 으로 나누는 런타임 에러를 막아 준다.

|| 도 비슷하다. 왼쪽이 true 면 오른쪽은 평가하지 않는다.

if name == "" || isInvalid(name) {
    ...
}

이 패턴은 실전에서 매우 자주 쓰인다.


5.4 대입 연산자

기본은 = 다.

x := 10
x = 20      // x 에 20 을 다시 대입

산술과 결합된 형태도 자주 쓴다.

연산자의미풀어 쓰면
+=더해서 대입x = x + y
-=빼서 대입x = x - y
*=곱해서 대입x = x * y
/=나누어서 대입x = x / y
%=나머지로 대입x = x % y
x := 10
x += 5    // x 는 15
x *= 2    // x 는 30
x %= 7    // x 는 2

증감 연산자

x++x-- 도 있다. 하지만 Go 의 증감 연산자는 다른 언어와 결이 다르다.

Go 의 x++, x-- 는 식(expression) 이 아니라 문(statement) 이다.

  • 다른 식 안에 끼워 넣지 못한다
  • 후위 형태만 있다 (전위 ++x 같은 건 없다)
x := 10
x++              // OK. x 는 11
x--              // OK. x 는 10

y := x++         // 컴파일 에러
fmt.Println(x++) // 컴파일 에러

C 같은 언어에서 흔히 보던 a = b++ 같은 영리한 코드는 Go 에선 쓸 수 없다. 이 점이 처음엔 어색하지만, “증감은 한 줄짜리 동작” 이라는 단순한 규칙을 강제해 가독성을 지킨다.


5.5 비트 연산자

비트 단위로 정수를 다루는 연산자다. 처음에는 자주 쓸 일이 없지만 알아 두면 좋다.

연산자의미
&AND (둘 다 1)
|OR (하나라도 1)
^XOR (둘이 다를 때 1)
<<왼쪽 시프트
>>오른쪽 시프트
&^AND NOT (Go 특유)
a := 0b1100  // 12
b := 0b1010  // 10

fmt.Println(a & b)   //  8  (0b1000)
fmt.Println(a | b)   // 14  (0b1110)
fmt.Println(a ^ b)   //  6  (0b0110)
fmt.Println(a << 1)  // 24
fmt.Println(a >> 1)  //  6

&^ (AND NOT) 연산자

다른 언어엔 잘 없는 Go 특유의 연산자다.

a &^ b 는 “a 의 비트 중에서 b 가 1 인 자리만 끄기” 다. a & (^b) 와 같다.

a := 0b1101  // 13
b := 0b0100  //  4

fmt.Println(a &^ b)  // 0b1001 → 9

“플래그 비트를 끄고 싶을 때” 종종 등장한다. 지금은 “이런 게 있다” 정도만 기억하자.


5.6 연산자 우선순위

Go 의 우선순위는 다섯 단계로 단순하게 정리돼 있다. 위쪽일수록 먼저 계산된다.

우선순위연산자
1 (가장 높음)*, /, %, <<, >>, &, &^
2+, -, |, ^
3==, !=, <, <=, >, >=
4&&
5 (가장 낮음)||

수학적인 직관과 대체로 맞다. 곱셈/나눗셈이 덧셈/뺄셈보다 먼저고, 산술이 비교보다 먼저고, AND 가 OR 보다 먼저다.

result := 3 + 4 * 2     // 11 (3 + 8), 14 아님
ok := a > 0 && b > 0    // (a > 0) && (b > 0)

헷갈리면 괄호로

우선순위를 외우려 애쓰기보다, 조금만 복잡하면 그냥 괄호를 치는 게 낫다.

// 동작은 같지만 의도가 더 분명함
result := 3 + (4 * 2)
ok     := (a > 0) && (b > 0)

코드를 읽는 사람도, 미래의 본인도 고마워한다.


5.7 정리

  • 산술 연산자에서 가장 자주 실수하는 곳은 정수 나눗셈(/)
    • 정수 / 정수 = 정수
    • 실수 결과가 필요하면 피연산자를 실수로
  • 비교 연산자는 bool 을 돌려준다, 다른 타입끼리는 비교 불가
  • 논리 연산자(&&, ||)는 단축 평가를 한다
  • x++, x-- 는 문이지 식이 아니다 (후위만 가능)
  • 비트 연산자에는 Go 특유의 &^ (AND NOT) 가 있다
  • 우선순위 외우기 어렵다면 괄호로 명시하자

값과 연산이 갖춰졌다. 다음 장에서는 그동안 가볍게만 다뤘던 문자열을 본격적으로 들여다본다. 한글이 등장하면서 흥미로운 함정이 몇 개 나온다.

6장. 문자열 다루기 기초

4장에서 문자열을 가볍게 소개했다. 이 장에서는 Go 의 문자열이 내부적으로 어떻게 생겼는지, 그리고 한글 같은 다국어 문자를 다룰 때 무엇을 조심해야 하는지 본다.

목표:

  • 문자열이 “바이트의 나열” 이라는 점 이해하기
  • 영문과 한글의 길이 차이를 설명할 수 있게 되기
  • byterune 의 쓰임새 구분하기

6.1 문자열의 본질

불변 (immutable)

Go 의 문자열은 한 번 만들어지면 내용을 바꿀 수 없다.

s := "hello"
s[0] = 'H'   // 컴파일 에러

문자열의 일부 글자를 바꾸고 싶다면 새 문자열을 만들어 변수에 다시 대입해야 한다.

s = "Hello"   // 새 문자열을 대입하는 건 가능

“변수가 가리키는 문자열을 통째로 교체” 하는 건 자유다. 다만 기존 문자열의 내부 글자만 살짝 바꾸는 건 안 된다.

바이트의 나열, UTF-8 인코딩

Go 에서 문자열은 사실 바이트들의 묶음 이다. 그리고 그 바이트들은 UTF-8 로 인코딩돼 있다.

  • 영문, 숫자, 기호 같은 ASCII 문자는 1바이트
  • 한글, 한자, 이모지 같은 문자는 2~4바이트

이 사실이 곧 이번 장의 함정들로 이어진다.

큰따옴표 vs 백틱

문자열 리터럴은 두 가지 방법으로 적을 수 있다.

s1 := "Hello\n World"   // 큰따옴표
s2 := `Hello\n World`   // 백틱 (raw string)
큰따옴표 "..."백틱 `...`
이스케이프 문자 해석 (\n, \t 등)이스케이프 해석 안 함
한 줄만 가능여러 줄 가능
s1 := "줄1\n줄2"        // "줄1" + 줄바꿈 + "줄2"
s2 := `줄1\n줄2`        // 그대로 "줄1\n줄2"

s3 := `여러
줄에 걸친
문자열`

정규식, JSON 템플릿, SQL 같이 역슬래시가 많이 나오는 문자열은 백틱이 편하다.


6.2 연결과 비교

+ 로 연결

문자열은 + 로 이어붙일 수 있다.

first := "Hello"
last  := "World"
msg   := first + ", " + last + "!"
fmt.Println(msg)  // Hello, World!

+= 로 누적도 가능하다.

s := ""
s += "안녕"
s += "하세요"
fmt.Println(s)  // 안녕하세요

짧은 연결은 + 로 충분하지만, 반복문 안에서 수백 번 이어붙여야 한다면 strings.Builder 가 훨씬 효율적이다. (26장에서)

비교

문자열도 5장의 비교 연산자를 그대로 쓴다.

fmt.Println("apple" == "apple")  // true
fmt.Println("apple" != "Apple")  // true (대소문자 다름)
fmt.Println("apple" < "banana")  // true (사전식)

비교는 바이트 단위로 이뤄진다. 한글도 마찬가지지만, “가나다 순으로 정확히 정렬되느냐” 는 더 복잡한 이야기다. 지금은 “같은가 다른가” 비교 정도만 안전하다고 보면 된다.


6.3 길이 구하기

len() 은 바이트 단위

문자열의 길이는 len() 함수로 구한다.

fmt.Println(len("hello"))  // 5

여기까지는 직관과 같다. 하지만 한글이 들어가면 결과가 달라진다.

fmt.Println(len("안녕"))  // 6

한글 한 글자가 UTF-8 에서 3바이트를 차지하기 때문이다. "안녕" 은 2글자지만 6바이트다.

len(s) 은 글자 수가 아니라 바이트 수다.

영문 한 글자는 1바이트, 한글 한 글자는 3바이트라는 점을 잊지 말자.

글자 수가 필요할 땐?

진짜 문자 단위 길이는 []rune 으로 변환해서 구한다.

s := "안녕Go"
fmt.Println(len(s))           // 8 (3 + 3 + 1 + 1)
fmt.Println(len([]rune(s)))   // 4 (안, 녕, G, o)

rune 의 정체는 잠시 뒤 6.5 절에서 다룬다.


6.4 인덱싱과 슬라이싱

s[i] 는 byte 를 돌려준다

문자열에 대괄호로 인덱스를 주면 그 위치의 바이트 를 돌려준다.

s := "hello"
fmt.Println(s[0])    // 104 (소문자 'h' 의 ASCII 코드값)

문자열을 인덱싱하면 글자가 아니라 숫자가 나오는 게 처음엔 어색하다. “인덱싱 = 바이트 꺼내기” 라고 기억하자.

문자처럼 보고 싶다면 변환이 필요하다.

fmt.Println(string(s[0]))   // "h"

슬라이싱

s[i:j] 형태로 부분 문자열을 잘라낼 수 있다.

s := "Hello, World!"
fmt.Println(s[0:5])    // "Hello"
fmt.Println(s[7:12])   // "World"
fmt.Println(s[:5])     // "Hello"  (앞부터 5바이트)
fmt.Println(s[7:])     // "World!" (7번째부터 끝까지)

여기서도 단위는 글자가 아니라 바이트 다.

한글 인덱싱의 함정

영문 문자열은 한 글자 = 한 바이트라 인덱싱이 직관적이다. 하지만 한글은 그렇지 않다.

s := "안녕"
fmt.Println(s[0])  // 236 (한글 첫 글자의 첫 바이트)
fmt.Println(s[1])  // 149 (둘째 바이트)
fmt.Println(s[2])  // 136 (셋째 바이트, 여기까지가 '안' 한 글자)

s[0]'안' 글자 통째가 아니다. '안' 을 구성하는 3바이트 중 첫 번째 바이트일 뿐이다.

그래서 한글 문자열을 잘못 슬라이싱하면 글자가 깨진다.

s := "안녕하세요"
fmt.Println(s[0:1])   // 깨진 문자
fmt.Println(s[0:3])   // "안"

[0:3] 처럼 글자 경계에 딱 맞춰 잘라야 온전한 글자가 된다. 이게 다음 6.5 절에서 rune 이 필요한 이유로 이어진다.


6.5 byte 와 rune

두 타입의 정체

4장에서 살짝 봤다.

  • byte = uint8 (1바이트 정수)
  • rune = int32 (4바이트 정수, 유니코드 코드 포인트)

각각 다른 목적으로 쓴다.

타입용도
byte“한 바이트” 를 다룰 때
rune“한 글자(유니코드)” 를 다룰 때

[]rune 으로 변환

문자열을 글자 단위로 다루려면 []rune 으로 변환한다.

s := "안녕Go"

bs := []byte(s)
rs := []rune(s)

fmt.Println(len(bs))   // 8 (바이트 개수)
fmt.Println(len(rs))   // 4 (글자 개수)

fmt.Println(string(rs[0]))   // "안"
fmt.Println(string(rs[1]))   // "녕"
fmt.Println(string(rs[2]))   // "G"
fmt.Println(string(rs[3]))   // "o"

rs[0]'안' 한 글자 전체에 해당하는 코드 포인트(정수) 다. string() 으로 다시 감싸면 우리가 아는 문자가 된다.

글자 단위 작업 패턴

한글이 섞인 문자열을 다룰 때는 보통 이렇게 한다.

s := "안녕하세요"

rs := []rune(s)
fmt.Println(len(rs))          // 5 (글자 수)
fmt.Println(string(rs[:3]))   // "안녕하"
fmt.Println(string(rs[2:]))   // "하세요"

“바이트로 다룰지, 글자로 다룰지” 만 의식하면 한글 처리도 어렵지 않다.

range 로 글자 순회 (짧은 언급)

반복문 for ... range 로 문자열을 돌면 바이트가 아니라 rune 단위 로 순회한다.

for i, r := range "안녕Go" {
    fmt.Println(i, string(r))
}

for 문 자체는 다음 8장에서 본격적으로 다룬다. 지금은 “range 로 돌리면 글자 단위” 라는 것만 기억해 두자.


6.6 정리

  • 문자열은 불변 이며 UTF-8 바이트의 나열 이다
  • 큰따옴표는 이스케이프 해석, 백틱은 원문 그대로(raw)
  • 연결은 +, 비교는 ==, < 등으로 한다
  • len(s)바이트 수 다 — 한글 한 글자는 3바이트
  • s[i] 인덱싱은 바이트 를 돌려준다
  • 글자 단위로 다루려면 []rune 으로 변환

이 정도면 일상적인 문자열 처리는 가능하다. 검색, 치환, 분리 같은 본격적인 기능은 strings 패키지에 들어 있다. 표준 라이브러리를 다루는 27장에서 다시 만난다.

다음 장에서는 그동안 무심코 써 온 fmt.Println 의 정체를 들여다본다. 출력 형식을 마음대로 조절하고, 사용자 입력도 받아 본다.

7장. fmt 패키지로 입출력

2장부터 매번 써 온 fmt.Println 의 정체를 이 장에서 들여다본다. fmt 는 Go 의 표준 입출력 패키지로, 출력뿐 아니라 입력 받기, 문자열 만들기에도 두루 쓰인다.

목표:

  • 세 가지 출력 함수의 차이 알기
  • 포맷 동사로 출력 모양 자유롭게 조절하기
  • 사용자 입력을 받아 변수에 담아 보기

7.1 fmt 패키지란

fmt 는 Go 표준 라이브러리에 들어 있는 포맷팅 기반 입출력 패키지 다.

  • 화면에 값을 출력 (Print, Println, Printf)
  • 문자열로 포맷팅 (Sprintf)
  • 사용자 입력을 받음 (Scan, Scanln, Scanf)

이름의 fmt 는 format 의 줄임말이다. 형식을 지정해 입출력을 다룬다는 뜻이다.

import "fmt"

3장에서 본 그 import 줄이 이걸 가져오는 것이다.


7.2 출력 함수

자주 쓰는 출력 함수 세 가지를 비교해 본다.

fmt.Print

값을 그대로 출력한다. 줄바꿈도 없고, 인자 사이에 자동 공백도 없다.

fmt.Print("Hello", "World")
fmt.Print("Done")

출력:

HelloWorldDone

세 단어가 다 붙어 나온다. “내가 적은 그대로” 가 화면에 찍힌다고 보면 된다.

예외: 인자가 둘 다 문자열이 아닌 경우엔 자동으로 공백이 들어간다. 그래서 동작이 살짝 헷갈릴 때가 있다. 깔끔하게 가려면 다음에 나오는 Printf 가 낫다.

fmt.Println

가장 많이 본 함수다.

  • 인자 사이에 자동으로 공백을 넣어 준다
  • 마지막에 자동으로 줄바꿈을 붙여 준다
fmt.Println("Hello", "World")
fmt.Println("Done")

출력:

Hello World
Done

“디버깅용 빠른 출력” 으로 가장 편하다.

fmt.Printf

포맷 문자열을 첫 인자로 받는다. 출력 모양을 정밀하게 다듬을 수 있다.

name := "Alice"
age  := 30

fmt.Printf("%s is %d years old.\n", name, age)

출력:

Alice is 30 years old.

%s 자리에 name, %d 자리에 age 값이 들어간다. 줄바꿈은 자동이 아니라 \n 을 직접 적어 줘야 한다.

한눈에 비교

함수자동 공백자동 줄바꿈포맷 지정
Print일부 경우만아니오아니오
Println아니오
Printf아니오아니오 (\n 직접)

7.3 자주 쓰는 포맷 동사

Printf 의 핵심은 포맷 동사(verb) 다. % 로 시작하는 짧은 코드로 “여기에 무엇을 끼워 넣을지” 를 지정한다.

기본 동사

동사의미
%d10진 정수42
%f실수3.140000
%s문자열Hello
%t불리언true
%v어떤 값이든 (기본 표현)42, Hello, true
%T값의 타입 출력int, string
%%퍼센트 문자 자체%
n  := 42
pi := 3.14
s  := "Go"
ok := true

fmt.Printf("%d\n", n)       // 42
fmt.Printf("%f\n", pi)      // 3.140000
fmt.Printf("%s\n", s)       // Go
fmt.Printf("%t\n", ok)      // true
fmt.Printf("%v %v %v\n", n, pi, s)  // 42 3.14 Go
fmt.Printf("%T\n", n)       // int
fmt.Printf("100%% done\n")  // 100% done

%v 는 “타입 신경 쓰기 귀찮을 때” 쓰는 만능 동사다. 디버깅용으로 매우 자주 쓰인다.

너비와 정밀도

숫자 앞뒤에 추가로 옵션을 붙일 수 있다.

표기의미
%5d너비 5칸 (오른쪽 정렬, 앞에 공백)
%-5d너비 5칸 (왼쪽 정렬)
%05d너비 5칸 (앞을 0 으로 채움)
%.2f소수점 아래 2자리
%6.2f전체 너비 6, 소수점 아래 2자리
%-10s너비 10칸 (왼쪽 정렬 문자열)

예시.

fmt.Printf("[%5d]\n",   42)     // [   42]
fmt.Printf("[%-5d]\n",  42)     // [42   ]
fmt.Printf("[%05d]\n",  42)     // [00042]
fmt.Printf("[%.2f]\n",  3.14159) // [3.14]
fmt.Printf("[%6.2f]\n", 3.14159) // [  3.14]
fmt.Printf("[%-10s|]\n", "Go")  // [Go        |]

표 모양으로 정렬해 출력할 때 유용하다.

fmt.Printf("%-10s %5d\n", "Alice",   30)
fmt.Printf("%-10s %5d\n", "Bob",    100)
fmt.Printf("%-10s %5d\n", "Charlie", 5)

출력:

Alice         30
Bob          100
Charlie        5

이스케이프 문자

문자열 안에서 특별한 의미를 가지는 짧은 표기들이다.

표기의미
\n줄바꿈
\t
\\역슬래시 자체
\"큰따옴표 자체
fmt.Printf("이름:\tAlice\n나이:\t30\n")

출력:

이름:	Alice
나이:	30

6장에서 본 백틱(` `) 문자열은 이런 이스케이프를 해석하지 않는다는 점을 같이 떠올려 보자.


7.4 문자열 만들기

화면에 출력하지 말고 문자열 변수로 받고 싶을 때 가 있다. 이때는 fmt.Sprintf 를 쓴다.

  • Printf 와 사용법 동일
  • 결과를 화면에 찍는 대신 string 으로 돌려준다
name := "Alice"
age  := 30

greeting := fmt.Sprintf("%s is %d years old.", name, age)
fmt.Println(greeting)
// Alice is 30 years old.

활용 예시.

// 파일명 만들기
path := fmt.Sprintf("logs/%d.txt", 2024)

// 로그 메시지 만들기
msg := fmt.Sprintf("user=%s action=%s ok=%t", name, "login", true)

문자열을 + 로 이어 만드는 것보다 훨씬 깔끔하다. 포맷 동사 덕에 숫자 자리수도 맞출 수 있다.

for i := 1; i <= 3; i++ {
    file := fmt.Sprintf("file_%03d.txt", i)
    fmt.Println(file)
}

출력:

file_001.txt
file_002.txt
file_003.txt

7.5 사용자 입력 받기

지금까지는 프로그램이 일방적으로 출력만 했다. 이제 사용자가 입력한 값을 변수에 담아 보자.

fmt.Scan

공백이나 줄바꿈을 기준으로 값들을 읽어 들인다.

var name string
var age int

fmt.Print("이름과 나이를 입력하세요: ")
fmt.Scan(&name, &age)

fmt.Println("이름:", name)
fmt.Println("나이:", age)

실행 예.

이름과 나이를 입력하세요: Alice 30
이름: Alice
나이: 30

& 가 붙는 이유 (짧게)

Scan 의 인자에 &name, &age 처럼 앞에 & 가 붙는다. 이건 “변수의 주소” 를 의미한다.

  • 함수가 변수의 값을 바꾸려면 주소를 알아야 한다
  • 값만 넘기면 함수 안에서 복사본만 바꾸고 끝난다

이 개념을 포인터 라고 부른다. 지금은 “입력 받을 때는 & 를 붙인다” 정도만 알면 충분하다. 포인터 자체는 14장에서 차근차근 다룬다.

fmt.Scanln

Scanln 은 줄바꿈에서 입력을 끝낸다. 한 줄에 정해진 개수만큼만 받고 싶을 때 좋다.

var name string
var age int

fmt.Print("입력: ")
fmt.Scanln(&name, &age)

Scan 과의 차이는 미묘하다. Scan 은 줄바꿈을 만나도 더 읽어들이려 기다리지만, Scanln 은 줄바꿈에서 멈춘다.

fmt.Scanf

포맷을 지정해서 받는다. Printf 의 입력 버전이라 보면 된다.

var year int
var month int

fmt.Print("입력 (YYYY-MM): ")
fmt.Scanf("%d-%d", &year, &month)

fmt.Println(year, month)

실행 예.

입력 (YYYY-MM): 2024-09
2024 9

어떤 걸 써야 하나

함수언제 쓰나
Scan공백이나 줄바꿈으로 구분된 값 여러 개
Scanln한 줄에 정확히 N 개의 값
Scanf입력 형식이 정해져 있을 때

초보 단계에서는 Scanln 을 기본으로 쓰면 거의 다 된다.


7.6 미니 실습: 인사 프로그램

지금까지 배운 걸 합쳐서 간단한 프로그램을 만들어 보자.

사용자에게 이름과 나이를 입력 받아, 보기 좋은 인사 메시지를 출력한다.

package main

import "fmt"

func main() {
    var name string
    var age int

    fmt.Print("이름을 입력하세요: ")
    fmt.Scanln(&name)

    fmt.Print("나이를 입력하세요: ")
    fmt.Scanln(&age)

    greeting := fmt.Sprintf(
        "안녕하세요, %s 님! 올해 %d 살이시군요.",
        name, age,
    )

    fmt.Println("---")
    fmt.Println(greeting)
    fmt.Printf("내년에는 %d 살이 됩니다.\n", age+1)
}

실행 예.

이름을 입력하세요: Alice
나이를 입력하세요: 30
---
안녕하세요, Alice 님! 올해 30 살이시군요.
내년에는 31 살이 됩니다.

이 작은 프로그램에 이번 장의 거의 모든 도구가 들어 있다.

  • fmt.Print 로 줄바꿈 없는 안내문
  • fmt.Scanln 으로 변수에 입력 받기
  • fmt.Sprintf 로 문자열 조립
  • fmt.Printlnfmt.Printf 로 결과 출력

7.7 정리

  • fmt 는 Go 표준 입출력 패키지다
  • 출력 함수 세 가지의 성격이 다르다
    • Print : 그대로
    • Println : 공백 + 줄바꿈 자동
    • Printf : 포맷 지정
  • 포맷 동사로 모양을 정밀하게 다듬는다 (%d, %f, %s, %v, %T 등)
  • Sprintf 는 출력 대신 문자열을 돌려준다
  • 입력은 Scan, Scanln, Scanf
    • 인자에는 &변수명 으로 주소를 넘긴다 (포인터, 14장)

이제 값을 다루고, 출력하고, 입력 받는 일이 가능해졌다. 하지만 프로그램이 진짜 “프로그램” 다워지려면 조건과 반복이 필요하다.

다음 장에서는 흐름 제어를 다룬다. if, switch, for 로 분기와 반복을 표현해 보자.

8장. 제어문

지금까지는 코드를 위에서 아래로 한 줄씩만 실행했다. 하지만 실제 프로그램은 조건에 따라 갈라지고, 같은 일을 여러 번 반복하기도 한다.

이 장에서는 흐름을 바꾸는 도구들을 익힌다.

목표:

  • 조건문 if 로 분기를 만들 수 있다
  • switch 로 여러 경우를 깔끔히 나눌 수 있다
  • for 한 가지로 모든 반복을 표현할 수 있다
  • break, continue, 라벨로 흐름을 세밀하게 제어한다

8.1 조건문 if / else

가장 기본적인 분기 도구다. 조건이 참일 때 블록 안의 코드를 실행한다.

기본 문법

if 조건 {
    // 조건이 참일 때
} else if 다른조건 {
    // 위는 거짓이고 이건 참일 때
} else {
    // 모두 거짓일 때
}

간단한 예제로 살펴본다.

package main

import "fmt"

func main() {
    score := 85

    if score >= 90 {
        fmt.Println("A")
    } else if score >= 80 {
        fmt.Println("B")
    } else if score >= 70 {
        fmt.Println("C")
    } else {
        fmt.Println("F")
    }
}

실행 결과:

B

조건에 괄호를 쓰지 않는다

C, Java 와 다르게 조건 자체를 괄호로 감싸지 않는다.

// OK
if x > 0 {
}

// 컴파일 에러는 아니지만, gofmt 가 자동으로 괄호를 빼지는 않는다.
// 다만 관례상 쓰지 않는다.
if (x > 0) {
}

대신 중괄호 { } 는 반드시 있어야 한다. 한 줄짜리 코드라도 생략할 수 없다.

// 컴파일 에러
if x > 0 fmt.Println("yes")

// OK
if x > 0 {
    fmt.Println("yes")
}

조건 앞에 명령문 넣기

Go 의 if 는 조건 앞에 짧은 명령문 하나를 같이 둘 수 있다.

if x := compute(); x > 0 {
    fmt.Println("positive:", x)
} else {
    fmt.Println("non-positive:", x)
}

세미콜론 ; 으로 두 부분이 나뉜다.

  • 앞 부분: 변수 선언 또는 짧은 명령문
  • 뒤 부분: 진짜 조건식

이 형태는 변수의 범위를 좁힌다는 장점이 있다. xif ~ else 블록 안에서만 살아 있다. 블록을 벗어나면 사라진다.

if x := compute(); x > 0 {
    fmt.Println(x) // OK
}
fmt.Println(x) // 컴파일 에러: x 가 없음

변수의 범위에 대한 자세한 설명은 10장에서 다룬다.

이런 패턴은 함수가 두 값을 돌려줄 때 자주 보인다. 다중 반환값은 9장에서 본격적으로 다룬다.


8.2 switch 분기

조건이 많아지면 if / else if 가 길어진다. 이럴 때 switch 가 가독성을 살려 준다.

기본 문법

switch 값 {
case 값1:
    // 값1 일 때
case 값2:
    // 값2 일 때
default:
    // 그 외
}

앞 절의 점수 예제를 switch 로 다시 써 본다.

grade := "B"

switch grade {
case "A":
    fmt.Println("훌륭함")
case "B":
    fmt.Println("좋음")
case "C":
    fmt.Println("보통")
default:
    fmt.Println("분발")
}

break 가 자동이다

C, Java 의 switch 는 각 case 끝에 break 를 써야 한다. 빠뜨리면 다음 case 로 흘러 넘어가는 이른바 “폴스루(fallthrough)” 현상이 생긴다.

Go 는 반대다. 각 case 끝에서 자동으로 switch 를 빠져나간다. 명시적으로 다음 case 로 넘기고 싶을 때만 fallthrough 키워드를 쓴다.

n := 1

switch n {
case 1:
    fmt.Println("one")
    fallthrough
case 2:
    fmt.Println("two")
case 3:
    fmt.Println("three")
}

실행 결과:

one
two

fallthrough 는 조건을 다시 보지 않고 그냥 다음 case 블록을 실행한다. 실제로는 거의 쓰이지 않는다.

한 case 에 여러 값

콤마로 여러 값을 한 줄에 묶을 수 있다.

switch day {
case "토", "일":
    fmt.Println("주말")
case "월", "화", "수", "목", "금":
    fmt.Println("평일")
}

표현식 없는 switch

switch 뒤에 비교할 값을 두지 않으면 각 case 에 자유로운 조건식을 쓸 수 있다. 긴 if / else if 사다리를 깔끔히 대체한다.

score := 85

switch {
case score >= 90:
    fmt.Println("A")
case score >= 80:
    fmt.Println("B")
case score >= 70:
    fmt.Println("C")
default:
    fmt.Println("F")
}

switch 는 변수의 타입을 분기하는 용도로도 쓸 수 있다. 이를 “타입 스위치“라 부른다. 인터페이스를 배우는 16장에서 다시 만난다.


8.3 반복문 for

Go 의 반복문은 for 하나뿐이다. 다른 언어의 while, do-while 같은 키워드가 따로 없다. 대신 for 가 세 가지 모습으로 변신한다.

일반 형태 (C 스타일)

가장 익숙한 모양이다.

for 초기식; 조건식; 후처리 {
    // 본문
}

예시:

for i := 0; i < 5; i++ {
    fmt.Println(i)
}

실행 결과:

0
1
2
3
4
  • 초기식 i := 0 은 시작 직전 한 번 실행
  • 조건식 i < 5 가 참인 동안 반복
  • 후처리 i++ 는 본문이 끝날 때마다 실행

조건만 (while 처럼)

초기식과 후처리를 생략하면 while 과 똑같이 쓰인다.

x := 1
for x < 100 {
    x *= 2
}
fmt.Println(x) // 128

무한 루프

조건도 없으면 무한 루프가 된다.

for {
    // 끝나지 않는 반복
}

break 로 빠져나오거나 return 으로 함수 자체를 끝내야 멈춘다.

for {
    line := readLine()
    if line == "quit" {
        break
    }
    process(line)
}

for … range

배열, 슬라이스, 문자열, 맵 같은 컬렉션을 처음부터 끝까지 훑을 때 쓴다.

슬라이스와 맵은 11, 12장에서 자세히 다룬다. 여기서는 range 의 모양만 익혀 두자.

슬라이스 순회:

nums := []int{10, 20, 30}

for i, v := range nums {
    fmt.Println(i, v)
}

실행 결과:

0 10
1 20
2 30

range 는 두 값을 돌려준다.

  • 첫 번째: 인덱스
  • 두 번째: 그 자리의 값

값만 필요하면 인덱스를 _ 로 무시한다.

for _, v := range nums {
    fmt.Println(v)
}

인덱스만 필요하면 두 번째를 아예 생략한다.

for i := range nums {
    fmt.Println(i)
}

문자열 range

문자열을 range 로 돌리면 바이트가 아닌 rune (유니코드 코드 포인트) 단위로 순회한다.

s := "안녕Go"
for i, r := range s {
    fmt.Printf("%d %c\n", i, r)
}

실행 결과:

0 안
3 녕
6 G
7 o

한글 한 글자가 UTF-8 로 3 바이트를 차지해서 인덱스가 1씩 증가하지 않는 점에 주목한다. 바이트 단위 순회를 원한다면 인덱스로 직접 접근해야 한다.

문자열의 바이트와 rune 차이는 6장에서 맛만 봤고, 본격적인 문자열 처리는 27장에서 다룬다.


8.4 break, continue

반복문 안에서 흐름을 가다듬는 두 키워드다.

break — 즉시 탈출

가장 안쪽 반복문을 빠져나간다.

for i := 0; i < 10; i++ {
    if i == 5 {
        break
    }
    fmt.Println(i)
}

실행 결과:

0
1
2
3
4

continue — 다음 반복으로

본문의 나머지를 건너뛰고 다음 반복으로 넘어간다.

for i := 0; i < 5; i++ {
    if i%2 == 0 {
        continue
    }
    fmt.Println(i)
}

실행 결과:

1
3

짝수일 때 continue 가 걸려서 fmt.Println 이 실행되지 않는다.


8.5 라벨로 중첩 반복 제어

breakcontinue 는 가장 안쪽 반복문만 다룬다. 중첩 반복에서 바깥 반복문을 한 번에 제어하려면 라벨 을 붙인다.

라벨 문법

OUTER:
    for i := 0; i < 3; i++ {
        for j := 0; j < 3; j++ {
            if i*j > 3 {
                break OUTER
            }
            fmt.Println(i, j)
        }
    }
  • 반복문 위 줄에 라벨이름: 형태로 적는다
  • 라벨 이름은 대문자로 쓰는 관례가 있다
  • break OUTEROUTER 가 가리키는 반복문을 종료한다

실행 결과:

0 0
0 1
0 2
1 0
1 1
1 2
2 0
2 1
2 2

i*j 가 처음으로 3 을 넘는 순간 (i=2, j=2 다음, 즉 i=2, j=… 가 아니라 i=2, j=2 까지 출력 후 다음 iteration 에서 멈춘다는 점은 직접 굴려 보며 확인해 보자.)

continue 도 라벨을 받는다

OUTER:
    for i := 0; i < 3; i++ {
        for j := 0; j < 3; j++ {
            if j == 1 {
                continue OUTER
            }
            fmt.Println(i, j)
        }
    }

continue OUTER 는 안쪽 반복문을 그만두고 바깥 반복문의 다음 회차로 넘어간다.

실행 결과:

0 0
1 0
2 0

자주 쓰면 안 된다

라벨은 강력하지만, 자주 쓰면 흐름을 따라가기 어렵다. 대안이 있다면 먼저 그쪽을 고려한다.

  • 깊은 중첩 자체가 신호다. 함수로 쪼개 보자.
  • 함수로 빼면 return 만으로 모든 반복을 한 번에 빠져나갈 수 있다.

라벨이 정말 깔끔한 답이 되는 경우만 쓴다.


8.6 정리

이 장에서 살펴본 내용:

  • if / else if / else 로 분기를 만든다
    • 조건에 괄호 없음, 중괄호 필수
    • if x := f(); 조건 { } 패턴으로 변수 범위 좁히기
  • switch 는 자동으로 break 된다
    • 폴스루는 fallthrough 로 명시
    • 표현식 없는 switchif 사다리 대체
  • 반복문은 for 한 가지로 통일
    • C 스타일, while 스타일, 무한 루프
    • for ... range 로 컬렉션과 문자열 순회
  • break, continue 는 가장 안쪽 반복문에 작동
  • 중첩 반복 제어는 라벨로 가능하지만 자제

흐름을 다루는 도구를 갖췄으니 이제 코드 덩어리를 재사용 가능한 단위로 묶을 차례다.

다음 장에서는 함수 를 다룬다. 이름을 붙이고, 입력과 출력을 정의하고, 여러 값을 한 번에 돌려주고, defer 와 클로저까지 익힌다.

9장. 함수

지금까지는 모든 코드를 main 안에 적었다. 프로그램이 조금만 커져도 이 방식은 빠르게 무너진다. 같은 일을 여러 번 적어야 하고, 한 함수가 너무 많은 일을 떠안게 된다.

해결책은 단순하다. 같은 일을 묶어 이름을 붙이는 것. 이것이 함수 다.

이 장의 목표:

  • 함수를 정의하고 호출할 수 있다
  • 매개변수와 반환값을 다양한 형태로 다룰 수 있다
  • 다중 반환값과 명명된 반환값을 안다
  • 가변 인자를 사용할 수 있다
  • defer 로 정리 작업을 안전하게 처리한다
  • 익명 함수와 클로저를 이해한다

9.1 함수 정의와 호출

가장 기본적인 모양은 이렇다.

func 이름(매개변수) 반환타입 {
    // 본문
}

작은 예제부터 본다.

package main

import "fmt"

func greet() {
    fmt.Println("Hello!")
}

func main() {
    greet()
    greet()
}

실행 결과:

Hello!
Hello!
  • func 는 함수를 시작하는 키워드
  • 함수 이름은 변수 이름 규칙과 같다
  • () 안에 매개변수를 적는다 (없으면 비워 둔다)
  • 매개변수 뒤에 반환 타입을 적는다 (없으면 생략)
  • 중괄호 위치는 3장에서 본 규칙 그대로

매개변수가 없는 함수

greet 처럼 괄호 안을 비워 두면 된다.

반환값이 없는 함수

반환 타입을 적지 않으면 아무것도 반환하지 않는다. 이런 함수도 안에서 return 을 써서 일찍 빠져나올 수 있다.

func warn(msg string) {
    if msg == "" {
        return
    }
    fmt.Println("경고:", msg)
}

9.2 매개변수와 반환값

값을 받고 값을 돌려주는 함수가 가장 흔하다.

func square(n int) int {
    return n * n
}

func main() {
    fmt.Println(square(4)) // 16
}
  • n int 는 “이름이 n 이고 타입은 int 인 매개변수”
  • 마지막 int 는 반환 타입
  • 함수 본문에서 return 으로 값을 돌려준다

같은 타입 매개변수 묶기

매개변수가 여러 개고 타입이 같다면 마지막 한 번만 타입을 적어도 된다.

// 매번 적기
func add(a int, b int) int {
    return a + b
}

// 한 번만 적기 (같은 결과)
func add(a, b int) int {
    return a + b
}

세 개 이상도 마찬가지다.

func clamp(x, lo, hi int) int {
    if x < lo {
        return lo
    }
    if x > hi {
        return hi
    }
    return x
}

타입이 섞여 있다면 그룹별로 묶는다.

func repeat(s string, n int) string {
    // ...
}

func mix(a, b int, c, d string) {
    // ...
}

9.3 다중 반환값

Go 함수는 값을 여러 개 동시에 돌려줄 수 있다. 다른 많은 언어와 구별되는 특징이다.

func swap(a, b int) (int, int) {
    return b, a
}

func main() {
    x, y := swap(1, 2)
    fmt.Println(x, y) // 2 1
}

반환 타입을 괄호로 묶고, return 도 콤마로 여러 값을 돌려준다. 호출 쪽은 받는 변수 개수를 정확히 맞춰야 한다.

결과와 에러를 함께 돌려주기

다중 반환의 가장 흔한 쓰임은 “결과 + 성공 여부” 또는 “결과 + 에러” 다.

func divide(a, b int) (int, bool) {
    if b == 0 {
        return 0, false
    }
    return a / b, true
}

func main() {
    q, ok := divide(10, 3)
    if ok {
        fmt.Println("몫:", q)
    } else {
        fmt.Println("0 으로 나눌 수 없음")
    }
}

Go 표준 라이브러리는 이 패턴을 폭넓게 쓴다. 정식 에러 처리는 error 인터페이스로 한다. 이 부분은 21장에서 자세히 다룬다.

blank identifier _

여러 값 중 일부만 필요하다면 나머지를 _ (언더스코어) 로 받아 버린다.

_, ok := divide(10, 0)
if !ok {
    fmt.Println("실패")
}

_ 는 “이 값을 안 쓰겠다” 는 명시다. Go 는 선언만 하고 안 쓰는 변수를 컴파일 에러로 처리한다. _ 는 그 규칙을 우회하는 공식 도구다.


9.4 명명된 반환값 (named return)

반환 타입에 이름을 미리 붙일 수도 있다.

func divmod(a, b int) (q, r int) {
    q = a / b
    r = a % b
    return
}
  • 반환 타입 자리에 q, r int 처럼 이름과 타입을 같이 적었다
  • 함수가 시작될 때 q, r 은 제로값 (0) 으로 자동 선언된다
  • 마지막의 return 은 값을 적지 않아도 q, r 의 현재 값을 돌려준다. 이걸 naked return 이라 부른다

호출 쪽은 일반 다중 반환과 똑같이 받는다.

q, r := divmod(17, 5)
fmt.Println(q, r) // 3 2

언제 좋고 언제 나쁜가

명명된 반환값의 장점:

  • 함수 시그니처만 봐도 반환의 의미를 알 수 있다
  • 짧은 함수에서 의도를 분명히 드러낸다

단점:

  • 함수가 길어지면 어디서 값이 바뀌는지 추적이 어렵다
  • naked return 은 함수 끝에서 어떤 값이 나가는지 한눈에 안 보인다

짧고 의미가 분명한 함수에서만 쓴다. 본문이 길어지면 그냥 일반 다중 반환을 쓰는 편이 안전하다.


9.5 가변 인자 (variadic)

같은 타입의 인자를 임의 개수 받고 싶을 때 쓴다. 타입 앞에 점 세 개 ... 를 붙이면 된다.

func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

func main() {
    fmt.Println(sum())             // 0
    fmt.Println(sum(1, 2, 3))      // 6
    fmt.Println(sum(10, 20, 30, 40)) // 100
}

함수 내부에서 nums 는 슬라이스([]int) 처럼 다룬다. range 로 순회할 수 있다.

슬라이스는 11장에서 본격적으로 다룬다. 지금은 “여러 값을 한 변수로 받는다” 정도만 알면 충분하다.

슬라이스 펼쳐 넘기기

이미 슬라이스를 가지고 있는데 가변 인자 함수에 그걸 넘기고 싶다면 호출할 때 ... 를 변수 뒤에 붙인다.

xs := []int{1, 2, 3, 4}
fmt.Println(sum(xs...)) // 10

xs... 가 없으면 컴파일 에러가 난다. Go 는 슬라이스를 자동으로 풀어 주지 않는다.

가변 인자는 마지막에 하나만

함수 시그니처에서 가변 인자는 가장 마지막 매개변수여야 하며, 하나만 둘 수 있다.

// OK
func f(prefix string, nums ...int) {}

// 컴파일 에러
func g(nums ...int, suffix string) {}

9.6 defer

defer 는 어떤 호출을 지금 등록하고 함수가 끝날 때 실행 시킨다.

func main() {
    defer fmt.Println("끝")
    fmt.Println("시작")
    fmt.Println("작업 중")
}

실행 결과:

시작
작업 중
끝

defer 가 가장 위에 있지만 출력은 마지막에 나온다. main 이 반환되기 직전에 실행되기 때문이다.

어디에 쓰나

가장 흔한 용도는 “정리(cleanup) 작업” 이다.

  • 파일 열고 닫기
  • 락 잡고 풀기
  • 네트워크 연결 열고 닫기
  • 자원 빌리고 반납하기

이런 작업은 “여는 부분” 바로 옆에 “닫는 부분” 을 defer 로 같이 적는다. 중간에 어떤 분기로 빠지든 함수만 끝나면 반드시 정리가 호출된다.

func work() {
    f := openFile()
    defer closeFile(f)  // 함수 끝에서 무조건 닫힘

    // 중간에 return, 에러로 빠져도 closeFile 은 호출된다
    process(f)
}

실제 파일 닫기는 29장에서, 락 해제는 23장에서 다시 만난다. 여기서는 defer 의 동작 원리만 익혀 두자.

LIFO 순서

여러 defer 가 쌓이면 가장 나중에 등록된 것부터 실행된다. 스택 구조(LIFO)다.

func main() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
}

실행 결과:

3
2
1

여러 자원을 순서대로 열었을 때 역순으로 닫는 것이 자연스럽다는 점과 맞아 떨어진다.

평가 시점은 등록할 때, 실행은 함수 끝

defer 가 받는 함수 호출의 인자defer 를 만나는 그 순간 평가된다. 실제 호출만 함수 끝으로 미뤄진다.

func main() {
    x := 10
    defer fmt.Println("x =", x) // 여기서 x 가 10 으로 캡처됨
    x = 99
}

실행 결과:

x = 10

x 가 99 로 바뀌었지만, 출력은 defer 가 등록되던 시점의 10 이다. “인자는 그 자리에서 굳고, 실행만 미뤄진다” 고 기억하면 된다.


9.7 익명 함수와 클로저

함수에 꼭 이름을 붙여야 하는 것은 아니다. 이름 없이 그 자리에 함수를 만들어 바로 쓰는 것을 익명 함수 라 부른다.

정의 후 즉시 호출

func main() {
    func() {
        fmt.Println("이름 없이 호출")
    }()
}

마지막의 () 가 호출 부분이다. 정의하자마자 부른 것이다.

변수에 함수를 담기

함수는 그 자체가 하나의 값이다. 변수에 넣어 둘 수 있고, 다른 함수에 넘길 수도 있다.

func main() {
    add := func(a, b int) int {
        return a + b
    }

    fmt.Println(add(3, 4)) // 7
}

add 의 타입은 func(int, int) int 다. 이런 함수 타입을 매개변수로 받는 함수도 만들 수 있다.

func apply(f func(int) int, x int) int {
    return f(x)
}

func main() {
    double := func(n int) int { return n * 2 }
    fmt.Println(apply(double, 5)) // 10
}

클로저: 바깥 변수를 캡처한다

익명 함수가 자신을 둘러싼 함수의 변수를 계속 들고 있을 수 있다. 이렇게 바깥 변수를 붙잡고 있는 함수를 클로저(closure) 라 부른다.

간단한 카운터를 만들어 본다.

func makeCounter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

func main() {
    c := makeCounter()
    fmt.Println(c()) // 1
    fmt.Println(c()) // 2
    fmt.Println(c()) // 3
}

makeCounter 의 지역 변수 count 는 보통이라면 함수가 끝나는 순간 사라진다. 하지만 안에서 만든 익명 함수가 count 를 잡고 있어서 함수가 반환된 뒤에도 살아남는다.

호출할 때마다 같은 count 가 1 씩 증가한다.

각 클로저는 자기 변수를 가진다

makeCounter 를 다시 부르면 완전히 새로운 count 가 만들어진다.

c1 := makeCounter()
c2 := makeCounter()

fmt.Println(c1()) // 1
fmt.Println(c1()) // 2
fmt.Println(c2()) // 1  (c2 의 count 는 따로)
fmt.Println(c1()) // 3

클로저는 “함수 + 캡처한 변수” 의 묶음이다. 같은 함수를 두 번 호출해 만든 두 클로저는 서로 다른 환경을 가진다.

클로저는 강력하지만, 어떤 변수를 캡처했는지를 머릿속에 그리고 있어야 한다. 특히 for 루프 안에서 클로저를 만들 때 함정이 있다. 동시성 코드에서도 자주 문제가 되므로 22장에서 다시 정리한다.


9.8 정리

이 장에서 살펴본 내용:

  • 함수는 func 이름(매개변수) 반환타입 { ... }
  • 같은 타입 매개변수는 묶어서 한 번만 타입 표기 가능
  • 다중 반환값으로 결과와 상태를 함께 돌려준다
  • _ 로 필요 없는 반환값을 무시한다
  • 명명된 반환값과 naked return 은 짧은 함수에 한정
  • 가변 인자 ...T 와 슬라이스 펼치기 xs...
  • defer 는 함수 종료 시 실행, LIFO, 인자는 등록 시점에 평가
  • 함수도 값이다. 익명 함수와 클로저로 동작을 변수처럼 다룬다

함수를 자유롭게 다루게 됐으니 이제 다음 질문이 자연스럽다. “여기 선언한 변수는 어디까지 살아 있는가?”

다음 장은 변수의 범위(scope)다. 패키지 / 함수 / 블록 세 단계의 범위, 변수 가리기(shadowing) 와 := 의 함정, 그리고 전역 변수를 자제해야 하는 이유까지 다룬다.

10장. 변수의 범위

함수를 자유롭게 쓸 수 있게 됐다. 그러자 새로운 질문이 따라온다.

함수 안에서 만든 변수는 함수 밖에서도 보이나? if 블록 안에서 만든 변수는 그 블록을 벗어나면 어떻게 되나? 같은 이름의 변수를 안과 밖에 모두 두면 어느 쪽이 우선인가?

이 답을 묶어서 변수의 범위(scope) 라고 부른다.

이 장의 목표:

  • 변수의 범위라는 개념을 한 줄로 설명할 수 있다
  • 패키지 / 함수 / 블록 세 단계의 범위를 구분한다
  • 변수 가리기(shadowing) 가 무엇인지 안다
  • := 가 일으키는 흔한 함정을 피한다
  • 전역 변수를 함부로 쓰지 않는 이유를 안다

10.1 변수의 범위(scope)란

변수의 범위란 한 마디로 그 이름이 가리키는 변수가 보이는 영역 이다.

func main() {
    x := 10
    fmt.Println(x) // OK
}

fmt.Println(x) // 컴파일 에러: x 가 없음

xmain 함수 안에서만 존재한다. 함수가 끝나면 사라지고, 함수 바깥에서는 보이지 않는다.

Go 에서 범위는 중괄호 { } 단위로 결정된다. 중괄호로 둘러싸인 영역이 하나의 블록이 되고, 그 안에서 선언된 변수는 그 블록의 범위를 가진다.

이 단순한 규칙 위에 세 가지 단계가 쌓여 있다.


10.2 세 가지 범위

패키지 수준 (전역 변수)

함수 밖에 선언한 변수는 같은 패키지의 어떤 함수에서도 접근할 수 있다. 보통 “전역 변수” 라고 부른다.

package main

import "fmt"

var greeting = "Hello"

func sayHi() {
    fmt.Println(greeting)
}

func sayBye() {
    fmt.Println(greeting, "and bye")
}

func main() {
    sayHi()
    sayBye()
}

greetingsayHi, sayBye, main 어디서나 보인다. 같은 디렉터리의 다른 .go 파일에서도 같은 패키지라면 접근할 수 있다.

다른 패키지에서도 보이게 하려면 이름의 첫 글자를 대문자로 쓴다 (Greeting). 패키지 간 export 규칙은 20장에서 자세히 다룬다.

패키지 수준 변수는 := 로 선언할 수 없다. 반드시 var 또는 const 로 적는다.

// OK
var count = 0
const limit = 100

// 컴파일 에러
count := 0

함수 수준 (지역 변수)

함수 안에서 선언한 변수는 그 함수 안에서만 살아 있다.

func work() {
    msg := "내부 메시지"
    fmt.Println(msg)
}

func main() {
    work()
    fmt.Println(msg) // 컴파일 에러: msg 가 없음
}

함수가 호출될 때마다 새로운 지역 변수들이 만들어지고, 함수가 끝나면 사라진다.

블록 수준 ({ } 안)

함수 안이라도 더 좁은 범위가 있다. if, for, switch 의 본문도 각자 하나의 블록이고, 그 안에서 선언한 변수는 그 블록 안에서만 보인다.

func main() {
    if x := 10; x > 0 {
        fmt.Println(x) // OK
    }
    fmt.Println(x) // 컴파일 에러
}

if 의 조건 앞에 선언한 xif ~ else 블록 전체에서만 살아 있다. 바깥에서는 같은 이름의 변수가 따로 있어야 한다.

for 도 마찬가지다.

for i := 0; i < 3; i++ {
    fmt.Println(i)
}
fmt.Println(i) // 컴파일 에러

루프 변수 i 는 for 블록 안에서만 보인다.

심지어 그냥 중괄호를 열기만 해도 새 블록이 된다.

func main() {
    {
        msg := "안쪽 블록"
        fmt.Println(msg)
    }
    fmt.Println(msg) // 컴파일 에러
}

이 패턴은 자주 쓰진 않지만, “변수 범위를 일부러 좁히고 싶을 때” 쓸 수 있다.

세 단계 한눈에

범위선언 위치보이는 영역
패키지 수준함수 밖같은 패키지 전체
함수 수준함수 본문 시작부그 함수 안
블록 수준{ }그 블록 안

10.3 함수 매개변수의 범위

함수 매개변수도 결국 변수다. 범위는 함수 본문 전체 다.

func greet(name string) {
    fmt.Println("Hello,", name)
}

namegreet 안에서만 보인다. greet 가 끝나면 사라진다.

매개변수는 복사된다

Go 의 매개변수는 기본적으로 값 복사 다. 함수 안에서 매개변수를 바꿔도 호출한 쪽의 원본은 영향을 받지 않는다.

func bump(n int) {
    n = n + 1
    fmt.Println("안:", n)
}

func main() {
    x := 10
    bump(x)
    fmt.Println("밖:", x)
}

실행 결과:

안: 11
밖: 10

bump 안의 nx 의 복사본이다. 복사본을 고친 것이지 x 자체를 고친 것은 아니다.

함수 안에서 호출자의 변수를 진짜로 바꾸고 싶다면 포인터 를 넘겨야 한다. 포인터는 14장에서 다룬다. 지금은 “매개변수는 복사된다” 만 기억해 두자.


10.4 변수 가리기 (shadowing)

같은 이름의 변수를 바깥 블록과 안쪽 블록 에 동시에 두면 어떻게 될까?

func main() {
    x := 10
    fmt.Println("바깥:", x)

    {
        x := 99
        fmt.Println("안쪽:", x)
    }

    fmt.Println("다시 바깥:", x)
}

실행 결과:

바깥: 10
안쪽: 99
다시 바깥: 10

안쪽 블록의 x 는 새로 만들어진 별개의 변수다. 바깥의 x 는 그대로 10 으로 살아 있고, 안쪽 블록이 끝나는 순간 안쪽 x 는 사라진다.

이렇게 안쪽 변수가 같은 이름의 바깥 변수를 가려 버리는 것을 변수 가리기(shadowing) 라고 부른다.

if / for 안에서 의도치 않게

문제는 의도하지 않은 shadowing 이다.

func main() {
    err := setup()
    if err != nil {
        return
    }

    if v, err := compute(); err == nil { // err 가 새로 만들어짐
        fmt.Println(v)
    }

    fmt.Println(err) // 어떤 err? 바깥의 err, 즉 setup 의 결과
}

if v, err := compute(); ...err 는 조건의 짧은 명령문이라서 새로운 변수 다. 바깥의 err 와는 다른 변수다.

코드를 빨리 읽으면 마지막 errcompute() 의 결과처럼 보이지만, 실제로는 setup() 의 결과다. 이런 혼동이 실제로 자주 일어난다.


10.5 := 의 함정

shadowing 의 단골 원인이 := 다. 원리를 짧게 정리해 둔다.

  • var x int 는 항상 새 변수를 만든다
  • x = 10 은 이미 있는 x 에 값을 넣는다
  • x := 10그 블록 안에 같은 이름의 변수가 없으면 새로 만들고, 있으면 그대로 쓴다… 가 아니다. 이미 그 블록 안에 같은 이름이 있으면 에러 다.

핵심은 “그 블록 안에” 라는 부분이다. 바깥 블록에 같은 이름이 있어도, 새로운 안쪽 블록에서 := 를 쓰면 새 변수가 만들어진다.

func main() {
    x := 10
    {
        x := 20  // 안쪽 블록의 새 변수
        fmt.Println(x) // 20
    }
    fmt.Println(x) // 10
}

다중 반환에서의 미묘함

가장 헷갈리는 케이스가 다중 반환의 := 다.

func main() {
    n, err := strconv.Atoi("10")
    if err != nil { /* ... */ }

    n, err := strconv.Atoi("20") // ?
}

위 코드는 컴파일 에러다. n, err 둘 다 이미 같은 블록에 있기 때문이다. 이 경우엔 = 를 써야 한다.

n, err = strconv.Atoi("20")

그런데 다중 반환의 := 는 특이한 규칙이 하나 있다.

왼쪽 이름들 중에 하나라도 새 변수 면, 같은 블록 안이라도 := 가 허용된다. 이미 있는 이름은 단순 대입처럼 동작한다.

예:

n, err := strconv.Atoi("10")
m, err := strconv.Atoi("20")  // OK: m 은 새 변수, err 는 기존 변수에 대입

문제는 이 규칙이 블록이 다를 때 와 어우러지면 조용한 shadowing 을 만든다는 점이다.

err := setup()

if cond {
    n, err := compute()  // 여기서 err 는 새 변수 (블록이 다름)
    _ = n
    if err != nil {
        return
    }
}

// 여기 err 는 setup() 의 결과
// compute() 의 err 는 if 블록을 벗어나며 버려졌다

위 코드는 컴파일도 잘 되고 실행도 잘 된다. 하지만 compute() 의 에러는 처리된 적이 없다. 조용히 묻혔다.

어떻게 피하나

  • 안쪽 블록에서도 같은 변수를 쓰려면 = 를 의식적으로 쓴다.
  • 변수 이름을 일부러 다르게 짓는다 (err, errComp).
  • 도구의 도움을 받는다.

마지막 항목이 중요하다. Go 표준 도구인 go vet 에는 shadowing 을 잡아 주는 분석기가 있었다. 요즘은 별도 도구로 분리되어 있지만, 린터 묶음(golangci-lint) 에 포함된 shadow 분석기로 흔히 검사한다.

golangci-lint run --enable=shadow

이런 도구를 처음부터 켜 두는 습관을 추천한다. 사람이 눈으로 잡기 가장 어려운 종류의 버그다.


10.6 전역 변수를 자제해야 하는 이유

패키지 수준 변수는 편리해 보인다. 어디서든 접근할 수 있고, 인자로 매번 넘기지 않아도 된다.

하지만 코드가 조금만 커지면 빠르게 문제가 된다.

추적이 어렵다

전역 변수의 값은 패키지 어디에서나 바뀔 수 있다. 버그가 났을 때

“이 값이 왜 이렇게 됐지?”

를 알려면 패키지의 모든 함수를 들춰 봐야 한다. 함수 인자로 들어오는 값은 호출 지점만 보면 되지만, 전역 변수는 그렇지 않다.

테스트가 어렵다

테스트는 보통 “이 함수에 이 입력을 주면 이 결과가 나온다” 형태로 짠다. 함수가 전역 변수를 참조하고 있으면 입력만으로 결과가 결정되지 않는다.

  • 테스트 실행 순서에 따라 결과가 달라진다
  • 다른 테스트가 전역 상태를 오염시키면 같이 깨진다
  • 테스트마다 전역 변수를 초기화/복원하는 코드가 늘어난다

테스트는 32장에서 본격적으로 다룬다. 지금은 “전역 변수가 늘면 테스트가 어려워진다” 정도만 기억하자.

동시성에서 위험하다

여러 고루틴이 동시에 전역 변수를 읽고 쓰면 경쟁 조건(race condition)이 생길 수 있다. 값이 중간에 깨지거나, 한쪽의 변경이 다른 쪽에 안 보인다.

이 문제는 매우 복잡하고 디버깅도 어렵다. 9부(22~25장) 에서 동시성을 다룰 때 자세히 본다.

그럼 언제 써도 되나

전부 금지하라는 뜻은 아니다. 다음 같은 경우는 자연스럽다.

  • const 로 선언한 상수 (값이 바뀌지 않으니 안전)
  • 패키지 전체에 한 번만 만들어지는 객체
    • 예: 로거, 설정, DB 커넥션 풀
    • 보통 변수가 아니라 함수로 노출한다
  • 매우 작은 유틸 패키지의 내부 캐시 등

기준은 단순하다.

자주 바뀌는 값은 전역 변수에 두지 않는다.

자주 바뀐다면 함수 인자로 넘기거나 구조체에 담아 다닌다 (13장).


10.7 정리

이 장에서 살펴본 내용:

  • 변수의 범위는 그 변수가 보이는 영역이다
  • Go 는 중괄호 { } 단위로 범위가 갈린다
  • 세 단계: 패키지 / 함수 / 블록
  • if, for, switch 의 본문은 각자 하나의 블록
  • 함수 매개변수는 복사돼서 함수 내부에서만 산다
  • 같은 이름을 안쪽에서 다시 선언하면 변수 가리기가 일어난다
  • 특히 := 다중 반환과 블록의 조합은 조용한 shadowing 의 원인
  • 린터 (golangci-lintshadow) 로 미리 잡는다
  • 전역 변수는 추적, 테스트, 동시성에서 모두 비싸다. 꼭 필요할 때만 쓴다

여기까지가 “기본 흐름과 함수 모양” 이다. 이제 데이터를 좀 더 본격적으로 묶어 다룰 차례다.

다음 장(11장) 부터는 여러 데이터를 묶는 자료구조 다. 배열과 슬라이스, 맵, 구조체로 이어진다. 지금까지 배운 for range, 함수, 범위 개념이 하나하나 다시 등장한다.

11장. 배열과 슬라이스

지금까지 다룬 변수는 값 하나만 담는 그릇이었다. 이번 장부터는 여러 값을 묶어서 다루는 방법을 배운다.

Go 에는 비슷해 보이는 두 형제가 있다.

  • 배열 (array) — 고정 길이
  • 슬라이스 (slice) — 가변 길이

이론상 둘 다 “값을 줄 세워 담는다” 는 점은 같다. 하지만 실제로 Go 개발자가 거의 매일 쓰는 건 슬라이스 쪽이다. 배열은 그 슬라이스를 이해하기 위한 발판으로 짧게 짚고 넘어간다.

목표:

  • 배열과 슬라이스가 어떻게 다른지 한 문장으로 말하기
  • 슬라이스를 자유롭게 만들고, 늘리고, 자르기
  • lencap 의 차이 이해하기
  • append, copy, for range 를 손에 익히기

11.1 배열

배열은 같은 타입의 값을 정해진 개수만큼 한 줄로 담는 자료형이다.

var a [5]int

이 한 줄은 “정수 5개를 담는 그릇 a” 를 만든다. 값을 따로 넣어 주지 않았기 때문에 다섯 칸 모두 정수의 제로값 0 으로 채워진다.

인덱스로 접근

각 칸은 0번부터 시작하는 인덱스로 가리킨다.

a[0] = 10
a[4] = 99
fmt.Println(a)      // [10 0 0 0 99]
fmt.Println(a[0])   // 10

범위 밖 인덱스를 쓰면 컴파일 또는 런타임 에러가 난다.

fmt.Println(a[5])  // panic: index out of range

길이가 타입의 일부다

여기가 배열의 가장 중요한 특징이자 함정이다.

var a [5]int
var b [10]int

ab 는 둘 다 정수 배열이지만, 타입이 서로 다르다.

  • a 의 타입은 [5]int
  • b 의 타입은 [10]int

대입도 불가능하다.

a = b  // 컴파일 에러: cannot use b (type [10]int) as type [5]int

함수에 넘길 때도 길이가 정확히 맞아야 한다. “정수 배열을 받는 함수” 가 아니라 “길이 5짜리 정수 배열을 받는 함수” 가 되는 셈이다.

이게 너무 빡빡하기 때문에 실무에서 배열을 직접 쓰는 일은 거의 없다.

배열 리터럴

값을 미리 정해서 만들 수도 있다.

nums := [3]int{1, 2, 3}

길이를 일일이 세기 귀찮다면 ... 을 써서 컴파일러에게 맡긴다.

nums := [...]int{10, 20, 30, 40}
fmt.Println(len(nums))  // 4

[...]int 는 “길이는 알아서 세 줘” 라는 뜻이다. 결과 타입은 여전히 [4]int 처럼 길이가 박힌 배열이다.

배열은 값이다

마지막으로 알아 둘 점. 배열을 다른 변수에 대입하면 통째로 복사된다.

a := [3]int{1, 2, 3}
b := a
b[0] = 999

fmt.Println(a)  // [1 2 3]
fmt.Println(b)  // [999 2 3]

b 를 바꿔도 a 는 그대로다. 이 동작은 슬라이스와 다르므로 잘 기억해 둔다.

배열에 대해선 이 정도면 충분하다. 이제 Go 의 주력 자료구조인 슬라이스로 넘어간다.


11.2 슬라이스: Go 의 주력 자료구조

슬라이스는 길이가 자유롭게 변하는 시퀀스다. 다른 언어의 동적 배열, ArrayList, 리스트와 비슷하다.

선언과 초기화

가장 흔한 세 가지 방법.

// 1. 리터럴로 바로 만들기
nums := []int{1, 2, 3}

// 2. make 로 길이만 지정해서 만들기
zeros := make([]int, 5)
// → [0 0 0 0 0]

// 3. make 로 길이와 용량 함께 지정
buf := make([]int, 0, 10)
// 길이 0, 용량 10

배열과 어떻게 다른지 한눈에 보이는 차이는 대괄호 안에 숫자가 없다는 점이다.

[5]int  // 배열: 길이 5
[]int   // 슬라이스: 길이 정해지지 않음

슬라이스의 내부 구조

슬라이스는 마법이 아니다. 내부적으로는 세 개의 값을 묶은 작은 구조다.

필드의미
포인터실제 데이터가 들어 있는 배열의 시작 위치
길이 (len)지금 보이는 원소의 개수
용량 (cap)다시 할당 없이 늘릴 수 있는 최대 개수
슬라이스 ──▶ [ * ] [ 3 ] [ 5 ]
              │
              ▼
            [ 1, 2, 3, _, _ ]   ← 실제 배열

즉, 슬라이스 그 자체는 “데이터를 가리키는 작은 손잡이” 다. 값을 담고 있는 진짜 배열은 따로 있다.

이 모델만 머릿속에 그려 두면 앞으로 나오는 len, cap, append, 슬라이싱이 자연스럽게 이해된다.


11.3 len 과 cap

내장 함수 두 개로 슬라이스의 상태를 들여다본다.

s := make([]int, 3, 10)

fmt.Println(len(s))  // 3
fmt.Println(cap(s))  // 10
  • len(s) — 지금 사용 중인 원소 수
  • cap(s) — 새 배열을 만들지 않고 담을 수 있는 최대치

처음엔 헷갈리기 쉬우니 비유로 본다.

len 은 가방 안에 든 책 권수 cap 은 가방 안에 들어갈 수 있는 최대 권수

가방이 꽉 차기 전까진 책을 더 넣을 수 있다. 넘치면 더 큰 가방으로 옮겨야 한다. 이 “옮기는 동작” 이 다음 절의 append 에서 일어난다.

만든 방식lencap
[]int{1,2,3}33
make([]int, 5)55
make([]int, 0, 10)010
make([]int, 3, 10)310

11.4 append: 원소 추가

슬라이스에 새 값을 더할 땐 내장 함수 append 를 쓴다.

s := []int{1, 2, 3}
s = append(s, 4)
fmt.Println(s)  // [1 2 3 4]

겉으로는 단순하지만, 내부에서는 두 가지 분기가 있다.

  1. 용량(cap)에 여유가 있다 → 같은 배열의 빈 칸에 그냥 채운다
  2. 용량이 부족하다 → 더 큰 배열을 새로 만들고 기존 값을 복사한 뒤 추가한다

그래서 append 는 반드시 반환값을 다시 받아야 한다.

// 옳은 사용
s = append(s, 4)

// 잘못된 사용 — 새 배열이 만들어졌다면 s 는 그대로 옛 데이터를 본다
append(s, 4)  // 컴파일러가 "값을 안 쓴다" 고 경고하기도 한다

여러 개를 한 번에

append 는 원소를 여러 개 받을 수 있다.

s := []int{1}
s = append(s, 2, 3, 4)
fmt.Println(s)  // [1 2 3 4]

슬라이스끼리 합치기

다른 슬라이스를 통째로 붙일 땐 ... 을 붙인다.

a := []int{1, 2, 3}
b := []int{4, 5, 6}

c := append(a, b...)
fmt.Println(c)  // [1 2 3 4 5 6]

b... 는 “b 의 원소들을 풀어서 인자로 넘긴다” 는 의미다. 9장의 가변 인자와 같은 문법이다.

append 의 성능 감각

append 가 새 배열을 만드는 일은 비용이 크다. 하지만 Go 는 한 번 늘릴 때 용량을 두 배 가까이 잡아 둔다. 그래서 평균적으로 보면 매우 빠르다.

미리 크기를 알 수 있다면 make([]T, 0, n) 으로 용량을 잡아 두는 것이 좋다. 자세한 메모리 이야기는 26장에서 다룬다.


11.5 슬라이싱

이미 있는 슬라이스(또는 배열)에서 “일부 구간만 잘라낸 슬라이스” 를 만들 수 있다.

s := []int{10, 20, 30, 40, 50}

mid := s[1:4]
fmt.Println(mid)  // [20 30 40]

s[1:4] 는 “인덱스 1부터 4 직전까지” 를 의미한다. 끝쪽 4는 포함되지 않는 점에 주의한다.

한쪽을 생략

양쪽 끝은 생략할 수 있다.

s[:3]   // 처음부터 인덱스 3 직전까지
s[2:]   // 인덱스 2부터 끝까지
s[:]    // 전체
표현결과
s[1:4][20 30 40]
s[:3][10 20 30]
s[2:][30 40 50]
s[:][10 20 30 40 50]

슬라이싱은 복사가 아니다

이게 슬라이스에서 가장 잘 모르고 지나가는 부분이다. 잘라낸 슬라이스는 원본과 같은 배열을 공유한다.

s := []int{1, 2, 3, 4, 5}
m := s[1:4]
m[0] = 999

fmt.Println(s)  // [1 999 3 4 5]
fmt.Println(m)  // [999 3 4]

m 을 건드렸을 뿐인데 s 까지 같이 바뀐 모습이 보인다. 손잡이만 두 개일 뿐, 안쪽 배열은 한 덩어리이기 때문이다.

이 동작은 강력하지만 메모리 측면에선 함정이 되기도 한다. “부분 슬라이스가 거대한 원본 배열을 붙잡고 놓아주지 않는” 패턴이 대표적이다. 자세한 이야기와 copy 로 끊어내는 기법은 26장에서 다룬다.


11.6 copy

두 슬라이스 사이에서 값을 복사할 땐 내장 함수 copy 를 쓴다.

src := []int{1, 2, 3}
dst := make([]int, 3)

n := copy(dst, src)
fmt.Println(dst)  // [1 2 3]
fmt.Println(n)    // 3

copy(dst, src)

  • dst 에 src 의 값을 복사한다
  • 실제로 복사된 개수를 반환한다

짧은 쪽 길이만큼만

두 슬라이스의 길이가 다르면, 짧은 쪽 길이만큼만 복사된다.

dst := make([]int, 2)
src := []int{1, 2, 3, 4, 5}

n := copy(dst, src)
fmt.Println(dst)  // [1 2]
fmt.Println(n)    // 2

이 동작 덕분에 버퍼 크기를 넘는 데이터를 잘라 받기 편하다.

append 와의 차이

도구하는 일
append슬라이스를 늘리며 값을 추가한다
copy기존 슬라이스의 칸에 값을 덮어쓴다

copy 는 dst 의 길이를 늘려 주지 않는다. 이미 있는 칸을 채울 뿐이다. “빈 슬라이스에 copy 했더니 아무 일도 안 일어났다” 는 초보의 단골 실수가 여기서 나온다.

var dst []int
src := []int{1, 2, 3}

copy(dst, src)
fmt.Println(dst)  // []  ← dst 의 길이가 0이라 아무것도 못 들어간다

이런 경우엔 append 를 써야 한다.


11.7 슬라이스 순회 (for range)

8장에서 살짝 본 for range 가 이제 본격적으로 빛난다.

인덱스와 값 둘 다

nums := []int{10, 20, 30}

for i, v := range nums {
    fmt.Println(i, v)
}
// 출력:
// 0 10
// 1 20
// 2 30

i 는 인덱스, v 는 그 인덱스의 값이다.

인덱스만 쓰고 싶을 때

값을 안 쓰면 그냥 두 번째 변수를 빼면 된다.

for i := range nums {
    fmt.Println(i)
}

값만 쓰고 싶을 때

인덱스를 안 쓸 땐 자리 표시자 _ 를 쓴다.

for _, v := range nums {
    fmt.Println(v)
}

_ 는 “값을 받기는 하지만 쓰지 않겠다” 는 명시적 표시다. Go 는 안 쓰는 변수를 컴파일 에러로 막기 때문에 이런 자리 표시자가 자주 등장한다.

range 가 주는 v 는 복사본이다

조금 미묘한 포인트.

nums := []int{1, 2, 3}

for _, v := range nums {
    v = v * 10  // 원본은 안 바뀐다
}
fmt.Println(nums)  // [1 2 3]

v 는 원소의 복사본이다. 원본을 바꾸려면 인덱스로 직접 접근해야 한다.

for i := range nums {
    nums[i] *= 10
}
fmt.Println(nums)  // [10 20 30]

11.8 nil 슬라이스

선언만 하고 초기화하지 않은 슬라이스는 어떻게 될까?

var s []int
fmt.Println(s == nil)  // true
fmt.Println(len(s))    // 0
fmt.Println(cap(s))    // 0

이 상태를 nil 슬라이스라고 한다.

  • 값은 nil
  • 길이도 0, 용량도 0
  • 안에 가리키는 배열이 아직 없는 상태

여기서 흥미로운 점.

nil 슬라이스에 append 해도 된다

var s []int

s = append(s, 1)
s = append(s, 2)
fmt.Println(s)  // [1 2]

append 는 nil 슬라이스를 받으면 새 배열을 알아서 만들어 첫 원소를 넣는다. 다른 언어처럼 “비어 있는 리스트인지 먼저 확인” 같은 방어 코드가 거의 필요하지 않다.

nil 슬라이스 vs 빈 슬라이스

비슷하지만 미묘하게 다르다.

var a []int           // nil 슬라이스
b := []int{}          // 빈 슬라이스 (nil 아님)

fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(len(a))   // 0
fmt.Println(len(b))   // 0

대부분의 코드에서는 둘이 똑같이 동작한다. 실용적으로는 “둘 다 길이 0인 슬라이스” 로 묶어서 이해하면 된다.

12장에서 다룰 맵의 nil 동작과는 다르다. 맵의 nil 은 함정이 많다.


11.9 정리

이 장에서 살펴본 내용:

  • 배열은 길이가 타입에 박힌 고정 시퀀스다
    • 잘 안 쓰이지만 슬라이스 이해의 발판
  • 슬라이스는 (포인터, 길이, 용량) 세 값을 가진 손잡이다
  • len 은 현재 길이, cap 은 재할당 없이 담을 수 있는 최대치
  • append 는 반환값을 반드시 다시 받아야 한다
  • 슬라이싱은 새 배열을 만드는 게 아니라 원본을 공유한다
  • copy 는 짧은 쪽 길이만큼만 복사한다
  • for range 로 인덱스, 값, 또는 둘 다 받을 수 있다
  • nil 슬라이스도 append 가 가능하다

슬라이스는 Go 코드의 기본 단위라고 봐도 좋다. 앞으로 거의 모든 챕터에서 다시 만나게 된다.

다음 장에서는 또 다른 핵심 자료구조인 을 다룬다. 키로 값을 찾는 자료구조가 어떻게 생겼는지 살펴본다.

12장. 맵

11장의 슬라이스는 “인덱스 번호로 값을 찾는” 자료구조였다. 하지만 실제 프로그램에서는 인덱스가 아니라 “이름” 이나 “ID” 같은 키로 값을 찾고 싶을 때가 훨씬 많다.

  • 사용자 이름 → 사용자 정보
  • 상품 코드 → 가격
  • 단어 → 등장 횟수

이런 경우에 쓰는 자료구조가 맵 (map) 이다. 다른 언어에서는 사전 (dictionary), 해시맵 (HashMap), 연관 배열 (associative array) 같은 이름으로도 불린다.

목표:

  • 맵을 선언하고 값을 넣고 꺼내기
  • 키가 있는지 안전하게 확인하기
  • 맵을 순회하기
  • nil 맵의 함정 피하기

12.1 맵이란

맵은 키-값 쌍을 모아 두는 자료구조다.

"alice" ──▶ 30
"bob"   ──▶ 25
"carol" ──▶ 42

키를 던지면 해당하는 값이 빠르게 돌아온다. 값을 찾는 데 드는 시간은 맵의 크기와 거의 무관하다. 원소가 백 개든 백만 개든 비슷한 속도라는 뜻이다.

키와 값의 타입

타입은 두 개를 지정한다.

  • 키 타입 — 무엇으로 찾을지
  • 값 타입 — 무엇이 저장되는지
map[string]int
//   └ 키    └ 값

위 타입은 “문자열 키로 정수 값을 찾는 맵” 이다.

키 타입의 조건

키는 아무 타입이나 될 수 없다. 비교 가능한 타입 만 키가 될 수 있다.

가능한 키 타입의 예:

  • string
  • int, float64 같은 숫자 타입
  • bool
  • 비교 가능한 필드들로만 이뤄진 구조체

불가능한 키 타입의 예:

  • 슬라이스 []int
  • 맵 자체
  • 함수

“비교 가능” 이란 == 연산자를 쓸 수 있다는 뜻이다. 슬라이스는 == 로 비교할 수 없기 때문에 키가 될 수 없다. 구조체 비교에 대해선 13장에서 자세히 다룬다.


12.2 선언과 초기화

세 가지 방법을 차례로 본다.

1. var 로 선언만 — nil 맵

var m map[string]int
fmt.Println(m == nil)  // true

이 상태의 맵은 nil 이다. 읽기는 가능하지만 쓰기는 패닉을 일으킨다.

fmt.Println(m["a"])  // 0 (제로값, 안전)
m["a"] = 1           // panic: assignment to entry in nil map

초보가 가장 흔히 만나는 함정이라 12.6 절에서 따로 강조한다.

2. make 로 만들기

비어 있는 사용 가능한 맵을 만들 땐 make 를 쓴다.

m := make(map[string]int)

m["alice"] = 30
m["bob"] = 25

fmt.Println(m)  // map[alice:30 bob:25]

이 시점부터 m 은 쓸 준비가 끝난 상태다. nil 이 아니다.

3. 맵 리터럴

처음부터 값을 채워서 만들 수도 있다.

ages := map[string]int{
    "alice": 30,
    "bob":   25,
    "carol": 42,
}

각 줄 끝에 쉼표가 필요하다. 마지막 항목 뒤에도 쉼표를 붙여야 한다 (gofmt 가 강제한다).

빈 맵도 리터럴로 만들 수 있다.

m := map[string]int{}

make(map[string]int) 과 사실상 같은 효과다.

방법결과
var m map[string]intnil 맵 (쓰기 불가)
m := make(map[string]int)사용 가능한 빈 맵
m := map[string]int{}사용 가능한 빈 맵
m := map[string]int{"a":1}초기 데이터 있는 맵

12.3 값 추가, 조회, 삭제

기본 세 가지 동작은 모두 한 줄로 끝난다.

추가와 수정

m := make(map[string]int)

m["alice"] = 30   // 추가
m["alice"] = 31   // 수정

키가 없으면 새로 만들고, 있으면 덮어쓴다. “있는지 확인 후 추가” 같은 분기를 따로 할 필요가 없다.

조회

age := m["alice"]
fmt.Println(age)  // 31

여기에 한 가지 의외의 동작이 있다.

없는 키를 조회하면?

에러가 나지 않는다. 대신 값 타입의 제로값 이 돌아온다.

m := map[string]int{"alice": 30}

fmt.Println(m["unknown"])  // 0

값 타입이 int 라서 0 이 돌아왔다. 값 타입이 string 이면 빈 문자열 "" 이 돌아온다.

이건 편리하지만 위험할 수도 있다. “진짜로 0 이 저장돼 있는 키” 와 “키 자체가 없는 경우” 를 구별하지 못하기 때문이다.

그래서 다음 절의 v, ok 형태가 필요해진다.

삭제

내장 함수 delete 를 쓴다.

delete(m, "alice")

키가 없어도 에러가 나지 않는다. “있으면 지우고, 없으면 그냥 두라” 는 안전한 동작이다.


12.4 키 존재 여부 확인

값 조회에 두 번째 반환값을 받으면 키가 실제로 있었는지 확인할 수 있다.

v, ok := m["alice"]
  • v — 값 (없으면 제로값)
  • ok — 키가 실제로 있었는지 (true/false)

흔한 패턴

if v, ok := m["alice"]; ok {
    fmt.Println("alice 의 나이는", v)
} else {
    fmt.Println("alice 정보가 없습니다")
}

8장에서 본 if 의 짧은 선언 형식과 결합되어 한 줄에 “조회 + 존재 확인 + 분기” 가 모두 들어간다. Go 코드에서 매우 자주 보게 되는 모양이다.

v 와 ok 의 조합 정리

상황vok
키가 있고 값이 3030true
키가 있고 값이 00true
키가 없음0 (제로값)false

값으로 0 이 들어 있는 경우와 키가 없는 경우를 구별할 수 있다는 게 핵심이다.


12.5 맵 순회 (for range)

11장의 for range 가 여기서 또 등장한다. 맵에서는 인덱스 대신 키가 나온다.

ages := map[string]int{
    "alice": 30,
    "bob":   25,
    "carol": 42,
}

for k, v := range ages {
    fmt.Println(k, v)
}

k 가 키, v 가 값이다.

키만, 값만

키만 필요하면 두 번째 변수를 빼면 된다.

for k := range ages {
    fmt.Println(k)
}

값만 필요하면 _ 를 쓴다.

for _, v := range ages {
    fmt.Println(v)
}

순회 순서는 매번 다르다

여기서 처음 보면 당황하는 특성이 있다.

for k, v := range ages {
    fmt.Println(k, v)
}

이 코드를 같은 프로그램에서 두 번 실행해도 출력 순서가 다를 수 있다. 실행할 때마다 일부러 섞기 때문이다.

// 1회차
bob 25
alice 30
carol 42

// 2회차
carol 42
bob 25
alice 30

이는 버그가 아니라 의도된 동작이다. 개발자가 “맵의 순서에 의존하지 않게” 만들기 위한 장치다.

왜 일부러 섞는지, 내부 구조와 함께 자세히 보려면 19장에서 다룬다. 지금은 “맵은 순서가 없다” 정도로 받아들이면 충분하다.

정렬된 순서로 순회하고 싶다면

키를 슬라이스로 모아 정렬한 뒤 순회하면 된다.

import "sort"

keys := make([]string, 0, len(ages))
for k := range ages {
    keys = append(keys, k)
}
sort.Strings(keys)

for _, k := range keys {
    fmt.Println(k, ages[k])
}

11장의 make([]T, 0, cap) 패턴이 자연스럽게 쓰인다. 미리 용량을 잡아 두면 append 가 재할당하지 않는다.


12.6 맵의 함정

세 가지만 기억하면 큰 사고는 막을 수 있다.

1. nil 맵에 쓰기 → 패닉

12.2 절에서 본 그 이야기다. 선언만 한 맵은 쓸 수 없다.

var m map[string]int
m["a"] = 1   // panic: assignment to entry in nil map

해결책은 단순하다.

m := make(map[string]int)
m["a"] = 1   // OK

슬라이스의 nil 은 append 가 가능했지만, 맵의 nil 은 쓰기가 불가능하다. 이 비대칭은 외워 두는 편이 빠르다.

2. 동시 접근은 안전하지 않다

여러 고루틴이 같은 맵을 동시에 읽고 쓰면 프로그램이 갑자기 죽거나 데이터가 깨질 수 있다.

// 위험: 여러 고루틴이 동시에 m 에 쓰기
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()

고루틴, 동시성, 그리고 안전하게 공유하는 방법은 22장부터 자세히 다룬다. 19장에서도 맵 내부 구조와 함께 다시 짚는다. 지금은 “혼자 쓰는 맵은 안심해도 되고, 여럿이 동시에 건드릴 거면 보호 장치가 필요하다” 정도로 기억해 둔다.

3. 맵 안의 값은 직접 수정 못 한다

이건 좀 미묘한 함정이다. 맵의 값이 구조체일 때 그 안의 필드를 바로 못 바꾼다.

type Point struct {
    X, Y int
}

m := map[string]Point{
    "a": {1, 2},
}

m["a"].X = 99  // 컴파일 에러

이유는 14~15장의 포인터와 메서드를 배운 뒤에야 완전히 와닿는다. 지금은 우회 방법만 알아 두면 된다.

p := m["a"]
p.X = 99
m["a"] = p

값을 통째로 꺼내 수정하고 다시 넣는 패턴이다.


12.7 정리

이 장에서 살펴본 내용:

  • 맵은 키-값을 모아 두는 자료구조다
  • 키는 비교 가능한 타입이어야 한다
  • var m ... 으로 만든 맵은 nil 이라 쓰기 불가
    • make 또는 리터럴로 만들어야 안전
  • 없는 키 조회는 에러 대신 제로값을 반환한다
  • v, ok := m[key] 로 키 존재 여부를 확인한다
  • delete 로 키를 지울 수 있다 (없어도 안전)
  • for range 로 순회할 때 순서는 매번 달라진다
  • 동시 접근은 따로 보호해야 한다

슬라이스와 맵. 이 두 자료구조만 익숙해져도 대부분의 일상 코드는 작성할 수 있다.

다음 장에서는 여러 값을 하나의 묶음으로 다루는 구조체 (struct) 를 본다. “사용자 한 명” 같은 단위 데이터를 표현하는 핵심 도구다.

13장. 구조체

11장과 12장에서 다룬 슬라이스와 맵은 같은 타입의 값을 줄 세우거나 키-값으로 짝지어 보관하는 도구였다.

그런데 실제 프로그램에서는 서로 다른 타입의 값들이 한 덩어리로 묶여 다녀야 하는 경우가 많다.

  • 사용자 한 명 — 이름(string), 나이(int), 활성(bool)
  • 상품 하나 — 코드(string), 가격(int), 재고(int)
  • 좌표 한 점 — X(int), Y(int)

이런 “한 덩어리짜리 데이터” 를 표현하는 도구가 구조체 (struct) 다. 다른 언어의 record, dataclass, 또는 필드만 있는 객체와 비슷하다.

목표:

  • 구조체를 정의하고 만드는 여러 방법 익히기
  • 필드에 접근하고 수정하기
  • 중첩 구조체와 익명 구조체 다루기
  • 구조체 비교가 가능한 조건 이해하기

13.1 구조체란

구조체는 서로 다른 타입의 필드들을 하나의 이름 아래 묶는 사용자 정의 타입이다.

Person
├── Name string
└── Age  int

이렇게 묶어 두면 “사용자 정보” 라는 한 단위로 다룰 수 있다. 함수 인자로 넘기거나, 슬라이스에 담거나, 맵의 값으로 쓰는 일이 쉬워진다.

type Person struct {
    Name string
    Age  int
}

alice := Person{Name: "Alice", Age: 30}

Person 이라는 새 타입이 생겼고, 그 타입의 값 하나가 alice 다.


13.2 구조체 정의

구조체는 type 키워드와 struct 키워드를 함께 써서 정의한다.

type Person struct {
    Name string
    Age  int
}

읽는 법.

  • type Person ... — “Person 이라는 새 타입을 만든다”
  • struct { ... } — “그 타입은 구조체이고, 안에는 다음 필드들이 있다”

필드 작성 규칙

필드 한 줄에 하나씩 쓰는 것이 표준이다. 이름이 먼저, 타입이 뒤에 온다 (변수 선언과 같은 순서).

type Book struct {
    Title    string
    Author   string
    Pages    int
    InStock  bool
}

같은 타입의 필드 여러 개를 한 줄에 묶을 수도 있다.

type Point struct {
    X, Y int
}

XY 둘 다 int 다.

한 줄짜리 정의

세미콜론으로 구분해서 한 줄에 쓰는 것도 문법적으론 가능하다.

type Person struct { Name string; Age int }

하지만 실무에서는 거의 쓰지 않는다. gofmt 가 여러 줄로 풀어 주기도 한다.


13.3 구조체 초기화

값을 채워 새 구조체를 만드는 방법은 크게 세 가지다.

1. 필드 이름 지정 (권장)

alice := Person{
    Name: "Alice",
    Age:  30,
}

각 필드의 이름과 값을 명시한다.

장점.

  • 어떤 값이 어디로 들어가는지 한눈에 보인다
  • 나중에 필드 순서가 바뀌어도 문제 없다
  • 일부 필드만 채워도 된다 (나머지는 제로값)

대부분의 코드에서 이 방식을 쓴다.

2. 순서대로 (비권장)

이름을 빼고 값만 순서대로 나열할 수 있다.

alice := Person{"Alice", 30}

짧지만 함정이 있다. 필드를 하나 추가하거나 순서를 바꾸면 모든 호출 지점이 깨진다.

// 나중에 Email 필드가 추가되면
type Person struct {
    Name  string
    Age   int
    Email string
}

alice := Person{"Alice", 30}  // 컴파일 에러

좌표처럼 필드가 영원히 두 개일 게 분명한 작은 구조체에서만 조심해서 쓴다.

3. 일부 필드만 (나머지는 제로값)

이름 지정 방식에서는 일부만 채워도 된다.

p := Person{Name: "Alice"}
fmt.Println(p)
// {Alice 0}

Age 를 안 적었기 때문에 int 의 제로값인 0 이 들어간다.

필드 타입제로값
int, float640
string""
boolfalse
슬라이스, 맵, 포인터nil

4장의 제로값 개념이 여기서도 그대로 적용된다.

4. 빈 구조체

값을 하나도 안 채우고 만들 수도 있다.

var p Person       // 모든 필드가 제로값
q := Person{}      // 같은 결과

이 둘은 똑같이 동작한다.


13.4 필드 접근

구조체 안의 필드는 점 . 으로 가져온다.

alice := Person{Name: "Alice", Age: 30}

fmt.Println(alice.Name)  // Alice
fmt.Println(alice.Age)   // 30

대입

같은 문법으로 값을 바꿀 수도 있다.

alice.Age = 31
fmt.Println(alice.Age)  // 31

Println 으로 통째로 출력

구조체 전체를 그대로 출력하면 중괄호 안에 값이 나열된다.

fmt.Println(alice)
// {Alice 31}

필드 이름까지 같이 보고 싶다면 %+v 를 쓴다.

fmt.Printf("%+v\n", alice)
// {Name:Alice Age:31}

7장의 포맷 동사가 여기서 다시 유용해진다.


13.5 중첩 구조체

구조체의 필드 타입은 또 다른 구조체일 수도 있다. 이를 중첩 구조체라고 부른다.

type Person struct {
    Name string
    Age  int
}

type Company struct {
    Name  string
    Owner Person
}

Company 안에 Person 이 통째로 들어 있다.

초기화

리터럴을 중첩해서 쓴다.

acme := Company{
    Name: "Acme",
    Owner: Person{
        Name: "Alice",
        Age:  30,
    },
}

접근

점을 이어서 깊이 들어간다.

fmt.Println(acme.Owner.Name)  // Alice

대입도 같은 방식이다.

acme.Owner.Age = 31
fmt.Println(acme.Owner.Age)   // 31

읽다 보면 자연스러워진다. “acme 의 Owner 의 Name” 처럼 문장 그대로 읽으면 된다.

한 번 더 중첩

깊이를 늘려도 같은 방식이다.

type Address struct {
    City    string
    Country string
}

type Person struct {
    Name    string
    Address Address
}

p := Person{
    Name: "Alice",
    Address: Address{
        City:    "Seoul",
        Country: "KR",
    },
}

fmt.Println(p.Address.City)  // Seoul

Go 에는 “임베딩 (embedding)” 이라는 또 다른 중첩 방식이 있다. 필드 이름을 생략하고 타입만 적는 형태인데, 메서드와 인터페이스 개념이 필요해 15~16장에서 다룬다. 지금은 평범한 필드로서의 중첩만 알면 충분하다.


13.6 익명 구조체

한 번만 쓸 구조체라면 굳이 type 으로 이름을 붙일 필요가 없다. 정의와 사용을 한 줄에 합칠 수 있다.

point := struct {
    X, Y int
}{
    X: 1,
    Y: 2,
}

fmt.Println(point.X, point.Y)  // 1 2

읽는 법.

  • struct { X, Y int } — 타입 정의 (이름 없음)
  • {X: 1, Y: 2} — 그 타입의 값 하나

언제 쓰는가

  • 함수 안에서만 잠깐 쓸 작은 데이터 묶음
  • 테스트 코드에서 케이스마다 다른 모양의 데이터를 표현할 때
  • JSON 응답을 임시로 받을 때 (30장 미리보기)
tests := []struct {
    input    int
    expected int
}{
    {1, 2},
    {2, 4},
    {3, 6},
}

for _, tc := range tests {
    if double(tc.input) != tc.expected {
        fmt.Println("실패:", tc.input)
    }
}

테이블 기반 테스트라고 부르는 흔한 패턴이다. 32장에서 본격적으로 다시 만난다.


13.7 구조체 비교

같은 타입의 구조체 두 개를 == 또는 != 로 비교할 수 있을까?

답은 “필드들이 비교 가능하면 된다” 이다.

비교 가능한 경우

모든 필드가 비교 가능한 타입이라면 구조체 자체도 그대로 비교할 수 있다.

type Point struct {
    X, Y int
}

a := Point{1, 2}
b := Point{1, 2}
c := Point{3, 4}

fmt.Println(a == b)  // true
fmt.Println(a == c)  // false

모든 필드 값이 같아야 같다고 판정된다.

비교 불가능한 경우

필드 중에 슬라이스, 맵, 함수처럼 비교가 불가능한 타입이 하나라도 있으면 구조체 전체도 == 로 비교할 수 없다.

type Bag struct {
    Items []string
}

a := Bag{Items: []string{"a"}}
b := Bag{Items: []string{"a"}}

fmt.Println(a == b)  // 컴파일 에러
// invalid operation: a == b
// (struct containing []string cannot be compared)

이유는 11장과 12장에서 본 그대로다. 슬라이스나 맵은 그 자체가 == 를 지원하지 않는다. 그래서 그걸 품은 구조체도 자동으로 비교 불가 상태가 된다.

맵의 키로 쓸 수 있는가

12장의 “키는 비교 가능한 타입이어야 한다” 와 연결된다.

구조체비교 가능?맵 키로 쓸 수 있나?
Point{X, Y int}
Person{Name string, Age int}
Bag{Items []string}아니오아니오

비교 가능한 구조체는 맵의 키로도 쓸 수 있다는 뜻이다. 좌표를 키로 두는 맵 같은 패턴이 그 덕에 깔끔하게 표현된다.

grid := map[Point]string{
    {0, 0}: "원점",
    {1, 1}: "대각선 한 칸",
}

13.8 정리

이 장에서 살펴본 내용:

  • 구조체는 서로 다른 타입의 필드를 한 덩어리로 묶는다
  • type 이름 struct { ... } 형태로 정의한다
  • 초기화는 필드 이름을 명시하는 방식을 쓰는 게 좋다
    • 일부 필드만 채우면 나머지는 제로값
  • 필드 접근과 수정은 점 . 으로 한다
  • 구조체 안에 구조체를 둘 수 있다 (중첩)
  • 이름 없는 익명 구조체를 즉석에서 만들 수도 있다
  • 비교 가능한 필드만 있다면 구조체 전체를 == 로 비교 가능하다
    • 비교 가능한 구조체는 맵 키로도 쓸 수 있다

슬라이스, 맵, 구조체. 이제 일상적인 데이터 모델을 표현하는 세 가지 도구가 모두 모였다.

하지만 한 가지 답답한 점이 곧 보일 것이다. 구조체를 함수에 넘기면 통째로 복사된다. 큰 구조체를 매번 복사하는 건 비효율적이고, 함수 안에서 값을 바꿔도 호출한 쪽에 반영되지 않는다.

이 문제를 풀기 위해 다음 장에서는 포인터 를 본다. “값을 직접 넘기지 말고 위치만 알려 주는” 방식이다.

14장. 포인터

13장에서 구조체를 만나면서 “여러 값을 묶어 하나로 다룬다” 는 감각을 익혔다. 이제 그 묶음을 함수에 넘기고, 원본을 직접 수정해야 할 일이 생긴다.

이때 등장하는 도구가 포인터다.

포인터는 “값” 이 아니라 “값이 사는 주소” 를 들고 다니는 변수다.

목표:

  • 값 복사와 참조의 차이를 이해한다
  • &* 의 의미를 익힌다
  • 함수에서 원본 값을 바꾸는 방법을 안다
  • Go 포인터가 C 포인터와 어떻게 다른지 안다

14.1 포인터가 왜 필요한가

값 복사라는 기본 동작

Go 에서 변수에 다른 변수를 대입하면 원본의 “값” 이 복사된다.

a := 10
b := a   // a 의 값 10 이 복사됨
b = 20

fmt.Println(a) // 10
fmt.Println(b) // 20

b 를 바꿔도 a 는 그대로다. 완전히 별개의 메모리 공간을 쓰기 때문이다.

함수에 인자를 넘길 때도 똑같다. 함수는 자기 안에서 쓸 복사본을 받는다.

func change(x int) {
    x = 999
}

func main() {
    n := 1
    change(n)
    fmt.Println(n) // 1 (그대로)
}

복사가 부담스러울 때

작은 정수 하나를 복사하는 비용은 사실상 0 이다.

하지만 큰 구조체라면 얘기가 달라진다.

type Report struct {
    Title   string
    Author  string
    Lines   [10000]string
}

이런 값을 함수에 넘길 때마다 10000개의 문자열이 통째로 복사된다. 호출이 잦으면 성능을 깎아먹는다.

원본을 바꾸고 싶을 때

함수 안에서 받은 값을 바꿔도 바깥 변수는 그대로라는 게 더 자주 문제가 된다.

func birthday(p Person) {
    p.Age++  // 복사본만 1 증가
}

p := Person{Name: "둘리", Age: 10}
birthday(p)
fmt.Println(p.Age) // 10 (변하지 않음)

원본을 바꾸려면 어떻게 해야 할까. “값” 대신 “값이 있는 자리” 를 넘기면 된다.

그 “자리” 가 바로 주소이고, 주소를 담는 변수가 포인터다.


14.2 &* 의 의미

포인터를 다룰 때는 두 기호를 쓴다.

기호이름
&x주소 연산자변수 x 가 사는 주소
*p역참조 연산자포인터 p 가 가리키는 값
*int타입 표기“int 를 가리키는 포인터” 타입

& 로 주소 얻기

x := 42
p := &x   // x 의 주소를 p 에 담는다

fmt.Println(x)   // 42
fmt.Println(&x)  // 0xc0000140a8 같은 주소
fmt.Println(p)   // 같은 주소

p 는 정수가 아니다. “정수가 들어 있는 메모리 칸의 주소” 다. 타입은 *int (int 를 가리키는 포인터).

* 로 값 꺼내기

주소만 들고 있어도 의미가 없다. 가리키는 값에 접근해야 한다. 이때 * 를 앞에 붙인다.

x := 42
p := &x

fmt.Println(*p)  // 42 (p 가 가리키는 값)

*p = 100         // p 가 가리키는 자리에 100 을 쓴다
fmt.Println(x)   // 100 (원본이 바뀌었다)

*p 는 두 가지로 쓰인다.

  • 값을 읽기: fmt.Println(*p)
  • 값을 쓰기: *p = 100

같은 * 인데 헷갈리는 이유

* 가 두 가지 자리에서 등장한다.

var p *int   // 여기 *는 "타입 표기"
*p = 10      // 여기 *는 "역참조"

위치로 구분하면 된다.

  • 타입 위치 (var p *int, func f(p *int)) → “포인터 타입”
  • 변수 앞 (*p, *p = ...) → “포인터가 가리키는 값”

14.3 포인터 변수 선언

명시적 선언

var p *int       // int 를 가리키는 포인터, 아직 아무것도 안 가리킴
fmt.Println(p)   // <nil>

선언만 하면 포인터의 초기값은 nil 이다. “아무것도 가리키지 않는다” 는 뜻이다.

:= 로 한 번에

대부분의 경우 이렇게 쓴다.

x := 10
p := &x          // 타입은 자동으로 *int

fmt.Println(*p)  // 10

nil 포인터 역참조의 위험

nil 인 포인터를 * 로 풀면 런타임 에러가 난다.

var p *int
fmt.Println(*p)  // panic: runtime error: invalid memory address

포인터는 쓰기 전에 반드시 어딘가를 가리키게 한다. 안 그러면 프로그램이 죽는다.

nil 검사 패턴

함수가 포인터를 받을 때 nil 체크를 먼저 하는 습관이 안전하다.

func printValue(p *int) {
    if p == nil {
        fmt.Println("(없음)")
        return
    }
    fmt.Println(*p)
}

14.4 함수에 포인터 넘기기

포인터의 가장 흔한 용도다.

값 전달: 원본이 안 바뀐다

func addOne(x int) {
    x = x + 1
}

func main() {
    n := 5
    addOne(n)
    fmt.Println(n) // 5
}

addOnen 의 복사본을 받았다. 복사본을 6 으로 바꿔봤자 바깥의 n 과는 무관하다.

포인터 전달: 원본을 바꿀 수 있다

func addOne(p *int) {
    *p = *p + 1
}

func main() {
    n := 5
    addOne(&n)
    fmt.Println(n) // 6
}

차이는 두 군데다.

  • 함수 시그니처: int*int
  • 호출 시: n&n
  • 함수 안: x*p

*p = *p + 1 은 “p 가 가리키는 자리에 들어 있는 값에 1 을 더해 다시 그 자리에 쓴다” 는 뜻이다.

두 변수 값 맞바꾸기 예제

값 전달로는 안 되고, 포인터로만 된다.

func swap(a, b *int) {
    *a, *b = *b, *a
}

func main() {
    x, y := 1, 2
    swap(&x, &y)
    fmt.Println(x, y) // 2 1
}

14.5 구조체와 포인터

포인터가 가장 자주 쓰이는 자리다.

큰 구조체 복사 비용 줄이기

type Person struct {
    Name string
    Age  int
}

func printPerson(p *Person) {
    fmt.Println(p.Name, p.Age)
}

값으로 받으면 매 호출마다 전체 구조체가 복사된다. 포인터로 받으면 주소(보통 8바이트) 만 넘어간다.

자동 역참조

C 라면 p->Name 처럼 따로 써야 하지만 Go 는 자동으로 풀어준다.

p := &Person{Name: "고길동", Age: 40}

fmt.Println(p.Name)     // OK (자동 역참조)
fmt.Println((*p).Name)  // 동일하지만 거의 안 씀

Go 에서 p.Fieldp 가 값이든 포인터든 똑같이 동작한다. 어느 쪽이라도 필드에 그대로 접근할 수 있다.

필드 수정도 자연스럽다

func birthday(p *Person) {
    p.Age++   // 원본이 바뀐다
}

func main() {
    me := Person{Name: "둘리", Age: 10}
    birthday(&me)
    fmt.Println(me.Age) // 11
}

new() 함수

새 구조체의 포인터를 한 번에 얻고 싶을 때 쓴다.

p := new(Person)
// p 는 *Person 타입
// 가리키는 Person 의 모든 필드는 제로값

위 코드는 다음과 같다.

var x Person
p := &x

세 가지 비교:

방식결과 타입메모
var x PersonPerson
p := &Person{}*Person가장 흔한 패턴
p := new(Person)*Person모든 필드 제로값

실무에서는 &Person{...} 가 가장 많이 쓰인다. 초기값을 같이 줄 수 있어 편하기 때문이다.


14.6 Go 포인터의 안전성

C / C++ 의 포인터는 강력한 만큼 무섭다. Go 는 그중 위험한 기능 대부분을 잘라냈다.

포인터 연산이 없다

C 에서는 이런 코드가 가능하다.

int *p = arr;
p++;       // 다음 요소 주소로 이동
p = p + 5; // 5칸 건너뛰기

Go 에서는 p + 1 같은 연산을 허용하지 않는다. “엉뚱한 메모리를 가리키게 만드는” 실수를 문법적으로 막아 둔 셈이다.

댕글링 포인터가 없다

C 에서는 이미 해제된 메모리를 포인터가 계속 가리키는 일이 흔하다.

Go 는 가비지 컬렉터가 있다. 어떤 포인터가 어떤 값을 가리키고 있다면 그 값은 절대 해제되지 않는다.

func newCounter() *int {
    n := 0
    return &n   // n 이 함수 스택에 있더라도 안전하다
}

n 은 함수가 끝나면 사라질 것 같지만, 포인터를 통해 바깥으로 새어 나가는 게 보이면 Go 컴파일러가 알아서 힙(heap)으로 옮긴다. 이걸 escape analysis 라고 부른다.

이 주제는 26장에서 더 깊이 다룬다. 지금은 “Go 는 포인터를 반환해도 안전하다” 만 기억하면 충분하다.

이중 해제가 없다

C 의 또 다른 단골 버그는 free 를 두 번 호출하는 것. Go 에는 free 자체가 없다. 사용하지 않는 메모리는 GC 가 알아서 정리한다.

정리: Go 포인터의 위치

기능C / C++Go
주소 얻기 (&)OO
역참조 (*)OO
포인터 산술 (p+1)OX
직접 freeOX (GC)
댕글링 포인터가능발생 안 함

Go 의 포인터는 “위험은 빼고, 편의는 남긴” 포인터다.


14.7 정리

이 장에서 살펴본 내용:

  • 값 대입과 인자 전달은 모두 “복사” 다
  • 원본을 바꾸려면 주소를 넘긴다 → 포인터
  • &x 는 주소, *p 는 그 자리의 값
  • *int, *Person 같은 타입 표기를 익혔다
  • 함수 시그니처에 *T 를 받으면 원본 수정이 가능하다
  • 구조체 포인터는 p.Field 로 자연스럽게 접근한다
  • new(T) 로 제로값 구조체의 포인터를 한 번에 얻는다
  • Go 포인터는 산술 / 댕글링 / 이중 해제가 없어 안전하다

포인터가 손에 익으면 다음 단계가 자연스럽게 열린다.

값을 “수정하는 동작” 을 함수에 묶어 두면 어떻게 될까. 그게 다음 장에서 다룰 메서드다. 포인터를 알게 됐으니 값 리시버와 포인터 리시버의 차이도 자연스럽게 이해할 수 있다.

15장. 메서드

14장에서 포인터를 익혔다. 이제 한 발 더 나가서, 특정 타입에 “동작” 을 묶어 두는 방법을 배운다.

함수와 메서드의 경계는 한 줄 차이지만, 이 한 줄이 Go 의 객체 지향 스타일을 만든다.

목표:

  • 메서드와 함수의 차이를 이해한다
  • 값 리시버와 포인터 리시버를 구분한다
  • 어느 쪽을 언제 쓸지 판단할 수 있다
  • 사용자 정의 타입에 메서드를 붙여 본다

15.1 메서드란

메서드는 “특정 타입에 묶여 있는 함수” 다.

13장에서 만든 Person 구조체를 다시 가져와 보자.

type Person struct {
    Name string
    Age  int
}

이름을 출력하는 동작이 필요하다고 하자. 함수로 만들면 이렇게 된다.

func Greet(p Person) {
    fmt.Println("안녕,", p.Name)
}

Greet(p)

메서드로 만들면 이렇게 된다.

func (p Person) Greet() {
    fmt.Println("안녕,", p.Name)
}

p.Greet()

호출이 Greet(p) 에서 p.Greet() 로 바뀌었다. “인사하는 동작은 Person 에 속한다” 는 의도가 문법에 그대로 드러난다.

메서드와 함수의 차이

항목함수메서드
정의 위치어디든타입에 묶임
호출 형태f(x)x.f()
첫 인자일반 매개변수리시버 (receiver)

본질적으로는 메서드도 함수다. 단지 “어떤 타입의 동작인지” 를 문법으로 표현한 것뿐이다.


15.2 메서드 정의

메서드 정의 문법은 다음과 같다.

func (리시버변수 리시버타입) 메서드이름(매개변수) 반환타입 {
    // 본문
}

func 와 메서드 이름 사이에 괄호로 묶인 한 덩어리가 추가됐다. 이걸 리시버(receiver) 라고 부른다.

간단한 예제

type Rectangle struct {
    Width, Height float64
}

// Rectangle 에 Area 메서드를 정의
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

사용은 이렇게 한다.

r := Rectangle{Width: 3, Height: 4}
fmt.Println(r.Area()) // 12

리시버 이름 짓는 관례

리시버 변수 이름은 보통 짧게 짓는다.

  • 타입 이름의 첫 글자 한두 개
  • Rectangler
  • Personp
  • HttpServers 또는 srv

Go 코드에서 this, self 같은 이름은 거의 안 쓴다.

메서드 이름 규칙

함수 / 변수와 같은 규칙이 적용된다.

  • 대문자로 시작 → 패키지 바깥에 공개됨 (exported)
  • 소문자로 시작 → 패키지 안에서만 사용

공개 / 비공개 규칙은 20장에서 더 자세히 다룬다.


15.3 값 리시버 vs 포인터 리시버

리시버는 두 가지 형태가 있다.

func (r Rectangle)  Area() float64 { ... }   // 값 리시버
func (r *Rectangle) Scale(factor float64)   { ... }   // 포인터 리시버

* 가 하나 붙는 작은 차이다. 하지만 동작은 14장에서 본 값/포인터 차이 그대로다.

값 리시버는 복사본을 받는다

type Counter struct {
    count int
}

func (c Counter) Add() {
    c.count++   // 복사본을 1 증가
}

func main() {
    c := Counter{}
    c.Add()
    c.Add()
    c.Add()
    fmt.Println(c.count) // 0
}

c.Add() 가 호출될 때마다 Counter 가 통째로 복사된다. 복사본을 늘려 봤자 원본은 그대로다.

포인터 리시버는 원본을 받는다

func (c *Counter) Add() {
    c.count++   // 원본을 1 증가
}

func main() {
    c := Counter{}
    c.Add()
    c.Add()
    c.Add()
    fmt.Println(c.count) // 3
}

*Counter 로 받았기 때문에 c.count++ 는 진짜 원본에 작용한다.

호출 문법은 똑같다

값이든 포인터든 호출은 똑같이 점으로 한다.

c := Counter{}
c.Add()    // 포인터 리시버라도 (&c).Add() 처럼 안 써도 됨

Go 가 자동으로 주소를 잡아 준다. 포인터로 갖고 있어도 마찬가지다.

p := &Counter{}
p.Add()    // OK

호출 시점에 “값을 메서드로 보낼 수 있는가” 만 보면 된다. 변수가 있고 주소를 잡을 수 있다면 Go 컴파일러가 알아서 변환한다.

값 리시버와 포인터 리시버 한눈에

항목값 리시버 (r T)포인터 리시버 (r *T)
받는 것복사본원본의 주소
원본 수정불가능가능
큰 구조체 비용매번 복사주소만 전달
nil 가능성없음 (값)있음 (*T)

15.4 어느 쪽을 쓸지 판단하기

처음에는 헷갈리지만 규칙이 단순하다.

1. 메서드가 값을 수정해야 하면 포인터

func (c *Counter) Add() { c.count++ }

값 리시버로는 어차피 안 바뀐다. 초보가 가장 자주 만나는 함정이다.

2. 큰 구조체면 포인터

type Report struct {
    Lines [10000]string
}

func (r *Report) Summary() string { ... }

매 호출마다 만 줄짜리 배열을 복사하지 않으려면 포인터 리시버를 쓴다.

3. 작고 불변이면 값

type Point struct {
    X, Y int
}

func (p Point) Distance() float64 { ... }

Point 같이 두세 필드짜리 구조체는 복사 비용이 거의 없다. 값 리시버가 더 안전하다 (변경 의도가 없음을 코드가 말해 준다).

4. 한 타입의 메서드는 하나로 통일하라

이게 가장 중요한 관례다.

// 권장하지 않음 — 섞여 있다
func (p Person) String() string { ... }
func (p *Person) SetAge(a int)  { ... }

값과 포인터를 섞으면 사용하는 쪽에서 헷갈린다. 또 16장의 인터페이스를 만족할 때 메서드 셋(method set) 규칙이 까다로워진다.

어느 한 메서드가 수정해야 한다면 모든 메서드를 포인터 리시버로 통일하는 게 일반적이다.

결정 흐름

조건결정
값을 수정해야 함포인터
구조체가 큼포인터
작고 안 바뀜
같은 타입의 다른 메서드가 포인터포인터 (통일)

15.5 사용자 정의 타입에 메서드 붙이기

메서드는 구조체에만 붙는 게 아니다.

기본 타입에 별명 짓기

type Celsius float64

c := Celsius(36.5)

Celsiusfloat64 와 다른 타입이다. 값은 똑같이 들어가지만, 컴파일러가 구분해서 본다.

거기에 메서드 붙이기

type Celsius float64

func (c Celsius) ToFahrenheit() Celsius {
    return c*9/5 + 32
}

func main() {
    body := Celsius(36.5)
    fmt.Println(body.ToFahrenheit()) // 97.7
}

float64 위에 의미를 한 겹 입힌 셈이다. 온도 단위 변환 같은 동작이 자연스럽게 묶인다.

슬라이스에도 가능하다

type Names []string

func (n Names) Count() int {
    return len(n)
}

func main() {
    list := Names{"가", "나", "다"}
    fmt.Println(list.Count()) // 3
}

단, 같은 패키지 안에서만

Go 는 “남의 패키지의 타입에 내 패키지에서 메서드를 붙이는 행위” 를 금지한다.

// 금지! 컴파일 에러
func (s string) Shout() string {
    return strings.ToUpper(s) + "!"
}

string 은 내장 타입이라 내가 메서드를 새로 정의할 수 없다. 필요하면 새 타입을 정의해야 한다.

type Shoutable string

func (s Shoutable) Shout() string {
    return strings.ToUpper(string(s)) + "!"
}

이 규칙은 “남이 만든 타입의 동작을 내가 마음대로 바꾸지 못하게” 하는 안전장치다.


15.6 정리

이 장에서 살펴본 내용:

  • 메서드는 특정 타입에 묶인 함수다
  • 호출은 x.f() 형태로 한다
  • 리시버에는 값 리시버와 포인터 리시버가 있다
  • 원본을 바꿔야 하면 포인터 리시버
  • 큰 구조체도 포인터 리시버 (복사 비용)
  • 작고 불변이면 값 리시버
  • 한 타입의 메서드는 하나로 통일하는 게 관례
  • 사용자 정의 타입(type MyInt int)에도 메서드를 붙일 수 있다
  • 다른 패키지의 타입에 메서드를 붙이는 건 금지

메서드까지 익히면 “동작을 가진 타입” 을 만들 수 있게 된다.

다음 장에서는 한 단계 더 추상화한다. “이런 메서드를 가진 모든 타입” 을 하나로 묶어 다루는 도구, 인터페이스를 배운다. Go 가 자랑하는 다형성이 여기서 등장한다.

16장. 인터페이스

15장에서 타입에 메서드를 묶는 법을 배웠다. 이제 “어떤 타입이든 좋다, 다만 이런 동작은 할 줄 알아야 한다” 는 조건을 표현하는 방법을 익힌다.

그것이 인터페이스다.

Go 의 인터페이스는 다른 언어와 결이 조금 다르다. 이 장 끝에서 그 차이를 분명히 알게 된다.

목표:

  • 인터페이스의 정의와 의미 이해
  • 암묵적 구현 방식 익히기
  • 다형성 코드를 직접 짜 보기
  • 빈 인터페이스(any)와 그 함정 알기
  • 타입 단언과 타입 스위치 사용

16.1 인터페이스란

인터페이스는 메서드 시그니처의 집합 이다.

“이런 이름과 시그니처의 메서드를 가지고 있는 모든 타입” 을 한 단어로 묶어 부르고 싶을 때 쓴다.

예를 들어 “Area() 메서드를 가진 모든 타입” 을 Shape 라고 부르고 싶다고 하자.

type Shape interface {
    Area() float64
}

이제 Shape 라는 이름은 “Area() float64 메서드를 가진 모든 타입의 별명” 이 된다.

왜 이게 필요한가

함수가 여러 종류의 타입을 받고 싶을 때가 있다.

func printArea(s Shape) {
    fmt.Println("넓이:", s.Area())
}

이 함수는 Circle 이든 Rectangle 이든 Triangle 이든 가리지 않는다. Area() 메서드만 있으면 받아 준다.

이게 다형성(polymorphism)이다. “구체적인 타입은 신경 끄고 동작만 보고 다루는 방식” 이라고 보면 된다.


16.2 인터페이스 정의

문법은 단순하다.

type 이름 interface {
    메서드1(매개변수) 반환타입
    메서드2(매개변수) 반환타입
    ...
}

표준 라이브러리의 예: Stringer

fmt 패키지에는 다음과 같은 인터페이스가 있다.

type Stringer interface {
    String() string
}

String() string 메서드를 가진 타입이라면 fmt.Println 이 그 메서드 결과를 출력에 사용한다.

type Person struct {
    Name string
    Age  int
}

func (p Person) String() string {
    return fmt.Sprintf("%s(%d세)", p.Name, p.Age)
}

func main() {
    p := Person{Name: "고길동", Age: 40}
    fmt.Println(p) // 고길동(40세)
}

Stringer 라는 이름을 어디에서도 명시하지 않았다. 그런데도 fmt.PrintlnString() 을 호출했다. 어떻게 가능할까. 다음 절이 그 답이다.


16.3 암묵적 구현 (Duck Typing)

Go 의 가장 큰 특징 중 하나가 여기에 있다.

“그 메서드를 가지고 있다면, 그 인터페이스를 만족한 것이다.”

따로 선언하지 않는다. 실제로 메서드 시그니처가 맞기만 하면 끝이다.

Java / C# 와 다른 점

Java 라면 명시적으로 선언해야 한다.

class Person implements Stringer { ... }

Go 에는 implements 키워드가 없다.

// Go 에선 그냥 메서드만 있으면 자동 성립
func (p Person) String() string { ... }

타입 정의 어디에도 Stringer 라는 단어가 없다. 그래도 컴파일러는 안다.

영어 속담 “오리처럼 걷고 오리처럼 운다면, 그건 오리다” 에서 따와 duck typing 이라고 부른다.

이게 왜 좋은가

  • 내가 만든 타입을 손대지 않고 남이 만든 인터페이스를 만족시킬 수 있다
  • 인터페이스를 나중에 추가해도 기존 코드를 수정할 필요가 없다
  • “메서드 셋이 맞느냐” 만 보면 되므로 설계가 가볍게 유지된다

16.4 다형성 사용 예: 도형

다양한 도형의 넓이를 출력하는 예제를 짜 보자.

인터페이스 정의

type Shape interface {
    Area() float64
}

두 타입 정의 및 메서드 구현

type Circle struct {
    R float64
}

func (c Circle) Area() float64 {
    return 3.14 * c.R * c.R
}

type Rectangle struct {
    W, H float64
}

func (r Rectangle) Area() float64 {
    return r.W * r.H
}

CircleRectangleArea() float64 를 가졌다. 즉, 둘 다 자동으로 Shape 다.

공통 함수

func PrintArea(s Shape) {
    fmt.Printf("%T 의 넓이: %.2f\n", s, s.Area())
}

%T 는 값의 실제 타입을 출력하는 포맷이다 (7장).

사용

shapes := []Shape{
    Circle{R: 3},
    Rectangle{W: 4, H: 5},
}

for _, s := range shapes {
    PrintArea(s)
}

출력:

main.Circle 의 넓이: 28.26
main.Rectangle 의 넓이: 20.00

서로 다른 타입을 한 슬라이스에 담을 수 있는 건 원소 타입이 인터페이스이기 때문이다.


16.5 빈 인터페이스 any / interface{}

0개 메서드의 인터페이스

type Anything interface{}

메서드 요구가 하나도 없다. “모든 타입이 자동으로 만족” 한다.

Go 1.18 부터 any 라는 별칭이 추가됐다.

var x any = 42        // int
x = "hello"           // string
x = []int{1, 2, 3}    // slice

anyinterface{} 는 완전히 같다. 새 코드에서는 any 를 쓰는 게 권장된다.

어디서 만나는가

  • JSON 디코딩 (값이 어떤 타입인지 미리 모를 때)
  • 가변 타입 인자 (fmt.Println(args ...any))
  • 컨테이너에 아무 타입이나 담아야 할 때
func print(args ...any) {
    for _, a := range args {
        fmt.Println(a)
    }
}

any 의 한계

편리하지만 대가가 있다.

  • 타입 안정성이 사라진다
  • 안에 든 값을 쓰려면 타입을 다시 알아내야 한다

다음 두 절에서 그 방법을 다룬다.


16.6 타입 단언 (Type Assertion)

any 에 담긴 값을 꺼내려면 “이거 사실은 string 이지?” 라고 단언해야 한다.

기본 형태

var i any = "hello"

s := i.(string)
fmt.Println(s) // hello

i.(string) 은 “i 가 가지고 있는 실제 타입이 string 이라고 단언한다” 는 뜻이다.

실패 시 동작

타입이 안 맞으면 패닉이 난다.

var i any = 42
s := i.(string)   // panic!

안전한 형태 (두 값 반환)

s, ok := i.(string)
if ok {
    fmt.Println("문자열:", s)
} else {
    fmt.Println("문자열이 아니다")
}

okfalse 면 패닉이 일어나지 않는다. 대신 s 는 string 의 제로값 "" 가 된다.

안전한 두 값 형태가 거의 모든 상황에서 정답이다.


16.7 타입 스위치 (Type Switch)

가능한 타입이 여러 개라면 타입 단언을 여러 번 늘어놓는 대신 타입 스위치를 쓴다.

func describe(i any) {
    switch v := i.(type) {
    case int:
        fmt.Println("int:", v*2)
    case string:
        fmt.Println("string:", strings.ToUpper(v))
    case bool:
        fmt.Println("bool:", !v)
    default:
        fmt.Printf("기타 (%T): %v\n", v, v)
    }
}

문법의 핵심은 i.(type) 이다. switch 안에서만 쓸 수 있는 특수 형태다.

case 안에서 v 의 타입이 자동으로 좁혀진다.

입력결과
describe(7)int: 14
describe("go")string: GO
describe(true)bool: false
describe(3.14)기타 (float64): 3.14

16.8 nil 인터페이스의 함정

Go 초보자가 자주 만나는 미묘한 버그가 있다.

인터페이스의 내부 구조

인터페이스 변수는 사실 두 가지 정보를 가진다.

슬롯의미
타입 (type)안에 들어 있는 값의 진짜 타입
값 (value)그 타입의 값

인터페이스가 nil 이 되려면 두 슬롯 모두 비어 있어야 한다.

잘못된 패턴

type MyError struct{ Msg string }

func (e *MyError) Error() string { return e.Msg }

func mayFail() error {
    var e *MyError = nil
    return e   // 함정!
}

func main() {
    err := mayFail()
    if err != nil {
        fmt.Println("에러:", err) // 이 분기로 들어간다!
    }
}

e 자체는 *MyError nil 인데도 err != nil 이 참이 된다.

왜냐하면 err (인터페이스) 의 슬롯이 이렇다.

슬롯
타입*MyError (nil 아님)
nil

타입 슬롯에 뭔가 들어 있기 때문에 인터페이스 자체는 nil 이 아니다.

올바른 패턴

에러가 없으면 nil 그 자체를 반환한다.

func mayFail() error {
    return nil   // 이렇게!
}

타입을 가진 변수를 거치지 말고 직접 nil 을 반환해야 한다.

이 함정은 21장의 에러 처리에서 다시 등장한다. “에러가 없으면 그냥 nil 을 반환한다” 만 지키면 대부분 피할 수 있다.


16.9 정리

이 장에서 살펴본 내용:

  • 인터페이스는 메서드 시그니처의 집합이다
  • “어떤 메서드를 가진 모든 타입” 을 묶어 다룬다
  • Go 는 암묵적 구현 — implements 키워드가 없다
  • 메서드 시그니처만 맞으면 자동으로 만족
  • 다형성을 통해 여러 타입을 한 함수로 처리할 수 있다
  • 빈 인터페이스 any 는 모든 타입을 받지만 타입 안정성이 약하다
  • 타입 단언 i.(T) 와 두 값 형태 v, ok := i.(T) 를 익혔다
  • 타입 스위치 switch v := i.(type) 로 여러 타입 분기
  • nil 인터페이스 함정: 타입 슬롯이 비어 있어야 nil 이다

인터페이스는 강력하다. 하지만 any 는 타입 정보를 잃는 단점이 있다.

다음 장에서는 그 단점을 해결하는 도구를 만난다. 타입 안정성을 유지하면서 여러 타입을 받는 함수를 만드는 방법 — 제네릭이다.

17장. 제네릭

16장에서 인터페이스로 다양한 타입을 받는 법을 배웠다. 그중 빈 인터페이스 any 는 모든 타입을 받을 수 있었지만 타입 정보를 잃어버린다 는 약점이 있었다.

이 장에서는 그 약점을 메우는 도구를 다룬다. Go 1.18 부터 추가된 제네릭(generics)이다.

제네릭은 “타입을 매개변수처럼 받는” 함수와 타입을 만드는 기능이다.

목표:

  • 제네릭이 필요한 이유 이해
  • 타입 매개변수 문법 익히기
  • 타입 제약(constraints) 사용
  • Map, Filter 같은 함수 직접 구현
  • 제네릭 타입 맛보기
  • 언제 쓰고 언제 안 쓸지 판단

17.1 제네릭이 왜 필요한가

같은 함수를 타입마다 복사하는 문제

정수 슬라이스의 합을 구하는 함수를 짜 보자.

func SumInt(nums []int) int {
    var total int
    for _, n := range nums {
        total += n
    }
    return total
}

float64 슬라이스에도 같은 게 필요해졌다.

func SumFloat(nums []float64) float64 {
    var total float64
    for _, n := range nums {
        total += n
    }
    return total
}

본문은 거의 똑같다. 타입만 다르다. 타입이 늘어날수록 같은 함수가 계속 늘어난다.

any 로 해결하면?

func SumAny(nums []any) any {
    // 안에서 일일이 타입 단언이 필요하다
    // 컴파일러가 타입 검사를 해 주지도 않는다
}

any 로 받으면 코드는 짧아지지만 단점이 크다.

  • 호출하는 쪽에서 []any 를 만들어 줘야 한다
  • 함수 안에서 매번 타입 단언이 필요하다
  • 컴파일 시점에 타입 오류를 못 잡는다
  • 박싱 비용 (값을 인터페이스에 감싸는 비용)

제네릭이 답이다

제네릭을 쓰면 함수 하나로 끝난다.

func Sum[T int | float64](nums []T) T {
    var total T
    for _, n := range nums {
        total += n
    }
    return total
}

Sum([]int{1, 2, 3})       // 6
Sum([]float64{1.5, 2.5})  // 4.0
  • 함수는 단 하나
  • 컴파일러가 타입을 검사
  • 호출하는 쪽도 평소처럼 자연스럽다

17.2 타입 매개변수 문법

제네릭의 핵심은 함수 이름과 매개변수 목록 사이에 들어가는 대괄호 블록 이다.

func 이름[T any](인자 T) T { ... }
//      ^^^^^^^
//      타입 매개변수

가장 단순한 예

func PrintAnything[T any](v T) {
    fmt.Println(v)
}

PrintAnything[int](42)
PrintAnything[string]("hi")

호출 시 대괄호로 타입을 지정한다. 하지만 보통 생략한다. Go 컴파일러가 인자에서 자동 추론한다.

PrintAnything(42)    // T = int 추론됨
PrintAnything("hi")  // T = string 추론됨

여러 타입 매개변수

쉼표로 나열한다.

func Pair[K, V any](k K, v V) {
    fmt.Println(k, v)
}

Pair("age", 30)

Go 1.18 이상 필요

제네릭은 Go 1.18 (2022년 3월) 부터 추가됐다. 설치된 Go 버전이 이보다 낮다면 동작하지 않는다.

go version

go1.18 이상이어야 한다. 현재는 1.22 이상이 일반적.


17.3 타입 제약 (Constraints)

타입 매개변수에는 항상 “어떤 타입이어야 하는가” 라는 조건을 붙여야 한다. 이게 타입 제약(constraint) 이다.

any: 아무 타입이나

func Identity[T any](x T) T {
    return x
}

any 는 16장에서 본 빈 인터페이스다. 모든 타입을 허용한다.

comparable: == 가 되는 타입

func Equal[T comparable](a, b T) bool {
    return a == b
}

Equal(1, 1)         // true
Equal("a", "b")     // false
Equal([]int{}, ...) // 컴파일 에러 (슬라이스는 == 불가)

comparable 은 Go 가 내장한 특수 제약이다. 정수, 문자열, 포인터 등 == 가 되는 타입만 받는다.

타입 집합으로 제한

| 로 여러 타입을 묶을 수 있다.

func Sum[T int | float64](nums []T) T { ... }

Sumint 슬라이스나 float64 슬라이스만 받는다.

인터페이스를 제약으로

타입 집합이 길어지면 인터페이스로 빼는 게 깔끔하다.

type Number interface {
    int | int64 | float32 | float64
}

func Sum[T Number](nums []T) T {
    var total T
    for _, n := range nums {
        total += n
    }
    return total
}

Number 는 메서드를 요구하지 않고 타입의 목록만 나열 한다. 이렇게도 인터페이스를 정의할 수 있다 (Go 1.18 부터).

~T (underlying type 포함)

다음 코드는 컴파일이 안 된다.

type MyInt int

var nums []MyInt
Sum(nums)   // 에러

MyIntint 와 다른 타입이라 int | float64 에 안 맞는다.

이때 ~ 를 붙이면 “그 타입을 기반으로 한 모든 타입” 으로 확장된다.

type Number interface {
    ~int | ~int64 | ~float32 | ~float64
}

~int 는 “int 그 자체이거나 underlying type 이 int 인 모든 타입” 이다. 이제 MyInt 도 통과한다.

~ 가 있으면 사용자 정의 타입까지 받는다. 라이브러리에서는 거의 항상 ~ 를 붙인다.


17.4 제네릭 함수 예제

다른 언어의 함수형 도구를 직접 구현해 보자.

Map: 모든 원소에 함수 적용

func Map[T, R any](s []T, f func(T) R) []R {
    result := make([]R, len(s))
    for i, v := range s {
        result[i] = f(v)
    }
    return result
}

사용 예:

nums := []int{1, 2, 3, 4}

doubled := Map(nums, func(n int) int { return n * 2 })
// [2 4 6 8]

asStr := Map(nums, func(n int) string {
    return fmt.Sprintf("v%d", n)
})
// ["v1" "v2" "v3" "v4"]

T 는 입력 슬라이스의 타입, R 은 결과 슬라이스의 타입이다.

Filter: 조건에 맞는 원소만

func Filter[T any](s []T, pred func(T) bool) []T {
    result := make([]T, 0, len(s))
    for _, v := range s {
        if pred(v) {
            result = append(result, v)
        }
    }
    return result
}

사용 예:

nums := []int{1, 2, 3, 4, 5, 6}

evens := Filter(nums, func(n int) bool {
    return n%2 == 0
})
// [2 4 6]

Reduce: 모든 원소를 한 값으로 접기

func Reduce[T, R any](s []T, init R, f func(R, T) R) R {
    acc := init
    for _, v := range s {
        acc = f(acc, v)
    }
    return acc
}

사용 예:

nums := []int{1, 2, 3, 4, 5}

sum := Reduce(nums, 0, func(acc, n int) int {
    return acc + n
})
// 15

joined := Reduce([]string{"가", "나", "다"}, "",
    func(acc, s string) string { return acc + s })
// "가나다"

이 세 함수는 golang.org/x/exp/slices, golang.org/x/exp/maps 같은 실험 패키지에서 비슷한 형태로 제공된다.


17.5 제네릭 타입 맛보기

함수만 제네릭이 되는 게 아니다. 타입 자체도 제네릭으로 정의할 수 있다.

제네릭 스택

type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(v T) {
    s.items = append(s.items, v)
}

func (s *Stack[T]) Pop() (T, bool) {
    var zero T
    if len(s.items) == 0 {
        return zero, false
    }
    last := len(s.items) - 1
    v := s.items[last]
    s.items = s.items[:last]
    return v, true
}

func (s *Stack[T]) Len() int {
    return len(s.items)
}

세 가지 새로운 문법이 있다.

문법의미
type Stack[T any] struct타입 매개변수 T 를 가진 구조체
func (s *Stack[T]) Push(v T)메서드에서 T 그대로 사용
var zero TT 타입의 제로값

사용

func main() {
    intStack := &Stack[int]{}
    intStack.Push(1)
    intStack.Push(2)
    v, _ := intStack.Pop()
    fmt.Println(v) // 2

    strStack := &Stack[string]{}
    strStack.Push("hello")
}

타입 매개변수를 인스턴스 만들 때 명시한다. Stack[int], Stack[string] 처럼.

18장에서 다룰 container/list 같은 표준 자료구조는 제네릭이 도입되기 전에 만들어졌다. 그래서 any 기반이고, 사용 시 타입 단언이 필요하다.


17.6 언제 쓰고 언제 안 쓰는가

제네릭은 강력하지만 만능 망치가 아니다.

잘 어울리는 경우

  • 컬렉션 자료구조
    • 스택, 큐, 트리, 집합 등
    • 안에 들어가는 타입과 무관한 로직
  • 알고리즘
    • 정렬, 검색, Map / Filter / Reduce
    • 비교만 되면 동작하는 함수
  • 유틸리티 함수
    • Min, Max, Contains 같은 류

어울리지 않는 경우

  • 단일 타입에서만 쓰는 코드
    • 미리 한 가지 타입으로 잡혀 있다면 제네릭을 끌어들이는 게 오히려 복잡해진다
  • 인터페이스로 충분한 경우
    • 메서드 호출이 핵심이라면 인터페이스가 더 자연스럽다

비교해 보자.

// 인터페이스 — 메서드를 호출하면 충분할 때
type Speaker interface {
    Speak() string
}
func Announce(s Speaker) { fmt.Println(s.Speak()) }

// 제네릭 — 타입 자체를 들고 다녀야 할 때
func First[T any](s []T) (T, bool) {
    var zero T
    if len(s) == 0 { return zero, false }
    return s[0], true
}

판단 기준 한 줄 요약

조건권장
다양한 타입의 컨테이너 / 알고리즘제네릭
다양한 타입의 “동작” 호출인터페이스
한 타입에서만 쓰는 코드일반 함수
박싱 비용이 신경 쓰임제네릭 (any 대신)

“인터페이스로 풀리면 인터페이스를 먼저 써 본다. 그래도 부족하면 제네릭을 꺼낸다.” 가 일반적인 가이드라인이다.


17.7 정리

이 장에서 살펴본 내용:

  • 제네릭은 같은 함수를 타입마다 복사하는 문제를 해결한다
  • any 보다 타입 안정성이 높고 박싱 비용도 적다
  • 문법은 [T 제약] 형태로 함수 / 타입에 붙인다
  • 타입 제약: any, comparable, 타입 집합, 인터페이스
  • ~T 로 사용자 정의 타입까지 포함시킬 수 있다
  • Map, Filter, Reduce 같은 도구를 직접 만들 수 있다
  • 제네릭 타입 (Stack[T any]) 으로 재사용 가능한 자료구조 작성
  • 컬렉션 / 알고리즘에 어울리고, 단일 타입 코드에는 굳이 쓸 필요가 없다

여기까지가 Go 의 추상화 도구 한 묶음이다.

  • 14장 포인터 — “참조” 의 도입
  • 15장 메서드 — 타입에 동작 묶기
  • 16장 인터페이스 — 동작으로 묶어 다루기
  • 17장 제네릭 — 타입으로 묶어 다루기

이 네 가지가 함께 작동하면 대부분의 모델링 문제를 풀 수 있다.

7부에서는 다시 자료구조로 돌아간다. 지금까지 배운 도구들을 활용해 리스트 / 큐 / 스택 / 맵을 더 깊이 들여다본다.

18장. 리스트, 큐, 스택, 링

지금까지 데이터를 묶는 방법으로 배열, 슬라이스, 맵, 구조체를 배웠다. 이 정도로도 웬만한 프로그램은 짤 수 있다.

하지만 실전에서는 더 특수한 모양의 자료구조가 필요할 때가 있다.

  • 줄 서 있는 순서대로 꺼내는 큐 (FIFO)
  • 가장 마지막에 넣은 것부터 꺼내는 스택 (LIFO)
  • 노드들이 양쪽으로 연결된 이중 연결 리스트
  • 끝없이 돌고 도는 원형 버퍼
  • 가장 우선순위 높은 것부터 꺼내는 힙

이 장의 목표는 세 가지다.

  • 슬라이스와 연결 리스트의 차이를 메모리 구조 차원에서 이해하기
  • Go 표준 라이브러리의 container/* 패키지 활용법 익히기
  • “이 상황엔 어떤 자료구조를 써야 하지?” 를 스스로 판단할 수 있게 되기

이 장에서는 Big-O 표기를 도구로 자주 쓴다. Big-O 자체에 대한 설명은 생략하니 표기가 낯설다면 다른 자료를 한 번 훑어보고 와도 좋다.


18.1 Go 표준 자료구조 한눈에 보기

Go 가 기본 제공하는 자료구조는 의외로 단출하다. 다음 표가 전부다.

자료구조위치한 줄 요약
배열 [N]T언어 내장길이 고정, 연속 메모리
슬라이스 []T언어 내장길이 가변, 연속 메모리
map[K]V언어 내장키-값 해시 테이블
container/list표준 라이브러리이중 연결 리스트
container/ring표준 라이브러리원형 연결 리스트
container/heap표준 라이브러리힙 (우선순위 큐의 토대)

언어 내장 세 가지가 90% 이상의 사용처를 커버한다. 나머지는 “필요할 때만 꺼내 쓰는” 도구다.

큐와 스택은 별도 자료형으로 제공되지 않는다. 슬라이스나 container/list 위에 직접 구현해서 쓴다.


18.2 메모리 구조부터 이해하기

자료구조의 성능 차이는 대부분 메모리에 어떻게 놓여 있는지에서 비롯된다. 슬라이스와 연결 리스트를 그림으로 비교해 보자.

슬라이스: 연속된 한 덩어리

슬라이스의 원소들은 메모리 위에 일렬로 붙어 있다.

인덱스:    0    1    2    3    4
주소:    0x10 0x14 0x18 0x1c 0x20
값:    [ 10 | 20 | 30 | 40 | 50 ]

이 구조의 장점은 분명하다.

  • 인덱스로 즉시 접근 (s[i]) — O(1)
  • CPU 캐시에 친화적

대신 약점도 분명하다.

  • 중간에 끼워 넣으려면 뒷부분을 전부 밀어야 한다 — O(n)
  • 앞에서 빼면 더 비싸다 — O(n)

연결 리스트: 흩어진 노드들

연결 리스트의 노드들은 메모리 곳곳에 흩어져 있고, 각자 다음 노드를 가리키는 포인터를 들고 있다.

[10 | →] ──→ [20 | →] ──→ [30 | →] ──→ [40 | →] ──→ nil
0x100       0x238       0x4a0       0x812

장점은 정반대다.

  • 노드 사이에 끼워 넣기 — 포인터만 바꾸면 끝, O(1)
  • 노드 제거 — 마찬가지로 O(1) (해당 노드를 알고 있을 때)

대신 약점도 정반대다.

  • “5번째 요소“를 찾으려면 처음부터 따라가야 한다 — O(n)
  • 메모리가 흩어져 있어 캐시 미스가 잦다

왜 보통 슬라이스가 더 빠른가

이론적으로 연결 리스트의 삽입은 O(1) 이고 슬라이스의 삽입은 O(n) 이지만, 실제로 측정해 보면 슬라이스가 빠른 경우가 많다.

이유는 캐시다.

  • CPU 는 메모리에서 데이터를 한 줄(cache line) 통째로 가져온다
  • 슬라이스는 인접한 원소가 함께 캐시에 올라온다
  • 연결 리스트는 다음 노드가 어디 있을지 모르니 매번 메인 메모리까지 다녀와야 한다

이 차이는 작아 보여도 누적되면 100배까지 벌어진다. “이론적 복잡도“와 “실측 성능“은 다를 수 있다는 점을 기억해 두자.

그래서 Go 진영에서는 “특별한 이유가 없으면 슬라이스를 써라” 라는 조언이 거의 격언처럼 통한다.


18.3 이중 연결 리스트: container/list

그럼에도 연결 리스트가 빛나는 상황은 있다.

  • 리스트 중간에서 자주 삽입/삭제가 일어날 때
  • LRU 캐시처럼 “방금 쓴 노드를 맨 앞으로 이동” 이 잦을 때
  • 거대한 데이터를 슬라이스 재할당 없이 유지하고 싶을 때

이럴 땐 container/list 가 답이다. 양방향으로 연결된 이중 연결 리스트를 제공한다.

기본 사용법

package main

import (
	"container/list"
	"fmt"
)

func main() {
	l := list.New()

	l.PushBack(10)  // 뒤에 추가
	l.PushBack(20)
	l.PushFront(5)  // 앞에 추가

	// 순회
	for e := l.Front(); e != nil; e = e.Next() {
		fmt.Println(e.Value)
	}
}

출력:

5
10
20

주요 메서드

메서드설명
PushBack(v)뒤에 추가
PushFront(v)앞에 추가
InsertBefore(v, e)노드 e 앞에 삽입
InsertAfter(v, e)노드 e 뒤에 삽입
Remove(e)노드 e 제거
MoveToFront(e)노드 e 를 맨 앞으로
MoveToBack(e)노드 e 를 맨 뒤로
Front() / Back()처음 / 마지막 노드
Len()원소 개수

노드와 값

PushBack 등은 새 노드 *list.Element 를 반환한다. 이 노드를 보관해 두면 나중에 O(1) 로 이동/삭제할 수 있다.

l := list.New()
e1 := l.PushBack("apple")
e2 := l.PushBack("banana")
l.PushBack("cherry")

l.MoveToFront(e2)       // banana 가 맨 앞으로
l.Remove(e1)            // apple 제거

for e := l.Front(); e != nil; e = e.Next() {
	fmt.Println(e.Value)
}
// banana
// cherry

Value 의 정체

e.Value 의 타입은 any 다. 어떤 값이든 넣을 수 있지만, 꺼낼 때 타입 단언이 필요하다.

e := l.Front()
s := e.Value.(string)  // string 으로 타입 단언
fmt.Println(len(s))

container/list 는 제네릭 도입 전부터 존재했다. 그래서 any 기반이다. 타입 안전이 중요하다면 17장의 제네릭으로 직접 래퍼를 만드는 편이 낫다.


18.4 큐 (FIFO) 구현하기

큐(Queue)는 “먼저 들어간 게 먼저 나온다” 규칙이다. 영어 두문자로 FIFO (First-In-First-Out).

은행 창구 줄, 프린터 작업 대기열 등이 큐다.

슬라이스로 구현 — 간단하지만 함정 있음

package main

import "fmt"

func main() {
	queue := []int{}

	// enqueue
	queue = append(queue, 1)
	queue = append(queue, 2)
	queue = append(queue, 3)

	// dequeue
	first := queue[0]
	queue = queue[1:]

	fmt.Println(first)  // 1
	fmt.Println(queue)  // [2 3]
}

append 로 뒤에 넣고 queue[1:] 로 앞을 잘라낸다. 짧고 직관적이다.

하지만 숨은 함정이 있다.

queue := make([]int, 0, 1000)
for i := 0; i < 1000; i++ {
	queue = append(queue, i)
}
for i := 0; i < 1000; i++ {
	queue = queue[1:]  // 앞을 자르기만 한다
}

// queue 는 비었지만…
// 원래 1000칸짜리 배열은 여전히 메모리에 남아 있다

queue[1:] 는 배열의 “창문“만 이동시킬 뿐 실제로 0번째 요소를 메모리에서 지우지 않는다. 오래 돌리는 프로그램에선 메모리 누수가 된다.

해결책은 세 가지다.

  1. 가끔 copy 로 새 슬라이스에 옮기기
  2. 큐가 비었을 때 queue = queue[:0] 으로 리셋
  3. 링 버퍼(원형 버퍼)를 직접 구현

링 버퍼는 같은 배열의 두 인덱스 (head, tail) 를 모듈러 연산으로 돌리는 방식이다. 복잡도가 올라가는 만큼 여기선 개념만 짚는다.

실무에서는 큐가 늘 작거나, 큐 사용 패턴이 “잠깐 쌓였다가 비워지는” 식이면 슬라이스 그대로 써도 충분하다.

container/list 로 구현

연결 리스트는 앞에서 빼고 뒤에 넣는 게 둘 다 O(1) 이다. 큐 구현에 자연스럽게 어울린다.

package main

import (
	"container/list"
	"fmt"
)

type Queue struct {
	l *list.List
}

func NewQueue() *Queue {
	return &Queue{l: list.New()}
}

func (q *Queue) Enqueue(v any) {
	q.l.PushBack(v)
}

func (q *Queue) Dequeue() (any, bool) {
	front := q.l.Front()
	if front == nil {
		return nil, false
	}
	q.l.Remove(front)
	return front.Value, true
}

func (q *Queue) Len() int {
	return q.l.Len()
}

func main() {
	q := NewQueue()
	q.Enqueue("first")
	q.Enqueue("second")
	q.Enqueue("third")

	for q.Len() > 0 {
		v, _ := q.Dequeue()
		fmt.Println(v)
	}
}

출력:

first
second
third

15장에서 배운 메서드, 14장에서 배운 포인터 리시버를 그대로 활용한 모양이다.


18.5 스택 (LIFO) 구현하기

스택(Stack)은 “나중에 들어간 게 먼저 나온다” 규칙이다. 영어 두문자로 LIFO (Last-In-First-Out).

쌓아 둔 책 더미, 함수 호출 스택, “되돌리기(Undo)” 기능 등이 스택이다.

슬라이스로 깔끔하게

스택은 슬라이스로 짜는 게 가장 자연스럽다. 앞을 건드릴 일이 없어 큐의 함정에서 자유롭다.

package main

import "fmt"

func main() {
	stack := []int{}

	// push
	stack = append(stack, 1)
	stack = append(stack, 2)
	stack = append(stack, 3)

	// pop
	n := len(stack)
	top := stack[n-1]
	stack = stack[:n-1]

	fmt.Println(top)   // 3
	fmt.Println(stack) // [1 2]
}

append 로 뒤에 쌓고 마지막 인덱스를 꺼낸 뒤 슬라이스를 한 칸 줄인다. 두 연산 모두 평균 O(1) 이다.

제네릭 스택

17장에서 본 제네릭으로 타입 안전한 스택을 만들 수 있다.

package main

import "fmt"

type Stack[T any] struct {
	data []T
}

func (s *Stack[T]) Push(v T) {
	s.data = append(s.data, v)
}

func (s *Stack[T]) Pop() (T, bool) {
	var zero T
	n := len(s.data)
	if n == 0 {
		return zero, false
	}
	top := s.data[n-1]
	s.data = s.data[:n-1]
	return top, true
}

func (s *Stack[T]) Peek() (T, bool) {
	var zero T
	n := len(s.data)
	if n == 0 {
		return zero, false
	}
	return s.data[n-1], true
}

func (s *Stack[T]) Len() int {
	return len(s.data)
}

func main() {
	s := &Stack[string]{}
	s.Push("a")
	s.Push("b")
	s.Push("c")

	for s.Len() > 0 {
		v, _ := s.Pop()
		fmt.Println(v)
	}
}

출력:

c
b
a
  • 타입 매개변수 T any 로 어떤 타입이든 담을 수 있다
  • var zero T 로 T 의 제로값을 안전하게 만든다
  • 호출 측이 타입 단언을 할 필요가 없다

Pop 이 두 값을 돌려주는 패턴은 Go 에서 매우 흔한 관용구다. “값 + 성공 여부” 또는 “값 + 에러” 형태.


18.6 원형 자료구조: container/ring

container/ring 은 양방향 원형 연결 리스트다. “끝이 처음으로 이어진” 모양을 떠올리면 된다.

 ┌──→ [A] ──→ [B] ──→ [C] ──→ [D] ──┐
 │                                   │
 └───────────────────────────────────┘

라운드 로빈(돌아가며 처리하기) 같은 용도에 어울린다.

기본 사용법

package main

import (
	"container/ring"
	"fmt"
)

func main() {
	r := ring.New(4)  // 길이 4 짜리 링 생성

	// 모든 자리에 값 채우기
	for i := 0; i < r.Len(); i++ {
		r.Value = i + 1
		r = r.Next()
	}

	// 한 바퀴 출력
	r.Do(func(v any) {
		fmt.Println(v)
	})
}

출력:

1
2
3
4

주요 메서드

메서드설명
ring.New(n)길이 n 인 링 생성
Next()다음 노드로 이동
Prev()이전 노드로 이동
Move(n)n 칸 이동 (음수면 반대)
Do(f)모든 노드를 순회하며 함수 적용
Len()노드 개수
Value노드의 값 (any)

라운드 로빈 예제

서버 3대에 요청을 번갈아 보낸다고 해 보자.

package main

import (
	"container/ring"
	"fmt"
)

func main() {
	servers := ring.New(3)
	for _, name := range []string{"A", "B", "C"} {
		servers.Value = name
		servers = servers.Next()
	}

	// 7번 요청을 분배
	for i := 0; i < 7; i++ {
		fmt.Printf("요청 %d → 서버 %v\n", i, servers.Value)
		servers = servers.Next()
	}
}

출력:

요청 0 → 서버 A
요청 1 → 서버 B
요청 2 → 서버 C
요청 3 → 서버 A
요청 4 → 서버 B
요청 5 → 서버 C
요청 6 → 서버 A

Next() 가 반환하는 새 노드를 변수에 다시 대입해야 한다. 안 그러면 같은 자리에서 계속 맴돈다.


18.7 우선순위 큐: container/heap 맛보기

우선순위 큐는 “가장 우선순위 높은 것부터 꺼내는” 큐다. 긴급한 작업 먼저 처리하기, 다익스트라 알고리즘 등에 쓰인다.

Go 는 container/heap 으로 최소 힙의 토대를 제공한다. 직접 자료구조를 들고 있지는 않다. 대신 heap.Interface 만 만족시키면 어떤 컬렉션이든 힙처럼 다룰 수 있게 해 준다.

heap.Interface

type Interface interface {
	sort.Interface          // Len, Less, Swap
	Push(x any)
	Pop() any
}

이 다섯 메서드만 구현하면 힙으로 동작한다.

정수 최소 힙 예제

package main

import (
	"container/heap"
	"fmt"
)

// IntHeap 은 정수 슬라이스 위에 만든 최소 힙
type IntHeap []int

func (h IntHeap) Len() int           { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] }
func (h IntHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }

func (h *IntHeap) Push(x any) {
	*h = append(*h, x.(int))
}

func (h *IntHeap) Pop() any {
	old := *h
	n := len(old)
	x := old[n-1]
	*h = old[0 : n-1]
	return x
}

func main() {
	h := &IntHeap{5, 1, 4, 2, 3}
	heap.Init(h)

	heap.Push(h, 0)

	for h.Len() > 0 {
		fmt.Println(heap.Pop(h))
	}
}

출력:

0
1
2
3
4
5

작은 수부터 차례로 나온다.

동작 흐름

단계설명
heap.Init기존 슬라이스를 힙 모양으로 정리
heap.Push새 값을 넣고 힙 속성 유지
heap.Pop가장 작은(또는 큰) 값을 꺼냄

핵심 포인트:

  • Less 의 반환값을 뒤집으면 최대 힙이 된다
  • Push/Pop 메서드는 slice 자체 조작만 한다
  • 힙 속성은 heap.Push / heap.Pop 이 알아서 유지한다

처음 보면 메서드가 두 종류로 갈리는 게 헷갈린다. 소문자 Push / Pop 은 내부 구현용, heap.Push(h, x) 가 우리가 호출하는 외부 API다.


18.8 연산별 속도 비교표

각 자료구조의 연산별 평균 시간 복잡도를 비교해 본다.

연산슬라이스 []Tcontainer/listmap[K]V
인덱스 접근 s[i]O(1)O(n)
키로 조회O(n) (선형 탐색)O(n)O(1)
맨 뒤에 추가평균 O(1)O(1)
맨 앞에 추가O(n)O(1)
중간에 삽입O(n)O(1) (노드를 알 때)
맨 뒤 제거O(1)O(1)
맨 앞 제거O(n)O(1)
키로 삭제O(n)O(n)O(1)
메모리 오버헤드낮음노드당 포인터 2개버킷 + 해시값
캐시 친화성매우 좋음나쁨보통

표 읽는 법

  • “맨 앞 제거” 는 슬라이스가 O(n) 이지만, 앞에서 본 queue[1:] 트릭으로 O(1) 처럼 쓸 수 있다 (단, 메모리 누수 주의)
  • container/list 의 “중간 삽입 O(1)” 은 삽입 위치 노드를 이미 갖고 있을 때만 성립한다
  • 맵의 “—” 는 그 연산이 의미 없거나 비효율적이라는 뜻

그래서 결론은

대부분의 경우 슬라이스가 가장 빠르다. 이론적 복잡도가 약간 불리해도 캐시 친화성이 그 차이를 메우고도 남는다.

다음 두 경우에만 다른 도구를 고려하면 된다.

  • 키로 빠르게 찾고 싶다 → 맵
  • 중간 노드 위치를 들고 자주 옮기고 싶다 → container/list

18.9 어떤 걸 언제 쓰나

상황별 추천을 표로 정리한다.

상황추천 도구
인덱스 순서대로 다룬다슬라이스
키로 찾아간다
스택이 필요하다슬라이스
큐가 필요하다 (작은 규모)슬라이스
큐가 필요하다 (긴 수명)container/list 또는 링 버퍼
중간에서 잦은 삽입/삭제container/list
LRU 캐시 비슷한 것container/list + 맵 조합
라운드 로빈 / 순환 버퍼container/ring
우선순위에 따라 꺼낸다container/heap 기반 우선순위 큐
중복 없는 원소 집합맵 (map[T]struct{})

결정 흐름도

어떤 연산이 가장 자주 일어나는가?

├─ 인덱스로 접근 → 슬라이스
├─ 키로 조회 → 맵
├─ "맨 뒤에 넣고 맨 뒤에서 꺼냄" → 슬라이스 (스택)
├─ "맨 뒤에 넣고 맨 앞에서 꺼냄"
│  ├─ 데이터가 짧게 머무름 → 슬라이스
│  └─ 데이터가 오래 누적됨 → container/list
├─ "가장 작은/큰 것부터 꺼냄" → container/heap
└─ "끝없이 순환" → container/ring

흔히 보이는 안티패턴

  • 그냥 슬라이스로 충분한데 container/list 를 쓰는 경우 (대부분 손해다)
  • 슬라이스를 그대로 큐로 쓰고 메모리 누수를 방치하는 경우
  • 키 검색이 잦은데 슬라이스를 선형 탐색하는 경우 (요소가 100개를 넘으면 거의 맵이 낫다)

18.10 정리

이 장에서 본 내용:

  • Go 표준 자료구조는 단출하다 — 슬라이스, 맵 + container/*
  • 슬라이스는 연속 메모리, 연결 리스트는 흩어진 노드
  • 대부분의 경우 캐시 친화성 덕분에 슬라이스가 더 빠르다
  • container/list 는 이중 연결 리스트
    • 중간 삽입/삭제가 잦거나 노드 이동이 잦을 때
  • 큐는 슬라이스 또는 container/list 로 만든다
    • 슬라이스 큐의 메모리 누수 함정 조심
  • 스택은 슬라이스가 자연스럽다
  • 제네릭으로 타입 안전한 스택을 깔끔하게 만들 수 있다
  • container/ring 은 라운드 로빈용 원형 리스트
  • container/heap 으로 우선순위 큐를 만든다
    • heap.Interface 만 만족시키면 된다
  • “특별한 이유 없으면 슬라이스” 가 Go 진영의 격언

이로써 “여러 데이터를 어떻게 묶어 다루느냐“의 큰 그림이 끝난다. 다음 장에서는 맵의 내부 구조를 더 깊이 들여다본다. “왜 맵은 그렇게 빠른가”, “왜 순회 순서가 매번 달라지는가” 같은 질문에 답할 차례다.

19장. 맵 깊이 이해하기

12장에서 맵의 기본 사용법을 익혔다. 선언하고, 값을 넣고, 키로 꺼내고, 삭제하는 정도였다.

이번엔 한 층 더 내려간다.

  • 맵 안쪽은 어떻게 생겼는가
  • 왜 그렇게 빠른가
  • 왜 가끔은 느려지는가
  • 왜 순회 순서가 매번 다른가
  • 왜 동시 접근에 안전하지 않은가

내부 구조를 알면 함정을 피할 수 있고, “맵을 써야 할지 슬라이스를 써야 할지” 같은 결정도 근거를 갖고 내릴 수 있게 된다.

이 장의 목표는 세 가지다.

  • 해시 테이블이 어떻게 동작하는지 큰 그림으로 이해하기
  • Go 맵의 특이한 성질들을 그 이유까지 알기
  • 맵을 잘 쓰는 패턴 몇 가지를 손에 익히기

Big-O 표기는 도구로만 쓴다. 표기 자체가 낯설면 다른 자료를 한 번 훑어 두고 오자.


19.1 해시 테이블이란

맵은 내부적으로 해시 테이블(hash table)이다. 이름은 거창하지만 발상은 단순하다.

“키를 숫자로 바꿔서 그 숫자를 배열 인덱스로 쓰자.”

배열의 인덱스 접근이 O(1) 이라는 점에 기댄 아이디어다.

단계별로 따라가기

m["apple"] = 100 을 저장하는 과정을 그려 보자.

1단계. 해시 함수가 키를 큰 정수로 바꾼다.

"apple" ──[해시 함수]──→ 0x9F4A...8B2D (큰 정수)

2단계. 그 정수를 버킷 개수로 나눈 나머지를 구한다.

0x9F4A...8B2D % 8 = 3

3단계. 3번 버킷에 (“apple”, 100) 을 저장한다.

버킷 0: [          ]
버킷 1: [          ]
버킷 2: [          ]
버킷 3: [ apple=100 ]  ← 여기
버킷 4: [          ]
버킷 5: [          ]
버킷 6: [          ]
버킷 7: [          ]

조회할 때는 같은 과정이다.

"apple" → 해시 → 0x9F4A...8B2D → % 8 → 3번 버킷에서 "apple" 찾기

키만 알면 어디로 가야 할지 즉시 안다. 이게 평균 O(1) 의 비밀이다.

해시 함수의 역할

좋은 해시 함수의 조건은 두 가지다.

  • 빠르게 계산된다
  • 결과가 골고루 흩어진다 (편중되지 않음)

값이 골고루 흩어져야 모든 버킷에 비슷한 수의 키가 들어간다. 한 버킷에만 몰리면 그 안에서 다시 찾아야 하니 O(1) 의 환상이 깨진다.

Go 의 해시 함수는 런타임이 알아서 골라 준다. 사용자가 신경 쓸 필요는 없다.

충돌(collision)

해시 함수가 아무리 잘 만들어져도, 서로 다른 키가 같은 버킷으로 가는 일은 반드시 생긴다.

"apple"  → 3번 버킷
"orange" → 3번 버킷  ← 충돌!

이걸 어떻게 처리하느냐가 해시 테이블 설계의 핵심 주제다.


19.2 Go 맵의 내부 구조

Go 맵은 표준적인 해시 테이블 구현이다. 실제 구조를 단순화해서 그리면 이렇다.

버킷 배열

맵은 내부적으로 버킷의 배열을 갖고 있다.

hmap (맵 헤더)
 │
 ├─ count: 5         (원소 개수)
 ├─ B: 3             (버킷 개수의 로그 — 2^3 = 8개)
 └─ buckets:
      ┌──────────────────────┐
      │ bucket 0             │
      ├──────────────────────┤
      │ bucket 1             │
      ├──────────────────────┤
      │ ...                  │
      ├──────────────────────┤
      │ bucket 7             │
      └──────────────────────┘

각 버킷은 보통 한 자리짜리가 아니라 여러 키-값 쌍을 담을 수 있는 작은 슬롯 모음이다.

bucket 3
 ┌────────────────────────────┐
 │ slot 0: "apple"   → 100    │
 │ slot 1: "grape"   → 50     │
 │ slot 2: (비어 있음)         │
 │ ...                         │
 │ overflow → 다른 버킷 포인터  │
 └────────────────────────────┘

충돌 해결: chaining

Go 맵은 같은 버킷에 들어온 키들을 그 안에 묶어 둔다. 이 방식을 chaining (체이닝) 이라 부른다.

같은 버킷에 자리가 부족하면 “오버플로우 버킷” 을 매달아 사슬처럼 잇는다.

bucket 3                  overflow bucket
 ┌──────────────┐          ┌──────────────┐
 │ apple → 100  │          │ peach → 75   │
 │ grape → 50   │   ──→    │ lemon → 30   │
 │ kiwi  → 90   │          │              │
 │ overflow →   │          └──────────────┘
 └──────────────┘

조회 시에는 같은 버킷의 슬롯을 차례로 비교하면서 정확한 키를 찾는다. 한 버킷의 슬롯 수는 작은 상수라 이 안에서의 선형 탐색은 무시할 만하다.

로드 팩터

해시 테이블의 핵심 지표가 로드 팩터(load factor) 다.

로드 팩터 = 원소 수 / 버킷 수
  • 너무 작으면 (= 버킷이 텅 빔): 메모리 낭비
  • 너무 크면 (= 버킷마다 많이 몰림): 충돌 잦아져 느려짐

Go 는 로드 팩터가 일정 임계치 (대략 6.5) 를 넘으면 버킷 개수를 두 배로 늘려 충돌을 줄인다.


19.3 동적 확장 (resize / rehash)

맵에 키가 점점 쌓이면 로드 팩터가 커진다. 임계치를 넘는 순간 맵은 스스로를 확장한다.

무슨 일이 일어나는가

  1. 새 버킷 배열을 만든다 (보통 2배 크기)
  2. 기존 버킷에 있던 키-값을 새 자리로 옮긴다
  3. 옮기는 과정에서 해시 값을 새 버킷 수로 다시 나눈다 (rehash)
확장 전 (버킷 8개)         확장 후 (버킷 16개)
┌────────────────┐         ┌────────────────┐
│ bucket 0       │         │ bucket  0      │
│ bucket 1       │         │ bucket  1      │
│ ...            │         │ ...            │
│ bucket 7       │         │ bucket 15      │
└────────────────┘         └────────────────┘
                              ↑
                       이 안에 새 자리에서
                       키들이 다시 흩어져 들어감

점진적 이주 (incremental rehashing)

키가 백만 개라면 한 번에 옮기다가 멈춤이 발생할 수 있다. Go 는 이걸 한 번에 다 하지 않는다. 대신 이후 맵 연산 (get, set, delete) 때마다 조금씩 옮긴다.

  • 새 버킷이 만들어지면 일단 이주 시작 상태로 표시
  • 매 연산 때 관련 버킷 한두 개씩 옮김
  • 다 옮겨지면 옛 버킷 배열을 버림

덕분에 큰 맵도 한 번에 멎지 않는다. 대신 트레이드오프가 있다.

왜 가끔 맵 연산이 느려질 수 있나

이주 중인 맵은 모든 연산이 평소보다 살짝 비싸다. 원인은 다음과 같다.

  • 키를 찾을 때 옛 버킷과 새 버킷 양쪽을 봐야 함
  • 매 연산이 이주를 조금씩 수행함

벤치마크에서 가끔 튀는 지점이 생기는 이유다. “맵은 평균 O(1)” 이라는 말이 “항상 O(1)” 이 아닌 까닭이기도 하다.

그래서 큰 맵을 만들 게 예상되면 make(map[K]V, sizeHint) 으로 미리 용량을 지정하는 게 좋다. 초반에 일어날 여러 번의 확장을 피할 수 있다.

// 100만 개를 넣을 예정이라면
m := make(map[string]int, 1_000_000)

19.4 흥미로운 특성들

Go 맵에는 처음 만나면 갸우뚱하는 특성들이 있다. 각각의 이유를 짚어 보자.

순회 순서가 매번 다르다

같은 맵을 두 번 순회해도 순서가 다를 수 있다.

package main

import "fmt"

func main() {
	m := map[string]int{
		"a": 1, "b": 2, "c": 3, "d": 4, "e": 5,
	}

	fmt.Println("첫 번째 순회:")
	for k, v := range m {
		fmt.Println(k, v)
	}

	fmt.Println("두 번째 순회:")
	for k, v := range m {
		fmt.Println(k, v)
	}
}

실행할 때마다, 그리고 같은 실행 안에서도 두 번째 순회는 첫 번째와 다른 순서일 수 있다.

이건 버그가 아니라 의도된 설계다.

왜 일부러 무작위로 만들었나

Go 팀은 다음 시나리오를 막고 싶었다.

  • 개발자가 우연히 한 순서에 맞춰 코드를 짠다
  • Go 버전 업데이트로 내부 알고리즘이 바뀌면 순서도 바뀐다
  • 멀쩡하던 코드가 갑자기 망가진다

그래서 처음부터 “순서에 의존할 수 없다” 는 사실을 강제로 드러내 놓았다. 순회 시작점을 매번 살짝 흔드는 식이다.

정렬된 순서가 필요하면 키들을 슬라이스로 뽑은 뒤 sort.Strings 등으로 정렬해서 순회한다.

import "sort"

keys := make([]string, 0, len(m))
for k := range m {
	keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
	fmt.Println(k, m[k])
}

동시 접근에 안전하지 않다

여러 고루틴이 같은 맵을 동시에 읽고 쓰면 프로그램이 죽거나 데이터가 깨진다.

package main

func main() {
	m := map[int]int{}

	for i := 0; i < 1000; i++ {
		go func(i int) { m[i] = i }(i)  // 위험
	}

	select {}
}

go run -race main.go 로 실행하면 경고와 함께 패닉이 난다.

fatal error: concurrent map writes

이유는 단순하다. 앞서 본 확장 / 이주 과정에서 한 고루틴이 버킷 구조를 바꾸는 중에 다른 고루틴이 그 버킷을 읽으면 일관되지 않은 상태를 보게 된다.

해결책은 두 가지다.

  • sync.Mutex 로 직접 보호
  • 표준 라이브러리의 sync.Map 사용

둘 다 동시성 챕터 (24장 / 25장) 에서 본격적으로 다룬다. 지금은 “그냥 맵은 동시 접근에 안전하지 않다” 정도만 머리에 새겨 두자.


19.5 nil 맵의 함정

맵 선언 방식에 따라 동작이 미묘하게 달라진다. 한 번쯤 호되게 당하고 기억하게 되는 함정이다.

두 가지 선언

var m1 map[string]int          // nil 맵
m2 := make(map[string]int)     // 빈 맵 (사용 가능)

겉으로 둘 다 비어 있지만 내부 상태가 다르다.

항목var m1 map[string]intmake(map[string]int)
초기 값nil빈 맵
len(m)00
읽기 m["x"]제로값 반환 (OK)제로값 반환 (OK)
쓰기 m["x"] = 1패닉OK
delete(m, "x")OK (아무 일도 안 함)OK
for range mOK (0번 돈다)OK

핵심은 굵게 표시한 한 줄이다. nil 맵에 쓰려고 하면 다음과 같이 죽는다.

panic: assignment to entry in nil map

왜 그렇게 동작하나

  • nil 맵은 “맵을 가리키는 포인터가 비어 있는 상태”
  • 읽기는 “어떤 키도 없는 셈” 으로 처리해서 제로값 반환
  • 쓰기는 “어디에 쓸지가 없는 상태” 라 어쩔 수 없이 패닉

읽기와 쓰기의 안전성이 비대칭이라 함수에서 맵을 반환받았을 때 무심코 쓰다가 사고가 난다.

안전한 패턴

type Config struct {
	flags map[string]bool
}

// 생성 시 반드시 초기화
func NewConfig() *Config {
	return &Config{
		flags: make(map[string]bool),
	}
}

구조체 안의 맵 필드는 생성자에서 초기화하는 습관을 들이자.

map[K]V 타입 반환값이 nil 일 수 있는 함수는 호출 측에서 if m == nil 체크를 하든지 명시적으로 빈 맵을 반환하도록 정해 두자.


19.6 맵 vs 슬라이스 검색 속도 실험

머리로만 “맵이 빠르다” 라고 외우는 건 약하다. 숫자로 직접 보자.

다음은 같은 값을 슬라이스 선형 탐색과 맵 조회로 찾는 벤치마크다.

package bench

import "testing"

const N = 100000

var (
	slice []int
	m     map[int]struct{}
)

func init() {
	slice = make([]int, N)
	m = make(map[int]struct{}, N)
	for i := 0; i < N; i++ {
		slice[i] = i
		m[i] = struct{}{}
	}
}

func BenchmarkSliceLookup(b *testing.B) {
	target := N - 1  // 최악 케이스: 맨 끝
	for i := 0; i < b.N; i++ {
		for _, v := range slice {
			if v == target {
				break
			}
		}
	}
}

func BenchmarkMapLookup(b *testing.B) {
	target := N - 1
	for i := 0; i < b.N; i++ {
		_, _ = m[target]
	}
}

실행:

go test -bench=.

대략적인 결과는 다음과 같다 (장비마다 다름).

N (원소 수)슬라이스 검색맵 검색
1,000약 600 ns약 25 ns
100,000약 60 µs약 25 ns

원소 수가 100배가 되어도 맵은 거의 같은 시간이 걸린다 — O(1) 의 위력이다. 슬라이스는 정직하게 100배 느려진다 — O(n).

그래도 슬라이스가 나은 경우

  • 원소가 매우 적다 (대략 10개 이하)
    • 캐시에 통째로 올라와 선형 탐색이 더 빠를 때가 있다
  • 검색은 거의 안 하고 순회만 한다
    • 슬라이스 순회가 보통 더 빠르다
  • 입력 순서를 유지해야 한다

“맵 vs 슬라이스” 를 정할 때는 단순히 “맵이 빠르니까” 가 아니라 실제로 어떤 연산이 자주 일어나는지를 봐야 한다.


19.7 맵을 잘 쓰는 패턴

맵의 진짜 효용은 단순한 키-값 저장을 넘어선다. 실전에서 자주 쓰이는 패턴 몇 가지를 보자.

집합 (Set) 흉내 내기

Go 에는 별도의 Set 자료형이 없다. 대신 맵의 키만 쓰는 식으로 집합을 표현한다.

set := map[string]struct{}{}

set["apple"] = struct{}{}
set["banana"] = struct{}{}

if _, ok := set["apple"]; ok {
	fmt.Println("apple 있음")
}

delete(set, "apple")

값 자리에 왜 struct{}{} 인가? 빈 구조체는 메모리를 차지하지 않기 때문이다.

값 타입한 원소당 추가 바이트
bool1 바이트
int8 바이트 (64비트 환경)
struct{}0 바이트

수백만 개의 집합을 만들 때 차이가 체감된다.

헬퍼로 깔끔하게

매번 struct{}{} 를 적기 번거롭다면 17장의 제네릭으로 작은 Set 타입을 만들 수 있다.

type Set[T comparable] map[T]struct{}

func (s Set[T]) Add(v T)         { s[v] = struct{}{} }
func (s Set[T]) Remove(v T)      { delete(s, v) }
func (s Set[T]) Has(v T) bool    { _, ok := s[v]; return ok }
func (s Set[T]) Len() int        { return len(s) }

func main() {
	s := Set[string]{}
	s.Add("a")
	s.Add("b")
	fmt.Println(s.Has("a"))  // true
	fmt.Println(s.Len())     // 2
}

카운터: 빈도 세기

map[K]int 는 카운터로 안성맞춤이다. 단어 빈도를 세는 예제를 보자.

package main

import (
	"fmt"
	"strings"
)

func main() {
	text := "the quick brown fox jumps over the lazy dog the fox"

	count := map[string]int{}
	for _, word := range strings.Fields(text) {
		count[word]++
	}

	for w, c := range count {
		fmt.Printf("%-6s %d\n", w, c)
	}
}

출력 (순서는 매번 다름):

the    3
fox    2
quick  1
brown  1
jumps  1
over   1
lazy   1
dog    1

핵심은 count[word]++ 한 줄이다. 처음 보는 키여도 count[word] 는 제로값 0 을 반환하므로 거기에 1 을 더해 다시 저장한다.

다른 언어처럼 “키가 있는지 먼저 확인” 할 필요가 없다.

그룹핑

비슷한 발상으로 데이터를 그룹별로 묶을 수 있다.

type Person struct {
	Name string
	City string
}

people := []Person{
	{"Alice", "Seoul"},
	{"Bob", "Busan"},
	{"Carol", "Seoul"},
	{"Dave", "Busan"},
}

byCity := map[string][]string{}
for _, p := range people {
	byCity[p.City] = append(byCity[p.City], p.Name)
}

for city, names := range byCity {
	fmt.Println(city, "→", names)
}

여기서도 append(byCity[p.City], p.Name) 한 줄이 “키가 없으면 nil 슬라이스에 추가” 라는 동작을 자연스럽게 처리한다. nil 슬라이스에 append 하면 새 슬라이스가 만들어지기 때문이다.

캐시 흉내

가장 단순한 형태의 캐시는 그냥 맵이다.

var cache = map[string]string{}

func get(key string) string {
	if v, ok := cache[key]; ok {
		return v
	}
	v := fetchFromDisk(key)  // 비싼 작업
	cache[key] = v
	return v
}

이 코드에는 두 가지 약점이 있다.

  • 동시 접근 시 안전하지 않다 (앞에서 봤다)
  • 무한히 자라서 메모리를 잡아먹는다

본격적인 캐시는 만료 정책, 크기 제한, 동시성 보호가 필요하다. 이런 주제들은 동시성 / 메모리 챕터에서 다시 만난다.


19.8 정리

이 장에서 본 내용:

  • 맵은 해시 테이블이다 — 키를 해시로 바꿔 버킷 인덱스로 쓴다
  • 충돌은 chaining 으로 해결 — 같은 버킷에 여러 키를 묶음
  • 로드 팩터가 임계치를 넘으면 버킷이 2배로 확장된다
  • Go 는 점진적 이주로 한 번에 멎는 일을 피한다
  • 그래서 맵은 “평균 O(1)” 이지만 “항상 O(1)” 은 아니다
  • 순회 순서는 의도적으로 무작위 — 코드의 견고함을 강제하기 위함
  • 동시 접근은 안전하지 않다 — 락 또는 sync.Map 필요
  • nil 맵은 읽기는 OK 지만 쓰기는 패닉 — make 로 초기화하자
  • 큰 검색 데이터셋은 슬라이스 선형 탐색보다 맵이 압도적으로 빠르다
  • 집합은 map[T]struct{}, 카운터는 map[K]int, 그룹핑은 map[K][]V

여기까지가 자료구조 깊이 보기의 마무리다. 다음 부에서는 코드를 잘 정리하고 안전하게 만드는 방법으로 넘어간다. 20장에서는 패키지와 모듈, 21장에서는 에러 처리를 다룬다.

20장. 패키지와 모듈

지금까지는 거의 모든 코드를 main.go 한 파일에 몰아 적었다. 작은 예제일 땐 그래도 괜찮지만, 프로젝트가 조금만 커져도 금세 한계가 온다.

코드를 여러 파일과 디렉터리로 나누고, 나눈 것끼리 잘 연결하는 도구가 필요하다. Go 에서는 패키지(package)모듈(module) 이 이 역할을 맡는다.

이 장의 목표는 다음과 같다.

  • 패키지가 왜 필요한지 이해하기
  • 직접 패키지를 만들고 가져다 써 보기
  • 대문자/소문자 한 글자로 공개 범위가 결정되는 규칙 익히기
  • 모듈, go.mod, go.sum 의 역할 알기
  • 외부 패키지를 받아 쓰는 절차 익히기

20.1 패키지가 왜 필요한가

지금 우리가 짠 프로그램들이 다음 셋 중 하나라도 겪는다면 패키지가 필요하다.

  • 파일 하나가 너무 길어졌다
  • 같은 코드를 여러 프로그램에서 쓰고 싶다
  • 함수 이름이 서로 충돌한다

코드를 정리한다

한 파일에 함수 50개가 들어 있으면, 어디서 무엇을 하는지 찾기 힘들다.

user 관련 코드는 user 패키지에, order 관련 코드는 order 패키지에 둔다. 이렇게 영역을 나누면 코드의 지도가 생긴다.

코드를 재사용한다

한번 잘 만든 greetings 패키지를 이 프로그램에서도, 저 프로그램에서도 쓸 수 있다. 복사-붙여넣기 대신 임포트만 하면 된다.

이름 충돌을 피한다

세상에는 Parse 라는 함수가 수도 없이 많다.

  • json.Parse
  • xml.Parse
  • url.Parse

패키지가 이름 앞에 점 하나로 붙어 누구의 Parse 인지 구분해 준다.

패키지는 “코드를 담는 폴더 + 이름표“라고 생각하면 편하다.


20.2 패키지 만들기

규칙: 디렉터리가 곧 패키지

Go 의 규칙은 단순하다.

  • 한 디렉터리 = 한 패키지
  • 같은 디렉터리의 .go 파일은 모두 같은 패키지에 속해야 한다
  • 디렉터리가 다르면 패키지도 다르다

패키지 이름은 보통 디렉터리 이름과 똑같이 짓는다. 꼭 그래야 하는 건 아니지만, 다르게 지으면 혼란만 커진다.

예제: greetings 패키지

작은 프로젝트를 하나 만들어 본다.

mkdir myapp
cd myapp
go mod init example.com/myapp
mkdir greetings

디렉터리 구조는 이렇게 된다.

myapp/
├── go.mod
└── greetings/

이제 greetings/hello.go 를 만든다.

package greetings

import "fmt"

func Hello(name string) string {
    return fmt.Sprintf("Hello, %s!", name)
}

첫 줄의 package greetings 가 중요하다. 이 파일은 greetings 패키지에 속한다고 컴파일러에게 알린다.

같은 패키지에 파일 더 추가하기

같은 디렉터리에 greetings/bye.go 를 만들어 보자.

package greetings

import "fmt"

func Bye(name string) string {
    return fmt.Sprintf("Goodbye, %s!", name)
}

두 파일 모두 package greetings 다. 이러면 HelloBye 는 같은 패키지에 속한 두 함수가 된다.

만약 한쪽에서 package greetings 라 적고 다른 쪽에서 package farewell 이라 적으면,

found packages greetings and farewell in /myapp/greetings

컴파일러가 바로 거부한다.


20.3 import 와 사용

한 모듈 안에서 다른 패키지 쓰기

방금 만든 greetingsmain.go 에서 가져다 써 보자.

myapp/main.go:

package main

import (
    "fmt"

    "example.com/myapp/greetings"
)

func main() {
    msg := greetings.Hello("Alice")
    fmt.Println(msg)
}

이제 디렉터리는 이런 모습이다.

myapp/
├── go.mod
├── main.go
└── greetings/
    └── hello.go

실행해 본다.

go run .

출력:

Hello, Alice!

import 경로의 정체

가져올 때 적은 경로를 보자.

import "example.com/myapp/greetings"

이 경로는 두 조각으로 이루어져 있다.

부분의미
example.com/myapp모듈 이름 (go.mod 의 module)
/greetings모듈 안의 디렉터리 경로

즉 import 경로는 모듈 이름 + 디렉터리 경로 다. 폴더 트리를 그대로 따라간다.

임포트한 패키지 부르기

임포트한 뒤에는 패키지 이름.식별자 로 접근한다.

greetings.Hello("Alice")

여기서 앞의 greetings 는 import 경로의 마지막 조각이자 파일 안의 package greetings 다. 보통 둘이 일치한다.

import 별칭

길거나 충돌이 날 땐 별칭을 쓸 수 있다.

import g "example.com/myapp/greetings"

func main() {
    fmt.Println(g.Hello("Alice"))
}

별칭은 가끔 필요할 때만 쓰고, 평소엔 원래 이름을 그대로 쓰는 게 좋다.


20.4 대문자/소문자 export 규칙

Go 에는 public 이나 private 같은 키워드가 없다. 대신 이름의 첫 글자가 그 역할을 한다.

대문자로 시작하면 외부 공개, 소문자로 시작하면 패키지 내부 전용.

이 규칙이 함수, 타입, 필드, 변수, 상수 모두에 똑같이 적용된다.

함수 예제

greetings/hello.go:

package greetings

import "fmt"

// 외부에서 쓸 수 있음 (대문자)
func Hello(name string) string {
    return format(name, "Hello")
}

// 패키지 내부 전용 (소문자)
func format(name, word string) string {
    return fmt.Sprintf("%s, %s!", word, name)
}

main.go 에서 호출해 보자.

greetings.Hello("Alice")  // OK
greetings.format("A", "B") // 컴파일 에러

두 번째 줄은 다음과 같은 에러가 난다.

cannot refer to unexported name greetings.format

구조체 필드도 똑같다

package user

type User struct {
    Name string // 외부 접근 가능
    age  int    // 같은 패키지 안에서만 보임
}

다른 패키지에서 User 를 쓰면,

u := user.User{Name: "Alice"}
fmt.Println(u.Name)  // OK
fmt.Println(u.age)   // 컴파일 에러

age 는 보이지도 않는다.

한눈에 정리

첫 글자의미예시
대문자exported (공개)Hello, Name
소문자unexported (내부)format, age

가독성을 위해서가 아니다. 컴파일러가 진짜로 첫 글자를 본다.


20.5 모듈이란

패키지가 코드를 담는 폴더라면, 모듈 은 그 폴더들을 한 단위로 묶은 큰 상자다.

모듈의 정의

모듈은 다음 세 가지를 한꺼번에 가지는 묶음이다.

  • 여러 개의 패키지
  • 하나의 버전 (예: v1.2.3)
  • 외부 의존성 목록

모듈 하나는 보통 하나의 git 저장소에 대응한다. GitHub 리포지토리 하나가 모듈 하나라고 보면 편하다.

go.mod 가 모듈을 정의한다

디렉터리 어딘가에 go.mod 파일이 있으면 거기서부터가 한 모듈이다.

myapp/                <- 여기가 모듈 루트
├── go.mod
├── main.go
├── greetings/        <- 패키지
└── user/             <- 패키지

go mod init 명령으로 만들었던 그 파일이다.

패키지와 모듈의 관계

이 둘은 다른 층위의 개념이다.

개념단위비유
패키지디렉터리 하나책의 한 챕터
모듈여러 패키지의 묶음책 한 권

작은 프로젝트는 모듈 하나에 패키지도 하나(main)만 있을 수도 있다. 큰 프로젝트는 모듈 하나에 패키지 수십 개가 들어 있을 수도 있다.


20.6 go.mod 와 go.sum

go.mod 의 구조

go mod init example.com/myapp 으로 만든 파일이다.

module example.com/myapp

go 1.22

처음엔 단 두 줄이다. 외부 패키지를 받기 시작하면 점점 늘어난다.

module example.com/myapp

go 1.22

require (
    github.com/google/uuid v1.6.0
    github.com/stretchr/testify v1.9.0
)

각 줄의 역할:

역할
module ...이 모듈의 이름 (import 경로의 뿌리)
go ...어떤 Go 언어 버전을 가정하는지
require ...어떤 외부 모듈을 어느 버전으로 쓰는지

go.sum 의 역할

go.sum 은 외부 모듈을 처음 받을 때 함께 생긴다. 안을 열어 보면 알 수 없는 해시값이 줄줄이 적혀 있다.

github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

이 파일의 역할은 두 가지다.

  • 보안 — 다운로드한 의존성이 변조되지 않았는지 검증
  • 재현성 — 다른 컴퓨터에서도 정확히 같은 코드를 받게 함

손으로 편집하지 않는다

go.modgo.sum 모두 직접 텍스트 에디터로 고치는 일은 거의 없다. 다음 명령들이 알아서 갱신해 준다.

go get github.com/...      # 의존성 추가
go mod tidy                # 안 쓰는 것 정리, 누락된 것 추가
go mod download            # 의존성 미리 받아두기

go.modgo.sum 은 둘 다 git 에 커밋한다. 빠지면 다른 사람이 같은 빌드를 만들 수 없다.


20.7 외부 패키지 사용 (go get)

표준 라이브러리만으로는 부족할 때가 있다. 그때는 다른 사람이 만든 패키지를 가져다 쓴다.

패키지 추가

예를 들어 UUID(고유 식별자) 생성 라이브러리를 써 보자.

go get github.com/google/uuid

이 한 줄로 일어나는 일:

  1. 인터넷에서 해당 모듈 최신 버전을 받음
  2. go.modrequire 에 항목 추가
  3. go.sum 에 해시 추가
  4. 로컬 캐시($GOPATH/pkg/mod)에 저장

코드에서 쓰기

package main

import (
    "fmt"

    "github.com/google/uuid"
)

func main() {
    id := uuid.New()
    fmt.Println(id)
}

import 경로가 곧 GitHub 주소다. Go 의 모듈 시스템은 “import 경로 = 가져올 위치” 라는 단순한 규칙을 따른다.

특정 버전 지정

버전을 직접 지정하고 싶으면 @ 뒤에 적는다.

go get github.com/google/uuid@v1.6.0
go get github.com/google/uuid@latest

정리하기: go mod tidy

코드를 짜다 보면 go.mod 가 실제 코드와 어긋날 때가 있다.

  • 코드는 안 쓰는데 require 에 남아 있거나
  • 코드는 쓰는데 require 가 누락되었거나

이럴 땐 한 줄이면 된다.

go mod tidy

소스 코드를 훑어서 필요한 건 추가하고 안 쓰는 건 지운다. 새 외부 패키지를 import 한 다음 습관적으로 한 번 돌려 주면 좋다.

자주 쓰는 명령 정리

명령역할
go mod init <name>새 모듈 만들기
go get <path>의존성 추가 / 업데이트
go mod tidygo.mod 와 실제 코드 일치시키기
go mod download의존성 미리 받기
go list -m all의존성 목록 보기

20.8 init 함수

패키지가 처음 사용될 때 딱 한 번만 실행되는 특별한 함수가 있다.

기본 사용

package config

import "fmt"

var Settings map[string]string

func init() {
    Settings = make(map[string]string)
    Settings["env"] = "development"
    fmt.Println("config 패키지 초기화")
}

init 함수의 규칙:

  • 매개변수도 반환값도 없다
  • 직접 호출할 수 없다
  • 패키지가 로드될 때 Go 런타임이 알아서 부른다
  • 한 패키지 안에 여러 개를 둘 수 있다

호출 순서

여러 init 함수가 있을 때의 순서는 정해져 있다.

  1. 임포트한 패키지의 init 이 먼저 다 끝남
  2. 그다음 현재 패키지의 패키지 수준 변수가 초기화됨
  3. 그다음 같은 패키지 안의 init 들이 차례로 실행됨
  4. 마지막으로 main.main 이 호출됨

같은 패키지 안에 여러 파일이 있고 각 파일에 init 이 있다면, 파일 이름 순서대로 실행된다.

신중히 쓰라

init 은 편리해 보이지만 함정도 많다.

  • 어디서 무엇이 실행되는지 추적이 어려워진다
  • 테스트할 때 부수 효과가 자꾸 따라온다
  • 임포트만 했는데 실행이 일어난다

가능하면 init 대신 명시적으로 호출되는 Setup() / New() 함수를 쓰자.

init 의 좋은 용도는 한정적이다.

  • 미리 계산해 두는 룩업 테이블 만들기
  • 정규표현식 컴파일 결과 캐싱
  • 표준 라이브러리에 자기 자신 등록하기 (예: 이미지 디코더, DB 드라이버)

20.9 internal 패키지

대문자 한 글자만으로 공개 여부를 가르는 방식은 단순하지만, 가끔 더 강한 캡슐화가 필요하다.

“이 패키지는 우리 모듈 안에서만 쓰고, 바깥 모듈은 절대 쓰지 못하게 막고 싶다.”

Go 는 이를 위해 internal 디렉터리 규칙을 둔다.

규칙

경로 어딘가에 internal 이라는 디렉터리가 있으면, 그 안의 패키지는 다음 영역에서만 임포트할 수 있다.

internal 의 부모 디렉터리, 그리고 그 아래의 모든 코드.

밖에서는 import 자체가 거부된다.

예제

myapp/
├── go.mod
├── main.go
├── api/
│   └── handler.go
└── internal/
    └── db/
        └── db.go

이 구조에서

  • main.go, api/handler.gointernal/db 를 임포트할 수 있다
  • myapp 바깥의 다른 모듈은 example.com/myapp/internal/db 를 임포트하면 컴파일 에러가 난다

언제 쓰는가

  • 라이브러리 작성자가 “이건 구현 세부사항이니 손대지 마세요“라고 선언할 때
  • 모듈 내부에서만 공유하는 헬퍼 패키지를 둘 때

오픈 소스 라이브러리들에 자주 보인다. 표준 라이브러리에도 곳곳에 internal 이 들어 있다.


20.10 정리

이 장에서 살펴본 내용:

  • 패키지는 같은 디렉터리에 모인 .go 파일들의 묶음이다
  • import 경로는 모듈 이름 + 디렉터리 경로
  • 이름의 첫 글자가 대문자면 외부 공개, 소문자면 패키지 내부 전용이다
  • 모듈은 여러 패키지를 묶은 단위이며 go.mod 가 그 정의를 담는다
  • go.sum 은 의존성 해시로 보안과 재현성을 보장한다
  • 외부 패키지는 go get 으로 받고 go mod tidy 로 정리한다
  • init 함수는 패키지 초기화에 쓰이지만 신중히 쓴다
  • internal 디렉터리로 강한 캡슐화를 만들 수 있다

이로써 코드를 어떻게 나누고 어떻게 모아 쓸지 도구를 다 갖췄다.

다음 장에서는 또 다른 큰 주제, “에러를 어떻게 다룰 것인가“를 살펴본다. Go 는 다른 언어와는 사뭇 다른 길을 택했는데, 그 사고방식을 차근차근 풀어 본다.

21장. 에러 처리

프로그램은 자주 실패한다.

  • 열려고 한 파일이 없거나
  • 네트워크가 끊겼거나
  • 사용자 입력이 이상하거나
  • DB 가 잠깐 응답을 안 한다거나

이런 상황을 어떻게 표현하고 어떻게 처리할지가 언어마다 다르다. Java, Python, JavaScript 는 예외(exception) 를 쓴다. Go 는 그 길을 택하지 않았다.

이 장의 목표는 다음과 같다.

  • Go 의 에러 처리 철학을 이해하기
  • error 인터페이스의 정체 알기
  • 에러 반환 / 검사 / 래핑 패턴 익히기
  • sentinel 에러와 사용자 정의 에러 만들기
  • panicrecover 의 올바른 자리 알기
  • 흔한 안티패턴 피하기

21.1 Go 의 에러 처리 철학

예외가 없다

대부분의 언어는 이런 식이다.

try:
    f = open("x.txt")
except FileNotFoundError:
    ...

함수는 “성공한 결과“만 반환하고, 실패하면 어딘가 위에서 잡혀 줄 거라 기대한다.

Go 에는 이 메커니즘이 없다.

에러는 값(value) 으로 반환된다.

함수 시그니처에 떡하니 적혀 있고, 호출하는 쪽에서 명시적으로 처리한다.

왜 이렇게 했는가

Go 설계자들의 주장은 단순하다.

  • 예외는 흐름을 보이지 않게 만든다
  • 어떤 함수가 무엇을 던질지 알기 어렵다
  • “조용히 위로 던지면 누군가 잡겠지” 가 대부분의 사고로 이어졌다

대신 Go 는 이렇게 강제한다.

“에러가 나는 함수는 에러를 반환해라. 호출자는 그것을 받아서 결정해라.”

명시성의 대가와 보상

물론 단점도 있다.

if err != nil {
    return err
}

이 패턴이 코드 곳곳에 깔린다. “왜 이렇게 장황하냐” 는 비판도 있다.

대신 얻는 게 있다.

  • 어떤 함수가 실패할 수 있는지 한눈에 보인다
  • 처리하지 않은 에러를 컴파일러가 자주 잡아 준다
  • 흐름이 위에서 아래로 정직하게 흐른다

panic 이라는 메커니즘도 있긴 하지만, 일반적인 에러 처리에 쓰는 도구가 아니다. 그 얘기는 뒤(21.7)에서 따로 다룬다.


21.2 error 인터페이스

Go 에서 “에러“라는 건 사실 하나의 인터페이스를 만족하는 어떤 값일 뿐이다.

정의

표준 라이브러리에 이렇게 정의되어 있다.

type error interface {
    Error() string
}

이게 전부다. Error() string 메서드 하나만 가지면 무엇이든 error 가 될 수 있다.

16장의 인터페이스가 떠오르는가

16장에서 인터페이스를 배웠다. “이런 메서드를 가진 타입이면 다 받겠다” 는 약속이었다.

error 는 그 약속의 가장 대표적인 사례다.

  • 표준 라이브러리의 에러
  • 외부 라이브러리의 에러
  • 내가 만든 에러 타입
  • errors.New 가 만든 단순한 에러

이 모두가 똑같은 error 인터페이스 뒤로 들어온다. 호출하는 쪽은 종류를 몰라도 err.Error() 로 메시지를 얻거나 if err != nil 로 발생 여부를 검사할 수 있다.

출력해 보면 그냥 메시지

package main

import (
    "errors"
    "fmt"
)

func main() {
    err := errors.New("something broke")
    fmt.Println(err)         // something broke
    fmt.Println(err.Error()) // something broke
}

fmt.Printlnerror 를 보면 알아서 Error() 를 호출해 준다.


21.3 에러 반환 관례

마지막 반환값이 error

Go 의 관례는 단순하다.

에러를 낼 수 있는 함수는 마지막 반환값으로 error 를 돌려준다.

표준 라이브러리의 예를 보자.

func Open(name string) (*File, error)
func ReadFile(name string) ([]byte, error)
func Atoi(s string) (int, error)

성공하면 결과를 반환하고 errnil, 실패하면 결과는 의미 없는 값이고 err 가 채워진다.

호출 패턴

result, err := someFunc()
if err != nil {
    // 에러 처리
    return err
}
// result 사용

이 다섯 줄짜리 모양이 Go 코드의 절반쯤을 채운다. 지겹다 싶을 만큼 자주 본다.

실전 예제

파일을 열어 내용을 출력하는 코드.

package main

import (
    "fmt"
    "os"
)

func main() {
    data, err := os.ReadFile("hello.txt")
    if err != nil {
        fmt.Println("파일 읽기 실패:", err)
        return
    }
    fmt.Println(string(data))
}

흐름이 보이는 그대로 흐른다.

  1. 파일 읽기 시도
  2. 실패하면 메시지 출력 후 종료
  3. 성공하면 내용 출력

nil 검사를 빼먹으면

err 를 검사하지 않고 결과를 그대로 쓰면 조용히 잘못된 값으로 진행하다가 나중에 엉뚱한 곳에서 터진다.

n, _ := strconv.Atoi("abc")
fmt.Println(n + 10)  // 10 출력. 사실 변환은 실패함

_ 로 받아 버리면 컴파일러도 더는 도와줄 수 없다.

새 함수를 부를 때마다 반사적으로 if err != nil 을 적는 습관이 든다. Go 개발자들에겐 이게 그냥 호흡이다.


21.4 에러 만들기

errors.New: 가장 간단한 방법

메시지 한 줄짜리 에러를 만들 때 쓴다.

import "errors"

func div(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("0으로 나눌 수 없다")
    }
    return a / b, nil
}

errors.New 가 돌려주는 값은 Error() 메서드를 가진 작은 객체다. 그 자체로 error 인터페이스를 만족한다.

fmt.Errorf: 포맷팅이 필요할 때

메시지에 변수 값을 끼워 넣고 싶을 땐 fmt.Errorf 를 쓴다.

import "fmt"

func div(a, b int) (int, error) {
    if b == 0 {
        return 0, fmt.Errorf("0으로 나눌 수 없다: a=%d", a)
    }
    return a / b, nil
}

fmt.Printf 와 같은 포맷 동사를 다 쓸 수 있다.

사용자 정의 에러 타입

메시지뿐 아니라 추가 정보를 함께 담고 싶을 때, 직접 에러 타입을 만든다.

type ValidationError struct {
    Field string
    Value string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf(
        "필드 %q 의 값 %q 가 올바르지 않다",
        e.Field, e.Value,
    )
}

핵심은 Error() string 메서드를 구현하는 것뿐이다. 이러면 이 타입의 값은 어디든 error 가 들어가는 자리에 쓸 수 있다.

func validateAge(s string) error {
    if s == "" {
        return &ValidationError{Field: "age", Value: s}
    }
    return nil
}

쓰는 쪽은 이렇다.

err := validateAge("")
if err != nil {
    fmt.Println(err)
}

이후 16장에서 본 타입 단언으로 원본 타입을 꺼내 추가 정보에 접근할 수도 있다. 이 방법은 21.5 에서 더 깔끔한 도구(errors.As)로 다시 다룬다.

포인터 리시버(*ValidationError)로 정의하는 게 관례다. 이유는 15장에서 다룬 리시버 선택 규칙과 같다.


21.5 에러 래핑 (%w)

컨텍스트가 없으면 추적이 어렵다

DB 호출이 실패했다고 해 보자. 원래 에러 메시지는 이렇다.

connection refused

이걸 그대로 위로 던지면, 호출 트리의 위에선 무슨 일이 났는지 알 길이 없다.

좋은 코드는 단계마다 컨텍스트를 더한다.

사용자 조회 실패: DB 쿼리 실패: connection refused

이런 식으로 차곡차곡 쌓고 싶다.

%w 로 감싸기

fmt.Errorf 의 포맷 동사 중 %w 가 특별하다.

err := doQuery()
if err != nil {
    return fmt.Errorf("DB 쿼리 실패: %w", err)
}

%w 는 단순히 메시지에 끼워 넣는 게 아니라, 원래 에러를 새 에러 안에 보존한다. 이걸 에러 래핑(wrapping) 이라 부른다.

errors.Is 와 errors.As

래핑된 에러 안을 들여다보는 도구가 둘 있다.

errors.Is(err, target)    // 같은 에러인가 비교
errors.As(err, &target)   // 특정 타입으로 꺼내기

errors.Is

특정 값을 찾을 때 쓴다.

if errors.Is(err, os.ErrNotExist) {
    fmt.Println("파일이 없네요")
}

err 자체가 os.ErrNotExist 든, 누군가 그걸 한 번 감싼 거든, 열 번 감싼 거든 다 찾아낸다.

errors.As

특정 타입으로 꺼낼 때 쓴다.

var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Println("필드:", ve.Field)
}

래핑된 어디쯤에 *ValidationError 가 있다면 그걸 꺼내서 ve 에 담아 준다.

한눈에 비교

도구묻는 것
errors.Is(err, X)“에러 체인에 X 와 같은 값이 있는가?”
errors.As(err, &v)“에러 체인에서 이 타입을 꺼낼 수 있는가?”

21.6 sentinel 에러

미리 정의된 에러 값

특정 상황을 호출자가 비교해서 처리해야 할 때, “이 에러는 이 값입니다” 라고 미리 정해 두는 패턴이 있다.

package store

import "errors"

var ErrNotFound = errors.New("not found")

이 패키지를 쓰는 쪽은 이렇게 한다.

user, err := store.GetUser(id)
if errors.Is(err, store.ErrNotFound) {
    // 사용자가 없는 경우만 따로 처리
    return
}
if err != nil {
    return err
}

이런 미리 정의된 비교용 에러 값을 sentinel(센티넬) 에러 라 부른다.

표준 라이브러리의 sentinel

이미 알게 모르게 자주 쓰고 있었다.

에러 값의미
io.EOF입력 끝에 도달했다
os.ErrNotExist파일이 없다
os.ErrPermission권한이 없다
sql.ErrNoRows조회 결과가 없다

for 루프로 파일을 읽다가 if err == io.EOF 로 끝을 검사해 본 경험이 있다면, sentinel 패턴을 이미 써 본 것이다.

== 비교는 그만, errors.Is 로

옛날 코드에는 이런 비교가 흔하다.

if err == io.EOF {  // 옛 스타일
    ...
}

값이 직접 반환됐다면 잘 동작한다. 하지만 누군가 중간에 %w 로 래핑했다면 이 비교는 실패한다.

요즘 권장 스타일은 항상 이렇다.

if errors.Is(err, io.EOF) {  // 권장
    ...
}

이름 짓기 관례

sentinel 변수는 보통 Err 로 시작한다.

var ErrNotFound = errors.New("not found")
var ErrTimeout  = errors.New("timeout")
var ErrInvalidInput = errors.New("invalid input")

여기서 Err 가 대문자로 시작하는 것은 20장에서 본 export 규칙 때문이다. 패키지 바깥에서 비교에 써야 하므로 공개되어 있다.


21.7 panic 과 recover

지금까지 본 건 다 “값으로서의 에러“였다. Go 에는 또 다른 메커니즘이 따로 있다. 바로 panic 이다.

panic 이란

panic 은 프로그램을 즉시 중단시키는 비상 탈출구다.

func main() {
    a := []int{1, 2, 3}
    fmt.Println(a[10])  // runtime error: index out of range
}

이 코드는 패닉을 일으키며 죽는다.

panic: runtime error: index out of range [10] with length 3

goroutine 1 [running]:
main.main()
    /tmp/main.go:5 +0x18
exit status 2

명시적으로 일으킬 수도 있다.

panic("뭔가 단단히 잘못됐다")

언제 panic 을 쓰는가

원칙은 단호하다.

“정말로 비정상적인 상황, 프로그램이 더 진행해 봐야 의미가 없을 때만 쓴다.”

그런 상황의 예:

  • 절대로 발생할 수 없다고 가정한 분기에 들어왔을 때
  • 프로그램 시작 시점에 필수 설정이 빠져 있을 때
  • 슬라이스 범위 초과, nil 역참조 같은 런타임 위반 (이건 우리가 부르지 않아도 Go 가 알아서 일으킴)

일반적인 실패는 panic 으로 처리하지 않는다.

  • 파일이 없다 → error 반환
  • 입력이 잘못됐다 → error 반환
  • 네트워크가 끊겼다 → error 반환

다른 언어의 throw 처럼 panic 을 던지면 Go 다운 코드가 아니게 된다.

recover

panic 으로 죽어 가는 프로그램을 살릴 수 있는 도구다. 딱 하나 규칙이 있다.

recoverdefer 안에서만 동작한다.

defer 는 9장에서 다뤘다. 함수가 끝날 때 실행되는 마법의 호출이다.

func safeDiv(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("패닉 발생: %v", r)
        }
    }()

    return a / b, nil
}

b 가 0 이면 a / b 가 패닉을 일으키지만, defer 안의 recover 가 잡아서 err 에 담아 정상 반환으로 돌린다.

라이브러리 경계에서 변환하는 패턴

라이브러리를 만들 때 자주 보이는 형태다.

안에서 어쩌다 panic 이 일어나더라도 바깥(사용자)에는 정상적인 error 로 변환해 보낸다.

func Run() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("내부 패닉: %v", r)
        }
    }()
    doWork()
    return nil
}

명명된 반환값(err error) 을 쓰는 이유는, defer 안에서 err 를 바꿔 줘야 하기 때문이다.

recover 를 만능 안전망으로 쓰지 말 것

try/catch 처럼 모든 함수에 두르는 건 잘못된 사용이다. recover 는 라이브러리의 가장 바깥 경계나, HTTP 핸들러 같은 요청 단위 격리 지점에만 쓴다.


21.8 에러 처리 안티패턴

마지막으로, 실전에서 자주 보이는 잘못된 패턴을 모았다.

안티패턴 1: 에러 무시하기

n, _ := strconv.Atoi(s)

_ 로 받아 버리면 변환 실패가 조용히 묻힌다. n 은 그냥 0 이 되고, 나중에 엉뚱한 곳에서 사고가 난다.

정말로 무시해도 되는 경우엔 주석을 남기자. 대부분은 그런 경우가 아니다.

안티패턴 2: 컨텍스트 없이 그냥 반환

func loadUser(id int) (*User, error) {
    data, err := db.Query(id)
    if err != nil {
        return nil, err  // 컨텍스트 없음
    }
    ...
}

이러면 호출 트리 깊은 곳에서 일어난 에러가 원본 메시지만 가지고 위로 올라간다. 어디서 났는지 알 수 없다.

권장 형태:

if err != nil {
    return nil, fmt.Errorf("loadUser(%d): %w", id, err)
}

단계마다 한 줄씩 컨텍스트를 더한다.

안티패턴 3: 같은 에러 메시지 중복 래핑

if err != nil {
    return fmt.Errorf("loadUser: %w", err)
}

이걸 위쪽에서 또

if err != nil {
    return fmt.Errorf("loadUser: %w", err)
}

이러면 메시지가

loadUser: loadUser: loadUser: connection refused

이렇게 된다. 래핑은 새 정보를 더할 때만 한다.

안티패턴 4: panic 남발

file, err := os.Open(path)
if err != nil {
    panic(err)  // 흔한 실수
}

파일이 없는 건 비정상이 아니라 예상 가능한 실패다. 호출자가 처리할 기회를 빼앗으면 안 된다.

panic 을 쓰기 전에 항상 자문하자.

“이 상황을 호출자가 처리할 가능성이 조금이라도 있나?”

조금이라도 있으면 error 다.

안티패턴 5: error 만 보고 결과도 함께 쓰기

data, err := os.ReadFile(path)
fmt.Println(string(data))  // err 검사 전에 사용
if err != nil {
    return err
}

err 가 있을 땐 결과값은 의미 없는 값이 보통이다. 먼저 에러를 검사하고 나서 결과를 쓴다.


21.9 정리

이 장에서 살펴본 내용:

  • Go 는 예외 대신 에러를 값으로 다룬다
  • errorError() string 메서드 하나짜리 인터페이스다
  • 함수는 마지막 반환값에 error 를 두는 게 관례다
  • errors.Newfmt.Errorf 로 에러를 만든다
  • 사용자 정의 에러 타입으로 추가 정보를 담을 수 있다
  • %w 로 에러를 래핑하고 errors.Is, errors.As 로 검사한다
  • sentinel 에러로 미리 정의된 비교용 값을 만든다
  • panic 은 비정상적인 상황만, recoverdefer 안에서 경계 보호용으로 쓴다
  • 에러 무시, 컨텍스트 없는 반환, panic 남발은 피한다

if err != nil 이 처음엔 번거롭게 느껴진다. 몇 주 쓰다 보면 그 명시성이 오히려 안심이 된다. 어떤 함수가 실패할 수 있는지가 코드에 그대로 보이고, 어디서 실패를 처리했는지도 그대로 보이기 때문이다.

다음 장부터는 Go 의 또 다른 큰 자랑거리, 동시성을 다룬다. 고루틴과 채널이라는 두 도구로 여러 일을 한꺼번에 다루는 법을 배운다.

22장. 고루틴과 채널 기초

드디어 Go 의 간판 기능에 도착했다. 바로 동시성(concurrency)이다.

Go 의 동시성은 두 개의 도구를 중심으로 돈다.

  • 고루틴 — 가벼운 실행 단위
  • 채널 — 고루틴 사이의 통로

이 장의 목표는 다음과 같다.

  • 고루틴이 무엇이며 왜 가벼운지 이해하기
  • go 키워드로 동시 작업을 시작하기
  • 채널로 고루틴 사이에 값을 주고받기
  • select, WaitGroup 같은 기본 도구 익히기

문법은 단순하지만 사고방식은 처음에 낯설다. 짧은 예제 여러 개로 천천히 익혀 보자.


22.1 고루틴이란

고루틴(goroutine) 은 Go 런타임이 관리하는 가벼운 실행 단위다.

다른 언어의 스레드와 비슷한 자리에 있지만, 무게가 한참 다르다.

OS 스레드와의 차이

OS 가 직접 다루는 스레드는 꽤 무거운 자원이다.

항목OS 스레드고루틴
시작 비용무겁다 (수십 µs)가볍다 (수 µs 이하)
스택 크기보통 1~8 MB 고정시작 2~8 KB, 필요시 증가
컨텍스트 스위치커널 호출Go 런타임이 직접
동시에 띄울 수 있는 수수천 개 정도수십만 개도 가능

수치는 환경마다 다르지만 차수 자체가 다르다는 점이 중요하다.

고루틴 1만 개를 띄워도 시스템이 멈추지 않는다. OS 스레드를 1만 개 띄우려 했다면 보통은 그 전에 시스템이 비명을 지른다.

Go 런타임 스케줄러

고루틴이 가벼울 수 있는 이유는 Go 런타임 스케줄러가 따로 있기 때문이다.

대강의 그림은 이렇다.

  • OS 스레드 몇 개가 워커 풀처럼 떠 있다
  • 우리가 만든 수많은 고루틴이 그 워커 위에서 돌아간다
  • 어느 고루틴을 어느 스레드에 올릴지는 Go 런타임이 알아서 결정한다

즉, 고루틴은 OS 스레드 위에 한 겹 더 얹은 사용자 영역의 가벼운 스레드라고 이해하면 된다.

핵심 한 줄

고루틴은 OS 스레드보다 훨씬 가벼운 Go 런타임의 실행 단위다. 그래서 수십만 개도 무리 없이 띄울 수 있다.


22.2 고루틴 시작하기

문법은 놀라울 만큼 단순하다. 함수 호출 앞에 go 키워드만 붙이면 된다.

go f()

이 한 줄의 의미는,

  • f() 를 새로운 고루틴에서 실행한다
  • 호출한 쪽은 기다리지 않고 즉시 다음 줄로 진행한다

첫 예제

package main

import (
	"fmt"
	"time"
)

func say(msg string) {
	for i := 0; i < 3; i++ {
		fmt.Println(msg, i)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	go say("hello")
	say("world")
}

say("hello") 는 고루틴에서, say("world") 는 main 에서 동시에 돈다.

출력은 환경에 따라 섞여 나온다.

world 0
hello 0
hello 1
world 1
world 2
hello 2

순서가 매번 같지 않다는 점이 중요하다. 고루틴의 실행 순서는 보장되지 않는다.

익명 함수도 OK

별도로 함수를 정의하지 않고 익명 함수를 그 자리에서 실행할 수도 있다.

go func() {
	fmt.Println("from goroutine")
}()

마지막 () 가 익명 함수를 즉시 호출하는 부분이다. 이 호출에 go 가 붙어 고루틴이 된다.

매개변수도 전달할 수 있다.

name := "Alice"
go func(n string) {
	fmt.Println("hi", n)
}(name)

클로저로 바깥 변수를 그냥 캡처하는 것도 가능하지만, for 루프 안에서 잡을 때 흔한 함정이 있다. 그 함정은 25장에서 다시 다룬다.


22.3 main 종료와 고루틴

여기서 초보가 거의 100% 만나는 함정이 있다.

“왜 출력이 안 나오죠?”

다음 코드를 실행해 보자.

package main

import "fmt"

func main() {
	go fmt.Println("hello from goroutine")
}

결과는,

(아무것도 안 나옴)

원인은 한 줄로 요약된다.

main 함수가 끝나면 모든 고루틴은 강제로 종료된다.

go fmt.Println(...) 를 호출하자마자 main 은 다음 줄로 넘어가고, 그 다음 줄이 없으니 그대로 끝난다. 고루틴이 실제로 실행될 틈이 없다.

임시방편: time.Sleep

가장 단순한 회피책은 main 을 잠깐 재우는 것이다.

package main

import (
	"fmt"
	"time"
)

func main() {
	go fmt.Println("hello from goroutine")
	time.Sleep(100 * time.Millisecond)
}

이번엔 출력이 나온다.

hello from goroutine

하지만 이 방식은 좋은 해법이 아니다.

  • 얼마를 자야 하는지 정확히 알 수 없다
  • 너무 짧게 자면 여전히 못 끝낸다
  • 너무 길게 자면 프로그램이 느려진다

time.Sleep예제용 임시방편일 뿐이다. 실전에선 다음 두 가지 도구를 쓴다.

  • 채널로 끝났다는 신호를 받기
  • sync.WaitGroup 으로 일제히 기다리기

둘 다 이번 장에서 배운다.


22.4 채널이란

채널(channel) 은 고루틴 사이에 값을 전달하는 통로다.

비유하자면 타입이 있는 컨베이어 벨트다.

  • 한쪽 끝에서 값을 올린다 (송신)
  • 다른 쪽 끝에서 값을 집는다 (수신)
  • 한 번에 한 타입의 값만 흐른다

채널을 통해 두 고루틴은,

  • 값을 주고받고 (통신)
  • 서로 박자를 맞춘다 (동기화)

이 두 가지가 동시에 일어난다는 점이 채널의 매력이다.

채널 타입 표기

채널은 자신이 운반하는 값의 타입을 가진다.

chan int       // int 가 흐르는 채널
chan string    // string 이 흐르는 채널
chan []byte    // 바이트 슬라이스가 흐르는 채널

chan T 형태로 읽으면 된다.


22.5 채널 만들고 쓰기

생성

ch := make(chan int)

make 로 만든다. 용량을 적지 않으면 언버퍼 채널(unbuffered channel) 이 된다.

송신과 수신

ch <- 5       // 5 를 ch 에 보낸다 (송신)
x := <-ch     // ch 에서 값을 꺼낸다 (수신)

화살표가 채널을 향하면 송신, 채널에서 나오면 수신이다. 시각적으로 방향이 곧 의미라서 외우기 쉽다.

송수신은 서로를 기다린다

언버퍼 채널의 핵심 성질이다.

  • 송신 측은 누군가 받기 시작할 때까지 멈춰 있다
  • 수신 측은 누군가 보낼 때까지 멈춰 있다

즉, 만남이 성사돼야 둘 다 진행된다. 이걸 “랑데부(rendezvous)” 라고 부른다.

짧은 예제

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		ch <- 42 // 송신
	}()

	v := <-ch // 수신
	fmt.Println(v)
}

실행 결과는 42.

흐름을 따라가 보자.

  1. main 이 채널을 만든다
  2. 고루틴을 띄우고 본인은 <-ch 에서 대기
  3. 고루틴이 ch <- 42 를 실행 → main 이 받는다
  4. main 이 값을 출력하고 종료

이 코드는 time.Sleep 없이도 안정적으로 동작한다. 채널이 박자를 맞춰 주기 때문이다.

한 가지 함정

ch := make(chan int)
ch <- 1        // 여기서 영원히 멈춤
fmt.Println(<-ch)

언버퍼 채널은 받는 쪽이 없으면 송신이 막힌다. 같은 고루틴(main) 안에서 송신과 수신을 차례로 하면, 첫 번째 송신이 영원히 끝나지 않는다.

이 상태가 바로 데드락(deadlock) 이다. Go 런타임이 모든 고루틴이 멈췄음을 감지하면 다음과 같은 패닉을 띄운다.

fatal error: all goroutines are asleep - deadlock!

이 메시지가 뜨면, “어딘가에서 받지 않는 채널에 보내고 있구나” 라고 의심해 보자.


22.6 채널 방향

채널은 양방향이 기본이지만, 함수 매개변수로 넘길 때는 방향을 제한할 수 있다.

chan T       // 양방향
chan<- T     // 송신 전용
<-chan T     // 수신 전용

화살표 위치가 곧 방향이다. chan<- 는 채널 안으로, <-chan 은 채널 밖으로 화살이 향한다.

왜 방향을 제한하나

함수 시그니처만 봐도 역할이 드러나기 때문이다.

func producer(out chan<- int) {
	for i := 0; i < 3; i++ {
		out <- i
	}
}

func consumer(in <-chan int) {
	for v := range in {
		fmt.Println(v)
	}
}

producer 는 보내기만 하고, consumer 는 받기만 한다. 컴파일러가 실수를 막아 준다. 예를 들어 producer 안에서 <-out 으로 받으려 하면 컴파일 에러가 난다.

호출하는 쪽은 양방향 채널을 만들어 넘기면 된다.

ch := make(chan int)
go producer(ch)
consumer(ch)

양방향 채널은 어느 쪽 매개변수에도 자동으로 맞는다. 반대로 단방향 채널을 양방향으로 다시 만들 순 없다.


22.7 버퍼 채널

지금까지 본 채널은 모두 언버퍼였다. 한 번에 한 값만 흐르고, 송수신이 서로를 기다린다.

버퍼 채널은 큐 역할을 한다.

ch := make(chan int, 3) // 용량 3

동작 규칙:

  • 버퍼에 자리가 있으면 송신은 즉시 통과
  • 가득 차면 송신이 막힌다 (받는 사람이 빼낼 때까지)
  • 비어 있으면 수신이 막힌다 (누군가 넣을 때까지)

예제

ch := make(chan int, 2)

ch <- 1
ch <- 2
// ch <- 3 // 여기서 막힌다

fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2

12 를 넣을 때는 막히지 않는다. 세 번째 송신부터 막힌다.

언제 버퍼를 쓰나

언버퍼가 기본이고, 버퍼는 “이런 이유로 필요해” 가 명확할 때만 쓴다.

쓸 만한 경우:

  • 송신과 수신의 속도가 들쭉날쭉할 때 버퍼로 잠깐 차이를 완충
  • 정해진 개수의 작업을 미리 큐에 쌓아 두고 워커가 가져가게 할 때
  • 결과를 모으는 채널의 용량을 미리 정해 둘 때

쓰면 안 되는 경우:

  • “데드락 났는데 버퍼 키우면 해결될까?” 식의 회피 → 보통 설계가 잘못된 신호다

버퍼는 성능을 위한 도구가 아니라 흐름의 모양을 정하는 도구다. 막연히 키운다고 빨라지는 일은 거의 없다.


22.8 채널 닫기와 range

채널을 다 썼다면 닫을(close) 수 있다.

close(ch)

닫힌 채널은 다음 성질을 가진다.

  • 새로 송신하면 패닉이 발생한다
  • 수신은 여전히 가능하지만, 버퍼에 남은 값을 다 꺼낸 뒤에는 제로값과 함께 ok = false 를 돌려준다

ok 받기

수신할 때 두 번째 값으로 ok 를 받을 수 있다.

v, ok := <-ch
if !ok {
	fmt.Println("채널이 닫혔다")
}

okfalse 면 채널이 닫혔고 더 받을 값도 없다는 뜻이다.

for range 로 자동 종료

매번 ok 를 확인하는 건 번거롭다. for range 가 이걸 자동으로 해 준다.

ch := make(chan int, 3)

go func() {
	for i := 0; i < 3; i++ {
		ch <- i
	}
	close(ch)
}()

for v := range ch {
	fmt.Println(v)
}

송신 측이 close(ch) 를 호출하면 수신 측의 range 가 자연스럽게 종료된다.

누가 닫아야 하나

관례는 분명하다.

보내는 쪽이 닫는다. 받는 쪽이 닫지 않는다.

이유는,

  • 받는 쪽은 송신이 끝났는지 알 길이 없다
  • 닫힌 채널에 송신하면 패닉이 난다
  • 보내는 쪽이 닫아야 패닉 위험이 없다

여러 송신자가 있다면 누가 닫을지 명확히 정해야 한다. 보통은 모든 송신자를 모은 뒤 마지막에 한 번만 닫는다. 이 패턴은 25장에서 다시 다룬다.


22.9 select 문 기초

여러 채널을 동시에 다뤄야 할 때 쓰는 도구가 select 다.

문법은 switch 와 닮았지만, 조건이 모두 채널 연산이다.

select {
case v := <-ch1:
	fmt.Println("ch1:", v)
case ch2 <- 42:
	fmt.Println("sent to ch2")
}

동작 규칙:

  • 모든 case 를 살펴 준비된 것을 찾는다
  • 준비된 case 가 하나면 그 case 실행
  • 여러 개면 임의로 하나를 골라 실행
  • 하나도 준비 안 됐으면 막혀서 기다린다

default 케이스

기다리고 싶지 않다면 default 를 쓴다.

select {
case v := <-ch:
	fmt.Println("got", v)
default:
	fmt.Println("nothing ready")
}
  • 준비된 case 가 있으면 그쪽 실행
  • 없으면 즉시 default 실행

이걸 “논블로킹(non-blocking) 채널 연산” 이라 부른다.

짧은 예제

package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() {
		time.Sleep(100 * time.Millisecond)
		ch1 <- "ping"
	}()
	go func() {
		time.Sleep(50 * time.Millisecond)
		ch2 <- "pong"
	}()

	for i := 0; i < 2; i++ {
		select {
		case v := <-ch1:
			fmt.Println("ch1:", v)
		case v := <-ch2:
			fmt.Println("ch2:", v)
		}
	}
}

ch2 가 먼저 준비되고 ch1 이 나중에 준비된다. 출력은 보통 이렇다.

ch2: pong
ch1: ping

select 는 채널 기반 동시성 코드의 거의 모든 곳에 등장한다. 25장의 패턴들도 결국 select 의 응용이다.


22.10 sync.WaitGroup

여러 고루틴을 띄워 놓고 모두 끝날 때까지 기다리기. 이게 의외로 자주 필요하다.

이때 쓰는 도구가 sync.WaitGroup 이다.

사용 패턴

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup

	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			fmt.Println("worker", id)
		}(i)
	}

	wg.Wait()
	fmt.Println("all done")
}

세 가지 메서드가 핵심이다.

메서드의미
Add(n)기다릴 고루틴 수를 n 만큼 늘린다
Done()고루틴 하나가 끝났음을 알린다
Wait()카운트가 0 이 될 때까지 막힌다

Done() 은 내부적으로 Add(-1) 과 같다.

관례: defer wg.Done()

고루틴 시작 직후 defer wg.Done() 을 적는 것이 관례다.

go func() {
	defer wg.Done()
	// ... 실제 작업 ...
}()

이유:

  • 작업 중간에 어떤 경로로 빠져나가든 항상 Done 이 호출된다
  • panic 이 나도 defer 는 실행되므로 안전하다

주의할 점

  • Add 는 고루틴을 띄우기 전에 호출한다
    • 고루틴 안에서 Add 를 하면 Wait 가 먼저 0을 보고 빠져나갈 수 있다
  • Done 을 한 번 더 호출하면 카운트가 음수가 되며 패닉이 난다
  • WaitGroup 은 값으로 복사하지 않는다
    • 함수에 넘길 때는 포인터로

채널 vs WaitGroup

같은 “기다림“이라도 도구를 나눠 쓰자.

상황도구
결과 값을 모아야 한다채널
끝났다는 사실만 알면 된다WaitGroup
둘 다 필요하다결과 채널 + WaitGroup

마지막 경우의 정석 패턴은 25장에서 다룬다.


22.11 정리

이 장에서 살펴본 내용:

  • 고루틴은 Go 런타임이 관리하는 가벼운 실행 단위다
  • go f() 한 줄로 새 고루틴을 시작한다
  • main 이 끝나면 모든 고루틴이 강제 종료된다
  • 채널은 타입이 있는 통로이며 송수신을 동기화한다
  • 언버퍼 채널은 송수신이 만나야 진행되고, 버퍼 채널은 큐처럼 동작한다
  • 채널 방향(chan<-, <-chan)으로 의도를 분명히 한다
  • closefor range 로 송신 종료를 깔끔히 전달한다
  • select 는 여러 채널 중 준비된 것을 처리한다
  • sync.WaitGroup 으로 고루틴 묶음의 끝을 기다린다

도구는 이제 손에 잡혔다. 하지만 도구가 있다고 안전한 코드가 되진 않는다. 여러 고루틴이 같은 데이터를 만지면 의외로 쉽게 망가진다.

다음 장에서는 그 망가지는 양상부터 들여다보고, 가장 기본적인 해결 도구인 뮤텍스(Mutex) 를 배운다.

23장. 동시성 문제와 뮤텍스

22장에서 고루틴과 채널을 손에 넣었다. 이제 동시에 돌릴 수 있고, 값을 주고받을 수도 있다.

그런데 도구가 강해진 만큼 새로운 종류의 버그도 따라온다. 하나의 데이터를 여러 고루틴이 동시에 만지면 정말로 이상한 일이 벌어진다.

이 장의 목표는 다음과 같다.

  • 경쟁 조건(race condition)이 무엇인지 이해하기
  • race detector 로 문제를 잡아내는 법 익히기
  • sync.Mutex / sync.RWMutex 로 보호하기
  • 데드락 같은 락 함정과 안티패턴 알아보기
  • sync/atomic 의 위치 파악하기

긴 장처럼 보이지만 사실 주제는 하나다. “공유 데이터를 안전하게 다루는 가장 기본적인 방법” 이다.


23.1 무엇이 잘못될 수 있는가

먼저 진짜로 망가지는 코드를 보자. 이론은 그 뒤에 따라온다.

카운터 1000개 증가

package main

import (
	"fmt"
	"sync"
)

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

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++
		}()
	}

	wg.Wait()
	fmt.Println("counter =", counter)
}

counter++ 를 1000 번 했으니 당연히 1000 이 찍혀야 한다.

실제로 돌려 보면 결과는,

counter = 873
counter = 962
counter = 1000   // 가끔은 맞기도 한다
counter = 941

매번 다르고, 보통은 1000 보다 작다.

왜 이런 일이 일어나나

counter++ 는 사람 눈엔 한 단계로 보이지만 사실 세 단계다.

  1. counter 의 값을 읽는다
  2. 1을 더한다
  3. 결과를 counter 에 쓴다

두 고루틴이 거의 동시에 이 세 단계를 밟으면 다음 같은 시나리오가 가능하다.

시각고루틴 A고루틴 Bcounter
t0read 00
t1read 00
t2add → 1add → 10
t3write 11
t4write 11

증가 두 번이 일어났는데 결과는 1. 하나가 사라졌다.

경쟁 조건과 데이터 레이스

두 용어가 헷갈리기 쉽다.

  • 경쟁 조건 (race condition)
    • 실행 순서에 따라 결과가 달라지는 모든 상황
    • 동시성 버그의 큰 분류
  • 데이터 레이스 (data race)
    • 그중에서도 같은 메모리에 대해 한 쪽이라도 쓰기인 두 접근이 동기화 없이 동시에 일어나는 경우
    • 메모리 모델 차원에서 정의된 엄격한 개념

위 카운터 예제는 데이터 레이스이자 경쟁 조건이다. Go 언어 명세는 데이터 레이스가 있는 프로그램의 동작을 정의하지 않는다. 즉, 뭐가 나와도 이상하지 않다.

핵심: 단순한 i++ 도 동시에 하면 우리가 기대하는 동작을 보장하지 않는다.


23.2 race detector

이런 버그를 눈으로 찾기는 끔찍하다. Go 는 다행히 강력한 도구를 기본으로 제공한다.

사용법

-race 플래그 한 줄이면 끝이다.

go run -race main.go
go test -race ./...
go build -race

이 플래그를 붙이면 컴파일러가 코드 곳곳에 메모리 접근 추적 코드를 함께 끼워 넣는다. 실행 중 의심되는 동시 접근을 만나면 보고한다.

보고서 읽기

위 카운터 예제를 go run -race 로 돌려 보면 대략 이런 메시지가 뜬다.

==================
WARNING: DATA RACE
Read at 0x00c0000180a8 by goroutine 8:
  main.main.func1()
      /path/main.go:14 +0x44

Previous write at 0x00c0000180a8 by goroutine 7:
  main.main.func1()
      /path/main.go:14 +0x55

Goroutine 8 (running) created at:
  main.main()
      /path/main.go:12 +0xa4
...
==================
Found 1 data race(s)

읽는 요령은 세 줄이면 충분하다.

  • Read at … / Write at … — 어떤 주소에서
  • goroutine N — 어느 고루틴이
  • at /path/main.go:14 — 어느 줄에서

두 접근이 동기화 없이 부딪쳤다는 신고서다.

비용과 한계

  • 실행이 5~10배 느려지고 메모리도 많이 쓴다
  • 그래서 운영 배포에선 쓰지 않는다
  • 개발/테스트 단계에서 켜는 것이 정석
  • CI 에서 go test -race ./... 를 도는 것을 강력히 권장

한 번 켜 두면 동시성 버그의 대부분을 잡아 준다. “잘 돌아가는데?” 보다 훨씬 믿을 만한 기준이 된다.


23.3 sync.Mutex

가장 기본적인 보호 도구는 뮤텍스(mutex) 다. 이름은 “mutual exclusion (상호 배제)” 의 줄임말이다.

  • 동시에 들어갈 수 있는 자리는 단 하나
  • 한 고루틴이 들어가 있으면 다른 고루틴은 문 앞에서 대기

이 단순한 규칙이 데이터 보호의 핵심이다.

기본 사용

import "sync"

var mu sync.Mutex

mu.Lock()
// ... 보호된 영역 (critical section) ...
mu.Unlock()

LockUnlock 사이가 임계 영역(critical section) 이다. 이 영역 안에서는 한 번에 한 고루틴만 실행된다.

카운터 고치기

23.1 의 카운터를 뮤텍스로 보호해 보자.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var (
		counter int
		mu      sync.Mutex
		wg      sync.WaitGroup
	)

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			counter++
			mu.Unlock()
		}()
	}

	wg.Wait()
	fmt.Println("counter =", counter)
}

이제 출력은 언제나,

counter = 1000

-race 로 돌려도 아무 경고가 나오지 않는다.

defer mu.Unlock() 관례

Unlock 을 깜빡하면 데드락이 난다. 패닉이 나면 더 심하게 망가진다. 그래서 가능하면 항상 defer 와 함께 쓴다.

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.value++
}

이 패턴이라면,

  • 함수에서 어떤 경로로 나가든 Unlock 이 보장된다
  • panic 이 나도 stack unwind 중에 호출된다

뮤텍스를 어디에 둘까

관례:

  • 보호 대상이 되는 데이터 바로 옆에 둔다
  • 구조체에 묶어 두는 게 가장 깔끔하다
type SafeCounter struct {
	mu    sync.Mutex
	value int
}

func (c *SafeCounter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.value++
}

func (c *SafeCounter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.value
}

조회 메서드(Value)도 락이 필요하다. 읽기조차 동기화 없이 동시 접근하면 데이터 레이스다. 이 부분이 처음엔 의외로 자주 빠진다.

뮤텍스는 복사하지 않는다

sync.Mutex 는 값으로 복사하는 순간 망가진다. 복사된 사본은 원본과 별개의 락이라서 같은 데이터를 같이 보호하지 못한다.

go vet 이 이 문제를 잡아 준다.

copylocks: passes lock by value: ...

규칙은 단순하다.

  • 뮤텍스를 가진 구조체는 보통 포인터로 다룬다
  • 함수 매개변수도 포인터로 받는다

23.4 sync.RWMutex

뮤텍스는 강력하지만 한 가지 아쉬움이 있다. 읽기끼리도 서로 배제한다는 점이다.

상황안전한가Mutex 는?
동시에 읽기안전막는다 (아쉬움)
동시에 쓰기위험막는다 (필요)
읽기 + 쓰기 동시위험막는다 (필요)

읽기는 본질적으로 안전한데도 막는 건 손해다. 이걸 풀어 주는 도구가 sync.RWMutex 다.

두 종류의 잠금

var mu sync.RWMutex

mu.RLock()   // 읽기 락
// ... 읽기만 ...
mu.RUnlock()

mu.Lock()    // 쓰기 락
// ... 읽기/쓰기 ...
mu.Unlock()

규칙:

  • 읽기 락은 여러 개가 동시에 잡혀 있어도 OK
  • 쓰기 락은 단 하나만 가능하며, 잡힌 동안엔 다른 어떤 락도 못 들어온다

예제

type SafeMap struct {
	mu sync.RWMutex
	m  map[string]int
}

func (s *SafeMap) Get(k string) (int, bool) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	v, ok := s.m[k]
	return v, ok
}

func (s *SafeMap) Set(k string, v int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.m[k] = v
}

Get 은 읽기 락, Set 은 쓰기 락을 잡는다. 읽기 요청이 많이 몰리는 캐시류에 잘 맞는다.

언제 이득인가

  • 읽기가 쓰기보다 압도적으로 많을 때
  • 임계 영역이 어느 정도 길어서 락 비용 절감 효과가 보일 때

이득이 별로 없는 경우:

  • 임계 영역이 매우 짧을 때
    • RWMutex 자체가 Mutex 보다 무거워 오히려 더 느릴 수 있다
  • 쓰기가 자주 일어날 때
    • 어차피 쓰기 락이 다 막으므로 이점이 사라진다

망설여지면 그냥 Mutex 로 시작하자. 측정 후 정말 병목이면 그때 RWMutex 로 바꿔도 늦지 않다.


23.5 데드락

락은 만능이 아니다. 잘못 쓰면 새로운 사고를 부른다. 대표적인 것이 데드락(deadlock) 이다.

데드락이란

두 개 이상의 고루틴이 서로가 가진 락을 기다리며 영원히 멈춰 있는 상태.

가장 간단한 예제는 락이 두 개일 때다.

package main

import (
	"sync"
	"time"
)

func main() {
	var a, b sync.Mutex

	go func() {
		a.Lock()
		time.Sleep(10 * time.Millisecond)
		b.Lock()
		b.Unlock()
		a.Unlock()
	}()

	go func() {
		b.Lock()
		time.Sleep(10 * time.Millisecond)
		a.Lock()
		a.Unlock()
		b.Unlock()
	}()

	select {}
}
  • 고루틴 1: a 를 잡고 → b 를 기다림
  • 고루틴 2: b 를 잡고 → a 를 기다림

서로가 상대를 기다리며 멈춘다. 운이 나쁘면 영원히 풀리지 않는다.

Go 런타임은 모든 고루틴이 멈췄을 때만 데드락 패닉을 띄운다. 일부만 데드락 상태라면 런타임은 모른다. 그래서 다른 일을 하는 고루틴이 같이 있으면 데드락이 조용히 묻혀 버린다.

락 순서 일관성 규칙

데드락을 막는 가장 쉬운 규칙은 단순하다.

모든 곳에서 동일한 순서로 락을 잡는다.

위 예제라면 모든 고루틴이 “a 먼저, b 나중” 순으로만 잡으면 데드락이 일어나지 않는다.

go func() {
	a.Lock()
	b.Lock()
	// 작업
	b.Unlock()
	a.Unlock()
}()

go func() {
	a.Lock() // 똑같이 a 먼저
	b.Lock()
	// 작업
	b.Unlock()
	a.Unlock()
}()

이걸 lock ordering 이라고 부른다. 규칙이 단순하지만 실전에선 의외로 어렵다.

  • 락이 늘면 순서 규칙이 복잡해진다
  • 여러 모듈을 합치면 서로의 락 순서가 충돌한다
  • 라이브러리 코드 안에 락이 숨어 있으면 추적이 어렵다

근본적으로는 락을 적게 가져가는 설계가 답이다. 이게 24장의 주제다.

한 가지 도구: TryLock

Go 1.18 이후 Mutex.TryLock 이 추가됐다. 잡을 수 있으면 잡고, 못 잡으면 즉시 false 반환.

if mu.TryLock() {
	defer mu.Unlock()
	// 보호된 작업
}

데드락 회피용으로 보이지만, 공식 문서가 “거의 쓸 일이 없다” 고 명시할 만큼 권장되지 않는다. 설계로 풀리지 않을 때의 마지막 수단 정도로만 알아 두자.


23.6 라이브락과 기아

데드락만큼 자주 언급되진 않지만 알아 두면 좋다.

라이브락 (livelock)

데드락이 “둘 다 멈춤” 이라면 라이브락은 “둘 다 계속 움직이지만 일은 못 하는 상태” 다.

  • 충돌을 감지하면 양보한다
  • 양쪽이 동시에 양보하면 또 충돌
  • 또 양보, 또 충돌… 반복

비유: 좁은 복도에서 두 사람이 같은 방향으로 피하면서 계속 마주치는 상황.

기아 (starvation)

특정 고루틴이 자원을 거의 못 잡고 계속 뒤로 밀리는 상태.

  • 쓰기 락 요청이 줄을 서 있는데 읽기 요청이 끊이지 않고 들어오면 쓰기 락이 영영 차례를 못 받는 식
  • RWMutex 설계에 따라 쓰기 starvation 이 생길 수 있다

Go 의 sync.Mutex 는 starvation 방지 모드를 내장해 오래 기다린 고루틴이 우선권을 받는다. 하지만 직접 만든 동기화 로직에선 이런 안전망이 없다.

데드락 / 라이브락 / 기아 모두 “공정성 (fairness)” 의 문제다. 락을 직접 설계할 일이 있다면 늘 의심해 봐야 한다.


23.7 락 안티패턴

코드에서 잘 보이는 함정들이다.

(1) 락을 든 채로 채널 송수신

mu.Lock()
ch <- value   // 받는 쪽이 없으면 여기서 영원히 막힌다
mu.Unlock()

채널 송수신이 막히는 동안 락이 계속 잡혀 있다. 다른 고루틴은 그 락을 영원히 기다린다. 효과적으로 데드락이다.

규칙:

락을 잡은 상태에서 시간이 얼마나 걸릴지 모르는 작업은 하지 않는다.

여기 해당하는 작업:

  • 채널 송수신
  • 네트워크 호출
  • 파일 I/O
  • 다른 락 요청 (lock ordering 주의)

준비를 락 밖에서 해 두고, 락 안에서는 짧게 끝나는 변경만 한다.

(2) 락 범위가 너무 넓다

mu.Lock()
defer mu.Unlock()

data := loadFromDisk()       // 느림
parsed := parse(data)        // 느림
m[key] = parsed              // 이게 보호 대상

보호하고 싶은 건 m[key] = parsed 한 줄이다. 디스크 I/O 가 끝날 때까지 락을 쥐고 있을 이유가 없다.

data := loadFromDisk()
parsed := parse(data)

mu.Lock()
m[key] = parsed
mu.Unlock()

(3) 메서드마다 별도 락

같은 데이터를 보호하는데 메서드마다 다른 락을 쓰면 보호가 안 된다.

type Buggy struct {
	mu1, mu2 sync.Mutex
	value    int
}

func (b *Buggy) Inc()   { b.mu1.Lock(); b.value++; b.mu1.Unlock() }
func (b *Buggy) Value() int {
	b.mu2.Lock(); defer b.mu2.Unlock()
	return b.value
}

IncValue 가 서로 다른 락을 잡고 있어 경쟁 조건이 그대로 남는다.

원칙:

하나의 데이터 = 하나의 락. 그 데이터를 만지는 모든 곳에서 같은 락을 쓴다.

(4) 락을 가진 채 콜백 호출

mu.Lock()
defer mu.Unlock()
callback(data)  // 콜백이 무엇을 할지 모른다

콜백 안에서 같은 락을 다시 잡으면 데드락이다. 콜백이 시간을 끌면 다른 고루틴이 다 막힌다. 호출자가 만든 코드라 무엇을 하는지도 알 수 없다.

가능하면 락 밖에서 콜백을 호출한다.


23.8 sync/atomic 맛보기

락이 무거워 보이는 경우가 있다. “카운터 하나 증가시키는 데 락까지 잡아야 하나?”

이런 단순한 경우엔 원자 연산(atomic operations) 이 더 가볍다. 표준 패키지 sync/atomic 이 제공한다.

기본 사용

import "sync/atomic"

var counter int64

atomic.AddInt64(&counter, 1)         // 1 증가
v := atomic.LoadInt64(&counter)      // 안전한 읽기
atomic.StoreInt64(&counter, 100)     // 안전한 쓰기

CPU 가 지원하는 원자 명령어로 직접 동작한다. 잠금/해제 없이 하나의 작업이 통째로 일어난다.

카운터 다시

var counter int64
var wg sync.WaitGroup

for i := 0; i < 1000; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		atomic.AddInt64(&counter, 1)
	}()
}

wg.Wait()
fmt.Println(atomic.LoadInt64(&counter))

뮤텍스 버전과 결과는 같지만 더 가볍다.

Go 1.19+ 의 atomic 타입

새 코드에선 타입화된 API 가 더 깔끔하다.

var counter atomic.Int64

counter.Add(1)
v := counter.Load()
counter.Store(100)

atomic.Int64, atomic.Uint64, atomic.Pointer[T] 등이 있다.

한계

원자 연산은 단일 값에 대해서만 동작한다.

  • 두 변수를 한꺼번에 일관성 있게 바꾸려면 못 한다
  • 맵이나 슬라이스 같은 복합 자료구조는 보호 못 한다

복잡한 상태가 필요하면 결국 락이나 채널이다.

선택 가이드

보호 대상추천
카운터, 플래그 같은 단일 정수atomic
작은 상태 묶음Mutex
데이터 흐름, 소유권 이전채널 (24장)

atomic 은 “락의 가벼운 대체” 가 아니라 “락이 필요 없을 만큼 단순한 경우의 도구” 다. 영역이 좁다는 걸 의식하고 쓰자.


23.9 정리

이 장에서 살펴본 내용:

  • counter++ 처럼 평범한 연산도 동시에 일어나면 결과가 망가진다
  • 이런 버그는 데이터 레이스라 부르며 결과가 정의되지 않는다
  • go run -race, go test -race 가 대부분의 레이스를 잡아 준다
  • sync.Mutex 는 가장 기본적인 보호 도구이며 defer mu.Unlock() 이 관례다
  • sync.RWMutex 는 읽기가 압도적으로 많을 때 유용하다
  • 락 두 개 이상을 잡을 땐 순서를 일관되게 한다 (데드락 방지)
  • 락을 든 채로 채널 송수신, I/O, 콜백 호출은 피한다
  • sync/atomic 은 단일 값에 한해 락보다 가볍다

여기까지가 락 기반 동시성의 기본기다. 잘 쓰면 강력하지만 코드가 빨리 복잡해지는 단점이 있다. 락이 여러 개 얽히기 시작하면 디버깅이 매우 힘들어진다.

다음 장에서는 시야를 한 번 넓힌다. “애초에 락을 거의 안 쓰는 설계” 가 가능할까? Go 가 제안하는 답을 본격적으로 살펴본다.

24장. 락 없이 동시성 설계하기

23장에서 우리는 뮤텍스로 데이터를 보호하는 법을 배웠다. 잘 동작한다. 하지만 코드가 커질수록 락도 같이 커진다는 점을 슬쩍 느꼈을 것이다.

이 장의 목표는 시야를 한 단계 넓히는 것이다. “공유 데이터를 락으로 지킨다” 대신 “애초에 공유하지 않게 설계한다” 는 발상이다.

다룰 내용:

  • Go 의 유명한 한 줄, “통신으로 메모리를 공유하라”
  • 락이 답이 아닌 상황들
  • 소유권 이전, 단일 작성자, 영역 나누기 같은 설계 패턴
  • 락과 채널 중 어느 쪽을 언제 쓸지

새 도구를 배운다기보다는 이미 알고 있는 도구를 다르게 조합하는 법을 배운다.


24.1 Go 의 철학

Go 의 동시성 격언으로 가장 유명한 한 줄.

Do not communicate by sharing memory; instead, share memory by communicating.

번역하면,

메모리를 공유함으로써 통신하지 말고, 통신함으로써 메모리를 공유하라.

겉으론 멋부린 말 같지만 안에는 분명한 처방이 들어 있다.

두 가지 그림

같은 일을 두 방식으로 한다고 해 보자. “카운터에 100 까지 누적해서 더해.”

(A) 메모리 공유로 통신

[고루틴 A]              [고루틴 B]
  |                      |
  +-- 공유 변수 counter --+
       ↑ Lock/Unlock 으로 보호
  • 공유 변수가 중심에 있다
  • 모두가 같은 자리를 동시에 만지므로 락이 필요하다

(B) 통신으로 공유

[고루틴 A] --값--> [집계 고루틴] <--값-- [고루틴 B]
                       |
                       └── 자기만의 변수에 누적
  • 누적 변수는 집계 고루틴 혼자만 만진다
  • 다른 고루틴은 채널로 “이만큼 더해 줘” 라고 부탁
  • 공유 변수 자체가 없으니 락이 필요 없다

(A) 와 (B) 는 같은 결과를 낸다. 하지만 (B) 의 코드는 더 읽기 쉽고 디버깅도 편하다.

락은 “공유 자원을 다 같이 쓰되 부딪치지 말자” 는 약속이다. 채널은 “그 자원을 한 사람에게 맡기고 부탁하자” 는 위임이다.

도구를 부정하는 건 아니다

오해를 막아 두자.

  • Go 가 락을 쓰지 말라고 하는 건 아니다
  • 표준 라이브러리 곳곳에 뮤텍스가 들어 있다
  • 단일 카운터 보호엔 그냥 Mutex 가 가장 깔끔하다

격언의 진짜 뜻은,

“공유 데이터를 지키는 데에만 집중하면 설계가 빨리 더러워진다. 한 발 떨어져서 ‘공유를 줄이는 설계’ 를 먼저 고려하라.”

이번 장은 그 “한 발 떨어진 시선” 의 카탈로그다.


24.2 락이 정답이 아닌 경우들

락이 슬슬 무거워지는 신호가 몇 가지 있다. 이런 게 보이면 설계를 다시 의심해 보자.

락 범위가 점점 커진다

처음엔 짧은 임계 영역으로 시작했는데 기능이 늘면서 점점 길어진다.

mu.Lock()
defer mu.Unlock()

if cond1 { ... }
data := fetch()       // 누군가가 슬쩍 추가
m[key] = transform(data)
publish(m[key])       // 또 누군가가 추가

이 정도면 사실상 싱글 스레드 프로그램이다. 모두가 락 앞에서 줄을 서느라 고루틴을 띄운 보람이 사라진다.

락이 여러 개로 늘어난다

기능이 커지면 락도 늘어난다.

  • userMu — 사용자 정보 보호
  • cacheMu — 캐시 보호
  • metricsMu — 지표 보호

이 셋이 서로 얽히기 시작하면

  • “어느 락이 먼저인가?” 같은 순서 규칙이 필요해진다 (23.5)
  • 잘못 잡으면 데드락
  • 새 코드 추가할 때 매번 락 순서를 검증해야 한다

라이브러리/API 경계에서 락이 새 나간다

func (s *Service) Get(key string) Item {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.items[key]
}

Get 이 돌려준 Item 을 호출자가 수정하면? 호출자가 그걸 또 다른 고루틴에 넘기면? 락의 보호 범위가 코드 밖으로 슬슬 새 나간다.

신호 체크리스트

신호의심해 볼 것
임계 영역이 한 화면을 넘어간다락이 데이터 흐름까지 짊어졌다
락이 3개 이상 얽힌다영역 분리가 잘못됐다
락 안에 채널 송수신이 있다안티패턴 (23.7)
반환값이 락 밖에서도 안전한지 헷갈린다소유권이 모호하다

이런 신호가 보이면 락을 더 정교하게 짜는 대신 설계 자체를 바꾸는 게 정답일 가능성이 높다.


24.3 소유권 이전 패턴

가장 강력한 발상은 이것이다.

어떤 데이터든 한 순간엔 한 고루틴만 만지게 한다.

이렇게만 보장되면 락이 아예 필요 없다. 경쟁할 수 있는 상대가 없기 때문이다.

채널은 이 “소유권“을 깔끔하게 옮기는 도구다. 값을 보내고 나면 그 값은 받는 쪽 것이고, 보낸 쪽은 만지지 않기로 약속하면 된다.

예제: 단계 사이로 데이터 넘기기

package main

import "fmt"

type Order struct {
	ID    int
	Items []string
	Total int
}

func main() {
	stage1 := make(chan *Order)
	stage2 := make(chan *Order)

	// 1단계: 주문 생성
	go func() {
		defer close(stage1)
		for i := 0; i < 3; i++ {
			o := &Order{ID: i, Items: []string{"apple", "bread"}}
			stage1 <- o // 여기서 소유권 이전
		}
	}()

	// 2단계: 합계 계산
	go func() {
		defer close(stage2)
		for o := range stage1 {
			o.Total = len(o.Items) * 1000
			stage2 <- o
		}
	}()

	// 3단계: 출력
	for o := range stage2 {
		fmt.Printf("order %d total=%d\n", o.ID, o.Total)
	}
}

Order 포인터는,

  • 1단계가 만들어서 보내고
  • 2단계가 받아서 수정하고
  • 3단계가 받아서 읽는다

세 단계 모두 같은 메모리를 만지지만 시간상으로는 한 번에 한 곳에서만 만진다. 락이 한 줄도 없는데 데이터 레이스가 없다.

약속이 핵심이다

  • “보낸 쪽은 보낸 뒤에 안 만진다”
  • “받은 쪽이 잠시 동안의 유일한 주인이다”

이 약속이 깨지면 데이터 레이스가 살아 돌아온다.

// 잘못된 예
stage1 <- o
o.Total = 999    // 보낸 후에도 만진다 — 위험

-race 로 돌리면 즉시 잡힌다.

채널은 마법이 아니다. “보낸 후엔 안 만진다” 는 규율이 진짜 보호의 근거다.


24.4 단일 작성자 패턴

읽기는 많고 쓰기는 한 곳에서만 일어나는 상황은 의외로 자주 나타난다.

이 패턴의 발상은,

  • 쓰기를 단 하나의 고루틴에만 맡긴다
  • 다른 모든 고루틴은 채널로 쓰기를 부탁한다
  • 그 한 고루틴이 자기 데이터를 자기만 수정하므로 락이 없다

예제: 안전한 카운터 서비스

package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	incCh chan int
	qCh   chan chan int
	stop  chan struct{}
}

func NewCounter() *Counter {
	c := &Counter{
		incCh: make(chan int),
		qCh:   make(chan chan int),
		stop:  make(chan struct{}),
	}
	go c.run()
	return c
}

// 단일 작성자 고루틴
func (c *Counter) run() {
	var value int
	for {
		select {
		case n := <-c.incCh:
			value += n
		case reply := <-c.qCh:
			reply <- value
		case <-c.stop:
			return
		}
	}
}

func (c *Counter) Inc(n int) { c.incCh <- n }
func (c *Counter) Value() int {
	reply := make(chan int)
	c.qCh <- reply
	return <-reply
}
func (c *Counter) Stop() { close(c.stop) }

func main() {
	c := NewCounter()
	defer c.Stop()

	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Inc(1)
		}()
	}
	wg.Wait()

	fmt.Println(c.Value())
}

run 안의 value 변수는 그 고루틴 혼자만 만진다. 외부에서는 채널을 통해서만 부탁할 수 있다.

장단점

  • 장점
    • 락이 없어 데드락 위험이 없다
    • 인터페이스(Inc, Value) 가 깔끔하다
    • 한 자리에서 모든 변경이 일어나니 로그/검증을 박기 좋다
  • 단점
    • 짧은 작업엔 채널 오버헤드가 락보다 크다
    • 단일 작성자 고루틴이 병목이 될 수 있다
    • 코드 양은 뮤텍스 버전보다 많다

그래서 모든 카운터를 이 패턴으로 짤 필요는 없다. “카운터” 가 아니라 “한 곳에서 일관성을 보장해야 하는 상태” 가 복잡할 때 빛난다.


24.5 영역 나누기 (Sharding)

또 다른 강력한 발상은 충돌 자체를 발생시키지 않게 데이터를 쪼개는 것이다.

영어로는 샤딩(sharding) 또는 파티셔닝(partitioning) 이라고 한다.

발상

큰 맵 하나를 보호하는 대신, 작은 맵 N개로 나누고 각 맵을 각 고루틴이 맡는다.

  • k 가 들어오면 hash(k) % N 으로 워커를 정한다
  • 한 워커는 자기 영역만 본다
  • 워커끼리는 데이터를 공유하지 않는다

예제: 카운트 by 키

package main

import (
	"fmt"
	"hash/fnv"
	"sync"
)

const N = 4

type Shard struct {
	counts map[string]int
	ch     chan string
}

func newShard() *Shard {
	return &Shard{
		counts: make(map[string]int),
		ch:     make(chan string, 16),
	}
}

func (s *Shard) run(wg *sync.WaitGroup) {
	defer wg.Done()
	for k := range s.ch {
		s.counts[k]++ // 이 맵은 나만 만진다
	}
}

func hashKey(k string) uint32 {
	h := fnv.New32a()
	h.Write([]byte(k))
	return h.Sum32()
}

func main() {
	shards := make([]*Shard, N)
	var wg sync.WaitGroup
	for i := 0; i < N; i++ {
		shards[i] = newShard()
		wg.Add(1)
		go shards[i].run(&wg)
	}

	// 입력 분배
	inputs := []string{"a", "b", "a", "c", "b", "a", "d"}
	for _, k := range inputs {
		idx := hashKey(k) % N
		shards[idx].ch <- k
	}

	// 입력 끝났음을 알리고 결과 수집
	for _, s := range shards {
		close(s.ch)
	}
	wg.Wait()

	for i, s := range shards {
		fmt.Printf("shard %d: %v\n", i, s.counts)
	}
}

핵심:

  • 같은 키는 항상 같은 워커로 간다
  • 워커끼리 같은 맵을 만지지 않는다
  • 락이 없고 채널은 분배 통로일 뿐이다

언제 빛나는가

  • 키별 독립 카운트, 집계
  • 키별 캐시
  • 사용자 ID 별 작업 처리 (같은 사용자는 같은 워커)

주의점

  • 분포가 균등해야 한다 (한 키만 폭주하면 그 워커만 바쁘다)
  • 워커 간 통신이 필요하면 다시 복잡해진다
  • 결과를 하나로 합쳐야 한다면 최종 단계에서 한 번은 모아야 한다

그래도 가장 큰 장점은 모든 충돌이 설계 단계에서 사라진다는 점이다. 런타임에 락을 잡을 일도, 데드락을 의심할 일도 없다.


24.6 역할 나누기 (생산자/소비자)

또 하나의 흔한 분리는 역할에 따른 분리다.

  • 어떤 고루틴은 데이터를 만들기만 한다 (생산자)
  • 어떤 고루틴은 데이터를 처리만 한다 (소비자)
  • 둘 사이를 채널이 잇는다

생산자는 자기 상태만, 소비자는 자기 상태만 만지므로 공유 데이터가 자연스럽게 줄어든다.

예제: 로그 처리

package main

import (
	"fmt"
	"sync"
)

func producer(out chan<- string, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 0; i < 5; i++ {
		out <- fmt.Sprintf("log-%d", i)
	}
}

func consumer(in <-chan string, wg *sync.WaitGroup) {
	defer wg.Done()
	for line := range in {
		fmt.Println("처리:", line)
	}
}

func main() {
	ch := make(chan string, 4)

	var prodWg sync.WaitGroup
	prodWg.Add(2)
	go producer(ch, &prodWg)
	go producer(ch, &prodWg)

	var consWg sync.WaitGroup
	consWg.Add(1)
	go consumer(ch, &consWg)

	prodWg.Wait()
	close(ch)
	consWg.Wait()
}
  • 생산자 2명이 채널에 로그를 만들어 넣는다
  • 소비자 1명이 채널에서 꺼내 처리한다
  • 채널은 둘 사이의 큐 역할

워커 풀의 직관

생산자가 한 명, 소비자가 N명이면 그게 바로 워커 풀이다.

[producer] --→ chan task --→ [worker 1]
                       \--→ [worker 2]
                       \--→ [worker 3]
  • 일감 채널 하나
  • 같은 일감을 가져가려고 워커 N개가 경합
  • 채널이 알아서 한 명에게만 전달
  • 락 없이 자연스럽게 부하 분산

워커 풀의 정식 구현은 25장에서 본다. 여기선 “역할을 분리하니까 락이 사라진다” 는 직관만 잡아 두자.


24.7 불변 데이터로 공유

가장 단순하면서도 강력한 카드.

읽기만 가능한 데이터는 자유롭게 공유해도 된다.

데이터 레이스는 “한 쪽이라도 쓰기“가 있을 때 발생한다 (23.1). 아무도 쓰지 않으면 동시에 100개 고루틴이 봐도 안전하다.

활용 패턴

  • 설정값을 시작 시 한 번 만들고 그 뒤로는 절대 수정하지 않는다
  • “조회 전용 사본” 을 만들어 공유한다
  • 변경이 필요하면 새 객체를 만들어 포인터만 교체한다 (copy-on-write)

예제: 설정 공유

type Config struct {
	Timeout int
	Retries int
	Hosts   []string
}

var current = &Config{
	Timeout: 3,
	Retries: 2,
	Hosts:   []string{"a.example", "b.example"},
}

func GetConfig() *Config {
	return current // 모두 같은 포인터를 본다 — 안전
}

설정을 만든 뒤 절대 수정하지 않는다면 여러 고루틴이 동시에 읽어도 락이 필요 없다.

업데이트가 필요할 땐?

func UpdateConfig(newCfg *Config) {
	current = newCfg // 포인터만 교체
}

이 자체는 안전하지 않다. 포인터 교체는 단일 워드 쓰기지만, 일관성 보장과 가시성을 위해 보통은 다음 중 하나를 쓴다.

  • atomic.Pointer[Config] 로 교체
  • 작은 락으로 보호
  • 24.4 의 단일 작성자 패턴

핵심은,

  • 객체 내부는 절대 수정하지 않는다
  • 포인터 교체만 동기화한다

이러면 읽기 쪽은 락 없이 마음 편하게 데이터를 본다.

함정

  • 슬라이스/맵은 “값을 안 바꾸기” 가 약속이지 강제가 아니다
    • 호출자가 슬그머니 append 할 수 있다
    • 외부에 노출할 땐 의식적으로 복사하거나 수정 불가 형태로 감싼다
  • Go 에는 const struct 같은 게 없다
    • 불변성은 규율로 지키는 약속이다

24.8 락 vs 채널 — 언제 무엇을

흔한 질문.

“이 상황에 락을 써야 할까, 채널을 써야 할까?”

Go 팀의 공식 FAQ 에 깔끔한 가이드가 있다. 요지를 정리하면 이렇다.

락이 어울리는 경우

  • 작은 상태의 보호
    • 카운터, 캐시 한 줄, 플래그
  • 임계 영역이 매우 짧다
    • 채널 송수신보다 락 한 쌍이 가볍다
  • 읽기/쓰기 패턴이 단순하다
  • 소유권의 흐름이 없다
    • 그냥 같은 자리를 여러 명이 본다

sync.Mutex / sync.RWMutex / sync/atomic 의 자리.

채널이 어울리는 경우

  • 데이터의 흐름이 있다
    • 단계 1 → 단계 2 → 단계 3
  • 소유권을 옮긴다
    • “이건 이제 너 거” 라고 말하고 싶다
  • 여러 비동기 이벤트를 다룬다
    • 들어오는 작업, 취소 신호, 타임아웃 등 (select)
  • 결과를 모아야 한다
    • 여러 고루틴의 출력을 하나로

chan T + select 의 자리.

양쪽 다 쓰기

둘 중 하나만 골라야 하는 건 아니다.

  • 채널로 작업을 분배하고
  • 각 워커 안에서는 작은 뮤텍스를 쓰고
  • 종료는 WaitGroup 으로 기다린다

이런 식으로 도구를 섞는 게 일반적이다. 오히려 한 가지로 다 해결하려 들면 코드가 어색해진다.

의사 결정 흐름

  1. 공유 자체를 줄일 수 있는가? — 24.3~24.7
  2. 데이터의 흐름이 보이는가? — 채널
  3. 그저 자리를 지키는 일인가? — 락
  4. 단일 값 카운터/플래그인가? — atomic

이 순서로 자문해 보면 대부분의 경우 자연스럽게 답이 나온다.


24.9 정리

이 장에서 살펴본 내용:

  • Go 의 격언: 메모리 공유로 통신하지 말고 통신으로 메모리를 공유하라
  • 락 범위가 커지거나 락이 얽히면 설계를 의심해 볼 신호다
  • 소유권 이전 패턴: 한 순간엔 한 고루틴만 데이터를 만진다
  • 단일 작성자 패턴: 쓰기는 한 고루틴에 위임하고 나머지는 채널로 부탁한다
  • 영역 나누기(샤딩): 키별로 워커를 분배해 충돌을 설계 단계에서 없앤다
  • 역할 나누기: 생산자와 소비자를 분리하면 공유가 자연스럽게 줄어든다
  • 불변 데이터는 락 없이 공유할 수 있다
  • 락 vs 채널은 대립 관계가 아니라 도구 차이다
    • 자리 지키기 → 락
    • 흐름 / 소유권 이전 → 채널

도구함이 한층 풍성해졌다. 이제 우리에겐 락, 채널, 그리고 그 둘을 묶는 설계 감각이 있다.

다음 장은 이 도구들을 본격적인 실전 패턴으로 묶는다. 파이프라인, 워커 풀, 취소, 그리고 동시성 코드를 디버깅하는 법까지 한꺼번에 정리한다.

25장. 동시성 패턴과 도구 모음

지금까지 우리가 손에 쥔 것들:

  • 22장 — 고루틴과 채널, select, WaitGroup
  • 23장 — 뮤텍스, race detector, atomic
  • 24장 — 락 없이 설계하는 발상들

이번 장은 그 도구들을 실제로 자주 쓰는 조합으로 묶는다. 파이프라인, fan-in/fan-out, 워커 풀, 그리고 취소/타임아웃을 다루는 context 패키지까지.

마지막엔 동시성 코드의 단골 버그인 고루틴 누수와 이를 추적하는 디버깅 팁을 다룬다.

목표:

  • 자주 쓰이는 동시성 패턴 4종을 손에 익히기
  • context 로 취소/타임아웃을 전파하는 법 익히기
  • 동시성 코드의 흔한 함정을 디버깅하는 감각 잡기

25.1 파이프라인 패턴

데이터를 여러 단계로 흘려보내는 구조.

[stage 1] --chan--> [stage 2] --chan--> [stage 3]

각 단계는 고루틴이고, 단계 사이를 채널이 잇는다. 한 단계는,

  • 입력 채널에서 값을 받고
  • 처리해서
  • 출력 채널로 보낸다

비유하면 공장 컨베이어 벨트다.

예제: 숫자 생성 → 제곱 → 합계

package main

import "fmt"

// 단계 1: 1..n 을 생성
func gen(n int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 1; i <= n; i++ {
			out <- i
		}
	}()
	return out
}

// 단계 2: 각 수를 제곱
func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for v := range in {
			out <- v * v
		}
	}()
	return out
}

// 단계 3: 합계 (수신 측에서 처리)
func main() {
	c1 := gen(5)
	c2 := square(c1)

	sum := 0
	for v := range c2 {
		sum += v
	}
	fmt.Println(sum) // 1+4+9+16+25 = 55
}

장점:

  • 단계별로 관심사가 또렷이 분리된다
  • 각 단계의 동시성을 따로 조절할 수 있다
  • 입력이 무한히 들어와도 메모리에 모두 쌓지 않는다 (스트리밍)

종료 규약

파이프라인을 안전하게 끝내는 규약 두 가지.

  1. 보내는 쪽이 닫는다.
    • 단계가 끝나면 자신의 출력 채널을 close
  2. 닫힌 채널은 range 가 자동 종료
    • 다음 단계가 자연스럽게 끝난다

이 규약이 지켜지면 마지막 단계까지 도미노처럼 조용히 끝난다.

중간 단계에서 일찍 끝내야 할 땐? 그건 취소(cancellation) 의 영역이다. 25.4 의 context 가 이를 담당한다.


25.2 Fan-out / Fan-in

파이프라인의 한 단계가 너무 무거우면 같은 단계를 여러 고루틴이 나눠 처리하게 만들 수 있다.

  • Fan-out — 하나의 입력 채널에서 여러 워커가 동시에 값을 가져간다
  • Fan-in — 여러 워커의 출력 채널을 하나의 채널로 합친다
        ┌──→ worker 1 ──┐
입력 ──┤   worker 2    ├──→ 합치기 ──→ 다음 단계
        └──→ worker 3 ──┘

Fan-out

특별한 코드가 필요 없다. 같은 채널을 여러 고루틴이 range 로 읽으면 한 값은 한 워커에게만 전달된다.

func startWorkers(in <-chan int, n int) []<-chan int {
	outs := make([]<-chan int, n)
	for i := 0; i < n; i++ {
		out := make(chan int)
		go func() {
			defer close(out)
			for v := range in {
				out <- v * v
			}
		}()
		outs[i] = out
	}
	return outs
}

각 워커는 자기만의 출력 채널을 만들고, 공용 입력 채널을 함께 읽는다.

Fan-in

여러 채널을 하나로 합치는 함수는 정해진 모양이 있다.

import "sync"

func merge(ins ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	wg.Add(len(ins))

	for _, in := range ins {
		in := in // 루프 변수 캡처 회피
		go func() {
			defer wg.Done()
			for v := range in {
				out <- v
			}
		}()
	}

	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

흐름:

  • 입력 채널마다 고루틴 하나가 붙어 출력으로 옮긴다
  • 모든 입력이 닫혀 끝나면 wg.Wait 가 풀린다
  • 그 직후 out 을 닫는다

“닫는 시점을 누가 책임지는가” 가 fan-in 의 핵심이다. 별도의 고루틴이 WaitGroup 을 보고 닫는 패턴이 정석이다.

합쳐서

func main() {
	c1 := gen(20)
	outs := startWorkers(c1, 3)
	merged := merge(outs...)

	for v := range merged {
		fmt.Println(v)
	}
}

워커 3개가 병렬로 제곱을 처리하고, 결과가 하나의 채널로 모인다. 순서는 더 이상 보장되지 않지만, 처리량은 올라간다.


25.3 워커 풀

가장 자주 쓰이는 동시성 패턴이라 따로 짚는다.

  • 고정된 N 개의 워커 고루틴
  • 공용 작업 채널에 작업을 던지면
  • 워커들이 알아서 하나씩 가져가 처리한다

장점:

  • 고루틴 수를 제어할 수 있다 (무작정 띄우다가 OOM 나는 사고 방지)
  • 외부 자원(파일, DB, API) 동시 접근 수를 제한할 수 있다

정식 구현

package main

import (
	"fmt"
	"sync"
)

type Job struct {
	ID  int
	Val int
}

type Result struct {
	ID  int
	Out int
}

func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		out := j.Val * j.Val // 실제 작업
		results <- Result{ID: j.ID, Out: out}
		fmt.Printf("worker %d 처리 %d\n", id, j.ID)
	}
}

func main() {
	const numWorkers = 3
	const numJobs = 10

	jobs := make(chan Job)
	results := make(chan Result, numJobs)

	var wg sync.WaitGroup
	for i := 1; i <= numWorkers; i++ {
		wg.Add(1)
		go worker(i, jobs, results, &wg)
	}

	// 작업 투입
	go func() {
		defer close(jobs)
		for i := 0; i < numJobs; i++ {
			jobs <- Job{ID: i, Val: i}
		}
	}()

	// 워커가 모두 끝나면 결과 채널을 닫는다
	go func() {
		wg.Wait()
		close(results)
	}()

	for r := range results {
		fmt.Printf("결과 %d -> %d\n", r.ID, r.Out)
	}
}

핵심 규약 정리:

누가무엇을 닫나
작업 투입 고루틴jobs 채널을 닫는다
별도 감시 고루틴wg.Waitresults 닫는다
워커자기는 채널을 닫지 않는다

이 분담이 정석이다. 워커가 results 를 직접 닫으려 하면, 다른 워커가 거기 송신하다 패닉이 난다.

워커 수 정하기

  • I/O 가 많은 작업: CPU 코어 수보다 많이 띄워도 된다
    • 대부분 시간 동안 대기 중이라 코어를 다 안 쓴다
  • CPU 가 많은 작업: 코어 수 정도가 적당하다
    • runtime.NumCPU() 로 가져올 수 있다

“그냥 1000명 띄우자” 는 거의 항상 잘못된 답이다. 어디선가 자원이 막혀 결국 더 느려진다.


25.4 context 패키지

지금까지 본 파이프라인/워커 풀에는 한 가지가 빠져 있다.

“이제 그만, 다 멈춰.” 를 어떻게 전달할까?

타임아웃, 사용자 취소, 부모 요청 취소. 이런 신호를 호출 트리 전체에 전파해 주는 도구가 표준 라이브러리 context 패키지다.

왜 따로 도구가 필요한가

채널 하나 만들어 “취소” 라고 부르면 안 되나?

사실 그게 핵심 아이디어 그대로다. context 는 그 아이디어를 표준화하고, 거기에 다음을 더 얹은 것이다.

  • 취소 채널 + “왜 끝났는지” 사유 (Err)
  • 자동 타임아웃 / 데드라인
  • 호출 트리 따라 자식 컨텍스트로 전파
  • 요청 범위 값(request-scoped value) 운반

기본 컨텍스트

ctx := context.Background()  // 루트 (보통 main, 서버 진입점)
ctx := context.TODO()        // "아직 정하지 않았다" 용 placeholder

대부분 함수의 첫 매개변수로 ctx context.Context 를 받는다.

취소 가능한 컨텍스트

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
  • ctx.Done() — 취소 시 닫히는 채널
  • ctx.Err() — 왜 끝났는지 (context.Canceled 또는 DeadlineExceeded)
  • cancel() — 직접 취소 (꼭 호출해 줘야 리소스 누수가 없다)

타임아웃 / 데드라인

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

// 또는
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second))
defer cancel()

지정한 시간이 지나면 ctx.Done() 이 자동으로 닫힌다.

사용 패턴

select 와 함께 쓰는 게 정석이다.

func work(ctx context.Context, in <-chan int) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err()
		case v, ok := <-in:
			if !ok {
				return nil
			}
			process(v)
		}
	}
}

ctx.Done() 케이스를 첫 번째에 두는 게 관례다.

예제: HTTP 요청 타임아웃

import (
	"context"
	"net/http"
	"time"
)

func fetch(ctx context.Context, url string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
	if err != nil {
		return nil, err
	}
	return http.DefaultClient.Do(req)
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	resp, err := fetch(ctx, "https://example.com")
	if err != nil {
		fmt.Println("실패:", err)
		return
	}
	defer resp.Body.Close()
	// ...
}
  • 2초 안에 응답이 오지 않으면 자동 취소
  • http.Clientctx.Done() 을 보고 연결을 끊는다

호출 트리 따라 전파

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

go a(ctx)   // a 안에서 다시 b(ctx) 호출
go c(ctx)   // c 안에서 다시 d(ctx) 호출

부모를 취소하면 자식들도 같이 취소된다. 즉, 취소가 트리 전체에 흐른다. 이 자동 전파가 context 의 진짜 가치다.

컨텍스트 값 (간단히)

ctx := context.WithValue(parent, key, value)
v := ctx.Value(key)

요청 단위 메타데이터(사용자 ID, 요청 ID 등)를 옮길 때 쓴다.

함정: 함수 매개변수 대용으로 쓰면 안 된다. 진짜 함수 인자는 함수 인자로 받자. 컨텍스트 값은 요청 범위 메타데이터 한정.

context 권장 규약

  • 함수 시그니처의 첫 번째 매개변수ctx context.Context
  • 구조체 필드로 보관하지 않는다 (수명 추적이 망가진다)
  • nil 컨텍스트는 넘기지 않는다 (context.TODO() 사용)
  • cancel() 은 항상 호출 (보통 defer cancel())

25.5 그 밖의 동기화 도구

표준 라이브러리에는 자주 쓰진 않지만 알아 두면 유용한 도구가 더 있다.

sync.Once

어떤 작업을 단 한 번만 실행하고 싶을 때.

var (
	once sync.Once
	conn *Connection
)

func GetConn() *Connection {
	once.Do(func() {
		conn = openConnection()
	})
	return conn
}

여러 고루틴이 동시에 GetConn 을 호출해도 openConnection딱 한 번만 실행된다. 다른 호출자는 첫 번째가 끝날 때까지 기다린다.

쓰임:

  • 싱글톤 초기화
  • 전역 캐시 워밍업
  • 무거운 설정 로딩

sync.Map

동시 접근에 안전한 맵이다.

var m sync.Map

m.Store("k", 1)
v, ok := m.Load("k")
m.Delete("k")
m.Range(func(k, v interface{}) bool {
	fmt.Println(k, v)
	return true
})

언제 쓸까?

공식 문서가 권장하는 좁은 경우는 이렇다.

  • 키 집합이 한 번 채워진 뒤 거의 변하지 않고 읽기만 많을 때
  • 또는 서로 다른 고루틴이 서로 다른 키만 만질 때

그 외에는 보통 map[K]V + sync.Mutex 가 더 빠르고 읽기 쉽다. “동시성 안전한 맵이 필요해 → sync.Map” 식의 반사적 선택은 피하자.

또한 sync.Map 은 타입 안전하지 않다 (interface{}). 제네릭 시대에는 좀 어색하게 느껴지는 API다.

sync.Cond

조건 변수(condition variable). “어떤 조건이 만족될 때까지 기다린다” 를 표현하는 저수준 도구.

var (
	mu   sync.Mutex
	cond = sync.NewCond(&mu)
	data []int
)

// 소비자
mu.Lock()
for len(data) == 0 {
	cond.Wait() // mu 를 풀고 잠들어 있다가 깨면 다시 잠근다
}
v := data[0]
data = data[1:]
mu.Unlock()

// 생산자
mu.Lock()
data = append(data, 42)
cond.Signal()  // 또는 cond.Broadcast()
mu.Unlock()

기능은 강력하지만 Go 에선 거의 채널로 대체 가능하다. 공식 문서도 “보통은 채널이 더 낫다” 고 안내한다.

→ 존재만 알아 두고, 처음엔 채널로 풀어 보자.


25.6 고루틴 누수

동시성 코드의 가장 흔한 사고 중 하나는 고루틴 누수다.

어떻게 새나

고루틴이 끝나지 않고 영원히 멈춰 있는 경우. 주범은 거의 항상 채널이다.

예제 1: 받는 사람이 없는 송신

func leak() {
	ch := make(chan int)
	go func() {
		ch <- 1 // 받는 사람이 없어 영원히 막힌다
	}()
	// ch 를 한 번도 읽지 않는다
}

leak 이 끝나도 안의 고루틴은 끝나지 못한다. 호출할 때마다 고루틴이 하나씩 쌓인다.

예제 2: 보내는 사람이 없는 수신

func wait(ch <-chan int) {
	v := <-ch // 누군가 보낼 때까지 영원히 대기
	fmt.Println(v)
}

호출자가 ch 를 닫지도, 보내지도 않으면 이 고루틴은 영원히 잠든다.

예제 3: 취소 없는 무한 루프

func poll() {
	for {
		check()
		time.Sleep(time.Second)
	}
}
go poll()

종료 신호를 받을 길이 없다. 프로세스가 죽기 전엔 살아 있다.

한 줄 진단

고루틴 누수는 보통 “끝나는 조건을 안 줬다” 의 다른 이름이다.

막는 법

대원칙:

고루틴을 만들었으면 언제, 어떻게 끝나는지 같이 정해 두자.

도구별 처방:

  • 채널 송신이 막힐 위험이 있다면
    • 받는 사람이 항상 있도록 설계
    • 또는 select + ctx.Done() 으로 빠져나갈 길
  • 채널 수신이 막힐 위험이 있다면
    • 송신자가 다 끝나면 채널을 닫는다
    • select + ctx.Done() 으로 빠져나갈 길
  • 무한 루프 고루틴은
    • 반드시 ctx context.Context 를 받고
    • 한 번씩 <-ctx.Done() 체크

context 적용 예

위 예제를 누수 없는 형태로 고치면,

func poll(ctx context.Context) {
	t := time.NewTicker(time.Second)
	defer t.Stop()

	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			check()
		}
	}
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go poll(ctx)

종료 시점에 cancel() 하면 고루틴이 깔끔히 끝난다.


25.7 동시성 디버깅 팁

마지막으로, 동시성 코드와 친해지는 데 도움이 되는 실용 팁 몇 가지.

(1) -race 는 기본으로 켠다

go test -race ./...

CI 에서 항상 켜 두자. 없을 땐 보이지 않던 버그가 켜고 나면 줄줄이 보이는 경우가 흔하다.

운영 배포에선 끄지만 (5~10배 느리다), 개발/테스트 단계의 표준 도구로 자리잡혀야 한다.

(2) 고루틴 수 모니터링

runtime.NumGoroutine() 으로 현재 고루틴 수를 알 수 있다.

import "runtime"

fmt.Println("goroutines:", runtime.NumGoroutine())

운영 서버에서는 시간에 따라 이 값이 서서히 증가만 하면 고루틴 누수다.

가벼운 패턴:

  • HTTP 핸들러 시작/끝에 로그
  • 요청 처리량은 비슷한데 고루틴 수만 늘면 의심
  • expvar 등으로 메트릭에 노출

(3) pprof 의 goroutine 프로파일

언급만 짧게.

  • import _ "net/http/pprof" 와 디버그 서버
  • go tool pprof http://.../debug/pprof/goroutine
  • 현재 살아 있는 고루틴들의 스택 트레이스를 본다

“어디서 멈춰 있는지” 가 한 번에 보이므로 누수 추적의 결정타가 된다.

자세한 사용법은 26장(pprof 절)에서 다룬다.

(4) 작은 단위로 격리해서 테스트

동시성 버그는 큰 시스템에서 잡기 어렵다. 의심되는 부분을 떼어 내 짧은 테스트로 재현해 보자.

  • 작은 카운터 1000번 증가
  • 채널 송수신 100번 반복
  • go test -race -count=100 ./...

-count=N 옵션은 같은 테스트를 N번 반복 실행한다. 드물게 터지는 버그를 잡는 데 효과적이다.

(5) 가능한 한 결정론적으로

테스트는 시간에 의존하지 않게 짠다.

  • time.Sleep 으로 “충분히 기다리겠지” 는 금물
  • 채널이나 WaitGroup 으로 명시적 동기화
  • 시간 자체가 필요하면 추상화 (Clock 인터페이스)

(6) 정리 체크리스트

코드 리뷰 때 다음을 떠올려 보자.

  • 모든 고루틴에 종료 경로가 있는가?
  • 채널은 누가 닫는지 명확한가?
  • 락 안에서 시간이 오래 걸리는 작업을 하지 않는가?
  • 락을 두 개 이상 잡는다면 순서가 정해져 있는가?
  • 공유 상태 옆에 // 보호: mu 같은 주석이 있는가?
  • -race 로 한 번 돌려 봤는가?

이 정도만 챙겨도 사고가 크게 줄어든다.


25.8 정리

이 장에서 살펴본 내용:

  • 파이프라인: 단계별 채널로 데이터를 흘려보낸다
  • Fan-out / Fan-in: 한 단계를 여러 워커로 병렬화하고 결과를 하나로 합친다
  • 워커 풀: 고정된 N개의 워커가 작업 채널을 나눠 처리한다
  • context 패키지로 취소/타임아웃을 호출 트리 전체에 전파한다
  • sync.Once, sync.Map, sync.Cond 는 좁은 상황에서 유용한 보조 도구다
  • 고루틴 누수는 “끝나는 조건이 없다” 의 다른 이름이고, context 와 채널 닫기 규약으로 막는다
  • 디버깅의 첫걸음은 -race, 고루틴 수 추적, pprof

여기까지가 Go 동시성의 큰 그림이다.

  • 22장에서 도구를 익히고
  • 23장에서 위험을 배우고
  • 24장에서 설계로 위험을 줄이고
  • 25장에서 실전 패턴으로 묶었다

다음 부에서는 시야를 다른 쪽으로 돌린다. 대용량 데이터와 메모리 효율 이야기다. 슬라이스, 포인터, 스트리밍 처리, 벤치마크와 pprof 같은 “성능을 의식한 코드” 의 기초를 본격적으로 다룬다.

26장. 대용량 데이터와 메모리 효율

지금까지는 “동작하는 코드” 를 목표로 했다. 이번 장부터는 한 단계 더 나아간다. “같은 일을 더 적은 메모리로, 더 빠르게 하는 방법” 을 알아본다.

목표:

  • 슬라이스가 메모리에서 어떻게 동작하는지 이해하기
  • 값 전달과 포인터 전달을 적절히 고르기
  • 큰 데이터를 한 번에 메모리에 다 올리지 않는 패턴 익히기
  • 자주 쓰는 최적화 기법을 손에 익히기
  • testing.Bpprof 로 성능을 직접 재 보기

26.1 슬라이스의 메모리 동작 깊이 이해

11장에서 슬라이스를 처음 배웠다. 여기서는 “그 슬라이스가 메모리에서 실제로 어떤 일을 하는지” 를 들여다본다.

슬라이스는 배열을 가리키는 “창문”

슬라이스는 그 자체로 데이터를 들고 있지 않다. 실제 데이터는 따로 있는 배열에 들어 있고, 슬라이스는 그 배열의 일부를 보는 창문이다.

arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4] // s 는 arr 의 일부를 본다

슬라이스 내부에는 세 가지 정보가 있다.

필드의미
포인터어느 배열의 어디부터 보는지
길이 (len)지금 보고 있는 원소 개수
용량 (cap)가리키는 배열의 남은 자리 포함 크기

이 세 값만 들고 다닌다. 그래서 슬라이스 자체는 매우 가볍다.

append 가 일으키는 재할당

append 는 슬라이스 끝에 원소를 붙인다. 하지만 그 동작이 늘 단순하지는 않다.

  • 용량(cap)에 여유가 있으면 같은 배열 끝에 그대로 적어 넣는다
  • 용량이 꽉 찼다면 더 큰 새 배열을 만들고 전부 복사한 뒤 거기에 추가한다

이때의 복사가 곧 재할당 비용이다. 크기가 커질수록 무시 못 할 수준이 된다.

growth 전략

새 배열을 만들 때 얼마나 크게 잡을까. Go 의 대략적인 규칙은 이렇다.

  • 작은 슬라이스는 약 2배씩 키운다
  • 어느 정도 커지면 1.25배 같은 작은 비율로 키운다

정확한 수치는 버전마다 다르다. 중요한 건 “한 번에 적당히 크게 잡아서 재할당 횟수를 줄인다” 는 점이다.

cap 을 미리 잡기

크기를 미리 알고 있다면 make 에 용량을 지정해 주는 게 좋다.

// 나쁜 예: cap 이 계속 모자라 재할당이 반복된다
s := []int{}
for i := 0; i < 10000; i++ {
    s = append(s, i)
}

// 좋은 예: 한 번에 충분히 잡아 둔다
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
    s = append(s, i)
}

같은 코드처럼 보이지만 재할당 횟수와 복사 비용이 크게 다르다.

부분 슬라이스가 거대한 배열을 붙잡는 함정

슬라이스는 원본 배열을 참조한다. 그래서 작은 부분만 뽑아 써도 배열 전체가 메모리에 남아 있다.

func firstFew(data []byte) []byte {
    return data[:10] // 앞 10바이트만 반환
}

10바이트만 반환했다고 안심하면 안 된다. 반환된 슬라이스가 살아 있는 동안 원본 data 전체가 GC 되지 않는다.

수백 MB 짜리 파일에서 앞 10바이트만 들고 있어도 그 파일 전체가 메모리에 남는 셈이다.

copy 로 메모리 끊어내기

원본을 놓아주려면 새 슬라이스에 복사해 둔다.

func firstFew(data []byte) []byte {
    out := make([]byte, 10)
    copy(out, data[:10])
    return out
}

이제 out 은 자기만의 작은 배열을 갖는다. 원본 data 는 GC 대상이 된다.


26.2 값 전달 vs 포인터 전달

14장에서 포인터를 배웠다. “큰 구조체는 포인터로 넘기는 게 좋다” 고 대충 이야기했었다. 여기서는 그 “좋다” 의 진짜 의미를 본다.

큰 구조체 복사 비용

값으로 넘기면 구조체 전체가 복사된다. 필드가 적을 때는 무시할 수준이지만 큰 구조체에서는 다르다.

type Big struct {
    data [1 << 14]byte // 약 16KB
}

func process(b Big) { ... } // 호출마다 16KB 복사

만 번 호출하면 160MB 어치의 복사가 일어난다.

포인터 전달이 항상 좋은 건 아닌 이유

“그럼 늘 포인터로 넘기면 되겠네?” 라고 생각할 수 있다. 사실은 그렇지 않다.

포인터를 넘기는 순간 컴파일러는 그 데이터가 함수 바깥에서도 참조될 수 있다고 판단한다. 그러면 그 값은 힙(heap) 에 할당된다.

힙 할당은 다음과 같은 비용을 낳는다.

  • 할당 자체가 스택 할당보다 느리다
  • GC 가 추적해야 할 객체가 늘어난다
  • 자주 일어나면 GC 부담이 커진다

스택과 힙, escape analysis

Go 는 컴파일 시점에 “이 값이 함수 바깥으로 새 나가는가” 를 분석한다. 이를 escape analysis 라고 부른다.

  • 안 새 나가면 → 스택에 둔다 (싸다, GC 무관)
  • 새 나가면 → 힙으로 올린다 (할당 비용 + GC)

같은 코드라도 함수 바깥으로 포인터를 반환하면 힙으로 가고, 함수 안에서만 쓰면 스택에 머문다.

이 분석은 자동이라 우리가 직접 할 일은 없다. 다만 “포인터를 자주 넘기면 GC 부담이 늘 수 있다” 는 감각은 갖고 있는 게 좋다.

언제 값, 언제 포인터

대략의 직관 가이드.

상황권장
작은 구조체 (필드 몇 개)
큰 구조체포인터
함수 안에서 값을 수정해야 함포인터
동시성에서 공유포인터 (락과 함께)
메서드 리시버가 섞이는 게 헷갈림한 타입은 한쪽으로 통일

확신이 없다면 처음엔 값으로 시작하고, 프로파일링으로 병목이 보이면 포인터로 바꿔도 늦지 않다.


26.3 대용량 데이터 처리 패턴

가장 흔한 함정은 이거다. “파일 전체를 메모리에 읽고 처리한다.” 파일이 1GB 라면 메모리가 1GB 필요해진다.

한 번에 다 메모리에 올리지 않기

대용량 데이터는 흘려보내며 처리한다. 한 번에 한 조각씩 읽고, 처리하고, 버린다.

핵심 도구는 세 가지다.

  • io.Reader / io.Writer 인터페이스
  • 채널로 만든 파이프라인
  • bufio.Scanner 같은 줄 단위 도구

DB 결과를 페이징/커서/배치로 가져오기

100만 행짜리 테이블을 한 번에 가져오면 안 된다. 나눠서 가져온다.

// 의사 코드: 페이징
pageSize := 1000
offset := 0
for {
    rows := db.Query(
        "SELECT ... LIMIT ? OFFSET ?",
        pageSize, offset,
    )
    if len(rows) == 0 {
        break
    }
    for _, r := range rows {
        process(r)
    }
    offset += pageSize
}

실제 DB 라이브러리에서는 보통 커서(cursor)배치 fetch 를 지원한다. 한 줄씩 스트림으로 받을 수도 있다.

// 의사 코드: 커서 방식
rows := db.QueryStream("SELECT ...")
defer rows.Close()
for rows.Next() {
    var r Row
    rows.Scan(&r)
    process(r)
}

어느 쪽이든 핵심은 같다. “전부 메모리에 올리지 말고 한 조각씩 받아 처리한다.”

io.Reader 로 스트림 처리

파일이든 네트워크든 압축 파일이든 Go 에서는 io.Reader 라는 통일된 모양으로 다룬다.

buf := make([]byte, 4096)
for {
    n, err := r.Read(buf)
    if n > 0 {
        process(buf[:n])
    }
    if err == io.EOF {
        break
    }
    if err != nil {
        return err
    }
}

buf 는 4KB 한 덩어리만 차지한다. 파일이 아무리 커도 메모리 사용량이 일정하다.

채널로 파이프라인 만들기

25장에서 본 파이프라인 패턴이 대용량 처리에도 그대로 쓰인다.

in := readLines("big.log")        // 단계 1
parsed := parseLines(in)           // 단계 2
filtered := filterErrors(parsed)   // 단계 3
for e := range filtered {
    saveError(e)
}

각 단계는 한 줄씩 흘려보낸다. 전체를 모았다가 다음 단계로 넘기는 게 아니다. 중간 어디서도 큰 메모리가 쌓이지 않는다.

bufio.Scanner 로 줄 단위 처리

텍스트 파일을 줄 단위로 다룰 때 표준 도구다.

f, err := os.Open("big.log")
if err != nil {
    return err
}
defer f.Close()

sc := bufio.NewScanner(f)
for sc.Scan() {
    line := sc.Text()
    process(line)
}
if err := sc.Err(); err != nil {
    return err
}

내부적으로 버퍼 한 덩어리만 들고 파일을 한 줄씩 흘려본다. 1GB 짜리 로그도 메모리 부담 없이 훑을 수 있다.

기본 줄 길이 제한이 있다. 매우 긴 줄을 만나면 sc.Buffer(...) 로 키워야 한다.


26.4 자주 쓰이는 최적화 기법

언어 차원에서 자주 쓰는 손쉬운 기법들이다. “이걸 안 했다면 손해” 정도의 수준이다.

strings.Builder 로 문자열 누적

문자열은 불변이다. s = s + "abc" 를 할 때마다 새 문자열을 만들고 옛 것을 버린다. 반복문 안에서 하면 매우 비싸다.

// 나쁜 예
s := ""
for i := 0; i < 10000; i++ {
    s += "x"
}

대신 strings.Builder 를 쓴다. 내부적으로 버퍼를 키워 가며 쌓는다.

// 좋은 예
var b strings.Builder
for i := 0; i < 10000; i++ {
    b.WriteString("x")
}
s := b.String()

문자열을 많이 이어 붙여야 한다면 항상 이쪽이다.

맵 미리 크기 지정

맵도 슬라이스처럼 내부적으로 성장한다. 크기를 알고 있다면 초기 크기를 지정한다.

m := make(map[string]int, 1000)

성장에 따른 재해싱 비용이 줄어든다.

슬라이스 미리 크기 지정

26.1 에서 본 패턴이다. 한 번 더 강조한다.

out := make([]int, 0, len(in))
for _, v := range in {
    out = append(out, v*2)
}

len(in) 만큼 결과가 나올 게 뻔하다면 미리 잡아 두는 게 깔끔하다.

sync.Pool 로 객체 재사용

같은 모양의 임시 버퍼를 초당 수천 번 만들었다가 버린다면 GC 가 비명을 지른다.

sync.Pool 은 그런 객체를 재활용한다.

var bufPool = sync.Pool{
    New: func() any {
        return make([]byte, 4096)
    },
}

func handle() {
    buf := bufPool.Get().([]byte)
    defer bufPool.Put(buf)
    // buf 를 사용
}
  • Get() 으로 가져와서 쓰고
  • Put() 으로 반납

풀에서 꺼낸 객체는 이전 사용 흔적이 남아 있을 수 있다. 필요하면 직접 초기화해야 한다.

모든 임시 객체에 쓸 필요는 없다. 프로파일링으로 GC 비용이 크다고 확인됐을 때 쓴다.


26.5 성능 측정

“느린 것 같다” 는 감각만으로 최적화하면 안 된다. 측정으로 확인한 뒤에 손댄다.

Go 는 표준 라이브러리에 벤치마크 도구가 있다.

testing.B 로 벤치마크 작성

테스트 파일(_test.go)에 BenchmarkXxx 로 시작하는 함수를 적는다.

package main

import "testing"

func BenchmarkConcat(b *testing.B) {
    for i := 0; i < b.N; i++ {
        s := ""
        for j := 0; j < 1000; j++ {
            s += "x"
        }
        _ = s
    }
}

func BenchmarkBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        var sb strings.Builder
        for j := 0; j < 1000; j++ {
            sb.WriteString("x")
        }
        _ = sb.String()
    }
}

b.N 은 Go 가 자동으로 정한다. 함수가 빠르면 더 많이 돌리고, 느리면 적게 돌려서 의미 있는 측정 시간을 채운다.

실행

go test -bench=. -benchmem
  • -bench=. 모든 벤치마크 실행
  • -benchmem 메모리 할당 정보도 함께

결과 읽는 법

출력은 대략 이렇게 생겼다.

BenchmarkConcat-8       1000   1234567 ns/op   55432 B/op   999 allocs/op
BenchmarkBuilder-8     30000     45678 ns/op    2048 B/op     5 allocs/op
항목의미
1000반복 횟수 (b.N)
ns/op한 번 실행에 든 나노초
B/op한 번 실행이 할당한 바이트
allocs/op한 번 실행이 일으킨 할당 횟수

위 예에서 Builder 가 약 27배 빠르고 할당 횟수는 200분의 1 수준이다. “문자열 누적엔 Builder” 라는 판단의 근거가 이런 숫자다.


26.6 pprof 맛보기

벤치마크는 “어떤 함수가 빠른가” 를 알려준다. pprof 는 한 단계 더 들어간다. “내 프로그램이 시간을 어디서 쓰고 있는가” 를 본다.

CPU 프로파일 켜기

벤치마크에 옵션을 붙이면 프로파일 파일이 생긴다.

go test -bench=. -cpuprofile=cpu.out

서버라면 net/http/pprof 패키지를 임포트해서 런타임에 프로파일을 받을 수도 있다.

분석

go tool pprof cpu.out

대화형 모드로 들어간다.

(pprof) top
(pprof) list functionName
  • top — 시간을 많이 쓴 함수 상위 목록
  • list — 함수 안 어느 줄에서 시간을 쓰는지 표시

메모리 프로파일

go test -bench=. -memprofile=mem.out
go tool pprof mem.out

명령은 거의 같다. “어떤 함수가 메모리를 많이 할당하는지” 를 본다.

깊은 분석은 별도 학습

pprof 는 도구가 깊다. 플레임 그래프, 차이 비교, 웹 UI 등 기능이 많다.

이 책에서는 “이런 게 있다, 필요할 때 찾으면 된다” 정도까지만 안내한다. 실무에서 마주칠 때 공식 문서나 별도 자료로 익히면 충분하다.


26.7 정리

  • 슬라이스는 배열을 가리키는 창문이다
    • append 는 cap 부족 시 재할당을 일으킨다
    • make([]T, 0, cap) 으로 미리 잡으면 비용을 줄인다
    • 부분 슬라이스가 큰 배열을 붙잡는 함정에 주의
    • 필요하면 copy 로 메모리를 끊어 낸다
  • 값/포인터 전달은 trade-off
    • 큰 구조체는 포인터, 작은 구조체는 값
    • 포인터는 힙 할당과 GC 부담을 늘릴 수 있다
  • 대용량 데이터는 흘려보내며 처리한다
    • io.Reader, bufio.Scanner, 채널 파이프라인
  • 손쉬운 최적화 기법
    • strings.Builder, 컨테이너 크기 미리 지정, sync.Pool
  • 최적화 전에 측정한다
    • testing.B 로 벤치마크
    • pprof 로 병목 분석

성능은 감으로 손대지 않는다. “이게 느리네” 가 아니라 “이 함수가 전체 시간의 70%를 쓰고 있네” 라는 근거가 생긴 뒤에 손대도 늦지 않다.

다음 장부터는 표준 라이브러리를 본격적으로 둘러본다. 27장은 문자열 다루기 심화부터 출발한다.

27장. 문자열 다루기 심화

6장에서 문자열의 기초를 다뤘다. “문자열은 불변이다”, “len 은 바이트 수다” 같은 이야기였다.

이번 장에서는 실제 코드에서 자주 만나는 문자열 처리 작업들을 살펴본다. 표준 라이브러리의 stringsstrconv 가 주인공이다.

목표:

  • strings 패키지의 자주 쓰는 함수 익히기
  • 문자열과 숫자, bool 사이의 변환 도구 익히기
  • 정규표현식이 있다는 사실과 입구 정도 알기

27.1 strings 패키지

strings 는 문자열을 검사하고 변환하는 도구 모음이다. 일상 코드에서 가장 많이 쓰는 함수들을 짧은 예제와 함께 본다.

포함 여부 검사: Contains

strings.Contains("hello world", "world") // true
strings.Contains("hello", "Xyz")         // false

부분 문자열이 들어 있는지 확인한다.

시작/끝 검사: HasPrefix / HasSuffix

strings.HasPrefix("hello.go", "hello") // true
strings.HasSuffix("hello.go", ".go")   // true

파일명 확장자 검사 등에 자주 쓴다.

위치 찾기: Index / LastIndex

strings.Index("hello", "l")     // 2
strings.LastIndex("hello", "l") // 3
strings.Index("hello", "z")     // -1 (없음)

없으면 -1 을 반환한다.

분할: Split

parts := strings.Split("a,b,c,d", ",")
// []string{"a","b","c","d"}

구분자 기준으로 잘라 슬라이스로 돌려준다.

빈 구분자("") 를 주면 글자 하나하나로 쪼개진다. 다만 멀티바이트 문자에서는 의도와 다를 수 있다.

조합: Join

strings.Join([]string{"a","b","c"}, "-")
// "a-b-c"

Split 의 반대다.

치환: Replace / ReplaceAll

strings.Replace("aaaa", "a", "b", 2) // "bbaa"
strings.ReplaceAll("aaaa", "a", "b") // "bbbb"

Replace 의 마지막 인자는 바꿀 횟수다. -1 을 주면 전부 바꾸지만, 보통은 ReplaceAll 이 더 읽기 쉽다.

대소문자: ToUpper / ToLower

strings.ToUpper("Hello") // "HELLO"
strings.ToLower("Hello") // "hello"

영문에서는 직관대로 동작한다. 유니코드 규칙도 잘 따른다.

공백/문자 제거: TrimSpace / Trim

strings.TrimSpace("  hello  \n")  // "hello"
strings.Trim("--hello--", "-")    // "hello"
strings.TrimLeft("--hello", "-")  // "hello"
strings.TrimRight("hello--", "-") // "hello"
  • TrimSpace 는 공백/탭/개행 등을 제거
  • Trim 은 특정 문자 집합을 제거
  • TrimLeft / TrimRight 는 한쪽만

Trim 두 번째 인자는 “문자 집합” 이다. "-_ " 을 주면 -, _, 공백을 모두 제거한다. 부분 문자열이 아니다.

반복: Repeat

strings.Repeat("ab", 3) // "ababab"

같은 문자열을 N 번 이어 붙인다.

길이 검사 도우미: Count, EqualFold

strings.Count("hello", "l")            // 2
strings.EqualFold("Hello", "hello")    // true (대소문자 무시)

빈도수 세기, 대소문자 무관 비교에 자주 쓴다.


27.2 strings.Builder

26장에서 본 strings.Builder 를 다시 정리한다. 문자열을 누적해 만드는 표준 도구다.

왜 쓰는가

// 안 좋은 예
s := ""
for i := 0; i < 1000; i++ {
    s += "x"
}

문자열은 불변이라 매번 새 문자열이 만들어진다. 반복문 안에서 하면 비용이 빠르게 누적된다.

strings.Builder 는 내부에 버퍼를 두고 키워 가며 쌓는다.

기본 사용

var b strings.Builder
b.WriteString("Hello, ")
b.WriteString("World")
b.WriteRune('!')
fmt.Println(b.String()) // "Hello, World!"
  • WriteString — 문자열 한 덩어리 추가
  • WriteRune — 유니코드 글자 하나 추가
  • WriteByte — 바이트 하나 추가
  • String() — 지금까지 쌓은 결과 반환

미리 용량 잡기

대략 얼마나 쓸지 알면 Grow 로 잡아 둔다.

var b strings.Builder
b.Grow(1024)

내부 버퍼 재할당 횟수가 줄어든다. 26장의 슬라이스 cap 잡기와 같은 발상이다.

strings.Builder 는 복사하면 안 된다. 함수에 넘길 때는 포인터로 넘긴다.


27.3 strconv 패키지

strconv 는 문자열과 다른 기본 타입 사이의 변환을 다룬다. “숫자를 입력 받았는데 string 으로 들어왔다” 같은 상황에서 쓴다.

정수 ↔ 문자열

가장 흔한 한 쌍은 ItoaAtoi 다.

s := strconv.Itoa(42)          // "42"
n, err := strconv.Atoi("42")   // 42, nil
n, err = strconv.Atoi("hello") // 0, error
함수의미
Itoaint → string
Atoistring → int (에러 가능)

이름이 옛 C 표준 라이브러리의 관례를 따른다.

더 일반적인 파싱: ParseInt / ParseFloat / ParseBool

// 진법, 비트 폭 지정 가능
n, err := strconv.ParseInt("ff", 16, 64) // 255

// 실수
f, err := strconv.ParseFloat("3.14", 64) // 3.14

// 불리언
b, err := strconv.ParseBool("true") // true

ParseInt 의 두 번째 인자가 진법이다. 0 을 주면 접두사를 보고 알아서 판단한다 (0x → 16진수, 0b → 2진수 등).

세 번째 인자는 결과의 비트 폭이다. 보통 64 를 쓴다.

더 일반적인 포매팅: FormatInt / FormatFloat

strconv.FormatInt(255, 16)               // "ff"
strconv.FormatFloat(3.14, 'f', 2, 64)
// "3.14"

FormatFloat 인자의 의미:

인자의미
변환할 실수
포맷'f', 'e', 'g'
자릿수소수점 아래 자리
비트 폭32 또는 64

이스케이프 처리: Quote / Unquote

문자열을 코드에 그대로 박을 수 있는 모양으로 변환하고 싶을 때 쓴다.

strconv.Quote("hello\n")   // `"hello\n"`
strconv.Unquote(`"hello\n"`) // "hello\n", nil

로그에 제어 문자가 섞인 문자열을 안전하게 찍거나, 설정 파일을 만들 때 유용하다.

Atoi vs ParseInt 언제 무엇

상황권장
단순 10진수 정수만 다룬다Atoi
16진수, 2진수 등 다양한 진법ParseInt
결과 비트 폭을 통제하고 싶다ParseInt

27.4 정규표현식 살짝

복잡한 패턴 매칭이 필요할 때 정규표현식(regexp) 을 쓴다. Go 의 regexp 패키지가 표준이다.

간단한 사용 예

re := regexp.MustCompile(`\d+`)

re.MatchString("abc 123 def")
// true (숫자가 들어 있나?)

re.FindString("abc 123 def")
// "123" (첫 번째 매칭)

re.FindAllString("a1 b22 c333", -1)
// []string{"1","22","333"}

re.ReplaceAllString("a1 b22", "#")
// "a# b#"
  • MustCompile — 정규식을 미리 컴파일
  • MatchString — 매칭 여부
  • FindString / FindAllString — 추출
  • ReplaceAllString — 치환

그룹 캡처

괄호로 묶으면 부분 그룹을 뽑을 수 있다.

re := regexp.MustCompile(`(\w+)@(\w+)`)
m := re.FindStringSubmatch("user@example")
// []string{"user@example","user","example"}

컴파일 비용

정규식 컴파일은 비싸다. 가능하면 패키지 변수로 한 번만 컴파일한다.

var emailRe = regexp.MustCompile(`...`)

func isEmail(s string) bool {
    return emailRe.MatchString(s)
}

깊이는 별도 학습

정규표현식 자체가 한 권의 책이다. Go 의 regexp 도 RE2 라는 별도 엔진을 쓰며, 선행/후방 탐색 같은 일부 기능이 빠져 있다.

깊이 들어갈 일이 생기면 정규표현식 입문서와 regexp 공식 문서를 함께 보는 게 좋다.


27.5 정리

  • strings 패키지로 검사, 분할, 치환, 트리밍을 한다
    • Contains, HasPrefix, Split, Join, ReplaceAll, TrimSpace
  • 문자열 누적은 strings.Builder
    • 반복문 안 + 연결은 피한다
  • strconv 로 문자열과 다른 타입을 변환한다
    • 정수: Atoi, Itoa, ParseInt, FormatInt
    • 실수: ParseFloat, FormatFloat
    • bool: ParseBool, FormatBool
    • 이스케이프: Quote, Unquote
  • 복잡한 패턴은 regexp 패키지
    • MustCompileMatchString, FindString, ReplaceAllString
    • 자주 쓰는 패턴은 패키지 변수로 캐시

문자열 처리는 양이 많지만 패턴은 단순하다. “검사 → 변환 → 누적” 세 갈래로 묶어 두고 필요할 때 사전처럼 찾아 쓰면 된다.

다음 장에서는 시간을 다룬다. 파일이나 네트워크만큼 자주 쓰이는 영역이다.

28장. 시간 다루기

시간은 프로그램 어디에나 등장한다. 로그 타임스탬프, 만료 검사, 타임아웃, 주기적인 작업, 기록 정렬…

Go 는 time 패키지 하나로 대부분의 시간 작업을 처리한다. 이번 장에서 그 사용법을 둘러본다.

목표:

  • time.Time 으로 시각을 표현하기
  • 현재 시각을 얻고 임의의 시각을 만들기
  • 포맷팅과 파싱 (Go 의 독특한 reference time 이해)
  • 시간 사이의 차이와 연산
  • 타이머/Ticker 와 동시성 조합
  • 시간대(timezone) 다루기

28.1 time.Time

time.Time 은 “시간 위의 한 점” 을 나타내는 타입이다. 일상 언어로 말하면 “어떤 순간” 이다.

예를 들어 “2026년 1월 1일 오전 9시 0분 0초” 같은 값이 하나의 time.Time 이 된다.

내부에는 두 가지가 함께 들어 있다.

  • 그 순간 자체 (몇 년 몇 월 며칠 몇 시…)
  • 어떤 시간대(time zone) 기준인지

같은 순간이라도 시간대가 다르면 표시는 달라진다.

순간한국(KST)UTC
같은 순간09:0000:00

time.Time 은 이 모든 정보를 함께 들고 있다.

비교나 빼기 같은 연산은 시간대와 무관하게 “같은 순간인지” 를 기준으로 동작한다.


28.2 현재 시각 / 만들기

현재 시각

now := time.Now()
fmt.Println(now)
// 2026-05-24 13:00:00.123 +0900 KST

time.Now() 는 시스템 시계의 현재 시각을 반환한다. 보통 가장 자주 쓰는 함수다.

임의 시각 만들기

time.Date 로 직접 만들 수 있다.

t := time.Date(
    2026, time.May, 24,
    13, 0, 0, 0,
    time.Local,
)

인자는 순서대로 다음을 뜻한다.

인자의미
year
month월 (time.Month 상수)
day
hour시 (0~23)
min
sec
nsec나노초
loc시간대 (*time.Location)

월은 time.January 부터 time.December 까지의 상수를 쓴다. 1 같은 정수를 그대로 써도 동작한다 (time.Month(1)).


28.3 포맷팅과 파싱

Go 의 독특한 reference time

다른 언어는 보통 YYYY-MM-DD HH:mm:ss 같은 형식 문자열을 쓴다. Go 는 좀 다르다. 기준 시각 자체를 형식 문자열로 쓴다.

기준 시각은 외워야 한다.

2006-01-02 15:04:05 -0700 MST

각 자리는 이런 의미다.

부분의미
2006
01
02
15시 (24시간)
04
05
-0700시간대 오프셋
MST시간대 이름

순서대로 외우면 1 2 3 4 5 6 -7. “2006년 1월 2일 3시 4분 5초, -7 오프셋” 이라는 식으로 기억하면 좋다.

Format

t := time.Now()
t.Format("2006-01-02")
// "2026-05-24"
t.Format("2006/01/02 15:04")
// "2026/05/24 13:00"
t.Format("2006-01-02T15:04:05Z07:00")
// RFC3339 형식

기준 시각의 자리에 원하는 자리 표시자를 끼워 넣는 식이다.

Parse

문자열을 time.Time 으로 되돌리는 함수다.

t, err := time.Parse(
    "2006-01-02",
    "2026-05-24",
)

첫 번째 인자가 형식, 두 번째가 실제 값.

자주 쓰는 포맷 상수

표준 라이브러리가 자주 쓰는 형식을 상수로 제공한다.

time.RFC3339      // "2006-01-02T15:04:05Z07:00"
time.RFC1123      // "Mon, 02 Jan 2006 15:04:05 MST"
time.DateOnly     // "2006-01-02"
time.TimeOnly     // "15:04:05"
time.DateTime     // "2006-01-02 15:04:05"
t.Format(time.RFC3339)
t.Format(time.DateTime)

한국 포맷 예시

t.Format("2006년 1월 2일 15시 04분")
// "2026년 5월 24일 13시 00분"

한글이 섞여도 문제없다. 자리 표시자 부분만 정확하면 된다.


28.4 시간 연산

time.Duration

“얼마만큼의 시간 길이” 를 나타내는 타입이다. 나노초 단위의 정수다.

time.Second           // 1초
3 * time.Second       // 3초
500 * time.Millisecond // 0.5초
time.Hour + 30*time.Minute // 1시간 30분

Duration 값을 그대로 출력하면 사람 친화적으로 나온다.

fmt.Println(2 * time.Hour) // "2h0m0s"

Add / Sub

t := time.Now()
later := t.Add(2 * time.Hour) // 2시간 뒤
diff := later.Sub(t)          // Duration
  • Add(d)Time 을 반환
  • Sub(other)Duration 을 반환

Before / After / Equal

t1.Before(t2) // t1 이 t2 보다 이전인가
t1.After(t2)  // t1 이 t2 보다 이후인가
t1.Equal(t2)  // 같은 순간인가

== 가 아니라 Equal 을 쓰는 게 안전하다. 시간대가 다른 같은 순간을 비교할 때 == 는 false 가 나올 수 있지만 Equal 은 true 를 돌려준다.

Since / Until

자주 쓰는 짧은 도우미다.

start := time.Now()
doWork()
elapsed := time.Since(start)
// Now() - start 와 같다

deadline := someTime
remaining := time.Until(deadline)
// deadline - Now() 와 같다

Since 는 “그 뒤로 얼마나 지났나”, Until 은 “그때까지 얼마나 남았나”.


28.5 타이머와 Ticker

time.Sleep

가장 단순한 도구. 지정한 시간 동안 현재 고루틴을 멈춘다.

time.Sleep(2 * time.Second)

time.After

지정 시간 뒤에 값을 넣어 주는 채널을 반환한다. select 에서 타임아웃을 만들 때 자주 쓴다.

select {
case msg := <-ch:
    fmt.Println("받음:", msg)
case <-time.After(3 * time.Second):
    fmt.Println("3초 안에 못 받음")
}

22장의 select 와 자연스럽게 결합한다.

time.NewTimer

한 번만 울리는 타이머가 필요하면 NewTimer 를 쓴다. 중간에 멈추거나 재설정할 수 있다는 점이 After 와 다르다.

t := time.NewTimer(5 * time.Second)
defer t.Stop()

select {
case <-t.C:
    fmt.Println("5초 경과")
case <-cancel:
    // 취소된 경우, Stop 으로 깔끔히 정리
}

time.NewTicker

주기적으로 신호를 보내는 도구. 1초마다, 10초마다 등의 정기 작업에 쓴다.

ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()

for i := 0; i < 3; i++ {
    t := <-ticker.C
    fmt.Println("틱:", t)
}

다 쓴 Ticker 는 반드시 Stop() 한다. 안 그러면 내부 고루틴이 살아 있어 누수 원인이 된다.

9부 동시성과의 조합

타이머/Ticker 는 채널을 반환한다. 그래서 22~25장에서 본 도구들과 자연스럽게 결합한다.

ctx, cancel := context.WithTimeout(
    context.Background(),
    5 * time.Second,
)
defer cancel()

select {
case result := <-doWork():
    handle(result)
case <-ctx.Done():
    fmt.Println("타임아웃 또는 취소")
}

context.WithTimeout 내부도 사실은 타이머다.


28.6 시간대 (timezone)

time.Location 은 시간대를 표현한다.

표준 위치

의미
time.UTCUTC
time.Local현재 시스템의 로컬 시간대

임의 시간대 불러오기

loc, err := time.LoadLocation("Asia/Seoul")
if err != nil {
    return err
}
t := time.Now().In(loc)
fmt.Println(t)

위치 이름은 IANA 데이터베이스 기준이다.

  • "UTC"
  • "Asia/Seoul"
  • "America/New_York"
  • "Europe/London"

In(loc) 은 같은 순간을 다른 시간대로 표시한다. 순간 자체는 안 바뀌고, 시간대만 바뀐다.

흔한 함정

같은 순간이라도 Format 결과는 시간대에 따라 다르다.

t := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
fmt.Println(t.Format(time.DateTime))
// "2026-01-01 00:00:00"

kst, _ := time.LoadLocation("Asia/Seoul")
fmt.Println(t.In(kst).Format(time.DateTime))
// "2026-01-01 09:00:00"

서버 로그가 UTC 인데 사용자 화면은 KST 로 보여 줘야 한다면 저장은 UTC, 표시할 때 In(loc) 으로 변환하는 게 무난한 관례다.


28.7 정리

  • time.Time 은 시각 + 시간대를 함께 들고 다닌다
  • time.Now() 로 현재 시각, time.Date(...) 로 임의 시각
  • 포맷팅/파싱은 reference time 2006-01-02 15:04:05 를 외운다
    • time.RFC3339, time.DateTime 같은 상수도 활용
  • time.Duration 으로 길이를 표현
    • Add, Sub, Since, Until 로 연산
    • Before, After, Equal 로 비교
  • 타이머와 Ticker
    • time.Sleep 은 단순 대기
    • time.After 는 select 타임아웃
    • time.NewTicker 는 주기 작업
  • 시간대는 time.UTC, time.Local, 또는 time.LoadLocation("Asia/Seoul")

시간은 단순해 보이지만 함정이 많다. “순간 자체” 와 “표시 방식” 을 머리 속에서 분리해 두면 헷갈릴 일이 줄어든다.

다음 장에서는 파일 입출력을 다룬다. 시간만큼이나 자주 쓰는 영역이다.

29장. 파일 입출력

파일을 읽고 쓰는 일은 거의 모든 프로그램의 기본 작업이다. 설정을 읽고, 로그를 남기고, 결과를 저장하고.

Go 는 파일 입출력을 위한 표준 도구가 잘 갖춰져 있다. 이 장에서 자주 쓰는 패턴을 익힌다.

목표:

  • 파일을 열고 안전하게 닫기
  • 전체 / 줄 단위 / 청크 단위 읽기
  • 전체 / 점진적 쓰기
  • io.Reader / io.Writer 인터페이스의 의미 이해
  • 경로 다루기와 디렉터리 작업

29.1 파일 열기/닫기

os.Open (읽기 전용)

f, err := os.Open("hello.txt")
if err != nil {
    return err
}
defer f.Close()

// f 로부터 읽기 수행
  • 파일이 없거나 권한이 없으면 에러
  • 성공하면 *os.File 을 돌려준다
  • 다 쓰면 반드시 Close() 해야 자원이 해제된다

os.Create (쓰기 전용, 새 파일)

f, err := os.Create("out.txt")
if err != nil {
    return err
}
defer f.Close()
  • 파일이 없으면 새로 만든다
  • 이미 있으면 기존 내용을 비우고 새로 시작한다
  • 의도와 다르게 기존 파일을 날리지 않도록 주의

os.OpenFile (옵션 지정)

가장 일반적인 형태다. 플래그로 동작을 세밀하게 정한다.

f, err := os.OpenFile(
    "app.log",
    os.O_APPEND|os.O_CREATE|os.O_WRONLY,
    0644,
)

자주 쓰는 플래그:

플래그의미
O_RDONLY읽기 전용
O_WRONLY쓰기 전용
O_RDWR읽기 + 쓰기
O_APPEND끝에 이어 쓰기
O_CREATE없으면 만들기
O_TRUNC열 때 내용을 비움

세 번째 인자는 권한(퍼미션) 이다. 0644 는 “주인 읽기/쓰기, 나머지 읽기만” 이다.

defer f.Close() 관례

9장에서 본 defer 가 여기서 빛을 발한다. 파일을 열자마자 defer f.Close() 를 적어 두면 함수가 어떻게 끝나든 닫힘이 보장된다.

func readFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    // 여기서 어떤 에러로 빠져나가도
    // Close() 는 호출된다
    ...
    return nil
}

닫기 자체에서 발생한 에러를 챙겨야 할 때도 있다. 그럴 때는 명시적으로 호출하고 에러를 살핀다.

에러 처리

21장의 에러 처리 관례를 그대로 따른다.

f, err := os.Open(path)
if err != nil {
    return fmt.Errorf("파일 열기 실패: %w", err)
}
defer f.Close()

%w 로 래핑해서 호출자가 원인을 추적할 수 있게 한다.


29.2 파일 읽기

상황에 따라 세 가지 방식이 있다.

전체 한 번에: os.ReadFile

작은 파일을 통째로 읽을 때.

data, err := os.ReadFile("config.json")
if err != nil {
    return err
}
fmt.Println(string(data))
  • 열기, 읽기, 닫기를 한 번에 처리
  • 결과는 []byte
  • 파일이 크다면 메모리를 통째로 차지하므로 주의 (26장 참고)

io.ReadAll

이미 열린 io.Reader 에서 전체를 읽어 들일 때.

f, err := os.Open("data.txt")
if err != nil {
    return err
}
defer f.Close()

data, err := io.ReadAll(f)

os.ReadFile 과 비슷하지만 “이미 손에 든 Reader” 에 쓸 수 있다는 점이 다르다.

한 줄씩: bufio.Scanner

텍스트 파일을 줄 단위로 처리할 때 표준 패턴이다.

f, err := os.Open("big.log")
if err != nil {
    return err
}
defer f.Close()

sc := bufio.NewScanner(f)
for sc.Scan() {
    line := sc.Text()
    process(line)
}
if err := sc.Err(); err != nil {
    return err
}
  • sc.Scan() 이 true 면 다음 줄이 있다
  • sc.Text() 가 줄 내용 (개행 제외)
  • 끝난 뒤 sc.Err() 로 에러를 확인

기본 줄 길이 제한이 있다. 긴 줄을 다룬다면 sc.Buffer(...) 로 키운다.

청크 단위: io.Reader + 버퍼

바이너리 파일이거나 줄이 의미 없는 경우.

f, _ := os.Open("video.mp4")
defer f.Close()

buf := make([]byte, 4096)
for {
    n, err := f.Read(buf)
    if n > 0 {
        process(buf[:n])
    }
    if err == io.EOF {
        break
    }
    if err != nil {
        return err
    }
}

Read 의 반환 규칙은 약간 까다롭다. n 이 0 이상이고 errio.EOF 일 수 있는 등 모든 조합을 다 처리해야 한다.

귀찮으면 위에서 본 Scannerio.ReadAll 을 쓰는 게 낫다.


29.3 파일 쓰기

전체 한 번에: os.WriteFile

data := []byte("hello\n")
err := os.WriteFile("hello.txt", data, 0644)
  • 파일을 만들거나 덮어쓰고
  • 데이터를 통째로 적고
  • 닫는다

작은 결과를 단번에 저장할 때 편하다.

점진적으로: bufio.Writer

여러 번 나눠서 쓸 때.

f, err := os.Create("out.txt")
if err != nil {
    return err
}
defer f.Close()

w := bufio.NewWriter(f)
for _, line := range lines {
    w.WriteString(line)
    w.WriteByte('\n')
}
if err := w.Flush(); err != nil {
    return err
}

bufio.Writer 는 내부 버퍼에 모아 두었다가 한 번에 파일로 보낸다. 시스템 호출 횟수가 줄어 성능이 좋다.

Flush() 를 잊지 마라. 내부 버퍼에만 남고 파일에 안 적힌 데이터가 그대로 사라질 수 있다.

defer w.Flush() 보다 명시적으로 끝에 Flush() 후 에러 검사를 하는 게 안전하다. defer 에서는 에러를 챙기기 까다롭다.


29.4 io.Reader / io.Writer 인터페이스

이 장에 계속 등장하는 두 인터페이스를 정리한다.

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

Go 의 입출력은 거의 전부 이 두 인터페이스로 통일된다.

  • os.File 은 둘 다 구현한다
  • net.Conn (네트워크 연결) 도 구현한다
  • bytes.Buffer, strings.Reader 도 구현한다
  • gzip.Reader (압축 해제) 도 구현한다

이게 강력한 이유. io.Copy(dst, src) 같은 함수 하나로 “파일에서 네트워크로”, “압축 해제 후 파일로” 등 어떤 조합이든 처리할 수 있다.

io.Copy(dstFile, srcFile)   // 파일 복사
io.Copy(dstConn, srcFile)   // 파일 → 네트워크
io.Copy(dstFile, gzReader)  // 압축 해제 → 파일

16장의 인터페이스가 이런 식으로 빛을 발한다.


29.5 경로 다루기

문자열을 직접 자르고 합치는 대신 표준 패키지를 쓴다.

path/filepath 패키지

filepath.Join("dir", "sub", "a.txt")
// "dir/sub/a.txt" (Linux/macOS)
// "dir\\sub\\a.txt" (Windows)

filepath.Dir("/a/b/c.txt")  // "/a/b"
filepath.Base("/a/b/c.txt") // "c.txt"
filepath.Ext("c.txt")        // ".txt"

abs, err := filepath.Abs("./data")
// 절대 경로로 변환
함수의미
Join경로 조각을 안전하게 합침
Dir디렉터리 부분
Base파일명 부분
Ext확장자 부분 (점 포함)
Abs절대 경로
Clean.., . 등을 정리

윈도우/유닉스 경로 차이

filepath 는 OS 의 경로 구분자를 자동으로 쓴다.

OS구분자
Linux / macOS/
Windows\

직접 + "/" + 로 합치면 윈도우에서 문제가 생긴다. 항상 filepath.Join 을 쓴다.

URL 경로처럼 항상 / 인 경우는 path 패키지를 쓴다. filepath 와 이름이 비슷하지만 다른 패키지다.


29.6 디렉터리 작업

디렉터리 만들기

os.Mkdir("data", 0755)       // 한 단계
os.MkdirAll("data/2026/05", 0755) // 중간 디렉터리 포함
  • Mkdir 은 부모가 없으면 실패한다
  • MkdirAll 은 중간 경로를 자동으로 만든다

디렉터리 내용 읽기

entries, err := os.ReadDir("data")
if err != nil {
    return err
}
for _, e := range entries {
    fmt.Println(e.Name(), e.IsDir())
}

os.ReadDir 은 디렉터리의 한 단계 자식 목록을 돌려준다. 각 항목에 Name(), IsDir(), Type() 같은 메서드가 있다.

파일 / 디렉터리 정보

info, err := os.Stat("hello.txt")
if err != nil {
    return err
}
fmt.Println(info.Size())    // 바이트 수
fmt.Println(info.ModTime()) // 마지막 수정 시각
fmt.Println(info.IsDir())   // 디렉터리인가

os.Stat 으로 메타데이터를 얻는다. “파일이 존재하는가” 확인에도 쓴다.

if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
    // 파일 없음
}

21장에서 본 errors.Is 와 함께 쓰면 깔끔하다.

파일 트리 순회

전체 디렉터리를 재귀적으로 훑을 때.

err := filepath.WalkDir(
    "data",
    func(path string, d fs.DirEntry, err error) error {
        if err != nil {
            return err
        }
        fmt.Println(path)
        return nil
    },
)
  • 모든 파일/디렉터리에 대해 콜백이 호출된다
  • 콜백이 에러를 반환하면 순회가 중단된다
  • 특정 디렉터리를 건너뛰려면 return filepath.SkipDir 를 반환한다

파일/디렉터리 지우기

os.Remove("hello.txt")       // 한 파일
os.RemoveAll("dir/sub")       // 디렉터리 전체 (위험)

RemoveAll 은 강력하다. 경로 계산이 잘못되면 엉뚱한 곳을 다 날릴 수 있다. 넘기는 경로를 두 번 확인하자.


29.7 정리

  • 파일 열기 = os.Open (읽기), os.Create (쓰기), os.OpenFile (옵션)
    • 항상 defer f.Close()
  • 읽기
    • 전체: os.ReadFile, io.ReadAll
    • 줄 단위: bufio.Scanner
    • 청크 단위: io.Reader.Read
  • 쓰기
    • 전체: os.WriteFile
    • 점진적: bufio.Writer (반드시 Flush)
  • io.Reader / io.Writer 는 파일/네트워크/압축 등 모든 입출력을 하나의 모양으로 묶는다
  • 경로는 path/filepath
    • Join, Dir, Base, Ext, Abs
  • 디렉터리는 os.Mkdir, os.ReadDir, os.Stat, filepath.WalkDir

파일 입출력은 패턴이 정형화돼 있다. “열기 → defer 닫기 → 읽기/쓰기 → 에러 검사” 이 흐름만 손에 익으면 대부분의 작업을 해낼 수 있다.

다음 장에서는 데이터를 파일이나 네트워크로 주고받을 때 가장 자주 쓰이는 형식인 JSON 을 다룬다.

30장. JSON 다루기

JSON 은 오늘날 가장 흔한 데이터 교환 형식이다. 웹 API, 설정 파일, 로그, DB 의 한 컬럼까지 어디에나 등장한다.

Go 의 표준 라이브러리 encoding/json 만으로 대부분의 JSON 작업을 처리할 수 있다. 이번 장에서 그 사용법을 익힌다.

목표:

  • Go 구조체와 JSON 사이를 자유롭게 변환하기
  • 필드 태그로 키 이름과 옵션을 다루기
  • 모양이 불확실한 JSON 을 안전하게 다루기
  • 큰 JSON 데이터를 스트리밍으로 처리하기

30.1 encoding/json 패키지

핵심 함수는 두 개다.

함수역할
json.MarshalGo 값 → JSON 바이트
json.UnmarshalJSON 바이트 → Go 값

이 둘만 알아도 90% 의 작업이 끝난다.

import "encoding/json"

30.2 구조체와 JSON 매핑

가장 흔한 패턴은 구조체와 JSON 을 1:1 로 맞추는 것이다.

기본 매핑

type User struct {
    Name string
    Age  int
}

u := User{Name: "Alice", Age: 30}
data, _ := json.Marshal(u)
fmt.Println(string(data))
// {"Name":"Alice","Age":30}

구조체 필드 이름이 그대로 JSON 키가 된다.

대소문자 규칙

매우 중요한 규칙: 대문자로 시작하는 필드만 JSON 에 포함된다.

type User struct {
    Name string // O 포함됨
    age  int    // X 무시됨 (소문자)
}

이는 20장에서 본 export 규칙과 같다. json 패키지는 소문자 필드에 접근할 수 없다.

필드 태그로 키 이름 바꾸기

JSON 키는 보통 snake_casecamelCase 다. Go 의 PascalCase 와 다르다. 필드 태그로 매핑을 명시한다.

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

data, _ := json.Marshal(User{Name: "Alice", Age: 30})
// {"name":"Alice","age":30}

백틱(`) 으로 둘러싼 문자열이 태그다. json:"키이름" 형태로 적는다.

옵션: omitempty

빈 값이면 JSON 에서 빼고 싶을 때.

type User struct {
    Name  string `json:"name"`
    Email string `json:"email,omitempty"`
}

u := User{Name: "Alice"} // Email 은 빈 문자열
data, _ := json.Marshal(u)
// {"name":"Alice"}     ← email 키가 없다

“빈 값” 의 기준은 각 타입의 제로값이다.

  • 문자열: ""
  • 숫자: 0
  • bool: false
  • 포인터/슬라이스/맵: nil

옵션: 제외("-")

특정 필드를 JSON 에서 항상 빼고 싶을 때.

type User struct {
    Name     string `json:"name"`
    Password string `json:"-"`
}

Password 필드는 JSON 에 절대 나오지 않는다. 민감 정보 처리에 유용하다.

예제: User 구조체와 JSON

type User struct {
    ID       int    `json:"id"`
    Name     string `json:"name"`
    Email    string `json:"email,omitempty"`
    Password string `json:"-"`
}

u := User{
    ID:       1,
    Name:     "Alice",
    Password: "secret",
}
data, _ := json.Marshal(u)
// {"id":1,"name":"Alice"}

30.3 인코딩 (구조체 → JSON)

Marshal

data, err := json.Marshal(value)
if err != nil {
    return err
}

결과는 []byte 다. 문자열이 필요하면 string(data) 로 변환한다.

MarshalIndent (가독성)

사람이 읽기 좋게 들여쓰기 된 JSON 을 만들 때.

data, _ := json.MarshalIndent(u, "", "  ")
fmt.Println(string(data))
{
  "id": 1,
  "name": "Alice"
}
  • 두 번째 인자: 줄 머리 접두사 (보통 "")
  • 세 번째 인자: 들여쓰기 (보통 공백 2~4칸)

설정 파일이나 디버그 출력에 유용하다.

슬라이스 / 맵 / 중첩 구조체

복잡한 구조도 그대로 인코딩된다.

type Address struct {
    City    string `json:"city"`
    Country string `json:"country"`
}

type Person struct {
    Name      string   `json:"name"`
    Hobbies   []string `json:"hobbies"`
    Address   Address  `json:"address"`
    Scores    map[string]int `json:"scores"`
}

p := Person{
    Name:    "Alice",
    Hobbies: []string{"reading", "hiking"},
    Address: Address{City: "Seoul", Country: "KR"},
    Scores:  map[string]int{"math": 90, "eng": 80},
}
data, _ := json.MarshalIndent(p, "", "  ")
{
  "name": "Alice",
  "hobbies": ["reading", "hiking"],
  "address": {
    "city": "Seoul",
    "country": "KR"
  },
  "scores": {
    "math": 90,
    "eng": 80
  }
}

별도 설정 없이 자동으로 처리된다.


30.4 디코딩 (JSON → 구조체)

Unmarshal

data := []byte(`{"name":"Alice","age":30}`)

var u User
if err := json.Unmarshal(data, &u); err != nil {
    return err
}
fmt.Println(u.Name, u.Age)

핵심 포인트:

  • 두 번째 인자에 포인터를 넘긴다
  • 그 포인터가 가리키는 구조체에 값이 채워진다

에러 처리

JSON 이 깨졌거나 타입이 맞지 않으면 에러가 난다.

data := []byte(`{"name":"Alice","age":"thirty"}`) // age 가 문자열

var u User
err := json.Unmarshal(data, &u)
if err != nil {
    fmt.Println("디코딩 실패:", err)
}

21장의 에러 처리 관례를 그대로 적용한다.

알 수 없는 필드는 무시

JSON 에는 있는데 구조체에는 없는 필드는 조용히 무시된다.

data := []byte(`{"name":"Alice","age":30,"extra":"hi"}`)

type User struct {
    Name string `json:"name"`
}

var u User
json.Unmarshal(data, &u)
// u.Name = "Alice", "age" 와 "extra" 는 무시

호환성을 유지하기 좋은 동작이다. 서버가 새 필드를 추가해도 클라이언트는 깨지지 않는다.

모르는 필드에서 에러를 내고 싶다면 json.DecoderDisallowUnknownFields() 를 쓴다.

빠진 필드는 제로값으로

JSON 에 없는 필드는 Go 의 제로값으로 남는다.

data := []byte(`{"name":"Alice"}`)

type User struct {
    Name string `json:"name"`
    Age  int    `json:"age"`
}

var u User
json.Unmarshal(data, &u)
// u = {Name:"Alice", Age:0}

“필드가 들어왔는가” 와 “값이 0 인가” 를 구분하려면 포인터 타입으로 선언한다.

type User struct {
    Age *int `json:"age"`
}
  • 안 들어왔으면 nil
  • 0 이 들어왔으면 0 을 가리키는 포인터

30.5 동적 JSON 다루기

JSON 모양을 미리 알 수 없는 경우가 있다. 구조체를 못 정한다.

map 또는 any 로 받기

data := []byte(`{"name":"Alice","age":30}`)

var m map[string]any
json.Unmarshal(data, &m)

fmt.Println(m["name"]) // "Alice"
fmt.Println(m["age"])  // 30 (실제 타입은 float64)

JSON 의 값들이 Go 타입으로 변환되는 규칙은 다음과 같다.

JSON 타입Go 타입
문자열string
숫자float64 (정수도 포함)
불리언bool
배열[]any
객체map[string]any
nullnil

숫자가 전부 float64 라는 점은 함정이다. 큰 정수를 다룬다면 정밀도 손실에 주의하자. json.DecoderUseNumber() 옵션이 도움이 된다.

값을 꺼낼 때는 16장의 타입 단언을 쓴다.

if name, ok := m["name"].(string); ok {
    fmt.Println(name)
}

json.RawMessage 로 지연 처리

일부 필드만 미리 디코드하고 나머지는 나중에 처리하고 싶을 때.

type Envelope struct {
    Type    string          `json:"type"`
    Payload json.RawMessage `json:"payload"`
}

RawMessage 는 그냥 바이트로 보관된다. Type 값을 본 뒤에 그에 맞는 구조체로 다시 Unmarshal 한다.

var env Envelope
json.Unmarshal(data, &env)

switch env.Type {
case "login":
    var p LoginPayload
    json.Unmarshal(env.Payload, &p)
case "logout":
    var p LogoutPayload
    json.Unmarshal(env.Payload, &p)
}

메시지가 여러 종류인 프로토콜에서 자주 쓰는 패턴이다.


30.6 스트리밍

Marshal / Unmarshal 은 데이터를 통째로 메모리에 들고 처리한다. 큰 파일이나 네트워크 스트림에서는 비효율적이다.

26장에서 본 스트리밍 철학을 JSON 에도 적용할 수 있다. 도구는 json.Decoderjson.Encoder 다.

json.Decoder

io.Reader 에서 JSON 값을 하나씩 읽어 들인다.

f, _ := os.Open("big.json")
defer f.Close()

dec := json.NewDecoder(f)
var u User
if err := dec.Decode(&u); err != nil {
    return err
}

여러 값이 이어진 형태(“JSON Lines”) 도 자연스럽다.

// 한 줄에 하나씩 JSON 객체가 있는 파일
dec := json.NewDecoder(f)
for {
    var u User
    err := dec.Decode(&u)
    if err == io.EOF {
        break
    }
    if err != nil {
        return err
    }
    process(u)
}

전체를 메모리에 올리지 않고 한 객체씩 흘려보낸다. 26장의 bufio.Scanner 와 같은 발상이다.

json.Encoder

io.Writer 에 JSON 을 흘려보낸다.

f, _ := os.Create("out.json")
defer f.Close()

enc := json.NewEncoder(f)
for _, u := range users {
    if err := enc.Encode(u); err != nil {
        return err
    }
}

Encode 호출이 한 객체 + 개행을 적는다. JSON Lines 형식이 그대로 만들어진다.

들여쓰기를 켤 수도 있다.

enc.SetIndent("", "  ")

큰 파일 / 네트워크 스트림 처리

HTTP 응답 같은 네트워크 스트림은 그 자체가 io.Reader 다. 그래서 같은 패턴이 그대로 적용된다.

resp, _ := http.Get(url)
defer resp.Body.Close()

dec := json.NewDecoder(resp.Body)
var data Response
dec.Decode(&data)

io.ReadAll 로 한 번에 다 받고 Unmarshal 하는 방법도 있지만, 응답이 커질 수 있다면 Decoder 가 더 안전하다.


30.7 정리

  • json.Marshal / Unmarshal 로 기본 변환
  • 구조체 ↔ JSON 매핑
    • 대문자 시작 필드만 인코딩됨
    • 태그 json:"키이름" 으로 키 이름 지정
    • omitempty 로 빈 값 생략
    • "-" 로 필드 제외
  • 디코딩 동작
    • 알 수 없는 필드는 무시
    • 빠진 필드는 제로값
    • 들어왔는지 0 인지 구분하려면 포인터 타입
  • 모양이 불확실한 JSON
    • map[string]any 로 받고 타입 단언
    • json.RawMessage 로 지연 처리
  • 스트리밍
    • json.Decoder 로 큰 파일/네트워크 처리
    • json.Encoder 로 점진적 출력

JSON 작업의 90% 는 “구조체와 태그를 잘 정의하는 것” 이다. 거기서부터 시작해서, 필요할 때 동적/스트리밍 도구를 꺼내 쓰면 된다.

다음 장에서는 지금까지 배운 것을 종합해 간단한 HTTP 서버를 만들어 본다. JSON, 시간, 파일, 동시성이 한자리에 모인다.

31장. 간단한 HTTP 서버

여기까지 왔다면 Go 의 거의 모든 핵심 기능을 봤다. 이번 장은 그 모든 게 한자리에 모이는 곳이다.

구조체와 JSON, 함수와 인터페이스, 동시성과 채널, 에러 처리… 이런 것들이 합쳐져 웹 서버라는 결과물이 된다.

목표:

  • net/http 만으로 HTTP 서버를 띄우기
  • 핸들러를 등록하고 요청/응답 다루기
  • 쿼리, 경로, JSON 본문 읽기
  • 미들웨어 패턴 익히기
  • 정적 파일 서빙

31.1 net/http 둘러보기

Go 의 표준 라이브러리에는 이미 완전한 HTTP 서버가 들어 있다.

  • 별도 프레임워크 없이 서버를 만들 수 있다
  • 운영 환경에서도 그대로 쓸 만한 성능
  • 외부 라이브러리는 라우팅 편의 등 부가 기능 정도

이 장은 표준 라이브러리만 으로 다룬다. 나중에 chi, echo, gin 같은 프레임워크로 옮겨가도 근본 개념은 그대로 통한다.

import "net/http"

31.2 가장 작은 HTTP 서버

전체 코드 먼저.

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, World!")
    })

    log.Println("listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

이 한 파일이 진짜 동작하는 웹 서버다. 실행 후 브라우저로 http://localhost:8080 에 접속하면 “Hello, World!” 가 보인다.

핵심 두 함수.

함수역할
http.HandleFunc경로 → 처리 함수 등록
http.ListenAndServe지정한 포트에서 서버 시작

ListenAndServe 의 두 번째 인자가 nil 이면 기본 라우터(default mux) 를 쓴다. 방금 등록한 핸들러가 거기 등록돼 있다.


31.3 핸들러

http.Handler 인터페이스

net/http 의 핵심 인터페이스는 이거다.

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

이 한 메서드만 가진 타입이면 모두 핸들러가 될 수 있다. 16장에서 본 인터페이스의 위력이다.

HandlerFunc 어댑터

매번 타입을 만들고 메서드를 다는 건 번거롭다. 그래서 http.HandlerFunc 라는 어댑터가 있다.

type HandlerFunc func(w ResponseWriter, r *Request)

// 자기 자신을 메서드로 호출
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

이 덕분에 그냥 함수를 핸들러로 쓸 수 있다. HandleFunc 가 내부에서 이 어댑터를 적용한다.

ResponseWriter 와 Request

핸들러 함수의 두 인자.

  • http.ResponseWriter — 응답을 적는 곳
    • 헤더 설정: w.Header()
    • 상태 코드: w.WriteHeader(...)
    • 본문: w.Write(...) 또는 fmt.Fprint(w, ...)
  • *http.Request — 요청 정보가 든 구조체
    • r.Method — GET, POST 등
    • r.URL — URL 객체
    • r.Header — 요청 헤더
    • r.Body — 요청 본문 (io.Reader)

31.4 URL 라우팅

http.ServeMux

내장 라우터다.

mux := http.NewServeMux()
mux.HandleFunc("/", indexHandler)
mux.HandleFunc("/about", aboutHandler)

http.ListenAndServe(":8080", mux)

직접 mux 를 만들어 두 번째 인자로 넘기는 편이 큰 프로젝트에서 더 깔끔하다.

경로 매칭 규칙

  • /users 처럼 끝에 / 가 없으면 정확 일치
  • /users/ 처럼 끝에 / 가 있으면 접두사 매치
mux.HandleFunc("/static/", staticHandler)
// /static/ 로 시작하는 모든 경로

Go 1.22 이후: 메서드 + 경로 패턴

Go 1.22 부터는 라우터가 한층 강력해졌다. 메서드와 경로 변수를 지정할 수 있다.

mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)

경로 변수는 r.PathValue("id") 로 꺼낸다.

이전에는 외부 라우터 라이브러리가 사실상 필수였지만, 이제는 표준만으로도 꽤 멀리 갈 수 있다.


31.5 요청 다루기

쿼리 파라미터

// GET /search?q=hello&page=2
func search(w http.ResponseWriter, r *http.Request) {
    q := r.URL.Query()
    keyword := q.Get("q")     // "hello"
    page := q.Get("page")     // "2" (문자열)

    fmt.Fprintf(w, "q=%s page=%s", keyword, page)
}

r.URL.Query()map 비슷한 타입이다.

  • Get(key) — 한 값 (없으면 "")
  • Has(key) — 키가 있는지

문자열이므로 숫자가 필요하면 27장의 strconv 로 변환한다.

경로 변수 (Go 1.22+)

mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "user id = %s", id)
})

요청 메서드

switch r.Method {
case http.MethodGet:
    ...
case http.MethodPost:
    ...
default:
    http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}

Go 1.22 이후 라우터 패턴을 쓰면 메서드는 라우팅 단계에서 걸러지므로 이 분기가 줄어든다.

헤더

ua := r.Header.Get("User-Agent")
auth := r.Header.Get("Authorization")

r.Headermap[string][]string 형태지만 보통 Get 으로 충분하다.

JSON 본문 디코딩

30장의 스트리밍 디코더가 여기서 빛난다.

type CreateUser struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUser
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad json", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    fmt.Fprintf(w, "created: %s", req.Name)
}
  • r.Bodyio.Reader 이므로 json.NewDecoder 가 그대로 받는다
  • 디코딩 실패는 400 으로 응답
  • r.Body.Close() 는 좋은 습관이지만 Go 가 자동 정리를 어느 정도 보장한다

31.6 응답 보내기

상태 코드

w.WriteHeader(http.StatusCreated) // 201
  • 한 번만 호출할 수 있다
  • 호출 안 하면 자동으로 200
  • 헤더를 먼저 설정한 뒤에 호출해야 한다

응답 헤더

w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Custom", "value")

WriteHeader 또는 Write 가 호출된 뒤에는 헤더 변경이 무시된다. “헤더 설정 → 상태 코드 → 본문” 순서를 지킨다.

JSON 응답 보내기 (전체 예제)

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
}

func getUser(w http.ResponseWriter, r *http.Request) {
    u := User{ID: 1, Name: "Alice"}

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)
    if err := json.NewEncoder(w).Encode(u); err != nil {
        log.Println("encode error:", err)
    }
}

json.NewEncoder(w).Encode(u) 한 줄이 구조체 → JSON 변환 + 응답 본문 쓰기를 한 번에 한다.

Encode 가 실패해도 응답 헤더는 이미 보낸 뒤다. 이 시점에서는 로그만 남기는 게 보통이다.

에러 응답 도우미

http.Error(w, "not found", http.StatusNotFound)
  • 적절한 헤더 설정
  • 상태 코드 설정
  • 본문 적기

세 가지를 한 번에 해 준다. 간단한 에러 응답에 매우 자주 쓴다.


31.7 미들웨어 패턴

같은 일을 모든 핸들러에서 반복하고 싶다. 예: 모든 요청에 대해 로그 남기기, 인증 확인 등.

미들웨어는 핸들러를 감싸는 또 다른 핸들러다.

모양

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s took %v", r.Method, r.URL.Path, time.Since(start))
    })
}
  • 입력으로 핸들러를 받고
  • 그 핸들러를 감싸는 새 핸들러를 반환

사용

mux := http.NewServeMux()
mux.HandleFunc("/", indexHandler)

handler := loggingMiddleware(mux)
http.ListenAndServe(":8080", handler)

여러 개 쌓기

h := loggingMiddleware(
    authMiddleware(
        recoveryMiddleware(mux),
    ),
)

함수 합성처럼 쌓는다. 가장 바깥쪽 미들웨어가 가장 먼저 실행된다.

흔히 쓰는 미들웨어역할
로깅요청/응답 시간 기록
인증Authorization 헤더 검사
패닉 복구핸들러의 panic 을 recover
CORS교차 출처 헤더 추가
압축gzip 응답 압축

미들웨어는 그 자체로 핸들러이므로 인터페이스 한 줄 (http.Handler) 만 구현하면 끝이다. 16장 인터페이스의 힘이 한 번 더 드러난다.


31.8 정적 파일 서빙

이미지, CSS, JS 같은 파일을 그대로 서빙할 때 http.FileServer 를 쓴다.

fs := http.FileServer(http.Dir("./public"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))
  • ./public 디렉터리의 파일들을 /static/... 경로로 노출
  • StripPrefix 는 요청 경로에서 /static/ 을 떼어 낸 뒤 파일 시스템에서 찾도록 한다

이 줄들만으로 작은 정적 사이트도 그대로 돌아간다.


31.9 정리

  • net/http 만으로 운영 가능한 웹 서버를 만들 수 있다
  • 가장 작은 서버
    • http.HandleFunc + http.ListenAndServe
  • 핸들러
    • 핵심 인터페이스 http.Handler (ServeHTTP)
    • 함수 어댑터 http.HandlerFunc
  • 라우팅
    • http.ServeMux
    • Go 1.22+ 는 "GET /users/{id}" 패턴 지원
  • 요청 다루기
    • 쿼리 r.URL.Query()
    • 경로 변수 r.PathValue(...) (1.22+)
    • 헤더 r.Header.Get(...)
    • JSON 본문 json.NewDecoder(r.Body).Decode(...)
  • 응답 보내기
    • 헤더 → 상태 코드 → 본문 순서
    • JSON 은 json.NewEncoder(w).Encode(...)
    • 에러는 http.Error(w, ..., status)
  • 미들웨어
    • 핸들러를 감싸는 또 다른 핸들러
    • 로깅, 인증, 복구, CORS 등에 활용
  • 정적 파일은 http.FileServer + http.StripPrefix

여기까지 오면 Go 의 핵심을 거의 다 활용한 셈이다. 구조체와 JSON 으로 데이터 형식, 함수와 인터페이스로 라우팅과 미들웨어, 동시성으로 다수 요청을 동시에 처리, 에러 처리로 안정적인 응답.

다음 장에서는 한 단계 더 나아간다. 지금까지 만든 코드를 테스트로 지킨다.

32장. 테스트 작성하기

코드를 짜고 나면 “잘 돌아갈까?” 라는 질문이 따라온다. 한두 번은 손으로 돌려 봐도 되지만, 규모가 커지면 그 방법으로는 감당이 안 된다.

이때 등장하는 것이 자동화된 테스트다. Go 는 외부 도구 없이도 표준 testing 패키지만으로 충분히 테스트할 수 있다.

목표:

  • 왜 테스트를 쓰는지 납득하기
  • testing 패키지의 기본 사용법 익히기
  • 테이블 기반 테스트와 서브테스트 다루기
  • 벤치마크, 예제, 커버리지까지 한 바퀴 돌아 보기

32.1 왜 테스트를 작성하는가

테스트는 단순한 “검증 도구” 이상의 가치를 가진다.

회귀 방지

새 기능을 넣다가 기존 기능을 망가뜨리는 사고를 흔히 본다. 테스트가 있다면 망가지는 순간 빨간불이 켜진다.

리팩터링 안전망

코드를 깔끔히 정리하고 싶을 때 “건드리면 뭐가 깨질지 모른다” 는 두려움이 가장 큰 장애물이다.

테스트가 든든히 받쳐 주면 구조를 바꿔도 의도된 결과만 같으면 안심할 수 있다.

사양의 역할

테스트 코드는 곧 “이 함수가 어떻게 동작해야 하는가” 의 명세다. 새로 합류한 동료가 읽기에도, 미래의 내가 다시 보기에도 좋다.

잘 짜인 테스트는 주석보다 정확하다. 주석은 코드와 함께 변하지 않을 수 있지만, 테스트는 깨지는 순간 알려 준다.


32.2 testing 패키지 한눈에 보기

Go 의 테스트는 표준 라이브러리 testing 으로 한다. 별도 프레임워크를 깔지 않는다.

파일 이름 관례

테스트 코드는 _test.go 로 끝나는 파일에 둔다.

파일역할
math.go실제 코드
math_test.go테스트 코드

빌드할 때는 _test.go 파일이 자동으로 제외된다. 배포 바이너리에는 테스트 코드가 섞이지 않는다.

함수 시그니처

func TestXxx(t *testing.T) { ... }
  • 이름은 반드시 Test 로 시작
  • 그 뒤 첫 글자는 대문자
  • 매개변수는 *testing.T 하나

같은 패키지냐, 분리 패키지냐

테스트 파일은 보통 두 가지 방식으로 둘 수 있다.

  1. 같은 패키지package math 그대로
    • 패키지 내부 식별자(소문자 함수 등)에 접근 가능
  2. _test 분리 패키지package math_test
    • 외부 사용자 입장에서 쓰는 것처럼 테스트
    • 공개 API 만 보임

대부분의 경우는 같은 패키지 방식으로 충분하다.


32.3 첫 테스트 작성

간단한 함수를 하나 만들고 테스트해 본다.

대상 함수

mathx/mathx.go:

package mathx

// Add 는 두 정수의 합을 반환한다.
func Add(a, b int) int {
	return a + b
}

테스트 코드

mathx/mathx_test.go:

package mathx

import "testing"

func TestAdd(t *testing.T) {
	got := Add(2, 3)
	want := 5
	if got != want {
		t.Errorf("Add(2, 3) = %d, want %d", got, want)
	}
}

실행

같은 디렉터리에서 다음을 실행한다.

go test

통과하면 이렇게 나온다.

ok      example.com/mathx       0.123s

자세히 보고 싶다면 -v 옵션을 붙인다.

go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok      example.com/mathx       0.123s

실패 시 모습

일부러 want := 6 으로 바꿔서 실행해 보자.

=== RUN   TestAdd
    mathx_test.go:9: Add(2, 3) = 5, want 6
--- FAIL: TestAdd (0.00s)
FAIL

어느 줄에서 어떤 차이가 났는지 깔끔하게 보여 준다.


32.4 t.Error, t.Errorf, t.Fatal

테스트 실패를 보고하는 메서드는 여러 가지다. 가장 자주 쓰는 두 그룹을 비교한다.

Error 계열 (실패 후 계속 진행)

메서드동작
t.Error(args...)실패 표시, 메시지 출력, 이후 코드 계속 실행
t.Errorf(format, ...)위와 같되 포맷 문자열 사용

Fatal 계열 (실패 후 즉시 중단)

메서드동작
t.Fatal(args...)실패 표시 후 해당 테스트 즉시 종료
t.Fatalf(format, ...)위와 같되 포맷 문자열 사용

언제 어느 쪽?

  • 한 검사가 실패해도 다음 검사가 의미 있다면 → Error
  • 한 검사가 실패하면 이후 코드가 의미 없다면 → Fatal

예를 들어 파일을 열지 못했으면 이후 코드는 돌릴 가치가 없으니 Fatal 이 맞다.

f, err := os.Open("data.txt")
if err != nil {
	t.Fatalf("파일 열기 실패: %v", err)
}
defer f.Close()
// 이후 코드는 f 가 유효하다는 전제

메시지 포맷 팁

좋은 실패 메시지는 세 가지를 담는다.

  1. 어떤 함수를 호출했는가
  2. 무엇이 나왔는가 (got)
  3. 무엇을 기대했는가 (want)
t.Errorf("Add(%d, %d) = %d, want %d", a, b, got, want)

32.5 테이블 기반 테스트

비슷한 검사를 여러 입력에 대해 반복하고 싶을 때 Go 에서는 테이블 기반 테스트 가 관례다.

기본 구조

입력과 기대값 쌍을 슬라이스로 만들고 for range 로 돌린다.

func TestAdd(t *testing.T) {
	cases := []struct {
		name string
		a, b int
		want int
	}{
		{"양수+양수", 2, 3, 5},
		{"음수 포함", -1, 1, 0},
		{"0 포함", 0, 7, 7},
	}

	for _, c := range cases {
		got := Add(c.a, c.b)
		if got != c.want {
			t.Errorf("%s: Add(%d,%d)=%d, want %d",
				c.name, c.a, c.b, got, c.want)
		}
	}
}

서브테스트로 깔끔하게 — t.Run

각 케이스를 별도의 테스트처럼 다루고 싶다면 t.Run 으로 감싼다.

for _, c := range cases {
	t.Run(c.name, func(t *testing.T) {
		got := Add(c.a, c.b)
		if got != c.want {
			t.Errorf("Add(%d,%d)=%d, want %d",
				c.a, c.b, got, c.want)
		}
	})
}

이렇게 하면 출력이 다음처럼 분리된다.

=== RUN   TestAdd
=== RUN   TestAdd/양수+양수
=== RUN   TestAdd/음수_포함
=== RUN   TestAdd/0_포함
--- PASS: TestAdd
    --- PASS: TestAdd/양수+양수
    --- PASS: TestAdd/음수_포함
    --- PASS: TestAdd/0_포함

특정 서브테스트만 돌리고 싶다면 -run 으로 지정한다.

go test -run TestAdd/음수

테이블 기반의 장점

  • 새 케이스 추가가 한 줄이면 끝
  • 어떤 입력에서 깨졌는지 한눈에 보임
  • 코드 중복이 거의 없음

거의 모든 단위 테스트는 이 패턴으로 풀린다. Go 코드를 읽다 보면 이 형태가 사방에서 보일 것이다.


32.6 테스트 헬퍼와 t.Helper

테스트 코드가 길어지면 공통 검사 로직을 헬퍼 함수로 빼고 싶어진다.

func assertEqual(t *testing.T, got, want int) {
	if got != want {
		t.Errorf("got %d, want %d", got, want)
	}
}

이대로 쓰면 실패 메시지가 헬퍼 함수의 줄 번호를 가리킨다.

helper_test.go:5: got 3, want 5

정작 잘못된 호출은 호출자 쪽인데 말이다.

t.Helper() 한 줄로 해결

헬퍼 함수 맨 위에 t.Helper() 를 적는다.

func assertEqual(t *testing.T, got, want int) {
	t.Helper()
	if got != want {
		t.Errorf("got %d, want %d", got, want)
	}
}

이제 실패 줄 번호가 호출자 위치로 표시된다. 실패 추적이 훨씬 편해진다.


32.7 setUp / tearDown

테스트 전후에 공통 준비/정리가 필요하면 두 가지 방법이 있다.

TestMain 으로 전체 묶기

테스트 파일 안에 다음 시그니처를 두면, 같은 패키지의 모든 테스트가 시작되기 전후로 한 번씩 돈다.

func TestMain(m *testing.M) {
	// 전역 준비
	setupDB()

	code := m.Run()

	// 전역 정리
	teardownDB()

	os.Exit(code)
}
  • 한 패키지에 TestMain 은 하나만
  • 호출하지 않으면 m.Run() 이 자동으로 돌아간다

서브테스트 단위로 직접

각 테스트가 독립적인 자원을 쓴다면 서브테스트 안에서 직접 준비/정리하는 편이 안전하다.

func TestThing(t *testing.T) {
	tmpDir := t.TempDir()      // 자동 삭제
	// ... 테스트 본문 ...
}
  • t.TempDir() 가 만든 디렉터리는 테스트 종료 시 자동으로 지워진다
  • t.Cleanup(func() { ... }) 로 임의 정리 작업도 등록 가능
t.Cleanup(func() {
	conn.Close()
})

가능하면 TestMain 보다 t.Cleanup 을 권한다. 테스트가 자기 정리를 책임지면 다른 테스트와의 결합이 줄어든다.


32.8 벤치마크 (26장 복습)

성능을 재고 싶다면 벤치마크 함수를 쓴다. 26장에서 잠깐 봤지만 다시 정리한다.

시그니처

func BenchmarkXxx(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Add(2, 3)
	}
}
  • 이름은 Benchmark 로 시작
  • 매개변수는 *testing.B
  • b.N 만큼 반복 — 횟수는 Go 가 알아서 늘려 정확도 확보

실행

go test -bench=.
BenchmarkAdd-10    1000000000    0.25 ns/op
  • Add-10: 함수 이름과 GOMAXPROCS 값
  • 0.25 ns/op: 한 번 호출당 평균 시간

자주 같이 쓰는 옵션

옵션의미
-bench=.모든 벤치마크 실행
-benchmem할당 횟수와 메모리도 출력
-count=NN 회 반복해서 분산 확인
-benchtime=3s한 벤치를 3초 동안 측정
go test -bench=. -benchmem -count=3

32.9 예제(Example) 함수

Example 로 시작하는 함수는 두 역할을 한다.

  • 테스트 (출력이 일치해야 통과)
  • 문서 (godoc 페이지에 코드 예시로 노출)
func ExampleAdd() {
	fmt.Println(Add(2, 3))
	// Output: 5
}
  • 마지막의 // Output: 주석이 핵심
  • 실제 출력이 이 주석과 같지 않으면 테스트 실패

순서가 중요하지 않은 출력

맵처럼 순서가 보장되지 않는 경우엔 // Unordered output: 을 쓴다.

func ExamplePrintMap() {
	PrintMap(map[string]int{"a": 1, "b": 2})
	// Unordered output:
	// a=1
	// b=2
}

패키지 / 타입 단위 예제

이름노출 위치
Example()패키지 첫 페이지
ExampleAdd()Add 함수 문서
ExampleUser_Greet()User 타입의 Greet 메서드 문서

잘 짠 Example 한 개가 README 한 단락을 대신한다. 사용법을 코드로 보여 주는 것이 가장 강력하다.


32.10 커버리지

테스트가 코드의 몇 퍼센트를 거쳤는지 알고 싶다면 -cover 옵션을 쓴다.

기본 출력

go test -cover
ok      example.com/mathx       0.123s  coverage: 78.3% of statements

HTML 리포트

어느 줄이 안 거쳐졌는지 시각적으로 보고 싶다면 프로파일을 파일로 떨군 뒤 HTML 로 변환한다.

go test -coverprofile=c.out
go tool cover -html=c.out

브라우저가 열리며, 초록은 거친 줄, 빨강은 안 거친 줄이다.

커버리지에 대한 한 마디

100% 가 항상 좋은 건 아니다. “이 함수의 핵심 동작이 빠짐없이 테스트되는가” 가 본질이다.

  • 비즈니스 로직은 높게
  • 단순 게터, 단순 위임 함수는 굳이 무리하지 않기

70~80% 정도를 가이드 삼되, 숫자보다 “중요한 경로가 다 덮였나” 를 본다.


32.11 정리

이 장에서 본 것:

  • _test.go 파일과 TestXxx(t *testing.T) 가 기본
  • 실패 보고는 Error / Fatal 계열로
  • 테이블 기반 테스트 + t.Run 이 거의 표준
  • 헬퍼 함수에는 t.Helper() 한 줄
  • 벤치마크, Example, 커버리지까지 표준 도구로 다 됨

테스트는 한 번 들이는 습관이지만 시간이 갈수록 복리로 돌아온다. “테스트 없이는 손도 못 댄다” 는 동료들의 농담은 대개 진심이다.

다음 장에서는 지금까지 배운 모든 것을 모아 실전 미니 프로젝트 두 개를 만들어 본다.

33장. 미니 프로젝트

지금까지 다룬 개념을 한자리에 모아 실제로 굴러가는 프로그램을 두 개 만들어 본다.

각 단계마다 “어느 챕터에서 본 내용을 쓰는지” 짧게 짚어 가며 진행한다. 지금까지의 학습이 점이 아니라 선으로 이어지는 순간이다.

목표:

  • 프로젝트 두 개를 처음부터 끝까지 만들어 보기
    • CLI 할 일 관리 도구 (todo)
    • JSON API 서버
  • 모듈 구성, 에러 처리, 테스트, 동시성을 한꺼번에 적용
  • “여기서부터 어디로 더 갈 수 있는가” 감 잡기

프로젝트 1: CLI 할 일 관리 도구 todo

콘솔에서 일을 추가하고, 목록 보고, 완료 표시하고, 지우는 작고 단단한 프로그램을 만든다.

데이터는 JSON 파일에 저장한다. 프로그램을 다시 켜도 목록이 유지된다.


33.1 요구사항 정리

가장 먼저 만들 것의 윤곽을 정한다.

명령동작
todo add "장보기"새 일을 추가
todo list전체 목록 출력
todo done 33번 일을 완료 처리
todo rm 33번 일을 삭제

조건:

  • 데이터는 사용자 홈 디렉터리의 ~/.todo.json 에 저장
  • 잘못된 명령엔 친절한 안내 메시지
  • 잘못된 ID 엔 명확한 에러

33.2 프로젝트 구조

작업 디렉터리를 만들고 모듈을 초기화한다 (2장, 20장).

mkdir todo
cd todo
go mod init example.com/todo

다음과 같은 구조로 잡는다.

todo/
├── go.mod
├── main.go
└── internal/
    └── tasks/
        ├── store.go
        └── store_test.go
  • main.go — 명령 파싱과 출력 담당
  • internal/tasks — 데이터 모델과 저장/로드 로직
    • internal/ 아래는 외부 모듈이 import 할 수 없다 (20장)

33.3 데이터 모델

먼저 한 개의 할 일이 어떻게 생겼는지 정한다 (13장, 30장).

internal/tasks/store.go:

package tasks

import "time"

type Task struct {
	ID        int       `json:"id"`
	Title     string    `json:"title"`
	Done      bool      `json:"done"`
	CreatedAt time.Time `json:"created_at"`
}
  • 필드는 외부에서 읽을 수 있도록 대문자
  • JSON 키는 소문자 / 스네이크 케이스
  • time.Time 은 RFC3339 문자열로 직렬화된다 (28장, 30장)

전체 데이터셋도 구조체로 둔다.

type Store struct {
	Tasks  []Task `json:"tasks"`
	NextID int    `json:"next_id"`
}
  • NextID 는 다음에 부여할 ID
  • 삭제가 일어나도 ID 가 재사용되지 않도록 따로 관리

33.4 저장과 로드

JSON 파일을 읽고 쓰는 함수를 만든다 (29장, 30장).

package tasks

import (
	"encoding/json"
	"errors"
	"io/fs"
	"os"
)

func Load(path string) (*Store, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, fs.ErrNotExist) {
		// 처음 실행이면 빈 저장소
		return &Store{NextID: 1}, nil
	}
	if err != nil {
		return nil, err
	}

	var s Store
	if err := json.Unmarshal(data, &s); err != nil {
		return nil, err
	}
	if s.NextID == 0 {
		s.NextID = 1
	}
	return &s, nil
}

func (s *Store) Save(path string) error {
	data, err := json.MarshalIndent(s, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0644)
}

핵심 포인트:

  • 처음 실행일 때 파일이 없는 건 에러가 아니다
    • errors.Is(err, fs.ErrNotExist) 로 분기 (21장)
  • MarshalIndent 로 사람도 읽기 좋게 저장
  • 쓰기 권한은 0644 (소유자만 쓰기, 모두 읽기)

33.5 명령 파싱

os.Args 만으로도 충분한 규모다 (간단한 CLI 라면).

main.go:

package main

import (
	"fmt"
	"os"
	"path/filepath"

	"example.com/todo/internal/tasks"
)

func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(1)
	}

	path := storePath()
	store, err := tasks.Load(path)
	if err != nil {
		fmt.Fprintln(os.Stderr, "데이터 로드 실패:", err)
		os.Exit(1)
	}

	cmd := os.Args[1]
	args := os.Args[2:]

	switch cmd {
	case "add":
		err = cmdAdd(store, args)
	case "list":
		err = cmdList(store, args)
	case "done":
		err = cmdDone(store, args)
	case "rm":
		err = cmdRm(store, args)
	default:
		usage()
		os.Exit(1)
	}

	if err != nil {
		fmt.Fprintln(os.Stderr, "오류:", err)
		os.Exit(1)
	}

	if err := store.Save(path); err != nil {
		fmt.Fprintln(os.Stderr, "저장 실패:", err)
		os.Exit(1)
	}
}

func storePath() string {
	home, _ := os.UserHomeDir()
	return filepath.Join(home, ".todo.json")
}

func usage() {
	fmt.Println(`사용법:
  todo add "할 일 제목"
  todo list
  todo done <id>
  todo rm <id>`)
}

명령이 늘면 flag 패키지로 바꾸면 된다. 지금 단계는 switch 만으로 충분하다.


33.6 각 명령 구현

add

func cmdAdd(s *tasks.Store, args []string) error {
	if len(args) == 0 {
		return fmt.Errorf("제목을 적어 주세요")
	}
	title := strings.Join(args, " ")
	t := tasks.Task{
		ID:        s.NextID,
		Title:     title,
		CreatedAt: time.Now(),
	}
	s.Tasks = append(s.Tasks, t)
	s.NextID++
	fmt.Printf("추가됨 #%d: %s\n", t.ID, t.Title)
	return nil
}
  • 여러 단어를 한 제목으로 묶기 위해 strings.Join 사용 (27장)

list

func cmdList(s *tasks.Store, args []string) error {
	if len(s.Tasks) == 0 {
		fmt.Println("(비어 있음)")
		return nil
	}
	for _, t := range s.Tasks {
		mark := "[ ]"
		if t.Done {
			mark = "[x]"
		}
		fmt.Printf("%s #%d %s\n", mark, t.ID, t.Title)
	}
	return nil
}

done

func cmdDone(s *tasks.Store, args []string) error {
	if len(args) == 0 {
		return fmt.Errorf("ID 를 적어 주세요")
	}
	id, err := strconv.Atoi(args[0])
	if err != nil {
		return fmt.Errorf("잘못된 ID: %q", args[0])
	}
	for i := range s.Tasks {
		if s.Tasks[i].ID == id {
			s.Tasks[i].Done = true
			fmt.Printf("완료 #%d\n", id)
			return nil
		}
	}
	return fmt.Errorf("ID %d 를 찾을 수 없습니다", id)
}
  • strconv.Atoi 로 문자열 → 정수 변환 (27장)
  • 인덱스 순회를 range 로 하되, 값을 수정하려면 s.Tasks[i] 식으로 직접 접근 (11장)

rm

func cmdRm(s *tasks.Store, args []string) error {
	if len(args) == 0 {
		return fmt.Errorf("ID 를 적어 주세요")
	}
	id, err := strconv.Atoi(args[0])
	if err != nil {
		return fmt.Errorf("잘못된 ID: %q", args[0])
	}
	for i, t := range s.Tasks {
		if t.ID == id {
			s.Tasks = append(s.Tasks[:i], s.Tasks[i+1:]...)
			fmt.Printf("삭제됨 #%d\n", id)
			return nil
		}
	}
	return fmt.Errorf("ID %d 를 찾을 수 없습니다", id)
}
  • 슬라이스에서 한 요소 제거하는 표준 패턴 (11장)

33.7 에러 처리 흐름

전체적으로 21장의 관례를 따른다.

  • 함수는 (결과, error) 를 반환
  • 호출자가 에러를 받아 적절히 처리
  • 사용자에게는 stderr 로 메시지를 보냄
  • 종료 코드는 0 이 아니어야 셸 스크립트에서 감지 가능
fmt.Fprintln(os.Stderr, "오류:", err)
os.Exit(1)

log.Fatal 도 비슷한 효과를 내지만, CLI 도구에서는 보통 stderr + Exit(1) 조합이 깔끔하다.


33.8 테스트 작성

저장/로드 로직만이라도 테스트해 둔다 (32장).

internal/tasks/store_test.go:

package tasks

import (
	"path/filepath"
	"testing"
)

func TestSaveLoad(t *testing.T) {
	tmp := t.TempDir()
	path := filepath.Join(tmp, "todo.json")

	s1 := &Store{NextID: 2}
	s1.Tasks = append(s1.Tasks, Task{ID: 1, Title: "사기"})

	if err := s1.Save(path); err != nil {
		t.Fatalf("Save: %v", err)
	}

	s2, err := Load(path)
	if err != nil {
		t.Fatalf("Load: %v", err)
	}

	if len(s2.Tasks) != 1 {
		t.Fatalf("Tasks 길이 %d, want 1", len(s2.Tasks))
	}
	if s2.Tasks[0].Title != "사기" {
		t.Errorf("Title=%q", s2.Tasks[0].Title)
	}
	if s2.NextID != 2 {
		t.Errorf("NextID=%d", s2.NextID)
	}
}

func TestLoadMissingFile(t *testing.T) {
	s, err := Load("/tmp/does-not-exist-xxx.json")
	if err != nil {
		t.Fatalf("없는 파일은 에러가 아니어야 함: %v", err)
	}
	if s.NextID != 1 {
		t.Errorf("초기 NextID=%d, want 1", s.NextID)
	}
}

t.TempDir() 는 테스트가 끝나면 자동으로 지워진다 (32장).

go test ./...

./... 는 “이 모듈의 모든 패키지” 라는 뜻이다.


33.9 빌드와 사용

go build -o todo
./todo add "장보기"
./todo add "운동 가기"
./todo list
./todo done 1
./todo list
./todo rm 2

작은 도구 하나가 완성됐다. 배운 거의 모든 개념이 한 번씩 등장한 셈이다.


프로젝트 2: JSON API 서버

이번엔 똑같은 “할 일” 데이터를 HTTP 위에서 다루는 서버를 만든다.


33.10 요구사항

메서드경로동작
GET/tasks목록 조회
POST/tasks새 일 추가
GET/tasks/{id}한 건 조회
PUT/tasks/{id}한 건 수정
DELETE/tasks/{id}삭제
  • 데이터는 메모리에 두기 (단순화)
  • 동시 요청에서도 안전해야 함 (23장)
  • 모든 요청을 로깅
  • Ctrl+C 로 깔끔하게 종료

go 1.22 부터는 표준 net/http 의 라우팅이 강해져 경로 패턴에 {id} 같은 변수를 직접 받을 수 있다 (31장). 이 책은 그 방식을 쓴다.


33.11 프로젝트 구조

mkdir todo-api
cd todo-api
go mod init example.com/todo-api
todo-api/
├── go.mod
├── main.go
└── internal/
    └── server/
        ├── store.go     # 메모리 저장소 (동시성 보호)
        ├── handlers.go  # 핸들러 함수들
        └── middleware.go

33.12 동시 접근에 안전한 저장소

여러 요청이 동시에 들어올 수 있으므로 공유 데이터는 sync.RWMutex 로 보호한다 (19장, 23장).

internal/server/store.go:

package server

import (
	"errors"
	"sync"
	"time"
)

type Task struct {
	ID        int       `json:"id"`
	Title     string    `json:"title"`
	Done      bool      `json:"done"`
	CreatedAt time.Time `json:"created_at"`
}

type Store struct {
	mu     sync.RWMutex
	tasks  map[int]Task
	nextID int
}

func NewStore() *Store {
	return &Store{
		tasks:  make(map[int]Task),
		nextID: 1,
	}
}

var ErrNotFound = errors.New("not found")

func (s *Store) List() []Task {
	s.mu.RLock()
	defer s.mu.RUnlock()
	out := make([]Task, 0, len(s.tasks))
	for _, t := range s.tasks {
		out = append(out, t)
	}
	return out
}

func (s *Store) Get(id int) (Task, error) {
	s.mu.RLock()
	defer s.mu.RUnlock()
	t, ok := s.tasks[id]
	if !ok {
		return Task{}, ErrNotFound
	}
	return t, nil
}

func (s *Store) Create(title string) Task {
	s.mu.Lock()
	defer s.mu.Unlock()
	t := Task{
		ID:        s.nextID,
		Title:     title,
		CreatedAt: time.Now(),
	}
	s.tasks[t.ID] = t
	s.nextID++
	return t
}

func (s *Store) Update(id int, title string, done bool) (Task, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.tasks[id]
	if !ok {
		return Task{}, ErrNotFound
	}
	t.Title = title
	t.Done = done
	s.tasks[id] = t
	return t, nil
}

func (s *Store) Delete(id int) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.tasks[id]; !ok {
		return ErrNotFound
	}
	delete(s.tasks, id)
	return nil
}

핵심:

  • 읽기 전용 동작은 RLock, 변경은 Lock
  • 잠금 범위는 짧게 유지
  • 외부에 슬라이스 / 맵을 그대로 노출하지 않고 복사본을 줌

락을 들고 있는 동안에는 HTTP 응답 같은 외부 작업을 절대 하지 않는다. 한쪽이 막히면 전체가 막힌다 (23장).


33.13 JSON 응답 도우미

핸들러마다 응답 코드를 정하고 JSON 을 쓰는 공통 함수를 둔다 (30장).

internal/server/handlers.go:

package server

import (
	"encoding/json"
	"net/http"
)

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

type errorResp struct {
	Error string `json:"error"`
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, errorResp{Error: msg})
}

33.14 핸들러 구현

같은 파일 안에 이어서 적는다.

type Server struct {
	store *Store
}

func NewServer(s *Store) *Server {
	return &Server{store: s}
}

func (s *Server) handleList(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, s.store.List())
}

type createReq struct {
	Title string `json:"title"`
}

func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
	var req createReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "잘못된 JSON")
		return
	}
	if req.Title == "" {
		writeError(w, http.StatusBadRequest, "title 이 비었습니다")
		return
	}
	t := s.store.Create(req.Title)
	writeJSON(w, http.StatusCreated, t)
}

func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) {
	id, ok := pathID(w, r)
	if !ok {
		return
	}
	t, err := s.store.Get(id)
	if err != nil {
		writeError(w, http.StatusNotFound, "없는 id")
		return
	}
	writeJSON(w, http.StatusOK, t)
}

type updateReq struct {
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request) {
	id, ok := pathID(w, r)
	if !ok {
		return
	}
	var req updateReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "잘못된 JSON")
		return
	}
	t, err := s.store.Update(id, req.Title, req.Done)
	if err != nil {
		writeError(w, http.StatusNotFound, "없는 id")
		return
	}
	writeJSON(w, http.StatusOK, t)
}

func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
	id, ok := pathID(w, r)
	if !ok {
		return
	}
	if err := s.store.Delete(id); err != nil {
		writeError(w, http.StatusNotFound, "없는 id")
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func pathID(w http.ResponseWriter, r *http.Request) (int, bool) {
	raw := r.PathValue("id")
	id, err := strconv.Atoi(raw)
	if err != nil {
		writeError(w, http.StatusBadRequest, "잘못된 id")
		return 0, false
	}
	return id, true
}
  • r.PathValue("id") 는 Go 1.22+ 의 표준 라우터 기능

33.15 라우팅

internal/server/server.go (또는 같은 파일에 이어서):

func (s *Server) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /tasks", s.handleList)
	mux.HandleFunc("POST /tasks", s.handleCreate)
	mux.HandleFunc("GET /tasks/{id}", s.handleGet)
	mux.HandleFunc("PUT /tasks/{id}", s.handleUpdate)
	mux.HandleFunc("DELETE /tasks/{id}", s.handleDelete)
	return loggingMiddleware(mux)
}

메서드와 경로를 한 줄로 지정할 수 있어 깔끔하다. loggingMiddleware 는 다음 절에서 만든다.


33.16 로깅 미들웨어

요청 한 건마다 메서드, 경로, 처리 시간을 남긴다.

internal/server/middleware.go:

package server

import (
	"log"
	"net/http"
	"time"
)

func loggingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s %s",
			r.Method, r.URL.Path, time.Since(start))
	})
}

미들웨어는 “들어오는 핸들러를 감싸서 새 핸들러를 돌려주는 함수” 다. 책임을 한 군데에 모으는 강력한 패턴이다.


33.17 그래스풀 종료

서버는 처리 중인 요청이 있을 때 바로 꺼지면 안 된다. SIGINT (Ctrl+C) 가 오면 새 요청은 막고, 진행 중인 요청은 끝까지 처리한 뒤 종료한다 (25장).

main.go:

package main

import (
	"context"
	"errors"
	"log"
	"net/http"
	"os/signal"
	"syscall"
	"time"

	"example.com/todo-api/internal/server"
)

func main() {
	store := server.NewStore()
	srv := server.NewServer(store)

	httpServer := &http.Server{
		Addr:    ":8080",
		Handler: srv.Routes(),
	}

	// SIGINT / SIGTERM 을 채널로 감지
	ctx, stop := signal.NotifyContext(context.Background(),
		syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	// 서버를 별도 고루틴에서 시작
	go func() {
		log.Println("listening on :8080")
		err := httpServer.ListenAndServe()
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	}()

	// 시그널 대기
	<-ctx.Done()
	log.Println("shutting down...")

	// 진행 중인 요청에 5초 여유
	shutdownCtx, cancel := context.WithTimeout(
		context.Background(), 5*time.Second)
	defer cancel()

	if err := httpServer.Shutdown(shutdownCtx); err != nil {
		log.Println("shutdown:", err)
	}
	log.Println("bye")
}

흐름 정리:

  1. 서버를 고루틴에서 시작
  2. 메인은 시그널을 기다림 (<-ctx.Done())
  3. 시그널이 오면 Shutdown 호출
  4. 5초 안에 모든 요청이 끝나길 기다린 뒤 종료

33.18 사용해 보기

서버를 띄운다.

go run .

다른 터미널에서 curl 로 두드려 본다.

curl -X POST http://localhost:8080/tasks \
  -H 'Content-Type: application/json' \
  -d '{"title":"장보기"}'

curl http://localhost:8080/tasks

curl -X PUT http://localhost:8080/tasks/1 \
  -H 'Content-Type: application/json' \
  -d '{"title":"장보기","done":true}'

curl http://localhost:8080/tasks/1

curl -X DELETE http://localhost:8080/tasks/1

서버 측 로그도 같이 흐를 것이다.

2026/05/24 10:00:00 listening on :8080
2026/05/24 10:00:05 POST /tasks 412.5µs
2026/05/24 10:00:08 GET /tasks 134.2µs
...

33.19 정리 — 다음 단계 안내

작지만 알찬 두 프로그램이 완성됐다. 지금까지의 챕터를 거의 모두 한 번씩 호출한 셈이다.

챕터 영역어디서 썼나
변수, 타입, 함수도처에
구조체와 JSON 태그Task 모델
슬라이스 / 맵저장소, 응답 본문
에러 처리CLI, 핸들러, 저장소
패키지 분리internal/... 구성
동시성과 락API 서버 저장소
context, signal그래스풀 종료
테스트store_test.go

여기서 어디로 더 갈 수 있나

다음 주제 중 끌리는 것을 골라 이 두 프로젝트를 확장해 가는 것이 좋은 학습이다.

  • 데이터베이스 연결
    • database/sql + SQLite 또는 PostgreSQL
    • 또는 ORM (예: gorm, ent)
  • 인증 / 권한
    • 헤더 토큰 검사 미들웨어
    • JWT
  • 설정 관리
    • 환경변수 / flag / 설정 파일
  • 로깅 고도화
    • log/slog 로 구조화 로깅
  • 배포
    • Dockerfile 작성
    • 단일 바이너리 + systemd
  • 테스트 강화
    • 핸들러 단위 테스트 (httptest)
    • 통합 테스트

한꺼번에 다 할 필요는 없다. 하나씩 하나씩 더해 가다 보면 어느새 “회사에서 쓰는 그 서버” 와 비슷한 모양이 된다.

지금까지 정말 수고했다. 이제 남은 건 직접 만들고 싶었던 그 무언가뿐이다.

부록 C. 더 공부할 자료

이 책은 시작점이다. 여기서 익힌 기초 위에 무엇을 더 쌓을지가 앞으로 더 중요하다.

이 부록은 다음 단계로 향하는 지도다. 공식 문서, 도구, 학습 경로, 커뮤니티를 정리했다.


C.1 공식 자료

가장 신뢰할 수 있는 자료는 공식 자료다. 최신 정보가 가장 빠르고, 정확하다.

핵심 사이트

자료주소무엇이 있나
Go 공식 홈https://go.dev/다운로드, 블로그, 문서 진입점
A Tour of Gohttps://go.dev/tour/welcome/1브라우저에서 바로 실행되는 입문 튜토리얼
Effective Gohttps://go.dev/doc/effective_go“Go 답게” 쓰는 법을 정리한 공식 문서
pkg.go.devhttps://pkg.go.dev/표준 라이브러리 / 외부 패키지 문서 포털
Go Playgroundhttps://go.dev/play/브라우저에서 코드 실행 / 공유

책에서 다 못 다룬 주제 위주로 보기 좋은 글

  • https://go.dev/blog/ — 공식 블로그. 새 기능이나 깊은 주제 글이 종종 올라온다
  • https://go.dev/doc/faq — 자주 묻는 질문
  • https://go.dev/ref/spec — 언어 명세 (한 번쯤 훑어 두면 미묘한 동작이 이해된다)

Tour of Go 는 가볍게 한 번 더 돌면서 “내가 이 책에서 본 게 여기서는 이렇게 쓰여 있구나” 를 다시 익히기에도 좋다.


C.2 한국어 자료

한국어로 된 Go 자료도 점점 늘고 있다. 사람마다 취향이 달라 특정 책 / 블로그를 콕 찍기보다 탐색 방향을 적는다.

어떻게 찾는가

  • “Go 한국어” 또는 “Golang 한국어” 검색
  • “Go 언어 + 주제 키워드” 로 좁히기 (예: “Go 언어 동시성 패턴”)
  • 발행 시점을 꼭 본다 — 5년 이상 된 글은 문법은 같아도 생태계 정보가 낡았을 수 있다

블로그 / 미디엄 / velog / tistory

기술 블로그 글을 찾아 읽는 습관은 큰 도움이 된다. 같은 주제를 여러 사람이 어떻게 설명하는지 비교해 보면 이해가 깊어진다.

  • 회사 기술 블로그 (당근, 카카오, 우아한형제들 등)
  • 개인 블로그 (Medium, velog, tistory)
  • 한국어 Go 미트업 / 발표 자료

한국어 자료를 볼 때 한 가지 주의점

오래된 자료에는 다음 같은 옛 흔적이 남아 있을 수 있다.

  • GOPATH 위주 설명 → 지금은 모듈 방식이 표준 (20장)
  • dep, glide 같은 옛 패키지 관리자 → 지금은 모듈
  • 1.18 이전이라 제네릭이 없는 코드 (17장)

모를 땐 공식 문서로 한 번 더 확인하는 습관이 좋다.


C.3 추천 도구

golangci-lint

여러 린터를 한 번에 묶어 돌려 주는 도구. 실무에서는 거의 표준이다.

  • 홈: https://golangci-lint.run/
  • 설치 후 프로젝트 루트에서:
golangci-lint run

.golangci.yml 로 규칙을 켜고 끌 수 있다. GitHub Actions / GitLab CI 와 잘 어울린다.

air — 라이브 리로드

코드 변경을 감지해 자동으로 재빌드 / 재실행해 준다. 웹 서버 개발 시 손이 편해진다.

  • 홈: https://github.com/cosmtrek/air
go install github.com/cosmtrek/air@latest
air

delve — 디버거

브레이크포인트, 변수 검사, 스텝 실행을 지원하는 Go 의 공식 디버거다. VS Code Go 확장이 내부적으로 쓴다.

  • 홈: https://github.com/go-delve/delve
go install github.com/go-delve/delve/cmd/dlv@latest
dlv debug

VS Code 의 디버그 패널 (F5) 만 눌러도 그대로 붙는다.

그 외 알아 두면 좋은 도구

도구용도
goimports임포트 정렬 + 자동 추가/제거
gotests함수에서 테스트 스켈레톤 자동 생성
mockgen인터페이스의 목 객체 생성
staticcheck강력한 정적 분석기 (golangci-lint 가 포함)
gops실행 중인 Go 프로세스 진단

처음부터 다 깔 필요는 없다. 자주 부딪히는 문제에 맞는 도구를 하나씩 들이면 된다.


C.4 추천 학습 경로

여기까지 왔다면 다음 단계는 사람마다 다르지만, 일반적으로 추천할 수 있는 경로는 이렇다.

1단계 — 기초 다지기 (지금 위치)

  • 이 책을 한 번 더 훑으며 어색한 챕터를 두 번 본다
  • A Tour of Go 를 처음부터 끝까지 한 번 더 돈다
  • Effective Go 를 천천히 읽는다

2단계 — 작은 프로젝트로 손에 익히기

자신만의 작은 도구를 하나 만든다.

  • 명령줄 도구 (예: 일정 알림, 파일 정리)
  • 간단한 웹 API
  • 봇 (Slack, Discord, Telegram 등)

만들고 싶은 게 있을 때 진짜 학습이 일어난다. 33장에서 만든 두 프로젝트를 확장하는 것도 훌륭한 출발점이다.

3단계 — 표준 라이브러리 깊이 파기

문서를 차분히 읽어 두면 평생 도움이 된다.

패키지무엇을 배우나
io, bufio스트림 처리 사고방식
context취소, 타임아웃, 요청 추적
net/http서버 / 클라이언트 모두
encoding/json데이터 변환
database/sql표준 DB 접근 인터페이스
sync, sync/atomic동시성 도구
log/slog구조화 로깅
testing표준 테스트 도구의 잘 안 알려진 기능

4단계 — 인기 오픈소스 코드 읽기

좋은 코드를 읽는 것이 좋은 코드를 쓰는 가장 빠른 길이다.

프로젝트무엇을 배울 수 있나
Kubernetes거대한 모듈 분리, 인터페이스 설계, 동시성
Docker / Moby시스템 프로그래밍, 패키지 구성
Prometheus라이브러리 설계, 메트릭, HTTP 라우팅
Caddy우아한 웹 서버 설계
etcd분산 시스템, Raft 알고리즘
HugoCLI 도구, 파일 처리

처음부터 전체를 읽으려 하지 말고 관심 있는 한 파일, 한 함수부터 본다.

5단계 — 깊이 있는 주제

언어를 넘어서는 영역.

  • 분산 시스템 / 마이크로서비스
  • 컨테이너 / 쿠버네티스
  • 관측성 (메트릭, 로그, 트레이스)
  • 성능 튜닝과 프로파일링

C.5 커뮤니티

혼자만 공부하면 막히는 지점에서 오래 머무른다. 사람을 만나라.

한국어 커뮤니티

  • GoLang Korea — 페이스북 그룹, 슬랙, 미트업
  • 한국 Go 사용자 모임의 발표 영상 (YouTube 검색)
  • 회사별 Go 사용 후기 / 도입기 글

영문 커뮤니티

  • r/golang — Reddit (https://reddit.com/r/golang/)
  • Gophers Slack — https://invite.slack.golangbridge.org/
  • GitHub Discussions — 인기 오픈소스의 토론 페이지
  • Stack Overflow — 질문 검색 시 답변 품질이 높다
  • GopherCon 발표 (YouTube)
    • 매년 진행되는 콘퍼런스. 깊은 주제 발표가 많다

Slack / Discord 에 일단 가입만 해 두고 다른 사람들의 질문과 답을 곁눈질로 보는 것만으로도 배운다.


C.6 마지막으로

여기까지 따라온 자신을 칭찬해 주자. 프로그래밍은 책을 덮는다고 끝나지 않는다. “실제로 무언가를 만들기 시작할 때” 가 진짜 시작이다.

작은 프로그램을 하나 짜고, 그게 동작하는 걸 보고, 다음 번에 조금 더 큰 걸 만든다. 이 반복이 쌓이면 어느 순간 “내가 Go 로 일을 한다” 가 자연스러워진다.

기억해 둘 다섯 가지.

  1. 가장 좋은 자료는 공식 문서다
  2. 막히면 누군가에게 물어봐도 된다
  3. 다른 사람의 코드를 자주 읽자
  4. 작은 것부터 끝까지 완성해 보자
  5. 즐기는 게 가장 오래 가는 학습이다

좋은 코드와 즐거운 개발 인생을 빈다. 이제 키보드 앞으로 돌아갈 시간이다.

부록 A. 자주 쓰는 go 명령어

go 명령어는 컴파일러뿐 아니라 프로젝트 관리, 테스트, 분석을 모두 묶은 도구다. 실무에서 자주 쓰는 것만 한 자리에 모았다.

이 부록은 사전처럼 쓰면 된다. 잊었을 때 한 줄씩 찾아보는 용도다.


A.1 한눈에 보기

명령한 줄 설명
go run빌드 후 즉시 실행 (결과물 안 남김)
go build실행 파일 생성
go install빌드 후 $GOBIN 에 설치
go test테스트 실행
go mod init새 모듈 초기화
go mod tidy의존성 정리
go get외부 패키지 받기 / 버전 변경
go fmt코드 정리
go vet정적 분석
go envGo 환경변수 확인
go doc패키지 / 식별자 문서 보기
go tool pprof프로파일 분석

A.2 빌드와 실행

go run

소스를 빌드해서 바로 실행한다. 실행 파일은 임시 위치에 만들어지고 사라진다.

go run main.go
go run .
go run ./cmd/server
표현의미
main.go그 파일 하나만
.현재 디렉터리의 패키지 전체
./cmd/server그 경로의 패키지

빠르게 시도해 볼 때 가장 자주 쓰는 명령이다.

go build

실행 파일을 만든다.

go build           # 현재 디렉터리에 실행 파일
go build -o bin/app
go build ./...     # 모듈 안 모든 패키지를 빌드 검증

크로스 컴파일도 환경변수만으로 된다.

GOOS=linux  GOARCH=amd64 go build -o app-linux
GOOS=darwin GOARCH=arm64 go build -o app-mac
GOOS=windows GOARCH=amd64 go build -o app.exe

빌드 시 자주 쓰는 플래그:

플래그의미
-o <파일>출력 파일 이름 지정
-race데이터 레이스 탐지기 포함
-ldflags="-s -w"바이너리 용량 줄이기 (디버그 심볼 제거)
-trimpath빌드 경로 정보 제거 (재현 가능 빌드)

go install

빌드 후 결과물을 $GOBIN (기본 $HOME/go/bin)에 둔다. CLI 도구 배포에 자주 쓴다.

go install example.com/cmd/mytool@latest
표현의미
@latest최신 버전
@v1.2.3특정 버전
@mainmain 브랜치 최신 커밋

$GOBINPATH 에 들어 있어야 어디서나 실행할 수 있다.


A.3 테스트와 측정

go test

go test            # 현재 패키지
go test ./...      # 모듈의 모든 패키지
go test -v         # 상세 출력
go test -run TestAdd      # 이름 패턴 매칭
go test -run TestAdd/음수 # 서브테스트만
go test -count=1   # 결과 캐시 무효화

go test 는 같은 코드면 결과를 캐싱한다. 캐시를 무시하고 강제로 다시 돌리려면 -count=1.

-race — 데이터 레이스 탐지

go test -race ./...
go run -race ./cmd/server

23장에서 봤듯이, 한 곳에서 동시 접근 사고가 일어나면 바로 보고해 준다. CI 에서는 거의 항상 켠다.

-bench — 벤치마크

go test -bench=.            # 모든 벤치마크
go test -bench=BenchmarkAdd # 특정 벤치마크
go test -bench=. -benchmem  # 메모리 할당도
go test -bench=. -benchtime=3s
go test -bench=. -count=5

-cover — 커버리지

go test -cover                    # 요약
go test -coverprofile=c.out       # 파일로 저장
go tool cover -html=c.out         # HTML 리포트
go tool cover -func=c.out         # 함수별 통계

A.4 모듈과 의존성

go mod init

새 모듈을 시작한다.

go mod init example.com/myapp

go.mod 파일이 생긴다. 모듈 경로는 보통 GitHub 등 호스팅 주소를 쓴다.

go mod tidy

go.mod, go.sum 을 코드와 동기화한다.

go mod tidy
  • 코드가 쓰는 외부 패키지를 자동으로 추가
  • 더 이상 쓰지 않는 의존성은 제거
  • 해시 정보를 go.sum 에 갱신

의존성 작업의 마무리로 거의 항상 한 번 돌린다.

go mod download

go.sum 에 명시된 의존성을 미리 받는다. 주로 CI 빌드의 첫 단계로 쓴다.

go mod download

go get

외부 패키지를 추가하거나 버전을 바꾼다.

go get github.com/google/uuid
go get github.com/google/uuid@v1.6.0
go get -u            # 모든 의존성 마이너 버전 업그레이드
go get -u ./...      # 위와 거의 같음
표현의미
패키지 추가go get <경로>
특정 버전 고정go get <경로>@v1.2.3
업그레이드go get -u <경로>
제거go mod tidy 후 자동

A.5 코드 품질 도구

go fmt / gofmt

코드 스타일을 통일한다.

go fmt ./...        # 모듈 전체 정리
gofmt -d main.go    # 변경 사항을 diff 로만 표시
gofmt -w main.go    # 파일에 직접 반영

VS Code 에서는 저장 시 자동으로 돈다.

go vet

흔한 실수를 정적으로 잡아 준다.

go vet ./...

잡아 주는 예:

  • Printf 의 포맷 동사와 인자 개수 불일치
  • shadowing 가능성
  • 사용되지 않는 결과
  • 잘못된 락 사용 패턴

go test 실행 시 자동으로 함께 도는 경우도 많다.

golangci-lint (외부)

여러 린터를 한 번에 묶어 돌려 주는 외부 도구다. 실무에선 거의 표준이다. 부록 C 에서 더 다룬다.


A.6 환경 정보

go env

설정된 환경변수를 출력한다.

go env
go env GOOS GOARCH GOPATH

자주 보는 값:

변수의미
GOOS타깃 OS (linux/darwin/windows …)
GOARCH타깃 아키텍처 (amd64/arm64 …)
GOPATH모듈 캐시 / bin 위치 (기본 ~/go)
GOROOTGo 자체 설치 위치
GOMODCACHE다운로드된 모듈 캐시

go version

go version
go version -m ./myapp    # 빌드된 바이너리의 모듈 정보

A.7 문서 보기

go doc

표준 라이브러리든 외부 패키지든 문서를 터미널에서 본다.

go doc fmt
go doc fmt.Println
go doc -src fmt.Println       # 소스 코드도
go doc strings.Builder

pkg.go.dev

웹 브라우저에서 보고 싶다면 https://pkg.go.dev 가 공식 문서 포털이다. 검색 한 줄이면 거의 모든 공개 패키지를 찾을 수 있다.

옛날엔 별도 godoc 서버를 띄웠지만, 지금은 공식 사이트가 그 역할을 대신한다.


A.8 프로파일링과 디버깅 보조

go tool pprof

CPU / 메모리 프로파일을 분석한다 (26장).

go test -cpuprofile cpu.out -bench=.
go tool pprof cpu.out

pprof 안에서 자주 쓰는 명령:

명령의미
top시간 많이 쓴 함수 상위 목록
list FuncName특정 함수 줄 단위 분석
web그래프를 브라우저로 (graphviz 필요)

HTTP 서버에 net/http/pprof 를 임포트하면 실행 중인 서버에서 바로 프로파일을 얻을 수 있다.

go tool trace

이벤트 트레이스를 분석한다. 고루틴 스케줄링, GC, 시스템 콜 등을 시각화한다.

go test -trace=trace.out -bench=.
go tool trace trace.out

A.9 자주 묶어 쓰는 한 줄들

실무에서 손에 익혀 두면 좋은 조합들이다.

# 의존성 정리 → 포맷 → 정적 분석 → 테스트
go mod tidy && go fmt ./... && go vet ./... && go test ./...

# 레이스 + 커버리지로 모두 돌리기
go test -race -cover ./...

# Linux용 작은 바이너리
GOOS=linux GOARCH=amd64 \
  go build -ldflags="-s -w" -trimpath -o app

# 의존성 업데이트 흐름
go get -u ./...
go mod tidy

A.10 정리

go 하나로 빌드, 테스트, 의존성, 분석까지 모두 된다. 처음에는 명령이 많아 보이지만, 실제로 매일 쓰는 건 다섯 개 정도다.

  • go run
  • go test
  • go build
  • go mod tidy
  • go fmt (대개 자동)

나머지는 필요할 때 이 부록을 한 번씩 펴 보면 된다.

부록 B. 초보가 자주 만나는 에러와 해결

Go 의 에러 메시지는 친절한 편이지만, 처음 보면 “이게 무슨 말이지” 싶은 경우가 적지 않다.

이 부록은 그런 메시지를 만났을 때 빠르게 원인과 해결책을 찾도록 정리한 카드 모음이다.

각 항목은 다음 형태로 적혀 있다.

  • 에러 메시지
  • 원인
  • 해결 코드

B.1 컴파일 단계 에러

imported and not used: “strings”

원인. 임포트만 해 놓고 한 번도 안 썼다. Go 는 미사용 import 를 컴파일 에러로 막는다 (3장).

잘못된 예:

import (
    "fmt"
    "strings" // 안 씀
)

func main() {
    fmt.Println("hi")
}

해결. 안 쓰는 import 줄을 지운다. VS Code 의 Go 확장은 저장 시 자동으로 정리해 준다.

가끔 import 만 해서 부작용을 얻는 패키지가 있는데 (예: database/sql 드라이버 등록) 이때는 빈 식별자 _ 를 붙인다.

import _ "github.com/lib/pq"

declared but not used: x

원인. 변수를 선언만 하고 한 번도 안 썼다 (4장).

func main() {
    x := 10 // 오류
}

해결. 쓰거나 지운다. 디버깅 중에 잠깐 살려 두고 싶다면 빈 식별자에 대입한다.

_ = x

함수 반환값을 받기 싫을 때도 _ 를 쓴다 — n, _ := strconv.Atoi(s).


cannot use X (type T1) as type T2 in argument

원인. 함수가 기대하는 타입과 넘긴 값의 타입이 다르다 (4장, 9장).

func greet(n int) { ... }

var x int32 = 3
greet(x) // 오류: int32 ≠ int

해결. 명시적 변환을 한다.

greet(int(x))

Go 는 다른 언어처럼 정수끼리 자동 변환하지 않는다. “비슷하게 생긴” 타입도 똑같이 취급하지 않는다.


missing return at end of function

원인. 반환 타입이 있는데 모든 실행 경로가 return 으로 끝나지 않는다 (9장).

func sign(n int) string {
    if n > 0 {
        return "+"
    } else if n < 0 {
        return "-"
    }
    // n == 0 일 때 빠짐
}

해결. 빠진 경로에 return 을 넣는다.

func sign(n int) string {
    if n > 0 {
        return "+"
    }
    if n < 0 {
        return "-"
    }
    return "0"
}

syntax error: unexpected newline, expecting {

원인. 여는 중괄호 { 를 다음 줄로 내렸다 (3장). 세미콜론 자동 삽입 규칙 때문에 컴파일이 깨진다.

// 오류
func main()
{
    fmt.Println("hi")
}

해결. { 를 항상 같은 줄에 둔다.

func main() {
    fmt.Println("hi")
}

if, for, switch 도 마찬가지다.


B.2 모듈 / 패키지 관련

go: cannot find main module

원인. 현재 디렉터리 또는 상위에 go.mod 가 없다 (20장).

해결. 모듈을 초기화한다.

go mod init example.com/myapp

no required module provides package X

원인. 외부 패키지를 import 했지만 go.mod 가 그 의존성을 모른다.

import "github.com/google/uuid"

해결. 의존성으로 등록한다.

go get github.com/google/uuid
go mod tidy

missing go.sum entry for module

원인. go.mod 에는 있는데 go.sum 에 해시가 빠졌다.

해결.

go mod tidy

go mod tidy 는 의존성 작업의 마지막에 거의 항상 돌린다.


package X is not in std

원인. 표준 라이브러리에 없는 패키지를 표준처럼 import 하고 있다.

해결. 외부 패키지라면 go get 으로 받는다. 오타가 아닌지도 한 번 확인한다.

go get github.com/...

B.3 런타임 panic

panic: runtime error: index out of range [N] with length M

원인. 슬라이스 / 배열의 유효 범위를 벗어난 인덱스 (11장).

xs := []int{1, 2, 3}
_ = xs[5] // panic

해결. 접근 전에 길이를 확인한다.

if i < len(xs) {
    _ = xs[i]
}

for range 를 쓰면 자동으로 안전한 인덱스를 받는다.


panic: assignment to entry in nil map

원인. 선언만 한 맵에 값을 넣고 있다 (12장). 선언만 하면 맵은 nil 이고, 쓰기는 panic 이 된다.

var m map[string]int
m["a"] = 1 // panic

해결. make 로 만든다.

m := make(map[string]int)
m["a"] = 1

리터럴로 만들어도 된다.

m := map[string]int{}

fatal error: concurrent map writes

원인. 여러 고루틴이 보호 없이 같은 맵을 쓰고 있다 (19장, 23장). Go 맵은 동시 쓰기에 안전하지 않다.

해결 1. sync.Mutex 로 보호.

var (
    mu sync.Mutex
    m  = map[string]int{}
)

mu.Lock()
m["a"]++
mu.Unlock()

해결 2. 쓰기는 한 고루틴만 하도록 설계 (24장). 채널로 변경 요청을 보내면 한 곳에서 적용한다.

해결 3. 읽기 위주라면 sync.Map 도 선택지 (25장).


fatal error: all goroutines are asleep - deadlock!

원인. 모든 고루틴이 서로를 기다리며 멈췄다 (22장, 23장).

가장 흔한 사례 두 가지.

사례 1. 받는 사람이 없는 채널에 보냄.

ch := make(chan int)
ch <- 1 // 받는 고루틴이 없음 → 영원히 대기

해결. 받는 고루틴을 먼저 띄우거나 버퍼 채널을 쓴다.

ch := make(chan int, 1)
ch <- 1

사례 2. range 가 끝나지 않음. 보내는 쪽이 close 를 안 했다.

for v := range ch { // close 없으면 무한 대기
    ...
}

해결. 송신이 끝났을 때 close(ch) 호출.

close(ch)

B.4 자주 부딪히는 함정

nil 인터페이스 비교 함정

var err *MyError = nil
var i error = err
fmt.Println(i == nil) // false (의외로!)

원인. 인터페이스는 (타입, 값) 두 부분이다 (16장). 타입이 박혀 있으면 값이 nil 이어도 인터페이스 자체는 nil 이 아니다.

해결. nil 일 가능성이 있는 경우엔 인터페이스로 박지 말고 그대로 반환한다.

func find() (*MyError, error) {
    return nil, nil // 인터페이스로 박지 않음
}

함수가 error 를 반환해야 한다면 nil 일 때는 명시적으로 nil 을 반환한다.

if err == nil {
    return nil
}
return err

슬라이스의 부분 슬라이스가 원본을 붙잡는 함정

big := make([]byte, 1<<20)
small := big[:10]

small 은 짧지만 내부적으로 big 의 큰 배열을 가리킨다. GC 가 big 을 못 풀어 메모리가 안 줄어든다 (11장, 26장).

해결. 메모리를 끊고 싶다면 copy 로 새 배열에 옮긴다.

small := make([]byte, 10)
copy(small, big[:10])
big = nil

루프 변수 캡처 함정 (Go 1.22 이전)

xs := []int{1, 2, 3}
for _, x := range xs {
    go func() {
        fmt.Println(x) // 예상치 못한 값
    }()
}

Go 1.22 미만에서는 x 가 매 반복마다 같은 변수라 고루틴 실행 시점엔 마지막 값으로 모이는 일이 흔했다 (10장, 22장).

해결 1. 매 반복에서 새 변수로 복사.

for _, x := range xs {
    x := x
    go func() { fmt.Println(x) }()
}

해결 2. 인자로 전달.

for _, x := range xs {
    go func(v int) { fmt.Println(v) }(x)
}

Go 1.22 부터는 for 루프 변수가 매 반복마다 새로 만들어진다. 같은 코드라도 결과가 달라진다. 사용 중인 Go 버전을 확인하자.


0 으로 나누기 / 나머지

x := 10 / 0 // 컴파일 에러

상수면 컴파일러가 잡아 준다. 하지만 변수라면 런타임 panic 이 된다.

var b int = 0
_ = 10 / b // panic

해결. 나누기 전에 0 검사를 한다.

if b != 0 {
    _ = 10 / b
}

shadowing 으로 인한 미묘한 버그

var x int

if true {
    x := 10 // 안쪽에서 새 x 생성
    _ = x
}
fmt.Println(x) // 0

원인. := 는 같은 이름이라도 바깥 블록의 변수를 안 가리지 않고 새 변수를 만든다 (10장).

해결. 바깥 변수를 쓰고 싶을 땐 = 로 대입한다.

if true {
    x = 10
}

go vet -shadow 또는 golangci-lintgovet 가 잡아 준다.


에러를 무시하면 안 되는데 무시한 경우

data, _ := os.ReadFile("config.json") // 위험

파일이 없거나 권한이 없을 때 data 는 빈 슬라이스가 되고 이후 코드가 이상하게 흘러간다 (21장).

해결. 에러는 항상 확인한다.

data, err := os.ReadFile("config.json")
if err != nil {
    return fmt.Errorf("config 읽기: %w", err)
}

B.5 정리

자주 만나는 에러 메시지의 패턴은 결국 몇 가지로 모인다.

  • 미사용 변수 / import
  • nil 맵 / nil 인터페이스
  • 슬라이스 인덱스 / 잘못된 길이
  • 동시 접근 / 데드락
  • go.mod 누락 / go.sum 누락

처음엔 당황스럽지만, 한 번씩 만나 본 다음부터는 메시지만 보고도 어느 줄을 봐야 할지 감이 온다.

이 부록은 다 외울 필요는 없다. 실제로 에러를 만났을 때 다시 펴 보면 된다.