[SpringBoot] DI 방법 (+생성자 주입을 사용해야하는 이유)

나른한 개발자·2026년 1월 6일

f-lab

목록 보기
15/46

의존성/결합도

의존성이란 간단히 다른 객체나 함수를 사용하는 것을 말한다. 어떤 두 클래스가 강하게 결합된 상태가 아니어도 사용하기만해도 의존하는 것이라고 볼 수 있다. 그리고 각 모듈들이 얼마나 강하게 결합되어있는지를 결합도(coupling)라고 부른다. 일반적으로 소프트웨어에서는 결합도가 약한 것이 좋다고 평가한다. 의존성과 결합도를 낮추기 위한 기법중 하나가 의존성 주입이다.

DI(Dependency Injection)

DI는 객체를 new를 통해 직접 생성하지 않고 외부로부터 주입받는 것을 의미한다. 스프링에서는 컨테이너가 객체를 생성할 때 의존성을 주입해준다.

스프링은 다양한 방식으로 DI를 지원한다.

  • 생성자 주입
  • setter 주입
  • 필드 주입

💡 TIP
스프링 같은 프레임워크를 사용해야한 의존성 주입을 할 수 있는 것은 아니다. 외부에서 객체나 값을 넣어주는 것 모두 의존성 주입이기 때문에, 파라미터로 전달하거나 생성자에서 의존성을 받는 방법 모두 의존성 주입이다.

생성자 주입

@Service
public class UserService {

    private UserRepository userRepository;
    private MemberService memberService;

    @Autowired
    public UserService(UserRepository userRepository, MemberService memberService) {
        this.userRepository = userRepository;
        this.memberService = memberService;
    }
    
}

생성자 주입(Constructor Injection)은 생성자를 통해 의존 관계를 주입하는 방법이다. 생성자 주입은 생성자의 호출 시점에 1회 호출 되는 것이 보장된다. 그렇기 때문에 주입받은 객체가 변하지 않거나, 반드시 객체의 주입이 필요한 경우에 강제하기 위해 사용할 수 있다. 또한 Spring 프레임워크에서는 생성자 주입을 적극 지원하고 있기 때문에, 생성자가 1개만 있을 경우에 @Autowired를 생략해도 주입이 가능하도록 편의성을 제공하고 있다.

setter 주입

@Service
public class UserService {

    private UserRepository userRepository;
    private MemberService memberService;

    @Autowired
    public void setUserRepository(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Autowired
    public void setMemberService(MemberService memberService) {
        this.memberService = memberService;
    }
}

필드 값을 변경하는 Setter를 통해서 의존 관계를 주입하는 방법이다. Setter 주입은 생성자 주입과 다르게 선택적인 의존성이나 주입받는 객체가 변경될 가능성이 있는 경우에 사용한다. (실제로 변경이 필요한 경우는 극히 드물다.)

@Autowired로 주입할 대상이 없는 경우에는 오류가 발생한다. 위의 예제에서는 UserRepository, MemberService 빈이 존재하지 않을 경우에 오류가 발생하는 것이다. 주입할 대상이 없어도 동작하도록 하려면 @Autowired(required = false)를 통해 설정할 수 있다. 선택적인 의존성이기 때문에 널 체크도 필요하다.

스프링 초기에는 수정자 주입이 자주 사용되었는데, 그 이유는 바로 getX, setX 등 프로퍼티를 기반으로 하는 자바 기본 스펙 때문이였다. 하지만 시간이 지나면서 점차 수정자 주입이 아닌 다른 방식이 주목받게 되었다.

필드 주입


@Service
public class UserService {

    @Autowired
    private MemberService memberService;

}

별도의 생성자 없이 필드에 의존성을 바로 주입하는 방법이다. 필드 주입을 이용하면 코드가 간결해져서 과거에 상당히 많이 이용되었던 주입 방법이다. 하지만 필드 주입은 외부에서 접근이 불가능하다는 단점이 존재하는데, 테스트 코드의 중요성이 부각됨에 따라 필드의 객체를 수정할 수 없는 필드 주입은 거의 사용되지 않게 되었다.

위에 예시를 보면 MemberService는 private 필드로 되어있다. 외부에서 접근할 수 없다. 일반적인 자바 코드(테스트 코드 등)에서 new UserService()를 호출해 객체를 만들면, 저 private 필드에 가짜 객체(Mock)를 넣고 싶어도 접근할 방법(Setter나 생성자)이 전혀 없다.

또한 필드 주입은 반드시 DI 프레임워크가 존재해야 하므로(프레임워크에 의존) 반드시 사용을 지양해야 한다. 그렇기에 애플리케이션의 실제 코드와 무관한 테스트 코드나 설정을 위해 불가피한 경우에만 이용하도록 하자.

생성자 주입을 권장하는 이유

Spring에서는 생성자 주입을 권장한다고 있는데 그 이유를 살펴보도록 하자.

