스프링 DI(Dependency Injection) 예시로 완벽 이해

전홍영·2024년 12월 9일
0

Spring

목록 보기
22/26

스프링의 3대 요소라고 불리는 IoC/DI, PSA, AOP 중 DI에 대해서 예시를 통해서 완벽 이해해보자.

왜 DI(의존성 주입)가 필요할까?

DI가 왜 필요할까? 그냥 객체를 직접 의존 객체를 생성하면 되는데 굳이 생성자나, setter를 이용해서 의존하는 객체를 주입할까?

이유는 바로 변경의 유연함 때문이다. 다음 코드를 봐보자.

public class ImageRegisterService {
    private ImageDao imageDao = new ImageDao();

	...
}

public class ImageUpdateService {
    private ImageDao imageDao = new ImageDao();

    ...
}

이미지를 등록하는 ImageRegisterService이다. 의존하는 객체로 ImageDao를 직접 생성한다. 그리고 이미지를 수정하는 ImageUpdateService가 있다. 이 ImageUpdateService 또한 ImageDao를 직접 생성하고 있다. 만약 후에 요구 조건이 바뀌어서 기존의 ImageDao가 아닌 CachedImageDao로 수정해야 하는 일이 생긴다면 어떻게 해야 할까?

그렇게 된다면 다음과 각각의 서비스에서 직접 생성하는 부분을 모두 수정해주어야 할 것이다.

public class ImageRegisterService {
    private ImageDao imageDao = new CachedImageDao();

	...
}

public class ImageUpdateService {
    private ImageDao imageDao = new CachedImageDao();

    ...
}

만약 ImageDao를 의존하고 있는 서비스가 100개라면 100개 모두 수정해주어야 한다. 이는 굉장히 비효율적인 작업이 될뿐만 아니라 만약 개발자가 실수라도 한다면 큰 버그로 이어질 수도 있다.

이러한 문제점을 해결하기 위해서 DI 의존성을 주입함으로써 변경을 유연하게할 수 있게 된다.

의존성을 주입해보자.

DI의 장점은 변경의 유연성이라고 했다. 어떻게 유연해지는지 코드를 작성하면서 알아보자. 일단 Image를 등록, 수정, 조회하는 서비스 3개를 생성한다.

public class ImageRegisterService {
    private ImageDao imageDao;

    public ImageRegisterService(ImageDao imageDao) {
        this.imageDao = imageDao;
    }

    public void registerImage(Image image) {
        imageDao.insertImage(image);
    }
}

public class ImageUpdateService {
    private ImageDao imageDao;

    public ImageUpdateService(ImageDao imageDao) {
        this.imageDao = imageDao;
    }

    public void updateImage(String name, String url) {
        Image image = imageDao.selectByName(name);

        if (image == null) {
            throw new NotFoundImageException();
        }

        image.setUrl(url);

        imageDao.updateImage(image);
    }
}

public class ImageLoadingService {
    private ImageDao imageDao;

    public ImageLoadingService(ImageDao imageDao) {
        this.imageDao = imageDao;
    }

    public Image loadingImage(String name) {
        Image image = imageDao.selectByName(name);

        if (image == null) {
            throw new NotFoundImageException();
        }

        return image;
    }
}

세개의 서비스 모두 직접 ImageDao를 생성해서 의존하지 않고 생성자를 통해 의존성을 주입받고 있다. 그리고 이 세개의 서비스와 ImageDao를 조립시켜주는 조립기를 만들어보자.

public class Assembler {
    private ImageRegisterService imageRegisterService;
    private ImageUpdateService imageUpdateService;
    private ImageLoadingService imageLoadingService;
    private ImageDao imageDao;

    public Assembler() {
        imageDao = new ImageDao();
        this.imageRegisterService = new ImageRegisterService(imageDao);
        this.imageUpdateService = new ImageUpdateService(imageDao);
        this.imageLoadingService = new ImageLoadingService(imageDao);
    }

    public ImageRegisterService getImageRegisterService() {
        return imageRegisterService;
    }

    public ImageUpdateService getImageUpdateService() {
        return imageUpdateService;
    }

    public ImageLoadingService getImageLoadingService() {
        return imageLoadingService;
    }
}

Assembler는 말 그대로 조립기 역할을 한다. 각각에 서비스에 ImageDao라는 객체를 만들어서 파라미터로 넘겨주어 의존성을 주입해준다. 이렇게 완성된 조립기만 있으면 각 서비스에서 직접 의존 객체를 생성하지 서비스 객체가 생성될 때 자동으로 의존성이 주입되는 것이다. 이제 한번 조립기를 이용해서 이미지를 저장하고 수정하고 조회해보자.

@Test
void run() {
    Assembler assembler = new Assembler();

    Image image = new Image("name", "URL");

    ImageRegisterService imageRegisterService = assembler.getImageRegisterService();

    imageRegisterService.registerImage(image);

    ImageUpdateService imageUpdateService = assembler.getImageUpdateService();

    imageUpdateService.updateImage(image.getName(), "new URL");

    ImageLoadingService imageLoadingService = assembler.getImageLoadingService();

    Image loadImage = imageLoadingService.loadingImage(image.getName());

    Assertions.assertThat(loadImage.getUrl()).isEqualTo("new URL");
}

간단하게 테스트 코드를 작성해보았다. 먼저 조립기를 생성하게 되면 각각의 서비스 객체가 생성되고 알아서 의존성이 주입될 것이다. 그리고 조립기 역할을 하는 Assembler는 자신이 생성하고 조립한 객체를 리턴하는 메서드를 제공한다. 이것이 바로 조립기의 역할이다.

