트래픽이 붙는 백엔드일수록 화려한 추상화보다 예측 가능한 구조가 중요하다. Go는 그 균형을 오래 유지한다.
By Subtraction
빠져 있는 기능이 곧 설계입니다
다른 언어를 쓰다 Go를 처음 열면 없는 것부터 눈에 들어옵니다. 클래스 상속이 없고, 예외가 없고, 함수 오버로딩이 없고, 오랫동안 제네릭도 없었습니다. 이것들은 아직 못 만든 기능이 아니라 빼기로 한 결정입니다. 목적이 분명합니다. 코드를 읽는 사람이 다른 파일을 열어 보지 않고도 이 함수가 무엇을 하는지 알 수 있게 만드는 것. 큰 조직에서 사람이 계속 바뀌는 서비스 코드를 몇 년 굴려 본 팀이라면 이 교환의 값어치를 압니다.
그래서 Go가 기본값이 되는 자리는 명확합니다. HTTP API 서버, 큐를 소비하는 워커, 로그·메트릭을 옮기는 파이프라인, 사내 CLI와 운영 도구. 요청을 받고 다른 시스템을 부르고 응답을 만드는 작업, 즉 대기 시간이 대부분인 네트워크 서비스에서 언어의 동시성 모델과 배포 형태가 그대로 이득이 됩니다.
반대로 Go가 좋은 답이 아닌 자리도 있습니다. 도메인 모델이 복잡하고 타입으로 규칙을 강하게 표현하고 싶을 때, 수치 계산이나 과학 라이브러리 생태계가 필요할 때, GC가 만드는 짧은 멈춤조차 허용되지 않는 실시간 경로일 때. 표현력을 일부러 줄인 언어이므로 추상화를 많이 요구하는 도메인에서는 같은 코드를 여러 번 쓰게 됩니다. 그 반복이 견딜 만한지가 판단 기준입니다.
Why It Works
이럴 때 특히 힘을 발합니다
운영이 읽기 쉽다
동시성이 goroutine과 channel 두 개념 위에 올라가 있어, 새로 합류한 사람도 며칠이면 팀의 동시성 코드를 같은 감각으로 읽습니다. gofmt가 서식 논쟁까지 끝내 줍니다.
배포 단위가 단순하다
실행 파일 하나로 움직이는 구조는 컨테이너 환경과 특히 궁합이 좋다. 운영팀이 서비스 형태를 예측하기 쉽다.
성능 튜닝 포인트가 선명하다
pprof로 할당과 CPU 프로파일을 바로 뜰 수 있고, 손댈 변수도 워커 수·큐 길이·타임아웃처럼 손에 잡힙니다. 튜닝이 추측이 아니라 측정에서 시작합니다.
Errors Are Values
에러를 값으로 다룬다는 것
Go에는 예외가 없습니다. 함수는 결과와 에러를 함께 돌려주고, 호출한 쪽이 그 자리에서 확인합니다. 그래서 코드에 if err != nil이 계속 등장합니다. 처음 보면 잡음처럼 느껴지지만, 이 반복이 설계 의도입니다. 실패 경로가 화면에 그대로 보이므로 어떤 호출이 실패할 수 있는지, 실패했을 때 무엇을 되돌려야 하는지를 읽는 사람이 건너뛸 수 없습니다. 예외가 있는 언어에서 흔한 “어딘가 위쪽에서 잡히겠지”가 성립하지 않습니다.
대신 지켜야 할 규율이 생깁니다. 에러를 그냥 올려보내지 말고 어느 단계에서 실패했는지 문맥을 붙여 감싸고, 호출한 쪽이 종류를 구분해야 한다면 문자열 비교 대신 센티널 에러나 커스텀 타입으로 판별합니다. 무시할 에러는 무시한다고 코드에 남깁니다. 로그만 찍고 흘려보내는 습관이 쌓이면 예외가 없는 이점이 사라집니다.
여기서 한 가지 함정을 같이 기억해 두는 편이 좋습니다. 값이 nil인 포인터를 인터페이스 타입으로 반환하면 그 인터페이스는 nil이 아닙니다. 타입 정보가 들어 있기 때문입니다. 그래서 에러 타입 포인터를 error로 반환하는 함수는 실패가 없어도 err != nil이 참이 됩니다. 감싸기와 판별을 어디까지 할지는 Go 에러 처리 가이드에서 이어집니다.
문맥을 붙여 감싸는 에러
var ErrNotFound = errors.New("not found")
func loadTenant(ctx context.Context, id string) (Tenant, error) {
row, err := store.Get(ctx, id)
if err != nil {
return Tenant{}, fmt.Errorf("load tenant %s: %w", id, err)
}
if row.Empty() {
return Tenant{}, fmt.Errorf("load tenant %s: %w", id, ErrNotFound)
}
return row.Tenant, nil
}
// 호출한 쪽에서 종류를 구분합니다
if errors.Is(err, ErrNotFound) {
w.WriteHeader(http.StatusNotFound)
return
}
%w로 감싸면 원래 에러가 보존되고, errors.Is로 종류를 판별할 수 있습니다. 문자열 비교로 분기하는 코드는 오래가지 못합니다.
Concurrency
고루틴이 싸다고 공짜는 아닙니다
고루틴은 스레드보다 훨씬 가벼워서 수만 개를 띄워도 견딥니다. 그래서 “일단 go 붙이자”가 쉽게 나옵니다. 문제는 시작 비용이 아니라 종료와 소유권입니다. 누가 이 고루틴을 멈추는지, 채널을 누가 닫는지, 받는 쪽이 사라지면 보내는 쪽은 어떻게 되는지를 정하지 않으면 코드는 조용히 새다가 며칠 뒤 메모리 그래프로 나타납니다.
Go가 준 해답이 context입니다. 요청 하나가 만들어 낸 모든 작업에 같은 취소 신호를 흘려보내고, 각 고루틴은 select에서 그 신호를 함께 기다립니다. 요청이 끊기거나 마감 시간이 지나면 하위 작업이 스스로 정리됩니다. 실무에서 context를 인자로 받지 않는 내부 함수는 대개 나중에 취소를 못 붙여서 고쳐 쓰게 됩니다.
슬라이스도 같은 종류의 함정을 갖고 있습니다. 슬라이스는 배열을 가리키는 창이라서, 잘라 낸 조각은 원본과 저장 공간을 공유합니다. 한쪽에서 쓴 값이 다른 쪽에 보이고, append가 용량 안에서 일어나면 원본이 덮어써집니다. 여러 고루틴이 같은 슬라이스를 만지면 여기서 경합이 생깁니다. 다행히 판별은 어렵지 않습니다. 테스트를 레이스 디텍터와 함께 돌리면 대부분 잡힙니다.
취소 없는 고루틴 누수
응답을 기다리다 요청이 취소됐는데 고루틴은 계속 채널을 기다립니다. 받는 쪽이 사라진 채널로 보내려는 고루틴도 영원히 멈춰 섭니다. context 취소와 select를 함께 쓰지 않으면 반드시 나옵니다.
버퍼 없는 채널 교착
버퍼 없는 채널은 보내는 쪽과 받는 쪽이 만나야 진행됩니다. 같은 고루틴에서 보내고 받거나, 받는 루프를 시작하기 전에 보내면 그 자리에서 멈춥니다. 채널을 두 번 닫는 실수도 같은 지점에서 나옵니다.
공유 상태 경합
맵이나 슬라이스를 여러 고루틴이 같이 수정하는 코드는 평소에는 잘 돌다가 부하가 걸릴 때만 깨집니다. 공유할 값은 채널로 넘기거나 뮤텍스로 감싸고, 테스트는 레이스 디텍터를 켜고 돌립니다.
Business Scenes
사업 현장에서 자주 보이는 장면
API 게이트웨이
짧은 응답 시간과 명확한 타임아웃 제어가 필요한 API 레이어에서 안정적으로 오래 간다.
배치 워커
대량 이미지 처리, 정산 집계, 메시지 소비 같은 작업에서 병렬 처리 구조를 단순하게 가져가기 좋다.
내부 플랫폼 툴
사내 CLI, 운영용 프록시, 배포 보조 도구처럼 팀의 손발이 되는 유틸리티에 잘 맞는다.
Workflow
팀이 움직이는 방식
- 서버는 handler보다 timeout, retry, queue 길이부터 문서화한다.
- 동시성은 “몇 개를 동시에 돌릴지” 숫자로 먼저 합의한다.
- 장애 리포트에는 stack trace보다 지연 구간과 backpressure 지점을 함께 적는다.
- 성능 이슈는 추상화보다 할당 수와 네트워크 hop을 줄이는 쪽부터 본다.
운영형 워커의 기본
type Job struct {
Tenant string
Attempts int
}
func worker(ctx context.Context, jobs <-chan Job, results chan<- error) {
for {
select {
case <-ctx.Done():
return
case job := <-jobs:
results <- handle(job)
}
}
}
실무의 Go 코드는 대개 “언제 멈추고, 몇 개 돌리고, 어디서 실패를 모을지”가 먼저 보인다.
Reading The Code
워커 예제가 저렇게 생긴 이유
위 워커 코드에서 먼저 볼 것은 인자 타입입니다. 받는 채널은 <-chan Job, 보내는 채널은 chan<- error로 방향을 못 박았습니다. 방향을 지정하면 이 함수가 채널을 닫거나 반대로 쓰는 실수를 컴파일러가 막아 줍니다. 채널을 닫는 책임은 보내는 쪽에 있다는 관례를 타입으로 표현한 셈입니다.
두 번째는 select의 첫 갈래가 ctx.Done()이라는 점입니다. 작업을 받기 전에 종료 신호부터 기다리기 때문에, 배포나 스케일 인으로 프로세스가 내려갈 때 워커가 스스로 빠져나옵니다. 이 한 줄이 없으면 잡을 계속 기다리는 고루틴이 남아 종료가 지연되고, 최악의 경우 처리 중이던 잡이 중간에 끊깁니다. 무한 루프 안에서 취소 신호와 작업 수신을 함께 기다리는 이 모양이 Go 워커의 기본형입니다.
세 번째는 결과를 반환하지 않고 결과 채널로 모은다는 점입니다. 워커를 몇 개 띄우든 호출한 쪽은 채널 하나만 읽으면 되고, 워커 수를 바꾸는 일이 설정값 변경으로 끝납니다. 여기에 인터페이스를 더할 때도 Go의 방식은 다릅니다. 구현체가 “나는 이 인터페이스를 구현한다”고 선언하지 않고, 필요한 쪽이 필요한 메서드만 모아 작은 인터페이스를 정의합니다. 그래서 테스트에서 가짜 구현을 끼우기 쉽고, 패키지 사이 의존이 한 방향으로 정리됩니다. 워커 풀과 인터페이스 분리를 실제 코드로 보려면 구조체·인터페이스 가이드를 이어 보면 됩니다.
Ship It
바이너리 하나로 끝나는 배포
Go의 실무 매력 절반은 도구 사슬에서 나옵니다. go build가 의존성을 모두 포함한 정적 실행 파일 하나를 만들고, 그 파일만 컨테이너에 넣으면 끝입니다. 런타임도, 인터프리터도, 시스템 패키지 목록도 함께 옮길 필요가 없습니다. 그래서 최종 이미지가 아주 얇아지고, “운영 서버에는 왜 이 버전이 없지” 같은 사고가 사라집니다. 크로스 컴파일도 환경 변수 두 개로 끝나기 때문에 맥에서 리눅스용 바이너리를 뽑아 넘기는 일이 일상입니다.
테스트는 언어에 붙어 있습니다. go test가 표준이고, 관례는 입력과 기대값을 슬라이스에 나열하고 한 루프에서 돌리는 테이블 주도 방식입니다. 케이스를 추가하는 일이 줄 하나 늘리는 일이 되니 테스트가 잘 자랍니다. 동시성 코드는 레이스 디텍터를 켜고 돌리는 것이 사실상 필수 절차이고, 성능 문제는 벤치마크와 pprof로 할당·CPU·블로킹 지점을 직접 봅니다. 여기에 go vet과 모듈 기반 의존성 관리까지 표준으로 들어 있어, 팀이 도구를 고르느라 쓰는 시간이 거의 없습니다.
운영 관점에서 이 조합이 주는 결과는 단순합니다. 빌드 산출물이 하나, 테스트 명령이 하나, 프로파일 방법이 하나입니다. 신규 입사자가 서비스 하나를 처음부터 띄워 보는 데 걸리는 시간이 짧고, 장애 대응 중에 도구 사용법을 검색할 일이 적습니다. 자세한 절차는 Go 테스트 가이드와 성능 가이드에 정리해 두었습니다.
Decide
언제 Go가 답이 아닌가
- 요청을 받아 다른 시스템을 부르고 응답을 만드는 서비스가 주력이라면, 동시성 모델과 배포 형태가 그대로 이득이 됩니다.
- 팀 인원이 자주 바뀌고 코드 스타일 합의에 지쳤다면, gofmt와 작은 문법이 논쟁 자체를 없애 줍니다.
- 컨테이너 이미지 크기와 기동 시간이 운영 지표에 들어 있다면 정적 바이너리 한 개라는 산출물이 바로 값을 합니다.
- 지금은 아닙니다 — 도메인 규칙이 복잡해 타입으로 상태를 강하게 잠그고 싶다면, 표현력을 줄인 언어가 계속 발목을 잡습니다.
- 지금은 아닙니다 — 수치 계산이나 데이터 과학 라이브러리가 필요한 작업이라면 생태계가 얇습니다.
- 지금은 아닙니다 — GC가 만드는 짧은 멈춤도 허용되지 않는 실시간 경로라면 다른 선택지를 봐야 합니다.
- 지금은 아닙니다 — 팀이 아직 취소와 타임아웃을 설계에 넣는 습관이 없다면, 고루틴을 늘리는 순간 누수부터 배우게 됩니다.
예제로 손을 풀고 싶다면 Go 학습 페이지가 출발점이고, 서비스 구조를 같이 고민 중이라면 확장 가능한 서비스 설계 정리를 함께 보면 판단이 빨라집니다.