Spring Core

유현수·2025년 3월 11일
post-thumbnail

스프링의 핵심

스프링은 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크

좋은 객체 지향 애플리케이션이란?

SOLID 원칙을 준수하여 개발된 프로그램이라고 요약할 수 있다.

하지만 이 원칙을 준수하기 위해 다형성을 사용한다 하더라도 다음과 같은 코드를 피하기는 어렵다.

MyInterface myInterface = new MyInterfaceImpl();

결국 인터페이스에만 의존하는 것이 아니라 생성자를 사용하는 시점에서 구체 클래스에 의존하게 되는 것.

어떻게 해결할 수 있을까?

IoC, DI의 탄생

여기서 스프링 코어의 핵심 개념이 등장한다.

바로 객체 간 의존 관계를 설정하는 외부 객체를 사용하는 것.

public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

}

Service 객체는 생성자를 통해 필요한 의존성을 주입받는다. 그리고 별도의 객체가 의존성 주입을 수행한다.

각 Service는 자신에게 필요한 구현체가 무엇인지 신경 쓸 필요가 없게 됐다.

Inversion of control(IoC, 제어의 역전)

기존 프로그램은 클라이언트 구현 객체가 자신에게 필요한 객체를 직접 생성, 연결, 실행했다. 개발자 입장에서는 자연스러운 흐름이다.

하지만 AppConfig같은 의존관계를 설정해주는 객체를 사용하게 되면 각 객체는 자신의 로직을 실행하는 역할만 수행하게 된다. 자신이 어떤 구체 클래스를 사용할지 더 이상 신경쓰지 않는다. 이는 이제 AppConfig의 역할이다.

이처럼 프로그램의 제어 흐름이 외부 객체인 AppConfig로 넘어가는 것을 제어의 역전이라고 한다.

Dependency injection(DI, 의존관계 주입)

각 클라이언트 객체는 인터페이스에 의존하기 때문에 어떤 구체 클래스를 사용하게 될지 모른다. 이는 런타임에 주입된다.

이처럼 런타임에 구현 객체를 생성하고 클라이언트에 연결해 의존관계가 설정되는 것을 의존관계 주입이라 한다.

Spring container

스프링을 사용한다면 AppConfig와 같은 DI를 개발자가 직접 구현할 필요가 없다. 스프링 컨테이너가 이 역할을 수행해주기 때문.

스프링 컨테이너는 @Configuration이 붙은 클래스를 설정 정보로 사용한다. 여기서 @Bean이 붙은 메서드를 모두 호출해 반환된 객체를 스프링 컨테이너에 등록한다. 이렇게 스프링 컨테이너에 등록된 객체를 “스프링 빈”이라 부른다.

스프링 컨테이너의 동작 과정은 다음과 같다.

spring-container-1

spring-container-2

spring-container-3

BeanFactoroy와 ApplicationContext

bean-factory-1

bean-factory-2

Singleton container

대부분의 스프링 애플리케이션은 웹 애플리케이션이다. 웹 애플리케이션은 보통 여러 고객이 동시에 요청을 한다.

만약 각 요청마다 새로운 객체가 생성되고 소멸한다면 더 많은 GC가 발생해야 하고 성능이 저하된다.

non-singleton

이를 방지하기 위해 스프링 빈은 기본적으로 싱글톤 객체로 생성된다.

스프링 컨테이너는 이처럼 싱글톤 객체를 생성하고 관리하는 싱글톤 레지스트리, 싱글톤 컨테이너이기도 하다.

싱글톤 방식의 주의점

여러 클라이언트가 하나의 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 무상태(stateless)로 설계해야 한다.

  • 특정 클라이언트에 의존적인 필드 X
  • 특정 클라이언트가 값을 변경할 수 있는 필드 X
  • 가급적 읽기만 가능
  • 필드 대신 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 함

@Configuration과 바이트코드 조작

