
예외 처리가 없는 Go 언어 구시대적 발상일까 혁신일까
우리가 프로그래밍을 하다 보면 '에러 처리'는 피할 수 없는 숙명인데요.
오늘은 많은 분들이 궁금해하시는 'Go 언어'의 독특한 에러 처리 방식에 대해 이야기해보려고 합니다.
자바나 자바스크립트 같은 다른 언어를 쓰다가 Go 언어를 처음 접하시는 분들은 이 방식에 꽤 당황하시거든요.
흔히 쓰이는 마법 같은 예외 처리 구문이 없고 에러를 함수의 반환값으로 직접 처리해야 하기 때문입니다.
겉보기에는 코드가 굉장히 길어지고 지루하게 반복되는 것처럼 보일 수 있는데요.
그래서 오늘은 왜 Go 언어가 이런 투박해 보이는 방식을 채택했는지 그 숨은 철학과 역사적 배경을 깊이 파헤쳐보겠습니다.
C 언어의 에러 처리와 그 씁쓸한 한계
Go 언어의 선택을 제대로 이해하려면 먼저 프로그래밍 언어의 조상격인 C 언어 시절로 거슬러 올라가야 하는데요.
C 언어는 언어의 구조적인 특성상 함수에서 단 하나의 값만 반환할 수 있었습니다.
그래서 정상적인 값과 에러를 구분하기 위해 보통 마이너스 일 같은 특별한 숫자를 반환하는 꼼수를 썼거든요.
아래 코드를 보면 새로운 프로세스를 생성하는 함수가 실패했을 때 마이너스 일을 반환하는 것을 볼 수 있습니다.
int main() {
pid_t pid = fork();
if (pid == -1)
printf("fork()에 실패했습니다\n");
}
하지만 이런 방식은 개발자들을 괴롭히는 치명적인 문제점들을 안고 있었는데요.
마이너스 일이라는 숫자가 진짜 에러를 의미하는지 아니면 계산 결과로 나온 단순한 음수인지 함수 내부를 모르면 전혀 알 길이 없었습니다.
이를 해결하기 위해 함수의 인자로 구조체의 포인터를 넘겨서 에러 코드를 몰래 받아오는 등 코드가 점점 기형적으로 변해갔거든요.
개발자들은 에러 처리를 위해 항상 해당 함수의 공식 문서를 꼼꼼히 외우다시피 해야만 했습니다.
당시에는 하드웨어 자원이 극도로 부족했기 때문에 이런 방식이 최선이었는데요.
하지만 소프트웨어의 규모가 폭발적으로 커지면서 이런 원시적인 에러 처리 방식은 대규모 시스템 개발의 발목을 강하게 잡기 시작했습니다.
예외 던지기의 등장과 그 이면에 숨겨진 그림자
C 언어의 이런 답답함을 해결하기 위해 C++이나 JavaScript 같은 후발 언어들은 '예외 던지기'라는 새로운 패러다임을 도입했는데요.
에러를 반환값에 지저분하게 섞지 않고 아예 별도의 실행 흐름으로 분리해버린 것입니다.
const fetchSomeAPI = () => {
throw new FetchError("데이터 얻기에 실패했습니다")
}
try {
await fetchSomeAPI()
} catch (e) {
console.error(e)
}
이 방식은 처음 등장했을 때 개발자들에게 그야말로 혁명적으로 다가왔거든요.
정상적인 반환값과 에러가 완벽하게 분리되었고 에러에 명확한 타입을 부여할 수 있게 되었습니다.
게다가 지금 당장 처리하기 힘든 에러는 상위 함수로 자연스럽게 떠넘겨버릴 수도 있었는데요.
하지만 시간이 지나면서 이 우아해 보이던 방식에도 치명적인 부작용들이 하나둘씩 나타나기 시작했습니다.
가장 큰 문제는 함수 선언만 봐서는 이 녀석이 언제 어디서 폭탄을 던질지 전혀 예측할 수 없다는 점이거든요.
결국 안전한 프로그램을 만들려면 내부 코드를 다 까봐야 하는 C 언어 시절의 악몽으로 다시 돌아가고 만 것입니다.
자바 같은 언어는 이 문제를 해결하려고 무조건 예외 처리를 강제하는 방식을 쓰기도 했는데요.
오히려 개발자들이 귀찮다는 이유로 예외 처리 블록을 텅 비워두는 끔찍한 안티 패턴을 만들어내는 결과를 초래했습니다.
네트워크 지연 같은 가벼운 문제부터 메모리 침범 같은 복구 불가능한 버그까지 전부 하나의 예외 시스템에 우겨넣은 것도 문제였거든요.
게다가 예외가 발생할 때마다 콜스택을 뒤져야 해서 시스템 성능을 갉아먹는 보이지 않는 원인이 되기도 했습니다.
Go 언어의 우직한 선택과 다중 반환값
이런 예외 처리의 참사를 지켜본 Go 언어의 창시자들은 전혀 다른 길을 가기로 결심했는데요.
마법 같은 예외 처리 구문을 아예 언어에서 빼버리고 C 언어처럼 투박하지만 예측 가능한 길을 선택한 것입니다.
대신 Go 언어는 함수가 여러 개의 값을 동시에 반환할 수 있는 아주 강력한 무기를 가지고 있었거든요.
이를 이용해 정상적인 결과값과 에러를 나란히 반환하는 투명하고 직관적인 방식을 만들어냈습니다.
func Sqrt(f float64) (float64, error) {
if f < 0 {
return 0, errors.New("음수의 제곱근은 구할 수 없습니다")
}
}
func main() {
f, err := Sqrt(-1)
if err != nil {
fmt.Println(err)
}
}
위 코드를 보시면 함수가 실행될 때 에러가 발생할 수 있다는 사실이 함수의 형태만으로도 투명하게 드러나는데요.
개발자는 반환된 에러 변수를 확인하는 것만으로 프로그램의 모든 안전장치를 확고하게 마련할 수 있습니다.
물론 정말로 프로그램이 당장 죽어야 할 만큼 치명적인 상황을 위한 대비책도 섬세하게 마련해 두었거든요.
func f() {
defer func() {
if r := recover(); r != nil {
fmt.Println("복구되었습니다")
}
}()
g()
}
func g() {
panic("치명적 에러 발생")
}
일반적인 에러는 반환값으로 얌전하게 처리하고 복구 불가능한 상황에서는 이처럼 별도의 시스템을 사용하도록 명확히 선을 그었는데요.
Go 언어의 공식 문서를 보면 이 철학이 아주 단호하고 명확하게 적혀 있습니다.
파일 열기 실패처럼 일상적으로 일어나는 평범한 문제들을 마치 대단한 예외 상황인 것처럼 다루는 것은 코드를 망치는 지름길이라고 경고하고 있거든요.
Go 언어는 다중 반환값을 통해 반환값을 억지로 훼손하지 않고도 에러를 우아하게 보고할 수 있는 최적의 환경을 제공합니다.
에러는 그저 값일 뿐이다
그럼에도 불구하고 매번 에러가 있는지 확인해야 하는 Go 언어의 코드가 너무 길고 흉하다는 비판은 끊이지 않았는데요.
이에 대해 Go 언어의 핵심 개발자인 롭 파이크는 '에러는 값이다'라는 아주 유명한 명언으로 화려하게 반박했습니다.
_, err = fd.Write(p0[a:b])
if err != nil {
return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
return err
}
다른 언어의 예외 처리 방식을 머릿속에 둔 채 Go 코드를 작성하면 위처럼 숨 막히게 지루한 코드가 반복될 수밖에 없거든요.
하지만 에러를 특별한 존재가 아니라 일반적인 데이터 즉 값으로 취급하면 상황은 완전히 달라집니다.
type errWriter struct {
w io.Writer
err error
}
func (ew *errWriter) write(buf []byte) {
if ew.err != nil {
return
}
_, ew.err = ew.w.Write(buf)
}
ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
if ew.err != nil {
return ew.err
}
이 코드를 자세히 보시면 에러가 발생할 때마다 즉시 처리하는 대신 구조체 안에 조용히 저장해 두는데요.
모든 작업이 끝난 후 마지막에 딱 한 번만 에러가 있었는지 검사하는 아주 세련된 방식을 보여주고 있습니다.
에러 역시 단순한 변수이자 인터페이스이기 때문에 개발자가 원하는 대로 이리저리 요리할 수 있다는 것을 증명한 셈이거든요.
중간에 발생한 에러를 즉각적으로 알아채기 힘들다는 소소한 단점은 있지만 대부분의 비즈니스 로직에서는 이것만으로도 충분히 훌륭하게 동작합니다.
최근에는 여러 개의 에러를 한 번에 묶어서 반환하는 유용한 기능들도 표준 라이브러리에 추가되었는데요.
var errs []error
for _, item := range items {
if err := validate(item); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
사용자의 입력값을 검증할 때 발생하는 자잘한 에러들을 리스트로 모아서 한 번에 응답을 내려줄 때 아주 유용하게 쓰이거든요.
이런 명시적인 에러 처리 흐름은 최근 시스템 프로그래밍의 대세로 떠오른 러스트 언어에서도 비슷하게 찾아볼 수 있습니다.
보이지 않는 마법에 기대기보다는 눈에 보이는 투명한 코드가 궁극적으로 더 유지보수하기 좋다는 철학이 업계의 표준으로 자리 잡아가고 있는 것이거든요.
투박해 보였던 Go 언어의 우직한 선택이 결국 시대를 앞서간 혜안이었음이 증명되고 있는 셈입니다.
철학이 담긴 코드의 아름다움
지금까지 Go 언어가 왜 대세인 예외 처리를 거부하고 자신만의 확고한 길을 개척했는지 살펴보았는데요.
처음에는 귀찮고 반복적인 타자 연습처럼 보이지만 그 안에는 시스템을 투명하고 안전하게 만들려는 거장들의 깊은 고민이 담겨 있었습니다.
에러를 피해야 할 특별한 재앙이 아니라 프로그램이 다루어야 할 평범한 데이터로 바라보는 시각의 전환이 이 철학의 핵심이거든요.
오늘 정리해 드린 내용이 여러분의 프로젝트에서 에러를 대하는 태도와 코드의 품질을 한 단계 끌어올리는 데 작은 보탬이 되기를 바랍니다.
'Go' 카테고리의 다른 글
| 당신의 URL이 구린 이유와 세련된 API 디자인을 위한 황금률 (0) | 2026.03.28 |
|---|---|
| Go 1.25 JSON v2 완벽 가이드 변화된 기능과 성능 분석 (1) | 2025.12.07 |
| Go 언어 부동소수점 완벽 정복 오차 없는 계산을 위한 필독 가이드 (0) | 2025.10.18 |
| Go 언어 로깅 완벽 가이드, 라이브러리 4가지 비교 분석 (0) | 2025.10.18 |
| Go 1.25 신기능 총정리 실전 예제와 마이그레이션 가이드 (4) | 2025.08.24 |
