Language/Golang

Golang (Go언어) 에러 핸들링

HeeWorld 2024. 12. 5. 19:24

Tucker의 Go 언어 프로그래밍 책과 유튜브를 통해 학습 중입니다.

Golang 마스코트 Gopher(고퍼)

 

에러 핸들링(Error handling)

- 프로그램의 에러를 처리하는 방법을 의미

- 에러는 프로그래머의 실수로 발생하지만, 때로는 외부적 요인(메모리/디스크 부족, 네트워크 단절 등)에 의해 발생되기도 함.

- 에러가 발생하면 경우에 따라 프로그램을 종료하거나 적절한 방식으로 처리하여 프로그램을 계속 실행시킬 수 있음.

 

 

에러 반환

- 에러를 처리하는 가장 기본 방식은 에러를 반환하고 알맞게 처리하는 방식

ex) ReadFile() 함수로 파일을 읽을 때 해당하는 파일이 없어 에러가 발생

package main

import (
	"bufio"
	"fmt"
	"os"
)

func ReadFile(filename string) (string, error) {
	file, err := os.Open(filename)		// 파일 열기
	if err != nil {
		return "", err		// 에러 나면 에러 반환
	}
	defer file.Close()		// 함수 종료 직전 파일 닫기
	rd := bufio.NewReader(file)		// 파일 내용 읽기
	line, _ := rd.ReadString('\n')
	return line, nil
}

func WriteFile(filename string, line string) error {
	file, err := os.Create(filename)	// 파일 생성
	if err != nil {		// 에러 나면 에러 반환
		return err
	}
	defer file.Close()
	_, err = fmt.Fprintln(file, line)	// 파일에 문자열 쓰기
	return err
}

const filename string = "data.txt"

func main() {
	line, err := ReadFile(filename)		// 파일 읽기 시도
	if err != nil {
		err = WriteFile(filename, "This is WriteFile")	// 파일 생성
		if err != nil {		// 에러를 처리
			fmt.Println("파일 생성에 실패했습니다.", err)
			return
		}
		line, err = ReadFile(filename)	// 다시 읽기 시도
		if err != nil {
			fmt.Println("파일 읽기에 실패했습니다.", err)
			return
		}
	}
	fmt.Println("파일내용:", line)	// 파일 내용 출력
}


// 결과

파일내용: This is WriteFile	 // open할 파일이 없었음

 

✓ ReadFile()은 filename에 해당하는 파일을 읽어서 반환하는 함수로, 파일 읽기에 실패하면 에러를 두 번째 반환값으로 반환함.

 

- os.Open() 함수로 파일을 열고, err이 nil이면 성공, 아니면 에러가 발생한 것(파일 열기에 실패), file.Close()함수는 파일 핸들을 닫음.

   = 파일 열기에 실패하면 호출자에게 에러가 발생함을 알려줌.

 

- bufio.NewReader() 함수로 bufio.Reader 객체를 만듦(해당 객체는 구분자까지 문자열을 읽어오는 ReadString() 메서드가 있음)

   = 해당 메서드를 통해 '\n'이 나올 때까지 파일에서 문자열을 읽음(개행으로 한 줄을 읽음)

- bufio.ReadString()은 첫 번째 반환값으로 읽은 문자열을, 두 번째 반환값으로 error를 반환

   = error는 문자열이 delim 문자로 끝나지 않을 경우에만 에러를 반환, _을 사용하여 에러 무시처리

 

- WriteFile() 함수는 filename에 해당하는 파일을 생성하고, 생성한 파일에 line 문자열을 씀.

- os.Create() 함수는 파일을 생성하고, 첫 번째 반환 값으로 파일 핸들 두 번째 반환 값으로 에러를 반환.

   = 파일 생성에 실패하면, 에러를 반환하여 호출자에서 처리를 요청함.

 

- fmt.Fprintln() 함수를 사용해 파일 핸들에 문자열과 줄바꿈 문자 "\n"을 씀.

- 첫 번째 인수로 Write() 메서드를 가진 io.Writer 인터페이스를 인수로 받음.

  = Write() 메서드를 가진 모든 객체는 Fprintln() 함수 인수로 쓸 수 있음.

     파일 핸들인 *File 타입도 Write() 메서드를 가지고 있기 때문에 인수로 사용할 수 없음.

