Go Package Architecture - 이론편

Go Package Architecture - 이론편

Go에서 다른 언어와 다르게 Directory(디렉토리)는 굉장히 중요하다. 많은 다른 언어들은 실제 디렉토리의 역할 이상을 하지는 않지만, Go는 Package(패키지)와 밀접한 관계가 있으며 프로그램이 어떻게 작성될지 결정하는 한 부분이다. 그 때문에 Go에서 디렉토리 구조를 어떻게 할지는 패키지를 어떻게 구성할 것인지와 꽤 유관하다. 이번 글에서는 Go를 사용하면서 어떤 형태의 디렉토리와 패키지 구조를 구성하는 것이 좋을지 혼자만의 고민을 정리했다.

일단 패키지 & 디렉토리 아키텍처 얘기를 꺼내기 전에 어떤 점들을 고려하면서 이러한 구조를 생각해 냈는지 정리했다

고민해 볼 Go 특징

Directory와 Package의 관계

Go에서 Package(패키지)는 go.mod 파일이 있는 모듈의 루트를 기준으로 한 디렉토리에 하나의 패키지만 존재할 수 있다. 또한 패키지의 이름은 기본적으로 디렉토리의 이름을 따르게 되어있다. 만약 패키지의 이름이 디렉토리의 이름과 다른 경우 패키지를 호출할 때 Alias를 붙이는 편이다.

1
2
3
4
5
6
7
8
9
10
package main  

import (
playground "changhoi.kim/playground/pkg"
"fmt"
)

func main() {
fmt.Println(playground.Hello())
}

필자는 Goland를 주로 쓰고 있는데, 이 IDE는 일반적인 기준에 따라 별칭을 붙이는 것 같다. 예를 들어 일반적인 v2 패키지 형태의 경우는 별칭을 붙여주지 않는 게 일반적이다.

1
2
3
4
5
6
import (
// fiber는 일반적인 v2 패턴으로 패키지를 제공해 별칭을 붙이지 않는 모습
"github.com/gofiber/fiber/v2"
// ...
)
// ...

별칭이 없어도 사실 잘 동작하지만, Go 생태계에서는 패키지 이름이 디렉토리 이름과 다른 경우 별칭을 사용하는 편이다. 개인적으로 이 생태계의 룰을 지키면서 별칭을 굳이 붙이고 싶지는 않다. 즉, 패키지 이름을 디렉토리 이름과 동일하게 맞추고 싶은 욕망이 기본적으로 있다. Go 블로그 글에서도 Conventionally, 패키지의 경로를 패키지의 이름으로 둔다는 얘기가 나온다.

별칭 얘기를 빼더라도 디렉토리가 항상 단일한 패키지 역할을 하기 때문에 디렉토리 구조가 프로그램의 동작과 유관하다. 그리고 가장 큰 문제로 다른 언어에 비해 꽤 쉽게 순환 참조를 만드는 편이다. 패키지의 어떤 함수만 불러오는 경우가 없고 그냥 통으로 패키지 임포트를 하므로 그렇다.

아마 Go를 사용하면서 모킹을 한 패키지로 모으려고 해본 경험이 있다면 쉽게 순환 참조 문제를 만났을 것 같다. 팀에서도 이러한 문제를 자주 만났었다.

Go Interface

Go의 인터페이스는 Duck Typing(덕 타이핑)으로, 명시적인 구현을 선언할 필요 없이 인터페이스를 구현만 한다면 다형성 조건을 만족하게 된다. 즉, implements 같은 구문이 필요 없다. 이러한 특징으로 인해 굉장히 느슨한 연결이 가능하다. 구현체는 인터페이스를 알 필요도 없기 때문이다.

그래서 개인적으로 패키지 간 연결이 굉장히 매끄럽고 진짜 정확한 의미의 인터페이스를 사용한다고 느껴진다. 예를 들어 A 패키지는 DoSomething이라는 인터페이스를 갖춘 어떤 타입이든 주입하면 사용할 수 있는 어떤 함수를 만들었다고 쳐보자. B 패키지는 이를 구현한 상태라고 했을 때 A 패키지에는 B에 대한 정보가 일절 필요 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// package domain
type Helloer interface {
Hello() string
}

func HelloPrinter(helloer Helloer) {
fmt.Println(helloer.Hello())
}

// package korean
type Korean struct {
// ...
}

func (k Korean) Hello() string {
return "안녕하세요."
}

// package main
func main() {
k := korean.Korean{}
domain.HelloPrinter(k) // OK
}

고민해 볼 Go Convention

Project Layout Convention

공식적인 건 아니지만 이미 꽤 유명한 가장 기본적인 디렉토리 구조가 있다. Go 팀으로부터 오피셜이 아니라고 표기해달라는 요청까지 받은 이 Github Repository가 사실상 표준(de facto)이다. 가장 대표적인 디렉토리는 cmd, internal, pkg 디렉토리인 것 같다.

cmd

