본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 11. 4. · 23 Views
Golang 핵심 개념 완벽 정리
Go 언어를 처음 시작하는 개발자를 위한 필수 개념 가이드입니다. 고루틴, 채널, 인터페이스 등 실무에서 가장 많이 사용되는 핵심 개념들을 실전 예제와 함께 정리했습니다. 이 가이드 하나로 Go의 기본기를 완벽하게 다질 수 있습니다.
목차
- 고루틴 - 동시성 프로그래밍의 핵심
- 채널 - 고루틴 간 안전한 통신
- 인터페이스 - 유연한 추상화의 핵심
- 구조체와 메서드 - 객체지향의 Go 스타일
- 에러 처리 - 명시적이고 안전한 방법
- 슬라이스와 배열 - 동적 데이터 컬렉션
- 맵 - 키-값 데이터 저장소
- 포인터 - 메모리 주소 직접 다루기
1. 고루틴 - 동시성 프로그래밍의 핵심
시작하며
여러분이 웹 서버를 만들 때 여러 사용자의 요청을 동시에 처리해야 하는 상황을 겪어본 적 있나요? 한 번에 한 명의 요청만 처리한다면 다른 사용자들은 계속 기다려야 하는 답답한 상황이 발생합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특히 API 서버나 데이터 처리 시스템에서는 동시에 여러 작업을 처리하지 못하면 성능이 크게 저하되고, 사용자 경험도 나빠집니다.
바로 이럴 때 필요한 것이 고루틴(Goroutine)입니다. 고루틴은 Go 언어의 가장 강력한 특징 중 하나로, 수천 개의 동시 작업을 가벼운 비용으로 처리할 수 있게 해줍니다.
개요
간단히 말해서, 고루틴은 Go 런타임이 관리하는 경량 스레드입니다. 일반 스레드보다 훨씬 적은 메모리를 사용하며(약 2KB), 수만 개를 동시에 실행할 수 있습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 동시 다발적으로 발생하는 작업들을 효율적으로 처리하기 위해서입니다. 예를 들어, API 서버에서 100개의 요청이 동시에 들어왔을 때 각 요청을 별도의 고루틴으로 처리하면 모든 요청을 빠르게 응답할 수 있습니다.
기존에는 운영체제 수준의 스레드를 생성하고 관리해야 했다면, 이제는 간단히 go 키워드만 붙여서 동시 실행할 수 있습니다. 스레드 풀을 관리하거나 복잡한 동기화 코드를 작성할 필요가 없습니다.
고루틴의 핵심 특징은 세 가지입니다: 첫째, 매우 가볍고 생성 비용이 저렴합니다. 둘째, Go 스케줄러가 자동으로 최적화하여 실행합니다.
셋째, 채널을 통해 안전하게 데이터를 주고받을 수 있습니다. 이러한 특징들이 Go를 동시성 프로그래밍에 최적화된 언어로 만들어줍니다.
코드 예제
// 여러 API를 동시에 호출하는 예제
package main
import (
"fmt"
"time"
)
// API 호출을 시뮬레이션하는 함수
func fetchData(source string) {
fmt.Printf("%s 데이터 가져오는 중...\n", source)
time.Sleep(2 * time.Second) // 네트워크 지연 시뮬레이션
fmt.Printf("%s 데이터 완료!\n", source)
}
func main() {
// 고루틴으로 동시 실행
go fetchData("사용자 API")
go fetchData("상품 API")
go fetchData("주문 API")
// 메인 함수가 종료되지 않도록 대기
time.Sleep(3 * time.Second)
fmt.Println("모든 작업 완료")
}
설명
이것이 하는 일: 이 코드는 세 개의 API 호출을 순차적으로 실행하지 않고 동시에 병렬로 처리합니다. 일반적으로 순차 실행하면 6초가 걸리지만, 고루틴을 사용하면 2초만에 완료됩니다.
첫 번째로, go 키워드를 함수 호출 앞에 붙이면 새로운 고루틴이 생성됩니다. 세 개의 fetchData 함수가 각각 독립적인 고루틴으로 실행되며, 서로를 기다리지 않고 동시에 실행됩니다.
이렇게 하면 전체 실행 시간이 크게 단축됩니다. 두 번째로, 각 고루틴은 독립적으로 동작하면서 fmt.Printf로 진행 상황을 출력합니다.
Go 스케줄러가 CPU 코어에 고루틴들을 자동으로 배분하고 스케줄링하기 때문에, 여러분은 복잡한 스레드 관리를 신경 쓸 필요가 없습니다. 세 번째로, main 함수 끝에 time.Sleep을 사용하는 이유는 메인 함수가 종료되면 모든 고루틴도 함께 종료되기 때문입니다.
실무에서는 sync.WaitGroup이나 채널을 사용하여 더 우아하게 대기합니다. 마지막으로, 모든 고루틴이 작업을 완료하면 "모든 작업 완료" 메시지가 출력됩니다.
여러분이 이 코드를 사용하면 네트워크 요청, 파일 처리, 데이터베이스 쿼리 등 시간이 걸리는 작업들을 동시에 실행하여 전체 처리 시간을 대폭 줄일 수 있습니다. 실무에서는 마이크로서비스 간 통신, 대량 데이터 처리, 실시간 알림 시스템 등에서 고루틴이 필수적으로 사용됩니다.
실전 팁
💡 메인 함수가 종료되면 모든 고루틴이 강제 종료되므로, 실무에서는 반드시 sync.WaitGroup으로 고루틴 완료를 기다려주세요. wg.Add(1), wg.Done(), wg.Wait() 패턴을 익혀두면 안전합니다.
💡 고루틴 내부에서 패닉이 발생하면 전체 프로그램이 종료될 수 있으므로, 중요한 고루틴에는 defer recover()를 사용하여 패닉을 복구하는 코드를 추가하세요.
💡 너무 많은 고루틴을 무분별하게 생성하면 오히려 성능이 저하될 수 있습니다. 워커 풀 패턴을 사용하여 고루틴 개수를 제한하고 재사용하는 것이 좋습니다.
💡 고루틴 간 데이터 공유 시 race condition을 주의하세요. go run -race 명령으로 race detector를 활성화하여 동시성 버그를 미리 발견할 수 있습니다.
💡 context 패키지를 사용하여 고루틴의 생명주기를 관리하고, 타임아웃이나 취소 신호를 전파하는 패턴을 익혀두면 더 안정적인 코드를 작성할 수 있습니다.
2. 채널 - 고루틴 간 안전한 통신
시작하며
여러분이 여러 고루틴을 실행했는데 서로 데이터를 주고받아야 하는 상황을 만났다면 어떻게 하시겠습니까? 일반적으로 공유 메모리를 사용하면 동기화 문제로 인해 데이터가 꼬이거나 프로그램이 예측 불가능하게 동작할 수 있습니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 여러 고루틴이 동시에 같은 변수에 접근하면 race condition이 발생하고, 이를 해결하기 위해 뮤텍스 같은 복잡한 동기화 메커니즘을 사용해야 합니다.
코드는 복잡해지고 버그 발생 가능성도 높아집니다. 바로 이럴 때 필요한 것이 채널(Channel)입니다.
채널은 고루틴 간에 안전하게 데이터를 전달할 수 있는 파이프로, "메모리를 공유하지 말고 통신으로 공유하라"는 Go의 철학을 구현합니다.
개요
간단히 말해서, 채널은 고루틴 간 데이터를 주고받을 수 있는 타입 안전한 큐입니다. 한쪽에서 데이터를 보내면(send) 다른 쪽에서 받을(receive) 수 있으며, 내부적으로 동기화가 자동으로 처리됩니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 여러 작업의 결과를 안전하게 수집하거나 작업 간 조율이 필요할 때 매우 유용합니다. 예를 들어, 여러 고루틴이 데이터를 처리한 결과를 하나의 채널로 보내서 메인 고루틴이 이를 수집하는 패턴은 실무에서 매우 흔합니다.
기존에는 뮤텍스로 공유 메모리를 보호하고 조건 변수로 신호를 보내는 복잡한 코드를 작성해야 했다면, 이제는 채널을 통해 데이터를 보내고 받기만 하면 됩니다. 동기화는 Go 런타임이 알아서 처리합니다.
채널의 핵심 특징은 세 가지입니다: 첫째, 송신과 수신이 기본적으로 블로킹되어 자동 동기화됩니다. 둘째, 버퍼 크기를 지정하여 비동기 통신도 가능합니다.
셋째, close로 채널을 닫아 더 이상 데이터를 보내지 않음을 알릴 수 있습니다. 이러한 특징들이 안전하고 명확한 동시성 코드 작성을 가능하게 합니다.
코드 예제
// 작업 결과를 채널로 수집하는 예제
package main
import (
"fmt"
"time"
)
// 작업을 처리하고 결과를 채널로 보냄
func processTask(id int, results chan<- string) {
time.Sleep(time.Second) // 작업 시뮬레이션
results <- fmt.Sprintf("작업 %d 완료", id) // 채널에 결과 전송
}
func main() {
// 문자열 타입 채널 생성
results := make(chan string, 3)
// 3개의 작업을 고루틴으로 실행
for i := 1; i <= 3; i++ {
go processTask(i, results)
}
// 모든 결과를 수신
for i := 1; i <= 3; i++ {
result := <-results // 채널에서 결과 수신
fmt.Println(result)
}
}
설명
이것이 하는 일: 이 코드는 세 개의 작업을 동시에 실행하고, 각 작업의 결과를 채널을 통해 안전하게 수집합니다. 채널이 고루틴 간 통신의 파이프 역할을 합니다.
첫 번째로, make(chan string, 3)으로 문자열 타입의 버퍼 채널을 생성합니다. 버퍼 크기가 3이라는 것은 채널에 최대 3개의 값을 저장할 수 있다는 의미입니다.
버퍼가 있으면 송신자는 버퍼가 가득 찰 때까지 블로킹되지 않고 계속 값을 보낼 수 있습니다. 이렇게 하면 송신과 수신의 타이밍을 유연하게 조절할 수 있습니다.
두 번째로, processTask 함수는 채널의 송신 전용 타입(chan<- string)을 매개변수로 받습니다. 이는 이 함수가 채널에 값을 보내기만 하고 받지는 않는다는 것을 타입 시스템으로 보장합니다.
results <- value 문법으로 채널에 값을 전송하며, 만약 채널 버퍼가 가득 찼다면 공간이 생길 때까지 블로킹됩니다. 세 번째로, 메인 함수에서는 <-results 문법으로 채널에서 값을 수신합니다.
채널에 값이 없다면 값이 도착할 때까지 블로킹되므로, 별도의 동기화 코드 없이도 모든 작업이 완료될 때까지 자동으로 기다립니다. 마지막으로, 3번의 루프를 통해 모든 작업 결과를 순서대로 출력합니다.
여러분이 이 코드를 사용하면 여러 고루틴의 결과를 안전하게 수집하고, 작업 완료를 자동으로 동기화할 수 있습니다. 실무에서는 워커 풀 패턴, 파이프라인 패턴, pub-sub 패턴 등 다양한 동시성 패턴에서 채널이 핵심적으로 사용됩니다.
특히 마이크로서비스 환경에서 비동기 작업 처리나 이벤트 스트리밍에 매우 효과적입니다.
실전 팁
💡 채널은 반드시 make로 초기화해야 합니다. var ch chan int처럼 선언만 하면 nil 채널이 되어 송수신 시 영구히 블로킹되므로 주의하세요.
💡 송신자가 더 이상 보낼 데이터가 없으면 close(ch)로 채널을 닫아주세요. 수신자는 value, ok := <-ch로 채널이 닫혔는지 확인할 수 있으며, range 루프로 채널이 닫힐 때까지 자동으로 수신할 수 있습니다.
💡 버퍼 없는 채널(make(chan Type))은 송신과 수신이 동시에 준비될 때까지 블로킹되므로 완벽한 동기화를 제공하지만, 데드락에 주의해야 합니다. 버퍼 채널은 송수신 타이밍을 분리하여 더 유연하지만 메모리를 더 사용합니다.
💡 select 문을 사용하면 여러 채널을 동시에 처리하거나 타임아웃을 구현할 수 있습니다. case <-ch1:, case <-time.After(duration): 같은 패턴으로 더 복잡한 동시성 로직을 우아하게 작성할 수 있습니다.
💡 채널을 함수 매개변수로 전달할 때 방향을 명시하세요(chan<-는 송신 전용, <-chan은 수신 전용). 이렇게 하면 타입 시스템이 잘못된 사용을 컴파일 타임에 방지해줍니다.
3. 인터페이스 - 유연한 추상화의 핵심
시작하며
여러분이 여러 종류의 데이터베이스를 지원하는 애플리케이션을 만들 때, 각 데이터베이스마다 다른 코드를 작성해야 한다면 얼마나 번거로울까요? MySQL용 코드, PostgreSQL용 코드, MongoDB용 코드를 각각 만들고, 나중에 새로운 데이터베이스를 추가할 때마다 전체 코드를 수정해야 합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 구체적인 구현에 의존하는 코드는 확장성이 떨어지고, 테스트하기도 어렵습니다.
코드 변경이 연쇄적으로 다른 부분에 영향을 미쳐 유지보수가 악몽이 됩니다. 바로 이럴 때 필요한 것이 인터페이스(Interface)입니다.
인터페이스는 "무엇을 할 수 있는지"만 정의하고 "어떻게 하는지"는 숨김으로써, 코드의 유연성과 테스트 가능성을 극대화합니다.
개요
간단히 말해서, 인터페이스는 메서드 시그니처의 집합입니다. 특정 타입이 해당 메서드들을 구현하면 자동으로 그 인터페이스를 만족하며, 명시적인 선언이 필요 없습니다(덕 타이핑).
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 코드의 의존성을 역전시켜 변경에 유연하게 대응하고 테스트를 용이하게 하기 위해서입니다. 예를 들어, 결제 시스템에서 신용카드, 페이팔, 비트코인 등 다양한 결제 수단을 지원할 때 PaymentMethod 인터페이스만 정의하면 새로운 결제 수단 추가가 매우 쉬워집니다.
기존에는 모든 구현을 알아야 했고 각 구현마다 별도의 처리 로직을 작성해야 했다면, 이제는 인터페이스 타입으로 통일하여 하나의 코드로 모든 구현을 다룰 수 있습니다. 다형성을 통해 코드 중복이 사라지고 확장성이 극대화됩니다.
인터페이스의 핵심 특징은 세 가지입니다: 첫째, 암시적 구현으로 결합도가 낮습니다. 둘째, 빈 인터페이스(interface{})는 모든 타입을 받을 수 있습니다.
셋째, 작은 인터페이스가 조합 가능성을 높입니다. 이러한 특징들이 Go의 간결함과 강력함을 동시에 만들어줍니다.
코드 예제
// 다양한 동물을 처리하는 인터페이스 예제
package main
import "fmt"
// 동물 인터페이스 정의
type Animal interface {
Speak() string
Move() string
}
// Dog 타입 정의
type Dog struct {
Name string
}
// Dog가 Animal 인터페이스를 구현
func (d Dog) Speak() string {
return "멍멍!"
}
func (d Dog) Move() string {
return "네 발로 뛰어다닙니다"
}
// Cat 타입 정의
type Cat struct {
Name string
}
// Cat이 Animal 인터페이스를 구현
func (c Cat) Speak() string {
return "야옹~"
}
func (c Cat) Move() string {
return "살금살금 걸어다닙니다"
}
// 인터페이스를 받는 함수
func AnimalInfo(a Animal) {
fmt.Printf("소리: %s\n", a.Speak())
fmt.Printf("이동: %s\n", a.Move())
}
func main() {
dog := Dog{Name: "바둑이"}
cat := Cat{Name: "나비"}
// 같은 함수로 다른 타입 처리
AnimalInfo(dog)
fmt.Println("---")
AnimalInfo(cat)
}
설명
이것이 하는 일: 이 코드는 서로 다른 타입(Dog, Cat)을 하나의 인터페이스(Animal)로 통일하여 처리합니다. 같은 함수로 다양한 타입을 다룰 수 있어 코드 재사용성이 극대화됩니다.
첫 번째로, Animal 인터페이스는 Speak()와 Move() 두 개의 메서드 시그니처를 정의합니다. 이것은 "동물이라면 소리를 내고 이동할 수 있어야 한다"는 계약을 명시하는 것입니다.
중요한 점은 어떤 타입이든 이 두 메서드를 구현하기만 하면 자동으로 Animal 타입으로 취급된다는 것입니다. 두 번째로, Dog와 Cat 타입은 각각 Speak()와 Move() 메서드를 구현합니다.
Go에서는 "이 타입은 Animal 인터페이스를 구현합니다"라고 명시적으로 선언할 필요가 없습니다. 필요한 메서드만 구현하면 컴파일러가 자동으로 인식합니다.
각 타입은 자신만의 방식으로 메서드를 구현하며, 이것이 다형성의 핵심입니다. 세 번째로, AnimalInfo 함수는 구체적인 타입이 아닌 Animal 인터페이스를 매개변수로 받습니다.
따라서 Dog든 Cat이든 상관없이 같은 코드로 처리할 수 있습니다. 함수 내부에서는 실제 타입이 무엇인지 알 필요가 없으며, 인터페이스가 제공하는 메서드만 호출합니다.
마지막으로, main 함수에서는 서로 다른 타입의 인스턴스를 같은 함수에 전달하여 일관된 방식으로 동작시킵니다. 여러분이 이 코드를 사용하면 새로운 동물 타입을 추가할 때 기존 코드를 전혀 수정하지 않아도 됩니다.
단지 새 타입에 Speak()와 Move() 메서드만 구현하면 자동으로 Animal 인터페이스를 만족하고 기존 함수들과 함께 동작합니다. 실무에서는 데이터베이스 추상화, HTTP 핸들러, 로거, 캐시 등 다양한 곳에서 인터페이스가 핵심적으로 사용되며, 특히 테스트에서 mock 객체를 만들 때 필수적입니다.
실전 팁
💡 인터페이스는 작게 유지하세요. 가장 이상적인 크기는 1-2개의 메서드입니다. 표준 라이브러리의 io.Reader, io.Writer처럼 단일 메서드 인터페이스가 가장 재사용성이 높습니다.
💡 인터페이스를 정의할 때는 "생산자"가 아닌 "소비자" 측에서 정의하세요. 즉, 라이브러리 코드가 아닌 애플리케이션 코드에서 필요한 인터페이스를 정의하는 것이 더 유연합니다.
💡 타입 단언(value.(Type))과 타입 스위치(switch v := value.(type))를 사용하여 인터페이스의 구체적인 타입을 확인할 수 있습니다. 하지만 이는 인터페이스의 장점을 반감시키므로 정말 필요할 때만 사용하세요.
💡 빈 인터페이스(interface{} 또는 Go 1.18+ any)는 모든 타입을 받을 수 있지만, 타입 안정성을 포기하는 것이므로 가능하면 구체적인 인터페이스를 정의하세요. 제네릭(generics)이 더 나은 대안일 수 있습니다.
💡 인터페이스 값이 nil인지 확인할 때 주의하세요. 인터페이스는 타입과 값 두 가지 정보를 담고 있어서, 값이 nil이어도 타입이 설정되어 있으면 인터페이스 자체는 nil이 아닙니다.
4. 구조체와 메서드 - 객체지향의 Go 스타일
시작하며
여러분이 사용자 정보를 관리하는 시스템을 만들 때, 이름, 이메일, 나이 같은 관련 데이터를 각각 따로 변수로 관리한다면 어떨까요? 코드가 복잡해지고, 이 데이터들을 함수에 전달할 때마다 매개변수가 늘어나며, 데이터와 관련된 로직을 어디에 두어야 할지 애매해집니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 관련된 데이터와 동작을 하나로 묶지 않으면 코드가 산만해지고, 유지보수가 어려워지며, 버그 발생 가능성도 높아집니다.
특히 대규모 프로젝트에서는 이러한 문제가 더욱 심각해집니다. 바로 이럴 때 필요한 것이 구조체(Struct)와 메서드(Method)입니다.
구조체는 관련 데이터를 묶고, 메서드는 그 데이터를 다루는 동작을 정의하여, Go만의 간결한 객체지향 프로그래밍을 가능하게 합니다.
개요
간단히 말해서, 구조체는 여러 필드를 하나로 묶은 복합 타입이고, 메서드는 특정 타입에 연결된 함수입니다. Go에는 클래스가 없지만, 구조체와 메서드를 조합하여 객체지향 프로그래밍의 핵심 개념을 구현합니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 데이터와 그 데이터를 다루는 로직을 하나로 캡슐화하여 코드를 조직화하고 재사용성을 높이기 위해서입니다. 예를 들어, User 구조체를 만들고 Validate(), Save(), SendEmail() 같은 메서드를 정의하면, 사용자 관련 모든 로직이 한 곳에 모여 코드 이해와 유지보수가 훨씬 쉬워집니다.
기존에는 함수에 여러 매개변수를 전달하고 글로벌 함수로 로직을 처리해야 했다면, 이제는 데이터를 구조체로 묶고 관련 로직을 메서드로 정의하여 명확한 책임 분리가 가능합니다. 데이터와 동작이 함께 있어 코드의 응집도가 높아집니다.
구조체와 메서드의 핵심 특징은 세 가지입니다: 첫째, 값 리시버와 포인터 리시버로 메서드의 동작 방식을 제어할 수 있습니다. 둘째, 구조체 임베딩으로 간단한 상속 같은 효과를 낼 수 있습니다.
셋째, 태그를 사용하여 메타데이터를 추가할 수 있습니다. 이러한 특징들이 Go의 실용적인 객체지향 프로그래밍을 만들어줍니다.
코드 예제
// 사용자 관리 시스템 예제
package main
import (
"fmt"
"strings"
)
// 사용자 구조체 정의
type User struct {
Name string
Email string
Age int
}
// 값 리시버 메서드 - 읽기 전용
func (u User) GetInfo() string {
return fmt.Sprintf("이름: %s, 이메일: %s, 나이: %d", u.Name, u.Email, u.Age)
}
// 포인터 리시버 메서드 - 데이터 수정 가능
func (u *User) UpdateEmail(newEmail string) {
u.Email = strings.ToLower(newEmail) // 이메일을 소문자로 정규화
}
// 포인터 리시버 메서드 - 비즈니스 로직
func (u *User) IsAdult() bool {
return u.Age >= 18
}
func main() {
// 구조체 인스턴스 생성
user := User{
Name: "김철수",
Email: "CHULSOO@EXAMPLE.COM",
Age: 25,
}
fmt.Println(user.GetInfo())
// 이메일 업데이트
user.UpdateEmail(user.Email)
fmt.Println("정규화된 이메일:", user.Email)
// 성인 여부 확인
if user.IsAdult() {
fmt.Println("성인 사용자입니다")
}
}
설명
이것이 하는 일: 이 코드는 사용자 데이터와 그 데이터를 다루는 로직을 하나로 묶어 체계적으로 관리합니다. 구조체로 데이터를 표현하고 메서드로 동작을 정의합니다.
첫 번째로, User 구조체는 사용자의 세 가지 속성을 하나로 묶습니다. 필드 이름이 대문자로 시작하면 패키지 외부에서 접근 가능하고(exported), 소문자로 시작하면 패키지 내부에서만 접근 가능합니다(unexported).
이를 통해 캡슐화를 구현할 수 있습니다. 구조체 리터럴로 간편하게 인스턴스를 생성할 수 있습니다.
두 번째로, GetInfo 메서드는 값 리시버(u User)를 사용합니다. 이는 메서드가 호출될 때 구조체의 복사본을 받는다는 의미입니다.
따라서 메서드 내부에서 필드를 수정해도 원본에는 영향을 주지 않습니다. 읽기 전용 메서드나 작은 구조체에 적합합니다.
값 리시버는 안전하지만 큰 구조체의 경우 복사 비용이 발생할 수 있습니다. 세 번째로, UpdateEmail과 IsAdult 메서드는 포인터 리시버(u *User)를 사용합니다.
포인터 리시버는 원본 구조체의 주소를 받기 때문에 메서드 내부에서 필드를 수정하면 실제 원본이 변경됩니다. UpdateEmail에서 이메일을 소문자로 정규화하는 것처럼 상태를 변경해야 하는 메서드는 반드시 포인터 리시버를 사용해야 합니다.
마지막으로, 메서드는 점(.) 표기법으로 호출하며, Go는 자동으로 값과 포인터 사이를 변환해줍니다. 여러분이 이 코드를 사용하면 사용자 관련 모든 로직이 User 타입 주변에 모여 있어 코드가 매우 명확해집니다.
실무에서는 데이터 모델(엔티티), 비즈니스 로직, API 요청/응답 구조체 등 거의 모든 곳에서 구조체와 메서드가 사용됩니다. 특히 JSON 마샬링/언마샬링, 데이터베이스 ORM, HTTP 핸들러 등에서 필수적입니다.
실전 팁
💡 메서드가 구조체 필드를 수정해야 하거나 구조체 크기가 크다면 포인터 리시버를 사용하세요. 읽기 전용이고 구조체가 작다면 값 리시버를 사용해도 됩니다. 일관성을 위해 한 타입의 모든 메서드는 같은 리시버 타입을 사용하는 것이 좋습니다.
💡 구조체 필드에 태그를 추가하여 JSON, XML, 데이터베이스 매핑 등의 메타데이터를 정의할 수 있습니다. 예: json:"email,omitempty" 태그는 JSON 직렬화 시 필드명과 동작을 제어합니다.
💡 구조체 임베딩(embedding)을 사용하여 다른 구조체를 포함시킬 수 있습니다. 임베디드 구조체의 필드와 메서드가 자동으로 승격되어 마치 상속처럼 동작합니다.
💡 생성자 함수를 만들 때는 NewUser() 같은 팩토리 함수를 정의하는 것이 관례입니다. 이를 통해 기본값 설정, 유효성 검증, 초기화 로직을 캡슐화할 수 있습니다.
💡 구조체를 비교할 때(== 연산자) 모든 필드가 비교 가능한 타입이어야 합니다. 슬라이스, 맵, 함수 타입 필드가 있으면 컴파일 에러가 발생하므로 주의하세요.
5. 에러 처리 - 명시적이고 안전한 방법
시작하며
여러분이 파일을 읽거나 네트워크 요청을 보낼 때, 항상 성공한다고 가정하고 코드를 작성한다면 어떻게 될까요? 파일이 없거나 네트워크가 끊기면 프로그램이 예기치 않게 종료되고, 사용자에게는 아무런 설명도 제공되지 않습니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 많은 언어에서는 예외(exception)를 던지고 어딘가에서 잡아야 하는데, 어디서 예외가 발생할지 명확하지 않아 놓치기 쉽습니다.
예외가 잡히지 않으면 프로그램 전체가 크래시됩니다. 바로 이럴 때 필요한 것이 Go의 명시적인 에러 처리입니다.
Go는 예외 대신 에러를 반환값으로 다루며, 각 함수 호출 지점에서 에러를 확인하고 처리하도록 강제하여 더 안정적인 코드를 만듭니다.
개요
간단히 말해서, Go에서 에러는 error 인터페이스 타입의 값입니다. 함수는 마지막 반환값으로 에러를 반환하고, 호출자는 이를 명시적으로 확인하여 처리합니다.
예외 처리 구문(try-catch)이 없습니다. 왜 이 개념이 필요한지 실무 관점에서 설명하자면, 에러를 무시하거나 놓치는 것을 방지하고 에러가 발생할 수 있는 지점을 코드에서 명확히 보여주기 위해서입니다.
예를 들어, 파일을 열고 데이터베이스에 연결하고 API를 호출하는 함수가 있다면, 각 단계에서 발생할 수 있는 에러를 순차적으로 확인하고 적절히 처리할 수 있습니다. 기존에는 예외가 어디서 발생할지 알기 어렵고 예외 계층 구조가 복잡했다면, 이제는 함수 시그니처만 보면 에러가 발생할 수 있는지 즉시 알 수 있습니다.
에러 처리 로직이 제어 흐름에 명시적으로 드러나 코드를 읽을 때 이해하기 쉽습니다. 에러 처리의 핵심 특징은 세 가지입니다: 첫째, 에러는 값이므로 일반 변수처럼 다룰 수 있습니다.
둘째, errors.New(), fmt.Errorf()로 간단히 에러를 생성할 수 있습니다. 셋째, Go 1.13+부터 에러 래핑으로 컨텍스트를 추가할 수 있습니다.
이러한 특징들이 Go의 실용적이고 명확한 에러 처리를 만들어줍니다.
코드 예제
// 파일 처리 예제
package main
import (
"errors"
"fmt"
"os"
)
// 사용자 정의 에러
var ErrFileEmpty = errors.New("파일이 비어있습니다")
// 파일을 읽고 처리하는 함수
func ProcessFile(filename string) (string, error) {
// 파일 열기
file, err := os.Open(filename)
if err != nil {
// 에러 래핑으로 컨텍스트 추가
return "", fmt.Errorf("파일 열기 실패: %w", err)
}
defer file.Close() // 함수 종료 시 파일 닫기
// 파일 정보 가져오기
stat, err := file.Stat()
if err != nil {
return "", fmt.Errorf("파일 정보 읽기 실패: %w", err)
}
// 파일 크기 확인
if stat.Size() == 0 {
return "", ErrFileEmpty
}
return "파일 처리 성공", nil
}
func main() {
result, err := ProcessFile("example.txt")
if err != nil {
// 특정 에러인지 확인
if errors.Is(err, ErrFileEmpty) {
fmt.Println("경고: 빈 파일입니다")
} else {
fmt.Printf("에러 발생: %v\n", err)
}
return
}
fmt.Println(result)
}
설명
이것이 하는 일: 이 코드는 파일 처리 과정에서 발생할 수 있는 여러 에러를 명시적으로 확인하고 처리합니다. 각 단계에서 에러를 체크하고 적절한 메시지와 함께 상위로 전파합니다.
첫 번째로, ProcessFile 함수는 문자열과 에러를 함께 반환합니다. Go의 관례는 성공 시 에러를 nil로 반환하는 것입니다.
os.Open()은 파일을 열 때 에러가 발생할 수 있으므로, 반환된 에러를 즉시 확인합니다. err != nil이면 에러가 발생한 것이고, 이 경우 fmt.Errorf()로 에러를 래핑하여 "파일 열기 실패"라는 컨텍스트를 추가한 후 반환합니다.
%w 동사는 원본 에러를 래핑하여 나중에 errors.Is()나 errors.As()로 확인할 수 있게 합니다. 두 번째로, defer file.Close()는 함수가 종료될 때(정상이든 에러든) 파일을 자동으로 닫습니다.
이는 리소스 누수를 방지하는 Go의 관용적 패턴입니다. file.Stat()로 파일 정보를 가져올 때도 에러를 확인하며, 파일 크기가 0이면 사전 정의된 ErrFileEmpty를 반환합니다.
이렇게 특정 에러를 변수로 정의하면 나중에 정확히 식별할 수 있습니다. 세 번째로, main 함수에서는 ProcessFile의 반환값을 받아 에러를 확인합니다.
errors.Is(err, ErrFileEmpty)는 래핑된 에러 체인에서 특정 에러를 찾아냅니다. 이를 통해 에러 타입에 따라 다른 처리를 할 수 있습니다.
마지막으로, 에러가 없으면 정상적인 결과를 출력하고, 에러가 있으면 적절한 메시지를 표시한 후 프로그램을 종료합니다. 여러분이 이 코드를 사용하면 프로그램의 모든 에러 발생 지점이 코드에 명시적으로 드러나고, 각 에러에 대한 처리 방법도 명확해집니다.
실무에서는 API 호출, 데이터베이스 쿼리, 파일 I/O, 외부 서비스 연동 등 실패할 수 있는 모든 작업에서 에러 처리가 필수적입니다. 로깅, 재시도, 사용자 알림 등의 에러 처리 전략을 체계적으로 구현할 수 있습니다.
실전 팁
💡 에러를 무시하지 마세요. result, _ := SomeFunc()처럼 _로 에러를 버리는 것은 매우 위험합니다. 정말로 에러를 무시해도 되는 경우에만 사용하고, 가능하면 주석으로 이유를 설명하세요.
💡 에러 메시지는 소문자로 시작하고 마침표를 붙이지 않는 것이 관례입니다. 에러는 다른 에러와 함께 래핑될 수 있기 때문에 문장 중간에 들어가도 자연스러워야 합니다.
💡 errors.Is()는 에러 값 비교에, errors.As()는 에러 타입 변환에 사용합니다. errors.As(err, &target)는 에러 체인에서 특정 타입의 에러를 찾아 target에 할당합니다.
💡 패닉(panic)은 정말 복구 불가능한 상황에만 사용하세요. 일반적인 에러 상황에는 에러를 반환하는 것이 Go의 철학입니다. 라이브러리 코드에서는 거의 패닉을 사용하지 않아야 합니다.
💡 커스텀 에러 타입을 만들 때는 Error() string 메서드를 구현하여 error 인터페이스를 만족시키세요. 추가 정보를 담을 수 있어 더 풍부한 에러 처리가 가능합니다.
6. 슬라이스와 배열 - 동적 데이터 컬렉션
시작하며
여러분이 사용자 목록이나 주문 내역 같은 데이터를 다룰 때, 크기가 고정되어 있다면 얼마나 불편할까요? 사용자가 10명일 수도, 1000명일 수도 있는데 미리 크기를 정해야 한다면 메모리 낭비가 심하거나 부족한 상황이 발생합니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 데이터 크기가 동적으로 변하는 것이 일반적인데, 고정 크기 자료구조만 사용하면 확장성이 떨어지고 유연하게 대응하기 어렵습니다.
데이터 추가, 삭제, 필터링 같은 작업이 복잡해집니다. 바로 이럴 때 필요한 것이 슬라이스(Slice)입니다.
슬라이스는 Go에서 가장 많이 사용되는 자료구조로, 동적 크기의 배열을 제공하며 효율적인 데이터 관리를 가능하게 합니다.
개요
간단히 말해서, 배열은 고정 크기이고, 슬라이스는 배열의 일부를 참조하는 동적 크기의 뷰입니다. 슬라이스는 포인터, 길이, 용량 세 가지 정보를 가지며, 자동으로 크기가 늘어납니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 대부분의 실무 코드에서는 데이터 크기를 미리 알 수 없고 동적으로 변하기 때문입니다. 예를 들어, API 응답으로 받은 데이터 목록, 사용자 입력의 모음, 데이터베이스 쿼리 결과 등은 모두 크기가 가변적이므로 슬라이스가 필수적입니다.
기존에는 고정 크기 배열을 사용하거나 복잡한 메모리 관리를 직접 해야 했다면, 이제는 슬라이스가 자동으로 용량을 관리하고 필요시 메모리를 재할당합니다. append() 함수 하나로 간편하게 요소를 추가할 수 있습니다.
슬라이스의 핵심 특징은 세 가지입니다: 첫째, 참조 타입이므로 함수에 전달할 때 복사 비용이 낮습니다. 둘째, 용량이 부족하면 자동으로 2배씩 증가합니다.
셋째, 슬라이싱 연산(slice[start:end])으로 일부를 쉽게 추출할 수 있습니다. 이러한 특징들이 Go의 효율적인 데이터 처리를 만들어줍니다.
코드 예제
// 슬라이스를 활용한 데이터 처리 예제
package main
import "fmt"
// 짝수만 필터링하는 함수
func FilterEven(numbers []int) []int {
result := make([]int, 0, len(numbers)) // 용량을 미리 할당
for _, num := range numbers {
if num%2 == 0 {
result = append(result, num) // 슬라이스에 추가
}
}
return result
}
func main() {
// 슬라이스 선언 및 초기화
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
fmt.Println("원본:", numbers)
fmt.Printf("길이: %d, 용량: %d\n", len(numbers), cap(numbers))
// 슬라이싱 - 일부 추출
slice := numbers[2:7] // 인덱스 2부터 6까지
fmt.Println("슬라이싱 [2:7]:", slice)
// 필터링
evenNumbers := FilterEven(numbers)
fmt.Println("짝수만:", evenNumbers)
// 요소 추가
numbers = append(numbers, 11, 12)
fmt.Println("추가 후:", numbers)
}
설명
이것이 하는 일: 이 코드는 슬라이스의 핵심 기능인 생성, 슬라이싱, 필터링, 추가를 보여줍니다. 슬라이스를 사용하여 데이터를 유연하게 다루는 방법을 보여줍니다.
첫 번째로, numbers := []int{1, 2, 3, ...}는 슬라이스 리터럴로 초기값을 가진 슬라이스를 생성합니다. 대괄호 안에 크기를 지정하지 않으면 슬라이스이고, 크기를 지정하면 배열입니다.
len()은 현재 슬라이스에 들어있는 요소 개수이고, cap()은 재할당 없이 추가할 수 있는 최대 용량입니다. 용량이 부족하면 Go 런타임이 자동으로 더 큰 메모리를 할당하고 데이터를 복사합니다.
두 번째로, 슬라이싱 연산 numbers[2:7]은 인덱스 2부터 6까지(7은 포함하지 않음) 요소를 가진 새로운 슬라이스를 만듭니다. 중요한 점은 이것이 복사가 아닌 뷰라는 것입니다.
즉, 원본 배열의 같은 메모리를 참조하므로 슬라이스를 수정하면 원본도 영향을 받습니다. [:]는 전체, [:n]은 처음부터 n-1까지, [n:]은 n부터 끝까지를 의미합니다.
세 번째로, FilterEven 함수는 make([]int, 0, len(numbers))로 초기 길이는 0이지만 용량은 미리 할당하여 효율적인 슬라이스를 생성합니다. range 키워드로 슬라이스를 순회하며, 각 요소가 짝수인지 확인합니다.
append(result, num)은 슬라이스에 요소를 추가하며, 용량이 부족하면 자동으로 확장됩니다. 마지막으로, append(numbers, 11, 12)처럼 여러 요소를 한 번에 추가할 수도 있습니다.
여러분이 이 코드를 사용하면 가변 크기의 데이터를 자유롭게 다룰 수 있습니다. 실무에서는 API 응답 파싱, 데이터 필터링/변환, 배치 처리, 버퍼 관리 등 거의 모든 곳에서 슬라이스가 사용됩니다.
JSON 마샬링/언마샬링, 데이터베이스 쿼리 결과, HTTP 요청 처리 등에서 필수적입니다.
실전 팁
💡 슬라이스를 함수에 전달할 때 복사되지만, 내부 포인터는 같은 배열을 가리키므로 요소를 수정하면 원본이 영향을 받습니다. 하지만 append()로 크기를 변경하면 원본에 영향을 주지 않을 수 있으므로, 크기 변경이 필요하면 슬라이스 포인터를 전달하거나 반환값을 사용하세요.
💡 make([]Type, length, capacity)로 슬라이스를 생성할 때, 최종 크기를 예상할 수 있다면 용량을 미리 할당하여 재할당을 줄이고 성능을 향상시키세요. 예: make([]int, 0, 100)
💡 슬라이스를 복사할 때는 copy(dst, src) 함수를 사용하세요. dst = src는 단순 할당으로 같은 배열을 참조하게 되어 독립적인 복사본이 아닙니다.
💡 append()는 새로운 슬라이스를 반환할 수 있으므로 반드시 결과를 변수에 할당하세요: slice = append(slice, elem). 그렇지 않으면 용량 확장 시 데이터가 손실될 수 있습니다.
💡 슬라이스를 비울 때는 slice = slice[:0]을 사용하면 용량은 유지하면서 길이만 0으로 만들 수 있어 메모리 재사용이 가능합니다. 완전히 초기화하려면 slice = nil을 사용하세요.
7. 맵 - 키-값 데이터 저장소
시작하며
여러분이 사용자 ID로 사용자 정보를 빠르게 찾아야 하거나, 단어의 출현 빈도를 세어야 하는 상황을 만났다면 어떻게 하시겠습니까? 슬라이스를 순회하며 찾는다면 데이터가 많을수록 느려지고, 코드도 복잡해집니다.
이런 문제는 실제 개발 현장에서 자주 발생합니다. 특정 키로 값을 빠르게 찾거나, 중복을 제거하거나, 연관 데이터를 관리해야 하는 경우가 매우 흔합니다.
선형 검색으로는 성능이 나쁘고, 직접 해시 테이블을 구현하기는 너무 복잡합니다. 바로 이럴 때 필요한 것이 맵(Map)입니다.
맵은 키-값 쌍을 저장하는 해시 테이블로, O(1) 평균 시간 복잡도로 매우 빠른 검색, 삽입, 삭제를 제공합니다.
개요
간단히 말해서, 맵은 키를 값에 연결하는 참조 타입의 자료구조입니다. 키는 비교 가능한 타입이어야 하고, 값은 어떤 타입이든 가능하며, 순서는 보장되지 않습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 키를 사용한 빠른 데이터 접근과 관리가 필요하기 때문입니다. 예를 들어, 세션 관리(세션 ID -> 사용자 데이터), 캐싱(키 -> 캐시된 결과), 집계(카테고리 -> 개수), 설정 관리(키 -> 설정 값) 등 실무의 거의 모든 곳에서 맵이 사용됩니다.
기존에는 선형 검색으로 O(n) 시간이 걸리거나 복잡한 트리 구조를 구현해야 했다면, 이제는 맵으로 O(1)에 접근하고 코드도 훨씬 간결해집니다. 키만 알면 즉시 값을 얻을 수 있습니다.
맵의 핵심 특징은 세 가지입니다: 첫째, 키는 유일해야 하며 중복 키는 덮어씁니다. 둘째, 키가 존재하지 않으면 값 타입의 제로값을 반환합니다.
셋째, 맵은 참조 타입이므로 함수에 전달하면 복사본이 아닌 참조가 전달됩니다. 이러한 특징들이 Go의 효율적인 데이터 관리를 만들어줍니다.
코드 예제
// 단어 빈도 카운터 예제
package main
import (
"fmt"
"strings"
)
func CountWords(text string) map[string]int {
// 맵 생성 - 키: string, 값: int
wordCount := make(map[string]int)
// 텍스트를 단어로 분리
words := strings.Fields(text)
for _, word := range words {
word = strings.ToLower(word) // 대소문자 통일
wordCount[word]++ // 키가 없으면 0으로 초기화 후 증가
}
return wordCount
}
func main() {
text := "Go is awesome Go is fast Go is simple"
counts := CountWords(text)
// 맵 순회
for word, count := range counts {
fmt.Printf("%s: %d번\n", word, count)
}
// 특정 키 존재 확인
if count, exists := counts["go"]; exists {
fmt.Printf("\n'go'는 %d번 등장했습니다\n", count)
}
// 키 삭제
delete(counts, "is")
fmt.Printf("\n'is' 삭제 후 맵: %v\n", counts)
}
설명
이것이 하는 일: 이 코드는 텍스트에서 각 단어가 몇 번 등장하는지 세는 단어 빈도 카운터입니다. 맵을 사용하여 단어를 키로, 등장 횟수를 값으로 저장합니다.
첫 번째로, make(map[string]int)로 문자열 키와 정수 값을 가진 맵을 생성합니다. 맵은 반드시 make로 초기화해야 하며, var m map[string]int처럼 선언만 하면 nil 맵이 되어 값을 저장할 수 없습니다.
strings.Fields(text)로 텍스트를 공백 기준으로 분리하여 단어 슬라이스를 얻습니다. 각 단어를 소문자로 변환하여 대소문자 구분 없이 카운트합니다.
두 번째로, wordCount[word]++는 맵의 강력한 기능을 보여줍니다. 만약 word 키가 맵에 존재하지 않으면 자동으로 값 타입의 제로값(int의 경우 0)으로 초기화된 후 증가합니다.
따라서 "키가 존재하는지 확인하고 없으면 0으로 초기화"하는 복잡한 로직이 필요 없습니다. 이것이 맵을 카운터로 사용하기 매우 편리하게 만듭니다.
세 번째로, range로 맵을 순회하면 키와 값을 함께 얻을 수 있습니다. 중요한 점은 맵의 순회 순서가 무작위라는 것입니다.
Go는 의도적으로 순회할 때마다 다른 순서를 반환하여 순서에 의존하는 코드를 방지합니다. value, exists := map[key] 패턴은 키가 존재하는지 확인하는 관용적 방법입니다.
exists가 true면 키가 존재하고, false면 존재하지 않으며 value는 제로값입니다. 마지막으로, delete(map, key)로 키-값 쌍을 삭제하며, 키가 존재하지 않아도 에러가 발생하지 않습니다.
여러분이 이 코드를 사용하면 키 기반의 빠른 데이터 접근과 집계가 가능해집니다. 실무에서는 캐시 구현, 세션 관리, 데이터 인덱싱, 중복 제거, 그룹화, 설정 관리 등 매우 광범위하게 사용됩니다.
특히 RESTful API에서 JSON 데이터를 map[string]interface{}로 다루는 것이 흔하며, 데이터베이스 쿼리 결과를 맵으로 변환하여 처리하는 경우도 많습니다.
실전 팁
💡 맵은 동시성 안전하지 않습니다. 여러 고루틴이 동시에 맵을 읽고 쓰면 패닉이 발생합니다. 동시 접근이 필요하면 sync.Mutex로 보호하거나 sync.Map을 사용하세요.
💡 맵의 제로값은 nil이며, nil 맵에서 읽기는 가능하지만(제로값 반환) 쓰기는 패닉을 일으킵니다. 반드시 make로 초기화하거나 맵 리터럴로 생성하세요.
💡 맵 리터럴로 초기값을 설정할 수 있습니다: m := map[string]int{"apple": 1, "banana": 2}. 이는 생성과 동시에 초기화하는 편리한 방법입니다.
💡 맵은 참조 타입이므로 함수에 전달하면 원본을 수정할 수 있습니다. 복사본을 만들려면 명시적으로 새 맵을 생성하고 키-값을 복사해야 합니다.
💡 맵의 키로 슬라이스, 맵, 함수는 사용할 수 없습니다(비교 불가능). 구조체는 모든 필드가 비교 가능하면 키로 사용 가능합니다. 복잡한 키가 필요하면 구조체를 만들어 사용하세요.
8. 포인터 - 메모리 주소 직접 다루기
시작하며
여러분이 큰 구조체를 함수에 전달할 때마다 전체 데이터가 복사된다면 얼마나 비효율적일까요? 메가바이트 크기의 데이터를 복사하는 데 시간이 걸리고, 메모리도 두 배로 사용됩니다.
또한 함수 내부에서 데이터를 수정해도 원본은 변하지 않아 의도한 대로 동작하지 않습니다. 이런 문제는 실제 개발 현장에서 자주 발생합니다.
데이터 복사 비용이 높거나, 함수가 원본 데이터를 수정해야 하거나, 여러 곳에서 같은 데이터를 공유해야 할 때 값 복사로는 해결이 어렵습니다. 바로 이럴 때 필요한 것이 포인터(Pointer)입니다.
포인터는 값 자체가 아닌 메모리 주소를 저장하여, 복사 없이 효율적으로 데이터를 다루고 원본을 직접 수정할 수 있게 합니다.
개요
간단히 말해서, 포인터는 변수의 메모리 주소를 저장하는 타입입니다. & 연산자로 주소를 얻고, * 연산자로 주소가 가리키는 값에 접근하며, 함수에 포인터를 전달하면 원본을 수정할 수 있습니다.
왜 이 개념이 필요한지 실무 관점에서 설명하자면, 큰 데이터의 복사 비용을 줄이고 원본 데이터를 직접 수정하기 위해서입니다. 예를 들어, 데이터베이스에서 가져온 대용량 레코드를 여러 함수에서 처리할 때 포인터를 전달하면 복사 비용 없이 효율적으로 처리할 수 있습니다.
기존에는 값을 복사하거나 전역 변수를 사용해야 했다면, 이제는 포인터로 메모리 주소만 전달하여 복사 비용을 제거하고 원본에 직접 접근할 수 있습니다. 메모리 효율성과 성능이 크게 향상됩니다.
포인터의 핵심 특징은 세 가지입니다: 첫째, Go는 포인터 연산을 지원하지 않아 안전합니다. 둘째, nil 포인터를 역참조하면 패닉이 발생합니다.
셋째, 구조체 포인터는 자동으로 역참조되어 편리합니다. 이러한 특징들이 Go의 안전하면서도 효율적인 메모리 관리를 만들어줍니다.
코드 예제
// 포인터를 활용한 구조체 수정 예제
package main
import "fmt"
type User struct {
Name string
Email string
Age int
}
// 값 리시버 - 복사본을 받음
func UpdateByValue(u User, newAge int) {
u.Age = newAge // 복사본 수정, 원본 영향 없음
fmt.Println("함수 내부(값):", u.Age)
}
// 포인터 리시버 - 주소를 받음
func UpdateByPointer(u *User, newAge int) {
u.Age = newAge // 원본 직접 수정
fmt.Println("함수 내부(포인터):", u.Age)
}
// new 키워드로 포인터 생성
func CreateUser(name string) *User {
return &User{ // 주소 반환
Name: name,
Age: 0,
}
}
func main() {
user := User{Name: "김철수", Email: "kim@example.com", Age: 25}
fmt.Println("원본:", user.Age)
// 값으로 전달 - 원본 변경 안 됨
UpdateByValue(user, 30)
fmt.Println("값 전달 후 원본:", user.Age) // 여전히 25
// 포인터로 전달 - 원본 변경됨
UpdateByPointer(&user, 30) // &로 주소 전달
fmt.Println("포인터 전달 후 원본:", user.Age) // 30으로 변경
// 포인터로 새 객체 생성
newUser := CreateUser("이영희")
newUser.Email = "lee@example.com" // 자동 역참조
fmt.Printf("새 사용자: %+v\n", *newUser)
}
설명
이것이 하는 일: 이 코드는 값 전달과 포인터 전달의 차이를 명확히 보여줍니다. 값 전달은 복사본을 만들어 원본을 보호하고, 포인터 전달은 원본을 직접 수정합니다.
첫 번째로, UpdateByValue 함수는 User 타입 값을 매개변수로 받습니다. Go는 기본적으로 값 복사 방식이므로, 함수가 호출될 때 user의 모든 필드가 복사된 새로운 구조체가 생성됩니다.
함수 내부에서 u.Age를 수정해도 이는 복사본의 필드를 수정하는 것이므로 원본 user에는 아무런 영향을 주지 않습니다. 이 방식은 안전하지만 큰 구조체의 경우 복사 비용이 높습니다.
두 번째로, UpdateByPointer 함수는 *User 타입, 즉 User의 포인터를 매개변수로 받습니다. &user로 user의 메모리 주소를 전달하면, 함수는 원본이 저장된 메모리 위치를 알게 됩니다.
함수 내부에서 u.Age를 수정하면 실제로는 (*u).Age를 수정하는 것인데, Go가 자동으로 역참조를 해주어 간결한 문법으로 작성할 수 있습니다. 이 수정은 원본 메모리에 직접 적용되므로 함수 외부에서도 변경사항이 반영됩니다.
세 번째로, CreateUser 함수는 구조체 포인터를 반환합니다. &User{...}는 구조체 리터럴의 주소를 의미하며, 함수 내부에서 생성된 구조체가 스택이 아닌 힙에 할당되어 함수가 반환된 후에도 유효합니다(Go의 escape analysis가 자동 처리).
main 함수에서 newUser.Email처럼 점 표기법을 사용하면 Go가 자동으로 포인터를 역참조하므로 (*newUser).Email이라고 쓸 필요가 없습니다. 마지막으로, %+v 포맷으로 구조체의 필드명과 값을 모두 출력할 수 있습니다.
여러분이 이 코드를 사용하면 언제 값을 복사하고 언제 포인터를 사용해야 하는지 명확히 이해할 수 있습니다. 실무에서는 큰 구조체, 데이터베이스 레코드, 설정 객체 등을 다룰 때 포인터를 주로 사용합니다.
특히 메서드에서 구조체 필드를 수정해야 한다면 포인터 리시버가 필수적이며, 여러 고루틴이 같은 데이터를 공유할 때도 포인터를 통해 효율적으로 처리합니다.
실전 팁
💡 nil 포인터를 역참조하면 런타임 패닉이 발생합니다. 포인터를 사용하기 전에 항상 if ptr != nil 체크를 하는 습관을 들이세요.
💡 작은 구조체(몇 개 필드 정도)는 값으로 전달해도 성능 차이가 거의 없으므로, 무조건 포인터를 사용할 필요는 없습니다. 수정이 필요한 경우에만 포인터를 사용하세요.
💡 슬라이스, 맵, 채널은 이미 참조 타입이므로 포인터로 전달할 필요가 없습니다. 함수 내부에서 요소를 수정하면 원본이 영향을 받습니다.
💡 new(Type)은 타입의 제로값으로 초기화된 포인터를 반환합니다. 구조체 리터럴에 &를 붙이는 것보다 간결하지만, 초기값을 설정할 수 없어 실무에서는 &Type{...} 형태를 더 많이 사용합니다.
💡 C와 달리 Go는 포인터 연산(포인터에 숫자를 더하거나 빼기)을 지원하지 않습니다. 이는 메모리 안전성을 위한 의도적인 설계입니다. 배열 요소 접근은 슬라이스 인덱싱을 사용하세요.
댓글 (0)
함께 보면 좋은 카드 뉴스
Golang 최신 기능 완벽 가이드
Go 1.21과 1.22에서 추가된 최신 기능들을 실전 코드와 함께 소개합니다. 제네릭 개선, 새로운 표준 라이브러리 함수, 성능 최적화 등 고급 개발자를 위한 필수 기능들을 다룹니다.
Golang 성능 최적화 가이드
Go 언어의 성능을 극대화하기 위한 핵심 기법들을 소개합니다. 메모리 관리, 동시성 처리, 프로파일링 등 실무에서 바로 적용 가능한 최적화 기법을 배울 수 있습니다.
Go 디자인 패턴 완벽 가이드
Go 언어의 핵심 디자인 패턴을 실전 예제와 함께 학습합니다. 싱글톤, 팩토리, 빌더 등 실무에서 자주 사용되는 패턴들을 다룹니다.
GORM 데이터베이스 ORM Go 완벽가이드
Go 언어의 강력한 ORM 라이브러리 GORM을 사용하여 데이터베이스를 쉽게 다루는 방법을 배웁니다. 기본적인 CRUD 작업부터 관계 설정, 쿼리 최적화까지 초급자도 쉽게 따라할 수 있는 실전 예제로 구성되어 있습니다.
Gin REST API 개발 완벽 가이드
Go 언어의 인기 웹 프레임워크 Gin을 사용하여 REST API를 개발하는 방법을 단계별로 배워봅니다. 라우팅, 미들웨어, JSON 처리 등 핵심 기능을 실습 위주로 학습합니다.