Go 의존성 주입 설명: 제로에서 히로까지
Emily Parker
Product Engineer · Leapcell

Golang의 의존성 주입(DI) 살펴보기
요약
이 기사는 Golang의 의존성 주입(DI)과 관련된 내용에 중점을 둡니다. 초보자를 위한 이해 접근 방식을 제공하기 위해 일반적인 객체 지향 언어 Java를 사용하여 DI 개념을 소개합니다. 기사에서 다루는 지식 포인트는 비교적 분산되어 있으며, 객체 지향 프로그래밍의 SOLID 원칙과 다양한 언어의 일반적인 DI 프레임워크 등을 포함합니다.
I. 소개
프로그래밍 분야에서 의존성 주입은 중요한 디자인 패턴입니다. Golang에서 의존성 주입의 적용을 이해하는 것은 코드 품질, 테스트 용이성 및 유지 관리성을 향상시키는 데 매우 중요합니다. Golang에서 DI를 더 잘 설명하기 위해 먼저 일반적인 객체 지향 언어인 Java부터 시작하여 DI 개념을 소개합니다.
II. DI 개념 분석
(I) DI의 전체적인 의미
의존성은 지원을 얻기 위해 무언가에 의존하는 것을 의미합니다. 예를 들어 사람들은 휴대폰에 크게 의존합니다. 프로그래밍 맥락에서 클래스 A가 클래스 B의 특정 기능을 사용하면 클래스 A는 클래스 B에 대한 의존성이 있음을 의미합니다. Java에서 다른 클래스의 메서드를 사용하기 전에 일반적으로 해당 클래스의 객체를 생성해야 합니다(즉, 클래스 A는 클래스 B의 인스턴스를 생성해야 함). 객체 생성 작업을 다른 클래스에 넘기고 의존성을 직접 사용하는 프로세스가 "의존성 주입"입니다.
(II) 의존성 주입의 정의
의존성 주입(DI)은 디자인 패턴이며 Spring 프레임워크의 핵심 개념 중 하나입니다. 주요 기능은 Java 클래스 간의 의존성 관계를 제거하고 느슨한 결합을 달성하며 개발 및 테스트를 용이하게 하는 것입니다. DI를 깊이 이해하려면 먼저 DI가 해결하고자 하는 문제를 이해해야 합니다.
III. Java 코드 예제로 일반적인 문제와 DI 프로세스 설명
(I) 강한 결합 문제
Java에서 클래스를 사용하는 경우 기존 방식은 해당 클래스의 인스턴스를 생성하는 것입니다. 다음 코드와 같습니다.
class Player{ Weapon weapon; Player(){ // Sword 클래스와 강하게 결합됨 this.weapon = new Sword(); } public void attack() { weapon.attack(); } }
이 방법은 결합이 너무 강하다는 문제가 있습니다. 예를 들어 플레이어의 무기가 칼(Sword)로 고정되어 있어 총(Gun)으로 교체하기 어렵습니다. Sword를 Gun으로 변경하려면 관련된 모든 코드를 수정해야 합니다. 코드 규모가 작을 때는 큰 문제가 되지 않을 수 있지만 코드 규모가 클 때는 많은 시간과 에너지가 소모됩니다.
(II) 의존성 주입(DI) 프로세스
의존성 주입은 클래스 간의 의존성 관계를 제거하는 디자인 패턴입니다. 예를 들어 클래스 A가 클래스 B에 의존하는 경우 클래스 A는 더 이상 클래스 B를 직접 생성하지 않습니다. 대신 이 의존성 관계는 외부 xml 파일(또는 java config 파일)에서 구성되고 Spring 컨테이너는 구성 정보에 따라 bean 클래스를 생성하고 관리합니다.
class Player{ Weapon weapon; // weapon이 주입됨 Player(Weapon weapon){ this.weapon = weapon; } public void attack() { weapon.attack(); } public void setWeapon(Weapon weapon){ this.weapon = weapon; } }
위의 코드에서 Weapon 클래스의 인스턴스는 코드 내부에서 생성되지 않고 생성자를 통해 외부에서 전달됩니다. 전달된 유형은 부모 클래스 Weapon이므로 전달된 객체 유형은 Weapon의 모든 하위 클래스가 될 수 있습니다. 전달할 특정 하위 클래스는 외부 xml 파일(또는 java 구성 파일)에서 구성할 수 있습니다. Spring 컨테이너는 구성 정보에 따라 필요한 하위 클래스의 인스턴스를 생성하고 Player 클래스에 주입합니다. 예는 다음과 같습니다.
<bean id="player" class="com.qikegu.demo.Player"> <construct-arg ref="weapon"/> </bean> <bean id="weapon" class="com.qikegu.demo.Gun"> </bean>
위의 코드에서 <construct-arg ref="weapon"/>
의 ref는 id="weapon"
인 bean을 가리키고 전달된 weapon 유형은 Gun입니다. Sword로 변경하려면 다음과 같이 수정할 수 있습니다.
<bean id="weapon" class="com.qikegu.demo.Sword"> </bean>
느슨한 결합이 완전히 결합을 제거하는 것을 의미하지는 않습니다. 클래스 A는 클래스 B에 의존하고 그 사이에는 강한 결합이 있습니다. 의존성 관계가 클래스 A가 클래스 B의 부모 클래스 B0에 의존하도록 변경되면 클래스 A와 클래스 B0 간의 의존성 관계에서 클래스 A는 클래스 B0의 모든 하위 클래스를 사용할 수 있습니다. 이때 클래스 A와 클래스 B0의 하위 클래스 간의 의존성 관계는 느슨한 결합입니다. 의존성 주입의 기술적 기반은 다형성 메커니즘과 리플렉션 메커니즘임을 알 수 있습니다.
(III) 의존성 주입의 유형
- 생성자 주입: 의존성 관계는 클래스 생성자를 통해 제공됩니다.
- Setter 주입: 주입기는 클라이언트의 setter 메서드를 사용하여 의존성을 주입합니다.
- 인터페이스 주입: 의존성은 의존성을 전달하는 모든 클라이언트에 의존성을 주입하는 주입 메서드를 제공합니다. 클라이언트는 인터페이스를 구현해야 하며 이 인터페이스의 setter 메서드는 의존성을 수신하는 데 사용됩니다.
(IV) 의존성 주입의 기능
- 객체 생성.
- 어떤 클래스에 어떤 객체가 필요한지 명확히 합니다.
- 이러한 모든 객체를 제공합니다. 객체에 변경 사항이 발생하면 의존성 주입은 조사하고 이러한 객체를 사용하는 클래스에 영향을 미치지 않아야 합니다. 즉, 객체가 나중에 변경되면 의존성 주입은 클래스에 대한 올바른 객체를 제공할 책임이 있습니다.
(V) 제어 반전 - 의존성 주입의 배후에 있는 개념
제어 반전은 클래스가 의존성을 정적으로 구성해서는 안 되며 다른 클래스에 의해 외부에서 구성되어야 함을 의미합니다. 이것은 S.O.L.I.D의 다섯 번째 원칙을 따릅니다 - 클래스는 구체적인 것보다 추상화에 의존해야 합니다(하드 코딩 방지). 이러한 원칙에 따르면 클래스는 책임을 완수하는 데 필요한 객체를 생성하는 대신 자신의 책임을 완수하는 데 집중해야 합니다. 이것이 의존성 주입이 필요한 곳이며 클래스에 필요한 객체를 제공합니다.
(VI) 의존성 주입 사용의 장점
- 단위 테스트를 용이하게 합니다.
- 의존성 관계의 초기화는 주입기 구성 요소에 의해 완료되므로 상용구 코드 감소.
- 애플리케이션을 쉽게 확장할 수 있습니다.
- 느슨한 결합을 달성하는 데 도움이 되며 이는 애플리케이션 프로그래밍에서 매우 중요합니다.
(VII) 의존성 주입 사용의 단점
- 학습 과정이 약간 복잡하고 과도하게 사용하면 관리 및 기타 문제가 발생할 수 있습니다.
- 많은 컴파일 오류가 런타임까지 지연됩니다.
- 의존성 주입 프레임워크는 일반적으로 리플렉션 또는 동적 프로그래밍을 통해 구현되므로 "참조 찾기", "호출 계층 구조 표시" 및 안전한 리팩토링과 같은 IDE 자동화 기능을 사용하지 못할 수 있습니다.
의존성 주입은 직접 구현하거나 타사 라이브러리 또는 프레임워크를 사용하여 달성할 수 있습니다.
(VIII) 의존성 주입 구현을 위한 라이브러리 및 프레임워크
- Spring (Java)
- Google Guice (Java)
- Dagger (Java 및 Android)
- Castle Windsor (.NET)
- Unity (.NET)
- Wire(Golang)
IV. Golang TDD에서 DI 이해
Golang을 사용하는 동안 많은 사람들이 의존성 주입에 대해 많은 오해를 가지고 있습니다. 사실 의존성 주입에는 많은 장점이 있습니다.
- 프레임워크가 반드시 필요한 것은 아닙니다.
- 설계를 지나치게 복잡하게 만들지 않습니다.
- 테스트하기 쉽습니다.
- 훌륭하고 일반적인 기능을 작성할 수 있습니다.
누군가에게 인사하는 함수를 작성하는 것을 예로 들어 보겠습니다. 실제 인쇄를 테스트할 것으로 예상합니다. 초기 함수는 다음과 같습니다.
func Greet(name string) { fmt.Printf("Hello, %s", name) }
그러나 fmt.Printf
를 호출하면 내용이 표준 출력으로 인쇄되고 테스트 프레임워크를 사용하여 캡처하기 어렵습니다. 이때 인쇄의 의존성을 주입해야(즉, "전달") 합니다. 이 함수는 어디에 어떻게 인쇄할지 신경 쓸 필요가 없으므로 특정 유형 대신 인터페이스를 받아야 합니다. 이렇게 하면 인터페이스의 구현을 변경하여 인쇄된 내용을 제어하고 테스트를 달성할 수 있습니다.
fmt.Printf
의 소스 코드를 살펴보면 다음과 같습니다.
// It returns the number of bytes written and any write error encountered. func Printf(format string, a ...interface{}) (n int, err error) { return Fprintf(os.Stdout, format, a...) }
Printf
내부에서는 os.Stdout
을 전달하고 Fprintf
를 호출합니다. Fprintf
의 정의를 더 살펴보면 다음과 같습니다.
func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) { p := newPrinter() p.doPrintf(format, a) n, err = w.Write(p.buf) p.free() return }
그중 io.Writer
는 다음과 같이 정의됩니다.
type Writer interface { Write(p []byte) (n int, err error) }
io.Writer
는 "어딘가에 데이터를 넣는" 데 일반적으로 사용되는 인터페이스입니다. 이를 바탕으로 이 추상화를 사용하여 코드를 테스트 가능하게 만들고 재사용성을 높입니다.
(I) 테스트 작성
func TestGreet(t *testing.T) { buffer := bytes.Buffer{} Greet(&buffer,"Leapcell") got := buffer.String() want := "Hello, Leapcell" if got != want { t.Errorf("got '%s' want '%s'", got, want) } }
bytes
패키지의 buffer
유형은 Writer
인터페이스를 구현합니다. 테스트에서는 이를 Writer
로 사용합니다. Greet
을 호출한 후 이를 통해 작성된 내용을 확인할 수 있습니다.
(II) 테스트 실행 시도
테스트를 실행할 때 오류가 발생합니다.
./di_test.go:10:7: too many arguments in call to Greet
have (*bytes.Buffer, string)
want (string)
(III) 테스트 실행을 위한 최소화된 코드 작성 및 실패한 테스트 출력 확인
컴파일러의 프롬프트에 따라 문제를 해결합니다. 수정된 함수는 다음과 같습니다.
func Greet(writer *bytes.Buffer, name string) { fmt.Printf("Hello, %s", name) }
이때 테스트 결과는 다음과 같습니다.
Hello, Leapcell di_test.go:16: got '' want 'Hello, Leapcell'
테스트가 실패합니다. name
은 인쇄할 수 있지만 출력이 표준 출력으로 이동하는 것에 주목하세요.
(IV) 통과할 수 있도록 충분한 코드 작성
writer
를 사용하여 테스트에서 버퍼로 인사를 보냅니다. fmt.Fprintf
는 fmt.Printf
와 유사합니다. 차이점은 fmt.Fprintf
는 문자열을 전달하기 위해 Writer
매개변수를 받는 반면 fmt.Printf
는 기본적으로 표준 출력으로 출력한다는 것입니다. 수정된 함수는 다음과 같습니다.
func Greet(writer *bytes.Buffer, name string) { fmt.Fprintf(writer, "Hello, %s", name) }
이때 테스트가 통과합니다.
(V) 리팩토링
처음에 컴파일러는 bytes.Buffer
에 대한 포인터를 전달해야 한다고 프롬프트했습니다. 기술적으로는 이것이 맞지만 충분히 일반적이지 않습니다. 이를 설명하기 위해 Greet
함수를 Go 애플리케이션에 연결하여 내용을 표준 출력으로 인쇄합니다. 코드는 다음과 같습니다.
func main() { Greet(os.Stdout, "Leapcell") }
실행할 때 오류가 발생합니다.
./di.go:14:7: cannot use os.Stdout (type *os.File) as type *bytes.Buffer in argument to Greet
이전에 언급했듯이 fmt.Fprintf
는 io.Writer
인터페이스를 전달할 수 있으며 os.Stdout
과 bytes.Buffer
모두 이 인터페이스를 구현합니다. 따라서 코드를 수정하여 더 일반적인 인터페이스를 사용합니다. 수정된 코드는 다음과 같습니다.
package main import ( "fmt" "os" "io" ) func Greet(writer io.Writer, name string) { fmt.Fprintf(writer, "Hello, %s", name) } func main() { Greet(os.Stdout, "Leapcell") }
(VI) io.Writer에 대한 추가 정보
io.Writer
를 사용하여 코드의 일반성이 향상되었습니다. 예를 들어 인터넷에 데이터를 쓸 수 있습니다. 다음 코드를 실행하세요.
package main import ( "fmt" "io" "net/http" ) func Greet(writer io.Writer, name string) { fmt.Fprintf(writer, "Hello, %s", name) } func MyGreeterHandler(w http.ResponseWriter, r *http.Request) { Greet(w, "world") } func main() { http.ListenAndServe(":5000", http.HandlerFunc(MyGreeterHandler)) }
프로그램을 실행하고 http://localhost:5000
을 방문하면 Greet
함수가 호출되는 것을 볼 수 있습니다. HTTP 핸들러를 작성할 때 http.ResponseWriter
와 http.Request
를 제공해야 합니다. http.ResponseWriter
도 io.Writer
인터페이스를 구현하므로 Greet
함수를 핸들러에서 재사용할 수 있습니다.
V. 결론
코드의 초기 버전은 제어할 수 없는 위치에 데이터를 쓰기 때문에 테스트하기 쉽지 않습니다. 테스트를 통해 코드를 리팩터링합니다. 의존성을 주입하여 데이터 쓰기 방향을 제어할 수 있으며 다음과 같은 많은 이점이 있습니다.
- 코드 테스트: 함수를 테스트하기 어려운 경우 일반적으로 함수 또는 전역 상태에 대한 의존성의 하드 링크가 있기 때문입니다. 예를 들어 서비스 계층이 전역 데이터베이스 연결 풀을 사용하는 경우 테스트하기 어려울 뿐만 아니라 실행 속도도 느립니다. DI는 인터페이스를 통해 데이터베이스 의존성을 주입하여 테스트에서 모의 데이터를 제어할 것을 옹호합니다.
- 관심사 분리: 데이터가 도착하는 위치와 데이터가 생성되는 방식을 분리합니다. 특정 메서드/함수가 너무 많은 기능을 수행한다고 느끼는 경우(예: 데이터 생성 및 데이터베이스 동시 쓰기 또는 HTTP 요청 및 비즈니스 로직 동시 처리) DI 도구를 사용해야 할 수 있습니다.
- 다른 환경에서 코드 재사용: 코드는 먼저 내부 테스트 환경에서 적용됩니다. 나중에 다른 사람들이 이 코드를 사용하여 새로운 기능을 시도하려는 경우 자신의 의존성을 주입하기만 하면 됩니다.
Leapcell: 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼
마지막으로 Golang을 배포하는 데 가장 적합한 플랫폼인 **Leapcell**을 추천합니다.
1. 다중 언어 지원
- JavaScript, Python, Go 또는 Rust로 개발하십시오.
2. 무제한 프로젝트를 무료로 배포
- 사용량에 대해서만 지불하십시오 - 요청 없음, 요금 없음.
3. 탁월한 비용 효율성
- 유휴 요금 없이 사용량에 따라 지불하십시오.
- 예: 60ms 평균 응답 시간으로 694만 건의 요청을 25달러로 지원합니다.
4. 간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
5. 간편한 확장성 및 고성능
- 높은 동시성을 쉽게 처리하기 위한 자동 크기 조정.
- 운영 오버헤드가 제로입니다. 구축에만 집중하십시오.
Leapcell Twitter: https://x.com/LeapcellHQ