그런데 AppConfig를 보면 싱글톤으로 동작할리가 없다는걸 알 수 있다.

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        System.out.println("call AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository() {
        System.out.println("call AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }

    @Bean
    public OrderService orderService() {
        System.out.println("call AppConfig.orderService");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }

}

memberRepository() 메서드는 3번 호출되고 있고, 해당 메서드는 그저 생성자를 사용해 객체 인스턴스를 반환할 뿐이다. 그런데 어떻게 이 객체 인스턴스가 스프링 컨테이너에서 싱글톤으로 관리되는걸까?

이는 스프링이 CGLIB 라이브러리를 사용해 바이트코드를 조작하기 때문이다. 스프링은 AppConfig 클래스를 상속하는 AppConfig$$SpringCGLIB$$ 클래스를 바이트코드 조작을 통해 생성한 후 스프링 빈으로 등록한다. 이 바이트코드의 실제 코드는 매우 복잡하겠지만 대략 유추해본다면 다음과 같이 동작할 것이다.

@Bean
public MemberRepository memberRepository() {
    if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있으면?) {
        return 스프링 컨테이너에서 찾아서 반환;
    } else { //스프링 컨테이너에 없으면
        기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
        return 반환
    }
}

Component scan

만약 등록해야 할 스프링 빈이 수십, 수백개가 되면 일일이 AppConfig 같은 설정 정보를 구성하기 번거로워진다. 그래서 스프링은 자동으로 스프링 빈을 등록하는 컴포넌트 스캔 기능을 제공한다. 또한 의존관계를 자동으로 주입하는 @AutoWired 기능도 제공한다.

컴포넌트 스캔을 사용하려면 @ComponentScan 어노테이션을 설정 정보에 붙여주면 된다.

@Configuration
@ComponentScan
public class AutoAppConfig {
}

아래처럼 컴포넌트 스캔을 시작할 위치를 지정할 수 있다. 기본값은 @ComponentScan 어노테이션이 위치한 패키지이다.

@ComponentScan(basePackages = "hello.core")

컴포넌트 스캔의 기본 대상은 다음과 같다. @Component 외 다른 어노테이션을 살펴보면 @Component를 포함하고 있는 것을 볼 수 있다.

  • @Component
    • 사용처: 컴포넌트 스캔에서 사용
  • @Controller
    • 사용처: 스프링 MVC 컨트롤러에서 사용
    • 부가기능: 스프링 MVC 컨트롤러로 인식
  • @Service
    • 사용처: 스프링 비즈니스 로직에서 사용
    • 부가기능 없음. 핵심 비즈니스 로직이 있다는 것을 표시하는 용도
  • @Repository
    • 사용처: 스프링 데이터 접근 계층에서 사용
    • 부가기능: 스프링 데이터 접근 계층으로 인식하고, 데이터 계층의 예외를 스프링 예외로 변환
  • @Configuration
    • 사용처: 스프링 설정 정보에서 사용
    • 부가기능: 스프링 설정 정보로 인식. 스프링 빈이 싱글톤을 유지하도록 추가 처리
💡

참고: 어노테이션에는 상속관계가 없다. 어노테이션이 특정 어노테이션을 들고 있는 것을 인식할 수 있는 것은 자바 언어가 지원하는 기능이 아니며, 스프링이 지원하는 기능이다.

스프링 부트의 대표 시작 정보인 @SpringBootApplication 어노테이션에 @ComponentScan이 들어있는걸 볼 수 있다.

...
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
    ...
}

스프링 부트 실행 시, @SpringBootApplication이 붙은 클래스의 하위 패키지에 대해 컴포넌트 스캔이 이루어지는 이유다.

의존관계 자동 주입

@AutoWired 어노테이션을 사용하면 의존관계가 자동으로 주입된다.

@Component
public class OrderServiceImpl implements OrderService {

		private final MemberRepository memberRepository;
		private final DiscountPolicy discountPolicy;
		
		@Autowired  // 의존관계 자동 주입
		public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
				this.memberRepository = memberRepository;
				this.discountPolicy = discountPolicy;
		}
}

auto-wired-1

auto-wired-2

중복 등록과 충돌

컴포넌트 스캔에서 같은 빈 이름을 등록하면 어떻게 될까? 두가지 상황에 대해 알아보자.

자동 빈 등록 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

의존관계 자동 주입 자세히 알아보기

