의존성 주입(DI)이란? (OCP와 DIP를 지키기 위한 방법)

SOLID 원칙 중에서 OCP와 DIP에 대해 많이 들어봤을 것이다.

OCP와 DIP를 모르는 사람을 위해 간단히 설명해보자면

 

OCP (Open Closed Principle) - 개방 폐쇠 원칙

변경에는 닫혀 있고, 확장에는 열려 있어야 한다.

즉, 확장은 가능하지만 이 확장으로 변경사항이 생겨선 안된다는 말이다.

(이 내용에 대해 헷갈릴 수 있다. 아래에서 자세히 설명한다.)

 

DIP (Dependency Inversion Principle) - 의존 역전 원칙

추상화에 의존하되, 구체화에 의존해선 안된다.

즉, 구현 클래스를 의존하지 말고, 추상체 (인터페이스, 추상 클래스)를 의존하라는 뜻이다.

 

DI를 설명하기 전에 OCP와 DIP를 위반하는 상황이 뭘까?

OCP와 DIP를 위반하는 경우

 

아래 코드는 위 협력관계를 표현한 코드이다.

public interface GameRepository {
    Long save(ChessGame chessGame, Connection connection);
}

 

public class MemoryGameRepository implements GameRepository {
    private final Map<Long, ChessGame> games;
    private Long gameId;

    public MemoryGameRepository() {
        this.games = new HashMap<>();
        this.gameId = 0L;
    }

    @Override
    public Long save(final ChessGame chessGame, final Connection connection) {
        gameId += 1;
        games.put(gameId, chessGame);
        return gameId;
    }
}

 

public class ChessGameService {
    private final GameRepository gameRepository;

    public ChessGameService() {
        this.gameRepository = new MemoryGameRepository();
    }
    
   	...
}

 

ChessGameService를 보면 인터페이스인 GameRepository를 갖고 있다.

얼핏 보면 코드에 어색한 부분은 보이지 않는다. 뭐가 문제일까??

 

바로 생성자부분을 보면 gameRepository를 초기화하면서 구체화에 의존하고 있고 (DIP 위반)

만약 이 구현체가 DBGameRepsository로 변경된다면 해당 초기화코드의 구현체를 변경해줘야한다. (OCP 위반)

 

그렇다면 OCP와 DIP를 위반하지 않도록 하기 위해선 어떻게 코드를 작성해야할까?

 

DI (Dependency Injection) 의존관계 주입 or 의존성 주입

위 문제를 해결하기 위해 사용할 수 있는 것이 바로 DI (의존관계 주입, 의존성 주입)이다.

먼저 이해하기 쉽도록 각각의 용어를 분리해 해석해보자.

 

의존성

한 클래스가 다른 클래스의 메소드나 데이터를 사용할 때, 이를 '의존한다'고 한다.

위 코드의 경우 ChessGameService는 GameRepository를 필드로 가지고 있고 이 것을 사용하고 있다.

즉, ChessGameService는 GameRepository를 의존하는 것이다.

쉬운 예시로는, 자동차 클래스가 엔진 클래스의 기능을 사용하면 자동차는 엔진에 의존하는 관계가 된다.

 

주입

필요한 의존성외부에서 내부로 전달하는 과정을 의미한다.

예를 들어, 자동차가 엔진을 필요로 할 때 자동차 내부에서 엔진을 직접 만들지 않고

외부에서 만들어진 엔진을 자동차에 제공(주입)한다고 한다.

 

그리고 이 내용을 합해서 DI를 정리하면 다음과 같다.

"객체가 자신의 의존성, 즉 필요로 하는 다른 객체들을 직접 생성하거나 관리하지 않고, 외부로부터 받아 사용하는 것"

 

DI를 적용해보자

그렇다면 OCP와 DIP를 위반하는 코드에 DI를 적용해 코드를 수정해보자.

 

public class ChessGameService {
    private final GameRepository gameRepository;

    public ChessGameService(final GameRepository gameRepository) {
        this.gameRepository = gameRepository;
    }
 	...
}

 

public class ChessConfig {

    public ChessGameService chessGameService() {
        return new ChessGameService(gameRepository());
    }
    public GameRepository gameRepository() {
        return new MemoryGameRepository();
    }
}

기존 코드에서 ChessGameService의 생성자 부분만 수정하고 ChessConfig를 추가했다.

 

바로 이 ChessConfig가 의존성을 관리하는 역할을 담당하게 된다.

class ChessGameServiceTest {

    void saveTest() {
        ChessConfig chessConfig = new ChessConfig();
        ChessGameService chessGameService = chessConfig.chessGameService();

        chessGameService.saveGame(new ChessGame(new Board()));
    }
}

ChessConfig가 의존성을 관리함으로써 구현체가 변경될 때 ChessConfig의 코드만 수정하면 되고 ChessGameService의 코드는 변경될 일이 없다.

이로써 구현체는 인터페이스를 의존하고 구현체가 변경되더라도 수정의 여지가 없기 때문에 OCP와 DIP 원칙 두 개를 다 지킬 수 있다.

 

구현 객체는 자신의 로직을 실행하는 역할만 담당하고, 프로그램 제어의 흐름은 ChessConfig가 가지게 되었다.

그리고 ChessConfig는 인터페이스의 구현체를 정해 생성하기까지 한다.

