CodeGym /행동 /Go SELF /errors.Is 및 errors.As

errors.Is 및 errors.As

Go SELF
레벨 17, 레슨 1
사용 가능

1. err.Error()가 아니라 의미로 오류를 검사합니다

처음 프로그램을 작성할 때는 이런 식으로 하고 싶어집니다. 오류 텍스트에 "invalid"라는 단어가 들어 있으면 “입력 오류”, 아니면 “내부 오류”라고 처리하는 방식입니다. 이 방법은 동작하긴 하지만, 리팩터링, 표현 변경, 로컬라이제이션, 라이브러리 변경, 또는 누군가가 메시지 끝에 마침표를 하나 붙이는 순간 바로 깨집니다.

Go에서 err.Error() 문자열은 무엇보다도 사람을 위한 메시지이지, 프로그램을 위한 계약이 아닙니다. strings.Contains(err.Error(), "...") 같은 방식으로 결정을 내리기 시작하면, 영원한 고통의 길로 들어서는 셈입니다. “왜 Peti의 컴퓨터에서는 잘 되는데 Masha에게만 다른 오류가 나지?” 같은 상황이 생기게 됩니다. (힌트: Peti가 의존성을 업데이트했기 때문입니다.)

때로는 문자열을 출력하기도 합니다. 로그, 사용자 화면, CLI 같은 곳에서는 말이죠. 하지만 로직 분기는 문자열이 아니라 검사 가능한 대상에 기반해 만드는 편이 좋습니다. Go에서는 이 작업을 위해 errors.Is와 errors.As가 존재합니다.

2. errors.Is: 특정 원인에 대한 오류 검사

errors.Is(err, target)은 이렇게 묻습니다. “err이(또는 체인 안의 어떤 원인이든) 바로 그 target 오류인가?” 이것이 중요한 이유는 wrapping 이후에는 err == target 비교가 거의 항상 더 이상 작동하지 않기 때문입니다. 바깥으로는 새 오류 래퍼가 반환되고, 원래 오류는 그대로 노출되지 않기 때문입니다.

핵심은 이렇습니다. errors.Is는 오류 원인 체인을 직접 따라가며 확인합니다. 마치 errors.Unwrap을 루프에서 직접 호출한 것과 같지만, 불필요한 반복과 실수를 줄여 줍니다.

미니 데모: wrapping 후 err == ErrX는 깨집니다

미리 만들어 둔 sentinel 오류를 생각해 봅시다.

package main

import (
	"errors"
	"fmt"
)

var ErrBadInput = errors.New("bad input")

func main() {
	err := fmt.Errorf("parse: %w", ErrBadInput)

	fmt.Println(err == ErrBadInput)          // false
	fmt.Println(errors.Is(err, ErrBadInput)) // true
}

err == ErrBadInput는 false입니다. 왜냐하면 err가 이제 “래퍼”이기 때문입니다. 반면 errors.Is(err, ErrBadInput)는 true입니다. errors.Is가 내부를 들여다볼 수 있기 때문입니다.

중요한 결과: errors.Is는 원인이 실제로 “안쪽에” 있을 때만 작동합니다

오류를 "%v"로 텍스트만 붙여서 추가했다면, 원인 체인이 만들어지지 않으므로 errors.Is가 찾을 수 있는 것이 없습니다.

이것이 바로 "%w"가 단순한 “예쁜 표기”가 아니라 실제로 유용한 도구인 이유입니다. 원인을 프로그램적으로 검사할 수 있게 만들어 주기 때문입니다.

3. errors.As: 원하는 타입의 오류를 꺼냅니다

errors.Is가 “이 원인이 맞나요? yes/no”에 답한다면, errors.As는 다른 질문에 답합니다. “이 오류 안에(또는 체인 안의 어떤 오류 안에든) 특정 타입의 오류가 있나요? 그리고 그 값을 꺼내서 세부 정보를 볼 수 있나요?”라는 질문입니다.

errors.As는 체인 전체를 따라가며 원하는 타입의 오류를 찾는 “스마트 검색”이라고 생각할 수 있습니다. 찾으면 그 오류를 여러분의 변수에 저장하고, 이후에는 일반 객체처럼 다룰 수 있습니다. 필드를 읽고, 결정을 내리고, 사용자에게 이해하기 쉬운 메시지를 만들 수 있습니다. 이제 err.Error()를 분석하고 텍스트로 의미를 추측할 필요가 없습니다. 타입이 있는 오류를 직접 꺼내면 됩니다.

실무에서 왜 필요한가

Typed error는 단순히 분류만 필요한 것이 아니라 세부 정보가 필요할 때 특히 유용합니다.

예를 들어 strconv.Atoi는 *strconv.NumError 타입의 오류를 반환합니다. 이 타입은 필드를 가지고 있으며, 어떤 입력이 파싱되지 않았는지 또는 어떤 연산이 실패했는지 알아야 할 때가 있습니다.

예제를 만들어 봅시다. 숫자를 파싱하고, "%w"로 컨텍스트를 덧붙여 오류를 감싼 뒤, typed error를 꺼냅니다.

