[ loC/DI ] 직접 설계해보는 SOLID 5원칙 : Bean 으로 수동으로 의존관계 주입하기

msung99·2023년 1월 23일
8

Spring

목록 보기
16/19
post-thumbnail

시작에 앞서

본 포스팅은 제 지난 포스팅 시리즈 예제로 이해하는 SOLID 설계원칙, 그리고 스프링 DI 컨테이너의 등장 에 이어서 진행되는 내용입니다. 이전 포스팅을 읽고 오시는 것을 권장드립니다! 이번 포스팅에서는 SOLID 5설계원칙을 지킬 수 있도록 스프링에서 제공해주는 스프링 빈 @Bean, 그리고 싱글톤이라는 것에 대해 다루어보고자 합니다.

객체지향 시리즈 포스팅 진행현황

현재 진행중인 포스팅 내용은 아래와 같습니다. 그 중 3번째 포스팅을 지금 진행해볼까합니다!

  • 객체지향을 아는척하지 말자 : 오해하고 있었던 객체지향의 정체
  • 예제로 이해하는 SOLID 5원칙, 그리고 스프링 DI 컨테이너의 등장
  • 직접 만들어보며 이해하는 SOLID 원칙과 DI 설계 : 수동으로 직접 의존관계 주입해보기 (현재 포스팅)
  • 싱글톤(SingleTon) : 왜 스프링 컨테이너를 써야할까?
  • 컴포넌트 스캔과 @Autowired 의 메커니즘 : 필요성에 대해
  • 알면 도움될 컴포넌트 스캔의 다양한 대상들과 DI 에 대한 해결방법

OCP, DIP 를 지키기위한 기능 : DI 컨테이너

지난번에 SOLID 5설계원칙 중에서 OCP, DIP 원칙은 순수자바코드 만으로는, 즉 다형성만으로는 지킬 수 없다고 했었습니다. 이 문제를 해결하도록 스프링에서는 DI(Dependency Injecion) 컨테이너라는 것을 제공해준다고 했었죠?

DI 컨테이너를 통해 어떤 장점을 제공받지?

저희는 DI 컨테이너를 통해 외부에서 각 인터페이스(역할)간의 연관관계를 설정하면서 OCP, DIP 를 지킬 수 있습니다. 또한 다음과 같은 장점들을 얻을 수 있게됩니다.

  • 클라이언트의 코드의 변경없이 기능 확장이 가능합니다. 즉, 쉽게 부품을 교체하듯이 개발이 가능해지죠.

  • 서비스에서 기술스택이나 정책등이 늦게 정해지더라도, 간단하게 인터페이스만 구현해놓고 나중에 구현 클래스 중 하나만 선택해서 끼워주면 OCP, DIP를 위반하는 문제가 없어집니다.

이게 무슨 말인지 아직 혼동이 올 수 있습니다. 지금부터 직접 코드를 구현해보면서 DI 컨테이너란 무엇인지를, 또 그를통해 얻어내는 장점들을 직접 이해해봅시다.


도메인 설계 : 문제발생 상황

저희는 다음과 같은 상황을 가정해보고, 이를 DI 컨테이너를 통해 해결해나가는 과정을 이해해볼겁니다.

  • 1.저희 개발자들은 외주를 맡은 상황입니다. 그런데 의뢰인이 다음과 같은 서비스 정책중 어떤것을 적용할 지 고민중인 상태입니다.

  • 2.의뢰인은 2개의 차종중(소나타, 스타랙스) 중에서 어떤 차종을 서비스에 적용시킬 것인지 고민중인 겁니다. (두 차량에 대한 것을 모두 서비스에 적용시키는 것이 아닌, 둘중에서 하나만 서비스에 적용시키고 싶은 경우입니다.)

    • 소나타가 선택될 경우 : 클라이언트로 부터 자동차 엑셀을 밟는 요청이 들어왔을때, 차량의 속도가 +20 증가하도록 구현해야합니다.

    • 스타랙스가 선택될 경우 : 마찬가지로 요청이 들어왔을때, 속도가 +10 증가하도록 구현해야합니다.

    1. 의뢰인은 서비스의 나머지 정책들은 고민하는 것 없이, 모든 기능을 확정한 상태입니다. 저희 개발자는 소나타, 스타랙스에 대한것만 고민하면 되겠죠?

설계상황 가정

저희 개발자는 역할과 구현을 철저히 구분하여, 도메인 설계를 진행해야하는 상황입니다. 따라서 위와 같이 도메인을 설계해볼 수 있습니다.

