[Go 2주 완성 #05] Day 8~9: 예외 처리의 새로운 접근 - try-catch는 잊어라

[Go 2주 완성 #05] Day 8~9: 예외 처리의 새로운 접근 - try-catch는 잊어라

이 글의 핵심

try-catch 대신 error 반환과 if err != nil, fmt.Errorf 래핑, defer로 자원 해제, panic·recover의 올바른 쓰임을 C++ RAII·예외와 비교합니다. Day 8~9입니다.

시리즈 안내

📚 Go 2주 완성 시리즈 #05 | 전체 목차 보기

이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼Day 8~9 내용입니다.

이전: #04 인터페이스 ← | → 다음: #06 고루틴·채널


들어가며: 명시적 에러 처리의 철학

C++에서는 try-catch로 에러를 한 곳에서 처리했습니다. 편리하지만, 어디서 예외가 발생할지 코드만 봐서는 알기 어렵습니다. Go는 예외를 사용하지 않습니다. 대신 에러를 값으로 반환하고, 호출자가 명시적으로 처리합니다. 처음에는 번거로워 보이지만, 코드의 흐름이 명확해지고 예상치 못한 예외로 프로그램이 죽는 일이 줄어듭니다.

이 글에서 배울 내용:

  • 다중 반환값으로 에러 전달
  • if err != nil 패턴
  • defer로 자원 해제 보장
  • errors.New·fmt.Errorf·%w, Sentinel, errors.Is/As
  • panicrecover의 올바른 사용

실무에서의 체감

C++ 위주로 서버를 다루던 환경에서 Go를 도입할 때 흔히 드는 인상은 문법과 툴체인이 단순해 보인다는 점입니다. 프로덕션에서는 그 단순함이 빌드·배포·동시성 코드 가독성으로 이어지는 경우가 많습니다.

자주 언급되는 장점:

  • 개발 속도: 팀·도메인에 따라 다르지만, 네트워크·CLI 코드를 빠르게 완성하기 쉬운 편입니다.
  • 안정성: GC가 있어 수동 할당 해제 부담이 줄어듭니다.
  • 배포: 단일 바이너리로 옮기기 쉬운 구조입니다.

목차

  1. 에러를 값으로: error 타입
  2. if err != nil 패턴
  3. defer: RAII의 대체재
  4. 에러 래핑과 언래핑
  5. errors.New vs fmt.Errorf, Sentinel, 실전 래핑
  6. panic과 recover
  7. 실습 과제

1. 에러를 값으로: error 타입

C++ vs Go: 에러 처리 방식

// C++: 예외로 에러 처리
#include <iostream>
#include <fstream>
#include <stdexcept>

void readFile(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 found");
        }
        std::cout << line << "\n";
    }
}

int main() {
    try {
        readFile("data.txt");
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << "\n";
        return 1;
    }
    return 0;
}
// Go: error 반환으로 에러 처리
package main

import (
    "bufio"
    "fmt"
    "os"
)

func readFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("failed to open file: %w", err)
    }
    defer file.Close()
    
    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := scanner.Text()
        if line == "" {
            return fmt.Errorf("empty line found")
        }
        fmt.Println(line)
    }
    
    if err := scanner.Err(); err != nil {
        return fmt.Errorf("scan error: %w", err)
    }
    
    return nil
}

func main() {
    if err := readFile("data.txt"); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

핵심 차이점:

  • Go는 예외를 던지지 않습니다
  • 에러는 반환값으로 전달됩니다
  • 호출자는 반드시 에러를 확인해야 합니다

error 인터페이스

// Go: error는 인터페이스
type error interface {
    Error() string
}

// 커스텀 에러 타입
type FileError struct {
    Path string
    Op   string
    Err  error
}

func (e *FileError) Error() string {
    return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err)
}

// 사용
func openFile(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return &FileError{
            Path: path,
            Op:   "open",
            Err:  err,
        }
    }
    return nil
}

2. if err != nil 패턴

기본 패턴

// Go: 가장 흔한 패턴
func doSomething() error {
    result, err := someOperation()
    if err != nil {
        return err  // 에러 전파
    }
    
    // result 사용
    return nil
}

에러 처리 변형

// Go: 다양한 에러 처리 패턴
package main

import (
    "fmt"
    "os"
)

// 1. 즉시 반환
func pattern1() error {
    f, err := os.Open("file.txt")
    if err != nil {
        return err
    }
    defer f.Close()
    // ...
    return nil
}

