Skip to main content

Golang, 그대들은 어떻게 할 것인가 - 1. 들어가며

About 2 minGoArticle(s)blogd2.naver.comgogolang

Golang, 그대들은 어떻게 할 것인가 - 1. 들어가며 관련

Go > Article(s)

Article(s)

Golang, 그대들은 어떻게 할 것인가 - 1. 들어가며 | NAVER D2
Golang, 그대들은 어떻게 할 것인가 - 1. 들어가며

안녕하세요, 만 2년차 개발자로 클로바노트 서버를 개발하고 있는 김준하입니다. 이 글의 시리즈에서는 클로바노트 V2를 개발하며 팀 내부 코드 구조를 개선한 이야기를 해보려 합니다. 최대한 누구나 이해하기 쉽게 설명하려 노력했지만 코드에 대한 내용이 어느 정도 있다 보니 Golang을 한 번이라도 접해보신 분이면 좀 더 이해하기 쉬울 것 같습니다.

미야자키 하야오 감독의 영화 “그대들은 어떻게 살 것인가”를 아시나요?

호불호가 갈리는 영화지만 저는 즐겁게 관람했습니다. 어떠한 정답을 직접 관객에게 주려고 하지 않고 본인의 삶을 풀어낸 회고록 같은 영화였습니다. “나는 이렇게 살았고, 이런 것을 후회하고 깨달았는데, 그래서 그대들은 어떻게 살 것인가요?” 이렇게 질문을 던지는 듯했습니다.

맞습니다. 영화 제목을 변형해서 이 글의 제목을 지어보았습니다. 제가 현재 팀에서 처음으로 Golang을 접하고 Golang으로 API 서버를 개발하면서 겪었던 고민과 결과물을 여려분께 소개하려 합니다. 제가 영화 제목을 변형했듯이 이 글의 내용은 저의 개발 일대기입니다. 여러분이 “이 사람은 이런 생각을 했고 이렇게 문제를 해결했구나” 하며 읽어주시면 좋겠습니다.


시작하기 전에

글을 시작하기 앞서, 제가 고민했던 부분이 더 잘 이해되도록 Golang의 몇 가지 특징과 컨벤션을 소개하겠습니다.

Don't panic

Golang을 처음 접한 사람들에게 가장 크게 눈에 띄는 부분은 error에 관한 부분입니다. Java 같은 언어는 예외(exception)를 try/catch로 처리하는 방식을 사용하지만 Golang에는 try/catch가 없습니다. try/catch와 비슷한 panic/recover가 있기는 하지만, Golang에는 Don't panicopen in new window이라는 가장 중요한 컨벤션이 있습니다. error를 처리할 때는 panic을 사용하지 말고 error 자체를 반환하라는 것입니다.

func openFile(fn string) error {  
    f, err := os.Open(fn)
    if err != nil {
        return err
        // panic(err) // don't panic!
    }

    defer f.Close()
    return nil
}

func main() {  
    // 잘못된 파일명을 넣음
    if err := openFile("Invalid.txt"); err != nil {
        fmt.Println(err)
    }
}

panic은 프로그램이 종료되어야 마땅한 상황(stackoverflow, OOM 등)에만 사용되어야 하며 recover를 통해 프로그램을 되살릴 수 있습니다.

Errors are value

panic이 사용되어야 하는 상황을 제외하면 error를 값으로 취급하는 접근 방식을 취합니다.

Values can be programmed, and since errors are values, errors can be programmed.

출처: The Go Blogopen in new window

값(value)은 변수나 상수에 할당하거나 조작이 가능하며, error 또한 값이므로 변수나 상수에 할당이 가능합니다.

var ErrDivideByZero = errors.New("divide by zero")

func Divide(a, b int) (int, error) {  
    if b == 0 {
        return 0, ErrDivideByZero
    }
    return a / b, nil
}

func main() {  
    result, err := Divide(10, 0)
    if err != nil {
        log.Error(err) // "divide by zero"
        return
    }
    fmt.Printlnt(result)
}

Import library by repository path

Golang은 라이브러리를 쉽게 배포하고 가져올 수 있습니다. GitHub 저장소 URL 자체가 가져오기 경로가 되고 GitHub에 release 태그만 만들면 됩니다.

다음 명령으로 라이브러리를 추가할 수 있습니다.

go get -u github.com/gin-gonic/gin  

위 명령을 실행하면 Golang 애플리케이션의 의존성을 관리하는 go.mod 파일에 다음과 같이 라이브러리가 추가됩니다.

// go.mod
module ...

go 1.20

require (  
    github.com/gin-gonic/gin v1.9.1
    go.mongodb.org/mongo-driver v1.12.1
  ...
)

