2021-09-02-스프링핵심원리-by-김영한님
2021년09월02일김영한님의 강의를 듣고 정리하는 글이다.
객체 지향 설계와 스프링
자바 진영의 추운 겨울과 스프링의 탄생
2000년대 초반에는 EJB(Enterprise Java Beans)라는 기술을 사용했었다. 근데 너무 복잡하고, 비쌌다.(한 대당 수천만원 가량..!) 그리고 EJB에 너무 의존적이게 되어 개발하기가 어려웠다. 그렇게 고통을 받고 있다가 로드 존슨이라는 사람이 책을 하나 냈는데, 이것이 지금의 스프링의 기초 토대가 되었다. 또한 EJB에서는 엔티티빈이라 해서 ORM을 제공했는데 이 또한 사용하긴 별로여서 개빈 킹이라는 인물이 Hibernate를 만들었고 현재는 거의 표준처럼 사용하게 되었다.
JPA는 인터페이스가 Hibernate는 구현체가 된다.
스프링 역사
- 2002년 로드 존슨 책 출간
- EJB의 문제점 지적
- EJB가 없어도 충분히 고품질의 확장 가능한 애플리케이션을 개발할 수 있음을 보여주고, 30,000라인 이상의 기반 기술을 예제 코드로 선보였다.
- 지금의 핵심 개념과 기반 코드가 들어있어서 개발자들이 이 책을 참고해서 개발했다.
- BeanFactory, ApplicationContext, POJO, 제어의 역전, 의존 관계등이 기술되어었다.
- 이후 유겐 휠러와 얀 카로프가 오픈소스로 만들자고 하여 되었다.
- EJB라는 겨울을 넘어 새로운 시작이란 뜻으로 Spring이라고 지어졌다.
스프링이란
스프링은 하나만 있는게 아니라 여러가지의 기술의 모음이라고 봐야 한다.
- 필수: 스프링 프레임워크, 스프링 부트
- 선택:
- 스프링 데이터: CRUD를 쉽게 해줄 수 있게 도와줌. (스프링 데이터 JPA)
- 스프링 세션: 세션 기능을 관리
- 스프링 시큐리티: 보안 관리
- 스프링 Rest Docs: API 문서화 편히 해줌
- 스프링 배치: 배치 처리를 관리
- 스프링 클라우드: 클라우드 기술 관리
스프링 프레임워크
- 핵심 기술: 스프링 DI 컨테이너, AOP, 이벤트, 기타
- 웹 기술: 스프링 MVC, 스프링 WebFlux
- 데이터 접근 기술: 트랜잭션, JDBC, ORM 지원, XML 지원
- 기술 통합: 캐시, 이메일, 원격접근, 스케줄링
- 테스트: 스프링 기반 테스트 지원
- 언어: 코틀린, 그루비
스프링 부트를 활용하면 스프링 프레임워크의 기술들을 편리하게 사용할 수 있다.
스프링 부트
- 스프링을 편리하게 사용할 수 있도록 지원, 최근에는 기본으로 사용한다.
- 단독으로 실행할 수 있는 스프링 애플리케이션을 쉽게 생성
- Tomcat 같은 웹 서버를 내장해서 별도의 웹 서버를 설치하지 않아도 됨
- 손쉬운 빌드 구성을 위한 starter 종속성 제공
- 스프링과 3rd party(외부) 라이브러리 자동 구성
- 메트릭, 상태 확인, 외부 구성 같은 프로덕션 준비 기능 제공
- 관례에 의한 간결한 설정
스프링 부트는 프레임워크를 보조해주는거지 따로 쓸 수 있는 것은 아니다.
스프링 단어
스프링이라는 단어는 문맥에 따라 다르게 사용된다.
- 스프링 DI 컨테이너 기술
- 스프링 프레임워크
- 스프링 부트, 스프링 프레임워크 등을 모두 포함한 스프링 생태계
스프링은 왜 만들어 졌을까
모든 기술에는 핵심 개념이 있다. 스프링도 지금 엄청 거대한데 처음에는 3만줄에서 시작되었다. 도대체 스프링에는 무슨 핵심 개념이 있었기에 이렇게 거대해질 수 있었을까? 전자 정부 프레임워크라서? DB 관리 등을 편히 해주어서? 그것들은 결과물들이고 핵심은 아래와 같다.
- 스프링은 자바 언어 기반의 프레임워크이다.
- 자바 언어의 가장 큰 특징은 객체 지향 언어이다.
- 스프링은 객체 지향 언어가 가진 강력한 특징을 살려내는 프레임워크이다.
- 스프링은 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크이다.
EJB 시절에는 EJB에 종속되어가지고 자바의 객체 지향을 잃게 되었었는데, 스프링은 DI를 활용하여 이를 해결해주었다.
좋은 객체 지향 프로그래밍이란
객체 지향 프로그래밍은 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나 여러 개의 독립된 단위, 즉 “객체”들의 모임으로 파악하고자 하는 것이다. 각각의 객체는 메시지를 주고받고, 데이터를 처리할 수 있다. (협력)
객체 지향 특징
- 추상화
- 캡슐화
- 상속
- 다형성
객체 지향 프로그래밍은 프로그램을 유연하고 변경이 용이하게 만들기 때문에 대규모 소프트웨어 개발에 많이 사용된다. 이런 유연하고 변경이 용이하게 해주는것이 다형성 덕분이다. 다형성을 위해서는 역할과 구현을 분리해서 생각해야 한다. 가령, 운전자가 있고 자동차가 있다. 운전자는 자동차가 K3든 테슬라든 아무리 차가 바뀌어도 자동차의 기능만 알면 사용할 수 있다. 이처럼 역할과 구현으로 구분하면 세상이 단순해지고, 유연해지면 변경도 편해진다. 이의 장점으론 다음과 같다.
- 클라이언트는 대상의 역할(인터페이스)만 알면 된다.
- 클라이언트는 구현 대상의 내부 구조를 몰라도 된다.
- 클라이언트는 구현 대상의 내부 구조가 변경되어도 영향을 받지 않는다.
- 클라이언트는 구현 대상 자체를 변경해도 영향을 받지 않는다.
자바에서는 역할은 인터페이스가 되고, 구현은 인터페이스를 구현한 클래스, 구현 객체가 된다. 객체를 설계할 때 역할과 구현을 명확히 분리해준다. 객체를 설계할 때 역할(인터페이스)를 먼저 부여하고, 그 역할을 수행하는 구현 객체를 만들어 준다. 혼자 있는 객체는 없기에 클라이언트(요청)와 서버(응답) 관계로 서로 협력을 한다.
다형성의 본질
- 인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경할 수 있다.
- 다형성의 본질을 이해하려면 협력이라는 객체사이의 관계에서 시작해야 한다.
- 클라이언트를 변경하지 않고, 서버의 구현 기능을 유연하게 변경할 수 있다.
스프링과 객체 지향
- 다형성이 가장 중요하다.
- 스프링은 다형성을 극대화해서 이용할 수 있게 도와준다.
- 스프링에서는 제어의 역전, 의존관계 주입은 다형성을 활용해서 역할과 구현을 편리하게 다룰 있도록 지원한다.
좋은 객체 지향 설계의 5가지 원칙 (SOLID)
SOLID는 클린코드로 유명한 로버트 마틴이 좋은 객체 지향 설계의 5가지 원칙을 정리한 것이다.
- SRP: 단일 책임 원칙 (Single Responsibility Principle)
- OCP: 개방-폐쇄 원칙 (Open/Closed Principle)
- LSP: 리스코프 치환 원칙 (Liskov Substitution Principle)
- ISP: 인터페이스 분리 원칙 (Interface Segregation Principle)
- DIP: 의존관계 역전 원칙 (Dependency Inversion Principle)
SRP 단일 책임 원칙
- 한 클래스는 하나의 책임만 가져야 한다.
- 하나의 책임이라는 것은 모호하다.
- 클 수 있고, 작을 수 있다.
- 문맥과 상황에 따라 다르다.
- 중요한 기준은 변경이다. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것이다.
OCP 개방-폐쇄 원칙
- 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
- 다형성을 활용한다.
- 인터페이스를 구현한 새로운 클래스를 하나 만들어서 새로운 기능을 구현
아래의 코드를 봐보자.
// MemberRepository m = new MemoryMemberRepository();
MemberRepository m = new JdbcMemberRepository();
구현체를 바꾸기 위해서 코드를 수정하였다. 이렇게 되면 다형성을 이용한거지만 OCP원칙이 지켜지지 않은 것이다. 왜냐, 수정을 한 것이기 때문이다. 이런 문제를 해결하려면 객체를 생성하고, 연관관계를 맺어주는 별도의 조립, 설정자가 필요하다.
LSP 리스코프 치환 원칙
- 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
- 다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야 한다는 것, 다형성을 지원하기 위한 원칙, 인터페이스를 구현한 구현체는 믿고 사용하려면 이 원칙이 필요하다.
만약에 자동차라는 인터페이스에서 엑셀은 앞으로 가게끔 규정해야한다. 그런데 뒤로가게 하는 구현체를 만들면 LSP를 위반하게 되는 것이다. 느리든 빠르든 앞으로 가게끔 구현해야 한다.
ISP 인터페이스 분리 원칙
- 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
- 자동차 인터페이스 -> 운전 인터페이스, 정비 인터페이스로 분리
- 사용자 클라이언트 -> 운전자 클라이언트, 정비사 클라이언트로 분리
- 분리하면 정비 인터페이스 자체가 변해도 운전자 클라이언트에 영향을 주지 않는다.
- 인터페이스가 명확해지고, 대체 가능성이 높아진다.
DIP 의존관계 역전 원칙
- 프로그래머는 “추상화에 의존해야지, 구체화에 의존하면 안된다.” 의존성 주입은 이 원칙을 따르는 방법 중 하나다.
- 구현 클래스에 의존하지 말고, 인터페이스에 의존하라이다.
- 즉, 역할에 의존하게 해야 한다는 것이다. 객체 세상에서도 클라이언트가 인터페이스에 의존해야 유연하게 구현체를 변경할 수 있다. 구현체에 의존하게 되면 변경이 아주 어려워진다.
MemberRepository m = new MemoryMemberRepository();
위 코드는 인터페이스에 의존하는 동시에 구현 클래스도 동시에 의존한다. 왜냐하면 new를 통해 구현 클래스를 직접 선택했기 때문이다. 이렇게 되면 DIP를 위반하게 되는 것이다.
객체 지향 설계와 스프링
스프링은 DI(Dependency Injection)와 DI 컨테이너를 제공함으로써 OCP와 DIP를 해결해준다. 이를 통해 클라이언트 코드의 변경 없이 기능 확장을 할 수 있고, 쉽게 부품을 교체하듯이 개발할 수 있다.
스프링 핵심 원리 이해1 - 예제 만들기
비즈니스 요구사항과 설계
- 회원
- 회원을 가입하고 조회
- 일반 등급과 VIP 등급
- 회원 데이터는 자체 DB를 구축할 수 있고, 외부 시스템과 연동할 수 있다.
- 주문
- 회원은 상품을 주문할 수 있다.
- 회원 등급에 따라 할인 정책을 적용할 수 있다.
- 할인 정책은 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용 (나중에 변경될 수 있음)
- 할인 정책은 변경 가능성이 높다. 회사는 아직 정책을 정하지 못했고, 오픈 직전까지 고민을 미루는 중이다. 최악의 경우 할인 적용을 하지 않을 수 있다.
미확정된 부분으로 개발을 무기한 연장할 순 없다. 이때 객체 지향 설계 방법을 활용하여 해결해본다. 바로 인터페이스를 만들고 구현체를 언제든지 갈아끼울 수 있도록 설계한다.
회원 도메인 설계
영한님의 자료에 관계도와 다이어그램이 있다.
회원 도메인 개발
github 참조
인터페이스의 구현체가 하나일 땐 관례적으로 클래스명 끝에 Impl을 붙혀준다고 한다. ex) MemberServiceImpl
주문과 할인 도메인 설계
- 주문 생성: 클라이언트는 주문 서비스에 주문 생성을 요청
- 회원 조회: 할인을 위해서 등급 필요. 주문 서비스는 회원 저장소에서 회원 조회
- 할인 적용: 주문 서비스는 회원 등급에 따른 할인 여부를 할인 정책에 위임한다.
- 주문 결과 반환: 주문 서비스는 할인 결과를 포함한 주문 결과를 반환한다.
스프링 핵심 원리 이해2 - 객체 지향 원리 적용
새로운 할인 정책 적용과 문제점
order 구현체에서 할인 정책을 바꿔보자.
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
// private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
private final DiscountPolicy discountPolicy = new RateDiscountPolicy();
}
- 역할과 구현을 충실하게 분리는 했다.
- 다형성도 활용하고, 인터페이스와 구현 객체를 분리했다.
- OCP, DIP 같은 객체지향 설계 원칙을 충실히 준수한걸로 보이지만…
- 추상(인터페이스)뿐만 아니라 구체(구현) 클래스에도 의존하고 있다.
- 추상(인터페이스) 의존: DiscountPolicy
- 구체(구현) 클래스: FixDiscountPolicy, RateDiscountPolicy
- OCP 또한 못 지켰다. 코드를 변경했기 때문이다. 이러면 클라이언트에 영향을 주게 된다.
- 추상(인터페이스)뿐만 아니라 구체(구현) 클래스에도 의존하고 있다.
관심사의 분리
위의 코드의 상황을 하나의 공연으로 치자면 배역(인터페이스)와 남자 배우(구현체)가 있다고 친다. 지금 상황은 남자 배우가 여자 배우를 직접 부르는 것과 같다. 본인 일에만 집중해야 하는데, 여러 가지 일을 맡게 된 것이다. 이를 방지하기 위해선 관심사를 분리해야 한다. 즉, 공연 기획자
를 데려와 공연을 구성하고, 배우 섭외와 배우를 지정해주는 역할을 해주어야 한다.
AppConfig
애플리케이션의 전체 동작 방식을 구성(config)하기 위해, 구현 객체를 생성하고 연결
하는 책임을 가지는 별도의 설정 클래스이다.
package hello.core;
import hello.core.discount.FixDiscountPolicy;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;
public class AppConfig {
public MemberService memberService() {
return new MemberServiceImpl(new MemoryMemberRepository());
}
public OrderService orderService() {
return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
}
}
package hello.core;
import hello.core.member.Grade;
import hello.core.member.Member;
import hello.core.member.MemberService;
public class MemberApp {
public static void main(String[] args) {
AppConfig appConfig = new AppConfig();
MemberService memberService = appConfig.memberService();
Member member = new Member(1L, "memberA", Grade.VIP);
memberService.join(member);
Member findMember = memberService.findMember(1L);
System.out.println("new member = " + member.getName());
System.out.println("find member = " + findMember.getName());
}
}
이런식으로 해두면 Service에서는 인터페이스만 의존하고, 실행만하면 된다.
IOC, DI, 컨테이너
제어의 역전 IoC(Inversion of Control)
기존 프로그램은 클라이언트 구현 객체가 스스로 필요한 서버 구현 객체를 생성하고, 연결하고, 실행했다. 즉 구현 객체가 프로그램의 제어 흐름을 스스로 조종했다. 개발자의 입장에서는 자연스러운 흐름이다. 반면 AppConfig가 등장하여 구현 객체는 자신의 로직을 실행하는 역할만 담당한다. 프로그램의 제어 흐름은 이제 AppConfig가 가져가게 되어 OrderServiceImpl
은 필요한 인터페이스들을 호출하지만 어떤 구현 객체들이 실행될지 모른다. 다시말해 제어 흐름에 대한 것들은 AppConfig가 가지고 있고 위의 OrderServiceImpl도 AppConfig가 생성한다. 뿐만 아니라 다른 인터페이스의 다른 구현 객체를 생성하고 실행한다. 이렇게 되면 OrderServiceImpl은 자신의 로직만 실행하면 된다. 이렇듯 프로그램의 제어 흐름을 직접 제어하는 것이 아니라 외부에서 관리하는 것을 제어의 역전이라고 한다.
프레임워크 vs 라이브러리**
- 프레임워크가 내가 작성한 코드를 제어하고, 대신 실행하면 그것은 프레임워크이다. (JUit)
- 내가 작성한 코드가 직접 제어의 흐름을 담당한다면 그것은 프레임워크가 아니라 라이브러리다.
의존관계 주입 DI(Dependency Injection)
OrderServiceImpl은 DiscountPolicy라는 인터페이스에 의존한다. 실제 어떤 구현 객체가 사용될지는 모른다. 의존관계는 정적인 클래스 의존 관계와, 실행 시점에 결정되는 동적인 객체(인스턴스) 의존 관계 둘을 분리해서 생각해야 한다.
정적인 클래스 의존관계
클래스가 사용하는 import 코드만 보고 의존관계를 쉽게 판단할 수 있다. 정적인 의존관계는 애플리케이션을 실행하지 않아도 분석할 수 있다. 예로 OrderServiceImpl은 MeberRepository, DiscountPolicy에 의존한다는 것을 알 수는 있다. 하지만 어떤 구현 객체가 주입될지는 알 수 없다.
동적인 객체 인스턴스 의존관계
애플리케이션 실행 시점(런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달해서 클라이언트와 서버의 실제 의존관계가 연결 되는 것을 의존관계 주입
이라고 한다. 객체 인스턴스를 생성하고, 그 참조값을 전달해서 연결되는 것이다. 이 의존관계 주입을 사용하면 클라이언트 코드를 변경하지 않고, 클라이언트가 호출하는 대상의 타입 인스턴스를 변경할 수 있다. 또한 정적인 클래스 의존관계를 변경하지 않고, 동적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있다.
IoC 컨테너 or DI 컨테이너
- AppConfig처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 IoC 또는 DI 컨테이너라고 한다.
- 의존관계 주입에 초점을 맞추어 최근에는 DI 컨테이너라 한다.
- 또는 어샘블러, 오브젝트 팩토리 등으로 불리기도 한다.
스프링으로 전환하기
이제 슬슬 스프링으로 넘어가는 설정을 하겠다. AppConfig에 다음과 같이 어노테이션들을 달아주자.
어노테이션은 발표해본게 있으니 정리하겠다.
@Configuration
public class AppConfig {
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public OrderService orderService() {
return new OrderServiceImpl(memberRepository(), discountPolicy());
}
@Bean
public DiscountPolicy discountPolicy() {
// return new FixDiscountPolicy();
return new RateDiscountPolicy();
}
}
public class MemberApp {
public static void main(String[] args) {
// AppConfig appConfig = new AppConfig();
// MemberService memberService = appConfig.memberService();
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
Member member = new Member(1L, "memberA", Grade.VIP);
memberService.join(member);
Member findMember = memberService.findMember(1L);
System.out.println("new member = " + member.getName());
System.out.println("find member = " + findMember.getName());
}
}
MemberApp에 AnnotationConfigApplicationContext를 생성했는데 이것은 어노테이션 기반으로 빈 객체를 만들어주는 클래스이다. 이를 토대로 우리가 @Bean으로 등록한 객체들을 싱글톤 객체로 관리해준다. 실행하면 아래와 같이 출력된다.
19:22:30.034 [main] DEBUG org.springframework.context.annotation.AnnotationConfigApplicationContext - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@17d677df
19:22:30.085 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.annotation.internalConfigurationAnnotationProcessor'
19:22:30.702 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.event.internalEventListenerProcessor'
19:22:30.705 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.event.internalEventListenerFactory'
19:22:30.708 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.annotation.internalAutowiredAnnotationProcessor'
19:22:30.711 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'org.springframework.context.annotation.internalCommonAnnotationProcessor'
19:22:30.735 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'appConfig'
19:22:30.749 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'memberService'
19:22:30.823 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'memberRepository'
19:22:30.836 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'orderService'
19:22:30.839 [main] DEBUG org.springframework.beans.factory.support.DefaultListableBeanFactory - Creating shared instance of singleton bean 'discountPolicy'
스프링 컨테이너와 스프링 빈
스프링 컨테이너 생성
- ApplicationContext를 스프링 컨테이너라 한다.
- ApplicationContext는 인터페이스이다. 즉 다형성이 적용된다.
- XML로도 할 수 있지만 자바 설정을 많이 쓴다.
컨테이너에 등록된 모든 빈 조회
@Test
@DisplayName("애플리케이션 빈 출력하기")
void findApplicationBean() {
String[] beanDefinitionNames = ac.getBeanDefinitionNames();
for (String beanDefinitionName : beanDefinitionNames) {
BeanDefinition beanDefinition = ac.getBeanDefinition(beanDefinitionName);
// Role ROLE_APPLICATION: 직접 등록한 애플리케이션 빈
// Role ROLE_INFRASTRUCTURE: 스프링이 내부에서 사용하는 빈
if (beanDefinition.getRole() == BeanDefinition.ROLE_APPLICATION) {
Object bean = ac.getBean(beanDefinitionName);
System.out.println("name = " + beanDefinitionName + " object name = " + bean);
}
}
}
스프링 빈 조회
스프링 컨테이너에서 스프링 빈을 찾는 가장 기본적인 조회 방법은 다음과 같다.
- ac.getBean(빈이름, 타입)
- ac.getBean(타입)
- 조회 대상 스프링 빈이 없으면 예외 발생한다.
- NoSuchBeanDefinitionException: No bean named ‘xxxx’ available
package hello.core.beanfind;
import hello.core.AppConfig;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class ApplicationContextBasicFindTest {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
@Test
@DisplayName("빈 이름으로 조회")
void findBeanByName() {
MemberService memberService = ac.getBean("memberService", MemberService.class);
assertThat(memberService).isInstanceOf(MemberServiceImpl.class);
}
@Test
@DisplayName("이름 없이 타입으로만 조회")
void findBeanByType() {
MemberService memberService = ac.getBean(MemberService.class);
assertThat(memberService).isInstanceOf(MemberServiceImpl.class);
}
@Test
@DisplayName("구체 타입으로 조회")
void findBeanByName2() {
MemberService memberService = ac.getBean("memberService", MemberServiceImpl.class);
assertThat(memberService).isInstanceOf(MemberServiceImpl.class);
}
@Test
@DisplayName("빈 이름으로 조회X")
void findBeanByNameX() {
// ac.getBean("xxxxx", MemberService.class);
assertThrows(NoSuchBeanDefinitionException.class, () -> ac.getBean("xxxxx", MemberService.class));
}
}
스프링 빈 조회 - 동일한 타입이 둘 이상
public class ApplicationContextSameBeanFindTest {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SameBeanConfig.class);
@Test
@DisplayName("타입으로 조회시 같은 타입이 둘 이상 있으면, 중복 오류가 발생한다")
void findBeanByTypeDuplicate() {
MemberRepository bean = ac.getBean(MemberRepository.class);
}
@Configuration
static class SameBeanConfig {
@Bean
public MemberRepository memberRepository1() {
return new MemoryMemberRepository();
}
@Bean
public MemberRepository memberRepository2() {
return new MemoryMemberRepository();
}
}
}
위와 같이 타입만 지정해두면 동일한 타입을 가진 빈이 2개가 있기에 예외를 발생시킨다.
NoUniqueBeanDefinitionException
BeanFactory와 ApplicationContext
다양한 설정 형식 지원 - 자바 코드, XML
스프링 컨테이너는 다양한 형식의 설정 정보를 받아드릴 수 있게 유연하게 설계되어있다.
ex) 자바 코드, XML, Groovy 등
- XML 설정 사용
최근에는 스프링 부트를 많이 사용하면서 잘 사용하지 않는다. 아직 레거시 프로젝트들이 XML로 이루어져있고, XML을 사용하면 컴파일 없이 빈 설정 정보를 변경할 수 있는 장점도 있다. GenericXmlApplicationContext
를 사용한다.
package hello.core.xml;
import hello.core.member.MemberService;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.support.GenericXmlApplicationContext;
import org.springframework.test.context.support.GenericXmlContextLoader;
public class XmlAppContext {
@Test
void xmlAppContext() {
ApplicationContext ac = new GenericXmlApplicationContext("appConfig.xml");
MemberService memberService = ac.getBean("memberService", MemberService.class);
Assertions.assertThat(memberService).isInstanceOf(MemberService.class);
}
}
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="memberService" class="hello.core.member.MemberServiceImpl">
<constructor-arg name="memberRepository" ref="memberRepository" />
</bean>
<bean id="memberRepository" class="hello.core.member.MemoryMemberRepository"/>
<bean id="orderService" class="hello.core.order.OrderServiceImpl">
<constructor-arg name="memberRepository" ref="memberRepository"/>
<constructor-arg name="discountPolicy" ref="discountPolicy"/>
</bean>
<bean id="discountPolicy" class="hello.core.discount.RateDiscountPolicy"/>
</beans>
이렇게 스프링은 정말 유연하게 여러 설정을 제공해준다.
스프링 빈 설정 메타 정보 - BeanDefinition
스프링이 이렇게 다양한 설정 형식을 지원할 수 있는 이유는 BeanDefiniton
이라는 추상화가 있어서다. 쉽게 말해 역할과 구현을 개념적으로 나눈 것이다.
예로 XML을 읽어서 BeanDefinition을 만들거나, 자바 코드를 읽어서 BeanDefinition을 만든다. 스프링 컨테이너는 자바 코드인지, XML인지 몰라도 된다. 오직 BeanDefinition만 알면 된다. 이 BeanDefinition을 빈 설정 메타정보라고 한다. @Bean이나 <bean> 당 각각 하나씩 메타 정보가 생성된다.
싱글톤 컨테이너
웹 애플리케이션과 싱글톤
스프링은 태싱이 기업용 온라인 서비스 기술을 지원하기 위해 탄생했다. 대부분의 스프링 애플리케이션은 웹 애플리케이션이다. 웹 뿐만 아니라 애플리케이션 개발도 얼마든지 할 수 있다. 웹 애플리케이션은 보통 여러 고객이 동시에 요청을 한다. 요청을 할 때마다 객체를 생성하고 소비하는 것은 낭비가 될 수도 있다. 만약 고객 트래픽이 초당 100이 나오면 초당 100개 객체가 생성되고 소멸되는 것이다. 이를 방지하기 위해 하나의 객체만 생성해서 그것을 공유하도록 설계하면 되는 것이다. 이런 디자인 패턴을 싱글톤 패턴이라고 한다.
싱글톤 패턴
싱글톤 패턴은 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다. 그렇기에 객체 인스턴스를 2개 이상 생성하지 못하도록 막아야 한다. 즉, private 생성자를 사용해서 외부에서 임의로 new 키워드를 사용하지 못하도록 막아야 한다.
public class SingletonService {
private static final SingletonService instance = new SingletonService();
public static SingletonService getInstance() {
return instance;
}
private SingletonService() {
}
public void login() {
System.out.println("싱글톤 객체 로직 호출");
}
}
이렇게 해두면 jvm이 올라오면서 static final SingletonService
부분을 초기화해두어서 접근할 수 있게 해준다. 그리고 생성자를 private으로 했기에 new로 접근할 수 없고 오직 getInstance() 스태틱 메소드로 접근할 수 있도록 해준다. 이런 싱글톤 패턴을 적용하면 고객의 요청이 올 때 마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 효율적으로 사용할 수 는 있지만 다음과 같은 문제점을 가지고 있다.
- 싱글톤 패턴을 구현하는 코드 자체가 많이 들어감.
- 의존관계상 클라이언트가 구체 클래스에 의존 -> DIP 위반
- 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.
- 테스트하기 어렵다.
- 내부 속성을 변경하거나 초기화 하기 어렵다.
- private 생성자로 자식 클래스를 만들기 어렵다.
- 위와 같은 문제로 유연성이 떨어지고 안티패턴으로 될 수도 있다.
싱글톤 컨테이너
스프링 컨테이너는 위의 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤(1개만 생성)으로 관리해준다. 바로 빈 객체를 만들어서 해주는 것이다. 스프링 컨테이너는 싱글턴 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리한다. 이렇게 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라 한다. 스프링의 컨테이너의 이런 기능 덕분에 우리는 싱글턴 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있는 것이다. 참고로 스프링 컨테이너는 싱글톤 방식뿐만 아니라 다른 방식도 설정할 수 있다. 근데 거의 싱글톤으로 많이 쓴다고 한다.
싱글톤 방식의 주의점
싱글톤 패턴이든, 스프링 같은 싱글톤 컨테이너를 사용하든, 객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful)하게 설계하면 안된다. 즉, 무상태(stateless)로 설계해야 한다. 다시 정리하면
- 특정 클라이언트에 의존적인 필드가 있으면 안된다.
- 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
- 가급적 읽기만 가능해야 한다.
- 필드 대신에 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.
- 스프링 빈이 필드에 공유 값을 설정하면 정말 큰 장애가 발생할 수 있다.
package hello.core.singleton;
public class StatefulService {
private int price; // 상태를 유지하는 필드
public void order(String name, int price) {
System.out.println("name = " + name + " price = " + price);
this.price = price; // 여기서 문제 발생
}
public int getPrice() {
return price;
}
}
class StatefulServiceTest {
@Test
void statefulServiceSingleton() {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
StatefulService statefulService1 = ac.getBean(StatefulService.class);
StatefulService statefulService2 = ac.getBean(StatefulService.class);
// ThreadA: A 사용자가 10000원 주문
statefulService1.order("userA", 10000);
// ThreadB: B 사용자가 20000원 주문
statefulService2.order("userB", 20000);
// ThreadA: 사용자A 주문 금액 조회
int price = statefulService1.getPrice();
System.out.println("price = " + price);
Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
static class TestConfig {
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
위의 코드대로 하면은 우리는 분명 A 사용자가 만원을 했는데 중간의 B 사용자로 인해 A 사용자의 돈도 2만원으로 찍히게 되는 것이다. 이러면 현장에서 정말 큰 장애가 발생하는 것이다. 그렇기에 스프링 빈은 반드시
무상태(stateless)로 설계해야 한다.
@Configuration 싱글톤
@Configuration 바이트 조작의 마법
컴포넌트 스캔
컴포넌트 스캔과 의존관계 자동 주입 시작하기
지금까지는 스프링 빈을 등록할 때 자바 코드에 @Bean이나 XML의 bean 등을 통해 설정 정보에 직접 등록할 스프링 빈을 나열했다. 그런데 현업에서는 이렇게 등록해야할 빈이 수십, 수백개가 되면 관리하기가 많이 힘들어 진다.
일단 개발자들은 반복을 싫어한다.
그래서 스프링에서는 설정 정보가 없어도 자동으로 스프링 빈을 등록하는 컴포넌트 스캔이라는 기능을 제공한다. 또한 의존관계도 자동으로 주입하는 @Autowired
라는 기능도 제공한다.
package hello.core;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
@Configuration
@ComponentScan(
excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Configuration.class)
)
public class AutoAppConfig {
}
- @ComponentScan을 통해 @Component들을 찾아 빈 객체로 등록을 해준다.
- excludeFilters에서 @Configuration 어노테이션은 빈으로 등록 안하도록 설정해주었다. 기존의 AppConfig 클래스를 등록하면 중복이 발생하니까.
- @Component를 붙혔으면 @ComponentScan가 찾아서 빈으로 등록하는데 클래스명을 사용하면서 맨 앞글자를 소문자로 바꾼다. 만약 MemberServiceImp이면 memberServiceImpl로 등록한다.
- 만약 빈 이름을 지정하고 싶으면 @Component(“이름”) 써주면 된다.
그리고 의존성을 주입하기 위해서는 클래스의 생성자에다가 @Autowired를 달아준다. 이렇게 하면 스프링 컨테이너가 타입이 맞는 빈 객체를 찾아서 넣어준다.
탐색 위치와 기본 스캔 대상
탐색할 패키지의 시작 위치 지정
모든 자바 클래스를 다 컴포넌트 스캔하면 시간이 오래 걸린다. 그래서 꼭 필요한 위치부터 탐색하도록 시작 위치를 지정할 수 있다.
@ComponentScan(
basePackges = "hello.core",
)
디폴트로는 @ComponentScan이 붙은 설정 정보 클래스의 패키지가 시작 위치가 된다.
권장방법으로 패키지 위치를 지정하지 않고, 설정 정보 클래스의 위치를 프로젝트 최상단에 두는 것이다. 최근 스프링 부트도 이 방법을 기본으로 제공한다. 가령 다음과 같은 구조가 있다 하자.
- com.hello
- com.hello.service
- com.hello.repository
이렇게 있으면 com.hello가 시작 루트이고, 여기에 AppConfig 같은 메인 설정 정보를 두고 @ComponentScan 어노테이션을 붙이고, basePackages 지정은 생략한다. 요새는 관례를 따르는것이 추세이기에 이렇게 프로젝트를 대표하는 정보같은 설정 정보는 시작 루트 위치에 두는 것이 좋다. 추가적으로 스프링 부트를 사용하면 스프링 부트의 대표 시작 정보인 @SpringBootApplication
를 이 프로젝트 시작 루트 위치에 두는 것이 관례이고, 이 안에도 @ComponentScan이 달려있다.
컴포넌트 스캔 기본 대상
컴포넌트 스캔은 @Component 뿐만 아니라 다음 내용도 추가로 대상에 포함된다.
- @Component: 컴포넌트 스캔에서 사용
- @Controller: 스프링 MVC 컨트롤러에서 사용
- @Service: 스프링 비즈니스 로직에서 사용
- @Repository: 스프링 데이터 접근 계층에서 사용
- @Configuration: 스프링 설정 정보에서 사용
위 항목들은 기본으로 @Component를 달고 있다. 또한 부가 기능을 수행해준다.
- @Controller: 스프링 MVC 컨트롤러로 인식
- @Repository: 스프링 데이터 접근 계층으로 인식하고, 데이터 계층의 예외를 스프링 예외로 변환해준다.
- @Configuration: 스프링 설정 정보로 인식하고, 스프링 빈이 싱글톤을 유지하도록 추가 저리
- @Service: 특별한 처리는 하지 않지만, 개발자들이 핵심 비즈니스 로직이 여기에 있겠구나 라고 비즈니스 계층을 인식하는데 도움이 된다.
어노테이션에는 상속관계라는 것이 없다. 그래서 이렇게 어노테이션이 특정 어노테이션을 들고 있는 것은 인식할 수 있는 것은 자바 언어가 지원하는 기능은 아니고, 스프링이 지원하는 기능이다.
필터
- includeFilters: 컴포넌트 스캔 대상을 추가로 지정한다.
- excludeFilters: 컴포넌트 스캔에서 제외할 대상을 지정한다.
@Configuration
@ComponentScan(
includeFilters = @Filter(type = FilterType.ANNOTATION, classes = MyIncludeComponent.class),
excludeFilters = @Filter(type = FilterType.ANNOTATION, classes = MyExcludeComponent.class)
)
FilterType 옵션으로는 여러가지가 있다.
- ANNOTATION: 기본값, 어노테이션을 인식해서 동작한다.
- ASSIGNABLE_TYPE: 지정한 타입과 자식 타입을 인식해서 동작한다.
- ASPECTJ: AspectJ 패턴 사용
- REGEX: 정규표현식
- CUSTOM: TypeFilter 이라는 인터페이스를 구현해서 처리
includeFilters를 사용할 일이 거의 없다. excludeFilters는 여러가지 이유로 간혹 사용할 때가 있지만 많지는 않다. 옵션을 변경하기보다는 스프링의 기본 설정에 최대한 맞추어 사용하는 것을 권장한다.
중복 등록과 충돌
컴포넌트 스캔에서 같은 빈 이름을 등록하면 충돌이 일어난다. 보통 이런 경우가 있다.
- 자동 빈 등록 vs 자동 빈 등록
- 수동 빈 등록 vs 자동 빈 등록
자동 빈 등록 vs 자동 빈 등록
컴포넌트 스캔에 의해 자동으로 스프링 빈이 등록되는데, 그 이름이 같은 경우 오류를 발생시킨다.
ConflictingBeanDefinitionException
수동 빈 등록 vs 자동 빈 등록
이런 경우에는 수동 빈 등록이 우선권을 가지게 되어 자동 빈을 오버라이딩 해버린다.
Overriding bean definition for bean ‘memoryMemberRepository’ with a different definition replacing
개발자가 의도한 상황이면 이래도 되겠지만 보통 이런 식의 일은 결과를 꼬이게 만들게 된다. 즉, 버그가 발생하게 되는 것이다..! 그래서 최근 스프링 부트에서는 수동 빈과 자동 빈 등록이 충돌나면 오류가 발생하도록 기본 값을 바꾸었다.
Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true
의존관계 자동 주입
다양한 의존관계 주입 방법
의존관계 주입은 크게 4가지 방법이 있다.
- 생성자 주입
- 수정자 주입(setter 주입)
- 필드 주입
- 일반 메서드 주입
생성자 주입
생성자를 통해서 의존 관계를 주입 받는 방법이다. 생성자 호출시점에 딱 1번만 호출되는 것이 보장된다. 불변, 필수 의존관계에 사용되는 것이 특징이다.
생성자가 하나인 경우에는 @Autowired 생략해도 된다. (단, 스프링 빈일 때)
수정자 주입(setter 주입)
setter라 불리는 필드의 값을 변경하는 수정자 메서드를 통해서 의존관계를 주입하는 바업이다. 선택, 변경 가능성이 있는 의존관계에서 사용하고 자바빈 프로퍼티 규약의 수정자 메서드 방식을 사용하는 방법이다.
자바빈 프로퍼티, 자바에서는 과거부터 필드의 값을 직접 변경하지 않고, setXXX, getXXX 라는 메서드를 통해서 값을 읽거나 수정하는 규칙을 만들었는데 이것을 자바빈 프로퍼티라고 한다.
class Data {
private int age;
public void setAge(int age) {
this.age = age;
}
public int getAge() {
return age;
}
}
필드 주입
필드에 바로 주입하는 방법이다. 코드가 간결해서 많은 개발자들을 유혹하지만 외부에서 변경이 불가능해서 테스트가히 어려운 치명적인 단점이 있다. DI 프레임워크가 없으면 아무것도 할 수 없다. 되도록이면 사용하지 말자.
일반 메서드 주입
일반 메서드를 통해서 주입 받을 수 있다. 한번에 여러 필드를 주입 받을 수 있는데, 일반적으로 잘 사용하지 않는다.
옵션 처리
주입할 스프링 빈이 없어도 동작해야 할 때가 있다. 그런데 @Autowired만 사용하면 required 옵션의 기본값이 true로 되어 있어서 자동 주입 대상이 없으면 오류가 발생한다. 자동 주입 대상을 옵션으로 처리하는 방법은 다음과 같다.
- @Autowired(required=false): 자동 주입할 대상이 없으면 수정자 메서드 자체가 호출 안됨
- org.springframework.lang@Nullable: 자동 주입할 대상이 없으면 null이 입력된다.
- Optional<>: 자동 주입할 대상이 없으면 Optional.empty가 입력된다.
public class AutowiredTest {
@Test
void AutowiredOption() {
ApplicationContext ac = new AnnotationConfigApplicationContext(TestBean.class);
}
static class TestBean {
@Autowired(required = false)
public void setNoBean1(Member noBean1) {
System.out.println("noBean1 = " + noBean1);
}
@Autowired
public void setNoBean2(@Nullable Member noBean2) {
System.out.println("noBean2 = " + noBean2);
}
@Autowired
public void setNoBean3(Optional<Member> noBean3) {
System.out.println("noBean3 = " + noBean3);
}
}
}
@Autowired(false)이면 빈 객체가 없으면 메서드 자체가 실행되지 않아 내용이 출력되지 않는다.
@Nullable, Optional은 스프링 전반에 걸쳐서 지원된다. 예를 들어 생성자 자동 주입에서 특정 필드에만 사용해도 된다.
생성자 주입을 선택해라!
과거에는 수정자 주입과 필드 주입을 많이 사용했지만, 최근에는 스프링을 포함한 DI 프레임워크 대부분이 생성자 주입을 권장한다. 이유는 다음과 같다.
불변
- 대부분의 의존관계 주입은 한 번 일어나면 애플리케이션 종료시점까지 의존관계를 변경할 일이 없다. 오히려 대부분의 의존관계는 애플리케이션 종료 전까지 변하면 안된다.(즉, 불변해야 한다.)
- 수정자 주입을 사용하면, setXXX 메서드를 public으로 열어두어야 한다.
- 누군가 실수로 변경할 수도 있고, 변경하면 안되는 메서드를 열어두는 것은 좋은 설계 방법이 아니다.
- 생성자 주입은 객체를 생성할 때 딱 한 번만 호출되므로 이후에 호출되는 일이 없다. 따라서 불변하게 설계 가능하다.
final 키워드
생성자 주입을 사용하면 필드에 final 키워드를 사용할 수 있다. 이렇게 하여 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에 막아준다.
컴파일 오류는 세상에서 가장 빠르고 좋은 오류다
수정자 주입을 포함한 나머지 주입 방식은 모두 생성자 이후에 호출되므로, 필드에 final 키워드를 사용할 수 없다. 오직 생성자 주입 방식만 final 키워드를 사용할 수 있다.
롬복과 최신 트렌드
막상 개발을 해보면, 대부분이 다 불변이기에 생성자에 final 키워드를 사용하게 된다. 그런데 생성자도 만들어야 하고, 주입 받은 값을 대입하는 코드도 만들어야 하는 귀찮음이 있다. 역시 개발자들은 편리함을 좋아하기에 무엇인가 만들어 놓았다. 바로 lombok이다.
@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;
@Override
public Order createOrder(Long memberId, String itemName, int itemPrice) {
Member member = memberRepository.findById(memberId);
int discountPrice = discountPolicy.discount(member, itemPrice);
return new Order(memberId, itemName, itemPrice, discountPrice);
}
}
@RequiredArgsConstructor를 사용하면 final로 지정한 필드에 대한 생성자를 만들어 준다. 롬복은 자바의 어노테이션 프로세서라는 기능을 이용해서 컴파일 시점에 생성자 코드를 자동으로 생성해준다. 실제로 .class 파일을 열어보면 내용이 추가된 것을 볼 수 있다.
조회 빈이 2개 이상 - 문제
@Autowired는 기본적으로 타입을 탐색한다. 그러다 보니 타입이 두 개 이상일 때 문제가 발생한다.
NoUniqueBeanDefinitionException 예외 발생
이때 하위 타입으로 지정하여 해결할 수도 있겠지만, DIP를 위반하고 유연성이 떨어지게 된다. 그렇기에 다른 방법으로 해결해야 한다.
@Autowired 필드 명, @Qualifier, @Primary
조회 빈이 2개 이상일 때 해결 방법은 다음과 같다.
- @Autowired 필드 명 매칭
- @Qualifier -> @Qualifier끼리 매칭 -> 빈 이름 매칭
- @Primary 사용
@Autowired 필드 명 매칭
@Autowired는 처음에는 타입 매칭을 시도하고, 여러 빈이 있으면 필드 이름, 파라미터 이름으로 빈 이름을 추가 매칭한다.
- 타입 매칭
- 타입 매칭 결과가 2개 이상일 때 필드명, 파라미터 명으로 빈 이름 매칭
@Qualifier 사용
추가 구분자를 붙여주는 방법이다. 주입시 추가적인 방법을 제공하는 것이지 빈 이름을 변경하는 것은 아니다. @Qualifier로 주입할 때 @Qualifier(“mainDiscountPolicy”)를 못 찾으면, mainDiscountPolicy라는 이름의 스프링 빈을 추가로 찾는다. 번거로운 점으론 모든 코드에 @Qualifier를 붙여주어야 하는 것이다.
@Primary 사용
우선순위를 정하는 방법이다. @Autowired 시에 여러 번 매칭되면 @Primary가 우선권을 가진다. 먼저 적용해줄 컴포넌트 클래스에 적어주면 된다.
@Primary, @Qualifier 활용
코드에서 자주 사용하는 메인 데이터베이스의 커넥션을 획득하는 스프링 빈이 있고, 코드에서 특별한 기능으로 가끔 사용하는 서브 데이터베이스의 커넥션을 획득하는 스프링 빈이 있다고 가정해보자. 메인 데이터베이스의 커넥션을 획득하는 스프링 빈은 @Primary를 적용해서 조회하는 곳에서 @Qualifier 지정 없이 편리하게 조회하고, 서브 데이터베이스 커넥션 빈을 획득할 때는 @Qualifier를 지정해서 명시적으로 획득하는 방식으로 사용하면 코드를 깔끔하게 유지할 수 있다. 물론 이때 메인 데이터베이스의 스프링 빈을 등록할 때 @Qualifier를 지정해주는 것은 상관없다.
@Primary는 기본값처럼 동작하는 것이고, @Qualifier는 매우 상세하게 동작한다. 스프링은 자동보다는 수동이, 넓은 범위의 선택권보다는 좁은 범위의 선택권이 우선 순위가 높다. 따라서 @Qualifier가 우선권이 높다.
어노테이션 직접 만들기
위에서 배운 @Qualifier(“mainDiscountPolicy”)는 문자열이기에 컴파일 시 타입 체크가 안된다. 다음처럼 만들면 문제를 해결할 수 있다.
import org.springframework.beans.factory.annotation.Qualifier;
import java.lang.annotation.*;
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Qualifier("mainDiscountPolicy")
public @interface MainDiscountPolicy {
}
어노테이션은 상속이라는 개념이 없다. 이렇게 여러 어노테이션을 모아서 사용하는 기능은 스프링이 지원해주는 기능이다. @Qualifier 뿐만 아니라 다른 어노테이션들도 함께 조합해서 사용할 수 있다. 단적으로 @Autowired도 재정의 할 수 있다. 물론 스프링이 제공하는 기능을 뚜렷한 목적 없이 무분별하게 재정의하는 것은 유지보수에 더 혼란만 가중할 수 있다.
조회한 빈이 모두 필요할 때, List, Map
의도적으로 해당 타입의 스프링 빈이 다 필요한 경우가 있다. 예를 들어, 할인 서비스를 제공하는데 클라이언트가 할인의 종류(rate, fix)를 선택할 수 있다 가정해보자. 스프링을 사용하면 전략 패턴을 매우 간단히 구현할 수 있다.
public class AllBeanTest {
@Test
void findAllBean() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class);
DiscountService discountService = ac.getBean(DiscountService.class);
Member member = new Member(1L, "userA", Grade.VIP);
int discountPrice = discountService.discount(member, 10000, "fixDiscountPolicy");
assertThat(discountService).isInstanceOf(DiscountService.class);
assertThat(discountPrice).isEqualTo(1000);
int rateDiscountPrice = discountService.discount(member, 20000, "rateDiscountPolicy");
assertThat(rateDiscountPrice).isEqualTo(2000);
}
static class DiscountService {
private final Map<String, DiscountPolicy> policyMap;
private final List<DiscountPolicy> policies;
@Autowired
public DiscountService(Map<String, DiscountPolicy> policyMap, List<DiscountPolicy> policies) {
this.policyMap = policyMap;
this.policies = policies;
System.out.println("policyMap = " + policyMap);
System.out.println("policies = " + policies);
}
public int discount(Member member, int price, String discountCode) {
DiscountPolicy discountPolicy = policyMap.get(discountCode);
return discountPolicy.discount(member, price);
}
}
}
자동, 수동의 올바른 실무 운영 기준
스프링이 나오고 시간이 갈수록 자동을 선호하는 추세이다. @Component뿐만 아니라 @Controller, @Service, @Repository처럼 계층에 맞추어 일반적인 애플리케이션 로직을 자동으로 스캔할 수 있도록 지원한다.
빈 생명주기 콜백
빈 생명주기 콜백 시작
데이터베이스 커넥션 풀이나, 네트워크 소켓처럼 애플리케이션 시작 시점에 필요한 연결을 미리 해두고, 애플리케이션 종료 시점에 연결을 모두 종료하는 작업을 진행하려면, 객체의 초기화와 종료 작업이 필요하다. 스프링에서는 어떻게 진행하는지 예제로 알아보자.
스프링 빈은 다음과 같은 라이프사이클을 가진다. 객체 생성 -> 의존관계 주입
스프링 빈은 객체를 생성하고, 의존관걔 주입이 다 끝난 다음에야 필요한 데이터를 사용할 수 있는 준비가 완료된다. 따라서 초기화 작업은 의존관계 주입이 모두 완료되고 난 다음에 호출해야 한다. 그러면 개발자는 어떻게 의존관계 주입이 모두 완료되었는지 알 수 있을까? 이는 스프링에서 의존관계 주입이 완료되면 스프링 빈에게 콜백 메서드를 통해서 초기화 시점을 알려주는 다양한 기능을 제공한다. 또한 종료되기 직전에 소멸 콜백을 준다.
스프링 빈의 이벤트 라이플 사이클
스프링 컨테이너 생성 -> 스프링 빈 생성 -> 의존 관계 주입 -> 초기화 콜백 -> 사용 -> 소멸전 콜백 -> 스프링 종료
스프링은 크게 3가지 방법으로 빈 생명주기 콜백을 지원한다.
- 인터페이스(InitializingBean, DisposableBean)
- 설정 정보에 초기화 메서드, 종료 메서드 지정
- @PostConstruct, @PreDestroy 어노테이션 지원
인터페이스 InitializingBean, DisposableBean
package hello.core.lifecycle;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.beans.factory.InitializingBean;
public class NetworkClient implements InitializingBean, DisposableBean {
// 의존관계 주입이 끝나면
@Override
public void afterPropertiesSet() throws Exception {
System.out.println("NetworkClient.afterPropertiesSet");
connect();
call("초기화 연결 메시지");
}
@Override
public void destroy() throws Exception {
System.out.println("NetworkClient.destroy");
disconnect();
}
}
인터페이스를 상속받아 구현하면 된다. 이때의 문제점은 스프링 전용 인터페이스이기에 해당 코드가 스프링 전용 인터페이스에 의존되고, 초기화 소멸 메서드의 이름을 변경할 수 없고, 내가 코드를 고칠 수 없는 외부 라이브러리(클래스 파일로 받아진것들)에는 적용할 수 없다. 이 방법은 초창기 방법이고 지금은 더 나은 방법들이 있어 거의 사용되지 않는다.
빈 등록 초기화, 소멸 메서드
설정 정보에 @Bean(initMethod ="init", destroyMethod = "close")
처럼 초기화, 소멸 메서드를 지정할 수 있다.
@Configuration
static class LifeCycleConfig {
@Bean(initMethod = "init", destroyMethod = "close")
public NetworkClient networkClient() {
NetworkClient networkClient = new NetworkClient();
networkClient.setUrl("http://hello-spring.dev");
return networkClient;
}
}
// Network Class
public void init() {
System.out.println("NetworkClient.init");
connect();
call("초기화 연결 메시지");
}
public void close() {
System.out.println("NetworkClient.close");
disconnect();
}
@Bean에서 initMethod와 destoryMethod를 통해 이름을 지정했다. 이렇게 함으로써 메서드 이름을 자유롭게 줄 수 있고, 스프링 빈이 스프링 코드에 의존하지 않고, 코드가 아니라 설정 정보를 사용하기 때문에 코드를 고칠 수 없는 외부 라이브러리에도 초기호, 종료 메서드를 적용할 수 있다.
종료 메서드 추론
@Bean의 destroyMethod 속성에는 아주 특별한 기능이 있는데, 라이브러리는 대부분 close, shutdown이라는 이름으로 종료 메서드를 가지고 있다. @Bean의 destroyMethod는 기본값이 inferred (추론)
으로 등록되어있는데, 이 추론 기능을 통해 close나 shutdown라는 메서드를 자동으로 호출해준다. 그렇기에 종료 메서드는 따로 적어주지 않아도 잘 동작하고, 추론 기능을 사용하기 싫으면 destoryMethod=”“처럼 빈 공백을 지정하면 된다.
어노테이션 @PostConstruct, @PreDestory
이제는 이 어노테이션을 통해 많이 쓴다고 한다.
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
@PostConstruct
public void init() {
System.out.println("NetworkClient.init");
connect();
call("초기화 연결 메시지");
}
@PreDestroy
public void close() {
System.out.println("NetworkClient.close");
disconnect();
}
- 최신 스프링에서 가장 권장하는 방법이다.
- 어노테이션 하나만 붙이면 되므로 매우 편리하다.
- javax.annotation.PostConstruct. 스프링에 종속적인 기술이 아니라 JSR-250라는 자바 표준이기에 스프링이 아닌 다른 컨테이너에서도 동작한다.
- 컴포넌트 스캔과 잘 어울린다.
- 유일한 단점으론 외부 라이브러리에는 적용하지 못하는 것이다. 외부 라이브러리를 초기호, 종료해야 하면 @Bean의 기능을 사용하자.
javax 패키지는 자바 진영에서 공식적으로 지원하는 패키지이다.
빈 스코프
빈 스코프란?
지금까지 스프링 빈이 스프링 컨테이너의 시작과 함께 생성되어서 스프링 컨테이너가 종료될 때 까지 유지된다고 학습했다. 이것은 스프링 빈이 싱글톤 스코프로 생성되기 때문이다. 스코프는 번역 그대로 빈이 존재할 수 있는 범위를 뜻한다.
스프링은 다양한 스코프를 지원한다.
- 싱글톤: 기본 스코프, 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프이다.
- 프로토타입: 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리하지 않는 매우 짧은 범위의 스코프이다.
- 웹 관련 스코프
- request: 웹 요청이 들어오고 나갈때 까지 유지되는 스코프
- session: 웹 세션이 생성되고 종료될 때 까지 유지되는 스코프
- application: 웹의 서블릿 컨텍스와 같은 범위로 유지되는 스코프
등록은 다음과 같이 지정할 수 있다.
컴포넌트 스캔 자동 등록
@Scope("prototype")
@Component
public class HelloBean {}
수동 등록
@Scope("prototype")
@Bean
PrototypeBean HelloBean() {
return new HelloBean();
}
프로토타입 스코프
싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다. 반면, 프로토타입 스코프로 설정하면 항상 새로운 인스턴스를 생성해서 반환한다. 반환한 이후에는 스프링 컨테이너에선 관리하지 않는다. 그렇기에 프로토타입 빈을 관리할 책임은 프로토타입 빈을 받은 클라이언트에 있다. 그래서 @PreDestroy 같은 종료 메서드가 호출되지 않는다. 클라이언트가 직접 종료하던가 해야한다.
프로토타입 스코프 - 싱글톤 빈과 함께 사용시 문제점
프로토타입과 싱글톤을 같이 사용할 때는 의도한 대로 잘 동작하지 않으므로 주의해야 한다.
package hello.core.scope;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Scope;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import static org.assertj.core.api.Assertions.assertThat;
public class SingletonWithPrototypeTest1 {
@Test
void prototypeFind() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
prototypeBean1.addCount();
assertThat(prototypeBean1.getCount()).isEqualTo(1);
PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
prototypeBean2.addCount();
assertThat(prototypeBean2.getCount()).isEqualTo(1);
}
@Test
void singletonClientUsePrototype() {
AnnotationConfigApplicationContext ac =
new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class);
ClientBean clientBean1 = ac.getBean(ClientBean.class);
int count1 = clientBean1.logic();
assertThat(count1).isEqualTo(1);
ClientBean clientBean2 = ac.getBean(ClientBean.class);
int count2 = clientBean2.logic();
assertThat(count2).isEqualTo(2);
}
@Scope("singleton")
static class ClientBean {
private final PrototypeBean prototypeBean; // 생성시점에 주입
@Autowired
public ClientBean(PrototypeBean prototypeBean) {
this.prototypeBean = prototypeBean;
}
public int logic() {
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
}
@Scope("prototype")
static class PrototypeBean {
private int count = 0;
public void addCount() {
count++;
}
public int getCount() {
return count;
}
@PostConstruct
public void init() {
System.out.println("PrototypeBean.init " + this);
}
@PreDestroy
public void destroy() {
System.out.println("PrototypeBean.destroy");
}
}
}
위대로 테스트하면 두번째 테스트에서 의문점이 생길 것이다. 분명 프로토타입은 계속 생겨야하는 것인 아닌가. 하지만 싱글톤 빈은 생성 시점에만 의존관계를 주입을 받기 때문에, 프로토타입 빈이 새로 생성되기는 하지만, 싱글톤 빈과 함께 계속 유지가 되는 것이다. 사용자는 계속 새로운 프로토타입을 원할 것이다. 이것은 다음 장에서 설명된다.
프로토타입 스코프 - 싱글톤 빈과 함께 사용시 Provideer로 문제 해결
의존관계를 외부에서 주입(DI) 받는게 아니라 직접 필요한 의존관계를 찾는 것을 Dependency Lookup (DL), 의존관계 조회(탐색)이라고 한다. 이런 DL 기능을 활용해서 프로토타입 빈을 찾으면 된다.
ObjectFactory, ObjectProvider
과거에는 ObjectFactory를 사용했는데 편의 기능(옵션, 스트림 처리 등)을 추가해서 ObjectProvider를 사용한다. 둘다 스프링에 의존한다.
@Scope("singleton")
static class ClientBean {
@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
}
JSR-330 Provider
javax.inject.Provider
라는 JSR-330 자바 표준을 사용하는 방법이 있다. 단 라이브러리를 추가해주어야 한다.
@Scope("singleton")
static class ClientBean {
private Provider<PrototypeBean> prototypeBeanProvider;
public int logic() {
PrototypeBean prototypeBean = prototypeBeanProvider.get();
prototypeBean.addCount();
int count = prototypeBean.getCount();
return count;
}
}
provider.get()을 통해 항상 새로운 프로토타입 빈이 생성된다. get()을 호출하면 내부에서는 스프링 컨테이너를 통해 해당 빈을 찾아서 반환해준다.(DL) 자바 표준이고, 기능이 단순하여 단위 테스트를 만들거나 mock 코드를 만들기 훨씬 쉽다.
웹 스코프
웹 스코프는 웹 환경에서만 동작한다. 웹 스코프는 프로토타입과 다르게 스프링이 해당 스코프의 종료시점까지 관리한다. 따라서 종료 메서드가 호출된다. 종류는 다음과 같다.
- request: HTTP 요청 하나가 들어오고 나갈 때까지 유지되는 스코프, 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고, 관리된다.
- session: HTTP Session과 동일한 생명주기를 가지는 스코프
- application: 서블릿 컨텍스트(ServletContext)와 동일한 생명주기를 가지는 스코프
- websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프
request 스코프 예제 만들기
웹 스코프는 웹 환경에서만 동작하므로 web 환경이 동작하도록 라이브러리를 추가해야한다.
implementation 'org.springframework.boot:spring-boot-starter-web'
이 라이브러리를 추가하면 스프링 부트는 내장 톰켓 서버를 활용해서 웹 서버와 스프링을 함께 실행시킨다. 참고로 스프링 부트는 웹 라이브러리가 없으면 우리가 지금까지 학습한 AnnotationConfigApplicationContext
을 기반으로 애플리케이션을 구동한다. 웹 라이브러리가 추가되면 웹과 관련된 추가 설정과 환경들이 필요하므로 AnnotationConfigServletWebServerApplicationContext
를 기반으로 애플리케이션을 구동한다.
다른 포트로 사용하고 싶으면 application.properties에 server.port=9090으로 설정해주자.
스코프와 Provider
ObjectProvider를 사용하면 호출하는 시점까지 request scope 빈의 생성을 지연할 수 있다. ObjectProvider.getObject()를 호출하는 시점에서 HTTP 요청이 진행중이므로 request scope 빈의 생성이 정상 처리된다. 컨트롤러나 서비스에서 따로 호출해도 같은 HTTP 요청이면 같은 스프링 빈이 반환된다.
스코프와 프록시
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger
프록시로 지정을 하면 GCLIB 라이브러리를 통해 위 클래스를 상속 받은 가짜 프록시 객체를 만들어서 주입해준다. 그리고 실제 요청이 오면 그때 내부에서 실제 빈을 요청하는 위임 로직을 동작시킨다. 이 프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯이 사용하여 request scopef를 편하게 이용할 수 있다. Provider나 프록시의 핵심 아이디어는 진짜 객체 조회를 꼭 필요한 시점까지 지연처리 한다는 점이다.