이번 포스팅에서는 의존성 역전(Dependency Inversion) 원칙에 대해 알아보겠습니다. 간단히 말해서 이것이 무엇인지 이야기하고 간단한 Go 애플리케이션을 예로 들어 이 원리를 살펴보겠습니다.
DIP(종속성 반전 원칙)는 Robert C. Martin이 처음 도입한 객체 지향 프로그래밍(OOP)의 5가지 SOLID 원칙 중 하나입니다. 그것은 다음과 같이 말합니다:
상위 수준 모듈은 하위 수준 모듈에서 아무것도 가져오면 안 됩니다. 둘 다 추상화(예: 인터페이스)에 의존해야 합니다.
추상화는 세부사항에 의존해서는 안 됩니다. 세부사항(구체적인 구현)은 추상화에 따라 달라집니다.
OOP 디자인 세계에서는 매우 잘 알려진 원칙이지만, 이전에 접해본 적이 없다면 언뜻 불명확하게 보일 수 있으므로 구체적인 예를 사용하여 이 원칙을 분석해 보겠습니다.
Go에서 DI 원칙의 구현이 어떻게 보일 수 있는지 살펴보겠습니다. ID를 기반으로 책에 대한 정보를 반환하는 단일 엔드포인트/책이 있는 HTTP 애플리케이션의 간단한 예부터 시작하겠습니다. 책에 대한 정보를 검색하기 위해 애플리케이션은 외부 HTTP 서비스와 상호 작용합니다.
cmd - Go 명령이 포함된 폴더입니다. 주요 기능은 여기에 있습니다.
내부 - 내부 애플리케이션 코드가 포함된 폴더입니다. 우리의 모든 코드는 여기에 있습니다.
main.go는 단순히 HTTP 서버를 시작합니다.
package main import ( "log" "net/http" "example.com/books/internal/app/httpbookapi" ) func main() { http.Handle("/book", &httpbookapi.Handler{}) log.Print("server listening at 9090") log.Fatal(http.ListenAndServe(":9090", nil)) }
HTTP 엔드포인트를 처리하기 위한 코드는 다음과 같습니다.
package httpbookapi import ( "encoding/json" "fmt" "net/http" "example.com/books/internal/model" ) type Handler struct { } func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { var ( ctx = request.Context() id = request.URL.Query().Get("id") book model.Book ) url := fmt.Sprintf("http://localhost:8080/book?id=%s", id) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { http.Error(writer, err.Error(), http.StatusInternalServerError) return } resp, err := http.DefaultClient.Do(req) if err != nil { http.Error(writer, err.Error(), http.StatusInternalServerError) return } defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(&book); err != nil { http.Error(writer, err.Error(), http.StatusInternalServerError) return } book.Price = 10.12 if book.Title == "Pride and Prejudice" { book.Price += 2 } writer.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(writer).Encode(book); err != nil { http.Error(writer, err.Error(), http.StatusInternalServerError) return } }
보시다시피 현재 모든 코드는 핸들러 내부에 직접 있습니다(Book 모델 제외). 핸들러에서 HTTP 클라이언트를 생성하고 외부 서비스에 요청합니다. 그런 다음 책에 가격을 지정합니다. 여기서는 이것이 최선의 디자인이 아니며 외부 서비스를 호출하기 위한 코드를 핸들러에서 추출해야 한다는 것이 모든 개발자에게 분명하다고 생각합니다. 그걸하자.
첫 번째 단계로 이 코드를 별도의 위치로 이동해 보겠습니다. 이를 위해 책을 검색하고 처리하기 위한 로직이 저장될 내부/pkg/getbook/usecase.go 파일과 내부 파일을 저장할 내부/pkg/getbook/types.go 파일을 생성합니다. 필요한 getbook 유형.
usecase.go 코드
package getbook import ( "context" "encoding/json" "fmt" "net/http" ) type UseCase struct { bookServiceClient *http.Client } func NewUseCase() *UseCase { return &UseCase{} } func (u *UseCase) GetBook(ctx context.Context, id string) (*Book, error) { var ( book Book url = fmt.Sprintf("http://localhost:8080/book?id=%s", id) ) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := u.bookServiceClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if err := json.NewDecoder(resp.Body).Decode(&book); err != nil { return nil, err } book.Price = 10.12 if book.Title == "Pride and Prejudice" { book.Price += 2 } return &book, nil }
유형.고 코드
package getbook type Book struct { ID string `json:"id"` Title string `json:"title"` Author string `json:"author"` Price float64 `json:"price"` }
새로운 핸들러 코드:
package httpbookapi import ( "encoding/json" "net/http" "example.com/books/internal/pkg/getbook" ) type Handler struct { getBookUseCase *getbook.UseCase } func NewHandler(getBookUseCase *getbook.UseCase) *Handler { return &Handler{ getBookUseCase: getBookUseCase, } } func (h *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { var ( ctx = request.Context() id = request.URL.Query().Get("id") ) book, err := h.getBookUseCase.GetBook(ctx, id) if err != nil { http.Error(writer, err.Error(), http.StatusInternalServerError) return } writer.Header().Add("Content-Type", "application/json") if err := json.NewEncoder(writer).Encode(book); err != nil { http.Error(writer, err.Error(), http.StatusInternalServerError) return } }
보시다시피 핸들러 코드가 훨씬 깔끔해졌습니다. 하지만 이제 getbook/usecase.go를 살펴보는 것이 훨씬 더 흥미로워졌습니다.
type UseCase struct { bookServiceClient *http.Client }
UseCase에는 *http.Client 형식의 종속성이 있으며 현재 어떤 방식으로든 초기화하지 않습니다. *http.Client를 NewUseCase() 생성자에 전달하거나 생성자 내에서 직접 *http.Client를 생성할 수 있습니다. 그러나 DI 원칙이 우리에게 말하는 것을 다시 한 번 생각해 봅시다.
상위 수준 모듈은 하위 수준 모듈에서 아무것도 가져오면 안 됩니다. 둘 다 추상화(예: 인터페이스)에 의존해야 합니다.
그러나 이 접근 방식을 사용하면 정반대의 결과를 얻을 수 있습니다. 우리의 상위 수준 모듈인 getbook은 하위 수준 모듈인 HTTP를 가져옵니다.
이 문제를 어떻게 해결할 수 있을지 생각해 봅시다. 시작하려면 Internal/pkg/bookserviceclient/client.go 라는 파일을 만들어 보겠습니다. 이 파일에는 외부 서비스에 대한 HTTP 요청 구현과 해당 인터페이스가 포함됩니다.
package bookserviceclient import ( "context" "fmt" "io" "net/http" ) type Client interface { GetBook(ctx context.Context, id string) ([]byte, error) } type client struct { httpClient *http.Client } func NewClient() Client { return &client{ httpClient: http.DefaultClient, } } func (c *client) GetBook(ctx context.Context, id string) ([]byte, error) { var ( url = fmt.Sprintf("http://localhost:8080/book?id=%s", id) ) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return b, nil }
다음으로 bookserviceclient 패키지의 인터페이스를 사용하여 시작하도록 UseCase를 업데이트해야 합니다.
package getbook import ( "context" "encoding/json" "example.com/books/internal/pkg/bookserviceclient" ) type UseCase struct { bookClient bookserviceclient.Client } func NewUseCase(bookClient bookserviceclient.Client) *Usecase { return &UseCase{ bookClient: bookClient, } } func (u *UseCase) GetBook(ctx context.Context, id string) (*Book, error) { var ( book Book ) b, err := u.bookClient.GetBook(ctx, id) if err != nil { return nil, err } if err := json.Unmarshal(b, &book); err != nil { return nil, err } book.Price = 10.12 if book.Title == "Pride and Prejudice" { book.Price += 2 } return &book, nil }
상황이 크게 개선된 것 같으며, 하위 수준 모듈에서 useсase의 종속성 문제를 해결했습니다. 그러나 아직은 거기까지가 아닙니다. 한 단계 더 나아가 보겠습니다. 현재 종속성을 선언하기 위해 useсase는 하위 수준 모듈의 인터페이스를 사용하고 있습니다. 이것을 개선할 수 있을까요? pkg/getbook/types.go 에서 필요한 인터페이스를 선언하면 어떻게 될까요?
이런 식으로 우리는 하위 수준 모듈에 대한 명시적인 종속성을 제거합니다. 즉, 상위 수준 모듈은 작업에 필요한 모든 인터페이스를 선언하여 하위 수준 모듈에 대한 모든 종속성을 제거합니다. 그런 다음 애플리케이션의 최상위 수준( main.go )에서 useсase가 작동하는 데 필요한 모든 인터페이스를 구현합니다.
또한 Go에서 내보낸 유형과 내보내지 않은 유형을 기억해 보겠습니다. Use-ase 인터페이스를 내보내야 합니까? 이러한 인터페이스는 이 패키지의 작업에 필요한 종속성을 지정하는 데만 필요하므로 내보내지 않는 것이 좋습니다.
usecase.go
package getbook import ( "context" "encoding/json" ) type UseCase struct { bookClient bookClient } func NewUseCase(bookClient bookClient) *UseCase { return &UseCase{ bookClient: bookClient, } } func (u *UseCase) GetBook(ctx context.Context, id string) (*Book, error) { var ( book Book ) b, err := u.bookClient.GetBook(ctx, id) if err != nil { return nil, err } if err := json.Unmarshal(b, &book); err != nil { return nil, err } book.Price = 10.12 if book.Title == "Pride and Prejudice" { book.Price += 2 } return &book, nil }
유형.이동
package getbook import "context" type bookClient interface { GetBook(ctx context.Context, id string) ([]byte, error) } type Book struct { ID string `json:"id"` Title string `json:"title"` Author string `json:"author"` Price float64 `json:"price"` }
client.go
package bookserviceclient import ( "context" "fmt" "io" "net/http" ) type Client struct { httpClient *http.Client } func NewClient() *Client { return &Client{ httpClient: http.DefaultClient, } } func (c *Client) GetBook(ctx context.Context, id string) ([]byte, error) { var ( url = fmt.Sprintf("http://localhost:8080/book?id=%s", id) ) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } resp, err := c.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { return nil, err } return b, nil }
main.go
package main import ( "log" "net/http" "example.com/books/internal/app/httpbookapi" "example.com/books/internal/pkg/bookserviceclient" "example.com/books/internal/pkg/getbook" ) func main() { bookServiceClient := bookserviceclient.NewClient() useCase := getbook.NewUsecase(bookServiceClient) handler := httpbookapi.NewHandler(useCase) http.Handle("/book", handler) log.Print("server listening at 9090") log.Fatal(http.ListenAndServe(":9090", nil)) }
이 기사에서는 Go에서 종속성 반전 원칙을 구현하는 방법을 살펴보았습니다. 이 원칙을 구현하면 코드가 스파게티처럼 되는 것을 방지하고 유지 관리 및 읽기가 더 쉬워집니다. 클래스의 종속성과 이를 올바르게 선언하는 방법을 이해하면 애플리케이션을 추가로 지원할 때 생활이 크게 단순화될 수 있습니다.