프로젝트의 메인 애플리케이션을 다믄 공간이다. 실행할 수 있는, 즉 메인 패키지와 메인 함수가 들어있으며 여러 Entry Point로 나눠질 수도 있다. 즉, cmd/web, cmd/cli 등 여러 main 함수가 디렉토리로 나눠질 수 있다. 보통 여기 작성되는 메인 패키지 코드는 다른 디렉토리의 코드를 가져와서 실행시키는 역할만 하는 작은 코드를 담는다고 한다.

하지만 “작다”라는 말에 너무 신경 쓰지 않는 것이 좋다. 프로그램을 실행시키기 위해 필요한 동작을 하는 곳이기도 하다. 예를 들어 필요한 의존성을 만들어 주입한 다음 사용하는 것도 일반적으로는 여기서 할 일이다. 보통 “재활용이 가능한 영역인가”를 기준으로 이곳에 둘 코드를 정하면 좋을 것 같다. 예를 들어 동일한 의존성 주입 코드가 여러 cmd 아래의 디렉토리에서 사용된다면 이는 cmd에 있을 필요는 없다.

internal

Private 코드를 담는 공간이다. internal 패키지 아래에 담긴 코드는 상위 디렉토리 혹은 동일 Depth의 다른 디렉토리에 있는 코드에서 사용할 수 없도록 Go 언어 수준에서 강제하고 있다. 예를 들어 SDK를 제공하는 입장에서 안전하지 않은 내부 동작을 숨기고 싶은 경우 이 디렉토리 아래에 패키지를 구성할 수 있다. 가장 최상단의 internal이 아니더라도 어디서든 마찬가지이다. internal/a/internal 패키지는 internal/b 패키지에서 사용할 수 없다.

굳이 내부적으로 internal 패키지를 만드는 경우는 못 봤다. 정말 거대한 팀에서 거대한 프로젝트를 모노리스로 만들고 있다면 필요할 수도 있을 것 같다.

보통 서비스 코드를 작성해야 하는 경우는 이 패키지 아래 많은 코드를 담는 경우가 많다. SDK처럼 굳이 내보내야 할 코드가 없기 때문이다. 약간 다른 언어의 src와 유사하게 사용되는 경향이 있는 것 같다.

pkg

internal과 반대로 노출하고자 하는 패키지를 이곳에 담는다. 이 패키지를 사용하려는 외부 개발자들은 pkg 디렉토리에 담긴 함수, 타입, 값 등을 안정감 있게 사용하도록 한다. pkg 디렉토리는 과거에 Go Module이 없던 시절에 익숙할 GOPATHpkg 디렉토리의 영향을 받은 건가 싶다. 개인적으로 보통 SDK를 제공할 때 굳이 임포트 경로에 pkg를 포함하고 싶지 않은 마음이라, SDK를 개발할 땐 그냥 루트 디렉토리를 사용한다.

Package Convention

Reference에 적어둔 것처럼 여러 패키지 컨벤션을 확인했다. 하지만 컨벤션은 보통 디렉토리 구조를 어떻게 해야 한다든지, 아키텍처가 어떻게 되어야 한다든지 이런 얘기를 하지는 않는다. 하지만 아예 없는 건 아니고 디렉토리 패스라든지, 어떤 형태로 만들라는 얘기가 조금 나온다. 공통으로 다음과 같은 것들이 있다.

단 하나의 패키지로 모든 API를 다루려고 하지 마라

패키지 이름을 interface, model과 같이 만들고 모든 인터페이스나 모델들을 하나의 패키지에서 관리하는 것을 지양하라는 소리다. 대신 책임에 따라 패키지를 구성하라고 말한다. 예를 들어 유저를 관리하는 책임을 지는 패키지 이름을 user라고 만들고 그 안에 UserService 같은 인터페이스를 넣을 수 있다. 이를 다른 표현으로 “Organize by responsibility“라고 하기도 한다.

패키지 경로를 표현으로써 사용하라

정확히 패키지 경로를 표현으로 쓰라는 문구를 본 것은 아니지만, Go 블로그 글을 보면 공식 패키지 경로가 다른데 같은 패키지 이름을 갖는 것은 전혀 이상한 게 아니며 오히려 명확한 표현이라는 내용이 나온다. 예를 들어 crypto, image, container, encoding 같은 패키지는 하위 경로에 같은 범위(ex. 이미지를 다루는, 알고리즘을 다루는 범위 등)에서 동작하는 패키지를 다루고 있다. 패키지 자체는 독립적으로 취급되지만, 상위 패키지 경로는 패키지를 불러오는 import 구문에서 패키지 표현의 일부로 활용된다. 이러한 관점에서 runtime/pprofnet/http/pprof 패키지는 같은 이름을 갖지만, 명확히 다른 동작을 할 것을 예상할 수 있다.

이러한 구체적인 Style Decision은 조금 더 원론적인 내용에 해당하는 Style Guide 측면에서 봤을 때 “반복을 피하라“와 같은 내용과도 연관이 된다. 예를 들어 httppprof라고 패키지 이름을 짓지 않는 이유는 패키지 이름이 쓰기 싫게 생긴 것도 있지만 이미 경로에서 표현하고 있는 정보를 반복 표현하는 것을 피한 것이다.