각 인터페이스끼리, 즉 역할끼리 원활히 협력할 수 있도록 설정정보를 주입해야 하는데, 이때 OCP, DIP 원칙은 꼭 지킬 수 있도록 외부에서 의존관계를 주입해야 하는 상황입니다.

  • 몰론 이 서비스에서는 구현할 기능이 정말 적기 때문에 OCP, DIP 를 위반해도 큰 문제는 없습니다. 하지만 이렇게 애매모호한 기능들에 대한 의뢰가 더 발생한다면, SOLID 원칙을 지켜야 더 유연성이 좋아지겠죠? 만일 지키지 않는다면 의뢰인이 서비스에 적용시킬 정책을 알려주기 전까지 계속 기다리는 수밖에 없을겁니다. 그에따라 코드 변경도 엄청나게 일어나야하겠죠. 반대로 OCP, DIP 를 지킨다면 코드변경이 거의 없을겁니다.

폴더 및 패키지 구성은 위와 같이 진행해줬습니다.


UserService

우선 첫번째로 클라이언트의 요청을 받을 수 있는 UserService 를 구현해줬습니다.

public interface UserService {

    void signUp(User signUpReq);  // 회원가입 (User 객체를 생성)
    void buyCar(Car car); // Car 객체를 생성
    Car getCarStatus(int carIdx);  // 구매한 차량의 현상태를 조회
    void pressAccelerator(int carIdx);  // 차량의 엑셀을 밟아서 속도 증가시키기
}

다음으로 그에대한 구현 클래스입니다. 우선 전체적인 코드를 먼저 살펴보자면 아래와 같습니다. 인터페이스와 클래스를 구분하여, 역할과 구현을 적절하게 분배한 것이죠?

public class UserServiceImpl implements UserService{

    private final CarService carService;
    private final UserRepository userRepository;

    public UserServiceImpl(CarService carService, UserRepository userRepository){
        this.carService = carService;
        this.userRepository = userRepository;
    }

    @Override // User 객체를 생성
    public void signUp(User signUpReq){
        userRepository.makeUser(signUpReq);
    }

    @Override // Car 객체를 생성
    public void buyCar(Car car) {
        carService.makeCar(car);
    }

    @Override // Car 객체를 carIdx 값을 통해 조회
    public Car getCarStatus(int carIdx) {
        return carService.findCarStatus(carIdx);
    }

    @Override // 자동차 엑셀 밟기
    public void pressAccelerator(int carIdx) {
        carService.pressAccelerator(carIdx);
    }
}

이때 유의해서 지켜볼 부분은 아래와 같습니다.

public class UserServiceImpl implements UserService{

    private final CarService carService; // 추상화(인터페이스)만 적절하게 의존하고 있다!
    private final UserRepository userRepository;
    
    ...
}

만일 UserServiceImpl 이 아래와 같이 누구를 구현하는지 명시되었다면 어떤것일까요? 이건 지난 포스팅에서도 말씀드렸듯이, OCP 와 DIP 에 위반되는 것입니다. 만일 UserRepository 의 구현 클래스를 바꾸고 싶은경우, UserServiceImpl 안에서 직접 코드 변경을해서 구현 클래스를 바꿔줘야 하기 때문이기 때문입니다.

이는 계속 말씀드렸듯이, 스프링의 DI 컨테이너를 통해 해결이 가능합니다. DI 컨테이너를 어떻게 구현할 수 있는지는 조금뒤에 설명드리겠습니다. 지금 여기서는 DIP, OCP 를 지키도록 인터페이스만 필드로 가지고 있다는것만 인지하고 있도록 합시다.

public class UserServiceImpl implements UserService{

    private final CarService carService = new CarServiceImpl();
    private final UserRepository userRepository = new UserRepositoryImpl();
    
    ...
}

UserRepositoryImpl, CarServiceImpl, Car 클래스

다음으로는 UserServiceImpl, CarServiceImpl 구현 클래스입니다. 인터페이스 코드는 큰 내용이 없으니, 생략하도록 하겠습니다.

UserRepositoryImpl

  • makeUser 메소드 : 임의로 데이터베이스를 구축하기 위해, 자바의 컬렉션 Map 을 활용해서 저장하는 방식을 구현했습니다. (실무에서는 이런 식으로는 DB 가 구현되지 않겠죠?)
public class UserRepositoryImpl implements UserRepository{

    private static Map<Integer, User> userList = new HashMap<>();

    @Override  // User 데이터를 DB 에 저장
    public void makeUser(User signUpReq){
        userList.put(signUpReq.getUserIdx(), signUpReq);
    }

    @Override  // userIdx 값을 가지는 User 데이터를 DB에서 조회
    public User findUserById(int userIdx){
        return userList.get(userIdx);
    }
}

