Go는 클래스 중심 언어와 다른 방식으로 데이터와 동작을 구성합니다.
여러 값을 하나로 묶을 때는 struct를 사용하고, 특정 타입에 동작을 연결할 때는 method를 정의합니다. 서로 다른 타입을 같은 방식으로 다루려면 interface를 사용하며, 다른 구조체의 필드와 메서드를 재사용할 때는 embedding을 활용할 수 있습니다.
Go에는 class, implements, enum 전용 키워드가 없습니다. 대신 작은 기능을 조합하는 방식으로 프로그램을 구성합니다.
디렉터리 구조는 다음과 같이 구성합니다.
sources/
└── structs-methods-interfaces/
└── main.gosources 디렉터리에서 실행합니다.
cd sources
go run ./structs-methods-interfacesstruct로 관련된 값을 하나로 묶기
Go 명세에서 구조체는 이름과 타입을 가진 필드의 연속으로 정의됩니다. 서로 관련된 여러 값을 하나의 값으로 다루고 싶을 때 struct를 사용합니다.
type Product struct {
ID int64
Name string
Price int
InStock bool
}Product는 새로운 구조체 타입이며, 다음 네 필드를 가집니다.
| 필드 | 타입 | 의미 |
|---|---|---|
ID |
int64 |
상품 식별자 |
Name |
string |
상품명 |
Price |
int |
가격 |
InStock |
bool |
재고 보유 여부 |
구조체 값은 composite literal로 만들 수 있습니다.
product := Product{
ID: 1001,
Name: "무선 키보드",
Price: 59000,
InStock: true,
}필드 이름을 지정하는 방식이 일반적으로 더 안전합니다. 필드 순서가 바뀌거나 새 필드가 추가되어도 기존 코드의 의미를 파악하기 쉽기 때문입니다.
필드는 점 표기법으로 접근합니다.
fmt.Println(product.Name)
fmt.Println(product.Price)
product.Price = 55000구조체 변수 자체가 주소를 가질 수 있는 값이면 포인터를 통해서도 필드에 접근할 수 있습니다.
productPointer := &product
productPointer.Price = 52000Go는 구조체 포인터의 필드에 접근할 때 아래 표현을 자동으로 역참조합니다.
productPointer.Price이는 다음 표현과 같은 의미입니다.
(*productPointer).Price구조체의 zero value
구조체 변수를 선언만 하면 각 필드에는 해당 타입의 zero value가 들어갑니다.
var product Product
fmt.Printf("%+v\n", product)출력:
{ID:0 Name: Price:0 InStock:false}구조체의 zero value는 각 필드의 zero value를 조합한 값입니다.
| 타입 | zero value |
|---|---|
| 정수 | 0 |
| 문자열 | "" |
| 불리언 | false |
| 포인터, slice, map, interface, function | nil |
zero value만으로도 의미 있는 상태를 표현할 수 있도록 타입을 설계하면 별도 초기화 코드가 줄어듭니다.
필드 공개 범위
Go에서는 이름의 첫 글자가 대문자이면 package 외부에 공개되고, 소문자이면 해당 package 내부에서만 접근할 수 있습니다.
type Account struct {
ID int64 // 다른 package에서도 접근 가능
owner string // 같은 package에서만 접근 가능
}이 규칙은 구조체 필드뿐 아니라 타입, 함수, 메서드, 상수, 변수 이름에도 동일하게 적용됩니다.
구조체는 값 타입이다
구조체를 다른 변수에 대입하면 기본적으로 전체 값이 복사됩니다.
original := Product{
ID: 1001,
Name: "무선 키보드",
Price: 59000,
}
copied := original
copied.Price = 49000
fmt.Println(original.Price) // 59000
fmt.Println(copied.Price) // 49000copied의 필드를 변경해도 original의 필드는 변경되지 않습니다.
다만 구조체 안에 slice, map, pointer처럼 다른 데이터를 참조하는 필드가 있다면 구조체를 복사해도 해당 참조 대상까지 깊은 복사되지는 않습니다.
type Cart struct {
Items []string
}
original := Cart{
Items: []string{"키보드", "마우스"},
}
copied := original
copied.Items[0] = "모니터"
fmt.Println(original.Items) // [모니터 마우스]
fmt.Println(copied.Items) // [모니터 마우스]구조체 값은 복사되었지만 두 Items slice가 같은 backing array를 참조하므로 요소 변경이 함께 보입니다.
구조체 대입은 구조체 필드를 값으로 복사합니다. 그러나 필드가 slice, map, pointer처럼 다른 저장공간을 참조하는 값이라면 참조 대상까지 자동으로 복제되지는 않습니다.
method와 receiver
메서드는 특정 타입에 연결된 함수입니다.
일반 함수와 달리 함수 이름 앞에 receiver가 추가됩니다.
type Product struct {
Name string
Price int
}
func (p Product) DisplayName() string {
return fmt.Sprintf("%s (%d원)", p.Name, p.Price)
}Product 타입의 값은 점 표기법으로 메서드를 호출할 수 있습니다.
product := Product{
Name: "무선 키보드",
Price: 59000,
}
fmt.Println(product.DisplayName())출력:
무선 키보드 (59000원)아래 부분이 receiver입니다.
(p Product)p는 receiver 변수 이름이고 Product는 receiver 타입입니다.
receiver는 특별한 this나 self가 아니라 메서드에 전달되는 매개변수입니다. 따라서 값 receiver는 호출 시 receiver 값을 복사해서 받습니다.
value receiver
값 receiver는 receiver 타입을 값으로 선언합니다.
func (p Product) DiscountedPrice(rate int) int {
return p.Price * (100 - rate) / 100
}이 메서드는 Product 값을 읽어 계산 결과를 반환하며 원본 상태를 변경하지 않습니다.
product := Product{
Name: "무선 키보드",
Price: 60000,
}
discounted := product.DiscountedPrice(10)
fmt.Println(discounted) // 54000
fmt.Println(product.Price) // 60000값 receiver 안에서 필드를 변경해도 복사본만 변경됩니다.
func (p Product) Rename(name string) {
p.Name = name
}product := Product{Name: "무선 키보드"}
product.Rename("기계식 키보드")
fmt.Println(product.Name) // 무선 키보드Rename의 p는 원본 product의 복사본이므로 원본 이름은 바뀌지 않습니다.
pointer receiver
원본 상태를 변경하려면 보통 pointer receiver를 사용합니다.
func (p *Product) Rename(name string) {
p.Name = name
}product := Product{Name: "무선 키보드"}
product.Rename("기계식 키보드")
fmt.Println(product.Name) // 기계식 키보드메서드의 receiver 타입은 *Product이지만 주소를 직접 쓰지 않고 product.Rename(...)으로 호출할 수 있습니다.
product가 주소를 구할 수 있는 값이므로 Go가 다음 호출 형태로 처리할 수 있기 때문입니다.
(&product).Rename("기계식 키보드")pointer receiver를 사용하는 주요 이유는 다음과 같습니다.
- receiver의 필드를 변경해야 하는 경우
- 큰 구조체의 전체 복사를 피하려는 경우
- 해당 타입의 메서드 receiver 방식을 일관되게 유지하려는 경우
sync.Mutex처럼 복사하면 안 되는 필드를 가진 타입인 경우
반대로 value receiver는 다음과 같은 경우에 잘 맞습니다.
- 메서드가 receiver 상태를 변경하지 않는 경우
- 타입이 작고 복사 비용이 낮은 경우
- 값 자체의 성격을 유지하려는 경우
time.Time처럼 불변 값에 가까운 사용 방식을 의도한 경우
한 타입의 일부 메서드는 value receiver, 일부는 pointer receiver로 무분별하게 섞기보다 타입의 성격과 method set을 고려해 일관되게 정하는 것이 좋습니다.
nil pointer receiver
pointer receiver 메서드는 receiver가 nil일 가능성이 있습니다.
type Counter struct {
Value int
}
func (c *Counter) Current() int {
if c == nil {
return 0
}
return c.Value
}var counter *Counter
fmt.Println(counter.Current()) // 0메서드 호출 자체는 가능하지만, 메서드 안에서 nil 검사를 하지 않고 c.Value에 접근하면 panic이 발생합니다.
nil receiver를 정상 상태로 허용할지는 타입 설계에 따라 결정해야 합니다. 무조건 허용해야 하는 규칙은 아닙니다.
interface와 암시적 구현
인터페이스는 메서드 시그니처의 집합으로 타입이 제공해야 하는 동작을 표현합니다.
type Pricer interface {
PriceValue() int
}다음 타입이 PriceValue() int 메서드를 제공하면 Pricer 인터페이스를 구현합니다.
type Product struct {
Price int
}
func (p Product) PriceValue() int {
return p.Price
}Go에는 다음과 같은 명시적 구현 선언이 없습니다.
implements Pricer타입의 method set이 인터페이스가 요구하는 메서드를 모두 포함하면 자동으로 인터페이스를 구현합니다.
func PrintPrice(p Pricer) {
fmt.Printf("가격: %d원\n", p.PriceValue())
}
product := Product{Price: 59000}
PrintPrice(product)이러한 암시적 구현 덕분에 인터페이스와 구현 타입을 서로 다른 package에서 독립적으로 정의할 수 있습니다.
작은 인터페이스
Go에서는 필요한 동작만 표현하는 작은 인터페이스가 일반적으로 사용하기 쉽습니다.
type Saver interface {
Save() error
}여러 책임을 한 인터페이스에 모두 넣으면 구현 타입이 불필요한 메서드까지 제공해야 합니다.
type Repository interface {
Save() error
Find() error
Update() error
Delete() error
Export() error
Import() error
}호출하는 코드가 Save만 필요하다면 Saver처럼 필요한 메서드만 요구하는 편이 결합도를 낮출 수 있습니다.
인터페이스는 사용하는 쪽에서 정의할 수 있다
다음 서비스는 저장 기능만 필요합니다.
type ProductSaver interface {
Save(Product) error
}
type ProductService struct {
saver ProductSaver
}
func NewProductService(saver ProductSaver) *ProductService {
return &ProductService{saver: saver}
}구체적인 저장소 타입은 인터페이스를 알고 있을 필요가 없습니다.
type MemoryProductRepository struct {
products []Product
}
func (r *MemoryProductRepository) Save(product Product) error {
r.products = append(r.products, product)
return nil
}MemoryProductRepository가 필요한 메서드를 제공하므로 자동으로 ProductSaver를 구현합니다.
컴파일 시점에 구현 여부를 명시적으로 확인하고 싶다면 다음 패턴을 사용할 수 있습니다.
var _ ProductSaver = (*MemoryProductRepository)(nil)이 코드는 실행을 위한 코드가 아니라 컴파일 시점 검사용 선언입니다. *MemoryProductRepository가 ProductSaver를 구현하지 않으면 컴파일 오류가 발생합니다.
method set과 인터페이스 구현
인터페이스 구현 여부는 단순히 메서드 이름이 존재하는지만 보는 것이 아니라 타입의 method set을 기준으로 결정됩니다.
정의된 타입 T와 포인터 타입 *T의 method set은 다음처럼 다릅니다.
| 타입 | 포함되는 메서드 |
|---|---|
T |
receiver가 T인 메서드 |
*T |
receiver가 T 또는 *T인 메서드 |
예를 들어 다음 타입이 있습니다.
type FileStore struct{}
func (s *FileStore) Save(Product) error {
return nil
}Save의 receiver가 *FileStore이므로 *FileStore는 인터페이스를 구현하지만 FileStore 값은 구현하지 않습니다.
var saver ProductSaver
store := FileStore{}
saver = &store // 가능
// saver = store // 컴파일 오류여기서 자주 헷갈리는 점은 메서드 직접 호출과 인터페이스 대입 규칙이 다르다는 것입니다.
store.Save(Product{}) // 가능store가 주소를 구할 수 있는 변수이므로 직접 호출에서는 Go가 (&store).Save(...) 형태로 처리합니다.
그러나 인터페이스에 값을 대입할 때는 자동 주소 변환으로 method set을 바꾸지 않습니다.
saver = storeFileStore 값의 method set에는 pointer receiver 메서드가 없으므로 위 대입은 허용되지 않습니다.
여러 구현을 같은 인터페이스로 사용하기
type NotificationSender interface {
Send(message string) error
}
type EmailSender struct{}
func (EmailSender) Send(message string) error {
fmt.Println("이메일 전송:", message)
return nil
}
type ConsoleSender struct{}
func (ConsoleSender) Send(message string) error {
fmt.Println("콘솔 출력:", message)
return nil
}호출하는 코드는 구체 타입보다 인터페이스에 의존할 수 있습니다.
func Notify(sender NotificationSender, message string) error {
return sender.Send(message)
}Notify(EmailSender{}, "작업이 완료되었습니다.")
Notify(ConsoleSender{}, "작업이 완료되었습니다.")nil interface와 typed nil
인터페이스 값은 동적 타입과 동적 값을 함께 가집니다.
var sender NotificationSender
fmt.Println(sender == nil) // true아무 값도 대입하지 않은 인터페이스는 nil입니다.
그러나 nil 포인터를 인터페이스에 대입하면 결과가 다릅니다.
var emailSender *EmailSender
var sender NotificationSender = emailSender
fmt.Println(emailSender == nil) // true
fmt.Println(sender == nil) // falsesender에는 동적 타입 *EmailSender가 들어 있으므로 인터페이스 자체는 nil이 아닙니다. 동적 값만 nil입니다.
이 상태에서 메서드가 nil receiver를 처리하지 못하면 호출 시 panic이 발생할 수 있습니다.
인터페이스의 nil 여부를 판단할 때는 nil 포인터가 인터페이스에 들어간 경우를 구분해야 합니다. 인터페이스는 동적 타입과 동적 값이 모두 없을 때만
nil입니다.
type assertion과 type switch
인터페이스가 가진 구체 값을 확인해야 할 때 type assertion을 사용할 수 있습니다.
sender, ok := value.(EmailSender)
if ok {
fmt.Printf("EmailSender: %+v\n", sender)
}ok를 받지 않는 assertion이 실패하면 panic이 발생합니다.
sender := value.(EmailSender)여러 타입을 구분할 때는 type switch를 사용할 수 있습니다.
func Describe(value any) {
switch v := value.(type) {
case Product:
fmt.Printf("Product: %+v\n", v)
case *Product:
fmt.Printf("*Product: %+v\n", v)
case string:
fmt.Printf("string: %s\n", v)
default:
fmt.Printf("unknown: %T\n", v)
}
}any는 interface{}의 별칭이며 모든 비인터페이스 타입의 값을 담을 수 있습니다.
Go에서 enum을 표현하는 방법
Go에는 enum 전용 키워드가 없습니다.
대신 이름 있는 타입과 const를 조합해 제한된 의미를 가진 상수 집합을 표현합니다.
type OrderStatus int
const (
OrderStatusPending OrderStatus = iota
OrderStatusProcessing
OrderStatusCompleted
OrderStatusCanceled
)iota는 괄호로 묶인 const 선언 안에서 각 ConstSpec의 인덱스를 나타내며 0부터 증가합니다.
위 상수의 값은 다음과 같습니다.
| 상수 | 값 |
|---|---|
OrderStatusPending |
0 |
OrderStatusProcessing |
1 |
OrderStatusCompleted |
2 |
OrderStatusCanceled |
3 |
이름 있는 타입을 사용하는 이유는 일반 정수와 상태 값을 구분하기 위해서입니다.
var status OrderStatus = OrderStatusProcessing
fmt.Println(status)0을 의미 없는 값으로 남기기
zero value인 0을 실제 상태로 사용할 수도 있지만, 값이 설정되지 않은 상태를 명확히 구분하고 싶다면 Unknown을 첫 값으로 둘 수 있습니다.
type OrderStatus int
const (
OrderStatusUnknown OrderStatus = iota
OrderStatusPending
OrderStatusProcessing
OrderStatusCompleted
OrderStatusCanceled
)이렇게 하면 선언만 된 변수의 값이 자연스럽게 OrderStatusUnknown이 됩니다.
var status OrderStatus
fmt.Println(status == OrderStatusUnknown) // true또는 첫 값을 건너뛸 수도 있습니다.
const (
_ OrderStatus = iota
OrderStatusPending
OrderStatusProcessing
OrderStatusCompleted
OrderStatusCanceled
)이 경우 유효한 상태 값은 1부터 시작합니다.
String 메서드 추가
상태 값을 사람이 읽을 수 있는 문자열로 변환하려면 메서드를 추가할 수 있습니다.
func (s OrderStatus) String() string {
switch s {
case OrderStatusPending:
return "pending"
case OrderStatusProcessing:
return "processing"
case OrderStatusCompleted:
return "completed"
case OrderStatusCanceled:
return "canceled"
default:
return "unknown"
}
}status := OrderStatusCompleted
fmt.Println(status) // completed
fmt.Println(status.String()) // completedString() string 메서드를 제공하면 fmt package가 값을 출력할 때 이 메서드를 사용할 수 있습니다.
유효성 검사
이름 있는 정수 타입과 상수 조합은 전용 enum과 완전히 같지는 않습니다.
다음처럼 상수 목록에 없는 값도 명시적 변환으로 만들 수 있습니다.
status := OrderStatus(100)따라서 외부 입력을 상태 타입으로 변환할 때는 유효성 검사가 필요할 수 있습니다.
func (s OrderStatus) IsValid() bool {
switch s {
case OrderStatusPending,
OrderStatusProcessing,
OrderStatusCompleted,
OrderStatusCanceled:
return true
default:
return false
}
}문자열 입력을 변환하는 함수도 만들 수 있습니다.
func ParseOrderStatus(value string) (OrderStatus, error) {
switch value {
case "pending":
return OrderStatusPending, nil
case "processing":
return OrderStatusProcessing, nil
case "completed":
return OrderStatusCompleted, nil
case "canceled":
return OrderStatusCanceled, nil
default:
return OrderStatusUnknown, fmt.Errorf("지원하지 않는 상태: %q", value)
}
}문자열 기반 상태 타입
외부 API나 JSON에 문자열 상태를 그대로 사용해야 한다면 문자열 기반 타입도 사용할 수 있습니다.
type PaymentStatus string
const (
PaymentStatusPending PaymentStatus = "pending"
PaymentStatusPaid PaymentStatus = "paid"
PaymentStatusFailed PaymentStatus = "failed"
PaymentStatusCanceled PaymentStatus = "canceled"
)장점은 로그와 JSON에서 값의 의미가 바로 보인다는 것입니다.
status := PaymentStatusPaid
fmt.Println(status) // paid정수 기반 타입은 비교와 저장이 간결하고 iota를 사용할 수 있습니다. 문자열 기반 타입은 외부 표현이 명확합니다. 어떤 방식을 사용할지는 저장 형식, API 규격, 호환성 요구에 따라 결정하면 됩니다.
iota값의 중간에 새 상수를 삽입하면 뒤에 있는 숫자 값이 바뀔 수 있습니다. DB나 외부 프로토콜에 숫자를 저장한다면 값을 명시적으로 고정하거나 기존 순서를 변경하지 않는 방식이 안전합니다.
예를 들어 외부에 저장되는 값이라면 다음처럼 명시할 수 있습니다.
const (
OrderStatusUnknown OrderStatus = 0
OrderStatusPending OrderStatus = 10
OrderStatusProcessing OrderStatus = 20
OrderStatusCompleted OrderStatus = 30
OrderStatusCanceled OrderStatus = 40
)embedding과 composition
임베딩은 필드 이름을 따로 지정하지 않고 타입 이름 자체를 구조체 필드로 선언하는 방식입니다.
type AuditInfo struct {
CreatedBy string
UpdatedBy string
}
type Document struct {
ID int64
Title string
AuditInfo
}Document는 AuditInfo 값을 내부 필드로 보유합니다.
document := Document{
ID: 1,
Title: "설계 문서",
AuditInfo: AuditInfo{
CreatedBy: "kim",
UpdatedBy: "lee",
},
}임베딩된 필드에는 타입 이름으로 직접 접근할 수 있습니다.
fmt.Println(document.AuditInfo.CreatedBy)필드 승격이 적용되면 바깥 구조체에서 바로 접근할 수도 있습니다.
fmt.Println(document.CreatedBy)document.CreatedBy는 편의상 승격된 selector이며, 실제로 AuditInfo 필드가 사라지거나 Document가 AuditInfo의 하위 클래스가 되는 것은 아닙니다.
임베딩은 상속이 아니다
다음 구조를 보겠습니다.
type Base struct {
Name string
}
func (b Base) Describe() string {
return "Base: " + b.Name
}
type Extended struct {
Base
Code string
}Extended는 Base를 임베딩했으므로 승격된 메서드를 호출할 수 있습니다.
value := Extended{
Base: Base{Name: "example"},
Code: "A-100",
}
fmt.Println(value.Describe())그러나 Extended가 Base의 하위 타입이 되는 것은 아닙니다.
func PrintBase(value Base) {
fmt.Println(value.Name)
}
extended := Extended{
Base: Base{Name: "example"},
}
// PrintBase(extended) // 컴파일 오류
PrintBase(extended.Base) // 가능상속 언어에서 기대하는 "Extended is a Base" 관계가 자동으로 만들어지지 않습니다. Extended는 Base 필드를 포함하는 별도의 타입입니다.
즉, 임베딩은 다음에 가깝습니다.
Extended has a Base다음 의미가 아닙니다.
Extended is a Base메서드 승격
임베딩된 타입의 메서드도 바깥 타입에서 호출할 수 있도록 승격될 수 있습니다.
type Logger struct{}
func (Logger) Log(message string) {
fmt.Println("[LOG]", message)
}
type Service struct {
Logger
}service := Service{}
service.Log("서비스 시작")이는 다음 호출을 간단히 쓴 것입니다.
service.Logger.Log("서비스 시작")바깥 타입에서 같은 이름의 메서드를 정의하면 바깥 타입의 메서드가 선택됩니다.
func (Service) Log(message string) {
fmt.Println("[SERVICE]", message)
}service.Log("서비스 시작") // Service.Log
service.Logger.Log("서비스 시작") // Logger.Log이를 메서드 재정의에 의한 동적 다형성으로 이해하면 안 됩니다. 바깥 타입에 선언된 메서드가 selector에서 우선 선택되는 것이며, 임베딩된 타입의 메서드는 여전히 명시적으로 호출할 수 있습니다.
이름 충돌
같은 깊이에서 동일한 이름이 둘 이상 승격되면 어느 필드나 메서드를 선택할지 모호해집니다.
type PrimaryContact struct {
Email string
}
type BackupContact struct {
Email string
}
type Customer struct {
PrimaryContact
BackupContact
}다음 코드는 모호하므로 컴파일되지 않습니다.
customer := Customer{}
// fmt.Println(customer.Email)경로를 명시해야 합니다.
fmt.Println(customer.PrimaryContact.Email)
fmt.Println(customer.BackupContact.Email)pointer embedding
값 타입뿐 아니라 포인터 타입도 임베딩할 수 있습니다.
type Metrics struct {
Count int
}
func (m *Metrics) Increase() {
m.Count++
}
type Worker struct {
*Metrics
}초기화할 때 임베딩된 포인터가 nil이 되지 않도록 주의해야 합니다.
worker := Worker{
Metrics: &Metrics{},
}
worker.Increase()
fmt.Println(worker.Count) // 1다음 값에서는 Metrics가 nil입니다.
worker := Worker{}이 상태에서 worker.Increase()를 호출하면 메서드 구현이 nil receiver를 처리하지 않는 한 panic이 발생할 수 있습니다.
인터페이스 임베딩
인터페이스도 다른 인터페이스를 임베딩할 수 있습니다.
type Reader interface {
Read() ([]byte, error)
}
type Writer interface {
Write([]byte) error
}
type ReadWriter interface {
Reader
Writer
}ReadWriter를 구현하려면 Read와 Write를 모두 제공해야 합니다.
이는 구조체 임베딩과 달리 인터페이스가 요구하는 method set을 조합하는 방식입니다.
전체 실행 예제
아래 예제는 다음 내용을 한 번에 확인합니다.
- 구조체 생성과 필드 접근
- value receiver와 pointer receiver
- 인터페이스의 암시적 구현
- method set에 따른 값과 포인터의 차이
- 정수 기반 enum 패턴
- 구조체 임베딩과 메서드 승격
- 인터페이스 임베딩
sources/06-structs-methods-interfaces/main.go:
package main
import (
"fmt"
)
type ProductStatus int
const (
ProductStatusUnknown ProductStatus = iota
ProductStatusDraft
ProductStatusActive
ProductStatusSoldOut
)
func (s ProductStatus) String() string {
switch s {
case ProductStatusDraft:
return "draft"
case ProductStatusActive:
return "active"
case ProductStatusSoldOut:
return "sold_out"
default:
return "unknown"
}
}
func (s ProductStatus) IsValid() bool {
switch s {
case ProductStatusDraft,
ProductStatusActive,
ProductStatusSoldOut:
return true
default:
return false
}
}
type AuditInfo struct {
CreatedBy string
UpdatedBy string
}
func (a AuditInfo) Summary() string {
return fmt.Sprintf("created_by=%s, updated_by=%s", a.CreatedBy, a.UpdatedBy)
}
type Product struct {
ID int64
Name string
Price int
Status ProductStatus
AuditInfo
}
func (p Product) Display() string {
return fmt.Sprintf(
"id=%d, name=%s, price=%d, status=%s",
p.ID,
p.Name,
p.Price,
p.Status,
)
}
func (p *Product) ChangePrice(price int) error {
if price < 0 {
return fmt.Errorf("가격은 0 이상이어야 합니다: %d", price)
}
p.Price = price
return nil
}
func (p *Product) Activate() {
p.Status = ProductStatusActive
}
type PriceChanger interface {
ChangePrice(int) error
}
type Describer interface {
Display() string
}
type ManagedProduct interface {
PriceChanger
Describer
}
func UpdatePrice(changer PriceChanger, price int) error {
return changer.ChangePrice(price)
}
func PrintDescription(describer Describer) {
fmt.Println(describer.Display())
}
func main() {
product := Product{
ID: 1001,
Name: "무선 키보드",
Price: 59000,
Status: ProductStatusDraft,
AuditInfo: AuditInfo{
CreatedBy: "kim",
UpdatedBy: "kim",
},
}
fmt.Println("1. 구조체와 value receiver")
fmt.Println(product.Display())
fmt.Println()
fmt.Println("2. pointer receiver로 상태 변경")
if err := UpdatePrice(&product, 55000); err != nil {
fmt.Println("가격 변경 실패:", err)
return
}
product.Activate()
fmt.Println(product.Display())
fmt.Println()
fmt.Println("3. 임베딩된 필드와 메서드 승격")
fmt.Println("생성자:", product.CreatedBy)
fmt.Println("감사 정보:", product.Summary())
fmt.Println("명시적 호출:", product.AuditInfo.Summary())
fmt.Println()
fmt.Println("4. 인터페이스 임베딩")
var managed ManagedProduct = &product
if err := managed.ChangePrice(52000); err != nil {
fmt.Println("가격 변경 실패:", err)
return
}
PrintDescription(managed)
fmt.Println()
fmt.Println("5. enum 패턴과 유효성 검사")
statuses := []ProductStatus{
ProductStatusUnknown,
ProductStatusDraft,
ProductStatusActive,
ProductStatusSoldOut,
ProductStatus(100),
}
for _, status := range statuses {
fmt.Printf(
"value=%d, name=%s, valid=%t\n",
status,
status,
status.IsValid(),
)
}
}실행합니다.
go run .출력 예:
1. 구조체와 value receiver
id=1001, name=무선 키보드, price=59000, status=draft
2. pointer receiver로 상태 변경
id=1001, name=무선 키보드, price=55000, status=active
3. 임베딩된 필드와 메서드 승격
생성자: kim
감사 정보: created_by=kim, updated_by=kim
명시적 호출: created_by=kim, updated_by=kim
4. 인터페이스 임베딩
id=1001, name=무선 키보드, price=52000, status=active
5. enum 패턴과 유효성 검사
value=0, name=unknown, valid=false
value=1, name=draft, valid=true
value=2, name=active, valid=true
value=3, name=sold_out, valid=true
value=100, name=unknown, valid=false이 예제에서 Product에는 Display()가 value receiver로 선언되어 있고, ChangePrice()와 Activate()는 pointer receiver로 선언되어 있습니다.
따라서 method set은 다음과 같습니다.
| 타입 | 주요 method set |
|---|---|
Product |
Display, 승격된 Summary |
*Product |
Display, ChangePrice, Activate, 승격된 Summary |
ManagedProduct는 Display()와 ChangePrice(int) error를 모두 요구합니다. Product 값에는 ChangePrice가 없으므로 구현하지 못하고, *Product가 ManagedProduct를 구현합니다.
var managed ManagedProduct = &product다음 대입은 컴파일되지 않습니다.
// var managed ManagedProduct = product정리
struct는 관련된 필드를 하나의 값으로 묶는 타입입니다. 구조체는 값 타입이므로 대입하거나 value receiver에 전달하면 값이 복사됩니다.
method는 receiver를 통해 특정 타입에 연결된 함수입니다. value receiver는 receiver 복사본을 받고, pointer receiver는 원본 값에 접근할 수 있으므로 상태 변경이 필요한 메서드에 주로 사용합니다.
interface는 필요한 메서드 집합을 표현합니다. Go에는 implements 선언이 없으며 타입의 method set이 인터페이스 요구사항을 만족하면 암시적으로 구현합니다.
Go에는 전용 enum 키워드가 없습니다. 이름 있는 정수 또는 문자열 타입과 const, 필요에 따라 iota를 조합해 상수 집합을 표현합니다. 상수 밖의 값도 만들어질 수 있으므로 외부 입력에는 유효성 검사를 추가하는 것이 좋습니다.
embedding은 다른 타입을 필드 이름 없이 포함하고 필드와 메서드 승격을 제공하는 합성 방식입니다. 임베딩된 타입과 바깥 타입 사이에 상속 관계가 생기는 것은 아닙니다.
핵심 관계를 정리하면 다음과 같습니다.
struct = 관련된 데이터를 필드로 묶는 타입
method = receiver 타입에 연결된 함수
interface = 필요한 method set을 표현하는 타입
enum 패턴 = 이름 있는 타입과 const/iota로 표현하는 상수 집합
embedding = 필드 보유와 승격을 제공하는 composition특히 인터페이스를 사용할 때는 value receiver와 pointer receiver가 만드는 method set의 차이를 확인해야 합니다.
T의 method set = receiver가 T인 메서드
*T의 method set = receiver가 T 또는 *T인 메서드이 차이 때문에 pointer receiver 메서드가 인터페이스 구현에 필요한 경우에는 값 T가 아니라 포인터 *T를 인터페이스에 대입해야 합니다.
참고 자료
- Go 언어 명세 - Struct types: https://go.dev/ref/spec#Struct_types
- Go 언어 명세 - Method declarations: https://go.dev/ref/spec#Method_declarations
- Go 언어 명세 - Method sets: https://go.dev/ref/spec#Method_sets
- Go 언어 명세 - Interface types: https://go.dev/ref/spec#Interface_types
- Go 언어 명세 - Iota: https://go.dev/ref/spec#Iota
- A Tour of Go - Methods and interfaces: https://go.dev/tour/methods/1
- Effective Go - Embedding: https://go.dev/doc/effective_go#embedding
'프로그래밍 > Go' 카테고리의 다른 글
| Go 인터페이스의 암시적 구현과 Java 인터페이스와의 차이 (0) | 2026.07.15 |
|---|---|
| Go struct와 receiver (0) | 2026.07.15 |
| Go 포인터, string, rune (0) | 2026.07.14 |
| Go 함수, closure, recursion (1) | 2026.07.14 |
| Go 배열, slice, map, range (0) | 2026.07.13 |
댓글