본문 바로가기
Language/Go

Error handling을 간단하게

by ocwokocw 2022. 6. 16.

- 출처: https://dave.cheney.net/2019/01/27/eliminate-error-handling-by-eliminating-errors

- 개요

Go2는 오류를 처리할 때 오버헤드를 줄이는 방향으로 목표하고 있다. 오류를 처리할 때 향상된 문법보다 더 중요한것이 있는데, 그건 바로 오류를 처리할 필요가 없도록 하는것이다. 이 말의 의미는 "error를 다루는 코드를 없애라"는 의미가 아니라 "다루어야 할 오류가 많아 지지 않도록 코드를 변경하라"는 의미이다.
 
원문의 저자는 해당 글을 John Ousterhout's의 A philosophy of Software Design에서 영감을 받았다고 한다.

- Example 1

아래 코드는 파일의 라인 수를 세는 코드이다.
 
func CountLines(r io.Reader) (int, error) {
        var (
                br    = bufio.NewReader(r)
                lines int
                err   error
        )

        for {
                _, err = br.ReadString('\n')
                lines++
                if err != nil {
                        break
                }
        }

        if err != io.EOF {
                return 0, err
        }
        return lines, nil
 }
 
위의 코드는 bufio.Reader를 선언하고 loop에서 ReadString 메소드를 호출하여 파일의 끝에 도달할때까지 행의 수를 세는 카운터를 증가시키고 있다. 그리고 행의 수를 반환한다. 요구사항을 만족시킨 코드이긴 하지만, error 처리를 하는 부분은 좀 복잡하다고 할 수 있다.
 
_, err = br.ReadString('\n')
lines++
if err != nil {
    break
}
 
특히 위의 부분에서 오류를 확인하기전에 행의 수를 증가시킨 부분이 보일것이다. 이렇게 작성한 이유는 ReadString이 라인 개행 문자를 만나기전에 파일의 끝인 io.EOF 상태에 도달하면 error를 반환하기 때문이다. 
 
이 문제를 해결하기 위해서 행의 수를 증가시킨다음 loop를 빠져나갈것인지 확인을 하고 있는것이다.
 
하지만 아직 error 확인을 끝내지 않았다. ReadString은 파일의 끝에 도달했을 때 io.EOF를 반환한다고 했다. ReadString 메소드가 더 읽을게 없을 때 멈추도록 해야한다. 그래서 CountLine의 호출자에게 error를 반환하기 전에 해당 error가 io.EOF가 아니면 상위로 전파시키고, 그렇지 않으면(io.EOF이면) 정상적인 동작이므로 nil을 반환한다. 이런 이유 때문에 아래처럼 단순하게 반환하지 못하는것이다.
 
return lines, err
 
원문의 필자는 이 예제가 Russ Cox가 언급했던 오류 처리가 함수 동작의 명료함을 흐리는 좋은 예제라고 말하고 있다. 향상된 버전의 코드를 살펴보자.
 
func CountLines(r io.Reader) (int, error) {
        sc := bufio.NewScanner(r)
        lines := 0

        for sc.Scan() {
                lines++
        }

        return lines, sc.Err()
}
 
위의 코드에서는 bufio.Reader 대신 bufio.Scanner를 사용했다. bufio.Scanner는 내부적으로 bufio.Reader를 사용하는데, 추상화계층이 이전 버전의 CountLines 함수에서 존재하던 오류 처리들을 하지 않아도 되게 도와주었다.
 
sc.Scan() 메소드는 텍스트의 개행문자가 매칭될때까지 error가 발생하지 않으면 true를 반환한다. 그래서 for loop 내부는 scanner의 버퍼에서 텍스트의 라인이 있을 때만 호출될 수 있다. 수정된 CountLines는 새로운 행이 없는 경우와 파일이 비어있는 경우를 정상적으로 처리하게 된다.
 
둘째로 sc.Scan은 error를 마주하자 마자 false를 반환하는데, 파일의 끝에 도달하거나 error를 마주할 때 for loop를 탈출하게 된다. bufio.Scanner 형은 처음 마주했던 error를 기억해두었다가 sc.Err() 메소드를 사용해서 for loop을 빠져나올 때 발생한 error를 복구한다.
 
