[Spring Boot] 입문 - 스프링 빈과 컨테이너, 그리고 의존 관계(feat. IoC, DI)
스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 - 김영한 인프런 강의 참고
스프링 컨테이너(Spring Container)와 스프링 빈(Spring Bean)
객체를 다루는 일반적인 방법은 프로그래머가 프로그램 로직 상 필요한 객체를 그때그때 직접 생성하고 필요한 메서드를 호출하는 것이다. 만약 다루는 언어가 자바라면 new
키워드를 통해 필요한 곳에 인스턴스를 생성하는 로직을 넣을 것이다.
하지만 스프링에서는 클래스들의 인스턴스(객체)를 일일이 프로그래머가 다루는 것이 아니라 스프링에게 관리를 맡기게 된다. 이것이 스프링의 주요 특징 중 하나인 IoC(Inversion of Control)이다.
IoC는 우리나라 말로 제어의 역전이라고 하며, 쉽게 말해 제어의 흐름을 바꾸는 것이다. 필요한 객체를 프로그래머가 직접 생성하는 것이 아니라 스프링이 생성해 놓은 객체를 가져와서 쓰게 된다.
IoC(Inversion of Control) : 제어의 역전, 객체를 개발자가 아닌 외부(스프링 컨테이너)에서 생성, 관리
이때 스프링이 객체들을 미리 생성해 관리하는 공간을 스프링 컨테이너라고 하며, 이렇게 스프링에 의하여 생성되고 관리되는 자바 객체를 스프링에선 빈(Bean)이라고 한다.
스프링 컨테이너(Spring Container) : 스프링에서 자바 객체들을 관리하는 공간
스프링 빈(Spring Bean) : 스프링에 의하여 생성되고 관리되는 자바 객체
즉, 스프링 컨테이너에서는 이 빈의 생성부터 소멸까지를 개발자 대신 관리해주는 곳이다. 개발자가 관리해야 할 객체를 스프링에게 맡겼기 때문에 객체에 대한 제어가 역전된 것이다.
그리고 스프링은 스프링 컨테이너에 스프링 빈을 등록할 때, 기본으로 싱글톤(Singleton)으로 등록한다(유일하게 하나만 등록해서 공유한다). 따라서 같은 스프링 빈이면 모두 같은 인스턴스다. 설정으로 싱글톤이 아니게 설정할 수 있지만, 특별한 경우를 제외하면 대부분 싱글톤을 사용한다.
이 이유에 대해서는 아래 블로그 글을 참고
https://mangkyu.tistory.com/151
의존 관계와 DI(Dependancy Injection)
위와 같이 외부에서 객체를 생성하고 관리함으로써 얻을 수 있는 장점을 이해하려면 의존 관계에 대해 알아야 한다.
의존 관계는 의존 대상 B가 변하면, 그것이 A에 영향을 미칠 때 A는 B와 의존 관계라고 한다. 쉽게 말해 B가 변경되었을 때 그 영향이 A에 미치는 관계를 말하며, 보통 한 클래스 내에 다른 클래스의 인스턴스를 생성할 때 의존 관계가 만들어 진다.
기존의 방식대로 개발자가 A 클래스에 B 클래스의 어떤 기능이 필요해서 A 클래스 안애 B 클래스를 직접 생성하여 사용하게 되면 A와 B 클래스의 의존성은 높아진다. 의존성이 높다는 것은 결합도가 높다는 걸로 바꿔 말할 수 있는데, 서로의 변화가 서로에게 큰 영향을 미칠 수 있다는 의미다.
이러한 문제를 해결하는 방법 중 하나가 클래스 내부에 객체를 생성하는 것이 아니라, 외부에서 미리 생성된 객체를 활용하는 것이다. 마치 자동차를 만들 때, 자동차 엔진을 만드는 공정과 타이어를 만드는 공정을 분리시켜 나중에 완성된 부품을 조립하는 것처럼 말이다. 이것이 DI(Dependancy Injection)다.
위에서 얘기했던, 컨테이너가 미리 생성해 놓은 객체(빈)를 개발자가 가져다가 사용한다는 말을 바꿔 말하면, 스프링 컨테이너가 외부에서 미리 생성해 놓은 객체를 주입시킨다고 할 수 있으며, 이것을 DI(Dependancy Injection)이라고 한다.
DI(Dependancy Injection) : 의존 관계를 외부에서 결정(주입)해주는 것
만약 자동차 타이어의 종류가 바뀐다면 타이어만 갈아 끼우면 된다. 엔진을 공정하는 것에는 아무런 영향이 없다. 심지어 타이어를 갈아 끼우는 것도 내가 직접 하는 것이 아니라 전달만 해 놓으면 기술자 또는 기계가 대신 끼워준다. 이는 자동차 엔진과 타이어 공정의 의존 관계가 최소화 됐기 때문이고, 제어를 외부에 맡겼기 때문이다.
이를 정리해 보면 아래와 같은 장점이 있다.
- 두 객체 간의 관계라는 관심사의 분리
- 두 객체 간의 결합도를 낮춤
- 객체의 유연성을 높임
- 테스트 작성을 용이하게 함
이 외에도 다양한 측면에서의 장점들이 있는데 이는 추후에 더 공부해 보면서 정리해 보겠다.
스프링 빈을 등록해 의존 관계 설정하기
그럼 이전 예제를 활용해서 DI를 통해 의존 관계를 설정해 보자.
이전 포스팅 : https://bnzn2426.tistory.com/123
서비스와 리포지토리를 구현하고 테스트까지 완료했기 때문에 이제 컨트롤로와의 의존 관계를 맺어줘야 한다.
/controller/MemberController.java
package com.example.practice.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import com.example.practice.service.MemberService;
@Controller
public class MemberController {
private final MemberService memberService;
@Autowired
public MemberController(MemberService memberService){
this.memberService = memberService;
}
}
생성자에 @Autowired
가 있으면 스프링이 연관된 객체를 스프링 컨테이너에서 찾아서 넣어준다. 이렇게 객체 의존관계를 외부에서 넣어주는 것이 DI(Dependency Injection)다. 하지만 막상 실행해 보면 에러가 발생한다.
그 이유는 아직 memberService
가 스프링 빈으로 등록되어 있지 않기 때문이다.
스프링 빈을 등록하는 방법은 2가지가 있다.
- 컴포넌트 스캔과 자동 의존 관계 설정
- 자바 코드로 직접 스프링 빈 등록하기
컴포넌트 스캔과 자동 의존 관계 설정
컴포넌트 스캔은 @Component
애노테이션이 있으면 스프링 컨테이너에 스프링 빈으로 자동 등록된다.
@Controller
애노테이션이 있는 컨트롤러가 스프링 빈으로 자동 등록된 이유도 내부에 @Component
어노테이션을 포함하고 있어 컴포넌트 스캔에 포함되었기 때문이다.
@Component
를 포함하는 다음 어노테이션도 스프링 빈으로 자동 등록된다.
아래는 공식 문서에 나와있는 어노테이션들이다.
@Controller
@Service
@Repository
실제로 @Controller
내부 코드를 보면 @Component
가 포함되어 있는 것을 확인할 수 있다.
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Controller {
/**
* The value may indicate a suggestion for a logical component name,
* to be turned into a Spring bean in case of an autodetected component.
* @return the suggested component name, if any (or empty String otherwise)
*/
@AliasFor(annotation = Component.class)
String value() default "";
}
스프링 컨테이너에 빈을 등록하는 이유는 스프링이 각 객체간 의존관계를 관리하도록 하는데 큰 목적이 있다. 객체가 의존관계를 등록할 때는 스프링 컨테이너에서 해당하는 빈을 찾고, 그 빈과 의존성을 만든다.
그럼 이제 회원 서비스를 스프링 빈으로 등록해 보겠다.
/service/MemberSerivce.java
// ...
@Service
public class MemberService {
private final MemberRepository memberRepository;
@Autowired
public MemberService(MemberRepository memberRepository){
this.memberRepository = memberRepository;
}
// ...
간단하게 클래스 위에 @Service
를 추가해 주면 스프링 빈으로 등록된다. 그리고 컨트롤러와 마찬가지로 서비스에서도 생성자에서 회원 리포지토리를 @Autowired
로 의존 관계를 맺어주도록 한다.
@Autowired
: 생성자에@Autowired
를 사용하면 객체 생성 시점에 스프링 컨테이너에서 생성자에 필요한 스프링 빈을 자동으로 찾아 주입한다. 생성자가 1개만 있으면@Autowired
는 생략할 수 있다.(단, 컨테이너에 해당 클래스가 스프링 빈으로 등록되어 있어야 함)
당연히 회원 리포지토리 역시 스프링 빈에 등록해야 한다.
/repository/MemoryMemberRepository.java
// ...
@Repository
public class MemoryMemberRepository implements MemberRepository{
// ...
이제 memberService
와 memberRepository
도 스프링 컨테이너에 스프링 빈으로 등록되었다. 스프링 컨테이너는 이제 등록된 빈들에게 아래와 같이 의존 관계를 맺어준다.
다시 스프링을 실행해 보면 정삭적으로 실행되는 것을 확인할 수 있다.
2022-12-31T06:23:53.187Z INFO 19431 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port(s): 8080 (http) with context path ''
2022-12-31T06:23:53.201Z INFO 19431 --- [ main] c.example.practice.PracticeApplication : Started PracticeApplication in 4.036 seconds (process running for 4.932)
참고로 DI에는 필드 주입, setter 주입, 생성자 주입 이렇게 3가지 방법이 있다.
필드 주입은 @Autowired
를 생성자에 붙이는게 아닌 필드에 선언된 인스턴스 앞에 붙이는 방법이다.
// ...
@Controller
public class MemberController {
@Autowired private final MemberService memberService;
// ...
}
이 방법은 코드의 유연한 수정이 안되므로 유지보수가 어렵다.
setter 주입같은 경우에는 setter
메서드를 따로 선언해 스프링 컨테이너가 이를 호출하게 하는 것이다.
// ...
@Controller
public class MemberController {
private MemberService memberService;
@Autowired
public void setMemberService(MemberService memberService) {
this.memberService = memberService;
}
// ...
}
이 방법은 final
키워드가 빠져 memberService
가 변경될 가능성이 열리게 된다. 더군다나 setter
메서드가 public
으로 선언되어야 하기 때문에 의도치 않게 외부에서 호출할 우려가 있다.
생성자 주입은 어플리케이션이 조립되는 시점에 생성자가 단 한 번만 호출된 후, 다시 호출될 일이 없다(위에서 말했듯이 싱글톤 방식이기 때문에). 의존관계가 실행중에 동적으로 변하는 경우는 거의 없으므로 실무에서는 생성자 주입을 권장하고 있다.
자바 코드로 직접 스프링 빈 등록하기
다음은 자바 코드로 직접 컨테이너에 스프링 빈을 등록하는 방법이다. 이 방법은 스프링 설정 파일을 자바 파일로 직접 만들고 해당 클래스에 @Configuration
을 붙인다. 설정 파일은 패키지의 시작 클래스(main
메서드가 있는 클래스)와 동일한 레벨에 만들어 준다.
(회원 서비스와 회원 리포지토리의 @Service
, @Repository
, @Autowired
어노테이션을 제거하고 진행해야 한다.)
/main/java/…/SpringConfig.java
package com.example.practice;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.example.practice.repository.MemberRepository;
import com.example.practice.repository.MemoryMemberRepository;
import com.example.practice.service.MemberService;
@Configuration
public class SpringConfig {
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
}
참고로 실무에서는 주로 정형화된 컨트롤러, 서비스, 리포지토리 같은 코드는 컴포넌트 스캔을 사용한다. 그리고 정형화 되지 않거나, 상황에 따라 구현 클래스를 변경해야 하면, 자바 코드를 통해 스프링 빈으로 등록한다.
왜냐하면 굳이 다른 파일들을 뒤져가며 어노테이션을 수정하는 것보다 설정 파일 하나만 수정하는 것이 더 안전하고, 유지보수가 훨씬 편리하기 때문이다.
위 예제에서는 비즈니스 요구사항에서 확인했듯이, 향후 메모리 리포지토리를 다른 리포지토리로 변경할 예정이다.
그래서 위와 같이 MemberRepository
를 인터페이스로 만들고 MemoryMemberRepository
가 이를 구현하도록 했다. MemoryMemberRepository
를 다른 구현체로 변경할 예정이므로 컴포넌트 스캔 방식 대신 자바 코드로 스프링 빈을 설정하는 편이 낫다.