// 2. 로깅 후 반환
func pattern2() error {
    f, err := os.Open("file.txt")
    if err != nil {
        fmt.Println("Failed to open file:", err)
        return err
    }
    defer f.Close()
    // ...
    return nil
}

// 3. 기본값 사용
func pattern3() int {
    data, err := fetchData()
    if err != nil {
        return 0  // 기본값
    }
    return data
}

// 4. 재시도
func pattern4() error {
    maxRetries := 3
    for i := 0; i < maxRetries; i++ {
        err := tryOperation()
        if err == nil {
            return nil
        }
        fmt.Printf("Attempt %d failed: %v\n", i+1, err)
    }
    return fmt.Errorf("failed after %d retries", maxRetries)
}

3. defer: RAII의 대체재

C++ RAII vs Go defer

// C++: RAII로 자동 정리
void processFile() {
    std::ifstream file("data.txt");
    std::lock_guard<std::mutex> lock(mtx);
    
    // 작업...
    
    // 스코프 종료 시 자동으로:
    // 1. lock 해제 (lock_guard 소멸자)
    // 2. file 닫기 (ifstream 소멸자)
}
// Go: defer로 명시적 정리
func processFile() error {
    file, err := os.Open("data.txt")
    if err != nil {
        return err
    }
    defer file.Close()  // 함수 종료 시 실행
    
    mu.Lock()
    defer mu.Unlock()   // 함수 종료 시 실행
    
    // 작업...
    
    return nil
    // 여기서 defer들이 역순으로 실행:
    // 1. mu.Unlock()
    // 2. file.Close()
}

defer의 실행 순서 (LIFO)

// Go: defer는 LIFO (Last In First Out)
package main

import "fmt"

func example() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
    
    fmt.Println("함수 본문")
}

func main() {
    example()
}

// 출력:
// 함수 본문
// 3
// 2
// 1

defer의 평가 시점

// Go: defer의 인자는 즉시 평가됨
package main

import "fmt"

func example() {
    x := 1
    defer fmt.Println("Deferred:", x)  // x=1이 즉시 평가됨
    
    x = 2
    fmt.Println("Current:", x)
}

func main() {
    example()
}

// 출력:
// Current: 2
// Deferred: 1  (defer 등록 시점의 값)

함수 호출로 지연 평가:

// Go: 함수로 감싸서 지연 평가
func example() {
    x := 1
    defer func() {
        fmt.Println("Deferred:", x)  // 실행 시점의 x 사용
    }()
    
    x = 2
    fmt.Println("Current:", x)
}

// 출력:
// Current: 2
// Deferred: 2  (실행 시점의 값)

defer 활용 패턴

// Go: defer 실전 패턴
package main

import (
    "fmt"
    "time"
)

// 1. 실행 시간 측정
func measureTime(name string) func() {
    start := time.Now()
    return func() {
        fmt.Printf("%s took %v\n", name, time.Since(start))
    }
}

func slowFunction() {
    defer measureTime("slowFunction")()  // 함수 반환값(함수)를 defer
    
    time.Sleep(100 * time.Millisecond)
}

// 2. 락 보호
func criticalSection() {
    mu.Lock()
    defer mu.Unlock()
    
    // 여러 return 경로가 있어도 unlock 보장
    if condition1 {
        return
    }
    if condition2 {
        return
    }
    // ...
}

// 3. 트랜잭션 롤백
func transaction() error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }
    
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()
    
    // 작업...
    if err := doWork(tx); err != nil {
        return err  // defer에서 Rollback 실행
    }
    
    return tx.Commit()
}

4. 에러 래핑과 언래핑

에러 래핑 (Error Wrapping)

// Go: 에러 래핑으로 컨텍스트 추가
package main

import (
    "fmt"
    "os"
)

func readConfig(path string) error {
    data, err := os.ReadFile(path)
    if err != nil {
        // %w로 원본 에러 래핑
        return fmt.Errorf("read config from %s: %w", path, err)
    }
    
    if len(data) == 0 {
        return fmt.Errorf("config file %s is empty", path)
    }
    
    return nil
}

func loadSettings() error {
    if err := readConfig("config.json"); err != nil {
        return fmt.Errorf("load settings: %w", err)
    }
    return nil
}

func main() {
    if err := loadSettings(); err != nil {
        fmt.Println("Error:", err)
        // 출력: Error: load settings: read config from config.json: open config.json: no such file or directory
    }
}

errors.Is와 errors.As

