C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼

C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼

이 글의 핵심

C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼에 대한 실전 가이드입니다.

시리즈 안내

📚 Go 2주 완성 시리즈 - 커리큘럼 메인 | 전체 목차 보기

이 글은 전체 커리큘럼 개요입니다. 각 Day별 상세 내용은 아래 링크를 참고하세요.

시작하기: #01 기본 문법 →


들어가며: 복잡함에서 심플함으로

C++의 강력함은 그대로 유지하면서, 복잡한 빌드 시스템과 포인터 연산의 피로도에서 벗어나고 싶으신가요? 심플한 문법과 강력한 동시성(Concurrency) 처리로 클라우드 네이티브 시대의 대세가 된 Go 언어. 기존 C/C++ 지식을 레버리지하여 단 2주 만에 Go 언어의 핵심을 마스터할 수 있는 pkglog 독점 커리큘럼을 소개합니다.

이 커리큘럼의 특징:

  • C++과의 직접 비교: 매 단계마다 C++ 코드와 Go 코드를 나란히 비교
  • 실무 중심: 이론보다는 바로 적용 가능한 패턴과 예제
  • 14일 집중 코스: 하루 2–3시간 투자로 완성하는 체계적 학습 경로
  • 최종 프로젝트: REST API 서버 구축으로 모든 개념 통합

관련 글: C++ 개발자의 뇌 구조로 이해하는 Go 언어, C++ vs Go 성능·동시성.


실무에서 겪은 문제

실제 프로젝트에서 이 개념을 적용하며 겪었던 경험을 공유합니다.

문제 상황과 해결

대규모 C++ 프로젝트를 진행하며 이 패턴/기법의 중요성을 체감했습니다. 책에서 배운 이론과 실제 코드는 많이 달랐습니다.

실전 경험:

  • 문제: 처음에는 이 개념을 제대로 이해하지 못해 비효율적인 코드를 작성했습니다
  • 해결: 코드 리뷰와 프로파일링을 통해 문제를 발견하고 개선했습니다
  • 교훈: 이론만으로는 부족하고, 실제로 부딪혀보며 배워야 합니다

이 글이 여러분의 시행착오를 줄여주길 바랍니다.


목차

  1. 1주 차: 패러다임의 전환과 기본기 다지기
  2. 2주 차: Go 언어의 꽃, 동시성과 실전 에코시스템
  3. 학습 팁과 추천 자료
  4. 정리: 2주 후 당신이 할 수 있는 것

시리즈 전체 글 목록

1주 차:

2주 차:

실무 심화(2주 이후 권장):


📌 1주 차: 패러다임의 전환과 기본기 다지기

C++의 잔재(상속, 예외 처리 등)를 덜어내고, Go 언어 특유의 심플함에 적응하는 주간입니다.

flowchart LR
    A["Day 1-2br/기본 문법"] --> B["Day 3-4br/메모리·자료구조"]
    B --> C["Day 5-6br/객체지향"]
    C --> D["Day 7br/인터페이스"]
    style A fill:#e1f5ff
    style B fill:#e1f5ff
    style C fill:#e1f5ff
    style D fill:#e1f5ff

Day 1~2: Go 언어의 철학과 기본 문법

📖 상세 글 보기: [Go 2주 완성 #01] Day 1~2: Go 언어의 철학과 기본 문법

주제: C++ 개발자의 시선에서 본 Go 언어의 첫인상

핵심 내용:

  • Go 설치 및 툴체인(go build, go run, go fmt) 소개
  • 변수 선언의 차이 (auto vs :=)
  • C++의 while을 대체하는 강력한 for
  • 가비지 컬렉터(GC)의 도입: newdelete로부터의 해방

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 2주 완성 #02] 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]
}

Slice의 핵심 개념:

  • Length: 현재 원소 개수 (len(slice))
  • Capacity: 재할당 없이 담을 수 있는 최대 개수 (cap(slice))
  • append로 추가 시 capacity를 초과하면 자동으로 재할당(보통 2배)
  • 슬라이싱(slice[1:3])은 원본 배열을 공유하므로 주의 필요

