Go의 구조체, 메서드, 암묵적 인터페이스 만족, 제네릭(타입 파라미터), 타입 제약 조건, 임베딩을 통한 합성 패턴을 체계적으로 다룹니다.
구조체(Struct)는 Go에서 데이터를 묶는 기본 단위입니다. 클래스가 없는 Go에서 구조체는 상태(필드)를 담는 유일한 사용자 정의 복합 타입입니다.
type User struct {
ID int64
Email string
Name string
CreatedAt time.Time
IsActive bool
}
// 구조체 초기화 -- 필드명 명시 방식 권장
user := User{
ID: 1,
Email: "kreath@example.com",
Name: "Kreath",
CreatedAt: time.Now(),
IsActive: true,
}구조체 초기화 시 필드명을 명시하는 방식을 사용하세요. 필드 순서에 의존하는 방식은 구조체에 필드가 추가될 때 컴파일 에러를 유발합니다.
메서드는 특정 타입에 연결된 함수입니다. **리시버(Receiver)**를 통해 구조체와 메서드를 연결합니다.
// 값 리시버 -- 구조체의 복사본에서 동작
func (u User) FullName() string {
return u.Name
}
// 포인터 리시버 -- 구조체를 직접 수정 가능
func (u *User) Deactivate() {
u.IsActive = false
}리시버를 값 타입으로 할지 포인터 타입으로 할지는 중요한 설계 결정입니다.
| 기준 | 값 리시버 | 포인터 리시버 |
|---|---|---|
| 구조체 수정 | 불가 | 가능 |
| 복사 비용 | 크기에 비례 | 8바이트 고정 |
| nil 호출 | 불가 | 가능 (주의 필요) |
| 관례 | 불변 메서드 | 변경 메서드 |
하나의 타입에서 값 리시버와 포인터 리시버를 혼용하면 혼란을 야기합니다. 일반적으로 구조체의 모든 메서드에 동일한 리시버 타입을 사용하는 것이 권장됩니다. 구조체를 수정하는 메서드가 하나라도 있다면, 모든 메서드를 포인터 리시버로 통일하세요.
Go의 인터페이스는 다른 언어와 근본적으로 다릅니다. Java나 C#에서는 implements 키워드를 명시해야 하지만, Go에서는 인터페이스에 정의된 메서드를 모두 구현하면 자동으로 해당 인터페이스를 만족합니다. 이를 덕 타이핑(Duck Typing)이라고 부르기도 합니다.
// 인터페이스 정의
type Writer interface {
Write(data []byte) (int, error)
}
// FileWriter는 Writer 인터페이스를 "자동으로" 만족
type FileWriter struct {
path string
}
func (fw *FileWriter) Write(data []byte) (int, error) {
return os.WriteFile(fw.path, data, 0644), nil
}
// ConsoleWriter도 Writer 인터페이스를 만족
type ConsoleWriter struct{}
func (cw *ConsoleWriter) Write(data []byte) (int, error) {
return fmt.Print(string(data))
}Go 표준 라이브러리에서 가장 널리 사용되는 인터페이스들은 메서드가 1-2개뿐입니다.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
type Stringer interface {
String() string
}
type Error interface {
Error() string
}이 작은 인터페이스들이 합성되어 강력한 추상화를 만듭니다.
// 인터페이스 합성
type ReadWriter interface {
Reader
Writer
}
type ReadCloser interface {
Reader
io.Closer
}Go 커뮤니티에서는 "인터페이스가 클수록 추상화가 약하다(The bigger the interface, the weaker the abstraction)"라는 격언이 있습니다. 인터페이스에 메서드를 추가할수록 해당 인터페이스를 구현하는 타입이 줄어들기 때문입니다.
암묵적 인터페이스 만족은 의존성 역전(Dependency Inversion)과 테스트에서 큰 이점을 줍니다. 서드파티 라이브러리의 구체 타입에 의존하는 대신, 사용하는 쪽에서 인터페이스를 정의할 수 있습니다.
// 사용하는 쪽에서 필요한 인터페이스를 정의
type UserRepository interface {
FindByID(ctx context.Context, id int64) (*User, error)
Save(ctx context.Context, user *User) error
}
type UserService struct {
repo UserRepository // 인터페이스에 의존
}
// 테스트에서는 모의 객체(Mock)를 주입
type mockUserRepo struct{}
func (m *mockUserRepo) FindByID(ctx context.Context, id int64) (*User, error) {
return &User{ID: id, Name: "테스트 사용자"}, nil
}
func (m *mockUserRepo) Save(ctx context.Context, user *User) error {
return nil
}Go 1.18에서 도입된 **제네릭(Generics)**은 타입 안전성을 유지하면서 코드 재사용성을 높여줍니다.
// 타입 파라미터를 가진 함수
func Map[T any, U any](slice []T, fn func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = fn(v)
}
return result
}
// 사용
names := Map(users, func(u User) string {
return u.Name
})타입 제약 조건(Type Constraints)은 타입 파라미터가 만족해야 하는 조건을 정의합니다. any는 가장 느슨한 제약이며, 더 구체적인 제약을 통해 타입 파라미터에서 사용할 수 있는 연산을 명시할 수 있습니다.
// 숫자 타입으로 제한
type Number interface {
~int | ~int32 | ~int64 | ~float32 | ~float64
}
func Sum[T Number](numbers []T) T {
var total T
for _, n := range numbers {
total += n
}
return total
}
// comparable 제약 -- == 연산자 사용 가능
func Contains[T comparable](slice []T, target T) bool {
for _, v := range slice {
if v == target {
return true
}
}
return false
}~int의 틸드(Tilde) 문법은 기본 타입이 int인 모든 정의된 타입을 포함한다는 의미입니다. 예를 들어 type UserID int도 ~int에 포함됩니다.
// 제네릭 결과 타입
type Result[T any] struct {
Data T
Error error
}
func NewResult[T any](data T, err error) Result[T] {
return Result[T]{Data: data, Error: err}
}
// 제네릭 캐시
type Cache[K comparable, V any] struct {
mu sync.RWMutex
items map[K]V
}
func (c *Cache[K, V]) Get(key K) (V, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.items[key]
return v, ok
}
func (c *Cache[K, V]) Set(key K, value V) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}Go의 제네릭은 의도적으로 단순하게 설계되었습니다. 타입 파라미터의 메서드 지원, 공변성/반공변성, 고차 타입 파라미터 등은 지원하지 않습니다. 제네릭을 과도하게 사용하면 오히려 코드 가독성이 떨어질 수 있으니 꼭 필요한 곳에만 사용하세요.
Go에서 합성(Composition)의 핵심 메커니즘은 구조체 임베딩입니다. 필드명 없이 타입만 선언하면, 해당 타입의 메서드와 필드가 외부 구조체로 "승격(Promoted)"됩니다.
type Timestamps struct {
CreatedAt time.Time
UpdatedAt time.Time
}
func (t *Timestamps) Touch() {
t.UpdatedAt = time.Now()
}
type Article struct {
Timestamps // 임베딩 -- 상속이 아닌 합성
ID int64
Title string
Content string
}
func main() {
article := Article{
ID: 1,
Title: "Go 타입 시스템",
}
article.Touch() // Timestamps의 메서드 직접 호출
fmt.Println(article.UpdatedAt) // Timestamps의 필드 직접 접근
}인터페이스도 다른 인터페이스를 임베딩하여 합성할 수 있습니다. 이를 통해 작은 인터페이스들을 조합하여 더 큰 인터페이스를 만듭니다.
type Repository[T any] interface {
Finder[T]
Saver[T]
Deleter
}
type Finder[T any] interface {
FindByID(ctx context.Context, id int64) (*T, error)
FindAll(ctx context.Context) ([]*T, error)
}
type Saver[T any] interface {
Save(ctx context.Context, entity *T) error
}
type Deleter interface {
Delete(ctx context.Context, id int64) error
}Go에서 자주 사용되는 함수형 옵션 패턴(Functional Options Pattern)은 인터페이스와 함수 타입을 결합한 우아한 설정 방법입니다.
type Server struct {
host string
port int
readTimeout time.Duration
writeTimeout time.Duration
maxConns int
}
type Option func(*Server)
func WithPort(port int) Option {
return func(s *Server) {
s.port = port
}
}
func WithTimeouts(read, write time.Duration) Option {
return func(s *Server) {
s.readTimeout = read
s.writeTimeout = write
}
}
func WithMaxConns(n int) Option {
return func(s *Server) {
s.maxConns = n
}
}
func NewServer(host string, opts ...Option) *Server {
s := &Server{
host: host,
port: 8080, // 기본값
readTimeout: 5 * time.Second,
writeTimeout: 10 * time.Second,
maxConns: 100,
}
for _, opt := range opts {
opt(s)
}
return s
}
// 사용 -- 필요한 옵션만 선택적으로 지정
server := NewServer("0.0.0.0",
WithPort(3000),
WithMaxConns(500),
)이 패턴은 생성자의 매개변수가 많아질 때, 기본값을 유지하면서도 유연한 설정을 가능하게 합니다. 표준 라이브러리의 net/http, gRPC, 그리고 대부분의 인기 라이브러리에서 이 패턴을 볼 수 있습니다.
이번 장에서 살펴본 핵심 내용을 정리합니다.
implements 키워드 없이 인터페이스를 구현합니다3장에서는 고루틴과 채널을 다룹니다. Go의 동시성 모델의 핵심인 고루틴의 내부 동작 원리, 버퍼드/언버퍼드 채널, sync 패키지의 동기화 프리미티브, 그리고 고루틴 생명주기 관리를 살펴봅니다.
이 글이 도움이 되셨나요?
Go 언어의 핵심 철학인 단순성과 합성을 살펴보고, 백엔드 개발에서 Go가 가진 빠른 컴파일, 단일 바이너리, 고루틴 등의 강점을 Java, Python, Rust와 비교하여 분석합니다.
Go의 동시성 핵심인 고루틴의 내부 구조, 버퍼드/언버퍼드 채널, sync 패키지의 WaitGroup과 Mutex, 고루틴 생명주기 관리와 일반적인 동시성 패턴을 다룹니다.
Go의 고급 동시성 패턴인 select 문, context.Context를 활용한 취소/타임아웃, errgroup, 팬아웃/팬인, 파이프라인, 워커 풀, 레이트 리미팅 패턴을 다룹니다.