인터페이스의 구현체들은 그런 사실도 모른채 자신의 로직을 실행하기만 한다.

이것이 바로 DI를 적용한 것이다.

 

그리고 위 Config 객체처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 것을 DI 컨테이너 또는 IoC 컨테이너라고 한다.

 

번외 : IoC와 DI 차이 ?
IoC(제어의 역전) DI(의존성 주입)
프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것 객체가 자신의 의존성, 즉 필요로 하는 다른 객체들을 직접 생성하거나 관리하지 않고, 외부로부터 받아 사용하는 것

얼핏보면 IoC는 DI이다?처럼 보이지만, IoC는 보다 더 넓은 개념으로 제어 흐름의 역전을 일반적으로 설명한다.

 

간단히 말해

IoC는 프레임워크나 라이브러리를 구별하는 것 처럼 소프트웨어의 제어권을 누가 가지고 있는가?이고,

DI는 의존관계를 어떻게 가질 것 인가? 이다.

즉, DI는 이러한 제어의 역전을 구현하는 구체적인 기법 중 하나이다.

 

Spring의 DI

Spring 프레임워크는 우리가 작성한 클래스들을 스프링 컨테이너에 Bean라는 객체로 관리하고

스프링 컨테이너는 이 Bean들의 생명주기와 의존성을 관리한다.

여기서 우리는 스프링 컨테이너에 객체의 제어권을 위임하기 때문에 IoC(제어의 역전)이 일어난다.

물론 개발자가 순수 자바코드로만 코드를 작성한다고 스프링이 다 해주는 것은 아니다.

스프링은 프레임워크이기에 개발자는 이 도구를 사용하기 위해 사용법을 익히고 사용해야 한다.

모든 내용에 대해 설명하기에는 분량이 너무 많기 때문에 간단하게 스프링에서 의존성 주입하는 과정에 대해서만 알아보자.

 

우리가 스프링 컨테이너에 Bean을 등록하기 위해서는 @Bean 어노테이션 또는 @Component 어노테이션으로

Bean을 등록한다.

그리고 의존성 주입을 하기 위해서는 @Autowired 어노테이션을 사용해 스프링 컨테이너에게 의존성 주입을 맡긴다.

@Service
public class ReservationService {
    private final ReservationRepository reservationRepository;

    @Autowired
    public ReservationService(final ReservationRepository reservationRepository) {
        this.reservationRepository = reservationRepository;
    }
    
    ... 생략

    public List<ReservationResponse> findAll() {
        List<Reservation> reservations = reservationRepository.findAll();
        return reservations.stream()
                .map(ReservationResponse::toResponse)
                .toList();
    }
}

위 Service 클래스는 @Component 어노테이션이 포함되어있는 @Service 어노테이션으로 Bean 등록되었다.

그리고 이 서비스에서 필요한 Repository 클래스를 필드로 가지고

@Autowired 어노테이션으로 해당 Repository를 현재 Service 클래스에 의존성 주입하고 있다.

(생성자가 하나 일 때는 @Autowired를 생략 가능)

이제 의존성 세팅은 끝났다.

끝났다는 것은 일반적으로 작성하는 순수 Java 코드와 다르게

ReservationService 클래스를 new 키워드로 직접 생성하면서 생성자 인자에 ReservationRepository를 또 다시 new 키워드로 생성해 넣어줄 필요가 없는 것이다.

 

어떻게 이런 것이 가능할까?

바로 제어권을 스프링 컨테이너에게 위임했기 때문이다. (IoC)

 

1. 스프링 컨테이너는 Bean 등록되는 어노테이션을 보고 해당 클래스를 스프링 컨테이너에 빈 이름 규칙에 맞게 저장한다.

-> ReservationService는 reservationServcie라는 이름으로 스프링 컨테이너에 저장된다.

 

2.@Autowired가 붙은 필드의 타입을 보고 스프링 컨테이너에서 해당 타입으로 저장된 Bean을 찾고 이 필드를 갖는 현재 클래스의 Bean을 찾아 의존성 주입해준다.

-> reservationServcie 이름의 Bean에 reservationRepository Bean을 의존성 주입해준다.

(이때 ReservationRepository도 Bean등록되었다는 것을 가정해 reservationRepository라는 이름의 Bean을 찾아 주입해주는 것이다.)

 

이런식으로 순수 자바 코드로 DI를 했던 것과 다르게

우리는 Spring의 어노테이션만을 사용해 간편하게 의존성 주입을 사용할 수 있게 된다.

문제의 예시는 다형성을 사용하지 않았지만 다형성이 적용되었다 할지라도 우리가 추가로 작업할일 없이 어노테이션하나로 OCP와 DIP를 지킬 수 있게 되는 것이다.

 

참고로 다형성이 적용되었을 때는 조금 다른 방식이 필요한데 아래 포스팅에서 설명한다.

 

[Spring] Bean이 2개 이상일 때 특정 Bean 선택 방법 (다형성 의존성 주입)

설명에 사용할 예시 코드 온라인 쇼핑몰에서 정률 할인과 정액 할인이 있다고 가정해보자. 그렇다면 다음과 같이 할인 정책 인터페이스와 각각의 구현체가 있을 것이다. 아래는 결제를 도와주

hstory0208.tistory.com