- 두 번째 인수부터는 쓸 내용이 들어가고, fmt.Fprintln() 함수는 첫 번째 반환값으로 쓴 길이를 반환하고 두 번째로 에러 발생 시 에러를 반환.

   = 쓴 글이는 필요 없어 _로 무시하고, 두 번째 에러만 반환

 

- main() 함수에서 ReadFile()로 파일 읽기를 시도하고 에러가 발생하면 WriteFile() 함수를 호출하여 파일을 생성함.

- 파일 생성 시에도 에러가 발생하면 에러 메시지를 출력하고 프로그램을 종료

- 파일 생성에 성공하면, ReadFile() 함수로 파일 읽기를 시도하고, 읽은 파일 내용을 출력하며 프로그램을 종료함.

파일이 없을 때 결과값
파일 내용 수정 후 다시 출력한 결과

 

 

사용자 에러 반환

- OS 패키지의 Open() 함수와 Create() 함수에서 발생한 에러 처리 방법 예제

fmt.Errorf(formatter string, ...interface{}) error

 

 

→ 에러를 만들어주는 함수로, 반환 타입이 error임.

- formatter string과 인자를 사용하여 해당 포멧으로 에러 메시지를 만듦.

errors.New(text string) error

 

→ errors 패키지의 New() 함수를 이용해서 error를 생성할 수 있음.

- 인수로 문자열을 입력하면 인수와 같은 메시지를 갖는 error를 생성하여 반환.

 

package main

import (
	"fmt"
	"math"
)

func Sqrt(f float64) (float64, error) {
	if f < 0 {
		return 0, fmt.Errorf(
			"제곱근은 양수여야 합니다. f:%g", f)	// f가 음수이면 에러 반환
	}
	return math.Sqrt(f), nil
}

func main() {
	sqrt, err := Sqrt(-1)
	if err != nil {
		fmt.Printf("Error: %v\n", err)	// 에러 출력
		return
	}
	fmt.Printf("Sqrt(-2) = %v\n", sqrt)
}


// 결과

Error: 제곱근은 양수여야 합니다. f:-1

 

- Sqrt() 함수는 인수의 제곱근을 반환하고, 인수 f가 음수이면 에러를 반환

 

 

에러 타입

- error는 interface로 문자열을 반환하는 Error() 메서드로 구성되어 있음.

- 즉, 어떤 타입이든 문자열을 반환하든 Error() 메서드를 포함하고 있다면 에러로 사용할 수 있음.

type error interface {
   Error() string
}

 

package main

import "fmt"

type PasswordError struct {		// 에러 구조체 선언
	Len        int
	RequireLen int
}

func (err PasswordError) Error() string {	// Error() 메서드
	return "암호 길이가 짧습니다."
}

func RegisterAccount(name, password string) error {
	if len(password) < 8 {
		return PasswordError{len(password), 8}	// error 반환
	}

	return nil
}

func main() {
	err := RegisterAccount("myID", "myPw")	// ID, PW 입력
	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("회원 가입됐습니다.")
	}
}


// 결과

암호 길이가 짧습니다. Len:4 RequireLen:8

 

- 암호 길이가 짧을 때 에러 정보를 담을 PasswordError 구조체를 선언하고, 이는 실제 입력한 길이와 필요한 길이를 필드로 가짐.

- PasswordError 구조체 메서드로 Error() 함수를 선언했기 때문에 PasswordError 구조체는 error 인터페이스로 사용될 수 있음.

 

- RegisterAccount() 함수는 암호 길이가 짧을 때 PasswordError 구조체 정보를 반환

  = RegisterAccount() 함수의 반환 타입은 error 타입이지만, PasswordError는 error 인터페이스로 쓸 수 있음. (구조체 반환 가능)

 

- RegisterAccount() 함수로 ID와 PW를 입력하고, 반환값이 nil이 아니면 에러가 발생됨.

- 에러를 PasswordError 타입으로 타입 변환하여 에러 메시지뿐만 아니라 Len과 RequireLen 필드에 접근 가능.

 

 

에러 랩핑

- 때로는 에러를 감싸서 새로운 에러를 만들어야 할 수도 있음.

package main

import (
	"bufio"
	"errors"
	"fmt"
	"strconv"
	"strings"
)

func MultipleFromString(str string) (int, error) {
	scanner := bufio.NewScanner(strings.NewReader(str)) // 스캐너 생성
	scanner.Split(bufio.ScanWords)                      // 한 단어씩 끊어 읽기

	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)	// 문자열을 숫자로 변환
	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
		if errors.As(err, &numError) {		// 감싸진 에러가 NumError인지 확인
			fmt.Println("NumberError:", numError)
		}
	}
}