의존관계를 주입하는 방법은 여러가지이다.

  • 생성자(Constructor) 주입
  • 수정자(Setter) 주입
  • 필드 주입
  • 일반 메서드 주입

단, 왠만하면 생성자 주입을 사용하자. 이유는 다음과 같다.

불변

  • 대부분의 의존관계 주입은 한번 일어나면 애플리케이션 종료시점까지 의존관계를 변경할 일이 없다. 오히려 대부분의 의존관계는 애플리케이션 종료 전까지 변하면 안된다.(불변해야 한다.)
  • 수정자 주입을 사용하면, setXxx 메서드를 public으로 열어두어야 한다.
  • 누군가 실수로 변경할 수 도 있고, 변경하면 안되는 메서드를 열어두는 것은 좋은 설계 방법이 아니다.
  • 생성자 주입은 객체를 생성할 때 딱 1번만 호출되므로 이후에 호출되는 일이 없다. 따라서 불변하게 설계할 수 있다.

누락

스프링을 사용하지 않고 순수 자바 코드로 단위 테스트를 한다고 가정해보자. 수정자 주입을 사용하면 어떤 의존관계가 누락되었는지 컴파일 오류로 알아낼 수 없다. 반면 생성자 주입을 사용하면 컴파일 오류로 누락된 의존관계를 확인할 수 있다.

@Qualifier, @Primary

@Autowired는 타입으로 조회한다. 단, 타입으로 조회했을 때 해당 타입을 구현하는 빈이 2개 이상이라면 문제가 발생한다.

@Component
public class FixDiscountPolicy implements DiscountPolicy {}
@Component
public class RateDiscountPolicy implements DiscountPolicy {}

위처럼 DiscountPolicy의 하위 타입인 빈이 2개일 때 의존관계 자동 주입을 실행하면

@Autowired
private DiscountPolicy discountPolicy

NoUniqueBeanDefinitionException 에러가 발생한다.

NoUniqueBeanDefinitionException: No qualifying bean of type 'hello.core.discount.DiscountPolicy' available: expected single matching bean but found 2: fixDiscountPolicy,rateDiscountPolicy

이때 하위 타입으로 직접 지정하는 것은 DIP를 위배하고 유연성이 떨어진다. 이러한 문제의 해결방안을 알아보자.

@Autowired 필드 명 매칭

@Autowired는 타입 매칭을 시도하고 이때 여러 빈이 있으면 필드 이름, 파라미터 이름으로 빈 이름을 추가 매칭한다.

@Qualifier

추가 구분자를 붙여주는 방법이다. 구분자를 “추가”할 뿐, 빈 이름을 변경하지 않는다.

빈 등록 시 @Qualifier를 붙여주고

@Component
@Qualifier("mainDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy {}

주입시에도 @Qualifer를 사용해 구분자를 일치시켜 주입한다.

@Autowired
public OrderServiceImpl(MemberRepository memberRepository, @Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy) {
		this.memberRepository = memberRepository;
		this.discountPolicy = discountPolicy;
}

@Primary

@Autowired 주입 시 여러 빈이 매칭되면 @Primary가 붙은 빈이 우선권을 가진다.

@Primary, @Qualifier 활용

메인 DB와 서브 DB의 커넥션을 획득하는 빈이 각각 있다고 가정하자. 이 때 메인 DB 빈에 @Primary, 서브 DB 빈에 @Qualifier를 사용해 명시적으로 빈을 획득하는 방식을 사용할 수 있다.

조회한 빈이 모두 필요할 때, List, Map

할인 서비스를 제공하는데 클라이언트가 할인의 종류를 선택해야 하는 요구사항이 있다고 가정해보자.

스프링에서는 조회한 빈을 모두 가져와 조건에 맞는 할인 서비스를 선택해 제공할 수 있다.

public class AllBeanTest {

    @Test
    void findAllBean() {
		    // 스프링 컨테이너를 생성할 때 클래스 정보를 넘기면 해당 클래스가 빈으로 등록된다.
        AnnotationConfigApplicationContext 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 rateDiscountPolicy = discountService.discount(member, 20000, "rateDiscountPolicy");
        assertThat(rateDiscountPolicy).isEqualTo(2000);
    }

    static class DiscountService {
        private final Map<String, DiscountPolicy> policyMap;
        private final List<DiscountPolicy> policies;

        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);
        }
    }
}

