C++ 개발자를 위한 Go 언어 2주 학습 커리큘럼: Day별 일정과 C++ 개념 대응
이 글의 핵심
C++에 익숙하면 Go의 문법은 금방 읽히지만, 예외 대신 에러를 값으로 돌려주고 상속 대신 인터페이스를 암묵적으로 만족시키는 방식에서 오히려 막히기 쉽습니다. 이 커리큘럼은 C++ 개념을 Go 개념에 대응시키는 멘탈 모델에서 출발해 포인터와 슬라이스, 동시성, 테스팅을 거쳐 REST API 서버를 만드는 미니 프로젝트로 마무리합니다. 각 Day는 시리즈의 개별 글로 연결됩니다.
들어가며: 복잡함에서 심플함으로
C++로 작은 서비스 하나를 만들 때도 빌드 설정, 플랫폼별 분기, 스레드 동기화에 드는 품이 적지 않습니다. 이 글은 C/C++ 경험이 있는 개발자가 Go의 기본 문법, 에러 처리, 고루틴과 채널, 테스트, REST API 서버까지 2주 동안 하루 단위로 따라갈 수 있게 짠 커리큘럼 개요입니다. 각 단계에서 C++ 코드와 Go 코드를 나란히 놓고 무엇이 달라지는지 비교합니다.
두 언어를 성능과 동시성 측면에서 먼저 비교해 보고 싶다면 C++ vs Go 성능·동시성을 함께 보면 됩니다.
C++에서 Go로 옮길 때 달라지는 점
C++로는 빌드·링크·플랫폼별 분기까지 신경 쓰다 보면, “작은 서비스 하나”에도 설정 파일과 스크립트가 불어납니다. Go로 옮기면 단일 정적 바이너리, 일관된 포맷(gofmt), 동시성 기본기(고루틴·채널) 덕분에 배포·운영 단계가 단순해지는 경우가 많습니다. 반대로 저수준 제어·템플릿 메타프로그래밍이 필요하면 여전히 C++가 맞는 영역입니다.
전환할 때 흔히 겪는 시행착오:
- 에러 처리:
if err != nil을 빠뜨려 조용히 실패하는 코드 — 에러를 값으로 받아 바로 확인하는 습관으로 줄일 수 있습니다. - 동시성: 뮤텍스만 익숙하면 채널·
select를 과소평가하기 쉽습니다 — #06을 권장합니다. - 종료 처리: HTTP 서버를 끌 때
Shutdown없이 프로세스만 죽이면 연결이 끊깁니다 — Go context 타임아웃·취소 글에서 정리합니다.
아래 커리큘럼은 이 함정들을 하나씩 짚도록 순서를 짰습니다.
심화: C++ 관점에서 보는 Go 내부 구조와 실무 전환
문법을 넘어서 런타임이 고루틴·채널·인터페이스를 어떻게 구현하는지를 C++의 직관(스레드, 가상 함수 테이블, 뮤텍스)과 맞춰 보면, 이후 성능 튜닝이나 대규모 마이그레이션에서 “왜 이런 현상이 생기는가”를 설명할 수 있습니다. 아래는 커리큘럼의 개념 지도를 내부 구현과 실무 패턴까지 연결한 심화 요약입니다.
C++에서 Go로의 멘탈 모델 매핑
| C++에서 익숙한 개념 | Go에서의 대응 | 한 줄 직관 |
|---|---|---|
std::unique_ptr / 소유권 | GC + 필요 시 포인터·슬라이스 공유 | 수명은 런타임이 추적하며, 공유 의도는 타입 설계로 드러냄 |
| RAII, 소멸자 | defer, Close, sync.Pool(재사용) | 종료·해제는 호출 경로가 보이는 쪽에 둠(암시적 소멸자 없음) |
const/constexpr | 컴파일 타임 상수 vs 런타임 불변(관용) | “불변 보장”은 언어가 강제하지 않고 API·리시버로 표현 |
| 예외 스택 풀어헤치기 | error 값 + fmt.Errorf 래핑 | 제어 흐름이 호출자에게 명시적으로 전달됨 |
| 가상 함수·상속 | 임베딩 + 인터페이스(암시적 구현) | “is-a”보다 작은 계약(인터페이스) 조합 |
std::thread 풀 | 고루틴 + 스케줄러 | OS 스레드 수와 고루틴 수를 직결하지 않음 |
| 템플릿·Concepts | 제네릭(1.18~) + 작은 인터페이스 | 컴파일 타임 다형성과 런타임 다형성을 분리해서 선택 |
실무에서 사고방식을 바꿔야 하는 지점이 몇 가지 있습니다. 첫째, 성능 최적화의 레버가 “인라인·템플릿 특화”에서 할당 줄이기·락 경합 줄이기·GC 압력 완화로 이동한다는 점입니다. 둘째, 동시성 버그는 데이터 레이스 탐지기(go test -race)와 스케줄러 특성(아래 M:N)을 함께 봐야 재현이 쉬워집니다.
고루틴 스케줄러 내부(M:N) — G, M, P
Go 런타임은 수많은 고루틴 G를 소수의 OS 스레드 M에 올려 실행하되, 그 사이에 논리 CPU 단위 P(프로세서 슬롯)를 둡니다. 요약하면 G는 실행 단위, M은 실제로 CPU를 붙잡는 스레드, P는 “지금 이 M이 실행할 수 있는 G의 큐와 자원 슬롯”에 가깝습니다.
- M:N 매핑: 많은 G가 적은 M에 스케줄되며,
GOMAXPROCS(기본은 논리 CPU 수)가 동시에 실행될 P의 개수를 조절합니다. C++에서 스레드 풀 크기를 정하는 것과 비슷하지만, 고루틴 자체는 OS 스레드가 아니어서 만들고 버리는 비용이 낮습니다. - 런 큐와 워크 스틸링(work stealing): 각 P는 로컬 큐를 가지고, 한쪽이 바쁘고 다른 쪽이 한가하면 다른 P의 큐에서 G를 가져옵니다. 이는
std::deque를 둘러싼 수동 부하 분산을 떠올리게 하지만, Go에서는 런타임이 이동·균형을 담당합니다. - 선점(preemption): 오래 CPU만 점유하는 G는 Go 1.14 이후 비협조적 선점으로 다른 G에게 양보할 수 있습니다. “협조적 스케줄링만 있다”는 옛날 설명은 이제 참고용으로만 두는 것이 좋습니다.
- 시스템 콜·블로킹: M이 커널에서 오래 막히면 P는 다른 M에 붙거나 G가 다른 실행 경로로 옮겨질 수 있습니다. 많은 블로킹 시스템 콜은 고루틴을 “가볍게” 만들어 주는 이점을 일부 상쇄합니다.
- 네트워크 폴러:
net의 다수 소켓 I/O는 커널의 이벤트 알림과 결합되어, 수만 개의 대기 고루틴을 OS 스레드 한 개씩 붙잡아 두지 않도록 설계되어 있습니다. C++의epoll/IOCP루프를 직접 짜던 경험이 있다면 “런타임이 그 루프를 숨긴다”고 이해하면 됩니다.
프로파일링할 때는 CPU 프로파일 외에 pprof의 mutex·block 프로파일과 runtime/trace를 함께 보면, G가 어디서 멈췄는지(락 경합, 채널 대기, 시스템 콜, GC 등)가 분리되어 보입니다. mutex·block 프로파일은 기본으로 꺼져 있어서 runtime.SetMutexProfileFraction, runtime.SetBlockProfileRate로 켜야 데이터가 쌓입니다.
채널 구현을 한 번에 이해하기
채널은 언어 문법처럼 보이지만, 런타임에서는 락·대기 큐·버퍼 링으로 구현된 hchan에 가깝습니다. C++ 개발자에게는 “조건 변수 + 큐 + (필요 시) 링 버퍼를 하나로 묶은 동기화 프리미티브”로 비유할 수 있습니다.
- 무버퍼 채널: 송신 G와 수신 G가 서로를 만날 때까지 대기합니다. 단순히 “빠르다/느리다”가 아니라 동기화 지점이 명확해져서, 레이스를 줄이는 데 유리합니다.
- 버퍼 채널: 정해진 슬롯이 차기 전까지 송신이 블로킹되지 않을 수 있습니다. 백프레셔(backpressure)를 코드로 표현하기 좋습니다.
- 대기열(대기 고루틴): 채널 연산에 막힌 G는 런타임이 관리하는 대기 구조로 들어갑니다.
select는 여러 채널의 준비 상태를 한 번에 검사하며, 여러 case가 동시에 준비되어 있으면 언어 명세에 따라 그중 하나를 균등한 의사 난수로 고릅니다. 그래서 case를 적은 순서에 우선순위를 기대하면 안 됩니다. close의 의미: 더 이상 송신할 값이 없음을 알립니다. 수신은 남은 값을 소진한 뒤 제로 값과false를 받습니다. 닫힌 채널에 송신하거나 이미 닫힌 채널을 다시 닫으면 패닉이 나므로, “누가 닫는가”를 팀 규약으로 정하는 것이 안전합니다.
실무 규칙: “채널은 소유자가 닫는다”, “에러는 별도 채널/값으로 전달한다”, “버퍼 크기는 부하 실험으로 정한다”를 #06의 예제와 함께 몸에 익히면, 내부 구현을 몰라도 올바른 패턴에 수렴합니다.
인터페이스 타입 시스템 vs C++ 다형성·Concepts
C++의 가상 함수는 컴파일 시점에 상속 관계가 선언되고, 객체 안에 vptr이 있습니다. Go 인터페이스는 메서드 집합을 만족하면 자동으로 해당 인터페이스가 되며, 런타임에 이타블(itab)과 원본 값/포인터가 짝을 이룹니다.
- 정적 타입 vs 동적 만족: 구체 타입
T가 컴파일 타임에Stringer인지 “표기”하지 않아도, 메서드가 있으면 됩니다. C++20 Concepts의 “요구 사항을 만족하는 타입”과 정서가 비슷하지만, Go는 암시적입니다. - 값 vs 포인터 수신자: 포인터 수신자로 정의한 메서드는 값
T의 메서드 집합에 들어가지 않습니다. 그래서T값을 그 인터페이스에 넣으면 컴파일 에러가 나고*T를 넣어야 합니다. 또 포인터가 아닌 값을 인터페이스에 담으면 값이 복사되어 대개 힙에 할당되는데, “왜 갑자기 힙으로 가나” 하는 이슈를 볼 때 이 점이 단서가 됩니다. - 빈 인터페이스
any: 모든 타입을 담을 수 있지만, C++의void*처럼 타입 정보가 사라지는 것은 아니고, 런타임에 타입·값 쌍으로 보관됩니다. 타입 단언(.(T))이나 타입 스위치(switch v := x.(type))로 원래 타입을 되찾습니다. - 작은 인터페이스: 표준 라이브러리의
io.Reader처럼 메서드 하나인 계약이 많습니다. 이는 C++에서 거대한 추상 기본 클래스를 두는 스타일과 대비됩니다.
인터페이스는 “문법상 편의”가 아니라 의존성 역전을 저비용으로 만드는 장치입니다. 테스트에서 fake를 주입하기 쉬운 이유도 여기에 있습니다.
프로덕션 마이그레이션 패턴(C++ → Go)
한 번에 전부 교체하는 빅뱅 포팅은 리스크가 큽니다. 아래는 현장에서 자주 쓰는 접합 전략입니다.
- 스트랭글러 피그(Strangler Fig): 새 기능·새 경로만 Go로 열고, 기존 C++ 바이너리는 점진적으로 줄입니다. API 게이트웨이나 라우터 뒤에서 트래픽 비율을 조절합니다.
- 프로세스 경계 분리: gRPC/HTTP + Protobuf/JSON 스키마로 계약을 고정합니다. C++와 Go가 같은 IDL을 공유하면 팀 간 충돌이 줄어듭니다.
- 사이드카·에이전트: 데이터 플레인은 C++, 제어·운영·관측은 Go로 두는 식의 역할 분담도 흔합니다. FFI로 한 프로세스 안에서 붙이기보다 네트워크 경계가 테스트·배포를 단순하게 만드는 경우가 많습니다.
- 관측 가능성 먼저: 구조화 로깅, 메트릭, 트레이싱을 양쪽 스택에 동일한 스키마로 넣어야, 포팅 후 지연·에러가 회귀인지 개선인지를 판별할 수 있습니다.
- 테스트 동등성: 골든 파일, 프로퍼티 기반 테스트, 부하 테스트를 동일 입력·동일 SLO로 맞춥니다. GC나 스케줄링 차이로 지연 분포가 달라질 수 있음을 전제합니다.
- 팀 운영: 린트(
staticcheck등),go test -race, CI 캐시, 모듈 미러 정책을 레포 초기에 고정합니다. C++의 복잡한 툴체인 대신 관행이 곧 품질이 됩니다.
이 심화 내용은 앞선 Day별 글을 읽은 뒤 “왜 이렇게 설계되었나?”를 연결하는 용도로 두었습니다. 세부 구현은 Go 버전에 따라 달라질 수 있으므로, 문제가 생기면 해당 버전의 런타임 소스·릴리스 노트를 함께 확인하는 습관을 권합니다.
1주 차: 패러다임의 전환과 기본기 다지기
C++의 잔재(상속, 예외 처리 등)를 덜어내고, Go 언어 특유의 심플함에 적응하는 주간입니다.
flowchart LR
A["Day 1-2<br/>기본 문법"] --> B["Day 3-4<br/>메모리·자료구조"]
B --> C["Day 5-6<br/>객체지향"]
C --> D["Day 7<br/>인터페이스"]
style A fill:#e1f5ff
style B fill:#e1f5ff
style C fill:#e1f5ff
style D fill:#e1f5ff
Day 1~2: Go 언어의 철학과 기본 문법
주제: C++ 개발자의 시선에서 본 Go 언어의 첫인상 핵심 내용:
- Go 설치 및 툴체인(
go build,go run,go fmt) 소개 - 변수 선언의 차이 (
autovs:=) - C++의
while까지 대신하는for문 - 가비지 컬렉터(GC)의 도입:
new와delete로부터의 해방 C++ vs Go 비교: 변수 선언
// C++: 변수 선언
int x = 10;
auto y = 20; // 타입 추론
const int MAX = 100;
std::vector<int> vec = {1, 2, 3};
// Go: 변수 선언
var x int = 10
y := 20 // 짧은 선언 (타입 추론)
const MAX = 100
// 여러 변수 동시 선언
var (
name string = "Go"
version int = 1
)
slice := []int{1, 2, 3}
C++ vs Go 비교: 반복문
// C++: 다양한 반복문
for (int i = 0; i < 10; i++) {
std::cout << i << "\n";
}
while (condition) {
// ...
}
for (const auto& item : container) {
// ...
}
// Go: for 하나로 모든 반복 처리
for i := 0; i < 10; i++ {
fmt.Println(i)
}
// while 대신 for (조건만)
for condition {
// ...
}
// range로 컨테이너 순회
for i, item := range slice {
fmt.Println(i, item)
}
// 무한 루프
for {
// break로 탈출
}
학습 포인트:
- Go는
while,do-while이 없습니다.for만으로 모든 반복을 처리합니다. :=는 함수 내에서만 사용 가능하며, 타입을 자동 추론합니다.go fmt는 코드 포맷을 자동으로 맞춰주므로, 팀 내 코딩 스타일 논쟁이 사라집니다.
Day 3~4: 메모리와 자료구조 (포인터, 배열, 슬라이스)
주제: 포인터 연산은 없지만 포인터는 있다? 핵심 내용:
- Go의 포인터(
*,&)와 Call by Value / Call by Reference - C++
std::vector와 Go Slice의 결정적 차이 (Capacity와 Length의 이해) - Map 구조의 활용 C++ vs Go 비교: 포인터
// C++: 포인터와 참조
void increment(int* p) {
(*p)++;
}
void increment_ref(int& r) {
r++;
}
int main() {
int x = 10;
increment(&x); // 포인터
increment_ref(x); // 참조
int* p = new int(42);
delete p; // 수동 해제
}
// Go: 포인터 (연산 없음, GC 자동 해제)
func increment(p *int) {
*p++ // 포인터 연산 없음, 역참조만 가능
}
func main() {
x := 10
increment(&x)
p := new(int) // GC가 자동 해제
*p = 42
// delete 불필요
}
핵심 차이점:
- Go는 포인터 연산(
p++,p + 1)이 불가능합니다. 안전성을 위해 제거되었습니다. - 참조(
&)는 없으며, 포인터만 있습니다. 함수 인자로 수정이 필요하면 포인터를 전달합니다. new로 할당해도delete불필요. GC가 자동으로 수거합니다. C++ vs Go 비교: 동적 배열
// C++: std::vector
#include <vector>
#include <iostream>
int main() {
std::vector<int> vec;
vec.push_back(1);
vec.push_back(2);
vec.push_back(3);
std::cout << "Size: " << vec.size() << "\n";
std::cout << "Capacity: " << vec.capacity() << "\n";
// 범위 기반 for
for (const auto& v : vec) {
std::cout << v << " ";
}
}
// Go: Slice (동적 배열)
func main() {
var slice []int // nil 슬라이스
slice = append(slice, 1)
slice = append(slice, 2)
slice = append(slice, 3)
fmt.Println("Length:", len(slice))
fmt.Println("Capacity:", cap(slice))
// range로 순회
for i, v := range slice {
fmt.Println(i, v)
}
// 슬라이싱
sub := slice[1:3] // [2 3]
fmt.Println(sub)
}
Slice의 핵심 개념:
- Length: 현재 원소 개수 (
len(slice)) - Capacity: 재할당 없이 담을 수 있는 최대 개수 (
cap(slice)) append로 추가할 때 capacity를 넘으면 새 배열을 할당해 복사합니다. 작은 슬라이스는 대략 2배씩 늘고, 일정 크기(Go 1.18 이후 256개 원소 기준)를 넘으면 증가율이 점점 1.25배 쪽으로 줄어듭니다. 정확한 값은 원소 크기와 메모리 할당 크기 클래스에 따라 달라집니다.- 슬라이싱(
slice[1:3])은 원본 배열을 공유합니다. 그래서 부분 슬라이스에append하면 capacity가 남아 있는 한 원본의 뒤쪽 원소를 덮어쓸 수 있습니다. C++ vs Go 비교: Map
// C++: std::map / std::unordered_map
#include <unordered_map>
#include <iostream>
#include <string>
using namespace std;
int main() {
std::unordered_map<std::string, int> m;
m["key1"] = 100;
m["key2"] = 200;
// 키 존재 확인
if (m.find("key1") != m.end()) {
std::cout << "Found: " << m["key1"] << "\n";
}
}
// Go: map
func main() {
m := make(map[string]int)
m["key1"] = 100
m["key2"] = 200
// 키 존재 확인 (두 번째 반환값)
if v, ok := m["key1"]; ok {
fmt.Println("Found:", v)
}
// 키 삭제
delete(m, "key1")
}
Day 5~6: 클래스(Class) 없는 객체지향 프로그래밍
주제: 상속(Inheritance)을 버리고 합성(Composition)을 취하다 핵심 내용:
struct의 정의와 활용- 메서드(Method)와 리시버(Receiver)의 개념 (포인터 리시버 vs 값 리시버)
- 객체의 합성(Embedding)을 통한 코드 재사용 패턴 C++ vs Go 비교: 클래스와 메서드
// C++: 클래스 기반 객체지향
class Counter {
private:
int count;
public:
Counter() : count(0) {}
void Increment() {
count++;
}
int GetCount() const {
return count;
}
void Reset() {
count = 0;
}
};
// 사용
Counter c;
c.Increment();
std::cout << c.GetCount() << "\n";
// Go: 구조체 + 메서드
type Counter struct {
count int // 소문자 = private (패키지 외부 접근 불가)
}
// 생성자 관례 (NewXxx 함수)
func NewCounter() *Counter {
return &Counter{count: 0}
}
// 포인터 리시버 - 필드 수정
func (c *Counter) Increment() {
c.count++
}
// 읽기만 하지만, 리시버 종류를 통일하기 위해 포인터 리시버 사용
func (c *Counter) GetCount() int {
return c.count
}
// 포인터 리시버 - 필드 수정
func (c *Counter) Reset() {
c.count = 0
}
// 사용 (함수 안에서)
c := NewCounter()
c.Increment()
fmt.Println(c.GetCount())
리시버 선택 가이드:
- 포인터 리시버
(c *Counter)는 메서드가 필드를 수정하거나, 구조체가 커서 복사 비용이 아까울 때, 또는sync.Mutex처럼 복사하면 안 되는 필드를 가질 때 씁니다. - 값 리시버
(c Counter)는 작고 불변인 타입(좌표, 시간 값 등)에 어울립니다. - 한 타입의 메서드는 모두 포인터 리시버거나 모두 값 리시버로 통일하는 것이 관례입니다. 위
Counter에서GetCount도 포인터 리시버로 둔 것은 이 때문입니다. C++ vs Go 비교: 상속 vs 합성
// C++: 상속 기반 재사용
class Animal {
public:
virtual void Speak() {
std::cout << "Some sound\n";
}
};
class Dog : public Animal {
public:
void Speak() override {
std::cout << "Woof!\n";
}
void Fetch() {
std::cout << "Fetching...\n";
}
};
// 사용
Dog d;
d.Speak(); // "Woof!"
d.Fetch();
// Go: 합성(Embedding) 기반 재사용
type Animal struct {
Name string
}
func (a Animal) Speak() {
fmt.Println("Some sound")
}
type Dog struct {
Animal // 임베딩 - Animal의 메서드가 Dog에 포함됨
Breed string
}
// Dog의 고유 메서드
func (d Dog) Fetch() {
fmt.Println("Fetching...")
}
// Animal.Speak를 가림(shadowing)
func (d Dog) Speak() {
fmt.Println("Woof!")
}
// 사용
d := Dog{
Animal: Animal{Name: "Buddy"},
Breed: "Golden Retriever",
}
d.Speak() // "Woof!" (Dog.Speak가 Animal.Speak를 가림)
d.Fetch()
핵심 차이점:
- Go는 상속이 없습니다. 대신 구조체 임베딩으로 메서드를 재사용합니다.
- 임베딩된 타입의 메서드가 자동으로 외부 타입에 “승격”됩니다.
- 같은 이름의 메서드를 외부 타입에 정의하면 임베딩된 메서드를 가립니다. 다만 이것은 가상 함수 오버라이드가 아닙니다.
Animal의 다른 메서드가 내부에서a.Speak()를 호출하면Dog의Speak가 아니라 항상Animal.Speak가 불립니다. C++의 템플릿 메서드 패턴처럼 “기반 쪽이 파생 쪽 동작을 호출”하는 구조가 필요하면 인터페이스를 필드로 받아야 합니다.
Day 7: 다형성의 재해석, 인터페이스(Interface)
주제: 가상 함수(Virtual Function) 없이 다형성 구현하기 핵심 내용:
implements키워드가 없는 암시적 인터페이스 (컴파일 타임에 검사되는 구조적 타이핑)- 빈 인터페이스
interface{}와 타입 단언(Type Assertion) - Go 라이브러리에서 흔히 쓰이는 소형 인터페이스(예:
io.Reader,io.Writer) 설계 패턴 C++ vs Go 비교: 다형성
#include <iostream>
// C++: 가상 함수로 다형성
class Shape {
public:
virtual double Area() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
double Area() const override {
return 3.14159 * radius * radius;
}
};
class Rectangle : public Shape {
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double Area() const override {
return width * height;
}
};
// 다형성 사용
void printArea(const Shape& s) {
std::cout << "Area: " << s.Area() << "\n";
}
int main() {
Circle c(5.0);
Rectangle r(4.0, 6.0);
printArea(c);
printArea(r);
}
// Go: 인터페이스로 다형성 (명시적 상속 불필요)
type Shape interface {
Area() float64
}
type Circle struct {
Radius float64
}
// Circle은 Area()를 구현하므로 자동으로 Shape 인터페이스 만족
func (c Circle) Area() float64 {
return 3.14159 * c.Radius * c.Radius
}
type Rectangle struct {
Width, Height float64
}
// Rectangle도 Area()를 구현하므로 Shape 인터페이스 만족
func (r Rectangle) Area() float64 {
return r.Width * r.Height
}
// 다형성 사용
func printArea(s Shape) {
fmt.Printf("Area: %.2f\n", s.Area())
}
func main() {
c := Circle{Radius: 5.0}
r := Rectangle{Width: 4.0, Height: 6.0}
printArea(c)
printArea(r)
}
핵심 차이점:
- Go는 명시적 상속 선언이 없습니다. 메서드만 구현하면 자동으로 인터페이스를 만족합니다.
- 흔히 덕 타이핑에 비유하지만, 만족 여부는 런타임이 아니라 컴파일 타임에 검사됩니다(구조적 타이핑).
- 인터페이스는 작게 만드는 것이 Go의 철학입니다. (예:
io.Reader는Read메서드 하나만) 표준 라이브러리 인터페이스 예시
// Go: io.Reader 인터페이스 (메서드 하나)
type Reader interface {
Read(p []byte) (n int, err error)
}
// *os.File, *bytes.Buffer, *strings.Reader 등이 모두 io.Reader 만족
func processData(r io.Reader) {
data, err := io.ReadAll(r)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
}
// 사용
f, err := os.Open("file.txt")
if err != nil {
log.Fatal(err)
}
defer f.Close()
processData(f) // *os.File은 io.Reader
buf := bytes.NewBufferString("hello")
processData(buf) // *bytes.Buffer도 io.Reader
빈 인터페이스와 타입 단언
// Go: interface{} (모든 타입 수용, C++의 void*와 유사하지만 타입 안전)
func printAny(v interface{}) {
// 타입 단언
if s, ok := v.(string); ok {
fmt.Println("String:", s)
} else if i, ok := v.(int); ok {
fmt.Println("Int:", i)
}
}
// 타입 스위치
func describe(v interface{}) {
switch t := v.(type) {
case string:
fmt.Printf("String: %s\n", t)
case int:
fmt.Printf("Int: %d\n", t)
default:
fmt.Printf("Unknown type: %T\n", t)
}
}
2주 차: Go 언어의 꽃, 동시성
2주 차에서는 에러 처리, 동시성, 모듈과 테스트를 다루고 마지막 날 작은 API 서버를 만듭니다.
flowchart LR
A["Day 8-9<br/>에러 처리"] --> B["Day 10-11<br/>고루틴·채널"]
B --> C["Day 12-13<br/>의존성·테스팅"]
C --> D["Day 14<br/>실전 프로젝트"]
style A fill:#fff4e1
style B fill:#fff4e1
style C fill:#fff4e1
style D fill:#ffcccc
Day 8~9: 예외(Exception) 처리의 새로운 접근
주제: try-catch는 잊어라, Go의 명시적 에러 핸들링
핵심 내용:
- 다중 반환값(Multiple Return Values)을 활용한 에러 전달
if err != nil패턴의 철학- 자원 해제를 함수 종료 시점에 보장하는
defer(C++ RAII의 대체) panic과recover(왜 일반적인 예외 처리로 쓰면 안 되는가?) C++ vs Go 비교: 예외 처리
// C++: try-catch 예외 처리
#include <stdexcept>
#include <fstream>
#include <iostream>
#include <string>
using namespace std;
void processFile(const std::string& path) {
std::ifstream file(path);
if (!file) {
throw std::runtime_error("Failed to open file");
}
std::string line;
while (std::getline(file, line)) {
if (line.empty()) {
throw std::invalid_argument("Empty line");
}
// 처리...
}
// RAII로 자동 닫힘
}
int main() {
try {
processFile("data.txt");
} catch (const std::exception& e) {
std::cerr << "Error: " << e.what() << "\n";
return 1;
}
return 0;
}
// Go: error 반환과 명시적 처리
func processFile(path string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close() // defer로 자원 해제 보장
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
return fmt.Errorf("empty line found")
}
// 처리...
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan error: %w", err)
}
return nil
}
func main() {
if err := processFile("data.txt"); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
defer의 활용: RAII 대체
// C++: RAII로 자동 정리
void criticalSection() {
std::lock_guard<std::mutex> lock(mtx); // 생성자에서 락
// 작업...
// 소멸자에서 자동 언락
}
void fileOperation() {
std::ifstream f("file.txt");
// 작업...
// 소멸자에서 자동 close
}
// Go: defer로 명시적 정리
func criticalSection() {
mu.Lock()
defer mu.Unlock() // 함수 종료 시 자동 실행
// 작업...
}
func fileOperation() error {
f, err := os.Open("file.txt")
if err != nil {
return err
}
defer f.Close() // return, panic 모두에서 실행
// 작업...
return nil
}
defer의 실행 순서 (LIFO)
// Go: defer는 LIFO (Last In First Out)
func example() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
fmt.Println("함수 본문")
}
// 출력:
// 함수 본문
// 3
// 2
// 1
panic과 recover (제한적 사용)
// Go: panic/recover 동작을 보여 주기 위한 예시
// 실제로는 b == 0을 error로 반환하는 것이 맞습니다(정수 0 나누기는 어차피 런타임 패닉).
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic recovered: %v", r)
}
}()
if b == 0 {
panic("division by zero") // 일반적으로는 error 반환 권장
}
return a / b, nil
}
func main() {
result, err := safeDivide(10, 0)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}
Go에서 에러는 평범한 반환값이라, 에러가 생길 수 있는 지점과 호출 스택을 따라 전파되는 경로가 코드에 그대로 드러납니다. panic은 인덱스 범위 초과처럼 프로그래밍 오류에 해당하는 상황에 남겨 두고, recover는 HTTP 서버가 한 요청의 패닉 때문에 전체 프로세스가 죽지 않게 하는 식의 경계 지점에서만 쓰는 것이 관례입니다.
Day 10~11: 고루틴(Goroutine)과 채널(Channel)
주제: 고루틴과 채널로 하는 동시성 프로그래밍 핵심 내용:
- OS 스레드(
std::thread)와 고루틴의 무게 차이 (수만 개의 고루틴 띄워보기) - “공유 메모리로 통신하지 말고, 통신으로 메모리를 공유하라”
- Channel을 이용한 고루틴 간의 안전한 데이터 동기화
select문을 활용한 다중 채널 제어 C++ vs Go 비교: 스레드 생성
// C++: std::thread (OS 스레드, 무거움)
#include <thread>
#include <iostream>
#include <vector>
void worker(int id) {
std::cout << "Worker " << id << " running\n";
}
int main() {
std::vector<std::thread> threads;
// 10개 스레드 생성 (스레드마다 OS 기본 스택 예약: Linux 보통 8MB, Windows 1MB)
for (int i = 0; i < 10; i++) {
threads.emplace_back(worker, i);
}
// 모든 스레드 대기
for (auto& t : threads) {
t.join();
}
return 0;
}
// Go: 고루틴 (2KB 스택으로 시작해 필요할 때 늘어남)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("Worker %d running\n", id)
}
func main() {
var wg sync.WaitGroup
// 10,000개 고루틴도 메모리 부담 없이 생성 가능
for i := 0; i < 10000; i++ {
wg.Add(1)
go worker(i, &wg) // go 키워드로 고루틴 생성
}
wg.Wait() // join에 해당: 모든 고루틴이 끝날 때까지 대기
}
핵심 차이점:
- 고루틴은 2KB 스택으로 시작해 필요할 때 런타임이 늘려 줍니다. 생성과 전환이 커널을 거치지 않으므로 OS 스레드보다 훨씬 가볍습니다. 공짜는 아니어서, 끝나지 않는 고루틴이 쌓이면 메모리 누수가 됩니다.
std::thread도 수천 개까지는 만들 수 있지만, 스레드마다 커널 객체와 스택 예약이 따라오고 컨텍스트 스위칭 비용도 커서 대개 스레드 풀로 수를 제한합니다. Go에서는 수만~수십만 개의 고루틴을 띄우는 설계가 일반적입니다.- 위 Go 코드의
sync.WaitGroup이join에 해당합니다.time.Sleep으로 기다리는 예제를 종종 보는데, 작업이 그보다 오래 걸리면main이 먼저 끝나 고루틴이 중간에 사라집니다. C++ vs Go 비교: 동기화
// C++: Mutex로 공유 메모리 보호
#include <mutex>
#include <thread>
#include <iostream>
using namespace std;
std::mutex mtx;
int counter = 0;
void increment(int n) {
for (int i = 0; i < n; i++) {
std::lock_guard<std::mutex> lock(mtx);
counter++;
}
}
int main() {
std::thread t1(increment, 1000);
std::thread t2(increment, 1000);
t1.join();
t2.join();
std::cout << "Counter: " << counter << "\n";
}
// Go: 채널로 통신 (권장 패턴)
func increment(ch chan int, n int) {
for i := 0; i < n; i++ {
ch <- 1 // 채널에 값 전송
}
}
func main() {
ch := make(chan int)
counter := 0
// 고루틴 2개 시작
go increment(ch, 1000)
go increment(ch, 1000)
// 결과 수신
for i := 0; i < 2000; i++ {
counter += <-ch // 채널에서 값 수신
}
fmt.Println("Counter:", counter)
}
채널의 기본 개념
// Go: 채널 생성과 사용
func main() {
// 버퍼 없는 채널 (동기)
ch := make(chan int)
go func() {
ch <- 42 // 전송 (수신자가 받을 때까지 블록)
}()
value := <-ch // 수신
fmt.Println(value)
// 버퍼 있는 채널 (비동기)
buffered := make(chan int, 3)
buffered <- 1 // 버퍼가 차기 전까지 블록 안 됨
buffered <- 2
buffered <- 3
fmt.Println(<-buffered) // 1
fmt.Println(<-buffered) // 2
}
select 문: 다중 채널 제어
// Go: select로 여러 채널 동시 대기
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "from ch2"
}()
// 먼저 준비된 채널에서 수신
for i := 0; i < 2; i++ {
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
case <-time.After(3 * time.Second):
fmt.Println("timeout")
return
}
}
}
실전 패턴: 워커 풀
// Go: 워커 풀 패턴
func worker(id int, jobs <-chan int, results chan<- int) {
for job := range jobs {
fmt.Printf("Worker %d processing job %d\n", id, job)
time.Sleep(time.Second) // 작업 시뮬레이션
results <- job * 2
}
}
func main() {
numJobs := 10
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// 3개 워커 시작
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// 작업 전송
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// 결과 수집
for a := 1; a <= numJobs; a++ {
<-results
}
}
Day 12~13: 의존성 관리와 테스팅
주제: 언어에 내장된 모듈 시스템과 테스트 도구 핵심 내용:
- Go Modules (
go.mod,go.sum) 기초 사용법 - C++에서는 복잡했던 외부 라이브러리 가져오기 (
go get) - 서드파티 프레임워크 없이
go test로 끝내는 유닛 테스트(Unit Test) 작성법 C++ vs Go 비교: 의존성 관리
# C++: CMake + vcpkg/Conan
# CMakeLists.txt 작성
# find_package() 설정
# vcpkg install 또는 conan install
# 빌드 시스템 설정...
# Go: 모듈 초기화 및 의존성 추가
go mod init myproject
go get github.com/gin-gonic/gin@latest
go mod tidy # 불필요한 의존성 정리
go.mod 파일 예시
// go.mod
module myproject
go 1.21
require (
github.com/gin-gonic/gin v1.9.1
github.com/stretchr/testify v1.8.4
)
핵심 차이점:
- Go는 빌드 시스템이 내장되어 있습니다. CMake, Make 등 불필요.
go get으로 의존성을 추가하면go.mod에 자동 기록됩니다.go.sum은 체크섬으로 의존성 무결성을 보장합니다. C++ vs Go 비교: 유닛 테스트
// C++: Google Test 사용 예시
#include <gtest/gtest.h>
int Add(int a, int b) {
return a + b;
}
TEST(MathTest, AddPositive) {
EXPECT_EQ(Add(2, 3), 5);
}
TEST(MathTest, AddNegative) {
EXPECT_EQ(Add(-2, -3), -5);
}
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
// Go: 내장 testing 패키지 (외부 프레임워크 불필요)
// math.go
package math
func Add(a, b int) int {
return a + b
}
// math_test.go (같은 패키지, _test.go 접미사)
package math
import "testing"
func TestAddPositive(t *testing.T) {
result := Add(2, 3)
expected := 5
if result != expected {
t.Errorf("Add(2, 3) = %d; want %d", result, expected)
}
}
func TestAddNegative(t *testing.T) {
result := Add(-2, -3)
expected := -5
if result != expected {
t.Errorf("Add(-2, -3) = %d; want %d", result, expected)
}
}
테스트 실행
# Go: 테스트 실행 (매우 간단)
go test # 현재 패키지 테스트
go test ./... # 모든 하위 패키지 테스트
go test -v # 상세 출력
go test -cover # 커버리지 측정
go test -bench=. # 벤치마크 실행
테이블 주도 테스트 (Table-Driven Test)
// Go: 테이블 주도 테스트 패턴
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 2, 3, 5},
{"negative", -2, -3, -5},
{"zero", 0, 0, 0},
{"mixed", -5, 10, 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}
벤치마크
// Go: 벤치마크 (함수명 BenchmarkXxx)
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
// 실행: go test -bench=.
// 출력 형식: BenchmarkAdd-8 <반복 횟수 b.N> <회당 ns/op>
-8은 실행 시 GOMAXPROCS 값입니다. Add처럼 아주 작은 함수는 컴파일러가 인라인한 뒤 결과를 쓰지 않는 호출을 통째로 없앨 수 있어서, 결과를 패키지 수준 변수에 대입해 두지 않으면 실제와 동떨어진 수치가 나올 수 있습니다. Go 1.24부터는 for b.Loop() { ... } 형태가 이런 최적화를 막아 주므로 새 코드에서는 이 형태를 권장합니다.
Day 14: 실전 미니 프로젝트 (REST API 서버 구축)
주제: 배운 것을 하나로 엮는 실전 프로젝트 핵심 내용:
net/http표준 라이브러리만을 이용한 초간단 웹 서버 띄우기- JSON 데이터 직렬화/역직렬화 (
encoding/json) - 동시성(Goroutine)을 활용한 백그라운드 작업 처리 실습 프로젝트: 간단한 TODO API 서버
// main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
"time"
)
// Todo 구조체
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
// 인메모리 저장소 (실전에서는 DB 사용)
type TodoStore struct {
mu sync.RWMutex
todos map[int]*Todo
nextID int
}
func NewTodoStore() *TodoStore {
return &TodoStore{
todos: make(map[int]*Todo),
nextID: 1,
}
}
func (s *TodoStore) Create(title string) *Todo {
s.mu.Lock()
defer s.mu.Unlock()
todo := &Todo{
ID: s.nextID,
Title: title,
Completed: false,
}
s.todos[s.nextID] = todo
s.nextID++
return todo
}
func (s *TodoStore) GetAll() []*Todo {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*Todo, 0, len(s.todos))
for _, todo := range s.todos {
result = append(result, todo)
}
return result
}
func (s *TodoStore) Update(id int, completed bool) error {
s.mu.Lock()
defer s.mu.Unlock()
todo, ok := s.todos[id]
if !ok {
return fmt.Errorf("todo not found")
}
todo.Completed = completed
return nil
}
// HTTP 핸들러
type TodoHandler struct {
store *TodoStore
}
func NewTodoHandler(store *TodoStore) *TodoHandler {
return &TodoHandler{store: store}
}
// GET /todos - 모든 TODO 조회
func (h *TodoHandler) GetTodos(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
todos := h.store.GetAll()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(todos)
}
// POST /todos - 새 TODO 생성
func (h *TodoHandler) CreateTodo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
if req.Title == "" {
http.Error(w, "Title required", http.StatusBadRequest)
return
}
todo := h.store.Create(req.Title)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(todo)
}
func main() {
store := NewTodoStore()
handler := NewTodoHandler(store)
// 라우팅 설정
http.HandleFunc("/todos", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handler.GetTodos(w, r)
case http.MethodPost:
handler.CreateTodo(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// 백그라운드 작업 예시 (고루틴 활용)
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
todos := store.GetAll()
log.Printf("Current todos count: %d", len(todos))
}
}()
// 서버 시작
addr := ":8080"
log.Printf("Server starting on %s", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatal(err)
}
}
테스트 코드
// main_test.go
package main
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestCreateTodo(t *testing.T) {
store := NewTodoStore()
handler := NewTodoHandler(store)
// 요청 생성
reqBody := `{"title":"Test Todo"}`
req := httptest.NewRequest(http.MethodPost, "/todos",
bytes.NewBufferString(reqBody))
req.Header.Set("Content-Type", "application/json")
// 응답 기록
w := httptest.NewRecorder()
handler.CreateTodo(w, req)
// 검증
if w.Code != http.StatusCreated {
t.Errorf("Expected status %d, got %d", http.StatusCreated, w.Code)
}
var todo Todo
if err := json.NewDecoder(w.Body).Decode(&todo); err != nil {
t.Fatal(err)
}
if todo.Title != "Test Todo" {
t.Errorf("Expected title 'Test Todo', got '%s'", todo.Title)
}
}
실행 방법
# 서버 실행
go run main.go
# 다른 터미널에서 테스트
curl http://localhost:8080/todos
curl -X POST http://localhost:8080/todos \
-H "Content-Type: application/json" \
-d '{"title":"Learn Go"}'
# 테스트 실행
go test -v
이 서버는 표준 라이브러리만 쓰므로 go build 하나로 의존성 없는 단일 바이너리가 나오고, GOOS=linux GOARCH=arm64 go build처럼 환경 변수만 바꿔 크로스 컴파일할 수 있습니다(cgo를 쓰지 않는 경우). 한편 http.ListenAndServe를 그대로 쓰면 타임아웃이 없고 종료 시 진행 중인 요청을 정리하지 못하므로, 실제 서비스에서는 http.Server에 ReadHeaderTimeout 등을 지정하고 Shutdown을 호출해야 합니다. 이 부분은 Go context 타임아웃·취소 글에서 다룹니다.
학습 팁과 추천 자료
효과적인 학습 전략
- C++ 사고방식 매핑: Go를 배울 때 “C++에서는 이렇게 했는데”를 항상 떠올리세요. 심화: 내부 구조와 실무 전환의 매핑 표를 함께 보면 개념이 빨리 고정됩니다.
- 작은 프로그램 많이 작성: 개념 하나를 배울 때마다 그것만 쓰는 짧은 프로그램을 직접 짜 보세요.
- 표준 라이브러리 탐색:
fmt,os,io,net/http,encoding/json등을 직접 사용해 보세요. - 에러 처리 습관화:
if err != nil패턴을 자연스럽게 받아들이세요. - 고루틴 실험: 수백, 수천 개의 고루틴을 띄워보며 가벼움을 체감하세요.
추천 학습 자료
참고 자료:
-
Tour of Go - 대화형 Go 튜토리얼
-
Effective Go - Go 스타일 가이드
-
Go by Example - 예제 중심 학습 스타일 가이드:
-
Google Go Style Guide 실전 프로젝트 아이디어:
-
CLI 도구 (파일 처리, 로그 분석기)
-
HTTP 클라이언트 (API 테스터)
-
간단한 웹 크롤러
-
채팅 서버 (WebSocket + 고루틴)
-
데이터베이스 마이그레이션 도구
C++ 개발자가 Go에서 주의할 점
flowchart TD
A[C++ 습관] --> B{Go에서 문제?}
B -->|Yes| C[수정 필요]
B -->|No| D[그대로 사용]
C --> E[RAII → defer]
C --> F[예외 → error 반환]
C --> G[상속 → 합성]
C --> H[템플릿 → 인터페이스/제네릭]
D --> I[포인터 개념]
D --> J[반복문 로직]
D --> K[조건문 구조]
자주 하는 실수:
- defer를 루프 안에서 사용: 함수 종료 시에만 실행되므로 리소스 누수 발생
- 에러 무시:
_로 에러를 무시하면 디버깅 어려움 - 루프 변수 클로저: 고루틴에서 루프 변수를 캡처할 때 주의
- 채널 닫기 누락: 수신자가 영원히 대기할 수 있음
- 포인터 vs 값 혼동: 메서드가 필드를 수정하는지에 따라 리시버 타입 선택
위 목록 중 C++ 개발자가 특히 자주 걸리는 부분을 조금 더 풀어 보면 다음과 같습니다.
defer는 스코프가 아니라 함수 단위이고, 인자는 등록 시점에 평가됩니다. C++ 소멸자는 블록이 끝날 때 실행되지만 defer는 감싸는 함수가 반환될 때 실행됩니다. 그래서 루프 안에서 파일을 열고 defer f.Close()를 쓰면 함수가 끝날 때까지 디스크립터가 쌓입니다. 반복마다 정리가 필요하면 처리 후 Close()를 직접 호출하거나, 루프 본문을 별도 함수로 빼서 그 안에서 defer를 씁니다. 실행 순서(LIFO)는 C++ 지역 객체의 소멸 순서와 같아서 헷갈릴 일이 적고, 실제로 자주 틀리는 것은 평가 시점입니다.
x := 1
defer fmt.Println(x) // 1 출력: 인자는 defer 문을 만날 때 평가
defer func() { fmt.Println(x) }() // 2 출력: 클로저는 실행 시점의 x를 읽음
x = 2
루프 변수 캡처는 Go 버전에 따라 결과가 다릅니다. Go 1.21까지는 for 루프 변수가 루프 전체에서 하나였기 때문에, 고루틴이 이를 캡처하면 대부분 마지막 값을 보았습니다. Go 1.22부터는 반복마다 새 변수가 만들어지지만, 이 의미는 go.mod의 go 지시어가 1.22 이상일 때만 적용됩니다. 오래된 모듈을 다룰 때는 go func(n int) { ... }(i)처럼 값을 인자로 넘기는 쪽이 버전과 무관하게 안전합니다.
그림: 실제 실행 결과(Go 1.24.7, Ubuntu 24.04) — 코드는 같고 go.mod의 go 지시어만 다르다. 1.21 이하 의미에서는 세 클로저가 같은 i(루프가 끝난 값 3)를 읽고, 1.22 이상에서는 반복마다 새 변수라 0 1 2가 나온다. (환경: Ubuntu 24.04 · go1.24.7)
포인터 리시버 메서드는 값 타입의 메서드 집합에 포함되지 않습니다. func (c *Counter) String() string만 정의해 두고 var s fmt.Stringer = Counter{}처럼 값을 넣으면 Counter does not implement fmt.Stringer (method String has pointer receiver) 컴파일 에러가 납니다. 이때는 &Counter{}를 넣어야 합니다.
nil 슬라이스와 빈 슬라이스는 JSON에서 다르게 나옵니다. var s []int는 null로, []int{}나 make([]int, 0)은 []로 직렬화됩니다. 둘 다 len이 0이라 Go 코드 안에서는 차이가 잘 보이지 않지만, API 응답을 받는 프런트엔드가 배열을 기대하면 null 때문에 오류가 날 수 있습니다.
문법보다 어려운 것
C++에서 Go로 옮겨 오면서 실제로 어려운 부분은 문법보다 습관입니다. 예외 대신 error 값을, 상속 대신 작은 인터페이스를, 락 대신 채널을 먼저 떠올리는 쪽으로 사고를 바꾸는 것이 이 커리큘럼의 실제 목표입니다. 반대로 저수준 메모리 제어나 템플릿 메타프로그래밍이 핵심인 코드라면 C++에 남겨 두는 편이 낫습니다.
Go는 철학과 기본 문법부터 순서대로 보면 됩니다.