Code Convention

코드는 이번 글 주제에서 더 자세한 범위에 속하지만, 어떻게 코드를 작성할지 머릿속에 그릴 수 있어야 패키지를 나눌 수 있다. 공식 라이브러리의 코드들이 어떤 느낌으로 작성되고 있는지 확인해 보려 한다.

인터페이스는 사용하는 쪽에서 작성한다

필자는 Go뿐만 아니라 모든 언어에서 “인터페이스”라는 용어를 동일한 맥락에서 사용하는 거라면 인터페이스를 사용하는 쪽에서 해당 인터페이스를 정의하는 방법이 맞다고 생각한다. “이런 동작을 하는 녀석을 데려오면 내가 사용해서 내 목적을 달성해 주지” 형태로 사용하는 방향이 다형성 관점에서 더 적합한 방향이다.

Animal 인터페이스

밥을 먹이는 Feeding 동작을 수행하려면 Animal이라는 인터페이스를 구현하고 있어야 한다. 이때 Animal이라는 인터페이스는 Feeding 패키지에 위치해야 자연스럽다는 의미다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package tycoon

type Animal interface {
Eat(string) error
}

func Feed(animal Animal) error {
return animal.Eat("john mat taeng food")
}

// main.go
package main

func main() {
duck := new(Duck)
if err := tycoon.Feed(duck); err != nil {
panic(err)
}
}

인터페이스는 최대한 작게 작성한다

하지만 이런 경우를 생각해 보자. 우리는 Animal이라고 불리는 묵직한 인터페이스를 가지고 있다. 이 인터페이스는 다음과 같이 여러 기능을 수행해야 하며, 여러 패키지에서 이를 사용하고 싶어 한다.

큰 인터페이스

이런 경우는 생각보다 많다. 개발자들은 반복을 싫어하는데, 이런 경우 Animal은 어떻게 되는 걸까? Feeding, Riding, Hunting에 각각 Animal 인터페이스를 만들고 싶지는 않다. 위의 경우는 Animal이라는 인터페이스는 사실 너무 거대한 존재이다. 다음과 같이 쪼개어 인터페이스를 정의하는 것이 더 올바르다.

작은 인터페이스

그래서 Go의 인터페이스 이름 짓는 컨벤션 중에 -er를 붙이고 단일한 수준의 동작만 정의한 아주 작은 인터페이스를 만드는 것이 있다. fmt.Stringer, io.Writer 등을 예로 들 수 있다.

바로 머릿속에 “근데 우리가 SDK 개발만 하는 것도 아니고… 우리는 UserService와 같은 거대한 인터페이스를 어쩔 수 없이 만든다고요…”라는 생각이 들 수도 있다. 이를 위해서 우리는 경로를 패키지 일부로써 활용해야 한다. 의존성이 아예 없는 루트 패키지부터 의존성의 말단에 위치한 패키지까지 계층적으로 패키지를 구성해야 한다.

계층적으로 구성하면 10 Depth가 넘는 패키지 패스가 만들어질 것처럼 느껴지지만 실제로 해보면 그렇지도 않다. 너무 과도하게 깊어지는 것 같다면 적절한 수준에서 패키지를 위로 끌어 올려도 상관없다. 패키지는 어떤 Depth에 있든 독립적인 패키지로서 동작하기 때문이다. 자세한 방법은 예시 패키지를 구성하면서 설명하려고 한다.

고민해 볼 Hexagonal Architecture

Hexagonal Architecture
출처: 헥사고날(Hexagonal) 아키텍처 in 메쉬코리아

필자가 가장 약한 부분이 이런 코드 레벨의 아키텍처 설계라고 생각한다. 여전히 Hexagonal Architecture(육각형 아키텍처)라고 불리는 방법론에서 사용하고 있는 이름들이 헷갈린다. 육각형 아키텍처의 핵심적인 레이어(의 표면)는 Adapter(어댑터)와 Port(포트)라고 생각된다.

어댑터의 바깥쪽은 통신 프로토콜, Socket 등 프로세스의 아예 바깥을 얘기한다. 따라서 어댑터는 프레임워크에서 HTTP 요청을 받아주는 역할이거나 CLI Flag를 받아오거나, 이벤트 메시지를 생산하거나 소비하는 클라이언트 등이 있을 수 있다. 어댑터를 기준으로 육각형 안쪽부터는 프로세스, 즉 우리가 핸들링하는 코드에 해당한다. 어댑터들의 도움으로 받아온 데이터를 애플리케이션의 순수한 비즈니스 코드를 수행할 수 있도록 포트를 통해 밀어 넣어야 한다.

이 아키텍처를 보면 “우리가 어떤 부분을 인터페이스화 해야 하는구나”를 느낄 수 있다. Service, Repository와 같이 도메인 코드를 동작시키는 인터페이스를 만들면 된다.

마무리

패키지를 구성하기 위한 배경지식을 모두 정리했다. 말은 쉽지, 이제 코드를 보여주자.

Reference

Author

changhoi

Posted on

2023-07-13

Updated on

2023-07-13

Licensed under

댓글

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×