  • 객체의 불변성 확보
  • 테스트 코드의 작성
  • final 키워드 작성 및 Lombok과의 결합
  • 스프링에 비침투적인 코드 작성
  • 순환 참조 에러 방지

객체의 불변성 확보

객체의 불변성이란 한번 생성된 객체가 끝까지 그 상태를 유지한다는 의미이다. 의존성 주입에서 불변성을 확보하는 것은 객체가 생성될 때 받은 의존성이 애플리케이션이 종료될때까지 바뀌지 않는 것이다.

불변성 확보가 중요한 이유는 안정성 때문이다. 만약 불변성이 확보되지 않는다면 앱 구동중에 어떤 코드나 설정 때문에 의존성이 다른 객체로 바뀌거나 null이 된다면 에러가 발생하여 서버가 죽을 수 있다. 또한 의존성이 주입되지 않아 NullPointerException 을 예방할 수 있다. 마지막으로 멀티 스레딩 환경에서 값이 변하지 않는 불변 객체는 여러 스레드가 동시 접근해도 데이터가 꼬이지 않는다.

이러한 이유 때문에 생성자 주입을 통해 변경의 가능성을 배제하고 불변성을 보장하는 것이 좋다.

테스트 코드의 작성

테스트가 특정 프레임워크에 의존하는 것은 침투적이므로 좋지 못하다. 그러므로 가능한 순수 자바로 테스트를 작성하는 것이 가장 좋은데, 생성자 주입이 아닌 다른 주입으로 작성된 코드는 순수한 자바 코드로 단위 테스트를 작성하는 것이 어렵다.

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;
    @Autowired
    private MemberService memberService;

    public void register(String name) {
        userRepository.add(name);
    }

}

위와 같이 필드 주입으로 구현했다고 해보자. UserService에 대한 테스트 코드는 아래와 같이 작성할 수 있을 것이다.

public class UserServiceTest {

    @Test
    public void addTest() {
        UserService userService = new UserService();
        userService.register("Name");
    }

}

위의 테스트 코드는 Spring 위에서 동작하지 않으므로 의존 관계 주입이 되지 않을 것이고, userRepository가 null이 되어 add 호출 시 NPE가 발생할 것이다. 이를 해결하기 위해 Setter를 사용하면 변경가능성을 열어두게 되는 단점을 갖게 된다.

반대로 테스트 코드에서 @Autowired를 사용하기 위해 스프링을 사용하면 단위 테스트가 아닐 뿐만 아니라, 컴포넌트들을 등록하고 초기화하는 시간 때문에 테스트 비용이 증가하게 된다. 그렇다고 대안으로 리플렉션을 사용하면 깨지기 쉬운 테스트가 된다.

반면에 생성자 주입을 사용하면 컴파일 시점에 객체를 주입받아 테스트 코드를 작성할 수 있으며, 주입하는 객체가 누락된 경우 컴파일 시점에 오류를 발견할 수 있다. 심지어 우리가 테스트를 위해 만든 Test객체를 생성자로 넣어 편리함을 얻을 수도 있다.

final 키워드 작성 및 Lombok과의 결합

생성자 주입을 사용하면 필드 객체에 final 키워드를 사용할 수 있으며, 컴파일 시점에 누락된 의존성을 확인할 수 있다.(final은 생성과 동시에 또는 생성자에서 반드시 초기화되어야 하기 때문이다) 반면에 다른 주입 방법들은 객체의 생성(생성자 호출) 이후에 호출되므로 final 키워드를 사용할 수 없다.

또한 final 키워드를 붙이면 Lombok과 결합되어 코드를 간결하게 작성할 수 있다. Lombok에는 final 변수를 위한 생성자를 대신 생성해주는 @RequiredArgsConstructor를 여기 에서 살펴보았다. Spring과 같은 DI 프레임워크는 Lombok과 환상적인 궁합을 보여주는데, 위에서 작성했던 생성자 주입 코드를 Lombok과 결합시키면 다음과 같이 간편하게 작성할 수 있다.

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final MemberService memberService;

    public void register(String name) {
        userRepository.add(name);
    }

}

