유연하고 테스트 가능한 Go 코드 작성하기

Marco
당근 테크 블로그
10 min readNov 4, 2020

Go 언어는 개발자에게 코딩의 즐거움을 일깨워주는 언어입니다. Go가 가진 명확한 한계(가비지 컬렉션으로 인한 성능 저하, 제네릭을 지원하지 않음)에도 불구하고 말이죠. 사실 한계점이라고 했지만, 이 또한 Go 언어의 장점을 극대화하기 위한 장치라고 생각합니다.

C 언어를 흔히 저수준 프로그래밍 언어라고 일컫습니다. 개발자가 컴퓨터, 즉 기계를 직접 다룰 수 있기 때문입니다. 그리고 개발자에게 기본적인 문법을 제외하면 할 수 있는 것에 제약을 두지 않죠. 하지만 이런 이유로 인해 개발자에게는 프로그래밍 언어 및 컴퓨터 사이언스에 대한 깊은 이해와 숙련도가 요구됩니다. 그렇지 않다면 개발자가 자유롭게 코드를 작성하게 되어 난해한 스파게티 코드가 만들어지거나, 메모리 누수가 발생하고, 심지어는 심각한 보안 문제가 발생할 수 있기 때문이죠. 따라서 프로그래밍 언어는 점차 개발자의 자유를 빼앗는 방향으로 발전하게 됩니다. 언뜻 부정적으로 보일 수 있는 발전이지만, 이는 개발자의 자유를 빼앗는 대신 그들에게 추상화라는 선물을 주었습니다. 객체 지향, 가비지 컬렉션, 동적 프로그래밍 등이 그 선물의 일부입니다. 저수준의 도구들을 직접 다루는 대신 추상화된 도구를 다룸으로써, 개발자는 보다 쉽게 현실 세계의 난해한 비즈니스적 요구사항을 달성할 수 있습니다. 만약 스타트업에서 웹 서비스를 C 언어로 개발해야 한다고 생각해보세요. 난감하지 않을까요?

Photo by cottonbro from Pexels

Go 언어는 이러한 점에서 특정한 목적을 달성하는데 탁월한 효율성을 보여주는 발전된 언어입니다. 그 목적은 바로 서버 프로그래밍이죠. Go 언어의 탄생 자체가 Google에서 서버 개발을 보다 더 잘하기 위한 것이었습니다¹. Go 언어를 사용하여 서버를 개발해보신 분은 알겠지만, Go는 서버 코드를 작성하는데 대단히 유용한 도구입니다. Go의 장점을 나열하자면, 정적인 타입 시스템, 25개 밖에 없는 예약어, 동시성 프로그래밍을 쉽게 하기위한 내장 도구(채널, 고루틴), gofmt를 사용한 코드 스타일 강제, 서버 프로그래밍을 하기 위한 훌륭한 기본 라이브러리와 여러 써드 파티 라이브러리 등이 있습니다. 여러 프로젝트에서 Go 언어를 사용하면서 이러한 기본 설계와 특성들이 큰 도움이 되고 있습니다.

Go 언어로 서버를 개발하면서 알게 된 한 가지 재미있는 점이 있습니다. Go로 작성하는 코드의 모습이 개발자마다 다르다는 점입니다. Python의 Django나 Ruby의 Ruby on Rails와 같은 서버 프레임워크가 Go 생태계에서는 큰 인기를 얻고 있지 않습니다. 따라서 개발자마다 본인의 취향에 맞는 라이브러리를 사용하는 것이 일반적입니다. 또는 아예 라이브러리를 사용하지 않는 경우도 심심치 않습니다. 당근마켓 플랫폼 부문은 주로 Go 언어를 사용해서 각종 내부 서버들을 개발하고 있습니다. 그런데 대부분의 개발자가 각자 Python, Ruby, Java 등을 사용하다가 현재 팀에 와서 Go를 주력으로 사용하고 있습니다. 앞서 말한 자유롭게 라이브러리를 골라서 사용하는 Go 생태계 특징과 현재 팀 내부의 상황이 만나서 아주 재밌는 일이 발생하고 있습니다. 각자의 스타일대로 조금씩 다른 코드들이 만들어지고 있는 것이죠. 코드 스타일을 남에게 강제하기는 어렵습니다. 이런 상황에서 저는 다른 사람이 보았을 때 이해하기 쉬운, 함께 작업하기 좋은 코드를 작성하기 위해 노력하고 있습니다.

서두가 길었습니다만, 이러한 생각을 바탕으로 코드를 작성하던 중에 흥미로운 패턴을 알게 되었고 이를 적용해 본 결과, 큰 도움이 되었습니다. 이를 공유하기 위해 이 글을 작성하게 되었습니다.

Go에는 객체지향 언어에 흔히 존재하는 인터페이스가 존재합니다. 인터페이스에 정의된 메서드를 구현하고 있다면 그 구조체는 해당 인터페이스를 통해 참조될 수 있습니다.

