어떠한 방식으로 Go 코드를 구조화해야 하는가?
최근 회사에서 Go 언어를 활용하여 신규 프로젝트를 진행할 기회가 생겼는데 추후 코드의 유지 관리를 고려해볼 때 Go 언어의 소스 코드 구조를 어떤 식으로 해야 하는가에 대해 고민이 생겼습니다.
구글링 중에 Go 언어의 코드 구조화에 관한 좋은 글을 발견하여 실제 프로젝트에도 반영해볼 겸 해서 번역해보았습니다. 역시나 저와 같은 고민을 앞서 해주신 선구자님들이 계시더라고요😁.
원문의 일부 내용은 번역에서 제외했으니 원문을 참고하실 분들은 해당 링크를 확인해주시기 바랍니다.
목차
표준 패키지 레이아웃
Vendoring. Generics. 이것들은 Go 커뮤니티 내에서 큰 이슈들로 보입니다. 하지만 좀 처럼 언급되지 않는 또 다른 이슈가 하나 있습니다 - 바로 어플리케이션 패키지 레이아웃 입니다.
Vendoring. Generics. These are seen as big issues in the Go community but there’s another issue that’s rarely mentioned — application package layout.
제가 지금까지 작업해왔던 모든 Go 어플리케이션은 “내 코드를 어떤 방식으로 구조화해야하는가?”라는 질문에 대한 각기 다른 대답을 가지고 있는것 처럼 보입니다. 몇몇 어플리케이션은 하나의 패키지에 모든 것을 밀어 넣기도하고 또 다른 어플리케이션은 타입이나 모듈 단위로 그룹화하기도 합니다. 여러분의 팀 전반에 걸쳐 적용할 만한 좋은 전략이 없다면, 여러분의 어플리케이션의 다양한 패키지들에 곳곳에 코드가 흩뿌려지게 되는 것을 보게 될 것입니다. 우리는 Go 어플리케이션 디자인에 적용할 수 있는 더나은 표준이 필요합니다.
Every Go application I’ve ever worked on appears to have a different answer to the question, how should I organize my code? Some applications push everything into one package while others group by type or module. Without a good strategy applied across your team, you’ll find code scattered across various packages of your application. We need a better standard for Go application design.
저는 더 나은 접근 방식을 제안하고자 합니다. 몇가지 단순한 규칙들을 따름으로써 우리는 코드를 분리시키고 테스트를 용이하게 만들며 프로젝트에 일관된 구조를 가지고 올 수 있게됩니다.
I suggest a better approach. By following a few simple rules we can decouple our code, make it easier to test, and bring a consistent structure to our project.
#1. 기본 패키지는 도메인 타입을 위한 패키지다
여러분의 어플리케이션은 데이터와 프로세스들이 어떠한 방식으로 상호작용하는지를 기술하는 논리적이고 고수준(사람이 이해하기 쉽게 작성된 프로그래밍 언어를 의미함)인 언어를 가지고 있습니다. 만약 여러분이 e커머스 어플리케이션을 개발 중이라면 여러분의 도메인은 고객, 계정, 신용카드 결제, 재고 관리와 같은 것들을 포함하고 있을것입니다. 만약 여러분이 페이스북을 개발하고 있다면 여러분의 도메인은 사용자, 좋아요, & relationships(?)이 될것 입니다. 이러한 도메인은 여러분의 근본적인 기술과는 독립적인 개념입니다.
Your application has a logical, high-level language that describes how data and processes interact. This is your domain. If you have an e-commerce application your domain involves things like customers, accounts, charging credit cards, and handling inventory. If you’re Facebook then your domain is users, likes, & relationships. It’s the stuff that doesn’t depend on your underlying technology.
저는 도메인 타입을 프로젝트의 기본(root) 패키지에 위치시킵니다. 기본 패키지는 사용자 정보를 저장하는 User 구조체나 사용자 정보를 가지고 오거나 저장하기 위해서 사용하는 UserService 인터페이스와 같은 단순한 데이터 타입들만을 포함하고 있습니다.
I place my domain types in my root package. This package only contains simple data types like a User struct for holding user data or a UserService interface for fetching or saving user data.
기본 패키지는 아래와 같은 구조를 보일 수 있을겁니다:
It may look something like:
- go
1 | package myapp |
이것은 여러분의 기본 패키지를 매우 단순하게 만들어 줍니다. 또한 여러분은 다른 도메인 타입에 의존하는 경우에만 동작을 수행하도록 하는 타입들을 포함할 수도 있습니다. 예를 들어, 여러분이 UserService를 주기적으로 폴링(polling)하는 특정 타입을 포함시켜야할 수 있습니다. 그러나 외부 서비스를 호출하거나 데이터베이스에 저장해서는 안됩니다. 그것은 세부적인 구현 사항입니다.
This makes your root package extremely simple. You may also include types that perform actions but only if they solely depend on other domain types. For example, you could have a type that polls your UserService periodically. However, it should not call out to external services or save to a database. That is an implementation detail.
기본 패키지는 여러분의 어플리케이션에 존재하는 어떠한 다른 패키지에도 의존적이어선 안됩니다!
The root package should not depend on any other package in your application!
#2. 의존성에 따라 하위패키지를 그룹화하라
만약 기본 패키지가 외부 의존성을 허용하지 않는다면 이러한 의존성들을 하위패키지에 추가해주어야 합니다. 이러한 접근 방식에서는 하위패키지가 도메인과 구현(implementation)사이의 어댑터로서 존재하게 됩니다.
If your root package is not allowed to have external dependencies then we must push those dependencies to subpackages. In this approach to package layout, subpackages exist as an adapter between your domain and your implementation.
예를 들어, 여러분의 UserService가 PostgreSQL을 지원해야할 수 있습니다. 이때 여러분은 postgres.UserService에 대한 세부 구현을 제공하는 postgres 하위패키지를 추가할 수 있습니다:
For example, your UserService might be backed by PostgreSQL. You can introduce a postgres subpackage in your application that provides a postgres.UserService implementation:
- go
1 | package postgres |
이것은 PostgreSQL 의존성을 분리하게되는데 이러한 의존성의 분리는 테스트 과정을 단순화시켜주고 추후 또 다른 데이터베이스로 마이그레이션하기 위한 쉬운 방식을 제공합니다. 여러분이 BoltDB와 같은 또 다른 데이터베이스에 대한 세부 구현을 지원하기로 결정했다면 이것은 플러그형 구조로 사용될 수 있습니다.
This isolates our PostgreSQL dependency which simplifies testing and provides an easy way to migrate to another database in the future. It can be used as a pluggable architecture if you decide to support other database implementations such as BoltDB.
이것은 또한 구현을 계층화하는 방법을 제공합니다. 아마도 여러분은 PostgreSQL 앞에 LRU 캐시를 두어 데이터를 메모상에 보관하기를 원할 것입니다. 이를 위해 PostgreSQL 세부 구현을 래핑(wrapping)할 수 있는 UserService를 구현하는 UserCache를 추가할 수 있습니다.
It also gives you a way to layer implementations. Perhaps you want to hold an in-memory, LRU cache in front of PostgreSQL. You can add a UserCache that implements UserService which can wrap your PostgreSQL implementation:
- go
1 | package myapp |
Golang의 기본 라이브러리들에서도 이러한 접근 방식을 살펴볼 수 있습니다. io.Reader는 바이트를 읽어들이기 위한 도메인 타입이고 그것의 세부 구현들은 의존성에 의해 그룹화되어 있습니다 - tar.Reader, gzip.Reader, multipart.Reader. 이것들 또한 계층화될 수 있습니다. os.File은 bufio.Reader에 의해 래핑되어있고 이 bufio.Reader는 gzip.Reader에 의해 래핑되어있으며 gzip.Reader는 tar.Reader에 의해 래핑되어있는 이러한 모습들은 흔히 보이는 계층 구조들입니다.
We see this approach in the standard library too. The io.Reader is a domain type for reading bytes and its implementations are grouped by dependency — tar.Reader, gzip.Reader, multipart.Reader. These can be layered as well. It’s common to see an os.File wrapped by a bufio.Reader which is wrapped by a gzip.Reader which is wrapped by a tar.Reader.
의존성 간의 의존성
여러분의 의존성은 홀로 고립된 채로 살아갈 수 없습니다. User 데이터는 PostgreSQL에 저장을 하고 재무와 관련된 트랜잭션 데이터는 Stripe와 같은 3rd party 서비스에 저장해야하는 경우가 생길 수 있습니다. 이러한 경우에는 Stripe가 논리적인 도메인 타입과 함께 묶이게 됩니다 - 이것을 TranscationService라고 부르기로 하겠습니다.
Your dependencies don’t live in isolation. You may store User data in PostgreSQL but your financial transaction data exists in a third party service like Stripe. In this case we wrap our Stripe dependency with a logical domain type — let’s call it TransactionService.
UserService에 TransactionService를 추가함으로써 우리는 이 두가지 의존성을 분리할 수 있습니다.
By adding our TransactionService to our UserService we decouple our two dependencies:
- go
1 | type UserService struct { |
이제 우리의 의존성들은 오직 공통된 도메인 언어를 통해서만 소통하게 됩니다. 이것은 우리가 다른 의존성들에 영향을 주지 않고 PostgreSQL을 MySQL로 Stripe를 또 다른 지불 프로세서로 변경할 수 있음을 의미합니다.
Now our dependencies communicate solely through our common domain language. This means that we could swap out PostgreSQL for MySQL or switch Stripe for another payment processor without affecting other dependencies.
이러한 접근 방식을 3rd party 의존성에만 국한 시키지마라
3rd party 의존성에만 국한 시키지말라는 말이 이상하게 들릴 수 있지만 이와 동일한 방식으로 Golang에서 제공하는 기본 라이브러리를 고립시킬 수 있습니다. 예를 들어, net/http 패키지는 단지 또 다른 의존성입니다. 우리가 만든 어플리케이션에 존재하는 http 라는 하위패키지에 이 net/http 패키지를 추가함으로써 고립된 환경을 구성할 수 있습니다.
This may sound odd but I also isolate my standard library dependencies with this same method. For instance, the net/http package is just another dependency. We can isolate it as well by including an http subpackage in our application.
해당 패키지의 이름이 그것이 감싸고 있는 의존성과 동일한 이름을 갖는다는 것이 이상할 수 있지만 이것은 의도된 것입니다. 여러분이 만든 어플리케이션의 다른 어떠한 모듈들에서도 net/http 패키지를 사용하는 곳이 없다면 패키지 이름 충돌이 발생할 일은 없습니다. 이름을 중복시킴으로 얻을 수 있는 이점은 이를 통해 여러분이 HTTP와 관련된 모든 코드들은 새롭게 만든 http 패키지에 포함되도록 한다는 것입니다.
It might seem odd to have a package with the same name as the dependency it wraps, however, this is intentional. There are no package name conflicts in your application unless you allow net/http to be used in other parts of your application. The benefit to duplicating the name is that it requires you to isolate all HTTP code to your http package.
- go
1 | package http |
이제 우리가 만든 http.Handler는 도메인과 HTTP 프로토콜 간 어댑터로서의 역할을 수행하게 됩니다.
Now your http.Handler acts as an adapter between your domain and the HTTP protocol.
#3. 공유된 mock 하위패키지를 사용하라
우리의 의존성들은 도메인 인터페이스를 통해 다른 의존성들과 분리되어 있기떄문에 우리는 mock 구현을 주입하기 위해 이러한 연결 지점들을 사용할 수 있습니다.
Because our dependencies are isolated from other dependencies by our domain interfaces, we can use these connection points to inject mock implementations.
여러분에게 mock를 생성해주는 GoMock과 같은 mocking 라이브러리들이 몇가지 있지만 저는 개인적으로 그것들을 직접 작성하는것을 선호합니다. 대부분의 mocking 도구들은 너무 복잡하게 되어있습니다.
There are several mocking libraries such as GoMock that will generate mocks for you but I personally prefer to just write them myself. I find many of the mocking tools to be overly complicated.
제가 사용하는 mocks는 매우 간단합니다. 예를 들어, UserService를 위한 mock은 아래와 같습니다:
The mocks I use are very simple. For example, a mock for the UserService looks like:
- go
1 | package mock |
이 mock은 인자들의 유효성을 검사하거나 기대된 데이터가 반환되는지를 확인하거나 실패하는 케이스를 주입해보기 위해 myapp.UserService 인터페이스를 사용하기만 한다면 어느 곳이든 함수들을 주입해볼 수 있습니다.
This mock lets me inject functions into anything that uses the myapp.UserService interface to validate arguments, return expected data, or inject failures.
예를 들어 위에서 만들어 본 http.Handler를 테스트하기 원한다고 해봅시다:
Let’s say we want to test our http.Handler that we built above:
- go
1 | package http_test |
mock는 유닛 테스트와 HTTP 프로토콜의 처리부분이 완전히 분리될 수 있도록 해줍니다.
Our mock lets us completely isolate our unit test to only the handling of the HTTP protocol.
#4. 메인 패키지는 의존성들을 하나로 엮는다
이러한 모든 의존성 패키지들이 고립된 채로 떠다니게 된다면 이들을 어떻게 하나로 결합할 수 있을지 궁금할 수 있습니다. 이 역할을 하는 것이 메인 패키지 입니다.
With all these dependency packages floating around in isolation, you may wonder how they all come together. That’s the job of the main package.
메인 패키지 레이아웃
어플리케이션은 2개 이상의 바이너리 파일을 생성할 수도 있는데 이를 위해 메인 패키지를 cmd 패키지의 하위 디렉토리로 두는 Go 언어의 관습(convention)을 사용할 것입니다. 예를 들어, 우리 프로젝트가 myapp 이라는 서버 바이너리뿐만 아니라 터미널을 통해 서버를 관리할 수 있도록 하는 myappctl 이라는 클라이언트 바이너리도 갖고있다고 해봅시다. 우리의 메인 패키지를 아래와 같이 구성하게 될 것입니다:
An application may produce multiple binaries so we’ll use the Go convention of placing our main package as a subdirectory of the cmd package. For example, our project may have a myapp server binary but also a myappctl client binary for managing the server from the terminal. We’ll layout our main packages like this:
1 | myapp/ |
컴파일 타임에 의존성 주입
“의존성 주입”이라는 용어는 나쁜 평판을 받아왔습니다. 이것은 장황한 Spring XML 파일에 대한 생각을 불러일으 킵니다. 하지만 그 용어의 실제 의미는 우리는 객체에게 스스로 빌드하거나 의존성을 직접 찾아가는 것을 요구하는 대신에 객체에게 의존성을 직접 전달해줄 것이다라는 것이 전부입니다.
The term “dependency injection” has gotten a bad rap. It conjures up thoughts of verbose Spring XML files. However, all the term really means is that we’re going to pass dependencies to our objects instead of requiring that the object build or find the dependency itself.
메인 패키지는 어떤 의존성을 어떤 객체에게 주입할 것인지를 선택하는 역할을 합니다. 메인 패키지는 단순히 이러한 조각들을 이어 붙이는 역할만을 수행하기 때문에 코드의 규모가 작고 사소한 경향이 있습니다:
The main package is what gets to choose which dependencies to inject into which objects. Because the main package simply wires up the pieces, it tends to be fairly small and trivial code:
- go
1 | package main |
여러분의 메인 패키지 또한 어댑터의 역할을 한다는 것도 중요한 점입니다. 메인 패키지는 터미널과 여러분의 도메인을 연결해줍니다.
It’s also important to note that your main package is also an adapter. It connects the terminal to your domain.
결론
어플리케이션 디자인은 매우 어려운 문제입니다. 너무나 많은 디자인 결정들이 존재하고 여러분에게 가이드해줄 견고한 이론들의 부재는 문제를 더욱 심각하게 만들 수 있습니다. 우리는 Go 어플리케이션에서 현재 사용되는 몇가지 접근 방식을 살펴보았고 그것들의 많은 결함들을 확인했습니다.
Application design is a hard problem. There are so many design decisions to make and without a set of solid principles to guide you the problem is made even worse. We’ve looked at several current approaches to Go application design and we’ve seen many of their flaws.
저는 의존성의 관점을 통해 디자인에 접근하는 방식이 코드 구성을 더 단순하고 쉽게 추론할 수 있도록 만들어 준다고 생각합니다. 먼저 우리는 도메인 언어를 디자인 합니다. 그리고나서 의존성들을 분리합니다. 다음으로 테스트 환경을 분리하기 위해 mocks를 도입합니다. 마지막으로 메인 패키지 안에서 이 모든 것들을 하나로 엮습니다.
I believe approaching design from the standpoint of dependencies makes code organization simpler and easier to reason about. First we design our domain language. Then we isolate our dependencies. Next we introduce mocks to isolate our tests. Finally, we tie everything together within our main package.
여러분이 디자인할 다음 어플리케이션에 이러한 이론들을 고려해보시기 바랍니다. 질문이 있으시거나 디자인에 대한 토론을 원하신다면 @benbjohnson 트위터를 통해 연락주시거나 Gopher 슬랙에서 benbjohnson을 찾아주세요.
Consider these principles in the next application you design. If you have any questions or want to discuss design, contact me at @benbjohnson on Twitter or find me as benbjohnson on the Gopher slack.
해당 게시글에서 발생한 오탈자나 잘못된 내용에 대한 정정 댓글 격하게 환영합니다😎
Reference