Spring
- 스프링이란 어플리케이션 개발에 필요한, 도움이 되는 기능들을 제공하는 프레임워크이자 생태계다.
- 스프링의 핵심은
IoC/DI,AOP,PSA다.
Inversion of Control / Dependency Injection
- 스프링은 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크이다.
- 스프링에서 이야기하는 제어의 역전(IoC), 의존관계 주입(DI)은 다형성을 활용해서 구현을 편리하게 변경할 수 있다.
- 자바로 OCP, DIP 원칙들을 지키면서 개발을 해보면, 결국 스프링 프레임워크를 만들게 된다.(더 정확히는 DI 컨테이너)
- DI 컨테이너의 역활이 없다면, 구현 객체를 변경할 때 클라이언트 코드도 함께 변경된다. - 구현 객체를 개발자가 직접 넣으니, 변경시 해당 코드가 변경 되어야 함
- 스프링 DI 컨테이너는 객체의 생성 및 의존 관계 연결 책임을 개발자 코드 밖으로 옮겨, 클라이언트 코드를 변경하지 않고도 구현체를 교체할 수 있게 한다.
Inversion of Control(제어의 역전):
- 기존 프로그램은 클라이언트 구현 객체가 스스로 필요한 서버 구현 객체를 생성하고, 연결하고, 실행했다. 한마디로 구현 객체가 프로그램의 제어 흐름을 스스로 조종했다.
- 프로그램의 제어 흐름을 직접 제어 하는 것이 아니라 외부에서 관리하는 것을 제어의 역전(IoC)이라 한다.
- 스프링에서는 객체의 생성과 생명주기 관리를 개발자가 아닌 **스프링 컨테이너(IoC Container)**가 대신
- 의존관계 주입에 초점을 맞추어 최근에는 주로 DI 컨테이너라 한다.
Dependency Injection(의존관계 주입)
- 애플리케이션 실행 시점(런타임)에 외부에서 실제 구현 객체를 생성하고 전달해서 실제 의존관계가 연결 되는 것을 의존관계 주입이라 한다.
- IoC를 구현하는 방식으로 인터페이스만 선언하면, 스프링 컨테이너가 런타임에 적절한 구현체를 '주입(Injection)'
- Spring에 특화된 클래스를 요구하지 않으며,
POJO(Plain-Old Java Object)객체를 의존성 주입을 통해 연결할 수 있다. - 생성자 주입 (Constructor Injection) — 권장 불변성 유지, 필수 의존성 명확화, 테스트 용이
Spring App Lifecyle
- 스프링 컨테이너: 설정정보를 바탕으로 스프링 빈를 생성하고 빈의 라이프사이클 관리 및 의존성주입을 담당하는 컴포넌트
- 스프링 컨테이너의 인터페이스는
ApplicationContext ApplicationContext는BeanFactory를 상속받음BeanFactory: 스프링 컨테이너의 최상위 인터페이스로 **스프링 빈(Spring IoC 컨테이너에 의해 관리되는 객체)**을 관리하고 조회하는 역할을 담당한다.
-
SpringApplication.run() -
애플리케이션 시작 통지 등 초기 이벤트를 처리할 리스너, Environment(application.properties(yml), 시스템 환경 변수, JVM 옵션 등) 준비
-
Spring Boot는 애플리케이션 환경(Web인지 여부)을 보고 적절한 ApplicationContext 구현체를 자동 선택
- AnnotationConfigApplicationContext(일반 Java 애플리케이션)
- AnnotationConfigServletWebServerApplicationContext (Spring Boot Web)
- AnnotationConfigReactiveWebServerApplicationContext (Spring WebFlux)
-
Bean Definition(빈 설정 메타정보) 로딩
- 스프링 컨테이너는 자바 코드인지, XML인지 몰라도 된다. 오직 BeanDefinition만 알면 된다.
@ComponentScan은 @Component가 붙은 모든 클래스를 스프링 빈으로 등록
-
BeanFactoryPostProcessor 실행 - 빈 설정 정보 변경 가능
-
Bean Instantiation(빈 생성)
- 스프링 컨테이너는 빈의 메타정보를 기반으로 스프링 빈을 생성
- 생성자 주입 사용시 객체 생성부터 의존성 빈 주입 보장
- 반면 다른 방법은 객체 생성 후, @Autowired 붙은 필드나 세터에 의존성이 주입
- 스프링은 빈이 싱글톤이 되도록 보장해주어야 한다.
@Configuration을 붙이면 CGLIB로 프록시 객체를 만들어 싱글톤을 보장- 존재하면 빈을 반환, 빈이 없으면 생성해서 스프링 빈으로 등록하고 반환하는 코드를 동적으로 생성
- 스프링 컨테이너는 빈의 메타정보를 기반으로 스프링 빈을 생성
-
빈 후처리기 - BeanPostProcessor
@PostConstruct메서드 실행- 빈 후처리기는 모든 Advisor(포인트컷 + 어드바이스)를 확인
- 타겟 빈이라면 ProxyFactory를 사용해 프록시 객체를 생성해 등록(컨테이너 -> 프록시 객체 -> 빈)
-
ApplicationRunner / CommandLineRunner 실행: 모든 컨테이너 설정과 빈 생성이 끝나면, 마지막으로 이 러너들을 실행
-
애플리케이션 실행
-
애플리케이션이 종료될 때, 컨테이너는 관리하던 빈들을 안전하게 소멸
- @PreDestroy, DisposableBean.destroy() 등이 호출되어 리소스(DB 연결, 파일 스트림 등)를 정리
Spring Bean lifecyle
- 스프링 빈 생성
- 생성자 주입이 필요한 빈이라면, 의존성 주입과 객체 생성이 이 단계에서 함께 일어납니다.
- 즉, 스프링은 의존성(다른 빈)을 먼저 준비한 뒤, 그 의존성을 인자로 넘겨 생성자를 호출합니다. final 필드도 이 시점에 초기화됩니다.
- 의존관계 주입
- 필드 주입(@Autowired가 붙은 필드)이나 세터 주입(@Autowired가 붙은 세터 메서드) 방식에 해당합니다.
- 초기화
- @PostConstruct 어노테이션이 붙은 메서드나, InitializingBean 인터페이스의 afterPropertiesSet() 메서드가 이 시점에 호출됩니다.
- 소멸전 콜백
- @PreDestroy 어노테이션이 붙은 메서드나, DisposableBean 인터페이스의 destroy() 메서드가 이 시점에 호출됩니다.
객체의 생성과 초기화를 분리하자.
- 생성자는 필수 정보(파라미터)를 받고, 메모리를 할당해서 객체를 생성하는 책임을 가진다.
- 반면에 초기화는 이렇게 생성된 값들을 활용해서 외부 커넥션을 연결하는등 무거운 동작을 수행한다.
- 생성자에서 무거운 초기화 작업을 하기보다, @PostConstruct에서 하는 것이 의존성 주입이 확실히 끝난 상태임을 보장받기에 권장
- 객체를 생성하는 부분과 초기화 하는 부분을 명확하게 나누는 것이 유지보수 관점에서 좋다.
- 물론 초기화 작업이 내부 값들만 약간 변경하는 정도로 단순한 경우에는 생성자에서 한번에 다 처리하는게 더 나을 수 있다.
Bean Scope
- 스프링이 해당 빈(Bean)을 어떻게, 얼마나 오래 관리할 것인가를 정의하는 생명주기 정책입니다.
- 이 정책이 중요한 이유는 '상태(State)' 관리와 직결되기 때문
싱글톤 (Singleton)
- 스프링 컨테이너(IoC Container) 당 오직 '하나'의 인스턴스만 생성됩니다.
- 스프링의 기본(Default) 스코프입니다.
- 모든 요청이 '하나의 객체'를 공유하므로, 싱글톤 빈에 상태를 가지게 해서는 안됌
프로토타입 (Prototype)
- 빈을 요청(주입 또는
getBean()호출)할 때마다 '매번 새로운' 인스턴스를 생성 - 각 객체가 자신만의 '상태(State)'를 가질 수 있습니다.
- 스프링이 생성만 하고 빈의 소멸(Destroy) 라이프사이클을 관리하지 않습니다.
- 빈의 소멸은 전적으로 GC(가비지 컬렉터)나 개발자의 몫이 됩니다.
🌐 웹(Web) 스코프
- 이 스코프들은 웹 환경에서만 (즉,
WebApplicationContext에서만) 동작 - 웹 스코프는 프로토타입과 다르게 스프링이 해당 스코프의 종료시점까지 관리 -> 종료 메서드 호출
request
- HTTP 요청 하나가 시작되고 끝날 때까지 유지되는 스코프
- 하나의 HTTP 요청 안에서는 동일한 빈 유지 (즉, 컨트롤러, 서비스에서 같은 빈을 공유)
- 사용 시점: 요청과 관련된 정보(예: 사용자 IP, 트랜잭션 ID)를 요청 처리 전 과정에서 공유해야 할 때 사용
session
- HTTP 세션 하나가 유지되는 동안 동일한 빈 인스턴스를 유지
- 사용자(브라우저)별로 다른 빈이 생성
- 사용자가 로그아웃하거나 세션이 만료될 때까지 유지
application
- 사실상 싱글톤 스코프와 거의 동일하게 동작 -> 모든 세션, 모든 요청에서 공유
- 사실상 싱글톤 빈으로 대체 가능하여 잘 사용하지는 않음
프로토타입 스코프 - 싱글톤 빈과 함께 사용시 문제점
- 스프링은 일반적으로 싱글톤 빈을 사용하므로, 싱글톤 빈이 프로토타입 빈을 사용하게 된다.
- 그런데 싱글톤 빈은 생성시점에만 의존관계 주입을 받기 때문에, 프로토타입 빈이 새로 생성되기는 하지만, 싱글톤 빈과 함께 계속 유지되는 것이 문제다.
- 아마 원하는 것이 이런 것은 아닐 것이다. 프로토타입 빈을 주입 시점에만 새로 생성하는게 아니라, 사용할 때 마다 새로 생성해서 사용하는 것을 원할 것이다.
- 가장 간단한 방법은 싱글톤 빈이 프로토타입을 사용할 때 마다 스프링 컨테이너에 새로 요청하는 것이다.
- 지정한 빈을 컨테이너에서 대신 찾아주는 DL 서비스를 제공하는 것이 바로
ObjectProvider이다. - 실무에서 웹 애플리케이션을 개발해보면, 싱글톤 빈으로 대부분의 문제를 해결할 수 있기 때문에 프로토타입 빈을 직접적으로 사용하는 일은 매우 드물다.
AOP
핵심 기능과 부가 기능
-
애플리케이션 로직은 크게 핵심 기능과 부가 기능으로 나눌 수 있다.
-
핵심 기능은 해당 객체가 제공하는 고유의 기능이다.
-
부가 기능은 핵심 기능을 보조하기 위해 제공되는 기능이다.
- 예를 들어서 로그 추적 로직, 트랜잭션 기능이 있다.
-
이러한 부가 기능은 단독으로 사용되지 않고, 핵심 기능과 함께 사용된다.
-
부가 기능 적용 문제를 정리하면 다음과 같다.
- 부가 기능을 적용할 때 아주 많은 반복이 필요하다.
- 부가 기능이 여러 곳에 퍼져서 중복 코드를 만들어낸다.
- 부가 기능을 변경할 때 중복 때문에 많은 수정이 필요하다.
- 부가 기능의 적용 대상을 변경할 때 많은 수정이 필요하다.
- 소프트웨어 개발에서 변경 지점은 하나가 될 수 있도록 잘 모듈화 되어야 한다.
-
그런데 부가 기능처럼 특정 로직을 애플리케이션 전반에 적용하는 문제는 일반적인 OOP 방식으로는 해결이 어렵다.
-
그 결과 부가 기능을 핵심 기능에서 분리하고 한 곳에서 관리하도록 했다.
-
이렇게 부가 기능과 부가 기능을 어디에 적용할지 선택하는 기능을 합해서 하나의 모듈로 만들었는데 이것이 바로 애스펙트(aspect)이다.
-
스프링이 제공하는 어드바이저도 어드바이스(부가 기능)과 포인트컷(적용 대상)을 가지고 있어서 개념상 하나의 애스펙트이다.
-
애스펙트는 우리말로 해석하면 관점이라는 뜻인데, 이름 그대로 애플리케이션을 바라보는 관점을 하나하나의 기능에서 횡단 관심사(cross-cutting concerns) 관점으로 달리 보는 것이다.
-
이렇게 애스펙트를 사용한 프로그래밍 방식을 관점 지향 프로그래밍 AOP(Aspect-Oriented Programming)이라 한다.
-
AOP는 OOP를 대체하기 위한 것이 아니라 횡단 관심사를 깔끔하게 처리하기 어려운 OOP의 부족한 부분을 보조하는 목적으로 개발되었다.
-
AOP의 대표적인 구현으로 AspectJ 가 있다.
-
스프링도 AOP를 지원하지만 대부분 AspectJ의 문법을 차용하고, AspectJ가 제공하는 기능의 일부만 제공한다.
AOP 적용 방식
- AOP를 사용하면 핵심 기능과 부가 기능이 코드상 완전히 분리되어서 관리된다.
- AOP를 사용할 때 부가 기능 로직은 어떤 방식으로 실제 로직에 추가될 수 있을까?
- 크게 3가지 방법이 있다.
- 컴파일 시점
- 클래스 로딩 시점
- 런타임 시점(프록시)
컴파일 시점
- .java 소스 코드를 컴파일러를 사용해서 .class 를 만드는 시점에 부가 기능 로직을 추가할 수 있다.
- AspectJ가 제공하는 특별한 컴파일러를 사용해야 한다.
- 컴파일 된 .class 를 디컴파일 해보면 애스펙트 관련 호출 코드가 들어간다.
- 참고로 이렇게 원본 로직에 부가 기능 로직이 추가되는 것을 위빙(Weaving)이라 한다.
- 위빙(Weaving): 옷감을 짜다. 직조하다. 애스펙트와 실제 코드를 연결해서 붙이는 것
- 단점은 컴파일 시점에 부가 기능을 적용하려면 특별한 컴파일러도 필요하고 복잡하다.
클래스 로딩 시점
- .class 파일을 JVM 내부의 클래스 로더에 보관한다.
- 이때 중간에서 .class 파일을 조작한 다음 JVM에 올릴 수 있다.
- 자바 언어는 .class 를 JVM에 저장하기 전에 조작할 수 있는 기능을 제공한다.
- 궁금한 분은 java Instrumentation를 검색해보자. 참고로 수 많은 모니터링 툴들이 이 방식을 사용한다.
- 이 시점에 애스펙트를 적용하는 것을 로드 타임 위빙이라 한다.
- 단점은 자바를 실행할 때 특별한 옵션( java -javaagent )을 통해 클래스 로더 조작기를 지정해야 하는데, 이 부분이 번거롭고 운영하기 어렵다.
런타임 시점 - 사용되는 방식
- 런타임 시점은 컴파일도 다 끝나고, 클래스 로더에 클래스도 다 올라가서 이미 자바가 실행되고 난 다음을 말한다.
- 따라서 자바 언어가 제공하는 범위 안에서 부가 기능을 적용해야 한다.
- 스프링과 같은 컨테이너의 도움을 받고 프록시와 DI, 빈 포스트 프로세서 같은 개념들을 총 동원해야 한다.
- 이렇게 하면 최종적으로 프록시를 통해 스프링 빈에 부가 기능을 적용할 수 있다. 그렇다.
- 지금까지 우리가 학습한 것이 바로 프록시 방식의 AOP이다.
- 프록시를 사용하기 때문에 AOP 기능에 일부 제약이 있다. 하지만 특별한 컴파일러나, 자바를 실행할 때 복잡한 옵션과 클래스 로더 조작기를 설정하지 않아도 된다.
정리
- AOP는 지금까지 학습한 메서드 실행 위치 뿐만 아니라 다음 과 같은 다양한 위치에 적용할 수 있다.
- AOP를 적용할 수 있는 지점을 조인 포인트(Join point)라 한다.
- 적용 가능 지점(조인 포인트): 생성자, 필드 값 접근, static 메서드 접근, 메서드 실행
- AspectJ를 사용해서 컴파일 시점과 클래스 로딩 시점에 적용하는 AOP는 바이트코드를 실제 조작하기 때문에 해당 기능을 모든 지점에 다 적용할 수 있다.
- 프록시 방식을 사용하는 스프링 AOP는 메서드 실행 지점에만 AOP를 적용할 수 있다.
- 프록시는 메서드 오버라이딩 개념으로 동작한다.
- 따라서 생성자나 static 메서드, 필드 값 접근에는 프록시 개념이 적용될 수 없다.
- 프록시 방식을 사용하는 스프링 AOP는 스프링 컨테이너가 관리할 수 있는 스프링 빈에만 AOP를 적용할 수 있다.
- 스프링은 AspectJ의 문법을 차용하고 프록시 방식의 AOP를 적용한다.
- 스프링이 제공하는 AOP는 프록시를 사용한다. 따라서 프록시를 통해 메서드를 실행하는 시점에만 AOP가 적용 된다.
AOP 용어 정리
조인 포인트(Join point)
- 어드바이스가 적용될 수 있는 위치, 메소드 실행, 생성자 호출, 필드 값 접근, static 메서드 접근 같은 프로그램 실행 중 지점
- 조인 포인트는 추상적인 개 념이다. AOP를 적용할 수 있는 모든 지점이라 생각하면 된다.
- 스프링 AOP는 프록시 방식을 사용하므로 조인 포인트는 항상 메소드 실행 지점으로 제한된다.
포인트컷(Pointcut)
- 조인 포인트 중에서 어드바이스가 적용될 위치를 선별하는 기능
- 주로 AspectJ 표현식을 사용해서 지정
타켓(Target) - 어드바이스를 받는 객체, 포인트컷으로 결정
어드바이스(Advice)
- 부가 기능
- 특정 조인 포인트에서 Aspect에 의해 취해지는 조치
- Around(주변), Before(전), After(후)와 같은 다양한 종류의 어드바이스가 있음
애스펙트(Aspect)
- 어드바이스 + 포인트컷을 모듈화 한 것
- @Aspect 를 생각하면 됨
어드바이저(Advisor)
- 하나의 어드바이스와 하나의 포인트 컷으로 구성
- 스프링 AOP에서만 사용되는 특별한 용어
위빙(Weaving)
- 포인트컷으로 결정한 타켓의 조인 포인트에 어드바이스를 적용하는 것
- 위빙을 통해 핵심 기능 코드에 영향을 주지 않고 부가 기능을 추가 할 수 있음
- 컴파일 타임(AspectJ compiler), 로드 타임, 런타임
AOP 프록시
-
AOP 기능을 구현하기 위해 만든 프록시 객체, 스프링에서 AOP 프록시는 JDK 동적 프록시 또는 CGLIB 프록시이다.
-
인터페이스가 있으면 스프링은 JDK 동적 프록시를 사용하여 프록시 객체 생성가능
-
CGLIB를 사용하면 인터페이스가 없어도 구체 클래스만 가지고 동적 프록시를 만들어낼 수 있다.
-
CGLIB는 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다.
-
프록시 팩토리는 인터페이스 없이 구체 클래스만 있으면 CGLIB를 사용해서 프록시를 적용한다.
-
프록시 팩토리의 서비스 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기 술에 의존하지 않고, 매우 편리하게 동적 프록시를 생성할 수 있다.
- 이것은 프록시 팩토리가 내부에서 JDK 동적 프록시인 경우 InvocationHandler 가 Advice 를 호출하도록 개발해두고, CGLIB인 경우 MethodInterceptor 가 Advice 를 호출하도록 기능을 개발해두었기 때문이다.
-
프록시 팩토리는 proxyTargetClass 라는 옵션을 제공하는데, 이 옵션에 true 값을 넣으면 인터페이스가 있어도 강제로 CGLIB를 사용한다.
-
스프링 부트는 AOP를 적용할 때 기본적으로 proxyTargetClass=true 로 설정해서 사용한다.
포인트컷, 어드바이스, 어드바이저 - 소개
포인트컷(Pointcut): 어디에 부가 기능을 적용할지, 적용하지 않을지 판단하는 필터링 로직이다.
- 주로 클래스와 메서드 이름으로 필터링 한다.
- 어떤 포인트(Point)에 기능을 적 용할지 하지 않을지 잘라서(cut) 구분하는 것이다.
- 포인트컷 표현식은 AspectJ pointcut expression 애스펙트J가 제공하는 포인트컷 표현식을 줄여서 말하는 것이다.
- execution 같은 포인트컷 지시자(Pointcut Designator)로 시작한다. 줄여서 PCD라 한다.
execution: 메소드 실행 조인 포인트를 매칭한다. 스프링 AOP에서 가장 많이 사용하고, 기능도 복잡하다.within: 특정 타입 내의 조인 포인트를 매칭한다.args: 인자가 주어진 타입의 인스턴스인 조인 포인트this: 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트target: Target 객체(스프링 AOP 프록시가 가리키는 실제 대상)를 대상으로 하는 조인 포인트@target : 실행 객체의 클래스에 주어진 타입의 애노테이션이 있는 조인 포인트@within : 주어진 애노테이션이 있는 타입 내 조인 포인트@annotation : 메서드가 주어진 애노테이션을 가지고 있는 조인 포인트를 매칭@args : 전달된 실제 인수의 런타임 타입이 주어진 타입의 애노테이션을 갖는 조인 포인트bean: 스프링 전용 포인트컷 지시자, 빈의 이름으로 포인트컷을 지정한다.- execution 은 가장 많이 사용하고, 나머지는 자주 사용하지 않는다. 따라서 execution 을 중점적으로 이해하자.
어드바이스( Advice ): 이전에 본 것 처럼 프록시가 호출하는 부가 기능이다. 단순하게 프록시 로직이라 생각하면 된다.
-
@Around: 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능- 어드바이스의 첫 번째 파라미터는 ProceedingJoinPoint 를 사용해야 한다.
-
@Before: 조인 포인트 실행 이전에 실행- 작업 흐름을 변경할 수는 없다.
-
@AfterReturning: 조인 포인트가 정상 완료후 실행- 반환되는 객체를 변경할 수는 없다.
-
@AfterThrowing: 메서드가 예외를 던지는 경우 실행 -
@After: 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)- 메서드 실행이 종료되면 실행된다.
-
모든 어드바이스는 org.aspectj.lang.JoinPoint 를 첫번째 파라미터에 사용할 수 있다. (생략해도 된다.)
-
단
@Around는 ProceedingJoinPoint 을 사용해야 한다. -
JoinPoint 인터페이스의 주요 기능
- getArgs() : 메서드 인수를 반환합니다.
- getThis() : 프록시 객체를 반환합니다.
- getTarget() : 대상 객체를 반환합니다.
- getSignature() : 조언되는 메서드에 대한 설명을 반 환합니다.
- toString() : 조언되는 방법에 대한 유용한 설명을 인쇄합니다.
- ProceedingJoinPoint 인터페이스의 주요 기능
- proceed() : 다음 어드바이스나 타켓을 호출한다.
-
스프링은 5.2.7 버전부터 동일한
@Aspect안에서 동일한 조인포인트의 우선순위를 정했다. -
실행 순서:
@Around,@Before,@After,@AfterReturning,@AfterThrowing -
어드바이스가 적용되는 순서는 이렇게 적용되지만, 호출 순서와 리턴 순서는 반대라는 점을 알아두자.
-
물론
@Aspect안에 동일한 종류의 어드바이스가 2개 있으면 순서가 보장되지 않는다. -
이 경우 앞서 배운 것 처럼
@Aspect를 분리하고@Order를 적용하자. -
@Around가 가장 넓은 기능을 제공하는 것은 맞지만, 실수할 가능성이 있다. -
반면에
@Before,@After같은 어드바이스는 기능은 적지만 실수할 가능성이 낮고, 코드도 단순하다. -
가장 중요한 점은 바로 이 코드를 작성한 의도가 명확하게 드러난다는 점이다.