시작하며

Go는 예외(exception) 대신 에러를 값으로 반환하는 방식을 채택한다. 이 글에서는 기본 에러 반환부터 사용자 정의 에러, error wrapping, 그리고 프로그램 흐름을 강제 중단하는 panic과 복구를 위한 recover까지 Go의 에러 핸들링 전반을 다룬다.

에러 핸들링

기본적인 에러 반환 처리

go bufio를 활용한 기본 에러 반환 예시이다.

func ReadFile(filename string) (string, error) {
	file, err := os.Open(filename) // 파일 열고
	if err != nil {
		return "", err // 에러시 반환
	}
	defer file.Close() // 닫는거 잊지말고 실행
 
	rd := bufio.NewReader(file) // bufio.Reader 객페로 file 읽기
	line, _ := rd.ReadString('\n') // 한줄 읽기, \n로 끝나지 않는 경우에만 error 반환
	return line, nil
}
 
func WriteFile(filename string, line string) error {
	file, err := os.Create(filename) // file handle과 error를 반환
	if err != nil {
		return err
	}
	defer file.Close()
 
	_, err = fmt.Fprintln(file, line) // 쓴 길이, 에러를 반환
	return err
}
 
const filename string = "data1.tx"
 
func main() {
	line, err := ReadFile(filename)
	if err != nil {
		err = WriteFile(filename, "file writing ongoing")
		if err != nil{
			fmt.Println("파일 생성에 실패했습니다.", err)
			return
		}
		line, err = ReadFile(filename)
		if err != nil {
			fmt.Println("파일 읽기에 실패했습니다.", err)
			return			
		}
	}
	fmt.Println("파일내용:", line)
}

사용자 정의 에러 반환 처리 (fmt.Errorf(), errors.New())

  import (
    "fmt"
    "math"
    "errors"
  )
 
  // 1. fmt.Errorf()를 사용한 사용자 정의 에러처리
  func Sqrt1(f float64) (float64, error) {
    if f < 0 {
      return 0, fmt.Errorf(
        "(사용자정의_Errorf())제곱근은 양수여야 합니다. f:%g", f)
    }
    return math.Sqrt(f), nil
  }
 
  // 2. errors.New()를 사용한 사용자 정의 에러처리
  func Sqrt2(f float64) (float64, error) {
    if f < 0 {
      errTxt := "(사용자정의_New())제곱근은 양수여야 합니다. "
      errTxt = errTxt + fmt.Sprintf("f:%g", f)
      return 0, errors.New(errTxt)
    }
    return math.Sqrt(f), nil
  }
 
  func main() {
    sqrt, err := Sqrt2(-2)
    if err != nil {
      fmt.Printf("Error: %v\n", err)
      return
    }	
    fmt.Printf("Sqrt(-2) = %v\n", sqrt)
  }

error 타입

error 타입은 실제로는 interface이며 문자열을 반환하는 Error() 메소드로 이루어져 있다. 결국 어떤 타입이든 Error() 메소드를 포함하고 있다면 에러로 사용할 수 있다.

// 에러 구조체 선언
type PasswordError struct { 
  Len int
  RequireLen int	
}
 
// Error() 메서드 선언 
// PasswordError 구조체 메서드로 Error()함수를 선언했으므로 해당 구조체는 error인터페이스로 쓰일수 있다.
func (err PasswordError) Error() string { 
  return "LenghError 암호 길이가 짧습니다."
}
 
func RegisterAccount(name, password string) error {
  if len(password) < 8 {
    return PasswordError{ len(password), 8 } // error 반환
  }
  return nil
}
 
func main() {
  err := RegisterAccount("myID", "myPw")
  if err != nil {
    if errInfo, ok := err.(PasswordError); ok { // 인터페이스 변환
      fmt.Printf("%v Len:%d RequireLen:%d\n", errInfo, errInfo.Len, errInfo.RequireLen)
    }
  } else {
    fmt.Println("회원 가입됐습니다.")
  }
}

error wrapping

에러를 감싸 새로운 에러를 만들 수도 있다. 도출하는 error의 깊이를 다르게 하면 에러의 내용을 감싸고 그 밖에 에러의 위치 정보를 나타낼 수 있다.

import (
  "fmt"
  "errors"
  "bufio"
  "strings"
  "strconv"
)
 
