과정을 즐기자

객체 지향 SOLID 원칙중 D (DIP)에 대해 알아보자 본문

아키텍쳐

객체 지향 SOLID 원칙중 D (DIP)에 대해 알아보자

320Hwany 2025. 8. 29. 23:10

객체 지향 언어를 사용하다보면 SOLID 원칙에 대해 많이 들어보셨을 겁니다.

SOLID는 유지보수성과 확장성이 높은 코드를 작성하기 위한 다섯 가지 설계 원칙을 의미합니다.

그 다섯 가지 원칙은 다음과 같습니다.

 

1. Single Responsibility Principle - 단일 책임 원칙

2. Open-Closed Principle - 개방-폐쇄 원칙

3. Liskov Substitution Principle - 리스코프 치환 원칙

4. Interface Segregation Principle - 인터페이스 분리 원칙

5. Dependency Inversion Principle - 의존성 역전 원칙

 

이번 글에서는 5번째 원칙인 DIP 에 대해 집중적으로 알아보겠습니다.

 

📘 DIP의 정의를 알아보자

먼저 DIP는 Dependency Inversion Principle의 약자입니다.

비슷한 약자로 DI(Dependency Injection) 가 있는데 이 두 가지는 이름이 비슷해 혼동하기 쉽지만 완전히 다른 개념입니다.

DIP의 I는 Inversion(역전) 으로 의존 관계의 방향을 역전시키는 원칙을 의미합니다.

DI의 I는 Injection(주입) 으로 의존 대상을 외부에서 주입받는 행위를 의미합니다.

 

의존성 역전 원칙이라고만 하면 잘 와닿지 않으니 자세한 설명을 살펴보겠습니다.

 

“상위 수준 모듈(High-level Module)은 하위 수준 모듈(Low-level Module)에 의존해서는 안 된다.
두 모듈 모두 추상화(Abstraction)에 의존해야 한다. 또한 추상화는 세부 사항(Details)에 의존해서는 안 되며,
세부 사항이 추상화에 의존해야 한다.”

— Robert C. Martin 

 

요약하면 추상화에 의존해야하지 구체화에 의존하면 안된다는 것입니다.

이번 글에서는 해당 설명의 의미와 역전이라는 단어가 왜 등장했는지를 코드와 그림을 통해 살펴보겠습니다.

 

📕 DIP 예제로 직접 살펴보자

 

단순한 레이어드 아키텍쳐를 생각해보겠습니다.

Service 에서 Repository 를 사용하는 구조는 아래 그림과 같습니다.

 

golang 코드로도 살펴보겠습니다.

 

application layer

package application

import (
    "go-dip-principle/persistence"
)

type MemberService struct {
    MemberRepository persistence.MemberRepository
}

func NewMemberService(memberRepository persistence.MemberRepository) *MemberService {
    return &MemberService{
       MemberRepository: memberRepository,
    }
}

 

persistence layer

package persistence

import (
    "gorm.io/gorm"
)

type MemberRepository struct {
    db *gorm.DB
}

func NewMemberRepository(db *gorm.DB) *MemberRepository {
    return &MemberRepository{db: db}
}

 

import 부분을 살펴보면 상위 수준 모듈인 application layer 에서 하위 수준 모듈인 persistence layer 에 의존하고 있습니다.

또한 여기서 MemberRepository는 추상화가 아닌 구체화된 구현체로 상위 모듈이 추상화에 의존하고 있지도 않습니다.

 

이제 해당 코드를 DIP 를 만족할 수 있도록 수정해보겠습니다. 수정된 그림은 아래와 같습니다.

MemberRepository 를 인터페이스로 만들고 application layer 로 올렸으며 MemberService는 MemberRepository 에

의존하고 MemberRepository 의 구현체를 persistence layer에 생성하였습니다.

 

 

application layer

package application

type MemberService struct {
    MemberRepository MemberRepository
}

func NewMemberService(memberRepository MemberRepository) *MemberService {
    return &MemberService{
       MemberRepository: memberRepository,
    }
}
package application

import (
    "go-dip-principle/domain"
)

type MemberRepository interface {
    Save(member *domain.Member) error
    GetById(memberId int) (*domain.Member, error)
}


persistence layer

package persistence

import (
    "go-dip-principle/application"
    "go-dip-principle/domain"
    "gorm.io/gorm"
)

// 컴파일 타임 인터페이스 컴플라이언스 검증
var _ application.MemberRepository = (*MemberRepositoryImpl)(nil)

type MemberRepositoryImpl struct {
    db *gorm.DB
}

 

수정된 코드의 import 부분을 다시 살펴보면 application layer 에서 persistence layer 에 의존하지 않습니다.

또한 persistence layer 에서 application layer 에 의존하지만 추상화된 상위 모듈에 의존합니다.

 

📗 역전(Inversion) 이라는 단어를 사용하는 이유

위 예제 코드가 SOLID 에서 말하는 DIP 를 적용했다고 볼 수 있습니다.

그렇다면 왜 해당 구조로 변경하는 원칙에서 Inversion 이라는 단어를 사용하는 것일까요?

이전 그림을 다시 한번 살펴보겠습니다.

 

Before

 

After

 

Before의 MemberRepository와 After의 MemberRepositoryImpl은 동일한 코드/역할을 합니다.

하지만 의존성 방향을 보면 들어오는 방향에서 나가는 방향으로 변경되었습니다.

그래서 의존성 역전 법칙(Dependency Inversion Principle)이라고 부릅니다.

 

📚 의존성 역전을 사용하는 이유

그렇다면 상위 모듈이 추상화에 의존하도록 하는 이유는 뭘까요?

구체화에 의존하게 되면 내부 구현이 바뀌었을 때 상위 모듈에도 영향을 주게됩니다.

 

package persistence

import (
	"go-dip-principle/application"
	"gorm.io/gorm"
)

// 컴파일 타임 인터페이스 컴플라이언스 검증
var _ application.MemberRepository = (*MemberRepositoryImpl)(nil)

type MemberRepositoryImpl struct {
	db *gorm.DB
}

func NewMemberRepository(db *gorm.DB) *MemberRepositoryImpl {
	return &MemberRepositoryImpl{db: db}
}

 

위 코드에서 gorm 이 아닌 다른 방식을 사용, RDB가 아닌 NoSQL 사용, RDB가 아닌 캐시 사용 등등..

여러가지 이유로 Repository 구현체는 얼마든지 변경될 수 있습니다.

하지만 위와 같이 의존성 역전을 한다면 application layer 에 영향을 주지 않고 구현체를 변경할 수 있습니다.

복잡한 문제를 해결하는 실무의 코드에서 요구 사항의 변경으로 인한 코드 변경을 최소화 할 수 있는 중요한 방법이라고 생각합니다.

 

또한 이러한 구조는 테스트하기 쉬운 코드를 만드는 데 도움이 됩니다.

예를 들어 MemberService를 테스트하려고 할 때 실제 MemberRepository 구현체가 외부 시스템과의 복잡한 연동을

포함하고 있다고 가정해보겠습니다. 이 경우 MemberService 역시 그 영향을 받아 테스트가 어려워집니다.

하지만 MemberRepository의 테스트용 구현체를 따로 만든다면 외부 연동에 의존하지 않고도 MemberService의 로직을

독립적으로 검증할 수 있습니다.

 

// 테스트를 위한 구현체
type FakeMemberRepositoryImpl struct {
}

func NewFakeMemberRepositoryImpl() *FakeMemberRepositoryImpl {
    return &FakeMemberRepositoryImpl{}
}