package main

import (
	"errors"
	"fmt"
	"strconv"
)

func main() {
	_, err := strconv.Atoi("12x")
	err = fmt.Errorf("parse id: %w", err)

	var numErr *strconv.NumError
	if errors.As(err, &numErr) {
		fmt.Println("bad number:", numErr.Num) // bad number: 12x
	}
}

우리는 오류 문자열을 분석하지 않습니다. “invalid syntax”를 찾지도 않고, 달의 위상으로 추측하지도 않습니다. 원인의 타입을 확인하고, 일반적인 Go 객체처럼 다루면 됩니다.

4. 왜 errors.As는 &target을 받는가

초보자에게 자주 생기는 솔직한 질문이 있습니다. “왜 그냥 errors.As(err, target)라고 쓰면 안 되나요? 왜 앰퍼샌드가 필요하고, 왜 가끔 ‘포인터의 포인터’처럼 보이나요?”

errors.As는 찾은 오류를 변수 target에 기록해야 합니다. 함수가 변수를 변경하려면 그 변수의 주소를 넘겨야 합니다. 이것은 fmt.Scan(&x)와 같은 논리입니다. “결과를 어디에 넣을지”를 전달하는 것입니다.

Scan과의 짧은 비유

여러분은 이미 Scan을 본 적이 있습니다.

package main

import "fmt"

func main() {
	var x int
	fmt.Scan(&x) // &x — 읽은 숫자를 저장할 위치
}

errors.As도 같은 원리로 동작합니다. 차이점은 stdin에서 읽는 것이 아니라 error chain에서 읽는다는 점뿐입니다.

목표 타입에서 *T는 무엇을 의미하나요

예제에서는 흔히 이렇게 씁니다.

var numErr *strconv.NumError
errors.As(err, &numErr)

이것은 차분하게 읽어야 합니다.

  • numErr는 strconv.NumError에 대한 포인터 값을 저장할 수 있는 변수입니다.
  • &numErr는 numErr 변수의 주소이며, errors.As가 찾은 오류를 넣을 수 있도록 해 줍니다.
  • 겉보기에는 “포인터의 포인터”처럼 보이지만, 이것은 정상입니다. “그 타입의 오류” 자체가 흔히 포인터이기 때문입니다.

“&target은 ‘답을 적어 넣을 위치’”라고 생각하면 훨씬 이해하기 쉽습니다.

5. errors.Is vs errors.As: 언제 무엇을 선택할까

errors.As를 과하게 쓰기 쉽습니다. “아, 타입을 꺼낼 수 있네? 그럼 항상 꺼내자.” 하지만 대부분의 경우 특정 오류 타입의 세부 정보가 필요하지는 않습니다. 상황의 의미만 알면 됩니다. 예를 들면 “잘못된 입력”, “찾을 수 없음”, “권한 없음”, “I/O 문제” 같은 것들입니다. 이런 분기에는 errors.Is가 더 적합합니다. 읽기도 더 쉽고, 코드가 특정 오류 내부 구조에 덜 묶입니다.

errors.As는 내부 구조가 정말 필요할 때 사용해야 합니다. 즉, 타입이 있는 오류의 필드를 꺼내 그것을 바탕으로 동작이나 메시지를 만들어야 할 때입니다. 예를 들어 정확히 어떤 값이 파싱되지 않았는지, 어떤 매개변수가 잘못되었는지, 어떤 타입이 기대되었는지 알아야 할 때입니다.

빠른 탐색을 위한 표를 보겠습니다.

질문 도구 얻는 것
“이 오류가 바로 이 특정 원인인가요?”
errors.Is(err, target)
bool
“안에 이 타입의 오류가 있나요? 그리고 그걸 받을 수 있나요?”
errors.As(err, &target)
bool + 채워진 target

아주 짧게 말하면: errors.Is는 “안에 그 원인(마커)이 있나요?”라고 묻고, errors.As는 “안에 원하는 타입의 오류가 있나요? 세부 정보를 꺼낼 수 있나요?”라고 묻습니다.

6. 예제: id를 파싱하고 처리 분기를 나눕니다

짧은 예제를 만들어 봅시다. 교육용 콘솔 애플리케이션이 있다고 가정해 보겠습니다. (tasker라고 부르겠습니다.) 이 앱은 명령과 인자를 받습니다. 아직은 flag 기반의 완전한 CLI를 만들지는 않지만, 다음은 하고 싶습니다.

  • 문자열에서 id를 파싱하기,
  • 컨텍스트를 포함한 오류를 반환하기,
  • main에서 이것이 어떤 오류인지 이해하기: “사용자가 숫자가 아닌 값을 입력했는가”, 아니면 “다른 문제인가”.

parseID 함수: 컨텍스트는 반환하되 원인은 잃지 않습니다

package main

import (
	"fmt"
	"strconv"
)

func parseID(s string) (int, error) {
	id, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("parse id %q: %w", s, err)
	}
	return id, nil
}