그럼 한 번 위에서 말했듯이 유연하게 코드를 변경해보자. ImageDao 대신 ImageDao를 상속하고 있는 CachedImageDao로 수정하게 된다면 다음과 같이 수정하면 된다.

public class Assembler {
    private ImageRegisterService imageRegisterService;
    private ImageUpdateService imageUpdateService;
    private ImageLoadingService imageLoadingService;
    private ImageDao imageDao;

    public Assembler() {
        imageDao = new CachedImageDao(); //이 부분만 수정해주면 된다.
        this.imageRegisterService = new ImageRegisterService(imageDao);
        this.imageUpdateService = new ImageUpdateService(imageDao);
        this.imageLoadingService = new ImageLoadingService(imageDao);
    }

    ...
}

수정한 곳은 단 1곳 생성자에 new ImageDao() 대신 new CachedImageDao()밖에 없다. 기존의 직접 의존 객체를 생성해서 사용할 때보다 훨씬 유연하게 변경이 가능해지는 것을 알 수 있다.

그럼 Spring에서 DI는 어떻게 사용해?

지금까지 DI를 이용해 의존 객체를 주입하는 방법에 대해 알아봤다. 그리고 객체를 서로 연결해주는 조립기에 대해서도 살펴보았다. 바로 이 조립기의 역할이 스프링하는 역할이다.

스프링은 앞서 구현해본 조립기와 유사한 역할을 한다. Assembler 클래스는 필요한 객체를 생성하고 의존성을 주입한다. 또한 객체를 제공하는 기능을 정의하고 있다. 차이점이 있다면 Assembler는 ImageDao나 ImageUpdateService처럼 특정 타입의 클래스만 생성한 반면 스프링은 범용 조립기라는 점이다.

스프링을 이용해서 의존성을 주입해보자. 일단 스프링은 조립기 역할을 한다고 했으니 조립기를 만들어야 한다. 스프링에서 조립기 역할을 할 수 있도록 하는 것이 @Configuration이다. @Configuration가 붙은 클래스는 스프링의 설정 클래스라고 생각하면 된다. 그리고 @Bean이 붙은 메서드는 스프링 컨테이너에 한 개만 생성되어 관리된다.

@Configuration
public class AppCtx {
    @Bean
    public ImageDao imageDao() {
        return new ImageDao();
    }

    @Bean
    public ImageLoadingService imageLoadingService() {
        return new ImageLoadingService(imageDao());
    }

    @Bean
    public ImageUpdateService imageUpdateService() {
        return new ImageUpdateService(imageDao());
    }

    @Bean
    public ImageRegisterService imageRegisterService() {
        return new ImageRegisterService(imageDao());
    }
}

이렇게 스프링 설정을 통해서 생성된 객체를 가지고 위에서 테스트한 것처럼 테스트를 작성해보자.

@Test
void runWithSpring() {
    AnnotationConfigApplicationContext appCtx = new AnnotationConfigApplicationContext(AppCtx.class);

    ImageRegisterService imageRegisterService = appCtx.getBean("imageRegisterService", ImageRegisterService.class);

    Image image = new Image("name", "URL");

    imageRegisterService.registerImage(image);

    ImageUpdateService imageUpdateService = appCtx.getBean("imageUpdateService", ImageUpdateService.class);

    imageUpdateService.updateImage(image.getName(), "new URL");

    ImageLoadingService imageLoadingService = appCtx.getBean("imageLoadingService", ImageLoadingService.class);

    Image loadImage = imageLoadingService.loadingImage(image.getName());

    Assertions.assertThat(loadImage.getUrl()).isEqualTo("new URL");
}

AnnotationConfigApplicationContext 클래스는 자바 어노테이션을 이용한 클래스로부터 객체 설정 정보를 가져오는 클래스이다. 이 클래스는 BeanFactory, ApplicationContext 인터페이스를 구현한 클래스로 객체의 정보를 가져오고, 메시지, 환경 변수 등을 처리할 수 있는 기능을 한다. 이 클래스를 생성하여 객체를 생성하고 객체를 사용할 수 있게된다. 위에서 작성한 run() 테스트와 runWithSpring() 테스트의 차이점은 run()은 내가 만든 조립기를 사용했고 runWithSpring()은 스프링을 사용했다는 것이다.

직접 의존 객체를 생성해서 사용한다면 코드의 복잡도가 올라가면서 코드의 유연성이 매우 떨어지게 된다. 따라서 의존성을 주입해서 사용하게 되면 코드의 유연성이 올라가서 유지보수하기가 훨씬 수월하고 간단하다. 스프링은 이러한 의존관계를 결정해주고 주입해주는 역할을 함으로써 개발자가 직접 객체를 생성하고 관계를 의존 관계를 맺어주지 않아도 되게 해준다. 이렇게 어떤 클래스가 사용할 객체를 생성하여 의존 관계를 맺어주는 것을 IoC(제어의 역전)이라고 한다. 관계를 맺어주는 과정에서 하나의 클래스를 다른 클래스의 생성자를 통해서 주입해주는 것이 바로 DI다.

참고 : 스프링 프로그래밍 입문 5 - 최범균
함께보면 좋은 글 : 의존 관계 주입(생성자? 필드?)
예제 코드

profile
Don't watch the clock; do what it does. Keep going.

0개의 댓글