231014 TIL #216 스프링 DI와 생성자 주입 방식

김춘복·2023년 10월 14일
0

TIL : Today I Learned

목록 보기
216/571

Today I Learned

면접 대비 겸 스프링 공부를 다시 복습하던 중 DI에 대한 내용을 재정리 해보았다.


IoC / DI / Bean

이미지 출처: ytechie-blog

  • IoC : 제어의 역전. 객체의 생성과 관리 및 의존성에 대한 제어권을 개발자가 아닌 프레임워크가 가지도록 하는 개념

  • DI : 의존성 주입. IoC의 구체적 구현 방식. 의존성을 외부에서 주입해 객체간의 의존성을 느슨하게 결합시킨다.

  • 객체를 직접 생성하면 수정이 필요할 때 하나하나 직접 다 고쳐줘야 하지만, DI를 통해 외부에서 주입받게 되면 외부에서 한번만 수정을 한 뒤 전달을 받으면 되기 때문에 확장성과 유지보수성이 좋아진다.

  • Bean : 스프링 IoC 컨테이너에서 관리되는 객체. 생성에서 제거까지 lifecycle 전체를 스프링이 관리한다.

    • Bean 등록 방법
    1. XML 설정 파일에서 <bean> 설정
    2. 클래스 위에 @Component, @Service, @Controller 등 활용
    3. @Configuration 클래스 안에서 @Bean으로 등록
  • 스프링 컨테이너는 Bean을 생성하고 필요한 곳에 Bean을 주입(DI)한다. 개발자 대신 스프링이 Bean의 생명주기와 의존성을 알아서 관리하기 때문에 유지보수가 편리하고, 개발자는 비즈니스 로직에 집중할 수 있게 된다.


스프링 공식 문서 DI

의존성 주입(DI) 방법

1. 생성자 주입(Constructor Injection)

@Service
public class UserService {
	// final을 붙일 수 있다
	private final UserRepository userRepository;
    private final RedisRepository redisRepository;
    
    //@Autowired 생략 가능
    public UserController(UserRepository userRepository, RedisRepository redisRepository) {
    	this.userService = userService;
        this.redisRepository = redisRepository;
    }
    //...
}
  • 생성자를 이용해 의존성을 주입하는 방법.
  • 객체가 생성될 때 생성자가 단 한번만 호출된다.
    객체 생성과 의존성 주입이 동시에 발생한다.
  • final 선언을 통해 객체가 변경이 안되게 할 수 있다.
  • 생성자가 1개만 존재하고, 그 생성자로 주입받은 객체가 Bean으로 등록이 되어있으면 @Autowierd를 생략할 수 있다.

2. 수정자 주입(Setter Injection)

@Service
public class UserService {

	private UserRepository userRepository;
    private RedisRepository redisRepository;
    
    @Autowired
    public setUserRepository(UserRepository userRepository) {
    	this.userService = userService;
    }
    
    @Autowired
    public setRedisRepository(RedisRepository redisRepository) {
    	this.redisRepository = redisRepository;
    }
    //...
}
  • Setter에 @Autowired를 붙여 의존성을 주입하는 방법.
  • 주입받는 객체가 생성된 이후에 Setter를 통해 주입이 되므로, 추후 객체의 변경이 가능하다.
  • 선택적 의존성(optional dependencies)의 경우에 세터 주입을 활용한다.
  • 세터 대신 일반 메서드를 사용해 메서드 주입 방식도 구현이 가능은 하다.

3. 필드 주입 (Field Injection)

@Service
public class UserService {

	@Autowired
	private UserRepository userRepository;
    @Autowired
    private RedisRepository redisRepository;
    //...
}
  • 필드에 @Autowired만 붙여 간단하게 의존성을 주입하는 방법
  • 객체의 생성 이후에 의존성이 주입된다.
  • 코드가 간결하다.
  • private 필드에 @Autowired를 붙였기 때문에 외부에서 접근이 불가능해 Unit 테스트가 어렵다.
  • 순환 참조문제가 생기면 감지하지 못한다.