이는 유용한 인터페이스의 사용 사례입니다. 향후 MemDB나 PostgreSQL이 아닌 MySQL을 사용하기로 결정되었다고 하더라도 MySQL 클라이언트 구조체가 DB 인터페이스에 존재하는 메서드를 구현하기만 한다면 되기 때문입니다. 흔히 말하는 다형성을 이런 방식으로 달성할 수 있습니다. 이는 다른 언어와 크게 다르지 않은 부분입니다. 하지만 Go에서 인터페이스는 단순히 메서드의 집합일 뿐이라는 점이 특이합니다. 특정 구조체가 해당 인터페이스를 구현한다는 점을 명시할 필요가 없습니다. 사실 위 코드에서 MemDB나 PostgreDB 구조체는 DB 인터페이스의 존재 유무와 상관없이 작성할 수 있습니다. 그렇지만 DB 인터페이스에 명시된 메서드를 구현하고 있기 때문에 PostgreDB 구조체는 묵시적으로 DB 인터페이스를 달성합니다. 이런 특징으로 Service 구조체가 자신의 멤버로 해당 구조체들을 사용할 수 있습니다.

여기서 조금 더 나아가 보겠습니다. Go에서는 인터페이스가 어느 패키지에 존재하는지도 제약이 없습니다. 앞서 말했듯이 인터페이스에 존재하는 메서드를 구조체가 갖고 있기만 한다면 묵시적으로 인터페이스를 달성하게 되기 때문입니다. 이를 응용할 수 있는 상황을 예시를 들어 설명하겠습니다. 저는 키워드 알림 서버를 작성하고 있습니다. 이 서버는 새로운 게시글이 생성되는 이벤트를 받습니다. 그리고 게시글의 제목에 매칭되는 키워드를 등록하고 있는 유저를 찾습니다. 이후에 특정 조건을 확인하고 유저에게 알림을 보냅니다. 동료 한명과 동시에 개발을 시작했고, 저는 각종 서비스와 DB 등을 호출하여 매칭되는 유저를 찾는 워커를 먼저 작성하기로 결정했습니다.

게시글이 생성되는 이벤트에 대해서는 정의가 되었고, 대략적인 워커의 동작 또한 결정이 된 상황입니다. 하지만 동료가 지역 정보, 유저, 알림 서비스, DB를 호출하는 클라이언트를 작성하기로 했기 때문에 제가 워커의 로직을 작성하고 테스트하기 위해서는 의존하고 있는 컴포넌트들의 구현이 선행되어야 합니다. 이 때, 인터페이스를 활용하여 제게 필요한 컴포넌트를 스스로 작성할 수 있습니다.

앞서 말했듯이 Go에서 인터페이스는 단순히 메서드의 집합일 뿐입니다. 따라서 실체가 아직 구현이 되어있지 않더라도 사용하는 쪽에서 필요한대로 정의한 후에 사용할 수 있습니다. 이제, 위처럼 필요한 컴포넌트가 준비되어 로직을 작성할 수 있습니다.

다른 서비스 및 DB에 접근하는 실제 컴포넌트 없이 서비스 로직을 완성했습니다. 하지만 언뜻 보기에도 listRegionIdsByPublishRange() 메서드는 테스트가 필요해 보입니다. 하지만 지역 서비스에 대한 클라이언트가 아직 존재하지 않는 상황에서 어떻게 테스트를 시작할 수 있을까요? 일단 테스트 코드를 작성해보겠습니다. 아래 테스트 코드는 Go 생태계에서 권장되는 Table Driven Test 방식으로 작성되었습니다². 이런 구조를 처음 본다면 난해하게 보일수 있지만, 핵심은 간단합니다. 몇 가지 케이스에서 인풋이 주어졌을 때, 예상되는 아웃풋을 리턴하는지 테스트한다는 것입니다.

하지만 RegionSvc 에 해당하는 실체가 존재하지 않기 때문에 위 테스트는 실패할 수 밖에 없습니다. 그렇다면 해결 방법은 무엇일까요? 이 글의 독자라면 쉬운 질문을 한다고 뭐라고 하시겠지만, 네, RegionSvc 인터페이스의 메서드를 가지는 모킹 클라이언트를 사용하여 이를 달성할 수 있습니다. 지역 정보 서비스는 이미 존재하므로 클라이언트가 없더라도 지역 정보 서비스에 대한 입력값과 출력값은 알 수 있습니다. 따라서 다음과 같이 모킹 클라이언트를 작성합니다.