스프링에 비침투적인 코드 작성

필드 주입을 사용하려면 @Autowired를 이용해야 하는데, 이것은 스프링이 제공하는 어노테이션이다. 그러므로 @Autowired를 사용하면 다음과 같이 UserService에 스프링 의존성이 침투하게 된다. 

프레임워크는 비즈니스 로직을 작성하는 서비스 계층에서 알아야 할 대상이 아니다. 물론 이는 필요한 자바 파일을 임포트해야 하는 정적 언어인 자바의 한계이기도 하다. 그래도 가능하다면 스프링이 없이 코드가 작성되면 더욱 유연한 코드를 확보하게 된다. 프레임워크가 자주 바뀌는 것도 아니므로 비록 스프링 코드가 침투하는게 치명적인 문제는 아니긴하다. 하지만 그래도 더 좋은 방법(생성자 주입)이 있는데, 굳이 사용할 필요는 없다.

순환 참조 에러 방지

생성자 주입을 사용하면 애플리케이션 구동 시점(객체의 생성 시점)에 순환 참조 에러를 예방할 수 있다. 예를 들어 필드 주입을 사용하여 서로가 서로를 의존성으로 가지는 객체가 있다고 가정하자.

@Service
public class UserService {

    @Autowired
    private MemberService memberService;
    
    @Override
    public void register(String name) {
        memberService.add(name);
    }

}

@Service
public class MemberService {

    @Autowired
    private UserService userService;

    public void add(String name){
        userService.register(name);
    }

}

필드 주입을 사용하면 스프링은 UserService, MemberService 객체를 우선 생성할 것이다. 이때 UserService내의 MemberService는 null로, MemberService내의 UserService도 null로 하여 객체를 생성한다. 그 다음 서로의 필드에 객체를 넣어준다.

그 후 UserService의 register() 메서드를 호출하게 되면 MemberService의 add()를 호출하게 될 것이고, 결국 서로가 서로를 계속 호출하여 stackoverflow가 발생하게 될 것이다.

Caused by: java.lang.StackOverflowError: null
	at com.mang.example.user.MemberService.add(MemberService.java:20) ~[main/:na]
	at com.mang.example.user.UserService.register(UserService.java:14) ~[main/:na]
	at com.mang.example.user.MemberService.add(MemberService.java:20) ~[main/:na]
	at com.mang.example.user.UserService.register(UserService.java:14) ~[main/:na]

이런 문제가 발견되지 않는다면 해당 메서드 호출시에 StackOverFlow 에러에 의해 서버가 죽게될 것이다. 반면 생성자 주입을 사용하면 Bean에 등록하기 위해 객체를 생성하는 과정에서 순환 참조가 발생하기 때문에 애플리케이션 구동 시점에 에러가 발생하여 사전에 발견할 수 있다.

DI가 발생하는 시점

마지막으로 의존성 주입이 언제 일어나는지, 의존성 주입 방법에 따라 그 시점에 대해 알아보도록 하자.

의존성 주입은 기본적으로 애플리케이션 구동 시점에 완료된다. 하지만 의존성 주입 방법에 따라 객체가 만들어지는 순간에 주입이 되느냐, 객체가 만들어진 직후에 주입이 되느냐에 차이가 있다.

생성자 주입

객체 생성과 주입이 동시에 일어난다. 스프링이 객체를 만들기 위해 new Service(의존성)을 호출해야 하므로, 생성자 파라미터에 들어갈 의존성이 반드시 먼저 준비되어 있어야 합니다.

필드 주입 & Setter 주입

객체 생성 후 즉시 주입이 일어난다.

  • 1단계(객체 생성): 파라미터가 없는 기본 생성자로 객체를 먼저 만든다.
  • 2단계(의존성 주입): 객체가 만들어진 직후, @Autowired가 붙은 필드나 Setter 메서드를 통해 의존성을 꽂아 넣는다.

MangKyu's Diary:티스토리

DI는 외부에서 의존성을 주입해주는 것을 말한다. 스프링에서는 개발자를 대신하여 의존성 주입을 해주는데, 이 덕분에 객체 간 결합도를 낮추어 유연성이 증가하고 유지보수성도 좋아졌다.

profile
Start fast to fail fast

0개의 댓글