여기서 중요한 것은 스타일입니다. 컨텍스트는 짧고 핵심만 담아야 합니다. “ERROR!!!! parse id failed!!!”처럼 쓰지 말고, 수행한 작업을 씁니다: "parse id \"...\"". 사람에게도 충분히 유용하고, 코드에는 원인 err가 체인을 통해 그대로 남아 있습니다.

main에서 *strconv.NumError를 errors.As로 꺼냅니다

package main

import (
	"errors"
	"fmt"
	"strconv"
)

func main() {
	_, err := parseID("12x")
	if err != nil {
		var numErr *strconv.NumError
		if errors.As(err, &numErr) {
			fmt.Println("id must be integer") // id must be integer
			return
		}

		fmt.Println("unexpected error:", err)
	}
}

사용자에게 보여 주는 메시지는 간단합니다. (“id must be integer”) 반면, 세부 정보를 가진 “무서운” 오류는 err에 남아 있습니다. 로그에 남기거나, 디버그에서 출력하거나, verbose 모드에서 보여 줄 수 있습니다.

대안: sentinel을 통해 “오류 클래스”를 errors.Is로 검사하기

때로는 표준 라이브러리의 특정 타입에 의존하고 싶지 않을 때가 있습니다. 우리는 “잘못된 입력”이라는 우리만의 개념을 원합니다. 이런 경우에는 sentinel + errors.Is가 더 유용합니다. errors.Is는 “체인 안에 이런 마커가 있나요?”를 확인합니다.

다른 사람의 코드를 읽는 법: 수동 Unwrap vs errors.Is/errors.As

실제 Go 코드를 읽게 되면(특히 표준 라이브러리에서), 두 가지 방식을 모두 보게 됩니다. 어떤 사람들은 errors.Unwrap을 통해 체인을 직접 따라가기도 하지만, 더 자주 쓰이는 방식은 errors.Is와 errors.As입니다. 더 짧고 실수할 여지가 적기 때문입니다.

동작 방식을 이해하려면, 체인을 “matryoshka”처럼 생각하면 됩니다.

flowchart TD
    E0["err: 'cmd: parse id '12x': ...'"] --> E1["unwrap -> *strconv.NumError"]

errors.Is/errors.As는 거의 같은 일을 하지만, 여러분이 직접 루프를 작성하지 않아도 되게 해 줍니다.

7. errors.Is와 errors.As를 다룰 때의 흔한 실수

실수 1: err.Error()나 strings.Contains로 오류를 검사하기.
이 코드는 처음에는 빠른 것처럼 보이지만, 텍스트, 언어, 의존성 버전이 조금만 바뀌어도 깨집니다. 검사 가능성이 필요하다면, 특정 원인-sentinel에는 errors.Is를, 특정 타입의 오류에는 errors.As를 사용하세요.

실수 2: wrapping 후 err == target으로 비교하고 “왜 안 맞지?”라고 놀라기.
fmt.Errorf("ctx: %w", ErrX) 이후에는 바깥으로 ErrX가 아니라 래퍼가 반환됩니다. 따라서 == 비교는 더 이상 유효한 도구가 아닙니다. 올바른 생각은 이렇습니다. “wrapping이 있다면 원인 검사는 errors.Is로 해야 한다.”

실수 3: errors.As가 변수의 주소(&target)를 받는다는 사실을 잊기.
&target이 아니라 그냥 target을 넘기면, errors.As는 찾은 오류를 기록할 수 없고, 코드는 컴파일되지 않거나 의미상 잘못됩니다. 비유하자면 Scan(&x)와 같습니다. “결과를 어디에 넣을지”를 지정하는 것입니다.

실수 4: errors.Is/errors.As가 "%v"로 오류를 “감싼” 경우에도 동작할 것이라고 기대하기.
fmt.Errorf("ctx: %v", err)는 텍스트만 추가하고, 풀 수 있는 원인 체인은 만들지 않습니다. errors.Is/errors.As 입장에서는 이것이 내부 원인이 없는 단순한 새 오류처럼 보입니다. 원인을 검사 가능하게 유지해야 한다면 "%w"로 감싸세요.

실수 5: errors.Is만으로 충분한데 errors.As를 사용하기.
errors.As는 특정 타입의 세부 정보가 정말 필요할 때 사용합니다. 단지 동작을 분기하려는 것이라면 (“not found” / “no permission” / “invalid input”) typed error는 내부 구조와의 결합을 불필요하게 늘릴 수 있습니다. 이런 경우에는 인식 가능한 원인을 설계하고 errors.Is로 검사하는 편이 더 간단하고 안정적입니다.

1
과제
Go SELF, 레벨 17, 레슨 1
잠금
잔액 확인
잔액 확인
1
과제
Go SELF, 레벨 17, 레슨 1
잠금
카운터 오류
카운터 오류
1
과제
Go SELF, 레벨 17, 레슨 1
잠금
폼 오류
폼 오류
1
과제
Go SELF, 레벨 17, 레슨 1
잠금
프로토콜 계산기
프로토콜 계산기
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION