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

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

이 글의 핵심

Go에는 class가 없습니다. struct·메서드·포인터 리시버·값 리시버·임베딩으로 상속 없이 객체지향을 구현하는 법을 C++과 비교합니다. Day 5~6입니다.

시리즈 안내

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

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

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


들어가며: 클래스 없는 객체지향

C++에서는 class로 데이터와 메서드를 묶고, 상속으로 코드를 재사용했습니다. Go는 클래스도 없고 상속도 없습니다. 대신 struct합성(Composition)(상속 대신 작은 타입을 조립·끼워 넣어 기능을 확장하는 방식)으로 더 심플하고 유연한 객체지향을 구현합니다. “상속보다 합성을 선호하라”는 디자인 원칙이 언어 차원에서 강제되는 셈입니다.

이 글에서 배울 내용:

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

실무에서의 체감

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

자주 언급되는 장점:

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

목차

  1. 구조체: 클래스의 대체재
  2. 메서드와 리시버
  3. 포인터 리시버 vs 값 리시버
  4. 구조체 임베딩: 상속 없는 재사용
  5. 생성자 패턴
  6. 실습 과제

1. 구조체: 클래스의 대체재

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{} 와 동일
}

2. 메서드와 리시버

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() { }


3. 포인터 리시버 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 -->|큰 구조체<br/>64바이트 이상| C
    D -->|작은 구조체| E{일관성}
    E -->|다른 메서드가<br/>포인터 리시버| C
    E -->|모두 값 리시버| F[값 리시버 T]

권장 사항:

  • 포인터 리시버 사용 시기:

    • 메서드가 필드를 수정할 때
    • 구조체가 클 때 (복사 비용 절감)
    • 일관성 유지 (한 타입의 메서드는 모두 같은 리시버 타입)
  • 값 리시버 사용 시기:

    • 읽기 전용 메서드
    • 작은 구조체 (int, bool, 작은 struct)
    • 불변성(Immutability)이 중요할 때

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

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

임베딩의 동작 원리

// 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")
}

5. 생성자 패턴

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),
    )
}

6. 실습 과제

과제 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는 클래스와 상속 대신 구조체와 합성으로 더 심플하고 유연한 객체지향을 구현합니다.

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

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

  • [Go 2주 완성 #02] Day 3~4: 메모리와 자료구조 - 포인터 연산은 없지만 포인터는 있다
  • C++ 개발자를 위한 2주 완성 Go 언어(Golang) 마스터 커리큘럼
  • C++ 상속과 다형성 | “virtual 함수” 완벽 가이드

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

Go struct 메서드, 포인터 리시버, Embedding 합성, Go OOP, C++ 상속 대체, Golang 객체지향, Go 2주 완성 등으로 검색하시면 이 글이 도움이 됩니다.

실전 팁

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

디버깅 팁

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

성능 팁

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

코드 리뷰 팁

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

실전 체크리스트

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

코드 작성 전

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

코드 작성 중

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

코드 리뷰 시

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

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


자주 묻는 질문 (FAQ)

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

A. C++ 클래스와 상속 대신 Go의 구조체와 합성을 사용하는 방법. 메서드 리시버(포인터 vs 값)의 차이와 구조체 임베딩을 통한 코드 재사용 패턴을 실전 예제로 학습합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

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: 메모리와 자료구조 - 포인터 연산은 없지만 포인터는 있다