왜 그런걸까? 🤔🤔🤔🤔

[Spring] 의존성 자동 주입 파헤치기

무모한 폴라베어 2025. 1. 6. 02:36

 

 

올해 초부터 얼마전까지 개발했던 프로젝트는 스프링 환경에서 동작하는 프로젝트였는데, 개발자로 취업 후 첫 투입된 프로젝트라서 처음보는게 꽤 많았다. 그 중에서 좀 인상깊었던거는 팀원들 각자 구현해야하는 기능이 달라서 각자 컨트롤러를 만들어야 했는데 그 컨트롤러안에서 의존성주입 방식이 모두 달랐던 것이다.

 

나는 국비학원 다닐때도 그랬고 사이드 프로젝트를 만들때도 생성자를 통한 의존성 주입이 정답이라고 생각했기 때문에 내가 만든 컨트롤러는 모두 생성자를 통해 의존성을 주입했는데 이런 방식으로 하는 사람이 나밖에 없다는 것에 좀 충격을 먹었다.😨 그래서 이번 기회에 의존성주입 방식에 따른 차이점을 좀 정리해보기로 했다.


의존성 자동 주입은 왜 하는걸까?

의존성 주입의 원리를 알아보려면 스프링의 빈 컨테이너와 빈에 대해 알아야한다. 스프링컨테이너는 스프링에서 사용하는 빈(Bean) 들을 관리해주는 객체이다. 스프링 빈은 간단히 말해서 스프링에서 사용되는 객체라고 생각하면 된다. 우리가 스프링을 개발할때 사용하는 @Repository, @Service, @Controller 어노테이션을 붙이는 클래스들 모두 스프링 빈이라고 생각하면 된다. 

 

그런데... 만약 클라이언트가 전체 글을 조회할 수 있는 엔드포인트를 만들었다고 가정하자. 이 엔드포인트는 BoardController 로 요청을 받아서 BoardService 에서 서비스 로직을 처리하고 BoardRepository 에서 데이터를 가져온다고 할때, 10명의 클라이언트가 요청을 한다면 각 레이어의 클래스 객체를 10씩 총 30 개의 객체가 요청마다 만들어질까? 

 

이런 문제가 생기는 것을 방지하기 위해 스프링컨테이너가 애플리케이션 구동시 초기화되면, 빈으로 등록해야할 객체들을 찾아서 객체들을 싱글톤으로 생성하고 스프링 컨테이너가 관리하게 된다. 개발자가 객체를 직접 생성하는 것이 아니라 애플리케이션이 객체를 생성하기 때문에 제어권이 역전되었다 라고 해서 이것이 IOC - Inversion Of Control 이라고 부른다.

 

문제는 여기서 생긴다. 

public class SampleController {

    private final SampleService sampleService;

    @GetMapping("/doSomething")
    public String doSomething() {
        return sampleService.doSomething();
    }
}

public class SampleService {

    private StudentRepository studentRepository;
    private BookRepository bookRepository;

    public String doSomething() {
        return SampleService.class.getSimpleName() + studentRepository.findAll().toString();
    }
}

public class StudentRepository {
    public List<String> findAll() {
        return List.of("Student1", "Student2", "Student3", "Student4");
    }
}

 

스프링에서는 IOC 로 인해 객체 생성을 스프링이 자동으로 해준다고 했다. 이는 일반적으로 자바 생성자를 통해 객체를 생성하는 과정과는 사뭇 다른 과정이다. 생성자를 통해 객체를 직접 생성하든 스프링컨테이너가 직접 객체를 생성하든 SampleService 객체를 생성하려면 StudentRepository 와 BookRepository 를 파라미터로 넘겨서 의존성을 주입시키는 과정은 필요할 것이다. 바로 이때 의존성 자동 주입이 필요한 것이다. 

 

 


 

1. @Autowired 를 사용한 의존성 자동 주입

첫번째 방법은   @Autowired  를 사용한 방식이다. 그리고 이  @Autowired  를 사용한 방식은 또 다시 3가지로 나눌 수 있다. 

 


1-1. 필드 주입

 @Autowired  를 활용한 방식중 그 첫번째 방식은 필드에  @Autowired  를 붙이는 것이다. 

@Service
public class SampleService {

    @Autowired
    private StudentRepository studentRepository;
    @Autowired
    private BookRepository bookRepository;

    public String doSomething() {
        return SampleService.class.getSimpleName() + studentRepository.findAll().toString();
    }
    
}

 

가장 일반적인 방법이라고 할 수 있다.  @Autowired  를 붙이고 어플리케이션 시작하면 스프링 컨테이너가 컴포넌트 스캔을 하면서  @Component ,  @Service  ,  @Controller  ,  @Repository   등의 어노테이션이 붙은 클래스들을 빈으로 등록하고  @Autowired  가 붙은 필드에 맞는 타입을 찾아서 자동으로 주입한다. 사용하기 편리하지만 두가지 단점이 있다.

 

 

단점 1.  테스트가 제한적이다.

스프링이 자동으로 의존성을 주입하기 때문에 만약 테스트할때  다른 의존성을 넣는다든지 같은 타입이지만 더미 데이터를 넣은 의존성을 넣고 싶은 경우 어떻게 할 방법이 없다. 코드로 보면 훨씬 이해가 쉽다. 

 

@Service
public class StoreService {
    
    @Autowired
    private FishRepository fishRepository;

    public List<String> getProduct() {
        return fishRepository.findAll();
    }
}

@Repository
public class FishRepository implements TestRepository{

    List<String> fishes = new ArrayList<>(
    				Arrays.asList("Salmon", "Amberjack", "Mackerel", "Snapper"));

    @Override
    public List<String> findAll() {
        return fishes;
    }
}

 

이 코드를 테스트 코드를 통해 다른 의존성을 넣을 수 있을지 검증해보자.

 

@SpringBootTest
class StoreServiceTest2 {

    @Autowired
    private StoreService storeService;

    @Test
    void injectDummyDependency() {

        FishRepository newFishRepository = new FishRepository();
        newFishRepository.fishes.add("Tuna");
        
        List<String> fishes = storeService.getProduct();
        Assertions.assertThat(fishes).contains("Tuna");
    }

}

 

이 코드는 통과할 수 있을까? 당연히 통과하지 못한다. 왜냐하면 테스트코드에서 만들 새로운 FishRepository 객체의 의존성을 StoreService 에 주입할 방법이 없기 때문이다. 지금은 레포지토리가 단순한 자바 코드로만 동작하지만 실제 DB 와 커넥션을 맺고 데이터를 조회하는 경우에 매번 DB 의 데이터와는 상관없이 단순히 StoreService 의 기능만 테스트한다면 굳이 DB 와 커넥션을 맺을 필요가 없기 때문에 가짜 객체를 사용하는 방법을 시도할 수 있는데 필드 주입은 그런 경우에 대응할 방법이 없다.

 

단점 2. 스프링 없이는 테스트할 수 없다. 

채소데이터를 테스트하려던 당신은 포기하고 생선데이터만 잘 나오는지 테스트해보기로 결정하고 테스트 코드를 만들어서 실행해봤다.

class StoreServiceTest {

    @Autowired
    private StoreService storeService;

    @Test
    void isValidFishData() {
        List<String> productList = storeService.getProduct();
        Assertions.assertThat(productList).contains("Salmon", "Amberjack", "Mackerel", "Snapper");
    }

}

 

하지만 결국 이런 에러를 보게될것이다. 

 

StoreService 의 의존성을 제대로 주입했는데 왜 NPE 가 발생했을까? 그건 의존성 자동주입을 할때 스프링 컨테이너가 관리하는 빈에서 타입에 맞는 것을 자동으로 넣어주는데, 이 테스트 코드만 실행하면 스프링 컨테이너가 만들어지기 전이기 때문에 의존성을 주입해줄 스프링 컨테이너가 없기 때문이다. 이 테스트가 정상 동작하게 하려면 클래스 위에  @SpringBootTest  를 붙이면 된다. 그러면 스프링을 실행시켰을때와 비슷한 로그가 출력되면서 테스트가 통과하는 것을 볼 수 있다.

 

만약 지금처럼 DB 에서 데이터를 조회하는 기능이 아니라 서비스 클래스 내에서만 사용할 기능을 테스트 한다면 스프링을 띄우는 일까지는 필요하지 않을것이다. 하지만 필드주입으로 의존성을 주입했기 때문에 매번 스프링을 띄우지 않고서는 테스트할 수 없다. 실무를 하기전에는 실행중인 어플리케이션을 중지시키고 재실행하는것이 그렇게 느리지 않았는데 처음으로 투입된 프로젝트는 재실행을 하는데 길때는 1-2 분까지 걸린적도 있었다. 😖😖 이런 상황에서는 필드 주입이 적절하지 않은 방법일 수 있다.


1-2. Setter 주입

 @Autowired  를 사용하는 두번째 방법은 수정자 주입이라고도 부르는 Setter 주입이다. 

 

@Service
public class StoreService {

    private FishRepository fishRepository;

    @Autowired
    public void setFishRepository(FishRepository fishRepository) {
        this.fishRepository = fishRepository;
    }

    public List<String> getProduct() {
        return fishRepository.findAll();
    }
}

 

필드주입과 마찬가지로 컴포넌트 스캔을 하면서 Setter 를 사용하여 자동으로 의존성을 주입해준다. 이 방법은 그래도 필드주입의 단점인 의존성을 바꿔서 테스트할 수 있기 때문에 해결방법이 될 수 있지 않을까?

 

단점 1. 외부에서 변경의 여지가 생긴다. 

Setter 주입이 그 해결책이 되려면 Setter 메서드 자체가 public 으로 선언되어야한다. 이로인해 외부에서 수정할 수 있는 여지가 생기게 되기 때문에 에러 발생의 소지가 있다. 

 

단점 2. 스프링 없이는 테스트할 수 없다.

똑같이  @Autowired  를 사용했기 때문에  동일한 단점을 가지고 있다.

 


 

1-3. 생성자 주입

세번째 방법은 생성자에   @Autowired  를 붙이는 것이다.

 

@Service
public class StoreService {

    private FishRepository fishRepository;

    @Autowired
    public StoreService(FishRepository fishRepository) {
        this.fishRepository = fishRepository;
    }
    
    //...
}

 

위의 두 방법과 다르게 이 방법만이 가지는 장점은 의존성을 주입할 필드를 final 로 선언할 수 있다는 것이다. final 로 필드를 선언함으로써 객체의 불변성을 보장할 수 있고 객체가 초기화할때 같이 의존성이 주입되므로 Setter 처럼 여러번 의존성 주입을 할 필요도 없다. 그렇다면 다른 의존성 주입 방법들이 가진 단점들도 해결할 수 있을까?

 

class StoreServiceTest {

    @Test
    void injectDummyDependency() {

        FishRepository newFishRepository = new FishRepository();
        newFishRepository.fishes.add("Tuna");
        StoreService storeService = new StoreService(newFishRepository);

        List<String> fishes = storeService.getProduct();
        Assertions.assertThat(fishes).contains("Tuna");
    }

}

 

 

성공이다! 기존 테스트 코드에 비해 달라진 점은 생성자로 직접 객체를 생성했다는 점이다.  이로 인해서 우리 원하는 리포지토리를 직접 넣어줄 수 있기 때문에 기존 코드들에 비해 더 자유로운 테스트가 가능해졌다.

 

또한, 자바 기본 문법인 생성자를 활용했으므로 스프링없이 테스트할 수 있다. 그래서  @SpringBootTest  어노테이션을 붙이지 않아도 테스트가 정상적으로 실행하는 것을 확인할 수 있다. 

 


2. @Resource 를 사용한 의존성 자동 주입

사실 이 글을 쓰게만든 원인 제공자이다. 회사 코드를 보니  @Autowired  대신에 왠 처음 보는 어노테이션이 있길래 어떤 점이 다르고 어떤 장점이 있길래 쓴것인지 궁금해졌다. 검색헤보니 이런 특징들이 있었다. 

 

1. 스프링에서 만든것이 아닌 자바에서 제공하는 어노테이션이다.

2. 이름 기반 매칭을 기본으로 사용한다. 

 

그럼 과연 필드주입을 할때 스프링이 없이는 테스트할 수 없었던 단점을 이 어노테이션은 해줄 수 있는 것일까?

 

@Service
public class StoreService {

    @Resource
    private FishRepository fishRepository;

    //...
}

class StoreServiceTest {

    @Resource
    private StoreService storeService;

    @Test
    void isValidFishData() {
        List<String> productList = storeService.getProduct();
        Assertions.assertThat(productList).contains("Salmon", "Amberjack", "Mackerel", "Snapper");
    }

}

 

테스트 결과는 실패다. 자바에서 제공하는 어노테이션이기 때문에 의존성 주입에 필요한 객체를 찾는 방법도 스프링 없이 가능하지 않을까 하는 생각이 들었는데 그건 아니었다.  @SpringBootTest  를 붙이니 테스트 코드는 통과했다. 자바에서 제공하기 때문에 프레임워크에 종속적이지 않다는 장점이 있다고 하는데 메리트가 있는지는 잘 모르겠다. 자바로 구현됐으면서 스프링을 사용하지 않는 어플리케이션에서는 선택을 고려해볼만 한걸까?

 

그리고 이후에 서술하겠지만 생성자 주입으로 의존성을 주입했을 경우 생성자가 단 하나만 존재할 경우 스프링이  @Autowired  를 자동으로 붙여준다. 하지만 이 어노테이션을 쓴다면 매번 붙여줘야하는 번거로움이 발생할 수는 있겠다. 회사에서 이 어노테이션을 사용한 이유는..... 별 이유 없이 쓴것 같다. 

 


 

그래서 뭘 사용하면 좋을까?

사실 정답은 생성자 주입이다. 생성자 주입은 Lombok 하고 함께 사용하기에도 편하고 불변성도 제공하는 반면, 필드주입은 스프링에서 사용하지 말라고 권장하고 있고 Setter 주입은 위험도가 큰 방법이기 때문이다. (생성자 주입 방식은 순환 참조 오류도 방지해준다고 한다.)  하지만 개개인의 개발환경에 맞는 방법을 사용하는 것이 맞다고 생각한다.

 

필드주입을 스프링에서 권장하지 않고 있고 테스트하기 어려운 방법이라고 하는데 만약 테스트코드를 작성하지 않는 조직이라면? 아마 단점들이 크게 와닿지 않을 것이다. 어찌됐든간에 현재 코드는 이상없이 돌아가기 때문이다. 😂 하지만 일반적인 경우에는 생성자 주입을 권장한다. 


마치며

이 글을 쓴 이유는 몰랐던 어노테이션이 궁금했던 것도 있지만 항상 생성자 주입이 정답이라고 생각했던 내가 필드주입을 하는 팀원들이 있을때 어떤 근거로 설득할 수 있을까 생각하기 위함도 있다. 오랜만에 복습도 하고 공부고 하니 각 방식에 대한 장단점은 명확히 알겠는데 '그런데 우리 테스트코드 안짜는데 굳이 생성자 주입 방식을 사용해야하나요?' 라고 반문한다면 설득은 못할지도 모르겠다.😇😇