이렇게 별도의 특정 공간에 라이브러리를 배포하지 않아도 쉽게 개발하고 사용할 수 있다는 장점이 있습니다. 그래서 저희 팀에서는 Golang에서 공통적으로 사용하는 코드를 별도의 공통 저장소에서 개발하여 사용하고 있습니다. 앞으로 소개할 코드도 대부분 해당 공통 저장소에서 개발한 내용입니다.

라이브러리 부족

Golang의 또 하나의 특징은 직접 개발해야 하는 부분이 많다는 것입니다. Java와 같은 다른 언어에 비해서 오픈 소스나 라이브러리가 많이 부족합니다.

단적인 예로, Golang 표준 라이브러리에 slice(=List)에서 맨 마지막 요소를 빼내는 pop 메서드가 내장되어 있지 않다는 점이 있습니다. 다음과 같이 맨 마지막 요소를 직접 꺼내고 그 앞까지의 slice를 재정의하는 pop을 직접 구현해야 합니다.

pop, s1 := s[len(s)-1], s[:len(s)-1]  
fmt.Println(s1) // [1 2 3 4]  
fmt.Println(pop) // 5  

또한, Set 자료 구조가 없어 이를 map으로 구현해야 합니다. 이렇게 직접 개발해야 하는 부분이 많아, 잘못하면 코드 양이 증가하고 중복 코드가 쉽게 생산될 수 있었습니다.


error가 쏘아올린 작은 공

panic을 던지지 않고 error를 상위 함수로 올리는 것은 보기에는 간단 명료한 컨벤션인 것 같지만, 이는 절대 만만하지 않았습니다. 2023년 Golang 사용자 설문open in new window에 따르면 이 점은 저뿐만 아니라 Golang 개발자들이 공통적으로 가지는 어려움으로 보입니다.

Error handling and learning are respondents' top challenges

함수가 함수를 부르고 error를 반환받고 또 반환하는 구조가 심심치 않게 발생하다 보니, 이 error에 대한 로그는 어디에서 남겨야 하는가에 대한 의문이 들기도 합니다.


클로바노트 V1 코드 개선 필요 사항

그럼 기존 V1 코드에서는 어떻게 처리하고 있었는지 간단한 구조로 살펴보겠습니다.

코드 예
// Controller
func DoAPI() {  
    result, code := service.DoSomething()
    if code == 500 {
        log.error("error in main, message: %s", err.Error())
        // create err response
        return
    } else if code != 0 {
        log.warn("error in main, message: %s", err.Error())
        // create err response
        return
    }
    //...
    // create response
}

// Service
func DoSomething() (Foo, int) {  
    result, code, err := db.DoSomething()
    if code != 0 {
        log.error("error in baz, message: %s", err.Error())
        return result, code
    }
    // ...
    return result, code
}

// DB
func DoSomething() (Foo, int, error) {  
    cursor, err := doQuery()
    if err != nil {
        log.error("error in bar, message: %s", err.Error())
        return nil, 500, err
    }
    // decodeCursor()
    return result, 0, nil
}

// DB common
func doQuery() (*mongo.Cursor, error) {  
    // ... do DB query
    return nil, errors.New("error")
}

Mongo 동작 방식 상이

서버마다 Mongo 드라이버를 각각 래핑하여 사용하고 있었는데, >서버마다 구현 방식이 달랐습니다. 예를 들어 FindOne에서 (result, error) 형식으로 반환할 때, A 서버는 결과 값이 없으면 (nil, nil)을 반환하고 B 서버는 (nil, not found err)를 반환하는 등 다르게 동작했습니다.

error 처리 및 로깅

상위 함수에서 쉽게 오류를 구분할 수 있도록 >error와 함께 error code를 사용했고, 디버깅을 위해 로그 레벨을 조정하며 >모든 지점에서 error 로그를 남겼습니다. 하지만 이는 컨벤션에 맞지 않는 방식이었고 중복 로그가 많아지는 문제가 있었습니다.

또한, error의 로그 레벨이 모호한 경우도 많았습니다. 예를 들어, DB 레이어 함수에서 어떤 document를 찾지 못했을 때 이는 Error 레벨일까요, Info 레벨일까요? 이 상황이 발생하면 안 되는 상황인지 정상적인 상황인지는 상위 레이어에서 결정되므로 해당 함수 내부에서는 알 수가 없었습니다.

이를 해결하기 위해, >error를 어디까지 올려야 하며 하위 레이어에서 발생한 error를 상위 레이어에서 어떻게 알 수 있을지 고민이 필요했습니다.


마치며

고민의 결과물을 클로바노트 V2 코드에 반영하여 위의 문제점들을 해결하려 했고, 그 과정에 대해 이야기를 풀어내보려고 합니다.

  • error를 어떻게 만들어냈는지
  • error를 어떻게 추척 가능하도록 올려 보냈는지
  • error 핸들링을 어떻게 했는지

위 3가지 고민을 중점으로 읽어주시면 좋겠습니다.


이찬희 (MarkiiimarK)
Never Stop Learning.