이제 테스트 케이스에 fields{RegionSvc: &mockRegionService{}} 와 같이 넣어준다면, 만세- 성공적인 테스트 결과를 볼 수가 있습니다. 어찌보면 당연한 소리를 한다고 할 수 있습니다. 하지만 여기서 중요한 포인트는 실제 클라이언트에 대한 아무런 의존성 없이 모킹을 사용하여 제가 작성한 로직을 테스트할 수 있다는 점입니다. 만약 Worker 구조체가 인터페이스가 아닌 실제 클라이언트를 멤버로 참조하고 있었다면, 모킹을 현재 패키지에서 사용할 수 없었을 겁니다! 실제 클라이언트 패키지에 모킹을 할 수 있도록 별도의 로직이 추가되어야 할 것입니다. 하지만 Worker 구조체, RegionSvc 인터페이스가 worker 패키지에 존재하기 때문에 모킹 또한 worker 패키지에 존재하는 것으로 충분합니다.

의존하는 컴포넌트의 실체 없이 서비스 로직을 작성하고 테스트까지 할 수 있었습니다. 이는 Go의 인터페이스 시스템이 잘 만들어져 있었기 때문이라고 생각합니다. 하지만 아쉽게도 아직 한가지 문제가 남아있습니다.

Matched 구조체가 worker 패키지에 정의되어 있는데, DB 인터페이스가 [][]Matched를 리턴하도록 되어있습니다. 이렇게 되면 실제 DB 클라이언트가 worker 패키지를 임포트하여 Matched를 참조하게 되겠죠. 이는 의존 관계를 위반하게 됩니다. DB가 Worker에 의해 호출되는 구조인데, 역으로 worker 패키지를 임포트하게 된다면 자칫하면 순환 참조 문제를 발생시킬 수 있습니다. 그렇다고 Matched 또한 인터페이스로 작성하게 된다면 불필요하게 멤버 변수들을 접근하기 위한 메서드를 여럿 작성하게 될 수 있습니다. 그래서 저는 한 프로젝트 내에서 여러 패키지들 사이를 오가는 구조체는 model이라는 별도의 패키지에 두도록 작성하고 있습니다.

이제는 Matched 구조체가 별도의 패키지에 존재하기 때문에, Worker나 DB 클라이언트에서 해당 구조체를 사용하는데 아무런 문제가 없습니다. DB 쪽에서는 Matched를 내부에서 사용하는 데이터 타입으로 상호 변환하는 코드만 추가하면 됩니다. 하지만 이 때, 주의해야 할점은 model 패키지에서는 라이브러리를 제외한 프로젝트 코드를 참조하지 않도록 해야한다는 것입니다. 즉, model 패키지는 의존성 트리에서 가장 아래에 존재하도록 유지해야 순환 참조 문제를 방지할 수 있습니다.

인터페이스와 모델을 사용하여 유연하고 테스트 가능한 Go 코드를 작성하는 방법에 대해서 알아봤습니다. 이는 객체 지향의 SOLID 원칙과 클린 아키텍처³에 대한 지식을 알고 있다면 당연하게 생각해볼 수 있는 방법입니다. 하지만 Go 언어의 독특한 인터페이스 시스템 덕분에, 복잡한 방법론을 사용하지 않더라도 보다 효율적으로 좋은 코드를 작성할 수 있습니다.

제가 선호하는 이 스타일이 모든 상황에서 통한다고 생각하지는 않습니다. 소프트웨어 개발에 있어서 은총알은 없다고 하기 때문이죠. 보편적으로 사용될 수 있는 더 나은 방법이 존재할 수 있습니다. 그렇기 때문에 저희 팀에서는 매주 Gopher 모임을 열고 있습니다. 당근마켓의 Go 유저들이 모여 더 나은 Go 프로젝트를 만들기 위해 열띤 토론을 하고 있습니다. 만약 이 글을 읽고 계신 분이 Go 언어로 서버를 개발하는데 관심이 있으시다면, 저희 플랫폼 부문에서 함께 더 나은 Go 코드를 개발하는 방법에 대해 이야기를 나눌 수 있으면 좋겠습니다⁴.

당근마켓 팀은 따뜻하고 건강한 지역 기반의 정보를 연결하기 위해 노력하고 있습니다. MAU 1000만을 넘어 빠르게 성장하고 있고, 이에 따라 여러 크고 작은 도전 과제들이 생겨나고 싶습니다. 이러한 도전 과제를 함께 해결하고 서비스를 개선해 나갈 팀원을 찾고 있습니다. 함께 하고 싶으시다면 이 곳을 확인해주시길 바랍니다.

참고하면 좋은 글

[1] Using Go at Google
https://go.dev/solutions/google/

[2] TableDrivenTests
https://github.com/golang/go/wiki/TableDrivenTests

[3] 클린 아키텍처 by 엉클 밥
https://medium.com/@younghyun/%ED%81%B4%EB%A6%B0-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98-by-%EC%97%89%ED%81%B4-%EB%B0%A5-a6a917ff6afc

[4] 당근마켓 개발팀 방구석 채용 라이브 영상
https://youtu.be/flvs0frD2E8

--

--