PHpullh
심층 가이드/Go/구조체/인터페이스

GO · 심층 가이드

Go 구조체/인터페이스 완전 정리

구조체와 메서드에서 출발해 임베딩, 인터페이스 설계 원칙, 의존성 주입, 리포지토리·서비스 계층까지 Go다운 구조 설계 15가지를 정리했습니다.

주제 15개 · 예제 코드 포함 · 최종 수정 2026-08-30 · 작성 pullh 편집팀

Go에는 클래스도 상속도 없습니다. 대신 구조체 임베딩과 암묵적 인터페이스 충족이 그 자리를 메웁니다. 결정적인 차이는 인터페이스를 구현하겠다고 선언하지 않는다는 점입니다. 메서드 집합이 맞으면 그 순간 충족됩니다. 그래서 인터페이스를 정의하는 쪽이 제공자가 아니라 소비자가 됩니다. 다른 언어에서 넘어온 사람이 패키지마다 거대한 인터페이스를 미리 선언해 두다가 결합도만 높이는 이유가 대체로 여기 있습니다.

앞부분 구조체 & 메서드인터페이스 — 덕 타이핑이 재료라면, 임베딩과 구성임베딩으로 상속 흉내내기는 그 재료를 조립하는 방식이고, 인터페이스 설계 원칙은 조립 결과를 어디서 끊을지에 대한 판단입니다. 그 판단이 서면 의존성 주입·팩토리 패턴·리포지토리 패턴·서비스 패턴이 하나의 계층 구조로 이어집니다. SOLID 원칙 적용 편은 마지막에 읽어 앞의 선택들을 되짚는 용도로 쓰면 좋습니다.

덜 알려진 함정은 nil 인터페이스입니다. 인터페이스 값은 타입과 값 두 칸으로 되어 있어서, nil인 *MyError 포인터를 error 인터페이스에 담으면 타입 칸이 채워져 err != nil이 참이 됩니다. 구체 타입 포인터를 에러로 반환하는 함수에서 자주 터집니다. 반환 타입을 처음부터 error로 두고 성공 시 리터럴 nil을 돌려주십시오. 임베딩도 마찬가지로 조용합니다. 내부 타입의 공개 메서드가 바깥 타입 API로 그대로 승격되므로 의도치 않은 노출을 점검해야 합니다.

01구조체 & 메서드

Go에는 클래스가 없습니다. struct와 메서드로 OOP를 구현합니다.

Go code

package main

import (
	"fmt"
	"math"
)

// 구조체 정의
type Point struct {
	X, Y float64
}