func MultipleFromString(str string) (int, error) {
  // NewScanner는 io.Reader를 인수로 받으므로, string타입을 io.Reader로 만들기 위해 
  // strings.NewReader()를 사용한다.
  scanner := bufio.NewScanner(strings.NewReader(str)) 
  scanner.Split(bufio.ScanWords) // bufio.ScanLines를 쓰면 한 단어씩 끊어읽어온다.
 
  pos := 0
  a, n, err := readNextInt(scanner)
  if err != nil {
    return 0, fmt.Errorf("failed to readNextInt(), pos:%d err:%w", pos, err)
  }
 
  pos += n + 1
  b, n, err := readNextInt(scanner)
  if err != nil {
    return 0, fmt.Errorf("failed to readNextInt(), pos:%d err:%w", pos, err)
  }
  return a * b, nil
}
 
func readNextInt(scanner *bufio.Scanner) (int, int, error) {
  if !scanner.Scan() {
     return 0, 0, fmt.Errorf("failed to scan")
  }
  word := scanner.Text()
  number, err := strconv.Atoi(word) // 문자를 int로 변환
  if err != nil {
    return 0, 0, fmt.Errorf("Failed to convert word to int, word:%s err:%w", word, err)
  }
  return number, len(word), nil
}
 
func readEq(eq string) {
  rst, err := MultipleFromString(eq)
  if err == nil {
    fmt.Println(rst)
  } else {
    fmt.Println(err)
    var numError *strconv.NumError
    // error 안에 감싸진 에러 중 두번째 인수의 타입인 *strconv.NumError로 변환될 수 있는 에러가 있다
    if errors.As(err, &numError) { 
      fmt.Println("NumberError:", numError)
    }
  }
}
 
func main() {
  readEq("123 3")
  readEq("123 abc")
}

panic

패닉(Panic)은 프로그램을 정상 진행시키기 어려운 상황에서 프로그램의 흐름을 중지시키는 기능이다. panic() 내장 함수를 호출하면 프로그램을 즉시 종료하고 에러 메세지와 함수 호출 순서를 나타내는 콜 스택(call stack)을 표시한다. panic이 발생한 마지막 함수 위치부터 역순으로 호출 순서를 표시한다.

func divide(a, b int) {
  if b == 0 {
    panic("b는 0일 수 없습니다") // b가 0인 경우 panic() 함수를 호출해 프로그램을 강제 종료한다.
  }
  fmt.Printf("%d / %d = %d\n", a, b, a/b )
}
 
func main() {
  divide(9, 3)
  divide(9, 0)
}
 
// 출력 내용
// 9 / 3 = 3
// panic: b는 0일 수 없습니다
 
// goroutine 1 [running]:
// main.divide(0x9?, 0x3?)
//     /Users/lsy/Desktop/Go-Study/error/error5/error5.go:7 +0x105
// main.main()
//     /Users/lsy/Desktop/Go-Study/error/error5/error5.go:14 +0x31

panic interface

panic()은 내장 함수의 인수로 interface{} 타입을 받으므로 모든 타입을 사용할 수 있다.

func panic(interface{})

panic 전파와 복구(recover)

panic은 호출 순서를 역순으로 전파된다. main() -> a() -> b() -> c() 순으로 가는 과정에서 c()에 panic이 발생하면 거꾸로 c() -> b() -> a() -> main() 순으로 전달되며, 마지막까지 복구되지 않으면 프로그램은 강제 종료된다.

panic()이 진행 중인 단계에서 recover()가 호출되면 panic 객체를 반환하고, 그렇지 않으면 nil을 반환한다. panic이 복구되면 프로그램은 정상 종료된다.

recover() 결과 처리

recover()의 시그니처는 다음과 같으며, 반환된 타입을 실제로 사용하려면 타입 검사가 필요하다.

func recover() interface{}
if r, ok := recover().(net.Error); ok {
  fmt.Println("r is net.Error Type")
}

정리하며

Go의 에러 핸들링은 에러를 값으로 취급한다는 철학에 기반한다. fmt.Errorf()errors.New()로 사용자 정의 에러를 만들고, %w 포맷 동사로 에러를 wrapping해 맥락 정보를 추가할 수 있다. 프로그램 진행이 불가능한 치명적 상황에서는 panic()을 사용하고, recover()로 이를 복구하는 패턴을 활용한다.