sayu.day

견고한 Go 백엔드: 에러 핸들링, 동시성, Resilience 패턴

Go로 프로덕션 서비스를 운영하다 보면 에러 처리, 동시성 제어, 외부 연동 안정성이라는 세 가지 축이 결국 하나로 맞물립니다. 에러를 값으로 다루는 방식은 동시성 코드가 실패를 전파하는 방식을 결정하고, 동시성 설계는 외부 API 장애가 우리 서비스로 번지는 속도를 좌우합니다. 이 글에서는 errors.Is/errors.As 기반 에러 설계에서 시작해 goroutine 동시성 제어, 그리고 Timeout·Retry·Circuit Breaker·Rate Limit 조합까지 순서대로 정리합니다. Java, Node.js 등 다른 생태계와의 대응 관계도 함께 짚어 학습 곡선을 줄입니다.

에러는 문자열이 아니라 값이다

Go 에러 전략의 출발점은 단순합니다. 에러를 로그에 찍을 문자열이 아니라 판단 가능한 값으로 다루는 것입니다. 상위 계층으로 올릴 때는 %w로 원인 체인을 유지하고, 분기 판단은 문자열 비교 대신 errors.Is, errors.As를 사용합니다.

래핑은 %w를 기본으로

func readConfig(path string) error {
	if _, err := os.ReadFile(path); err != nil {
		return fmt.Errorf("config read failed: %w", err)
	}
	return nil
}

%v를 쓰면 원인 에러가 체인에서 사라집니다. 에러가 여러 계층을 거쳐 전달되는 경로에서는 %w를 기본값으로 둬야 합니다.

errors.Is로 분기하기

var ErrNotFound = errors.New("not found")

func findUser(id string) (*User, error) {
	u, err := repo.Find(id)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("find user: %w", err)
	}
	return u, nil
}

호출부는 체인 전체를 탐색해 의미를 찾습니다.

if errors.Is(err, ErrNotFound) {
	// 404 처리
}

errors.As로 타입 추출하기

type ValidationError struct {
	Field string
	Msg   string
}

func (e *ValidationError) Error() string {
	return fmt.Sprintf("invalid %s: %s", e.Field, e.Msg)
}
var vErr *ValidationError
if errors.As(err, &vErr) {
	log.Printf("field=%s msg=%s", vErr.Field, vErr.Msg)
}

도메인 에러로 HTTP에 매핑하기

type Code string

const (
	CodeNotFound Code = "NOT_FOUND"
	CodeConflict Code = "CONFLICT"
)

type DomainError struct {
	Code  Code
	Msg   string
	Cause error
}

func (e *DomainError) Error() string { return e.Msg }
func (e *DomainError) Unwrap() error { return e.Cause }

핸들러는 원천 에러가 아니라 도메인 코드만 보고 응답을 결정합니다.

func toStatus(code Code) int {
	switch code {
	case CodeNotFound:
		return http.StatusNotFound
	case CodeConflict:
		return http.StatusConflict
	default:
		return http.StatusInternalServerError
	}
}

운영에서 지켜야 할 규칙

  • 사용자에게 보여줄 메시지와 내부 로그 메시지를 분리합니다.
  • 재시도 가능 여부(temporary/permanent)를 코드나 타입으로 표현합니다.
  • 로그는 책임 계층에서 한 번만 남깁니다. 중복 로깅은 노이즈만 늘립니다.

피해야 할 패턴도 명확합니다. return errors.New("...")로 원인을 지우는 것, err.Error() 문자열 포함 여부로 분기하는 것, 패키지 전역 센티넬 에러를 과도하게 남발하는 것은 모두 장애 대응 속도를 떨어뜨립니다.

Goroutine 동시성: 가볍다고 무제한은 아니다

에러 전략이 실패를 값으로 다루는 규칙이라면, 동시성 설계는 그 실패가 얼마나 넓게 퍼지는지를 결정합니다. Java Thread와 Go goroutine을 비교하면 차이가 뚜렷합니다.

특성 Java Thread Go Goroutine
초기 스택 ~1MB (OS 할당) ~2KB (Go 런타임)
최대 스택 고정 동적 확장(최대 1GB)
스케줄링 OS 커널 Go 런타임(M:N)
생성 비용 높음 낮음
실용적 상한 수천 개 수십만 개

가볍다는 것이 무제한이라는 뜻은 아닙니다. 초기 스택 2KB에 디스크립터 약 400바이트를 더하면 goroutine 하나당 약 2.4KB가 필요합니다. 10만 개면 최소 240MB이며, 512MB~2GB로 제한된 컨테이너에서는 OOM 위험이 실질적입니다. 실제로 요청마다 goroutine을 만들고 외부 API 응답이 지연되면, 대기 중인 goroutine이 계속 쌓이다가 컨테이너 메모리 제한에 도달해 강제 종료되는 시나리오가 흔합니다. 따라서 goroutine 생성은 제한된 자원처럼 다뤄야 합니다.

ExecutorService 대응: Worker Pool

Java의 Executors.newFixedThreadPool(10)이 스레드 수를 고정하고 BlockingQueue로 작업을 흘려보내듯, Go에서는 고정된 수의 worker goroutine이 buffered channel에서 작업을 꺼내는 Worker Pool 패턴을 씁니다. executor.submit(task)jobs <- task로, Future.get()<-results로 대응합니다.

CompletableFuture.allOf 대응: errgroup

여러 goroutine을 실행하고 결과를 모으는 상황은 errgroup으로 다룹니다. Main이 g.Go(fn1), g.Go(fn2), g.Go(fn3)으로 세 goroutine을 실행했을 때, 하나가 에러를 반환하면 연결된 Context가 취소되고 나머지 goroutine은 ctx.Done()을 감지해 스스로 정리합니다. g.Wait()는 이 중 첫 에러를 반환합니다. Java의 CompletableFuture.allOf가 모든 작업의 완료를 기다리는 것과 달리, errgroup은 Context와 연동해 첫 에러 발생 시 나머지를 취소할 수 있다는 점이 핵심 차이입니다.

Channel 설계 원칙

Unbuffered channel은 SynchronousQueue처럼 핸드셰이크·동기 통신에, buffered channel은 ArrayBlockingQueue처럼 백프레셔·비동기 처리에 사용합니다. 닫기 규칙도 명확히 해야 합니다. Java의 BlockingQueue는 보통 닫지 않지만, Go에서는 채널을 생성한 Producer가 close()를 호출하는 것이 관례입니다. Consumer가 닫으면 다른 Consumer가 막힌 채널에 쓰기를 시도해 패닉이 발생할 수 있습니다.

Mutex vs Channel

공유 상태를 보호할 때는 sync.Mutex(Java의 synchronized 대응)를, 작업을 분배하거나 이벤트를 전달할 때는 Channel(BlockingQueue 대응)을 씁니다. 캐시나 카운터처럼 상태 자체를 지키는 문제인지, 작업이나 이벤트를 전달하는 문제인지를 먼저 구분하면 선택이 쉬워집니다.

Race Detector

Go는 Java의 ThreadSanitizer에 대응하는 경쟁 상태 탐지 도구를 표준으로 내장하고 있습니다.

go test -race ./...
go build -race ./cmd/myapp

런타임 오버헤드가 있지만 테스트 환경에서는 문제가 되지 않으므로 CI에 필수로 넣는 것을 권장합니다.

개념 Java 대응 Go
스레드 풀 ExecutorService Worker Pool + Channel
병렬 실행 CompletableFuture errgroup
공유 상태 synchronized sync.Mutex
메시지 전달 BlockingQueue Channel
경쟁 상태 탐지 ThreadSanitizer -race 플래그

외부 통신 Resilience: 겹겹이 쌓는 방어선

동시성 제어로 내부 자원을 지켰다면, 다음은 외부 의존성 장애가 우리 서비스로 전파되지 않도록 막는 일입니다. 마이크로서비스 환경에서는 외부 API가 느려질 때 우리 서비스가 어떻게 반응할지, 재시도는 언제 해야 하고 언제 하면 안 되는지, 장애 전파를 어떻게 차단할지를 미리 정해둬야 합니다. Resilience4j(Java), Polly(.NET), cockatiel·opossum(Node.js) 생태계의 패턴을 Go에서도 동일하게 적용할 수 있습니다.

적용 순서

바깥에서 안쪽으로 갈수록 개별 요청에 가까운 제어를 담당합니다.

순서 패턴 목적 위치 이유
1 Rate Limit 요청 속도 제한 가장 바깥에서 과부하 방지
2 Circuit Breaker 장애 서비스 격리 불필요한 재시도 차단
3 Retry 일시적 실패 복구 타임아웃 전에 재시도 결정
4 Timeout 무한 대기 방지 가장 안쪽에서 개별 요청 제어

Timeout: 다계층으로 설정