마지막으로 bufio.Scanner는 파일의 마지막에 도달할때까지 다른 error를 마주하지 않으면  io.EOF를 nil로 변환한다.
 

- Example 2

원문의 필자는 2번째 예제를 작성할 때 Rob Pike의 Errors are values 라는 블로그 글에서 영감을 받았다고 한다.
 
파일을 열고, 쓰고, 닫을 때 error를 다루기는 하지만 ioutil.ReadFile과 ioutil.WriteFile과 같은 helper들로 캡슐화 되기 때문에 저수준을 다루지는 않는다. 하지만 저수준의 network protocol을 다룰 때에는 종종 직접 I/O를 사용하여 응답을 구성해야 하는데, 이런 경우 오류처리가 반복될 수 있다. HTTP/1.1 응답을 생성하는 HTTP 서버 예제를 살펴보자.
 
type Header struct {
        Key, Value string
}

type Status struct {
        Code   int
        Reason string
}

func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
        _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)
        if err != nil {
                return err
        }
        
        for _, h := range headers {
                _, err := fmt.Fprintf(w, "%s: %s\r\n", h.Key, h.Value)
                if err != nil {
                        return err
                }
        }

        if _, err := fmt.Fprint(w, "\r\n"); err != nil {
                return err
        } 

        _, err = io.Copy(w, body) 
        return err
}
 
처음에 fmt.Fprintf 를 사용해서 상태를 표현하는 행을 만들고 error 여부를 확인했다. 그리고 각 header의 key와 value를 쓸때마다 매번 error를 확인했다. 후반부에는 \r\n을 추가하여 header 영역을 종료할 때 error를 확인하고 응답 body를 client 에게 복사했다. 마지막엔 io.Copy에서 발생한 error는 확인할 필요는 없지만, io.Copy 에서 반환한 2 가지 값을 WriteResponse가 기대하는 하나의 값으로 변환했다.
 
반복 작업이 많았을 뿐더러 기본적으로 io.Writer에 byte를 쓰는 작업은 error를 다루는데있어서 다양한 형태를 갖고 있다. 하지만 약간의 wrapper type을 사용하면 손쉽게 오류처리를 할 수 있다. 
 
type errWriter struct {
        io.Writer
        err error
}

func (e *errWriter) Write(buf []byte) (int, error) {
        if e.err != nil {
                return 0, e.err
        }

        var n int
        n, e.err = e.Writer.Write(buf)
        return n, nil
}
 
위의 코드에서 errWriter는 io.Writer 형을 embedding하여 io.Writer를 한번 감싸주고 있다. errWriter는 내부적으로 갖고 있는 writer에게 error가 감지될때까지 쓰는 작업을 전달한다. error가 감지되면 쓰는 작업을 버리고 이전 error를 반환한다.
 
func WriteResponse(w io.Writer, st Status, headers []Header, body io.Reader) error {
        ew := &errWriter{Writer: w} 
        fmt.Fprintf(ew, "HTTP/1.1 %d %s\r\n", st.Code, st.Reason)

        for _, h := range headers {
                fmt.Fprintf(ew, "%s: %s\r\n", h.Key, h.Value)
        }

        fmt.Fprint(ew, "\r\n")
        io.Copy(ew, body)

        return ew.err
}
 
errWriter를 WriteResponse에 적용하면 코드가 확연하게 간결해진다. 각 행위마다 더이상 error를 확인하기 위한 문법들이 필요하지 않게 되는것이다. 함수 마지막 부분에서 io.Copy의 반환값을 변환하는 번거로운 작업을 하지 않고, ew.err field를 검사하기만 하면 error를 검출하는 작업을 할 수 있게 된다.
 
만약 과도한 오류 처리에 직면해있다면 행위의 일부를 helper type으로 추출하는것을 시도해보길 바란다.
 
 
 
 

'Language > Go' 카테고리의 다른 글

Empty struct  (0) 2022.06.21
Zero value  (0) 2022.06.17
Exception  (0) 2022.06.14
Error handling  (0) 2022.06.12
Effective Go - Errors  (0) 2022.06.05

댓글