Bean lifecycle

스프링 빈의 이벤트 라이프사이클

스프링 컨테이너 생성 → 스프링 빈 생성 → 의존관계 주입 → 초기화 콜백 → 사용 → 소멸 전 콜백 → 스프링 종료

Bean lifecycle callback

  • 인터페이스(InitializeBean, DisposableBean)
  • @Bean(initMethod = "methodA", destroyMethod = "methodB")
  • @PostConstruct, @PreDestroy

콜백으로 실행할 메서드에 @PostConstruct, @PreDestroy를 붙여 사용하는 것이 가장 권장된다.

외부 라이브러리의 메서드를 콜백으로 사용해야 할 경우 @Bean 어노테이션에 직접 콜백 메서드를 지정하는 방법을 사용하기도 한다.

Bean scope

빈 스코프는 빈이 존재할 수 있는 범위를 뜻한다. 스프링은 다음과 같은 스코프를 지원한다.

  • 싱글톤: 기본 스코프. 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프.
  • 프로토타입: 스프링 컨테이너가 빈의 생성과 의존관계 주입까지만 관여하고 더는 관리하지 않는 매우 짧은 범위의 스코프.
  • 웹 관련 스코프
    • request: 웹 요청이 들어오고 나갈 때까지 유지되는 스코프.
    • session: 웹 세션이 생성되고 종료될 때까지 유지되는 스코프.
    • application: 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프.

빈 스코프는 다음과 같이 지정할 수 있다.

@Scope("prototype")
@Component
public class HelloBean {}

Prototype scope

prototype-scope

스프링 컨테이너는 프로토타입 빈을 생성하고 의존관계 주입, 초기화까지만 처리한다. 클라이언트에 빈을 반환한 이후에는 관리하지 않기 때문에 @PreDestroy같은 종료 메서드는 호출되지 않는다.

프로토타입 빈의 특징을 정리하면 다음과 같다.

  • 스프링 컨테이너에 요청할 때마다 새로 생성된다.
  • 스프링 컨테이너는 프로토타입 빈의 생성과 의존관계 주입, 그리고 초기화까지만 관여한다.
  • 종료 메서드가 호출되지 않는다.
  • 프로토타입 빈은 프로토타입 빈을 조회한 클라이언트가 관리해야 한다. 종료 메서드 호출도 클라이언트가 직접 해야한다.

Prototype scope with singleton bean

prototype-scope-with-singleton-bean-1

prototype-scope-with-singleton-bean-2

prototype-scope-with-singleton-bean-3

How to use “Provider”

만약 ‘logic()’ 메서드 호출 시 매번 새로운 프로토타입 빈이 생성되길 의도했다면 위와 같은 동작은 의도를 벗어난 현상이다.

Provider를 사용해 싱글톤 빈과 프로토타입 빈을 함께 사용하더라도 프로토타입 빈이 매번 새로 생성되도록 해보자.

방법1: ObjectProvider로 문제 해결하기

@Autowired
private ObjectProvider<PrototypeBean> prototypeBeanProvider;

public int logic() {
		PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
		prototypeBean.addCount();
		int count = prototypeBean.getCount();
		return count;
}

ObjectProvidergetObject() 메서드를 호출하는 시점에 새로운 프로토타입 빈이 생성된다.

별도의 라이브러리가 필요하지 않다는 장점이 있는 방법.

방법2: JSR-330 Provider로 문제 해결하기

jakarta.inject:jakarta.inject-api:2.0.1 라이브러리를 설치하고 다음과 같이 사용할 수 있다.

@Autowired
private Provider<PrototypeBean> provider;

public int logic() {
		PrototypeBean prototypeBean = provider.get();
		prototypeBean.addCount();
		int count = prototypeBean.getCount();
		return count;
}

별도 라이브러리가 필요하지만 자바 표준이므로 다른 컨테이너에서도 사용할 수 있는 방법.

특별히 다른 컨테이너를 사용할 일이 없다면 스프링이 제공하는 기능인 ObjectProvider를 사용하자.