// Go: 특정 에러 확인
package main

import (
    "errors"
    "fmt"
    "os"
)

var (
    ErrNotFound    = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
)

func findUser(id int) error {
    if id < 0 {
        return ErrNotFound
    }
    return nil
}

func main() {
    err := findUser(-1)
    
    // errors.Is로 에러 확인
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User not found")
    }
}
// Go: errors.As로 타입 확인
package main

import (
    "errors"
    "fmt"
    "os"
)

func openFile(path string) error {
    _, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("open file: %w", err)
    }
    return nil
}

func main() {
    err := openFile("nonexistent.txt")
    
    // errors.As로 특정 타입 추출
    var pathErr *os.PathError
    if errors.As(err, &pathErr) {
        fmt.Println("Path error:")
        fmt.Println("  Op:", pathErr.Op)
        fmt.Println("  Path:", pathErr.Path)
        fmt.Println("  Err:", pathErr.Err)
    }
}

fmt.Errorf%w를 쓰면 에러 체인이 만들어져, 여러 겹 감싼 뒤에도 errors.Is(err, os.ErrNotExist)처럼 근본 원인을 찾을 수 있습니다. 반면 %v만 쓰면 문자열로만 붙고 Is/As가 따라가지 못합니다.


5. errors.New vs fmt.Errorf, Sentinel, 실전 래핑

errors.New vs fmt.Errorf

API쓰는 때메모
errors.New("msg")문맥 없는 고정 메시지의 단순 에러, Sentinel로 패키지 전역 변수에 두고 errors.Is로 비교포맷 불가 — 문자열 하나
fmt.Errorf("format", args...)동적 값을 문자열에 넣을 때포맷만 하고 끝내면 래핑 아님
fmt.Errorf("...: %w", err)원인 에러를 체인에 남길 때(Go 1.13+)Unwrap 가능 → errors.Is/As가 안쪽까지 따라감
package example

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found") // Sentinel — 같은 변수로 Is 비교

func badUser(id int) error {
    return fmt.Errorf("user %d not found", id) // OK이지만 Is(ErrNotFound)와 연결하려면 %w 필요
}

func goodUser(id int) error {
    return fmt.Errorf("user %d: %w", id, ErrNotFound) // 체인에 ErrNotFound 포함
}

커스텀 에러 타입과 errors.As

  • errors.Is값이 같은지(Sentinel 또는 Unwrap 체인) 볼 때 쓰고,
  • errors.As구체 타입으로 필드(예: HTTP 상태 코드, 에러 코드)를 꺼낼 때 씁니다.
  • 커스텀 타입은 Error() string만 구현해도 되고, 필요하면 Unwrap() error를 추가해 한 겹 더 감쌀 수 있습니다.
package example

import "fmt"

type AppError struct {
    Code    int
    Message string
    Err     error
}

func (e *AppError) Error() string {
    return fmt.Sprintf("code=%d: %s", e.Code, e.Message)
}

func (e *AppError) Unwrap() error { return e.Err }

Sentinel 에러(전역 비교용) 실무 패턴

  • 패키지 상단에 var ErrXxx = errors.New(...) 로 선언해 의미를 고정합니다.
  • 호출부는 **errors.Is(err, pkg.ErrNotFound)처럼 비교하고, 중간 함수들은 fmt.Errorf("context: %w", err)**로 맥락만 덧붙입니다.
  • 같은 문장 문자열을 여러 번 errors.New로 만들면 Is가 실패합니다. 반드시 공유된 변수를 쓰세요.

에러 래핑 실전 체크리스트

  1. 감싸기: 실패 지점에서 return fmt.Errorf("작업 X: %w", err).
  2. 판별: 바깥에서는 errors.Is / errors.As만 사용(문자열 Contains 비교는 최후 수단).
  3. 로그: 최종적으로 사용자·운영에 남길 때 한 번만 상세 스택이 필요한지 팀 규칙으로 정합니다.

6. panic과 recover

panic: 복구 불가능한 오류

// C++: 예외 던지기
void divide(int a, int b) {
    if (b == 0) {
        throw std::invalid_argument("division by zero");
    }
    std::cout << a / b << "\n";
}
// Go: panic (일반적으로 지양)
func divide(a, b int) int {
    if b == 0 {
        panic("division by zero")  // 프로그램 종료
    }
    return a / b
}

// ✅ 권장: error 반환
func divideWithError(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

recover: panic 복구

// Go: recover로 panic 복구
package main

import "fmt"

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()
    
    result = a / b  // b=0이면 panic
    return result, nil
}