Car

  • 다음으로는 Car 클래스입니다. 이때 Car 의 필드중 curSpeed 를 봅시다. curSpeed 는 현재 차량의 속도를 나타내는 필드인데, UserServiceImpl 에서 pressAccelerator 메소드를 통해 요청을 받으면 차종에 따라 +10 또는 +20 증가하는 것입니다.
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Car {
    int carIdx;
    String carName;  
    int curSpeed;  // 현재 속도 : 엑셀을 밟는 요청이 들어올때마다, 차종에 따라 다른값으로 일정량 증가
    int userIdx;   // 어떤 유저의 차량인지 (= 외래키(Foreign key) 와 유사)
}

CarServiceImpl

  • pressAccelerator : 엑셀을 밟았을때 속도가 증가하는 것입니다. 계속 말씀드렸듯이, 어떤 차종으로 구현 클래스가 선택되는가에 따라서 속도가 +10 또는 +20 이 추가되는 것입니다.
public class CarServiceImpl implements CarService{

    private final CarRepository carRepository;

    public CarServiceImpl(CarRepository carRepository){
        this.carRepository = carRepository;
    }

    @Override  // Car 데이터 DB 에 저장 
    public void makeCar(Car car) {
        carRepository.makeCar(car);
    }

    @Override  //  엑셀을 밟았을때 속도가 증가하도록 하는것
    public void pressAccelerator(int carIdx) {
        carRepository.incrementSpeed(carIdx);
    }

    @Override   // carIdx 값에 해당하는 차량의 현 상태 조회
    public Car findCarStatus(int carIdx) {
        return carRepository.getCarInfo(carIdx);
    }
}

즉, 다시말해 아래와 같은 CarRepository 필드가 구현 클래스로 어떤것이 구현되는가에 따라 추가되는 속도가 달라지는 것입니다.

 private final CarRepository carRepository;

구현클래스로 어떤걸 선택할까? : SonarTarRepository, StarRexRepository

직전에 위에서 살펴본 것 처럼, 어떤 구현 클래스를 택하는가에 따라 차량 속도의 증가량이 달라질겁니다.

 private final CarRepository carRepository;

즉 구현 클래스로 아래처럼 SonarTarRepository 클래스를 택하거나

private final CarRepository carRepository = new SonarTarRepository();

또는 StarRexRepository 클래스를 선택해줄 수 있을겁니다.

private final CarRepository carRepository = new StarRexRepository();

각 구현클래스에 대한 속도 증가와 관련한 메소드만 간단하게 살펴보자면 아래와 같습니다.

SonarTarRepository 의 속도증가 메소드

 @Override
    public void incrementSpeed(int carIdx) {
        Car car = carList.get(carIdx);
        car.curSpeed += 20;
    }

StarRexRepository 의 속도증가 메소드

@Override
    public void incrementSpeed(int carIdx) {
        Car car = carList.get(carIdx);
        car.curSpeed += 10;
    }

문제점 : 이러면 OCP, DIP 를 위반할텐데?

그런데 문제가 발생했습니다. CarRepository 에 대한 구현 클래스로 어떤것을 선택할지 직접 코드를 수정해야하는 것이라면 OCP, DIP 를 위반하기 때문입니다.
즉 new StarRexRepository(); 와 같은 코드를 직접 개발자가 일일이 입력하자니, SOLID 원칙을 위반하게 됩니다. 따라서 이를 해결하도록 등장하는 것이 뭐다? 바로 계속 말씀드렸던 스프링의 DI 컨테이너이죠.

// CarRepository 의 구현클래스를 SonarTarRepository 에서 StarRexRepository 로
// 직접 바꿔주고 싶은 경우, 아래처럼 직접 코드 변경이 일어나야합니다.

// 변경전
private final CarRepository carRepository = new StarRexRepository();
// 변경 후(직접 코드를 수정)
private final CarRepository carRepository = new StarRexRepository();

스프링의 DI, loC 컨테이너

드디어 DI 컨테이너와 스프링 빈(Bean) 에 대해 설명드릴 수 있는 단계까지 오게 되었군요. 이들이 무엇인지 알아봅시다.

제어의 역전(loC)

프로그램의 제어흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것을 제어의 역전(loC)이라고 합니다.

예를들어 OrderServiceImpl 은 필요한 인터페이스들을 호출하지만, 어떤 구현 객체들이 실행될지 모르게 되는 것입니다. 내부에서 관계를 주입하는 것이 아닌, 외부에서 주입해주기 때문이죠.

loC 컨테이너, DI 컨테이너

내부가 아닌, 외부에서 객체를 생성하고 관리하면서 의존관계를 연결해주는 것들을 loC 컨테이너 또는 DI 컨테이너라고 부릅니다.