func main() {
	readEq("123 3")
	readEq("123 abc")
}


// 결과

369
Failed to readNextInt(), pos:4 err:Failed to convert word to int, word:abc err:strconv.Atoi: parsing "abc": invalid syntax
NumberError: strconv.Atoi: parsing "abc": invalid syntax

 

- MultipleFromString() 함수는 문자열에서 두 단어를 읽어 각 숫자로 변환한 뒤 곱한 결과를 반환하는데, 만약 과정에서 에러가 발생하면 에러를 반환.

- 한 단어씩 읽는 bufio 패키지의 Scanner를 만들고, NewScanner() 함수는 io.Reader 인터페이스를 인수로 받기 때문에

   string 타입을 io.Reader로 만들어 주려고 string.NewReader()함수를 사용.

   = ★ 문자열을 한 줄씩 또는 한 단어씩 끊어 읽고자 할 때 주로 사용하는 구문

 

- Scanner 객체의 Split() 메서드를 호출해 어떻게 끊어 읽을지 알려줌.

- bufio.ScanWords를 사용하면 한 단어씩 끊어 읽게 되고 bufio.ScanLines를 사용하면 한 줄씩 끊어 읽게 됨.

 

- Scan() 메서드를 통해 첫 번째 단어를 읽어오는데, 만약 "123"을 읽으면 문자열이기 때문에 int 타입인 숫자로 변경해야 함.

- ★ strconv 패키지의 Atoi() 함수는 문자열을 int 타입으로 변경. (Itoa() 함수는 반대로 숫자를 문자열로 바꿈) ★

- Atoi() 함수는 숫자로 변경 시 숫자가 아닌 문자가 섞여있는 경우 NumberError 타입의 에러를 반환함.

   = 에러가 발생한 경우 fmt.Errorf() 함수의 %w 서식문자를 통해 에러를 감싸줌.

fmt.Errorf("Error: %w", err)

 

→ 위와 같이 %w를 사용하면, err를 감싸서 새로운 에러를 반환하게 됨.

- 다른 정보와 strconv.Atoi() 함수에서 발생한 에러까지 에러 하나로 반환할 수 있게 됨.

 

- readNewInt() 함수에서 발생한 에러를 다시 감싸 pos 정보를 에러에 추가 함.

   = 출력 결과에 네 번째 글자를 읽을 때 에러가 발생했음을 알 수 있음.

 

- 감싸진 에러를 다시 꺼낼 때는 errors 패키지의 As() 함수를 이용함.

- errors.As()는 err 안에 감싸진 에러 중 두 번째 인수의 타입인 *strconv.NumError로 변환할 수 있는 에러가 있으면 변환하여 값을 넣고,

   true를 반환함

- 코드에서 감싼 에러가 strconv.Atoi() 함수에서 발생한 에러이기 때문에 이 에러는 *strconv.NumError 타입이라, errors.As() 함수는 true를 반환하고,

   numError에 *strconv.NumError 타입 값을 넣어 줌.

= 감싸진 에러를 검사하고 각 에러 타입별로 다른 처리를 할 수 있음.

 

 

패닉(Panic)

- 프로그램을 정상 진행 시키기 어려운 상황을 만났을 때 프로그램 흐름을 중지시키는 기능

- Go언어는 내장 함수 panic()으로 패닉 기능을 제공

- Panic() 내장 함수를 호출하고 인수로 에러 메시지를 입력하면, 프로그램을 즉시 종료하고 에러 메시지를 출력하고,

   함수 호출 순서를 나타내는 콜 스택(Call Stack)을 표시 함. (이 정보를 사용해 에러가 발생한 경로를 파악할 수 있음)

package main

import "fmt"

func divide(a, b int) {
	if b == 0 {
		panic("b는 0일 수 없습니다.")	// Panic 발생
	}
	fmt.Printf("%d / %d = %d\n", a, b, a/b)
}

func main() {
	divide(9, 3)
	divide(9, 0)	// Painc 발생
}


// 결과

9 / 3 = 3
panic: b는 0일 수 없습니다.

goroutine 1 [running]:
main.divide(0x9?, 0x3?)
	/tmp/sandbox1432610449/prog.go:7 +0xee
main.main()
	/tmp/sandbox1432610449/prog.go:14 +0x29

 