생성자 주입 방식을 사용해야 하는 이유

스프링 공식문서의 Dependency Injection에서는
"The Spring team generally advocates constructor injection"이라고 생성자 주입방식을 대체로 권장한다. 그 이유에 대해 알아보자.

1. 불변성

  • 생성자 주입방식은 객체가 생성될 때 생성자를 한 번만 호출해 의존성을 주입하고 그 이후에는 변경되지 않는다.
    그리고 객체를 final로 선언할 수 있어서 한 번 할당되면 다른 값으로 변경할 수 없다.

  • 불변성을 통해 일관성과 안정성을 제공하고, 여러 스레드에서 동시에 사용하더라도 상태 변경으로 인한 경쟁조건이 발생하지 않아 Thread-safe한 객체가 된다.

  • 수정자 주입을 사용할 시, 객체를 변경할 수 있기 때문에 객체의 상태를 예측하기 어렵게 되어 유지보수가 힘들게 된다. 불필요하게 객체의 수정 가능성을 열어둘 필요가 없다.

  • 따라서 생성자 주입 방식을 사용하면서 final로 선언해 불변성을 확보하는 것이 좋다.

2. NullPointException 방지

  • 수정자나 필드주입을 사용하면 주입받는 객체가 null인 상태로 남아있을 수 있다. 이 상태에서 해당 객체를 사용하려고 하면 NullPointException이 발생하게 된다.

  • 하지만 생성자 주입방식을 사용하면 필요한 의존성이 모두 주입되어야 해당 객체를 생성할 수 있다. 그래서 객체가 null일 경우 컴파일 시점이나 런타임과정에서 객체 생성 시점에 에러가 발생해 NullPointException을 방지할 수 있다.

3. lombok의 @RequiredArgsConstructor 을 사용해 간편한 의존성 주입

  • 생성자 주입방식을 사용하면 주입하는 객체를 바꿀 때 마다 매번 생성자 코드를 수정해야한다.

  • 하지만 lombok의 @RequiredArgsConstructor를 사용하면 final이나 @NotNull이 붙은 필드의 생성자를 자동으로 만들어 준다. 생성자가 하나일 때 @Autowired가 생략 가능한데, 해당 생성자를 lombok이 자동으로 만들어 주니 그냥 필드만 final로 구현하면 자동으로 주입이 된다.

  • 따라서 생성자 주입 방식을 보다 편하게 사용할 수 있다.

  • 단, IDE 설정에 따라 @RequiredArgsConstructor를 사용 시 생성자가 보이지 않아서 의존성 명시성 측면에서는 단점이 있을 수 있다.

@Service
@RequiredArgsConstructor
public class UserService {
	
	private final UserRepository userRepository;
    private final RedisRepository redisRepository;
    //...
}

4. 단위 테스트 코드 작성에 용이

  • 필드 주입 방식은 해당 필드가 private이라면 직접 접근해서 값을 변경할 수 없다.(그렇다고 public이나 protected로 변경하면 캡슐화가 깨지는 문제가 발생한다.) 필드 주입방식으로 단위테스트를 진행 시 NullPointException이 발생한다.

  • 생성자 주입 방식을 사용하면 필요한 의존성을 쉽게 주입할 수 있다.

@Service
@RequiredArgsConstructor
public class UserService {

	private final UserRepository userRepository;
    
    public void login() {
    // ...
    }
    // ...
}
public class UserServiceTest {
	
    UserRepository mockUserRepository = mock(UserRepository.class);
    UserService userService = new UserService(mockUserRepository);
    
    @Test
    public void testLogin() {
    	userService.login();
        //...
    }
}
  • 단위 테스트 진행 시, mock객체나 원하는 객체를 만들어서 쉽게 테스트 진행이 가능하다.

  • (수정자 주입 방식도 이 장점에는 해당된다.)

