[Go 2주 완성 #03] Day 5~6: 클래스 없는 객체지향 - 상속을 버리고 합성을 취하다

이 글의 핵심

Go는 상속보다 합성을 선호하라는 원칙을 언어 차원에서 강제합니다. 이 글은 시리즈 세 번째 편으로 C++의 클래스 계층을 구조체 임베딩으로 옮길 때 무엇이 달라지는지, 리시버 종류에 따라 원본이 바뀌는지 여부, 옵션이 많은 타입의 생성자를 설계하는 법을 실습 과제와 함께 설명합니다.

시리즈 안내

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

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

이전: #02 자료구조 ← | → 다음: #04 인터페이스


💡 초보자를 위한 한 줄: Go에는 class 키워드가 없습니다. struct + 메서드로 객체를 만듭니다. 상속도 없습니다. 대신 구조체 임베딩(작은 struct를 큰 struct에 끼워 넣기)으로 코드를 재사용합니다. 메서드의 (p *Person)을 리시버라고 하며, 포인터 리시버는 필드 수정 가능, 값 리시버는 복사본으로 동작합니다.

들어가며: “클래스가 없는데 어떻게 객체지향을 하죠?”

C++에서는 class로 데이터와 메서드를 묶으며, 상속으로 코드를 재사용했습니다:

class Animal {
public:
    virtual void Speak() { cout << "..."; }
};

class Dog : public Animal {  // ← 상속
public:
    void Speak() override { cout << "Woof!"; }
};

Go는 완전히 다릅니다. 클래스도 없고 상속도 없습니다:

type Animal struct {
    name string
}

type Dog struct {
    Animal  // ← 임베딩 (상속 아님!)
    breed string
}

대신 struct와 합성(Composition)(상속 대신 작은 타입을 조립·끼워 넣어 기능을 확장하는 방식)으로 더 심플하고 유연한 객체지향을 구현합니다. “상속보다 합성을 선호하라”는 디자인 원칙이 언어 차원에서 강제되는 셈입니다.

이 글에서 배울 내용:

  • struct로 데이터 정의하기
  • 메서드와 리시버의 개념
  • 포인터 리시버 vs 값 리시버
  • 구조체 임베딩으로 코드 재사용

C++ 개발자 관점: C++ 백그라운드에서 Go로 전환하며 겪은 차이점과 함정을 중심으로 설명합니다. 포인터, 동시성, 메모리 관리 등 핵심 개념을 비교하며 정리했습니다.

실무에서의 체감

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

자주 언급되는 장점:

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

구조체: 클래스의 대체재

C++ vs Go: 구조체 정의

// C++: 클래스
class Person {
private:
    std::string name;
    int age;
    
public:
    Person(const std::string& n, int a) : name(n), age(a) {}
    
    void SetName(const std::string& n) { name = n; }
    std::string GetName() const { return name; }
    
    void SetAge(int a) { age = a; }
    int GetAge() const { return age; }
    
    void Introduce() const {
        std::cout << "I'm " << name << ", " << age << " years old\n";
    }
};
// 사용
Person p("Alice", 30);
p.Introduce();
// Go: 구조체 + 메서드
// 패키지 선언
package main
import "fmt"
type Person struct {
    Name string  // 대문자 = public (패키지 외부 접근 가능)
    age  int     // 소문자 = private (패키지 내부만)
}
// 생성자 관례 (NewXxx 함수)
func NewPerson(name string, age int) *Person {
    return &Person{
        Name: name,
        age:  age,
    }
}
// Getter (관례: Get 접두사 생략)
func (p *Person) Age() int {
    return p.age
}
// Setter
func (p *Person) SetAge(age int) {
    p.age = age
}
// 메서드
func (p *Person) Introduce() {
    fmt.Printf("I'm %s, %d years old\n", p.Name, p.age)
}
// 사용
func main() {
    p := NewPerson("Alice", 30)
    p.Introduce()
}

핵심 차이점:

  • 접근 제어: 대문자 시작 = public, 소문자 = private (패키지 단위)
  • 생성자: NewXxx 함수가 관례 (강제 아님)
  • 메서드: 구조체 외부에 정의 (리시버 사용)

구조체 초기화 방법

// Go: 다양한 초기화 방법
package main
type Point struct {
    X, Y int
}
func main() {
    // 1. 필드 순서대로
    p1 := Point{10, 20}
    
    // 2. 필드명 지정 (권장)
    p2 := Point{X: 10, Y: 20}
    
    // 3. 일부만 초기화 (나머지는 제로 값)
    p3 := Point{X: 10}  // Y는 0
    
    // 4. 제로 값으로 초기화
    p4 := Point{}  // X=0, Y=0
    
    // 5. 포인터로 생성
    p5 := &Point{X: 10, Y: 20}
    
    // 6. new (제로 값으로 초기화)
    p6 := new(Point)  // &Point{} 와 동일

    _, _, _, _, _, _ = p1, p2, p3, p4, p5, p6  // 사용하지 않은 지역 변수는 컴파일 에러이므로
}

Go는 사용하지 않은 지역 변수를 컴파일 에러(declared and not used: p1)로 처리하기 때문에, 예제처럼 여러 값을 만들어 보기만 할 때는 마지막 줄처럼 빈 식별자 _에 대입해 둡니다. 초기화 방법 중에서는 필드 이름을 지정하는 2번을 기본으로 쓰는 것이 좋습니다. 1번처럼 순서대로 값을 나열하면 나중에 구조체에 필드가 추가되거나 순서가 바뀌었을 때 호출하는 모든 곳이 컴파일 에러가 나거나, 더 나쁘게는 같은 타입의 필드끼리 값이 뒤바뀐 채로 컴파일됩니다. go vet도 다른 패키지의 구조체를 필드 이름 없이 초기화하면 composite literal uses unkeyed fields 경고를 냅니다.

C++과 가장 다른 점은 제로 값이 곧 쓸 수 있는 상태가 되도록 설계하는 문화입니다. bytes.Buffer나 sync.Mutex는 생성자 없이 var b bytes.Buffer로 선언만 해도 바로 쓸 수 있습니다. 반대로 맵 필드는 제로 값이 nil이라, 생성자를 거치지 않은 구조체에서 s.cache["k"] = v를 하면 panic: assignment to entry in nil map이 납니다. 제로 값으로 쓸 수 없는 필드가 있다면 NewXxx 생성자를 두고, 그 사실을 문서 주석에 적어 두는 것이 관례입니다.


메서드와 리시버

Go의 메서드는 리시버(Receiver)를 가진 함수입니다. C++의 this 포인터와 유사하지만 명시적입니다.

C++ vs Go: 메서드 정의

// C++: 클래스 내부에 메서드 정의
class Counter {
private:
    int count;
    
public:
    Counter() : count(0) {}
    
    void Increment() {
        count++;  // this->count++와 동일
    }
    
    int GetCount() const {
        return count;
    }
};
// Go: 구조체 외부에 메서드 정의
package main
type Counter struct {
    count int
}
// 생성자
func NewCounter() *Counter {
    return &Counter{count: 0}
}
// 메서드 (포인터 리시버)
func (c *Counter) Increment() {
    c.count++  // (*c).count++ 와 동일 (자동 역참조)
}
// 메서드 (포인터 리시버, 읽기 전용이지만 일관성 위해)
func (c *Counter) GetCount() int {
    return c.count
}

리시버 문법: func (receiver Type) MethodName() { }

C++의 멤버 함수와 달리 메서드는 구조체 정의 밖, 그리고 같은 패키지 안이면 어느 파일에든 정의할 수 있습니다. 대신 다른 패키지의 타입(예: time.Time이나 int)에는 메서드를 추가할 수 없어서, 필요하면 type MyTime time.Time처럼 새 타입을 정의한 뒤 거기에 메서드를 붙입니다. 리시버 이름은 this나 self 대신 타입 이름의 첫 글자(c, p)처럼 짧게 짓는 것이 Go 커뮤니티의 관례이며, 린터도 this를 쓰면 지적합니다.


포인터 리시버 vs 값 리시버

값 리시버 (Value Receiver)

// Go: 값 리시버 - 복사본에서 동작
type Point struct {
    X, Y int
}
func (p Point) Distance() float64 {
    return math.Sqrt(float64(p.X*p.X + p.Y*p.Y))
}
func (p Point) Move(dx, dy int) {
    p.X += dx  // ❌ 원본 변경 안 됨 (복사본 수정)
    p.Y += dy
}
func main() {
    p := Point{3, 4}
    fmt.Println(p.Distance())  // 5
    
    p.Move(10, 10)
    fmt.Println(p)  // {3 4} - 변경 안 됨!
}

포인터 리시버 (Pointer Receiver)

// Go: 포인터 리시버 - 원본 수정
type Point struct {
    X, Y int
}
func (p *Point) Move(dx, dy int) {
    p.X += dx  // ✅ 원본 변경됨
    p.Y += dy
}
func (p *Point) Reset() {
    p.X = 0
    p.Y = 0
}
func main() {
    p := Point{3, 4}
    p.Move(10, 10)      // Go가 자동으로 &p로 변환
    fmt.Println(p)      // {13 14} - 변경됨!
    
    // 포인터로 생성해도 동일
    p2 := &Point{1, 2}
    p2.Move(5, 5)       // Go가 자동 처리
    fmt.Println(*p2)    // {6 7}
}

리시버 선택 가이드

flowchart TD
    A[메서드 정의] --> B{필드 수정?}
    B -->|Yes| C[포인터 리시버 *T]
    B -->|No| D{구조체 크기}
    D -->|큰 구조체\n64바이트 이상| C
    D -->|작은 구조체| E{일관성}
    E -->|다른 메서드가\n포인터 리시버| C
    E -->|모두 값 리시버| F[값 리시버 T]

권장 사항:

  • 포인터 리시버 사용 시기:
    • 메서드가 필드를 수정할 때
    • 구조체가 클 때 (복사 비용 절감)
    • 일관성 유지 (한 타입의 메서드는 모두 같은 리시버 타입)
  • 값 리시버 사용 시기:
    • 읽기 전용 메서드
    • 작은 구조체 (int, bool, 작은 struct)
    • 불변성(Immutability)이 중요할 때

리시버 선택이 단순한 성능 문제가 아닌 이유는 메서드 집합(method set) 규칙 때문입니다. T 값의 메서드 집합에는 값 리시버 메서드만 들어가고, *T의 메서드 집합에는 값·포인터 리시버 메서드가 모두 들어갑니다. p.Move(10, 10)처럼 주소를 얻을 수 있는 변수에서 호출할 때는 Go가 (&p).Move로 자동 변환해 주므로 차이가 드러나지 않지만, 인터페이스에 값을 넣을 때는 자동 변환이 없습니다. 다음 글에서 다룰 인터페이스를 쓸 때 가장 먼저 만나는 에러가 이것입니다.

cannot use p (variable of type Point) as Mover value in variable declaration:
Point does not implement Mover (method Move has pointer receiver)

var m Mover = &p처럼 포인터를 넣으면 해결됩니다. 맵의 값(m["a"].Move(1, 1))처럼 주소를 얻을 수 없는 값에서 포인터 리시버 메서드를 부르면 cannot call pointer method Move on Point 에러가 나는 것도 같은 규칙에서 나옵니다.

값 리시버에는 복사와 관련된 함정도 있습니다. 구조체에 sync.Mutex 필드가 있는데 값 리시버 메서드에서 Lock()을 호출하면, 복사된 뮤텍스를 잠그는 셈이라 아무것도 보호하지 못합니다. 컴파일은 되지만 go vet이 passes lock by value: Counter contains sync.Mutex로 경고합니다. 제 경험상 C++에서 넘어온 개발자가 “읽기 전용이니까 값 리시버”로 바꿨다가 이런 문제를 만드는 경우가 흔해서, 한 타입에 포인터 리시버 메서드가 하나라도 있으면 전부 포인터 리시버로 통일하는 규칙을 두는 편이 안전했습니다.


구조체 임베딩: 상속 없는 재사용

C++ vs Go: 상속 vs 합성

// C++: 상속으로 코드 재사용
class Animal {
protected:
    std::string name;
    
public:
    Animal(const std::string& n) : name(n) {}
    
    virtual void Speak() {
        std::cout << "Some sound\n";
    }
    
    void Eat() {
        std::cout << name << " is eating\n";
    }
};
class Dog : public Animal {
public:
    Dog(const std::string& n) : Animal(n) {}
    
    void Speak() override {
        std::cout << "Woof!\n";
    }
    
    void Fetch() {
        std::cout << name << " is fetching\n";
    }
};
// 사용
Dog d("Buddy");
d.Speak();  // "Woof!"
d.Eat();    // "Buddy is eating"
d.Fetch();
// Go: 임베딩으로 코드 재사용
// 패키지 선언
package main
import "fmt"
type Animal struct {
    Name string
}
func (a Animal) Speak() {
    fmt.Println("Some sound")
}
func (a Animal) Eat() {
    fmt.Printf("%s is eating\n", a.Name)
}
type Dog struct {
    Animal  // 임베딩 - Animal의 필드와 메서드가 Dog에 포함됨
    Breed string
}
// Dog의 고유 메서드
func (d Dog) Fetch() {
    fmt.Printf("%s is fetching\n", d.Name)  // Animal.Name 직접 접근
}
// Animal.Speak 오버라이드
func (d Dog) Speak() {
    fmt.Println("Woof!")
}
// 사용
func main() {
    d := Dog{
        Animal: Animal{Name: "Buddy"},
        Breed:  "Golden Retriever",
    }
    
    d.Speak()  // "Woof!" (오버라이드됨)
    d.Eat()    // "Buddy is eating" (Animal의 메서드)
    d.Fetch()  // "Buddy is fetching"
}

핵심 차이점:

  • Go는 상속이 없습니다
  • 임베딩: 다른 구조체를 필드로 포함하면, 그 구조체의 메서드가 자동으로 “승격”됩니다
  • 오버라이드: 같은 이름의 메서드를 정의하면 임베딩된 메서드를 가립니다

“가린다(shadowing)“와 C++의 virtual 오버라이드는 결정적으로 다릅니다. 예를 들어 Animal에 func (a Animal) Introduce() { a.Speak() }가 있다면, d.Introduce()를 호출해도 출력은 "Woof!"가 아니라 "Some sound"입니다. Introduce의 리시버는 Dog가 아니라 Dog 안에 들어 있는 Animal 값이고, Go에는 “실제 객체가 Dog라는 것을 기억했다가 Dog.Speak로 보내는” 가상 함수 테이블이 없기 때문입니다. 템플릿 메서드 패턴처럼 “부모 메서드가 자식이 재정의한 메서드를 호출하는” C++ 설계를 그대로 옮기면 이 지점에서 조용히 동작이 바뀝니다. Go에서 이런 구조가 필요하면 Introduce(s Speaker)처럼 인터페이스를 인자로 받거나, 구조체 필드로 인터페이스를 들고 있게 만듭니다.

임베딩은 is-a 관계를 만들지 않는다는 점도 기억해야 합니다. Dog를 Animal 타입 매개변수를 받는 함수에 넘기면 cannot use d (variable of type Dog) as Animal value로 컴파일되지 않고, d.Animal처럼 안쪽 값을 명시적으로 꺼내 넘겨야 합니다. 여러 타입을 한 가지로 다루는 일은 전부 인터페이스의 몫이며, 임베딩은 “필드와 메서드를 편하게 가져다 쓰는 문법”에 가깝습니다.

임베딩의 동작 원리

// Go: 임베딩 상세
package main
import "fmt"
type Base struct {
    Value int
}
func (b Base) Print() {
    fmt.Println("Base:", b.Value)
}
type Derived struct {
    Base  // 임베딩
    Extra string
}
func main() {
    d := Derived{
        Base:  Base{Value: 42},
        Extra: "additional",
    }
    
    // Base의 메서드 직접 호출
    d.Print()  // "Base: 42"
    
    // Base 필드 직접 접근
    fmt.Println(d.Value)  // 42 (d.Base.Value와 동일)
    
    // 명시적 접근도 가능
    d.Base.Print()
    fmt.Println(d.Base.Value)
}

다중 임베딩

// Go: 여러 구조체 임베딩
package main
import "fmt"
type Logger struct{}
func (l Logger) Log(msg string) {
    fmt.Println("[LOG]", msg)
}
type Validator struct{}
func (v Validator) Validate(data string) bool {
    return data != ""
}
type Service struct {
    Logger     // 임베딩 1
    Validator  // 임베딩 2
    name string
}
func (s *Service) Process(data string) {
    s.Log("Processing started")  // Logger의 메서드
    
    if !s.Validate(data) {       // Validator의 메서드
        s.Log("Invalid data")
        return
    }
    
    s.Log("Processing completed")
}
func main() {
    svc := &Service{name: "MyService"}
    svc.Process("test data")
}

여러 타입을 임베딩할 때 두 타입에 같은 이름의 메서드나 필드가 있으면 충돌이 납니다. 예를 들어 Logger와 Validator가 모두 Name() 메서드를 가지면, 정의 자체는 컴파일되지만 s.Name()을 호출하는 순간 ambiguous selector s.Name 에러가 납니다. s.Logger.Name()처럼 명시적으로 고르거나 Service에 Name()을 직접 정의해 가리면 됩니다. 또 포인터를 임베딩한 경우(*Logger)에는 그 필드가 nil인 상태에서 승격된 메서드를 부르면 panic: runtime error: invalid memory address or nil pointer dereference가 나므로, 생성자에서 반드시 채워 넣어야 합니다.

임베딩한 타입의 공개 메서드는 모두 바깥 타입의 공개 API가 된다는 점도 설계에 영향을 줍니다. Service에 sync.Mutex를 임베딩하면 svc.Lock()을 패키지 밖에서도 호출할 수 있게 되어, 내부 잠금 전략이 외부에 노출됩니다. 이런 경우에는 임베딩 대신 mu sync.Mutex처럼 이름 있는 비공개 필드로 두는 것이 일반적입니다.


생성자 패턴

Go에는 생성자가 없지만, NewXxx 함수를 만드는 것이 관례입니다.

C++ vs Go: 생성자

// C++: 생성자
class Database {
private:
    std::string host;
    int port;
    bool connected;
    
public:
    // 생성자
    Database(const std::string& h, int p) 
        : host(h), port(p), connected(false) {}
    
    // 기본 생성자
    Database() : Database("localhost", 5432) {}
    
    // 소멸자
    ~Database() {
        if (connected) {
            disconnect();
        }
    }
};
// Go: NewXxx 생성자 패턴
package main
type Database struct {
    host      string
    port      int
    connected bool
}
// 생성자 (포인터 반환이 관례)
func NewDatabase(host string, port int) *Database {
    return &Database{
        host:      host,
        port:      port,
        connected: false,
    }
}
// 기본값 생성자
func NewDefaultDatabase() *Database {
    return NewDatabase("localhost", 5432)
}
// 소멸자 없음 - defer로 정리
func (db *Database) Close() error {
    if db.connected {
        return db.disconnect()
    }
    return nil
}
// 사용
func main() {
    db := NewDatabase("localhost", 5432)
    defer db.Close()  // RAII 대용
    
    // 작업...
}

Functional Options 패턴

많은 옵션이 필요할 때 사용하는 고급 패턴입니다.

// Go: Functional Options 패턴
package main
import "time"
type Server struct {
    host    string
    port    int
    timeout time.Duration
    maxConn int
}
type Option func(*Server)
func WithHost(host string) Option {
    return func(s *Server) {
        s.host = host
    }
}
func WithPort(port int) Option {
    return func(s *Server) {
        s.port = port
    }
}
func WithTimeout(timeout time.Duration) Option {
    return func(s *Server) {
        s.timeout = timeout
    }
}
func NewServer(opts ...Option) *Server {
    // 기본값
    s := &Server{
        host:    "localhost",
        port:    8080,
        timeout: 30 * time.Second,
        maxConn: 100,
    }
    
    // 옵션 적용
    for _, opt := range opts {
        opt(s)
    }
    
    return s
}
// 사용
func main() {
    // 기본값 사용
    srv1 := NewServer()
    
    // 일부 옵션만 지정
    srv2 := NewServer(
        WithHost("0.0.0.0"),
        WithPort(9000),
    )
    
    // 모든 옵션 지정
    srv3 := NewServer(
        WithHost("192.168.1.1"),
        WithPort(3000),
        WithTimeout(60 * time.Second),
    )

    _, _, _ = srv1, srv2, srv3
}

Functional Options는 옵션이 많고 앞으로도 늘어날 공개 라이브러리의 생성자에서 특히 가치가 있습니다. 새 옵션을 추가해도 기존 호출 코드가 깨지지 않고, 기본값이 생성자 한 곳에 모여 있기 때문입니다. 대신 옵션마다 함수를 하나씩 만들어야 해서 코드가 늘어나고, 어떤 옵션이 있는지 문서 없이 알기 어렵다는 단점이 있습니다. 옵션이 서너 개뿐인 내부 코드라면 NewServer(cfg Config)처럼 설정 구조체를 넘기고 제로 값인 필드에 기본값을 채우는 방식이 더 단순합니다. 다만 설정 구조체 방식은 “0을 명시적으로 원한다”와 “지정하지 않았다”를 구분할 수 없다는 한계가 있어서, 타임아웃 0처럼 제로 값 자체가 의미를 갖는 옵션이 있다면 Functional Options가 더 정확합니다. 옵션 함수가 잘못된 값(음수 포트 등)을 받을 수 있다면 type Option func(*Server) error로 만들어 생성자가 에러를 돌려주게 하는 변형도 자주 쓰입니다.


실습 과제

과제 1: Rectangle 구조체

// Go: Rectangle 구조체와 메서드
package main
import "fmt"
type Rectangle struct {
    Width, Height float64
}
func NewRectangle(w, h float64) *Rectangle {
    return &Rectangle{Width: w, Height: h}
}
func (r *Rectangle) Area() float64 {
    return r.Width * r.Height
}
func (r *Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}
func (r *Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
}
func main() {
    rect := NewRectangle(10, 5)
    fmt.Printf("Area: %.2f\n", rect.Area())
    fmt.Printf("Perimeter: %.2f\n", rect.Perimeter())
    
    rect.Scale(2)
    fmt.Printf("After scaling - Area: %.2f\n", rect.Area())
}

과제 2: Stack 구현

// Go: Stack 자료구조 구현
package main
import (
    "errors"
    "fmt"
)
type Stack struct {
    items []int
}
func NewStack() *Stack {
    return &Stack{items: make([]int, 0)}
}
func (s *Stack) Push(item int) {
    s.items = append(s.items, item)
}
func (s *Stack) Pop() (int, error) {
    if len(s.items) == 0 {
        return 0, errors.New("stack is empty")
    }
    
    index := len(s.items) - 1
    item := s.items[index]
    s.items = s.items[:index]
    
    return item, nil
}
func (s *Stack) Peek() (int, error) {
    if len(s.items) == 0 {
        return 0, errors.New("stack is empty")
    }
    return s.items[len(s.items)-1], nil
}
func (s *Stack) IsEmpty() bool {
    return len(s.items) == 0
}
func (s *Stack) Size() int {
    return len(s.items)
}
func main() {
    stack := NewStack()
    
    stack.Push(1)
    stack.Push(2)
    stack.Push(3)
    
    fmt.Println("Size:", stack.Size())  // 3
    
    if top, err := stack.Peek(); err == nil {
        fmt.Println("Top:", top)  // 3
    }
    
    for !stack.IsEmpty() {
        if item, err := stack.Pop(); err == nil {
            fmt.Println("Popped:", item)
        }
    }
}

과제 3: 임베딩 활용

// Go: 임베딩으로 기능 확장
package main
import (
    "fmt"
    "time"
)
// 기본 타이머
type Timer struct {
    start time.Time
}
func (t *Timer) Start() {
    t.start = time.Now()
}
func (t *Timer) Elapsed() time.Duration {
    return time.Since(t.start)
}
// 로깅 기능 추가
type Logger struct {
    prefix string
}
func (l Logger) Log(msg string) {
    fmt.Printf("[%s] %s\n", l.prefix, msg)
}
// Timer + Logger 합성
type LoggedTimer struct {
    Timer   // 임베딩
    Logger  // 임베딩
}
func NewLoggedTimer(prefix string) *LoggedTimer {
    return &LoggedTimer{
        Logger: Logger{prefix: prefix},
    }
}
func (lt *LoggedTimer) Start() {
    lt.Timer.Start()
    lt.Log("Timer started")
}
func (lt *LoggedTimer) Stop() time.Duration {
    elapsed := lt.Elapsed()
    lt.Log(fmt.Sprintf("Timer stopped: %v", elapsed))
    return elapsed
}
func main() {
    timer := NewLoggedTimer("BENCHMARK")
    timer.Start()
    
    time.Sleep(100 * time.Millisecond)
    
    timer.Stop()
}

정리: Day 5~6 학습 체크리스트

완료해야 할 항목

  • struct로 데이터 타입 정의
  • 대문자/소문자로 접근 제어
  • 메서드와 리시버 문법 이해
  • 포인터 리시버 vs 값 리시버 차이 숙지
  • NewXxx 생성자 패턴 사용
  • 구조체 임베딩으로 코드 재사용
  • 실습 과제 3개 완료

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

C++Go비고
classstruct + 메서드더 심플
생성자/소멸자NewXxx / defer관례 기반
this 포인터리시버 (명시적)더 명확
상속임베딩합성 우선
virtual인터페이스 (다음 글)암시적

다음 단계 예고

Day 5~6에서는 구조체와 메서드를 배웠습니다. 다음 글에서는 인터페이스를 다룹니다. 가상 함수 없이 다형성을 구현하는 Go의 핵심 개념입니다.


📚 시리즈 네비게이션

이전 글목차다음 글
← #02 자료구조📑 전체 목차#04 인터페이스 →

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


Go는 클래스와 상속 대신 구조체와 합성으로 더 심플하고 유연한 객체지향을 구현합니다.

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 옵션이 많은 구조체의 생성자는 어떻게 만드는 게 좋나요?

A. Go에는 기본 인자나 생성자 오버로딩이 없어서, 옵션이 많아지면 NewServer(host, port, timeout, maxConn, ...)처럼 인자 목록이 계속 길어집니다. 이럴 때 type Option func(*Server)를 정의하고 WithPort(8080) 같은 함수를 가변 인자로 받는 Functional Options 패턴을 쓰면, 기본값은 생성자 안에 두고 필요한 옵션만 골라 넘길 수 있습니다. 새 옵션을 추가해도 기존 호출 코드를 고칠 필요가 없다는 점이 가장 큰 장점입니다.