legacy/Spring

[Spring] 생성자 주입(constructor injection)을 사용하는 이유

heemang.dev 2024. 7. 3. 14:49

 

1. 생성자 주입이란?

생성자 주입 방법은 객체를 생성하기 위해 생성자를 호출하는 시점 1회에만 호출되는 것을 보장한다.

@Service
public class UserService {

    private UserRepository userRepository;
		
		@Autowired
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

 

스프링 프레임워크는 생성자 주입을 적극적으로 지원하고 있기 때문에 아래와 같이 생성자가 1개만 존재하는 경우에는 @Autowired 사용 없이 주입이 가능하다.

@Service
public class UserService {

    private UserRepository userRepository;
		
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }
}

 

 

2. 생성자 주입을 사용하는 이유

스프링 프레임워크에서 의존 관계를 주입하기 위해서 필드 주입, 수정자 주입, 생성자 주입 3가지 방법이 존재한다.

그러나 스프링 프레임워크도 생성자 주입을 적극적으로 지원하고 있는데 그 이유는 아래와 같다.

  • 객체의 불변성 보장
  • 테스트 코드 작성 용이
  • final 키워드 사용
  • NPE 방지
  • 객체 간의 순환 참조 방지

 

2-1. 객체의 불변성 보장

생성자 주입은 객체를 생성하는 시점 1번만 의존 관계를 주입할 수 있다. 이미 의존 관계가 주입된 객체를 바꿀 일이 거의 없기 때문에 객체가 생성된 이후에 의존 관계를 다시 주입할 수 없도록 해야한다. 이 상황에서 수정자 주입을 열어두게 되면 객체의 변경 가능성을 불필요하게 열어두는 것이 되어 유지보수성이 떨어진다.

따라서 생성자 주입을 사용하여 객체의 변경 가능성을 배제하여 불변성을 보장해야 한다.

 

2-2. 테스트 코드 작성 용이

생성자 주입을 사용하는 경우, 주입의 대상이 되는 객체를 테스트 코드 안에서 직접 new 연산자를 사용하여 주입할 수 있다. 즉, 스프링 컨테이너의 도움없이 테스트 코드를 작성할 수 있는 것이다.

public class JackTest {
    @Test
	  public void test() {
	      Jack jack = new Jack();
	      JackCoding jackCoding = new JackCoding(jack);
	      jack.jack();
	  }
}

 

생성자 방식이 아닌 필드 주입 방식을 사용하게 되면 아래와 같이 Mock 객체를 생성하고 리플렉션을 사용하여 필드에 주입해주어야 한다. (new 연산자 사용으로 인해 스프링 컨테이너를 사용하지 않았기 때문에 UserService를 생성하는 시점에 TeamService가 @Autowired에 의해 주입되지 않는다.)

class UserServiceTest {

    @Test
    void test() throws NoSuchFieldException, IllegalAccessException {
        UserService userService = new UserService();

        TeamService mockService = mock(TeamService.class); // Mock 객체 생성
        // Reflection을 사용하여 UserService 클래스의 teamService 필드를 가져온다.
        // private 필드를 포함하여 가져온다.
        Field teamServiceField = UserService.class.getDeclaredField("teamService");
        teamServiceField.setAccessible(true); // private 필드이므로 접근 가능하도록 설정한다,
        // Reflection을 사용하여 userService 인스턴스의 teamService 필드에 mockService를 주입한다.
        teamServiceField.set(userService, mockService);

        Assertions.assertThat(userService.getTeamService()).isNotNull();
        Assertions.assertThat(userService.getTeamService()).isEqualTo(mockService.toString());
    }
}

 

2-3. final 키워드 사용 가능

생성자 주입을 사용하는 경우 필드에 final 키워드를 사용할 수 있으며, 컴파일 시점에 누락된 의존성을 확인할 수 있다. (final 키워드를 사용하는 필드는 컴파일 시점에 반드시 초기화가 되어 있어야 한다.) 생성자 주입이 아닌 방식은 스프링 컨테이너가 생성된 이후에 의존성이 주입되기 때문에 final 키워드를 사용할 수 없다.

 

2-4. NPE(Null Pointer Exception) 방지

@RestController
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/user")
    public String getUser() {
        return userService.getTeamService();
    }
}
@Service
public class UserService {

    private TeamService teamService;

    // 수정자 주입 방식
    @Autowired
    public void setTeamService(TeamService teamService) {
        this.teamService = teamService;
    }

    public String getTeamService() {
        return teamService.toString();
    }
}

 

UserService의 경우 TeamService를 수정자 방식으로 의존 관계 주입을 받고 있다. 따라서 수정자를 호출하지 않으면 UserService가 참조하는 TeamService에는 Null 값이 저장되어 있을 것이다. 수정자를 호출하지 않은 상태에서 “/user”로 요청이 들어와 UserService의 getTeamService()를 호출하면 NPE가 발생한다.

 

따라서 아래와 같이 TeamService를 반드시 호출해주어야하는 불편함이 발생한다.

@RestController
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/user")
    public String getUser() {
        userService.setTeamService(new TeamService());
        return userService.getTeamService();
    }
}

 

 

2-5. 객체 간의 순환 참조 방지

두 개의 Service가 서로 참조관계에 있기 때문에 순환참조가 발생한다. UserService를 생성하기 위해서는 TeamService를 주입해야 하고, 반대로 TeamService를 생성하기 위해서는 UserService를 주입해야 한다.

수정자, 필드 주입은 컴파일 시점에 순환 참조 오류를 잡지 못한다. 즉, 런타임 시점에 UserService나 TeamService를 사용해야 하는 시점에 오류가 발생한다.

@Service
public class UserService {

    @Autowired
    private TeamService teamService;

}
@Service
public class TeamService {

    @Autowired
    private UserService userService;
}

 

다시 설명하자면, UserSerivce → TeamService 의존하고 TeamService → UserService를 의존하고 있다. 문제는 컴파일 타임에 순환 참조가 있음을 인식하지 못한다는 것이다. 애플리케이션은 정상적으로 실행되지만 UserService 또는 TeamService의 비즈니스 로직을 호출하는 시점에 순환참조가 발생하면서 아래와 같이 StackOverflow가 발생한다.

 

 

  • 수정자, 필드 주입 방식 : 객체 생성 시점에는 순환 참조 문제를 알 수 없다.
  • 생성자 주입 방식 : 빈을 등록하기 위해 객체를 생성하는 시점(컴파일 시점)에 순환참조 문제를 인식할 수 있다.

전자 방식은 애플리케이션은 정상적으로 수행되나 비즈니스 로직을 수행하는 과정에서 순환 참조 문제가 발생하고, 후자 방식은 애플리케이션을 시작하기 위한 빈을 생성하는 과정에서 순환 참조 에러를 발생시킨다.

 

 


 

참고

https://mangkyu.tistory.com/125

https://prodo-developer.tistory.com/152

https://prodo-developer.tistory.com/152