func main() {
    result, err := safeDivide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
    } else {
        fmt.Println("Result:", result)
    }
}

panic/recover는 언제 쓰나

상황권장
일반적인 실패(파일 없음, 네트워크 타임아웃, 잘못된 입력)error 반환
프로그래밍 오류(불변식 위반, nil 역참조, 도달하면 안 되는 분기)패닉 가능 — 가능하면 테스트로 걸러냄
초기화 시 반드시 성공해야 하는 값(정규식 MustCompile, template.Must)panic 허용되는 관용구
HTTP 서버 등에서 한 요청만 격리recover고루틴 경계에서만 의미 있음 — 미들웨어·핸들러 상단 패턴(#06 이후와 연결)

recover지연 함수(defer) 안에서만 동작합니다. “조용히 삼키기”보다는 로그 남기고 error로 변환하거나 요청 단위로 500을 반환하는 식이 일반적입니다.

panic 사용 가이드

// ❌ 나쁜 예: 일반 에러에 panic 사용
func fetchUser(id int) *User {
    user, err := db.Query(id)
    if err != nil {
        panic(err)  // 나쁨!
    }
    return user
}

// ✅ 좋은 예: error 반환
func fetchUser(id int) (*User, error) {
    user, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("fetch user %d: %w", id, err)
    }
    return user, nil
}

// ✅ panic이 적절한 경우: 프로그래밍 오류
func mustCompile(pattern string) *regexp.Regexp {
    re, err := regexp.Compile(pattern)
    if err != nil {
        panic(fmt.Sprintf("invalid regex pattern: %s", pattern))
    }
    return re
}

// 초기화 시 사용 (프로그램 시작 시 실패해야 함)
var emailRegex = mustCompile(`^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$`)

7. 실습 과제

과제 1: 파일 복사 with defer

// Go: defer로 안전한 파일 복사
package main

import (
    "fmt"
    "io"
    "os"
)

func copyFile(src, dst string) error {
    // 원본 파일 열기
    srcFile, err := os.Open(src)
    if err != nil {
        return fmt.Errorf("open source: %w", err)
    }
    defer srcFile.Close()  // 반드시 닫힘
    
    // 대상 파일 생성
    dstFile, err := os.Create(dst)
    if err != nil {
        return fmt.Errorf("create destination: %w", err)
    }
    defer dstFile.Close()  // 반드시 닫힘
    
    // 복사
    _, err = io.Copy(dstFile, srcFile)
    if err != nil {
        return fmt.Errorf("copy: %w", err)
    }
    
    return nil
}

func main() {
    if err := copyFile("source.txt", "dest.txt"); err != nil {
        fmt.Fprintf(os.Stderr, "Copy failed: %v\n", err)
        os.Exit(1)
    }
    fmt.Println("Copy successful")
}

과제 2: 커스텀 에러 타입

// Go: 커스텀 에러 타입
package main

import (
    "errors"
    "fmt"
)

type ValidationError struct {
    Field   string
    Value   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error [%s=%s]: %s", 
        e.Field, e.Value, e.Message)
}

func validateEmail(email string) error {
    if email == "" {
        return &ValidationError{
            Field:   "email",
            Value:   email,
            Message: "email is required",
        }
    }
    
    if !strings.Contains(email, "@") {
        return &ValidationError{
            Field:   "email",
            Value:   email,
            Message: "invalid email format",
        }
    }
    
    return nil
}

func registerUser(email string) error {
    if err := validateEmail(email); err != nil {
        return fmt.Errorf("register user: %w", err)
    }
    
    // 등록 로직...
    return nil
}

func main() {
    err := registerUser("invalid")
    
    // 타입 확인
    var validationErr *ValidationError
    if errors.As(err, &validationErr) {
        fmt.Println("Validation failed:")
        fmt.Println("  Field:", validationErr.Field)
        fmt.Println("  Value:", validationErr.Value)
        fmt.Println("  Message:", validationErr.Message)
    }
}

과제 3: 에러 체인

// Go: 에러 체인 추적
package main

import (
    "errors"
    "fmt"
)

var (
    ErrDatabase   = errors.New("database error")
    ErrConnection = errors.New("connection error")
)

func connectDB() error {
    return ErrConnection
}

func queryUser(id int) error {
    if err := connectDB(); err != nil {
        return fmt.Errorf("query user %d: %w", id, err)
    }
    return nil
}