연결(5초), TLS 핸드셰이크(5초), 응답 헤더(10초), 전체 요청(30초)처럼 계층별로 타임아웃을 나눠 설정합니다. 각각 DNS 장애·네트워크 단절, 인증서 문제, 서버 처리 지연, 대용량 응답이라는 서로 다른 장애 상황에 대응합니다. 타임아웃은 선택이 아니라 모든 외부 호출에 반드시 있어야 하는 최소 방어선입니다.

Retry: 재시도 가능한 실패만 재시도

5xx 서버 에러, 408 요청 타임아웃, 네트워크 오류는 재시도 대상입니다. 429 Too Many Requests는 대기 후 재시도해야 하고, 400·401·403·404 같은 4xx 클라이언트 에러는 재시도하지 않습니다. 재시도 간격은 지수 백오프에 지터를 더해 설계합니다(예: 100ms → 200ms+jitter → 400ms+jitter → 800ms+jitter). 지터가 필요한 이유는 동시에 실패한 클라이언트들이 동시에 재시도하면서 발생하는 thundering herd를 막기 위해서입니다.

Circuit Breaker: 상태 머신으로 장애 격리

Circuit Breaker는 Closed(정상 동작) → Open(실패율 60% 이상, 최소 5회 요청 기준으로 즉시 실패 처리) → 30초 경과 후 Half-Open(제한된 수의 요청만 통과시켜 테스트) → 테스트 성공 시 Closed, 실패 시 다시 Open으로 순환하는 상태 머신입니다. Closed와 Half-Open 상태에서는 메트릭을 수집하지만 Open 상태에서는 즉시 실패(Fallback)를 반환해 불필요한 재시도를 차단합니다. Go에서는 sony/gobreaker가 Resilience4j의 failureRateThresholdReadyToTrip 콜백으로, waitDurationInOpenStateTimeout으로, permittedNumberOfCallsInHalfOpenStateMaxRequests로 대응시킵니다.

Rate Limiting: Token Bucket

초당 일정 개수의 토큰이 버킷에 채워지고, 요청이 도착할 때마다 토큰을 소비합니다. 토큰이 있으면 처리하고 없으면 대기하거나 거부합니다. 외부 API가 100 req/s로 제한되어 있다면 우리 쪽은 80 req/s처럼 20% 여유를 두고 설정하는 것이 안전합니다. 버스트 트래픽이나 같은 API 키를 쓰는 다른 클라이언트를 고려한 완충 장치입니다.

언제 어떤 패턴을 조합할까

상황 적용 패턴
네트워크 일시 장애 Retry + Exponential Backoff
외부 서비스 장애 Circuit Breaker
외부 API 호출량 제한 Rate Limiting
모든 외부 호출 Timeout(필수)

실제 도입은 보통 단계적으로 이뤄집니다. 처음에는 Timeout만 적용했다가 외부 장애 시 요청이 누적되는 문제를 겪고, Retry를 추가했다가 재시도 폭풍을 경험하고, Circuit Breaker로 장애를 격리한 뒤, 마지막으로 Rate Limiting까지 더해 안정성을 완성하는 흐름입니다. 처음부터 네 가지를 모두 넣기보다, 장애를 겪을 때마다 필요한 패턴을 순서대로 채워 넣는 편이 현실적입니다.

Go 라이브러리는 Circuit Breaker에 sony/gobreaker, Retry는 직접 구현 또는 avast/retry-go, Rate Limiting은 golang.org/x/time/rate, 여러 패턴을 통합하려면 failsafe-go를 검토할 만합니다.

패턴 목적 적용 위치
Timeout 무한 대기 방지 필수, 모든 외부 호출
Retry 일시적 실패 복구 5xx, 네트워크 오류만
Circuit Breaker 장애 전파 차단 외부 의존성 호출
Rate Limiting 과부하 방지 API 호출량 제한 시

정리

에러를 값으로 다루는 규칙, goroutine과 channel을 절제해서 쓰는 동시성 설계, 그리고 Timeout·Retry·Circuit Breaker·Rate Limit을 겹겹이 쌓는 외부 통신 방어선은 서로 독립된 주제가 아닙니다. 에러 코드가 명확해야 Circuit Breaker가 재시도 가능한 실패와 그렇지 않은 실패를 구분할 수 있고, 동시성이 절제되어 있어야 외부 장애가 발생했을 때 goroutine이 무한정 쌓이지 않습니다. 이 세 축을 팀 표준으로 고정하면 장애 대응 속도와 코드 일관성이 함께 좋아집니다.