C++ vs Go 비교: Map

// C++: std::map / std::unordered_map
#include <unordered_map>

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) 없는 객체지향 프로그래밍

📖 상세 글 보기: [Go 2주 완성 #03] Day 5~6: 클래스 없는 객체지향

주제: 상속(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): 필드를 수정하거나, 구조체가 큰 경우
  • 값 리시버 (c Counter): 읽기 전용이고, 구조체가 작은 경우
  • 일관성: 한 타입의 메서드는 모두 포인터 또는 모두 값 리시버로 통일하는 것이 관례

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 오버라이드
func (d Dog) Speak() {
    fmt.Println("Woof!")
}

// 사용
d := Dog{
    Animal: Animal{Name: "Buddy"},
    Breed:  "Golden Retriever",
}
d.Speak()  // "Woof!" (오버라이드됨)
d.Fetch()

핵심 차이점:

  • Go는 상속이 없습니다. 대신 구조체 임베딩으로 메서드를 재사용합니다.
  • 임베딩된 타입의 메서드가 자동으로 외부 타입에 "승격"됩니다.
  • 같은 이름의 메서드를 정의하면 오버라이드 효과를 낼 수 있습니다.

Day 7: 다형성의 재해석, 인터페이스(Interface)

📖 상세 글 보기: [Go 2주 완성 #04] Day 7: 다형성의 재해석, 인터페이스

주제: 가상 함수(Virtual Function) 없이 다형성 구현하기

핵심 내용:

  • implements 키워드가 없는 암시적 인터페이스 (Duck Typing)
  • 빈 인터페이스 interface{}와 타입 단언(Type Assertion)
  • Go 라이브러리에서 흔히 쓰이는 소형 인터페이스(예: io.Reader, io.Writer) 설계 패턴

C++ vs Go 비교: 다형성

// 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는 명시적 상속 선언이 없습니다. 메서드만 구현하면 자동으로 인터페이스를 만족합니다.
  • "Duck Typing": "오리처럼 걷고 오리처럼 운다면 오리다"
  • 인터페이스는 작게 만드는 것이 Go의 철학입니다. (예: io.ReaderRead 메서드 하나만)

표준 라이브러리 인터페이스 예시

// 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, _ := os.Open("file.txt")
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 언어의 꽃, 동시성과 실전 에코시스템

OS 스레드나 Mutex로 고통받던 과거를 뒤로하고, 우아한 동시성 제어와 실무 적용법을 배웁니다.

flowchart LR
    A["Day 8-9br/에러 처리"] --> B["Day 10-11br/고루틴·채널"]
    B --> C["Day 12-13br/의존성·테스팅"]
    C --> D["Day 14br/실전 프로젝트"]
    style A fill:#fff4e1
    style B fill:#fff4e1
    style C fill:#fff4e1
    style D fill:#ffcccc

Day 8~9: 예외(Exception) 처리의 새로운 접근

📖 상세 글 보기: [Go 2주 완성 #05] Day 8~9: 예외 처리의 새로운 접근

주제: try-catch는 잊어라, Go의 명시적 에러 핸들링

핵심 내용:

  • 다중 반환값(Multiple Return Values)을 활용한 에러 전달
  • if err != nil 패턴의 철학
  • 자원 해제를 보장하는 마법의 키워드 defer (C++의 RAII 패턴 대체)
  • panicrecover (왜 일반적인 예외 처리로 쓰면 안 되는가?)

C++ vs Go 비교: 예외 처리

// C++: try-catch 예외 처리
#include <stdexcept>
#include <fstream>

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 - 복구 가능한 심각한 오류에만 사용
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은 예외적 상황: 복구 불가능한 프로그래밍 오류에만 사용

Day 10~11: 고루틴(Goroutine)과 채널(Channel)

📖 상세 글 보기: [Go 2주 완성 #06] Day 10~11: 고루틴과 채널

주제: C++ 개발자가 Go에 열광하는 진짜 이유, 동시성 프로그래밍

핵심 내용:

  • 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개 스레드 생성 (각 1~8MB 스택)
    for (int i = 0; i < 10; i++) {
        threads.emplace_back(worker, i);
    }
    
    // 모든 스레드 대기
    for (auto& t : threads) {
        t.join();
    }
    
    return 0;
}
// Go: 고루틴 (경량 스레드, 수 KB 스택)
func worker(id int) {
    fmt.Printf("Worker %d running\n", id)
}

func main() {
    // 10,000개 고루틴도 가볍게 생성 가능
    for i := 0; i < 10000; i++ {
        go worker(i)  // go 키워드로 고루틴 생성
    }
    
    // 고루틴 완료 대기 (간단한 예시)
    time.Sleep(time.Second)
}

핵심 차이점:

  • 고루틴: 수 KB 스택으로 시작, 필요 시 자동 확장. M:N 스케줄링으로 OS 스레드보다 훨씬 가볍습니다.
  • 생성 비용: std::thread는 OS 스레드 생성 비용이 큽니다. 고루틴은 거의 무료입니다.
  • 수량: C++에서는 수백 개 스레드가 한계. Go는 수만~수십만 고루틴도 가능합니다.

C++ vs Go 비교: 동기화

// C++: Mutex로 공유 메모리 보호
#include <mutex>
#include <thread>

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 2주 완성 #07] Day 12~13: 의존성 관리와 테스팅

주제: CMake와 vcpkg보다 수백 배 쉬운 패키지 관리

핵심 내용:

  • 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   1000000000   0.25 ns/op

Day 14: 실전 미니 프로젝트 (REST API 서버 구축)

📖 상세 글 보기: [Go 2주 완성 #08] Day 14: 실전 미니 프로젝트

주제: 배운 것을 하나로 엮는 실전 프로젝트

핵심 내용:

  • net/http 표준 라이브러리만을 이용한 초간단 웹 서버 띄우기
  • JSON 데이터 직렬화/역직렬화 (encoding/json)
  • 동시성(Goroutine)을 활용한 백그라운드 작업 처리 실습

프로젝트: 간단한 TODO API 서버

// main.go
package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "sync"
)

// 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

C++와 비교한 장점:

  • 빌드 속도: C++의 긴 컴파일 시간 vs Go의 초고속 빌드
  • 의존성: 복잡한 CMake 설정 vs 한 줄 go get
  • 동시성: 복잡한 스레드 풀 vs 간단한 go 키워드
  • 배포: 단일 바이너리로 크로스 컴파일 가능

학습 팁과 추천 자료

효과적인 학습 전략

  1. C++ 사고방식 매핑: Go를 배울 때 "C++에서는 이렇게 했는데"를 항상 떠올리세요.
  2. 작은 프로그램 많이 작성: 매일 10~20줄 짜리 프로그램을 5개 이상 작성하세요.
  3. 표준 라이브러리 탐색: fmt, os, io, net/http, encoding/json 등을 직접 사용해보세요.
  4. 에러 처리 습관화: if err != nil 패턴을 자연스럽게 받아들이세요.
  5. 고루틴 실험: 수백, 수천 개의 고루틴을 띄워보며 가벼움을 체감하세요.

추천 학습 자료

공식 자료:

스타일 가이드:

실전 프로젝트 아이디어:

  • 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[조건문 구조]

자주 하는 실수:

  1. defer를 루프 안에서 사용: 함수 종료 시에만 실행되므로 리소스 누수 발생
  2. 에러 무시: _로 에러를 무시하면 디버깅 어려움
  3. 루프 변수 클로저: 고루틴에서 루프 변수를 캡처할 때 주의
  4. 채널 닫기 누락: 수신자가 영원히 대기할 수 있음
  5. 포인터 vs 값 혼동: 메서드가 필드를 수정하는지에 따라 리시버 타입 선택

정리: 2주 후 당신이 할 수 있는 것

습득 가능한 핵심 스킬

이 커리큘럼을 완료하면 다음을 할 수 있습니다:

  • Go 문법 완전 이해: 변수, 함수, 구조체, 인터페이스
  • 메모리 관리: GC 환경에서의 효율적인 메모리 사용
  • 에러 처리: if err != nil 패턴과 에러 래핑
  • 동시성 프로그래밍: 고루틴과 채널을 활용한 병렬 처리
  • 테스트 작성: 유닛 테스트와 벤치마크
  • REST API 서버: 실무에 바로 적용 가능한 웹 서버 구축
  • 의존성 관리: Go Modules로 라이브러리 관리

C++에서 Go로 전환 체크리스트

메모리·리소스:

  • GC가 메모리를 관리한다는 사실 수용
  • 파일·락 등은 defer로 정리
  • 루프 안에서는 명시적 Close 호출

타입·다형성:

  • 인터페이스는 메서드 집합일 뿐
  • 명시적 implements 없이 덕 타이핑
  • 제네릭보다 인터페이스 우선 고려

에러 처리:

  • 예외 대신 error 반환
  • fmt.Errorf로 에러 래핑
  • panic은 복구 불가능한 경우만

동시성:

  • 채널 우선, Mutex는 필요 시
  • 루프 변수는 인자로 전달
  • 채널 사용 후 close 호출

다음 단계

2주 커리큘럼을 완료했다면:

  1. 실전 프로젝트: 기존 C++ 프로젝트의 일부를 Go로 재작성
  2. 고급 패턴: Context, 미들웨어, 의존성 주입
  3. 성능 최적화: 프로파일링, 메모리 최적화
  4. 프레임워크: Gin, Echo 등 웹 프레임워크 학습
  5. 클라우드: Docker, Kubernetes와 Go 통합

마무리

C++의 복잡함에서 Go의 심플함으로 전환하는 것은 단순히 새로운 언어를 배우는 것 이상입니다. **"적게 쓰고 많이 얻는다"**는 Go의 철학을 체득하면, 더 빠르고 안전한 코드를 작성할 수 있습니다.

CMake 설정에 시간을 쏟는 대신 비즈니스 로직에 집중하고, 복잡한 스레드 동기화 대신 채널로 우아하게 통신하세요. 2주 후, 당신은 클라우드 네이티브 시대의 필수 언어를 마스터한 개발자가 되어 있을 것입니다.

다음 읽을 글: C++ 개발자의 뇌 구조로 이해하는 Go 언어에서 더 깊이 있는 개념 매핑을 확인하세요.

시리즈 시작하기: [Go 2주 완성 #01] Day 1~2: Go 언어의 철학과 기본 문법부터 시작하세요!

시리즈 전체 목차: Go 2주 완성 시리즈 인덱스에서 전체 글 목록과 추천 학습 경로를 확인하세요.


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


자주 묻는 질문 (FAQ)

Q. C++ 경험이 많으면 Go를 더 빨리 배울 수 있나요?

A. 네. 포인터, 메모리 관리, 동시성 개념에 익숙하다면 Go의 개념을 빠르게 이해할 수 있습니다. 다만 C++의 일부 습관(예외 처리, 상속)은 의도적으로 버려야 합니다.

Q. Go 제네릭이 없다면 타입 안전성은 어떻게 보장하나요?

A. Go 1.18부터 제네릭이 추가되었습니다. 그 전에는 인터페이스와 타입 단언으로 처리했습니다. 현재는 [T any] 같은 타입 파라미터를 사용할 수 있습니다.

Q. 실무에서 Go는 어떤 분야에 많이 쓰이나요?

A. 클라우드 인프라(Docker, Kubernetes), 마이크로서비스, API 서버, CLI 도구, DevOps 도구에 널리 사용됩니다. Google, Uber, Netflix 등에서 대규모로 활용 중입니다.

Q. C++보다 Go가 느리지 않나요?

A. 단일 스레드 성능은 C++이 우수하지만, 동시성 처리가 필요한 서버 애플리케이션에서는 Go가 더 효율적일 수 있습니다. GC 오버헤드는 있지만, 개발 생산성과 안전성을 고려하면 충분히 합리적인 트레이드오프입니다.

관련 글