Go 오류 처리 최고 Best Practices
Emily Parker
Product Engineer · Leapcell

Go에서 오류는 단순히 값이며, 오류 처리는 기본적으로 값을 비교한 후 결정을 내리는 것입니다.
비즈니스 로직은 필요한 경우에만 오류를 무시해야 하며, 그렇지 않으면 오류를 무시해서는 안 됩니다.
이론적으로 이러한 설계는 프로그래머가 모든 오류를 의식적으로 처리하도록 하여 더 강력한 프로그램을 만들 수 있게 합니다.
이 글에서는 오류를 적절하게 처리하기 위한 모범 사례에 대해 이야기해 보겠습니다.
TL;DR
- 비즈니스 로직상 필요한 경우에만 오류를 무시하고, 그렇지 않으면 모든 오류를 처리하십시오.
errors
패키지를 사용하여 스택 정보를 위해 오류를 래핑하고, 오류 세부 정보를 더 정확하게 출력하며, 분산 시스템에서trace_id
를 사용하여 동일한 요청에서 발생하는 오류를 연결하십시오.- 오류 로깅 또는 폴백 메커니즘 구현을 포함하여 오류는 한 번만 처리해야 합니다.
- 현재 모듈 수준보다 높은 오류를 발생시켜 혼란을 일으키는 것을 방지하기 위해 오류 추상화 수준을 일관되게 유지하십시오.
- 최상위 설계를 통해
if err != nil
의 빈도를 줄이십시오.
정확한 오류 로깅
오류 로그는 문제 해결에 도움이 되는 중요한 수단이므로, 쉽게 혼동되지 않는 로그를 출력하는 것이 매우 중요합니다. err
을 사용하여 스택 로그를 가져와 문제 해결에 어떻게 도움이 될 수 있을까요?
종종 스스로에게 물어보십시오: 이러한 방식으로 기록된 오류가 문제 해결에 실제로 도움이 될 수 있을까요?
로그를 보고 오류를 정확히 찾아낼 수 없다면, 오류를 전혀 기록하지 않는 것과 같습니다.
github.com/pkg/errors
패키지는 스택을 포함할 수 있는 래퍼를 제공합니다.
func callers() *stack { const depth = 32 var pcs [depth]uintptr n := runtime.Callers(3, pcs[:]) var st stack = pcs[0:n] return &st } func New(message string) error { return &fundamental{ msg: message, stack: callers(), } }
스택 인쇄는 fundamental
이 Format
인터페이스를 구현하기 때문에 가능합니다.
그런 다음 fmt.Printf("%+v", err)
를 사용하여 해당 스택 정보를 인쇄할 수 있습니다.
func (f *fundamental) Format(s fmt.State, verb rune) { switch verb { case 'v': if s.Flag('+') { io.WriteString(s, f.msg) f.stack.Format(s, verb) return } fallthrough case 's': io.WriteString(s, f.msg) case 'q': fmt.Fprintf(s, "%q", f.msg) } }
구체적인 예를 살펴보겠습니다.
func foo() error { return errors.New("something went wrong") } func bar() error { return foo() // 오류에 스택 정보 첨부 }
여기서 foo
는 errors.New
를 호출하여 오류를 생성하고, bar
를 사용하여 또 다른 호출 계층을 추가합니다.
다음으로 오류를 출력하는 테스트를 작성해 보겠습니다.
func TestBar(t *testing.T) { err := bar() fmt.Printf("err: %+v\n", err) }
최종 출력에는 foo
와 bar
의 스택이 모두 포함됩니다.
err: something went wrong golib/examples/writer_good_code/exception.foo E:/project/github/go-lib-new/go-lib/examples/writer_good_code/exception/err.go:8 golib/examples/writer_good_code/exception.bar E:/project/github/go-lib-new/go-lib/examples/writer_good_code/exception/err.go:12 ...
첫 번째 줄은 오류 메시지를 정확하게 출력하고, 첫 번째 스택 추적은 오류가 생성된 위치를 가리킵니다.
이 두 가지 오류 정보를 통해 오류 메시지와 오류의 출처를 모두 확인할 수 있습니다.
분산 시스템의 오류 추적
이제 단일 시스템에서 오류를 정확하게 출력할 수 있지만, 실제 프로그램에서는 많은 동시 상황이 발생하는 경우가 많습니다. 오류 스택이 동일한 요청에 속하도록 하려면 어떻게 해야 할까요? 이를 위해서는 trace_id
가 필요합니다.
자신의 요구 사항과 선호하는 형식에 따라 trace_id
를 생성하고 컨텍스트에 설정할 수 있습니다.
func CtxWithTraceId(ctx context.Context, traceId string) context.Context { ctx = context.WithValue(ctx, TraceIDKey, traceId) return ctx }
로깅할 때 CtxTraceID
를 사용하여 traceId
를 검색할 수 있습니다.
func CtxTraceID(c context.Context) string { if gc, ok := c.(*gin.Context); ok { // gin의 요청에서 trace id 가져오기 ... } // go 컨텍스트에서 가져오기 traceID := c.Value(TraceIDKey) if traceID != nil { return traceID.(string) } // trace id가 없는 경우 하나 생성 return TraceIDPrefix + xid.New().String() }
로그에 traceID
를 추가하면 요청에 대한 전체 오류 로그 체인을 가져올 수 있으므로 문제 해결 중 로그 검색의 어려움을 크게 줄일 수 있습니다.
오류는 한 번만 처리해야 합니다.
오류는 한 번만 처리해야 하며, 로깅도 오류를 처리하는 방법으로 간주됩니다.
한 곳에서 로깅으로 문제를 해결할 수 없는 경우, 오류를 여러 번 처리하는 대신 프로그램에서 문제가 발생한 위치를 명확히 알 수 있도록 오류에 더 많은 컨텍스트 정보를 추가하는 것이 적절한 접근 방식입니다.
처음에 오류 로그를 사용할 때 오해가 있었습니다. 다음과 같은 일부 비즈니스 오류에 대한 로그를 출력해야 한다고 생각했습니다.
- 사용자 계정/비밀번호가 잘못되었습니다.
- 사용자 SMS 인증 코드 오류
이러한 오류는 사용자 입력 오류로 인해 발생하므로 처리할 필요가 없습니다.
정말로 신경 써야 할 것은 프로그램의 버그로 인해 발생하는 오류입니다.
일반적인 비즈니스 로직 오류의 경우, 오류 수준 로그를 출력할 필요가 없습니다. 오류 노이즈가 너무 많으면 실제 문제가 가려질 뿐입니다.
오류에 대한 폴백
위의 방법을 사용하여 실제 프로젝트에서 오류를 정확하게 출력하여 문제 해결에 도움을 줄 수 있습니다. 그러나 종종 프로그램이 "자체 치유"되고 오류를 적응적으로 해결하기를 원합니다.
일반적인 예: 캐시에서 검색하지 못한 후 소스 데이터베이스로 폴백해야 합니다.
func GerUser() (*User, error) { user, err := getUserFromCache() if err == nil { return user, nil } user, err = getUserFromDB() if err != nil { return nil, err } return user, nil }
또는 트랜잭션이 실패한 후 보상 메커니즘을 시작하려고 합니다. 예를 들어 주문이 완료된 후 사용자에게 사이트 내 메시지를 보내려고 합니다.
메시지를 동기적으로 보내지 못할 수 있지만, 이 시나리오에서는 실시간 요구 사항이 특히 높지 않으므로 메시지 전송을 비동기적으로 다시 시도할 수 있습니다.
func CompleteOrder(orderID string) error { // 주문을 완료하는 다른 로직... message := Message{} err := sendUserMessage(message) if err != nil { asyncRetrySendUserMessage(message) } return nil }
의도적으로 오류 무시
API가 오류를 반환하지 않도록 할 수 있다면 호출자는 오류 처리에 노력을 기울일 필요가 없습니다. 따라서 오류가 발생했지만 호출자가 추가 조치를 취할 필요가 없는 경우 오류를 반환할 필요가 없습니다. 코드가 정상적으로 실행되도록 하십시오.
이것이 "Null Object Pattern"입니다. 원래는 오류 또는 nil 객체를 반환해야 하지만, 호출자가 오류 처리를 하지 않도록 빈 구조체를 반환하여 호출자의 오류 처리 로직을 건너뛸 수 있습니다.
이벤트 핸들러를 사용할 때 존재하지 않는 이벤트가 있는 경우 "Null Object Pattern"을 사용할 수 있습니다.
예를 들어 알림 시스템에서 실제 알림을 보내지 않으려고 할 수 있습니다. 이 경우 null 객체를 사용하여 알림 로직에서 nil 검사를 방지할 수 있습니다.
// Notifier 인터페이스 정의 type Notifier interface { Notify(message string) } // EmailNotifier의 구체적인 구현 type EmailNotifier struct{} func (n *EmailNotifier) Notify(message string) { fmt.Printf("Sending email notification: %s\n", message) } // Null 알림 구현 type NullNotifier struct{} func (n *NullNotifier) Notify(message string) { // Null 구현, 아무것도 하지 않음 }
메서드가 오류를 반환하지만 호출자로서 처리하고 싶지 않은 경우, 오류를 수신하기 위해 _
를 사용하는 것이 가장 좋습니다. 이렇게 하면 다른 개발자가 오류를 잊었는지 의도적으로 무시했는지 혼동하지 않습니다.
func f() { // ... _ = notify() } func notify() error { // ... }
사용자 정의 오류 래핑
투명한 오류는 오류 처리와 오류 값 구성 간의 결합을 줄일 수 있지만, 오류가 로직 처리에서 효과적으로 사용되도록 허용하지 않습니다.
오류를 기반으로 로직을 처리하면 오류가 API의 일부가 됩니다.
오류에 따라 상위 계층에 다른 오류 메시지를 표시해야 할 수 있습니다.
Go 1.13 이후에는 errors.Is
를 사용하여 오류를 확인하는 것이 좋습니다.
오류 유형은 errors.As
로 확인할 수 있지만, 이는 공용 API가 여전히 신중한 오류 유지 관리가 필요함을 의미합니다.
그렇다면 이러한 오류 처리 스타일로 인해 발생하는 결합을 줄일 수 있는 방법이 있을까요?
오류 특성을 통합 인터페이스로 추출할 수 있으며, 호출자는 오류를 이 인터페이스로 캐스팅하여 판단할 수 있습니다. net 패키지는 이러한 방식으로 오류를 처리합니다.
type Error interface { error Timeout() bool // 오류가 시간 초과인가요? }
그런 다음 net.OpError
는 해당 Timeout
메서드를 구현하여 오류가 시간 초과인지 확인하고 특정 비즈니스 로직을 처리합니다.
오류 추상화 수준
추상화 수준이 현재 모듈보다 높은 오류를 발생시키지 마십시오. 예를 들어 DAO 계층에서 데이터를 가져올 때 데이터베이스가 레코드를 찾지 못하면 RecordNotFound
오류를 반환하는 것이 적절합니다. 그러나 상위 계층에서 오류를 변환하지 않도록 하기 위해 DAO 계층에서 APIError
를 직접 발생시키면 적절하지 않습니다.
마찬가지로 하위 수준의 추상 오류는 현재 계층의 추상화에 맞게 래핑해야 합니다. 상위 계층에서 래핑한 후 하위 계층에서 오류를 처리하는 방법을 변경해야 하는 경우 상위 계층에 영향을 미치지 않습니다.
예를 들어 사용자 로그인은 처음에 MySQL을 스토리지로 사용할 수 있습니다. 일치하는 항목이 없으면 오류는 "레코드를 찾을 수 없음"이 됩니다. 나중에 Redis를 사용하여 사용자를 일치시키면 일치에 실패하면 캐시 누락이 됩니다. 이 경우 상위 계층에서 기본 스토리지의 차이를 느끼지 않도록 해야 하므로 일관되게 "사용자를 찾을 수 없음" 오류를 반환해야 합니다.
err != nil
줄이기
if err != nil
의 빈도는 최상위 설계를 통해 줄일 수 있습니다. 일부 오류 처리는 하위 수준에서 캡슐화할 수 있으며 상위 수준에 노출할 필요가 없습니다.
함수의 순환 복잡성을 줄임으로써 if err != nil
에 대한 반복적인 검사 횟수를 줄일 수 있습니다. 예를 들어 함수 로직을 캡슐화함으로써 외부 계층은 오류를 한 번만 처리하면 됩니다.
func CreateUser(user *User) error { // 유효성 검사의 경우 분산시키는 대신 하나의 오류만 발생시키십시오. if err := ValidateUser(user); err != nil { return err } }
오류 상태를 구조체에 포함하고, 오류를 구조체 내부에 캡슐화하고, 문제가 발생한 경우에만 마지막에 오류를 반환하여 외부 계층에서 균일하게 처리할 수 있도록 할 수도 있습니다. 이렇게 하면 비즈니스 로직에서 여러 if err != nil
검사를 삽입하는 것을 방지할 수 있습니다.
데이터 복사 작업을 예로 들어 보겠습니다. 소스 및 대상 구성을 전달한 다음 복사를 수행합니다.
type CopyDataJob struct { source *DataSourceConfig destination *DataSourceConfig err error } func (job *CopyDataJob) newSrc() { if job.err != nil { return } if job.source == nil { job.err = errors.New("source is nil") return } // 소스 인스턴스화 } func (job *CopyDataJob) newDst() { if job.err != nil { return } if job.destination == nil { job.err = errors.New("destination is nil") return } // 대상 인스턴스화 } func (job *CopyDataJob) copy() { if job.err != nil { return } // 데이터 복사 ... } func (job *CopyDataJob) Run() error { job.newSrc() job.newDst() job.copy() return job.err }
오류가 발생하면 Run
의 각 단계가 계속 실행되지만 각 함수는 즉시 err
을 확인하고 설정된 경우 반환됩니다. 마지막에만 job.err
이 호출자에게 반환됩니다.
이 접근 방식은 기본 로직에서 err != nil
의 수를 줄일 수 있지만, 실제로 검사를 분산시킬 뿐 실제로 줄이지는 않습니다. 따라서 실제 개발에서는 이 트릭이 거의 사용되지 않습니다.
Leapcell은 Go 프로젝트 호스팅을 위한 최고의 선택입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다중 언어 지원
- Node.js, Python, Go 또는 Rust로 개발하십시오.
무제한 프로젝트를 무료로 배포
- 사용량에 대해서만 지불하십시오. 요청이나 요금이 부과되지 않습니다.
타의 추종을 불허하는 비용 효율성
- 유휴 요금 없이 사용한 만큼만 지불하십시오.
- 예: $25로 평균 응답 시간이 60ms인 694만 건의 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅
손쉬운 확장성과 뛰어난 성능
- 고도의 동시성을 쉽게 처리할 수 있도록 자동 확장됩니다.
- 운영 오버헤드가 없으므로 구축에만 집중하십시오.
설명서에서 더 자세히 알아보십시오!
X에서 팔로우하세요: @LeapcellHQ