// 값 수신자 메서드 — 복사본에서 실행
func (p Point) Distance() float64 {
	return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

// 포인터 수신자 메서드 — 원본 수정 가능
func (p *Point) Scale(factor float64) {
	p.X *= factor
	p.Y *= factor
}

func (p Point) String() string {
	return fmt.Sprintf("(%.1f, %.1f)", p.X, p.Y)
}

// 임베딩 — 상속 대신
type Circle struct {
	Point        // 임베딩 (익명 필드)
	Radius float64
}

func (c Circle) Area() float64 {
	return math.Pi * c.Radius * c.Radius
}

// 구조체 태그 (JSON, DB 매핑)
type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

func main() {
	p := Point{X: 3, Y: 4}
	fmt.Println(p.Distance())  // 5
	p.Scale(2)
	fmt.Println(p)             // (6.0, 8.0)

	// 임베딩으로 Point 메서드 승격
	c := Circle{Point: Point{1, 2}, Radius: 5}
	fmt.Println(c.Distance()) // 임베딩된 Point의 메서드
	fmt.Printf("넓이: %.2f
", c.Area())

	// 구조체 리터럴
	u1 := User{ID: 1, Name: "Alice"}
	u2 := User{1, "Bob", "bob@test.com"}
	_ = u1; _ = u2

	// 익명 구조체
	config := struct {
		Host string
		Port int
	}{"localhost", 8080}
	fmt.Println(config.Host, config.Port)
}
알아두면 좋은 점

수신자를 값으로 할지 포인터로 할지: 구조체를 수정해야 하거나 크기가 크면 포인터, 그렇지 않으면 값 수신자를 사용하세요. 같은 타입의 모든 메서드는 일관성 있게 하나로 통일하세요.

자주 하는 실수

값 수신자 메서드를 통해 구조체를 수정하면 복사본만 수정됩니다. 원본에 반영되지 않습니다.

02인터페이스 — 덕 타이핑

Go 인터페이스는 암묵적으로 구현됩니다. 선언 없이 메서드만 맞으면 됩니다.

Go code

package main

import (
	"fmt"
	"math"
)

// 인터페이스 정의
type Shape interface {
	Area() float64
	Perimeter() float64
}

// Stringer 인터페이스 (fmt.Stringer)
type Stringer interface {
	String() string
}

// 구현체 — 명시적 선언 없음
type Rectangle struct{ W, H float64 }
func (r Rectangle) Area() float64      { return r.W * r.H }
func (r Rectangle) Perimeter() float64 { return 2*(r.W+r.H) }
func (r Rectangle) String() string {
	return fmt.Sprintf("Rect(%.1f×%.1f)", r.W, r.H)
}

type Circle struct{ R float64 }
func (c Circle) Area() float64      { return math.Pi * c.R * c.R }
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.R }

// 인터페이스를 파라미터로
func printShape(s Shape) {
	fmt.Printf("넓이: %.2f, 둘레: %.2f
", s.Area(), s.Perimeter())
}

// 인터페이스 슬라이스
func totalArea(shapes []Shape) float64 {
	var total float64
	for _, s := range shapes { total += s.Area() }
	return total
}

// 빈 인터페이스 — 모든 타입 허용
func describe(i interface{}) {
	fmt.Printf("타입: %T, 값: %v
", i, i)
}

// 타입 단언
func main() {
	shapes := []Shape{
		Rectangle{3, 4},
		Circle{5},
	}
	for _, s := range shapes { printShape(s) }
	fmt.Printf("총 넓이: %.2f
", totalArea(shapes))

	describe(42)
	describe("hello")
	describe(Rectangle{1, 2})

	// 타입 단언
	var s Shape = Rectangle{3, 4}
	if r, ok := s.(Rectangle); ok {
		fmt.Println("Rectangle:", r.W, r.H)
	}
}
알아두면 좋은 점

작은 인터페이스가 좋습니다. Go 표준 라이브러리의 io.Reader(메서드 1개), io.Writer(메서드 1개)가 대표 예시입니다.

자주 하는 실수

인터페이스 값이 nil인지 확인할 때 주의: 인터페이스가 nil 포인터를 담고 있으면 i == nil은 false입니다. 타입과 값이 모두 nil이어야 interface nil입니다.

03제네릭 (Go 1.18+)

Go 1.18에 추가된 제네릭으로 타입 안전한 재사용 가능한 코드를 작성합니다.

Go code

package main

import (
	"fmt"
	"golang.org/x/exp/constraints"
)

// 타입 파라미터 — [T constraint]
func Map[T, U any](s []T, fn func(T) U) []U {
	result := make([]U, len(s))
	for i, v := range s { result[i] = fn(v) }
	return result
}

func Filter[T any](s []T, fn func(T) bool) []T {
	var result []T
	for _, v := range s {
		if fn(v) { result = append(result, v) }
	}
	return result
}

// constraints.Ordered — 정렬 가능한 타입 제약
func Min[T constraints.Ordered](a, b T) T {
	if a < b { return a }
	return b
}

// 제네릭 구조체
type Stack[T any] struct {
	data []T
}

func (s *Stack[T]) Push(v T)     { s.data = append(s.data, v) }
func (s *Stack[T]) Pop() (T, bool) {
	if len(s.data) == 0 {
		var zero T
		return zero, false
	}
	n := len(s.data) - 1
	v := s.data[n]
	s.data = s.data[:n]
	return v, true
}
func (s *Stack[T]) Len() int { return len(s.data) }

// 커스텀 타입 제약
type Number interface {
	~int | ~int32 | ~int64 | ~float32 | ~float64
}

func Sum[T Number](nums []T) T {
	var total T
	for _, n := range nums { total += n }
	return total
}

func main() {
	nums := []int{1, 2, 3, 4, 5}
	doubled := Map(nums, func(n int) int { return n * 2 })
	fmt.Println(doubled) // [2 4 6 8 10]

	strs := Map(nums, func(n int) string {
		return fmt.Sprintf("item%d", n)
	})
	fmt.Println(strs)

	evens := Filter(nums, func(n int) bool { return n%2 == 0 })
	fmt.Println(evens) // [2 4]

	fmt.Println(Min(3, 5))       // 3
	fmt.Println(Min("a", "b"))   // a

	var s Stack[string]
	s.Push("hello"); s.Push("world")
	if v, ok := s.Pop(); ok { fmt.Println(v) } // world

	fmt.Println(Sum([]float64{1.1, 2.2, 3.3}))
}
알아두면 좋은 점

제네릭은 실제로 필요할 때만 사용하세요. Go의 철학은 "단순함"입니다. 인터페이스로 해결되면 제네릭을 피하는 것이 관례입니다.

자주 하는 실수

constraints 패키지는 golang.org/x/exp에 있습니다. Go 1.21+에서는 cmp 패키지로 일부가 표준화됐습니다.

04maps 패키지 (Go 1.21+)

maps.Clone, maps.Keys 등 표준 맵 유틸

Go code

<span class="cm">// maps 패키지 (Go 1.21+) 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("maps 패키지 (Go 1.21+)") }
알아두면 좋은 점

GO 공식 문서를 함께 참고하세요.

자주 하는 실수

자주 발생하는 실수에 주의하세요.

05임베딩과 구성 (Embedding &amp; Composition)

Go는 상속 대신 구조체 임베딩으로 코드를 재사용합니다. 임베딩된 타입의 메서드가 자동으로 승격(promote)되어 외부 구조체에서 직접 호출할 수 있으며, 인터페이스도 임베딩하여 조합할 수 있습니다.

Go code

package main

import "fmt"

// 1. 구조체 임베딩 기초
type Logger struct {
	Prefix string
}

func (l Logger) Log(msg string) {
	fmt.Printf("[%s] %s\n", l.Prefix, msg)
}

func (l Logger) Error(msg string) {
	fmt.Printf("[%s] ERROR: %s\n", l.Prefix, msg)
}

type Server struct {
	Logger     // 임베딩: Logger의 메서드가 승격됨
	Host string
	Port int
}

// 2. 메서드 오버라이드 (shadowing)
func (s Server) Log(msg string) {
	// 원래 Logger.Log도 호출 가능
	s.Logger.Log(fmt.Sprintf("%s:%d - %s", s.Host, s.Port, msg))
}

// 3. 인터페이스 임베딩
type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

// Reader + Writer 조합
type ReadWriter interface {
	Reader
	Writer
}

// 4. 다중 임베딩
type Metrics struct {
	RequestCount int
}

func (m *Metrics) IncrementRequests() {
	m.RequestCount++
}

func (m Metrics) Report() string {
	return fmt.Sprintf("요청 수: %d", m.RequestCount)
}

type APIServer struct {
	Server     // Logger 포함
	*Metrics   // 포인터 임베딩도 가능
	Version string
}

func main() {
	// 구조체 임베딩 사용
	srv := Server{
		Logger: Logger{Prefix: "WEB"},
		Host:   "localhost",
		Port:   8080,
	}
	srv.Log("서버 시작")          // Server.Log 호출 (오버라이드됨)
	srv.Error("연결 실패")        // Logger.Error 직접 승격

	// 다중 임베딩
	api := APIServer{
		Server:  srv,
		Metrics: &Metrics{},
		Version: "v2",
	}
	api.Log("API 요청 처리")      // Server.Log → Logger.Log
	api.IncrementRequests()       // Metrics.IncrementRequests
	api.IncrementRequests()
	fmt.Println(api.Report())     // "요청 수: 2"
	fmt.Printf("서버: %s:%d (%s)\n", api.Host, api.Port, api.Version)
}
알아두면 좋은 점

임베딩은 "has-a" 관계이지 "is-a" 관계가 아닙니다. 내부 타입의 메서드가 승격되지만, 다형성(polymorphism)은 인터페이스로 구현하세요.

자주 하는 실수

두 임베딩된 타입에 같은 이름의 메서드가 있으면 컴파일 에러가 발생합니다. 이 경우 외부 구조체에서 명시적으로 어떤 임베딩의 메서드를 호출할지 지정해야 합니다.

06임베딩으로 상속 흉내내기

Go는 상속이 없지만 구조체 임베딩으로 코드 재사용과 다형성을 구현합니다. 합성(Composition) 패턴입니다.

Go code

package main

import "fmt"

type Animal struct {
	Name string
}

func (a Animal) Speak() string {
	return a.Name + "이(가) 소리를 냅니다"
}

type Dog struct {
	Animal     // 임베딩
	Breed string
}

// 메서드 오버라이드
func (d Dog) Speak() string {
	return d.Name + ": 멍멍!"
}

type Cat struct {
	Animal
}

func (c Cat) Speak() string {
	return c.Name + ": 야옹~"
}

type Speaker interface {
	Speak() string
}

func main() {
	pets := []Speaker{
		Dog{Animal: Animal{Name: "바둑이"}, Breed: "진돗개"},
		Cat{Animal: Animal{Name: "나비"}},
	}
	for _, p := range pets {
		fmt.Println(p.Speak())
	}
}
알아두면 좋은 점

임베딩 + 인터페이스 조합이 Go의 다형성 패턴입니다. 상속 대신 "작은 인터페이스 + 합성"을 사용하세요.

자주 하는 실수

임베딩된 타입의 메서드는 외부 타입을 모릅니다. Animal.Speak()에서 Dog의 필드에 접근할 수 없습니다.

07인터페이스 설계 원칙

Go의 암시적 인터페이스 구현을 활용한 설계 원칙. "소비자가 인터페이스를 정의한다"가 핵심입니다.

Go code

package main

import "fmt"

// 원칙 1: 작은 인터페이스
type Saver interface {
	Save(data []byte) error
}

// 원칙 2: 소비자 측에서 정의
// 패키지 A가 인터페이스를 정의, 패키지 B가 구현
type Notifier interface {
	Notify(msg string) error
}

// 구현체들 — 인터페이스를 몰라도 됨
type FileSaver struct{ Path string }
func (f FileSaver) Save(data []byte) error {
	fmt.Printf("파일 저장: %s (%d bytes)\n", f.Path, len(data))
	return nil
}

type SlackNotifier struct{ Webhook string }
func (s SlackNotifier) Notify(msg string) error {
	fmt.Printf("Slack 알림: %s\n", msg)
	return nil
}

// 인터페이스를 받아 사용
func backup(s Saver, n Notifier) {
	s.Save([]byte("backup data"))
	n.Notify("백업 완료")
}

func main() {
	backup(
		FileSaver{Path: "/backup"},
		SlackNotifier{Webhook: "https://hooks.slack.com/..."},
	)
}
알아두면 좋은 점

"인터페이스는 생산자가 아닌 소비자가 정의한다"가 Go의 핵심 설계 원칙입니다. 필요한 메서드만 포함하세요.

자주 하는 실수

구현체 패키지에서 인터페이스를 정의하고 반환하는 것은 Java 스타일입니다. Go에서는 호출하는 쪽에서 필요한 인터페이스를 정의하세요.

08의존성 주입

인터페이스를 통해 의존성을 주입하여 테스트 가능하고 유연한 코드를 작성합니다.

Go code

package main

import "fmt"

// 인터페이스 정의
type UserRepo interface {
	FindByID(id int) (string, error)
}

type Mailer interface {
	Send(to, subject string) error
}

// 서비스: 의존성 주입
type UserService struct {
	repo   UserRepo
	mailer Mailer
}

func NewUserService(r UserRepo, m Mailer) *UserService {
	return &UserService{repo: r, mailer: m}
}

func (s *UserService) Greet(id int) error {
	name, err := s.repo.FindByID(id)
	if err != nil { return err }
	return s.mailer.Send(name, "환영합니다!")
}

// 구현체
type MemoryRepo struct{ users map[int]string }
func (r *MemoryRepo) FindByID(id int) (string, error) {
	if u, ok := r.users[id]; ok { return u, nil }
	return "", fmt.Errorf("사용자 없음: %d", id)
}

type ConsoleMailer struct{}
func (m *ConsoleMailer) Send(to, subj string) error {
	fmt.Printf("메일 → %s: %s\n", to, subj)
	return nil
}

func main() {
	repo := &MemoryRepo{users: map[int]string{1: "Gopher"}}
	svc := NewUserService(repo, &ConsoleMailer{})
	svc.Greet(1)
}
알아두면 좋은 점

생성자에서 인터페이스를 받으면 테스트 시 목(mock) 객체를 쉽게 주입할 수 있습니다.

자주 하는 실수

구체적인 타입에 의존하면 테스트와 교체가 어렵습니다. 항상 인터페이스에 의존하세요.

09팩토리 패턴

생성 로직을 캡슐화하는 팩토리 패턴. 인터페이스를 반환하여 구현체를 숨깁니다.

Go code

package main

import "fmt"

type Database interface {
	Connect() string
	Query(sql string) string
}

type postgres struct{ dsn string }
func (p *postgres) Connect() string { return "PostgreSQL 연결: " + p.dsn }
func (p *postgres) Query(sql string) string { return "PG: " + sql }

type sqlite struct{ path string }
func (s *sqlite) Connect() string { return "SQLite 열기: " + s.path }
func (s *sqlite) Query(sql string) string { return "SQLite: " + sql }

// 팩토리 함수
func NewDatabase(driver, source string) (Database, error) {
	switch driver {
	case "postgres":
		return &postgres{dsn: source}, nil
	case "sqlite":
		return &sqlite{path: source}, nil
	default:
		return nil, fmt.Errorf("미지원 드라이버: %s", driver)
	}
}

func main() {
	db, err := NewDatabase("postgres", "host=localhost")
	if err != nil { panic(err) }
	fmt.Println(db.Connect())
	fmt.Println(db.Query("SELECT 1"))
}
알아두면 좋은 점

팩토리 함수는 인터페이스를 반환하고 구조체를 비공개(소문자)로 유지하여 구현 세부사항을 숨기세요.

자주 하는 실수

팩토리에서 구체적 타입을 반환하면 소비자가 구현에 의존하게 됩니다. 항상 인터페이스를 반환하세요.

10빌더 패턴

복잡한 객체를 단계적으로 구성하는 빌더 패턴. 메서드 체이닝으로 가독성을 높입니다.

Go code

package main

import "fmt"

type Query struct {
	table  string
	wheres []string
	order  string
	limit  int
}

type QueryBuilder struct {
	q Query
}

func Select(table string) *QueryBuilder {
	return &QueryBuilder{q: Query{table: table}}
}

func (b *QueryBuilder) Where(cond string) *QueryBuilder {
	b.q.wheres = append(b.q.wheres, cond)
	return b // 체이닝
}

func (b *QueryBuilder) OrderBy(col string) *QueryBuilder {
	b.q.order = col
	return b
}

func (b *QueryBuilder) Limit(n int) *QueryBuilder {
	b.q.limit = n
	return b
}

func (b *QueryBuilder) Build() string {
	sql := "SELECT * FROM " + b.q.table
	for i, w := range b.q.wheres {
		if i == 0 { sql += " WHERE " } else { sql += " AND " }
		sql += w
	}
	if b.q.order != "" { sql += " ORDER BY " + b.q.order }
	if b.q.limit > 0 { sql += fmt.Sprintf(" LIMIT %d", b.q.limit) }
	return sql
}

func main() {
	sql := Select("users").
		Where("age > 18").
		Where("active = true").
		OrderBy("name").
		Limit(10).
		Build()
	fmt.Println(sql)
}
알아두면 좋은 점

빌더의 각 메서드가 *Builder를 반환하면 메서드 체이닝이 가능합니다. 마지막에 Build()로 최종 객체를 생성하세요.

자주 하는 실수

빌더를 값 리시버로 구현하면 매 호출마다 복사되어 상태가 유지되지 않습니다. 포인터 리시버를 사용하세요.

11옵션 패턴 (구조체 방식)

설정 구조체 + 기본값 패턴으로 함수 옵션을 관리합니다. Functional Options 대비 구조가 명확합니다.

Go code

package main

import "fmt"

type ClientConfig struct {
	BaseURL    string
	Timeout    int
	RetryCount int
	Verbose    bool
}

// 기본값을 제공하는 생성자
func DefaultConfig() ClientConfig {
	return ClientConfig{
		BaseURL:    "https://api.example.com",
		Timeout:    30,
		RetryCount: 3,
		Verbose:    false,
	}
}

type Client struct {
	config ClientConfig
}

func NewClient(cfg ClientConfig) *Client {
	// 누락된 값에 기본값 적용
	if cfg.BaseURL == "" {
		cfg.BaseURL = DefaultConfig().BaseURL
	}
	if cfg.Timeout <= 0 {
		cfg.Timeout = DefaultConfig().Timeout
	}
	return &Client{config: cfg}
}

func main() {
	// 기본값 + 일부 커스터마이징
	cfg := DefaultConfig()
	cfg.Timeout = 60
	cfg.Verbose = true

	c := NewClient(cfg)
	fmt.Printf("URL: %s, Timeout: %d\n", c.config.BaseURL, c.config.Timeout)
}
알아두면 좋은 점

설정 구조체 방식은 IDE 자동완성이 잘 지원되고, 설정 필드가 한눈에 보입니다. 필드가 5개 이하일 때 적합합니다.

자주 하는 실수

구조체의 제로 값(0, "")이 유효한 설정값인 경우 기본값과 구분할 수 없습니다. 이때는 포인터 필드(*int)를 사용하세요.

12리포지토리 패턴

데이터 접근 로직을 추상화하는 리포지토리 패턴. 저장소 교체와 테스트가 용이합니다.

Go code

package main

import (
	"errors"
	"fmt"
)

type User struct {
	ID   int
	Name string
}

// 리포지토리 인터페이스
type UserRepository interface {
	FindByID(id int) (*User, error)
	Save(user *User) error
	FindAll() ([]*User, error)
}

// 인메모리 구현
type memoryUserRepo struct {
	users map[int]*User
	seq   int
}

func NewMemoryUserRepo() UserRepository {
	return &memoryUserRepo{users: make(map[int]*User)}
}

func (r *memoryUserRepo) FindByID(id int) (*User, error) {
	u, ok := r.users[id]
	if !ok { return nil, errors.New("사용자 없음") }
	return u, nil
}

func (r *memoryUserRepo) Save(u *User) error {
	if u.ID == 0 { r.seq++; u.ID = r.seq }
	r.users[u.ID] = u
	return nil
}

func (r *memoryUserRepo) FindAll() ([]*User, error) {
	all := make([]*User, 0, len(r.users))
	for _, u := range r.users { all = append(all, u) }
	return all, nil
}

func main() {
	repo := NewMemoryUserRepo()
	repo.Save(&User{Name: "Gopher"})
	repo.Save(&User{Name: "Rustacean"})
	users, _ := repo.FindAll()
	for _, u := range users { fmt.Printf("%+v\n", *u) }
}
알아두면 좋은 점

리포지토리 인터페이스는 소비자 측(서비스 계층)에서 정의하세요. 실제 DB 구현은 별도 패키지에 둡니다.

자주 하는 실수

리포지토리에 비즈니스 로직을 넣으면 계층 분리가 깨집니다. 리포지토리는 순수한 CRUD만 담당하세요.

13서비스 패턴

비즈니스 로직을 서비스 계층에 캡슐화합니다. 리포지토리와 조합하여 클린 아키텍처를 구현합니다.

Go code

package main

import (
	"errors"
	"fmt"
)

type Order struct {
	ID     int
	UserID int
	Total  float64
	Status string
}

type OrderRepo interface {
	Save(o *Order) error
	FindByID(id int) (*Order, error)
}

// 서비스: 비즈니스 로직 담당
type OrderService struct {
	repo OrderRepo
}

func NewOrderService(r OrderRepo) *OrderService {
	return &OrderService{repo: r}
}

func (s *OrderService) PlaceOrder(userID int, total float64) (*Order, error) {
	if total <= 0 {
		return nil, errors.New("주문 금액은 0보다 커야 합니다")
	}
	order := &Order{UserID: userID, Total: total, Status: "pending"}
	if err := s.repo.Save(order); err != nil {
		return nil, fmt.Errorf("주문 저장 실패: %w", err)
	}
	return order, nil
}

// 간단한 인메모리 구현
type memOrderRepo struct{ orders map[int]*Order; seq int }
func (r *memOrderRepo) Save(o *Order) error { r.seq++; o.ID = r.seq; r.orders[o.ID] = o; return nil }
func (r *memOrderRepo) FindByID(id int) (*Order, error) { o, ok := r.orders[id]; if !ok { return nil, errors.New("없음") }; return o, nil }

func main() {
	repo := &memOrderRepo{orders: make(map[int]*Order)}
	svc := NewOrderService(repo)
	order, _ := svc.PlaceOrder(1, 29900)
	fmt.Printf("주문 생성: %+v\n", *order)
}
알아두면 좋은 점

서비스 메서드에서 에러를 래핑(%w)하면 호출자가 에러 원인을 추적할 수 있습니다.

자주 하는 실수

서비스에서 HTTP 응답을 직접 작성하면 웹과 CLI 간 재사용이 불가합니다. 서비스는 순수 비즈니스 로직만 담당하세요.

14도메인 주도 설계 (DDD) 기초

값 객체, 엔티티, 애그리거트 등 DDD 핵심 개념을 Go로 구현합니다.

Go code

package main

import (
	"errors"
	"fmt"
)

// 값 객체 (Value Object) — 불변, 동등성으로 비교
type Money struct {
	Amount   int64
	Currency string
}

func (m Money) Add(other Money) (Money, error) {
	if m.Currency != other.Currency {
		return Money{}, errors.New("통화 불일치")
	}
	return Money{Amount: m.Amount + other.Amount, Currency: m.Currency}, nil
}

// 엔티티 — ID로 식별
type Product struct {
	ID    string
	Name  string
	Price Money
}

// 애그리거트 루트
type Cart struct {
	ID    string
	Items []CartItem
}

type CartItem struct {
	Product Product
	Qty     int
}

func (c *Cart) AddItem(p Product, qty int) {
	c.Items = append(c.Items, CartItem{Product: p, Qty: qty})
}

func (c *Cart) Total() Money {
	total := Money{Currency: "KRW"}
	for _, item := range c.Items {
		m := Money{Amount: item.Product.Price.Amount * int64(item.Qty), Currency: "KRW"}
		total, _ = total.Add(m)
	}
	return total
}

func main() {
	p := Product{ID: "1", Name: "Go 책", Price: Money{35000, "KRW"}}
	cart := &Cart{ID: "cart-1"}
	cart.AddItem(p, 2)
	fmt.Printf("총액: %d %s\n", cart.Total().Amount, cart.Total().Currency)
}
알아두면 좋은 점

값 객체는 구조체 비교(==)로 동등성을 판단합니다. 모든 필드가 comparable이면 자동으로 비교 가능합니다.

자주 하는 실수

애그리거트 내부 엔티티를 외부에서 직접 수정하면 불변식이 깨집니다. 반드시 애그리거트 루트의 메서드를 통해 수정하세요.

15SOLID 원칙 적용

Go에서 SOLID 원칙을 적용합니다. 작은 인터페이스와 합성이 Go 스타일의 SOLID입니다.

Go code

package main

import "fmt"

// S — 단일 책임: 각 타입이 하나의 역할
type Validator struct{}
func (v Validator) Validate(email string) bool { return len(email) > 3 }

type Formatter struct{}
func (f Formatter) Format(name string) string { return "Mr./Ms. " + name }

// O — 개방/폐쇄: 인터페이스로 확장
type Notifier interface { Notify(msg string) }

type EmailNotifier struct{}
func (e EmailNotifier) Notify(msg string) { fmt.Println("이메일:", msg) }

type SMSNotifier struct{}
func (s SMSNotifier) Notify(msg string) { fmt.Println("SMS:", msg) }

// I — 인터페이스 분리: 작은 인터페이스
type Reader interface { Read() string }
type Writer interface { Write(s string) }

// D — 의존성 역전: 추상에 의존
type App struct {
	notifiers []Notifier
}

func (a *App) Alert(msg string) {
	for _, n := range a.notifiers {
		n.Notify(msg) // 인터페이스에 의존
	}
}

func main() {
	app := &App{notifiers: []Notifier{EmailNotifier{}, SMSNotifier{}}}
	app.Alert("서버 점검 안내")
}
알아두면 좋은 점

Go의 암시적 인터페이스 구현은 OCP와 DIP를 자연스럽게 지원합니다. "Accept interfaces, return structs" 원칙을 따르세요.

자주 하는 실수

Java처럼 큰 인터페이스를 만들면 ISP를 위반합니다. Go에서는 1-2개 메서드의 작은 인터페이스가 최선입니다.

정리하며

  • 인터페이스는 구현하는 쪽이 아니라 사용하는 쪽 패키지에서 좁게 정의합니다
  • nil 구체 타입 포인터를 error에 담으면 nil 비교가 참이 되지 않습니다
  • 임베딩은 내부 타입의 공개 메서드를 바깥 API로 승격시키므로 노출 범위를 확인합니다
  • 저장소는 인터페이스로 끊고 서비스 계층은 그 인터페이스에만 의존시킵니다

더 깊이 들어가고 싶다면 Go 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.