- divide() 함수의 제수를 나타내는 b가 0이면, panic() 함수를 호출해 프로그램을 강제 종료함.

- panic() 함수의 인수로 에러가 발생한 원인을 적을 수 있음.

 

- 콜 스택이란 panic이 발생한 마지막 함수 위치부터 역순으로 호출 순서를 표시함.

 

 

패닉 생성

- 패닉은 내장 함수 panic()을 사용해서 발생시킬 수 있음.

- panic() 내장 함수의 인수로 interface{} 타입 = 모든 타입을 사용할 수 있음.

- 일반적으로 string 타입 메시지나 fmt.Errorf() 함수를 이용해서 만들어진 에러 타입을 주로 사용함.

// Panic 함수 선언
func panic(interface{})


// Panic 함수 예시 구문
panic(42)
panic("unreachable")
panic(fmt.Errorf("This is error num:%d", num)
panic(SomeType{SomeData})

 

 

패닉 전파 그리고 복구

- 프로그램을 개발할 때는 문제를 빠르게 파악해서 해결 하는 것이 중요!

 

* 패닉(Panic)의 전파

- 패닉의 전파는 콜스택 역순으로 전파가 됨.

 

ex) 함수 호출 과정에서 main() → f() → g() → h() 중 h() 함수에서 패닉이 발생하면 호출 순서를 거꾸로 올라가며 함수로 전달

      main() 함수에서까지 복구되지 않으면 프로그램이 그제서야 강제 종료 됨.

전파 단계

 

* 패닉(Panic)의 복구(recover)

- 어느 단계에서든 패닉은 복구된 시점부터 프로그램이 계속됨.

- recover() 함수를 호출해 패닉 복구를 할 수 있음.

- recover() 함수가 호출되는 시점에 패닉이 전파중이면 panic 객체를 반환하고, 그렇지 않으면 nil을 반환.

- recover 함수는 defer 함수와 함께 사용 함!!!

func recover() interface{}

 

package main

import "fmt"

func f() {
	fmt.Println("f() 함수 시작")
	defer func() {		// 패닉 복구
		if r := recover(); r != nil {
			fmt.Println("panic 복구 -", r)
		}
	}()

	g()		// g() → h() 순서로 호출
	fmt.Println("f() 함수 끝")
}

func g() {
	fmt.Printf("9 / 3 = %d\n", h(9, 3))
	fmt.Printf("9 / 0 = %d\n", h(9, 0))		// h() 함수 호출 - 패닉
}

func h(a, b int) int {
	if b == 0 {
		panic("제수는 0일 수 없습니다.")		// 패닉 발생 !!
	}
	return a / b
}

func main() {
	f()
	fmt.Println("프로그램이 계속 실행됨")		// 프로그램 실행 지속됨
}


// 결과

f() 함수 시작
9 / 3 = 3
panic 복구 - 제수는 0일 수 없습니다.
프로그램이 계속 실행됨

 

- main() 함수에서 f() 함수를 호출하고 다시 g() →h() 순서로 함수가 호출 됨.

- 두 번째 h() 함수 호출 시 두 번째 인수값이 0이라서 패닉이 발생

- h() 함수에 패닉이 발생하여 호출 순서를 거슬러 전파 됨. = g() → f() → main()으로 전파

 

- 패닉이 f() 까지 전파 됐으나 defer를 사용해 함수 종료 전 함수 리터럴이 실행됨.

- 함수 리터널 내부에서 recover()를 사용해 패닉 복구를 시도하고, 전파중인 패닉이 있으므로 복구가 되고 panic 메시지 출력함.

 

- 패닉이 f() 함수에서 복구됐기 때문에 프로그램이 강제 종료되지 않고 계속 실행됨.

   = 그래서 "프로그램이 계속 실행됨" 메시지가 출력됨.

 

recover()는 제한적으로 사용하는 것이 좋음.

패닉이 발생되면 그 즉시 호출 순서를 역순으로 전파하기 때문에 복구가 되더라도 프로그램이 불안정할 수 있음.

문제가 발생되면 개발자가 빨리 인지를 하고 수정하는 것이 서비스에 더 낫고, recover()되면 문제가 있다는 것을 인지할 수 없음. ※

 

 

Go는 SEH를 지원하지 않음

- SEH(Structured Error Handling) 구조화된 에러 처리를 지원하지 않음.

- 성능 문제와  ※에러를 먹어버리는 문제(오히려 에러 처리를 등한시 함)※ 로 지원하지 않음.