Web scope

특징

  • 웹 스코프는 웹 환경에서만 동작한다.
  • 웹 스코프는 프로토타입과 다르게 스프링이 해당 스코프의 종료 시점까지 관리한다.

종류

  • request: HTTP 요청 하나가 들어오고 나갈 때까지 유지되는 스코프. 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고, 관리된다.
  • session: HTTP session과 동일한 생명주기를 가지는 스코프
  • application: 서블릿 컨텍스트(ServletContext)와 동일한 생명주기를 가지는 스코프
  • websocket: 웹 소켓과 동일한 생명주기를 가지는 스코프

각각 범위가 다를 뿐, 동작 방식은 비슷하다.

Provider vs Proxy

Controller에서 request 스코프의 빈을 주입해 사용할 경우 에러가 발생한다. Controller 빈을 생성하는 시점에는 아무런 요청이 들어오지 않기 때문에 request 스코프 빈이 없기 때문이다.

이 경우 Provider 혹은 Proxy를 사용해 문제를 해결할 수 있다.

방법1: ObjectProvider를 사용하는 방법

@Controller
@RequiredArgsConstructor
public class LogDemoController {
		private final LogDemoService logDemoService;
		private final ObjectProvider<MyLogger> myLoggerProvider;
		
		@RequestMapping("log-demo")
		@ResponseBody
		public String logDemo(HttpServletRequest request) {
				String requestURL = request.getRequestURL().toString();
				// request가 들어온 이후이므로 getObject() 메서드 호출 시점에는 request 스코프 빈을 DL할 수 있다.
				MyLogger myLogger = myLoggerProvider.getObject();
				myLogger.setRequestURL(requestURL);
				myLogger.log("controller test");
				logDemoService.logic("testId");
				return "OK";
		}
}

방법2: Proxy를 사용하는 방법

request 스코프 빈에 proxyMode를 적용한다. 인터페이스라면 TARGET_INTERFACE를 사용하면 된다.

프록시를 사용할 경우 request 스코프 빈을 주입받는 클라이언트 코드가 일반적인 빈을 주입받는 것과 똑같이 사용할 수 있다는 장점이 있다.

프록시는 어떻게 동작하는걸까?

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger { ... }
@Controller
@RequiredArgsConstructor
public class LogDemoController {

    private final LogDemoService logDemoService;
    private final MyLogger myLogger;

    @RequestMapping("log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) throws InterruptedException {
        String requestURL = request.getRequestURL().toString();
        myLogger.setRequestURL(requestURL);
        myLogger.log("controller test");
        Thread.sleep(1000);
        logDemoService.logic("testId");
        return "OK";
    }
}

Proxy 동작 원리

Controller에서 주입받은 myLogger 빈의 클래스명을 조회하면 다음과 같이 나온다.

CGLIB 라이브러리로 바이트코드 조작을 수행해 MyLogger를 상속받은 새로운 객체를 만들어 주입했다는걸 볼 수 있다.

myLogger = class hello.core.common.MyLogger$$EnhancerBySpringCGLIB$$b68b726d

이 새로운 객체가 Proxy 객체이다. 이 객체는 어떤 역할을 할까?

proxy

어노테이션 설정 변경 만으로 원본 객체를 프록시 객체로 대체할 수 있게 되었다. 이것이 바로 다형성(원본 빈을 상속한 가짜 프록시 객체 등록)과 DI 컨테이너(외부에서 관리되는 빈을 자유롭게 조회)가 가진 큰 장점이다.

프록시 객체 덕분에 클라이언트는 마치 싱글톤 빈을 사용하듯 편리하게 request 스코프 빈을 사용할 수 있다.

프록시는 꼭 웹 스코프가 아니어도 사용할 수 있다.

💡 Provider이든, Proxy이든 핵심은 진짜 객체 조회를 꼭 필요한 시점까지 지연시킨다는 점에 있다!

⚠️ prototype, request처럼 특별한 scope는 꼭 필요한 곳에만 최소화하여 사용하자.
무분별하게 사용하면 유지보수가 어려워진다.

profile
"Life isn't about finding yourself. Life is about creating yourself."

0개의 댓글