func getProfile(id int) error {
    if err := queryUser(id); err != nil {
        return fmt.Errorf("get profile: %w", err)
    }
    return nil
}

func main() {
    err := getProfile(123)
    
    fmt.Println("Error:", err)
    // 출력: Error: get profile: query user 123: connection error
    
    // 원본 에러 확인
    if errors.Is(err, ErrConnection) {
        fmt.Println("Root cause: connection error")
    }
}

과제 4: defer로 실행 시간 측정

// Go: defer로 함수 실행 시간 측정
package main

import (
    "fmt"
    "time"
)

func trace(name string) func() {
    start := time.Now()
    fmt.Printf("Entering %s\n", name)
    
    return func() {
        fmt.Printf("Exiting %s (took %v)\n", name, time.Since(start))
    }
}

func processData() {
    defer trace("processData")()
    
    fmt.Println("Processing...")
    time.Sleep(100 * time.Millisecond)
    fmt.Println("Done")
}

func main() {
    processData()
}

// 출력:
// Entering processData
// Processing...
// Done
// Exiting processData (took 100ms)

정리: Day 8~9 학습 체크리스트

완료해야 할 항목

  • error 타입과 if err != nil 패턴 숙지
  • 다중 반환값으로 에러 전달
  • defer로 자원 해제 보장 (RAII 대체)
  • defer의 LIFO 실행 순서 이해
  • fmt.Errorf%w로 에러 래핑
  • errors.New(Sentinel) vs fmt.Errorf·%w 구분
  • errors.Is, errors.As로 에러 검사
  • panic/recover는 제한적으로만 사용
  • 실습 과제 4개 완료

C++에서 Go로 전환 포인트

C++Go비고
throwreturn error명시적
try-catchif err != nil매 호출마다
RAIIdefer함수 단위
예외 전파에러 반환 체인명확한 흐름
std::exceptionerror 인터페이스더 간결

다음 단계 예고

Day 8~9에서는 Go의 에러 처리를 배웠습니다. 다음 글에서는 Go의 진짜 강점인 고루틴과 채널을 다룹니다. C++ 개발자가 Go에 열광하는 이유를 직접 체험하게 됩니다!


📚 시리즈 네비게이션

이전 글목차다음 글
← #04 인터페이스📑 전체 목차#06 고루틴·채널 →

Go 2주 완성 시리즈: 커리큘럼#01 기본 문법#02 자료구조#03 객체지향#04 인터페이스#05 에러 처리#06 고루틴·채널#07 테스팅#08 REST API#09 context·우아한 종료


한 줄 요약: Go는 예외 대신 명시적 에러 반환으로 코드 흐름을 명확하게 하고, defer로 자원 해제를 보장합니다.

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

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

  • [Go 2주 완성 #04] Day 7: 다형성의 재해석, 인터페이스 - 가상 함수 없이 다형성 구현하기
  • C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
  • C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지

이 글에서 다루는 키워드 (관련 검색어)

Go error 처리, if err != nil, defer Go, panic recover, fmt.Errorf, C++ 예외 비교, Golang 에러 핸들링, Go 2주 완성 등으로 검색하시면 이 글이 도움이 됩니다.

실전 팁

실무에서 바로 적용할 수 있는 팁입니다.

디버깅 팁

  • 문제가 발생하면 먼저 컴파일러 경고를 확인하세요
  • 간단한 테스트 케이스로 문제를 재현하세요

성능 팁

  • 프로파일링 없이 최적화하지 마세요
  • 측정 가능한 지표를 먼저 설정하세요

코드 리뷰 팁

  • 코드 리뷰에서 자주 지적받는 부분을 미리 체크하세요
  • 팀의 코딩 컨벤션을 따르세요

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ try-catch 대신 Go의 명시적 에러 핸들링을 배웁니다. 다중 반환값, if err != nil 패턴, defer로 자원 해제, panic/recover의 올바른 사용법까지 실전 예제로 학습합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.


관련 글

  • C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
  • C++ vs Go | 성능·동시성·선택 가이드 완전 비교 [#47-1]
  • C++ 개발자의 뇌 구조로 이해하는 Go 언어 [#47-2]
  • [Go 2주 완성 #01] Day 1~2: Go 언어의 철학과 기본 문법 - C++ 개발자의 첫인상
  • [Go 2주 완성 #02] Day 3~4: 메모리와 자료구조 - 포인터 연산은 없지만 포인터는 있다