2025-12-19
Go 언어에는 **클래스(Class)**가 없습니다. 대신 **구조체(struct)**와 메서드(Method), 그리고 **인터페이스(Interface)**를 조합하여 객체지향 프로그래밍의 핵심 개념들을 구현합니다. 이는 Go 언어의 설계 철학인 “단순함과 명확함”을 반영한 선택입니다.
이 글에서는 struct의 기본 정의부터 시작하여, 메서드 정의와 리시버(Receiver) 패턴, 임베딩(Embedding)을 통한 구조체 합성, 그리고 **구조체 태그(Struct Tags)**를 활용한 JSON 변환까지 실무에서 반드시 알아야 할 모든 패턴을 심층적으로 다룹니다.
🛠️ 실무에서 struct의 중요성
필자가 웹 API 서버나 데이터 파이프라인을 구축할 때, struct는 피할 수 없는 핵심 도구입니다. 데이터베이스 모델, HTTP 요청/응답 구조, 비즈니스 로직의 도메인 객체 등 모든 곳에서 struct를 사용하게 됩니다. 특히 타입 안전성(Type Safety)을 보장하면서도 유연한 설계가 가능하다는 점이 Go struct의 가장 큰 강점입니다.
구조체는 서로 다른 타입의 데이터를 하나의 논리적 단위로 묶는 사용자 정의 타입입니다. 이전에 배운 type 키워드를 사용하여 정의합니다.
type Person struct {
Name string
Age int
}
이 코드는 Person이라는 새로운 데이터 타입을 정의합니다. 이 타입은 Name(문자열)과 Age(정수) 두 개의 **필드(Field)**를 가집니다.
구조체 변수를 선언만 하고 초기화하지 않으면, 각 필드는 해당 타입의 Zero Value로 자동 초기화됩니다.
var p Person
fmt.Println(p) // 출력: { 0} — Name은 "" (빈 문자열), Age는 0
이는 Go 언어의 메모리 안전성을 보장하는 중요한 특징입니다. 초기화되지 않은 변수를 사용해도 Undefined Behavior가 발생하지 않습니다.
구조체를 사용하려면 먼저 초기화해야 합니다. Go 언어는 상황에 맞는 다양한 초기화 방법을 제공합니다.
var p Person
// p.Name = "", p.Age = 0
모든 필드가 Zero Value로 초기화됩니다.
p := Person{Name: "Alice", Age: 25}
가장 권장되는 방식입니다. 필드 이름을 명시하므로 가독성이 높고, 필드 순서가 바뀌어도 영향을 받지 않습니다.
// 순서를 바꿔도 동일한 결과
p := Person{Age: 25, Name: "Alice"}
p := Person{"Alice", 25}
필드 이름을 생략하고 정의 순서대로 값을 나열합니다. 짧아 보이지만 실무에서는 권장되지 않습니다. 구조체 정의가 변경되면 모든 초기화 코드를 수정해야 하기 때문입니다.
new 키워드를 사용한 초기화p := new(Person)
// p는 *Person 타입 (포인터)
// p.Name = "", p.Age = 0
new(Person)은 Zero Value로 초기화된 Person 구조체의 포인터를 반환합니다. 포인터가 필요한 경우에 사용합니다.
💡 포인터 vs 값
new를 사용하면 포인터를 얻지만, 리터럴 초기화에&를 붙여도 동일한 효과를 얻을 수 있습니다.p := &Person{Name: "Alice", Age: 25} // p는 *Person 타입
구조체의 필드는 . (dot) 연산자로 접근합니다.
p := Person{Name: "Alice", Age: 25}
fmt.Println(p.Name) // 출력: Alice
p.Age = 26
fmt.Println(p.Age) // 출력: 26
Go 언어의 강력한 편의 기능 중 하나는 포인터에 대해서도 . 연산자를 사용할 수 있다는 점입니다.
p := new(Person)
p.Name = "Bob" // (*p).Name = "Bob"와 동일
p.Age = 27
fmt.Println(p) // 출력: &{Bob 27}
C 언어였다면 p->Name 또는 (*p).Name처럼 명시적으로 역참조해야 하지만, Go 언어는 컴파일러가 자동으로 처리합니다.
Go 언어에서는 구조체에 메서드를 정의하여 해당 타입의 “행위(Behavior)“를 구현합니다. 메서드는 **리시버(Receiver)**를 가진 함수입니다.
type Person struct {
Name string
Age int
}
// Person 타입의 메서드
func (p Person) Greet() string {
return "안녕하세요, 제 이름은 " + p.Name + "입니다."
}
func main() {
alice := Person{Name: "Alice", Age: 25}
fmt.Println(alice.Greet()) // 출력: 안녕하세요, 제 이름은 Alice입니다.
}
(p Person) 부분이 **리시버(Receiver)**입니다. 이는 Greet 메서드가 Person 타입에 속한다는 것을 의미합니다.
위 예시의 (p Person)처럼 구조체를 값으로 받는 리시버입니다. 메서드 내부에서 필드를 수정해도 원본에 영향을 주지 않습니다.
func (p Person) HaveBirthday() {
p.Age++ // 복사본의 Age만 증가
}
func main() {
alice := Person{Name: "Alice", Age: 25}
alice.HaveBirthday()
fmt.Println(alice.Age) // 출력: 25 (변경되지 않음)
}
리시버를 포인터로 받으면 원본 구조체를 직접 수정할 수 있습니다.
func (p *Person) HaveBirthday() {
p.Age++ // 원본의 Age를 증가
}
func main() {
alice := Person{Name: "Alice", Age: 25}
alice.HaveBirthday()
fmt.Println(alice.Age) // 출력: 26 (변경됨)
}
🛠️ 실무 Best Practice
- 구조체 크기가 큰 경우: 포인터 리시버를 사용하여 복사 비용을 줄입니다.
- 필드를 수정해야 하는 경우: 반드시 포인터 리시버를 사용합니다.
- 일관성 유지: 한 타입의 모든 메서드는 가급적 같은 종류의 리시버를 사용합니다.
Go 언어에는 상속(Inheritance)이 없지만, **임베딩(Embedding)**을 통해 구조체를 합성(Composition)할 수 있습니다.
type Address struct {
City string
Country string
}
type Employee struct {
Name string
Age int
Address // 필드 이름 없이 타입만 명시 (임베딩)
}
func main() {
emp := Employee{
Name: "Alice",
Age: 30,
Address: Address{
City: "Seoul",
Country: "Korea",
},
}
// 임베딩된 필드에 직접 접근 가능
fmt.Println(emp.City) // 출력: Seoul
fmt.Println(emp.Country) // 출력: Korea
}
Address가 Employee 안에 이름 없이 포함되어 있습니다. 이를 익명 필드(Anonymous Field) 또는 임베딩이라고 합니다.
임베딩된 타입의 메서드도 외부 타입에서 직접 호출할 수 있습니다.
type Address struct {
City string
Country string
}
func (a Address) FullAddress() string {
return a.City + ", " + a.Country
}
type Employee struct {
Name string
Address
}
func main() {
emp := Employee{
Name: "Alice",
Address: Address{City: "Seoul", Country: "Korea"},
}
// Address의 메서드를 Employee에서 직접 호출
fmt.Println(emp.FullAddress()) // 출력: Seoul, Korea
}
이는 Go 언어의 구조체 합성(Composition over Inheritance) 철학을 구현하는 핵심 메커니즘입니다.
구조체 필드 뒤에 백틱(`)으로 감싼 문자열을 추가하여 메타데이터를 정의할 수 있습니다. 이를 **구조체 태그(Struct Tag)**라고 합니다.
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
구조체 태그는 주로 encoding/json 패키지와 함께 사용되어 JSON 필드 이름을 지정합니다.
package main
import (
"encoding/json"
"fmt"
)
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
}
func main() {
// 구조체 → JSON
p := Person{Name: "Alice", Age: 25}
jsonData, _ := json.Marshal(p)
fmt.Println(string(jsonData)) // 출력: {"name":"Alice","age":25}
// JSON → 구조체
jsonStr := `{"name":"Bob","age":30}`
var p2 Person
json.Unmarshal([]byte(jsonStr), &p2)
fmt.Println(p2) // 출력: {Bob 30}
}
type User struct {
ID int `json:"id"`
Name string `json:"name,omitempty"` // 값이 Zero Value면 JSON에서 생략
Password string `json:"-"` // JSON 변환에서 제외
Email string `json:"email_address"` // 다른 이름으로 매핑
}
| 태그 옵션 | 의미 |
|---|---|
omitempty | Zero Value일 경우 JSON에서 필드 생략 |
- | JSON 변환에서 완전히 제외 |
"이름" | JSON 필드 이름을 구조체 필드 이름과 다르게 지정 |
🛠️ 실무 팁
API 응답 구조체에서
omitempty를 사용하면 불필요한 필드 전송을 줄여 네트워크 비용을 절감할 수 있습니다. 특히 선택적 필드가 많은 경우 유용합니다.
구조체는 모든 필드가 비교 가능한 타입일 경우에만 ==와 != 연산자로 비교할 수 있습니다.
type Point struct {
X, Y int
}
p1 := Point{X: 1, Y: 2}
p2 := Point{X: 1, Y: 2}
fmt.Println(p1 == p2) // 출력: true
슬라이스, 맵, 함수 타입 필드를 포함하면 비교 불가능합니다.
type Container struct {
Items []int // 슬라이스는 비교 불가
}
c1 := Container{Items: []int{1, 2}}
c2 := Container{Items: []int{1, 2}}
// fmt.Println(c1 == c2) // ❌ 컴파일 에러: invalid operation
이 경우 reflect.DeepEqual을 사용하거나, 직접 비교 로직을 구현해야 합니다.
Go 언어에는 생성자(Constructor)가 없지만, 관례적으로 New 접두사를 가진 함수를 정의합니다.
type Config struct {
Host string
Port int
}
// 생성자 함수
func NewConfig(host string, port int) *Config {
return &Config{
Host: host,
Port: port,
}
}
func main() {
cfg := NewConfig("localhost", 8080)
fmt.Println(cfg)
}
많은 선택적 파라미터가 있을 때 사용하는 고급 패턴입니다.
type Server struct {
Host string
Port int
Timeout int
}
type Option func(*Server)
func WithTimeout(t int) Option {
return func(s *Server) {
s.Timeout = t
}
}
func NewServer(host string, port int, opts ...Option) *Server {
s := &Server{
Host: host,
Port: port,
Timeout: 30, // 기본값
}
for _, opt := range opts {
opt(s)
}
return s
}
func main() {
srv := NewServer("localhost", 8080, WithTimeout(60))
fmt.Println(srv) // 출력: &{localhost 8080 60}
}
struct{}는 메모리를 전혀 차지하지 않는 특수한 타입입니다. 주로 시그널링 용도로 사용됩니다.
done := make(chan struct{}) // 메모리 효율적인 채널
go func() {
// 작업 수행...
done <- struct{}{} // 완료 신호
}()
<-done // 완료 대기
Go 언어에서 구조체는 단순한 “데이터 묶음”을 넘어, 타입 시스템의 중심이자 객체지향 설계의 기반입니다. 클래스가 없는 대신, 구조체와 메서드, 인터페이스, 임베딩을 조합하여 더 명확하고 유연한 설계가 가능합니다.
오늘 배운 핵심을 요약하면:
type 키워드로 정의하며, 필드들은 각 타입의 Zero Value로 자동 초기화된다.다음 글에서는 Go 언어의 **인터페이스(Interface)**를 다루며, 구조체와 메서드를 넘어 **다형성(Polymorphism)**과 **추상화(Abstraction)**를 구현하는 방법을 알아보겠습니다. Go가 어떻게 덕 타이핑(Duck Typing)과 정적 타입 시스템을 결합하는지 확인해보세요!