당신의 Go 코드의 nil 체크가 실패하는 이유(feat. interface)

2024. 5. 13. 23:47컴퓨터 공학

반응형

* 몇 개월 전, 회사에서 문제 해결을 위해서 리팩터링이 있었고, 그 과정에서 에러가 발생했었는데, 그 경험을 공유하고자 이 글을 작성합니다.

 

 

저에게 하루 약 120만 건 이상의 요청이 들어오는 서버에 관한 태스크가 주어졌습니다.

 

서버에 새로운 파이프라인을 추가했고, 이에 대해서 개발환경에서 모든 테스트를 마치고, 실제 운영환경에 배포했습니다.

 

 

무사 배포를 기원했지만, 배포하자마자 서버는 죽었다 살아나기를 반복했고, 이 때문에 바로 서버를 롤백했습니다.

 

 

저는 매우 의아했습니다. 에러가 발생한 지점의 코드를 저는 고친 것이 없었고, 해당 지점에서 원래 에러가 발생하고 있지 않았기 때문이죠.

 

그렇게 한 시간 동안 원인을 찾기 위해 로그를 열심히 찾았고, 결국 문제의 원인을 찾아냅니다.

 

 

 

 

문제 발생의 원인


로그를 보던 중, 이런 로그를 보게 됩니다.

 

{0xc00000c030, 0x0}

 

중괄호로 된 것은 struct 같은데, 

왜 하나는 주소값이 있고, 하나는 주소값이 없는 것인가...

 

마침 에러가 발생한 곳은, 모두 인터페이스 nil check를 거친 곳이어서 더 수상했습니다.

 

그러던 중, 특이한 사실을 알아냈습니다.

 

바로 인터페이스의 nil이 진짜 nil이 아닐 수도 있다는 사실을요.

 

package main

import (
	"fmt"
)

type A struct {
	ID string
}

func makeInt() interface{} {
	var a *A
	return a
}

func main() {
	var a = makeInt()

	if a == nil {
		fmt.Println("a is nil")
	} else {
		fmt.Println("a is not nil")
	}
}

 

 

여기서 어떤 것이 출력될까요?

 

당연하게 저는 `a is nil`이 출력될 것으로 예상했지만

결과는 `a is not nil`이 출력되었습니다.

 

 

왜 그럴까요?

 

이건 위의 로그에서도 알 수 있는데요,

 

interface는 내부적으로 type과 value를 가지고 있습니다.

 

그리고 value만 nil이라면 nil check에 실패하게 되는 것이죠.

 

 

 

에러가 난 코드를 간단하게 재현해 보자면,

 

package main

import (
	"fmt"
)

type A struct {
	ID string
}

func makeInt() interface{} {
	var a *A
	return a
}

func main() {
	var a = makeInt()
	var ok bool
	var b *A

	if b, ok = a.(*A); ok {
		fmt.Println("ok")
		return
	}

	fmt.Println(b.ID)
}

 

 

이렇게 type 변환을 시도했고, nil이라면 실패했을 type 변환에 성공한 것입니다.

 

 

 

이렇게 interface가 nillable 하게 되면, 이런 문제를 야기할 수 있습니다.

그렇기 때문에 반드시 inteface는 nil이 되어서는 안 됩니다.

 

 

 

라이브러리 선택과 제거의 중요성


저는 서버를 수정하는 과정에서 chebyrash/promise라는 라이브러리를 사용하고 있는 코드를 보고,

 

일반 go 루틴을 사용하는 것과 차이가 없다고 느껴 이를 제거했습니다.

 

 

GitHub - chebyrash/promise: Promise / Future library for Go

Promise / Future library for Go. Contribute to chebyrash/promise development by creating an account on GitHub.

github.com

 

그리고 이것이 문제를 드러나게 했습니다.

 

promise의 내부 코드를 살펴보면 다음과 같은 코드가 있습니다.

 

func (p *Promise[T]) handlePanic() {
	err := recover()
	if err == nil {
		return
	}

	switch v := err.(type) {
	case error:
		p.reject(v)
	default:
		p.reject(fmt.Errorf("%+v", v))
	}
}

 

 

promise 실행 중, 에러가 나면 recover()를 통해서 panic을 잡아주는 로직이 존재했던 것입니다.

더구나 아무런 로그를 남기지 않으니 알 도리가 없었죠.

 

 

이를 통해 문제가 발생한 이유를 알아냈습니다.

 

- 원래 코드에 문제가 있어서 panic을 발생하고 있었으며,

- promise의 recover 때문에 에러가 발생하지 않고 있었고,

- 로그를 남기지 않았기 때문에 문제가 발생하는 줄도 몰랐던 것입니다.

 

 

라이브러리를 제거할 때, 내부에 있는 구현부까지 확인했어야 했는데,

제 불찰로 인해 문제가 발생했습니다.

 

 

 

교훈


이 문제를 통해서 얻은 교훈은 다음과 같습니다.

 

- 언어의 특성을 잘 이해하고 코드를 작성해야한다.

- nillable한 인터페이스를 만들지 말자.

- 로그를 잘 찍자.

 

 

반응형