5. 순환 참조 에러 방지

@Service
public class ServiceA {
	@Autowired
    private ServiceB serviceB;
    
    public void runA(){
    	serviceB.runB();
    }
}
@Service
public class ServiceB {
	@Autowired
    private ServiceA serviceA;
    
    public void runB(){
    	serviceA.runA();
    }
}
  • 위의 코드에서 runA() 메서드를 실행시키면 runB()와 runA()가 계속 서로를 호출해 JVM의 stack메모리가 가득 차서 StackOverflow 에러가 발생한다.

  • 문제는 필드 주입과 수정자 주입은 객체의 생성 이후에 의존성이 주입되기 때문에 서버가 구동 되고 해당 코드가 호출되기 전 까지 문제를 알 수 없다는 것이다.

  • 하지만 생성자 주입방식은 컴파일 시점이나 객체 생성 시점에 순환 참조 에러를 예방할 수 있다.

new serviceA(new serviceB(new serviceA(new serviceB(...))))
  • Bean에 등록하기 위해 객체를 생성하는 과정에서 이미 위와같은 순환참조 문제로 인해 BeanCurrentlyInCreationException이 발생해 서버 구동 전에 미리 문제를 알 수 있게 된다.

  • 스프링부트 2.6부터는 순환참조가 기본적으로 허용되지 않도록 바뀌어 필드주입이나 수정자 주입에서도 생성자 주입방식과 동일한 시점에 에러가 발생하게 바뀌었다.

⭐ 결론
생성자 주입 방식은 객체의 불변성이 유지되면서 NPE와 순환참조 문제를 미리 예방할 수 있고, 단위 테스트 작성에 용이하고 lombok 어노테이션을 통해 간단하게 구현이 가능하다.


객체지향 관점에서 DI로 의존성 문제를 해결할 때의 장점

DI, 의존성 주입을 통해 객체지향의 SOLID 원칙 중 OCP와 DIP 원칙을 지킬 수 있다.

  • OCP : 자신의 확장에는 열려있고 주변 환경의 변화에는 닫혀있어야 한다는 원칙
  • DIP : 변경이 잦고 구체적인 것보다는 변경이 없는 추상화된 것에 의존해야한다는 원칙

DI를 사용하지 않고 객체를 직접 생성해서 쓸 경우

public class Switch {
 // private Game game = new Zelda();
    private Game game = new Mario();
    // ...
}
  • 위의 상황에서 Switch 클래스는 Game의 구현체인 Zelda와 Mario 클래스를 직접 생성해서 의존한다. 게임을 바꾸기 위해서 직접 Switch 클래스의 코드를 Zelda에서 Mario로 고쳐야하므로 OCP를 위반하게되고, 구체적인 구현체에 의존하고 있으므로 DIP도 위반하고 있다.

DI를 사용해 객체를 외부에서 주입받을 경우

@Configuration
public class SwitchConfig {

	@Bean
    public Switch switch() {
    	return new Switch(new Mario());
    }
}
//----------------------------------------------------------
public class Switch {
    private Game game;
    
    public Switch(Game game) {
    	this.game = game;
    }
    // ...
}
  • SwitchConfig를 통해 Switch 객체가 Bean으로 등록되고 스프링의 IoC 컨테이너에 의해 관리된다. DI를 적용해 Switch 클래스가 객체를 생성하지 않고 외부에서 주입받게 된다.

  • Game을 변경할 때 Switch 클래스에 수정이 필요없으므로 OCP도 지키면서 코드의 확장성을 높일 수 있게 된다.

  • Switch는 추상화된 Game에 의존하여 DIP를 지키면서 객체간 결합도가 느슨해져 유연성을 높일 수 있게 되었다.

  • 단위 테스트를 할 때 실제 Game 객체 대신 테스트용 Mock Game 객체를 주입할 수 있어 테스트의 용이성이 향상된다.

profile
Backend Dev / Data Engineer

0개의 댓글