최근에는 loC 컨테이너라는 말보다는 DI 컨테이너라고 라고 부릅니다.

이어서 스프링 빈(Bean) 이 무엇인지 설명드린 후에, 바로 코드로 직접 구현하며 이들을 설명드리도록 하겠습니다.


스프링 빈(Bean)

DI 컨테이너가 무엇인지 감을 잡았다면, 스프링에서 제공하는 빈(Bean) 이라는 것이 무엇인지 꼭 아셔야합니다. 빈을 통해 의존관계가 주입되고, 관리되기 때문이죠.

  • DI 컨테이너, 즉 스프링 컨테이너에서는 어떤 방식으로 의존관계를 주입하고 객체들을 관리할까요? 바로 빈(Bean) 이라는 것을 통해 관리합니다.

  • 저희가 외부에서, 즉 스프링 컨테이너에서 설정해준 환경 설정정보를 가지고 @Bean 어노테이션이 붙은 것들을 Bean 객체로 생성해서 스프링 컨테이너에다 넣어주고 관리해줍니다.


빈(Bean) 을 통해 의존관계 주입하기

  • 아래와 같이 스프링은 @Configuration 이라는 어노테이션이 붙은 클래스를 기반으로 스프링 컨테이너의 Bean 객체들을 생성합니다.

  • 보시듯이 @Bean 어노테이션이 붙은 것들은 4개입니다. 따라서 4개의 Bean 객체가 생성되고, 외부에서 의존관계가 자동으로 주입되는 모습을 볼 수 있습니다.

@Configuration
public class BeanConfig {

    @Bean
    public UserService userService() {
        return new UserServiceImpl(carService(), userRepository());
    }

    @Bean
    public UserRepository userRepository(){
        return new UserRepositoryImpl();
    }

    @Bean
    public CarService carService(){
        return new CarServiceImpl(carRepository());
    }

    @Bean
    public CarRepository carRepository(){
        return new SonarTarRepository();
    }
}

스프링 컨테이너 내부는 어떻게 생겼을까? : Bean 생성과정

코드만 보고는 잘 아직 이해가 안될 수 있습니다. 좀 더 자세히 뜯어보면 아래와 같습니다!

1. 스프링 컨테이너에 빈 생성

스프링 컨테이너안에는 아래처럼 Bean 저장소가 따로 있는데, key-value 쌍으로 빈들이 생성됩니다. 이때 key, value 에 저장되는 것은 다음과 같습니다.

  • key (Bean 이름) : @Bean 이 붙은 메소드의 이름
  • value : 빈 객체가 저장. 해당 메소드에서 리턴하는 객체의 타입을 기반으로 생성 및 저장됨

2. 설정정보(코드) 에 기반하여 컨테이너의 각 Bean 끼리 의존관계 생성(의존관계 주입)

그 다음으로는 스프링 컨테이너에 생성된 각 빈끼리 연관관계가 맺어짐으로써 의존관계가 주입되는 것입니다. 어쩌면 당연한 말이지만, 의존관계는 저희가 @Configuration 어노테이션이 붙은 클래스 안에 기입한 설정 정보를 기반으로 주입이 됩니다.

  • 생성자 주입 : 이때 각 빈을 잘 살펴보면 return 을 할때 생성자가 호출되면서 의존관계가 주입되는 모습을 볼 수 있습니다. 생성자를 통해 의존관계가 주입된다고 하여 이 방식을 생성자 주입이라고도 부릅니다.


마치며

이로써 저희는 SOLID 설게 5원칙을 지키며 어떻게 의존관계를 외부에서 주입할 수 있는지를 코드와 예제를 분석하며 자세하게 알아봤습니다. 다소 어려웠던 주제일 수 있는데, 햇갈리거나 모르는점이 있으시다면 댓글로 남겨주세요!

이번 블로깅이 현재 포스팅을 보시는 여러분들에게 도움이 되셨으면 하는 바람입니다 😉


참고

docs.spring.io : Bean (Spring Framework 6.0.4 API)
docs.spring.io


추후 포스팅 계획 : 계속 이어지는 내용들

앞서 말씀드렸지만, 저는 객체지향에 대한 내용을 이 포스팅을 마무리로 끝내지 않습니다. 다음 포스팅에서는 스프링의 컴포넌트 스캔, 자동 의존관계 주입(Autowired) 에 대해 알아보겠습니다!

profile
꾸준히 성장하는 과정속에서, 제 지식을 많은 사람들과 공유하기 위한 블로그입니다 😉

1개의 댓글

comment-user-thumbnail
약 7시간 전

설명 정말 잘하시네요. 개념을 다시 복습하는 마음으로 정독했습니다. 양질의 글 감사합니다. :)

답글 달기