오픈소스 프로젝트 Wire-Jacket 소개
1. Introduction
블록체인, 코인 주제의 프로젝트로 Ossicones를 개발하는 중이었습니다.
스마트 컨트랙트 등 직접 구현을 해보는 게 익숙해지는데 좋을 것 같다는
생각으로 결정했고, 토이프로젝트이다보니 이런저런 기술들을 적용해보고
자주 교체해보는 실험적인 개발을 하고 싶었습니다.
이를 위해 Dependency Injection을 적용하는 게 적합하다 생각하여
정리하면서 포스팅도 했었죠.
처음엔 단순 흥미로 Compile-Time DI를 수행하는 Google의 Wire를
사용하기로 결정하였고, 직접 사용해 개발하다보니 Wire의 장단점이
확실히 보였습니다.
(Wire의 예제와 DI의 기본적인 부분은 위 포스팅에서 확인할 수 있습니다.)
Wire는 코드를 생성하는 방법을 통해 Compile-Time에 DI를 수행함으로써
Run-Time에 겪을수 있는 문제들을 예방하기에 좋습니다.
하지만 라이브러리 수준이기 때문에 Automation이나 Singletone 처리 등을
사용자가 직접 부담해야하는 도구입니다.
Cloud App 개발 경험과 Twelve Factors를 토대로 환경변수 기반의 설정으로
주입할 Dependency를 간단히 설정할 수 있게 하면 Wire의 장점과 더불어
Cloud 환경에서 편리하게 사용할 수 있는 IoC Container를 만들 수 있을 것이라는
생각이 들었고, 개발을 시작했습니다.
2. Wire
Wire는 Compile-Time DI인 것이 기존의 다른 DI 라이브러리와 큰 차이입니다.
'wire' 명령을 통해 생성된 injector 코드를 이용해 개발을 하게 되는데 gRPC와
유사한 방식이라고 생각하면 됩니다.
(go에서 gRPC는 protobuf IDL 파일을 작성하고 'protoc' 명령으로 생성된
go 파일을 이용하여 개발하게 됩니다.)
이 방법으로 얻게 되는 것은 컴파일 레벨에서 DI가 잘 설정되어 수행될 수 있는지
사전에 파악할 수 있다는 점입니다. 일반적으로 DI는 무엇이든 될 수 있는
Interface에 동적으로 Dependency를 Injection하는 형태이기 때문에
무언가 잘못되는 경우, Run-Time에서 문제가 발생합니다.
특히 시작하고서 한창 동작중에 발생한 문제는 잡아내기가 어렵고,
Cloud 같이 배포하기 어려운 환경에서는 이를 디버깅하기 위해 많은
'작업의 반복'이 발생합니다.
Wire는 DI 처리 부담을 Compile-Time으로 옮겨 안정적인 초기화와 올바른
상호 참조를 가능하게 하여 이러한 문제를 사전에 대부분 검증할 수 있도록 해줍니다.
Wire runs as a code generator, which means that the injector works without making calls to a runtime library.
This enables easier introspection of initialization and correct cross-references for tooling like guru.
이러한 특징은 특히나 런타임에서 발생하는 문제를 디버깅하기 힘든 Cloud 환경에서
유용하다고 생각이 들었고, 최종적으로 Ossicones는 Cloud 환경에서 동작하는 것을
목표로 했기 때문에 이러한 내용과 적합하였습니다.
하지만 Wire는 Injector와 Provider 간의 Binding 개념만 제공하며 대부분
IoC Container가 제공하는 기능들이 존재하지 않아 사용자가 부담해야 합니다.
3. Wire-Jacket
Wire의 장점을 이용하고 단점을 커버하여 Cloud 환경에 최적화된 DI 라이브러리를
만들 수 있겠다 생각이 들었고, Wire 기반의 IoC Container를 개발하였습니다.
Wire의 가벼운 특성을 유지하면서 사용자가 injector들을 직접 호출하지 않아도
필요한 순간에 Singleton으로 객체를 사용할 수 있도록 reflection을 이용한 경량의
Automation 기능을 추가하였습니다.
또한, 사용자가 기존에 사용하던 Dependency를 대체하고 싶은 경우, 코드의 수정이나
컴파일 없이 설정 파일을 변경하고 재기동함으로써 간단하게 대체할 수 있습니다.
(해당 설정에 대응하는 환경변수가 있는 경우, 오버라이드되어 환경변수의 설정을 사용)
이를 통해 다양한 버전의 백업과 변경, 배포 과정을 간략화합니다.
이름은 Wire-Jacket입니다. 낯선 이름일 수도 있을 텐데요.
위 그림과 같은 케이블(Cable)이나 전선(Wire)의 개념에서 외피를 Jacket이라고 하더군요.
Jacket은 이러한 선들을 그룹화하고, 기계적, 습기, 화학적 이슈들로부터 코어를 보호하는 외피입니다.
Wire 라이브러리로 작성된 Injector들을
묶어 DI를 자동화하는 IoC Container가
컨셉이었으므로.
이를 실제 Wire(전선)에 비유하여 전선을
감싸 한 번에 꽂을 수 있는
Wire-Jacket이라 이름 지었습니다.
Wire-Jacket의 큰 특징은 다음과 같으며 하나씩 살펴보겠습니다.
- Cloud 환경을 위한 환경변수 기반 Config
- google/wire 기반 IoC Container
- Lazy Loading, Eager Loading
3.1 Cloud 환경을 위한 환경변수 기반 Config
App의 Dependency 교체를 위해
Wire-Jacket에 Config가
Twelve Factors에는 '설정은 환경변수에 저장'한다는 내용이 있습니다.
Container 수준에서 앱을 설정하기에 환경변수가 가장 적합하기 때문입니다.
Wire-Jacket은 Cloud 환경을 목적으로 하는 라이브러리이기 때문에 개발하면서
이 부분을 고려했습니다.
환경변수는 표현할 수 있는 형식이 제한적이고 테스트를 수행하기에도 불편하며
json, yaml, toml 등의 형식에 비해 Application 친화적이지 않아 더 효과적인
방법을 고민하였고 최종적으로는 Viper를 사용하였습니다.
Viper 내용은 위의 포스팅에서 확인할 수 있습니다.
Viper를 wrapping 하여 ViperJacket이라는 설정 툴을 만들었습니다.
사용자는 User Config를 위해 추가적으로 구현할 필요 없이 이를 사용할 수 있습니다.
처음엔 ViperConfig라고 지었으나 ViperJacket이라고 굳이 이름을 변경한
이유는 기존의 Viper와는 다르게 동작하는 몇 가지가 있는데 이를 ViperConfig라는
이름 때문에 직관적으로 오해할 소지가 있어 이름을 구분하였습니다.(말장난이죠)
ViperJacket은 기본적으로 Viper와 동일합니다.
- json, yaml, toml, flag 등의 다양한 형식 지원
- 환경변수 위주의 설정값 오버라이딩
- default value 처리
다른 점은 이와 같습니다.
- Default 설정 파일(app.conf)과 Flag 방식으로 설정 파일 지정
- Interface를 이용한 GetOrDefault 방식의 Read 단일화
- String, String Array, String Map String 형식의 설정 값에 포함되는
환경변수 Expand 처리
EX)
home=${HOME}/Document/workspace
templates=$OSSICONES_HOME/templates
certificates=[$OSSICONES_HOME/cert/test.com, $OSSICONES_HOME/cert/ossicones.com]
환경 변수를 Expand 하여 처리하는 경우 Cloud 환경 등에서 path 설정이
훨씬 편리해집니다.
Viper는 이러한 기능이 없어 ViperJacket에서 구현하였습니다.
하지만 이 기능은 편리해지는 대신 위험요소도 가져옵니다.
만약 실수로 존재하지 않는 환경변수가 포함된 경우,
Expand시 기본적으로 empty string("")가 사용되며 이러한 경우에
사용자가 전혀 의도하지 않은 값이 설정될 수 있습니다.
예)
templates=$OSSICONES_HOME/templates
$OSSICONES_HOME이 존재하지 않는 경우,
GetString("OSSICONES_HOME", "/home/app/ossicones") 호출 시,
/templates로 읽게 됨
이러한 의도치 않은 설정을 방지하기 위해 설정이 존재하지만 존재하지 않는 환경변수가
포함되는 경우에는 default value를 사용하도록 하였습니다.
default value에도 환경변수가 포함될 수 있으며 만약 여기에도 존재하지 않는 환경변수가
포함된 경우에는 의도한 것이라 보고 기본과 같이 empty string("")을 사용하도록
설계하였습니다.
따라서, default value에는 확정적으로 존재하는 환경변수를 사용하거나 환경변수를
사용하지 않는 것이 자연스럽겠죠.
3.2 google/wire 기반 IoC Container
ViperJacket에 대한 내용을 먼저 설명한 이유는 Wire-Jacket에서 사용할
Dependency를 지정하기 위해 Config 기능을 사용하기 때문입니다.
작성해놓은 여러 구현(Implement)들을 코드의 변경과 컴파일 없이 재사용할
수 있도록 하고 싶었습니다.
예를 들어 mysql로 연동하던 서버가 mongodb와 연동하게 한다던지
DB 연동에 긴급한 문제가 발생해 DB를 사용하지 않는 긴급 모드로 동작하게 한다던지
하는 상황을 위해서 말이죠.
Wire-Jacket은 이러한 경우에 config, 혹은 환경변수의 문자열 값만 변경하고
App을 재기동하면 되도록 설계하였습니다.
이 기능은 버전의 업데이트나 롤백을 간단하게 하며, Cloud 환경에서 배포하기에
유리한 상태가 됩니다.
예를 들어 Kubernetes(k8s)에서 레플리카셋에 의해 3개의 파드로 동작중인 App의
DB 모듈을 변경하기 위해서는 ConfigMap의 값을 하나 변경하고, 동작중인
파드를 모두 죽이면 k8s가 이들을 다시 살려 변경된 모듈을 사용하게 합니다.
이러한 방식은 크고 불필요한 여러 버전의 Container Image들을 저장할 필요 없게 하고,
모듈의 변경을 위해 코드 수정 -> Build 및 검증 -> Image Build -> 배포의 과정에서
발생할 수 있는 실수를 차단하며 이를 반복할 필요가 없습니다.
CI/CD가 되어 있는 경우라도 시간과 단계를 크게 단축할 수 있습니다.
(물론 완전 새로운 모듈을 배포하는 게 목적이라면 적합한 CI/CD 과정을 통해 검증하는
것이 좋습니다.)
이러한 방식은 불필요한 모듈을 바이너리가 포함하게 되어 크기가 커질 수는 있지만
성능에 영향을 미치지는 않습니다.
Wire를 통해 생성된 코드는 객체를 생성하기 위한 Injector들을 포함합니다.
Wire-Jacket은 이러한 Injector들을 저장하여 객체가 필요한 순간에만 호출합니다.
이미 존재하는 객체의 경우에는 그대로 꺼냅니다.
3.3 Lazy Loading, Eager Loading
Injector 실행 방식에 대해 더 자세히 설명합니다.
Wire에서 Injector의 형식은 일반적으로 아래와 같이 간단한 형식을 사용합니다.
매개 변수 : 주입될 dependency
함수 구현 : Binding 방식
반환 값 : 생성할 object
개인적으로는 코드의 단순화와 가독성을 위해 위처럼 단순한 방식으로 통일하는 것을
선호하지만 아래와 같이 advanced한 binding 방식을 지원하기도 합니다.
https://github.com/google/wire/blob/main/docs/guide.md#advanced-features
주의점)
Wire는 Injector는 매개 변수, 반환 값의 타입에 따라 DI를 자동화하여 코드를 생성합니다.
즉, 같은 매개변수에 동일한 타입이 두 개 이상 있는 경우, 사용자 오류라고 생각하기 때문에 굳이 넣으려면
따로 동일 타입을 선언하여 사용하여야 합니다.
Wire-Jacket에서 Dependency가 주입되어 Binding 된 Interface, Implement를
모듈(Module)이라고 합니다.
위의 InjectBolt, InjectOssiconesBlockchain은 아래의 B의 방식으로 구현된 예이며
기본적으로 Lazy loading을 채택하여 해당 모듈이 필요해지는 순간에만 Injector가
실행되어 객체를 생성합니다.
사용자의 의도에 따라 A의 경우처럼 객체를 미리 생성하여 Binding 하는
Eager loading 방식도 수행할 수 있습니다.
(적절하게 설정된 경우, Wire-Jacket이 알아서 Loading을 수행하기
때문에 App의 핵심 모듈만 제외하고 굳이 Eager로 사용할 필요는 없습니다.)
A 모듈에 대한 GetModule을 실행하거나 A 모듈을 Eager로 설정한 경우,
Wire-Jacket은 A를 Injection 하기 위해 필요한 Dependency를 검사합니다.
해당 Dependency가 생성되어 있는 경우, 그 객체를 사용하며
생성되어 있지 않은 경우, 해당 Dependency를 생성하기 위한
Injector를 찾아 실행합니다.(그 Injector에 필요한 Dependency까지 알아서
수행합니다.)
결과적으로 A 모듈을 사용하려 하면 위의 그림처럼 A 모듈에 필요한 B 모듈,
B 모듈에 필요한 C 모듈까지 Wire-Jacket에 의해 생성되며 필요한 경우에
생성된 모듈을 재사용하게 됩니다.
4. Example
Wire-Jacket의 go-doc에도 실행할 수 있는 예제가 작성되어 있지만
서론에 언급하였듯이 Wire-Jacket을 이용해 블록체인 프로젝트인 Ossicones를
시간 날 때마다 구현하고 있습니다.
토이프로젝트라 코드가 자주 바뀌어 test는 안될 수 있으나 cmd/ossicones.go 빌드하여
실행하는 데는 항상 문제가 없도록 하고 있으니 use-case나 코드 템플릿을 참고할 수 있습니다.
5. Conclusion
DI 라이브러리로써 개발되었으나 config 기반의 IoC 설정 기능은 이미 프레임워크의
영역을 조금은 침범하고 있습니다. 해당 기능을 사용하지 않고, DI만 처리할 수도
있으나 그러한 경우 Wire-Jacket을 채택할 메리트가 없습니다.
Wire-Jacket의 목적과 특징을 극대화하기 위한 기능이기 때문에 차후 프레임워크로
몸집을 키우거나, Uber의 dig처럼 Wire-Jacket을 더 큰 프레임워크에 포함되는 일부로서
사용하는 것도 고려하고 있습니다.
오픈소스이니 궁금한 경우 구현을 직접 확인할 수 있습니다.
'Programming > Go' 카테고리의 다른 글
Go/Golang Module 정리(작성 중) (0) | 2021.12.21 |
---|---|
Go/Golang for sets: map[T]struct{} vs. map[T]bool (0) | 2021.11.29 |
Go/Golang Release & pkg.go.dev package update (0) | 2021.10.06 |
Go/Golang HTTP 성능 튜닝 (0) | 2021.09.24 |
Go/Golang Dependency Injection (12) | 2021.09.10 |