의존성 역전 원칙: 구현이 아닌 계약에 의존하는 설계
의존성 역전 원칙(DIP)의 구조와 의존성 주입 방식, 계층형 아키텍처와 테스트에서 추상화를 활용하는 설계 방법을 정리한다.
2026-08-14 · 최초 발행 2025-05-23
구현체가 서비스 로직을 끌고 가지 않게 하려면
의존성 역전 원칙(Dependency Inversion Principle, DIP)은 SOLID 원칙 가운데 하나다. 고수준 모듈이 저수준 모듈에 직접 의존하지 않고, 양쪽 모두 인터페이스나 추상 클래스 같은 추상화에 의존하도록 설계하라는 원칙이다.
이 구조는 결합도를 낮추고 코드 재사용성과 유지보수성을 높이는 데 목적이 있다. 서비스 로직이 특정 데이터베이스나 외부 구현에 고정되지 않으므로, 구현의 변경이 상위 모듈까지 전파되는 범위를 줄일 수 있다.
의존성의 방향이 달라지는 지점
전통적인 구조에서는 고수준 모듈이 저수준 모듈을 직접 참조한다. 저수준 구현이 바뀌면 이를 호출하는 상위 코드도 영향을 받고, 단위 테스트에서도 실제 구현을 분리하기 어렵다.
DIP를 적용하면 두 모듈은 추상화에 연결된다. 고수준 모듈은 자신이 필요한 계약을 바라보고, 저수준 모듈은 그 계약을 구현한다.
핵심은 단순히 인터페이스를 추가하는 데 있지 않다. 인터페이스를 사용하는 쪽, 즉 고수준 모듈의 요구사항이 계약을 규정하고 저수준 모듈이 이를 충족하는 구조에 있다.
추상화가 맡는 역할
구체적인 구현이 아니라 계약에 의존해야 한다. 이때 인터페이스는 제공자의 내부 구조보다 클라이언트가 실제로 필요로 하는 동작을 기준으로 설계한다.
인터페이스의 정의는 사용자 역할을 하는 고수준 모듈이 맡고, 구현은 제공자 역할의 저수준 모듈이 맡는다. 따라서 고수준 모듈에서 구체 클래스를 직접 생성하는 new 사용을 줄이고, 외부에서 의존성을 전달하는 방식을 활용한다.
의존성을 전달하는 방식
의존성 주입(Dependency Injection, DI)은 DIP를 구현할 때 쓰이는 대표적인 패턴이다. 주입 시점과 대상에 따라 선택지가 달라진다.
필수 의존성은 생성자에서 전달할 수 있다. 객체가 만들어지는 순간 의존성이 정해지므로 불변성을 보장하고 순환 의존성을 발견하기 쉽다.
public class Service {
private final Repository repository;
public Service(Repository repository) {
this.repository = repository;
}
}
선택적 의존성이라면 객체 생성 뒤 setter 메서드를 통해 전달할 수 있다. 의존성 교체는 쉽지만 불변성을 보장하기는 어렵다.
public class Service {
private Repository repository;
public void setRepository(Repository repository) {
this.repository = repository;
}
}
특정 작업에만 필요한 의존성은 메서드 호출 시점에 받을 수 있다. 메서드별로 다른 의존성이 필요하거나 사용 시점에 의존성을 정해야 할 때 적합하다.
public class Service {
public void processData(Repository repository, Data data) {
repository.save(data);
}
}
데이터 저장소를 교체 가능한 경계로 만들기
비즈니스 로직은 MySQL, MongoDB, 파일시스템 같은 저장소 구현을 직접 알 필요가 없다. 데이터 접근 인터페이스에만 의존하게 하면 저장소가 바뀌어도 비즈니스 로직을 수정하지 않아도 된다.
다음 예시에서 UserService는 MySQLUserRepository가 아니라 UserRepository 계약에만 의존한다. 구현체는 해당 계약을 만족시키는 역할을 맡는다.
// 추상화 - 고수준 모듈에서 정의
public interface UserRepository {
User findById(Long id);
void save(User user);
}
// 저수준 모듈 - 추상화 구현
public class MySQLUserRepository implements UserRepository {
@Override
public User findById(Long id) {
// MySQL 구현
}
@Override
public void save(User user) {
// MySQL 구현
}
}
// 고수준 모듈 - 추상화에만 의존
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void registerUser(User user) {
// 비즈니스 로직
userRepository.save(user);
}
}
계층과 생성 책임을 분리하는 방법
계층형 아키텍처에서는 각 계층이 하위 계층의 구현이 아니라 추상화에 의존하도록 구성할 수 있다. 특히 도메인 계층이 인프라 계층에 직접 묶이지 않게 하면 도메인 모델 중심의 설계를 유지하기 수월하다.
객체 생성 책임은 팩토리로 분리할 수 있다. 이렇게 하면 서비스 코드가 구체 클래스의 생성에 직접 의존하는 범위를 줄일 수 있으며, DI 컨테이너와 함께 사용할 수도 있다.
public class RepositoryFactory {
public UserRepository createUserRepository() {
return new MySQLUserRepository();
}
}
추상화에 의존하는 구조는 Mock 객체를 주입한 단위 테스트에도 유리하다. 실제 저장소 구현 없이 서비스 로직을 분리해 검증할 수 있고, 테스트 주도 개발(TDD)에도 활용할 수 있다.
@Test
public void registerUser_ValidUser_CallsSaveOnce() {
// Mock 의존성 생성
UserRepository mockRepository = mock(UserRepository.class);
// 테스트 대상 객체에 의존성 주입
UserService service = new UserService(mockRepository);
// 테스트 실행
User user = new User("test@example.com");
service.registerUser(user);
// 검증
verify(mockRepository, times(1)).save(user);
}
추상화가 복잡성을 늘리는 경우
모든 클래스에 인터페이스를 만들면 오히려 불필요한 복잡성이 생길 수 있다. 변경 가능성이 높은 경계를 중심으로 적용하고, 추상화가 실제 교체나 분리의 필요성을 뒷받침하는지 판단해야 한다.
모듈 간 순환 의존성도 주의할 대상이다. 의존성 그래프를 확인하고, 모듈화와 책임 분리를 통해 순환 구조가 생기지 않도록 관리해야 한다.
DI 프레임워크에 지나치게 의존하는 설계 역시 경계할 필요가 있다. 핵심 도메인 로직은 프레임워크와 독립적으로 유지하고, 테스트 가능한 구조를 보존해야 한다.
서비스 계약과 플러그인 확장
마이크로서비스 환경에서 서비스 간 계약은 인터페이스와 같은 추상화 역할을 한다. API 명세를 계약으로 두면 서비스 내부 구현이 바뀌어도 다른 서비스에 미치는 영향을 줄일 수 있다.
플러그인 아키텍처에서도 핵심 시스템이 플러그인 인터페이스를 정의하고, 구현체는 독립적으로 개발·배포할 수 있다. 이 구조는 시스템 확장성을 높인다.
SOLID 원칙 안에서의 DIP
DIP를 적용하면 모듈별 책임이 더 명확해진다. 인터페이스를 단일 책임에 집중시키면 응집도 높은 모듈을 설계하는 데 도움이 된다.
개방-폐쇄 원칙(OCP)에서는 추상화를 통해 확장에는 열리고 수정에는 닫힌 구조를 만들 수 있다. DIP는 이러한 구조를 가능하게 하는 핵심 메커니즘이다.
리스코프 치환 원칙(LSP)은 DIP의 전제이기도 하다. 인터페이스를 구현하는 객체는 계약을 지켜야 하며, 구현체끼리 서로 대체 가능해야 한다.