[김영한 스프링 review] 스프링 핵심 원리 - 기본편 (2)

조갱·2023년 10월 15일
0

스프링 강의

목록 보기
3/16

[김영한 스프링 review] 스프링 핵심 원리 - 기본편 (1)에서 이어지는 내용입니다.

스프링 핵심 원리 이해2 - 객체 지향 원리 적용

스프링으로 전환하기

이전에 순수한 자바 언어로 작성한 코드를 스프링 프레임워크의 도움을 받아 수정해본다.

AppConfig 을 싱글톤 빈으로 등록

@Configuration
public class AppConfig {
	@Bean
	public MemberService memberService() {
 		return new MemberServiceImpl(memberRepository());
 	}
 
 	@Bean
	public OrderService orderService() {
		return new OrderServiceImpl(memberRepository(), discountPolicy());
	}

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

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

Bean을 등록하기 위해서
클래스에 @Configuration 어노테이션을 붙이고
메소드에 @Bean 어노테이션을 붙인다.

@Configuration 을 붙이지 않으면 싱글톤 빈으로 관리되지 않는다.
스프링이 위 어노테이션을 탐색하여 CGLIB을 통한 프록시 작업을 하고, Config Class를 싱글톤으로 관리하게 된다.

(후에 나오겠지만 AOP 또한 CGLIB을 이용한 프록시 기술을 제공한다.)

App에 스프링 적용

이전 설정(AppConfig 사용하기 참조) 에 스프링을 적용해보자.

public class OrderApp {
	public static void main(String[] args) {
		// AppConfig appConfig = new AppConfig();
		// MemberService memberService = appConfig.memberService();
		// OrderService orderService = appConfig.orderService();

		ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
		MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
		OrderService orderService = applicationContext.getBean("orderService", OrderService.class);

        Member member = new Member(1L, "memberA", Grade.VIP);
		memberService.join(member);
		
        Member findMember = memberService.findMember(1L);
        Order order = orderService.createOrder(findMember.memberId, "itemA", 10000);
	}
}

여기서 bean의 이름은 @Bean 어노테이션을 붙인 메소드의 이름이 된다.
따라서 getBean(메소드명, 변환할 클래스) 에서 메소드명은 memberService, orderService가 된다.

또한, 이전에는 개발자가 작성한 AppConfig 클래스를 호출했는데,
이제는 스프링에 등록된 빈을 조회하기 위해 context.getBean 메소드를 사용한다.

스프링을 적용했는데, 코드가 이전보다 더 복잡해진것 같다.
추후에 자동 의존관계 주입을 통해 코드를 더 간결하게 해보겠다.

스프링 컨테이너와 스프링 빈

스프링 컨테이너 생성

//스프링 컨테이너 생성
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
  • ApplicationContext를 스프링 컨테이너라고 한다.
    • 더 정확히는 스프링 컨테이너를 BeanFactory, ApplicationContext 로 나누기는 하지만, BeanFactory를 직접 사용하는 경우는 드물기 때문에, 일반적으로 ApplicationContext를 스프링 컨테이너라고 한다.
  • ApplicationContext는 인터페이스이고,
    AnnotationConfigÀpplicationContext는 구현체이다.
    • AnnotationConfigApplicationContext : 어노테이션 기반 자바 설정 코드 (AppConfig.class)
    • GenericXmlApplicationContext : XML 기반 설정 파일 (appConfig.xml)
    • xxxApplicationContext : 기타 등등 환경 기반 설정 파일 (appConfig.xxx)

스프링 컨테이너 생성 과정

  1. newApplicationConfigApplicationContext() 를 통해 컨테이너를 생성한다.
    이때 매개변수로 구성정보 (AppConfig.class)를 함께 전달해야한다.
  1. 구성정보에 있는 빈 등록
    메소드명 -> 빈 이름, 클래스 -> 객체
    @Bean 어노테이션에 name 속성을 할당하여 빈 이름을 변경할 수도 있다.
    빈 이름은 중복되면 안된다.
    같은 이름을 할당하는 경우, 설정에 따라 기존 빈이 덮어써지거나 오류가 발생한다.
  1. 스프링 빈 의존관계 설정

스프링 빈 조회

등록한 스프링 빈을 조회해보자.

기본

// 스프링 컨테이너 생성
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

// 스프링 컨테이너에 등록된 빈들의 이름을 조회
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=" + bean);
	}
}

ac.getBean(빈이름, 타입)
ac.getBean(타입)

조회 대상 스프링 빈이 없으면 예외 발생
NoSuchBeanDefinitionException: No bean named 'xxxxx' available

타입으로만 조회하면 유연성이 떨어진다.
만약 타입이 같은데 이름이 다른 빈이 2개 이상 있으면 어떻게 될까?

동일한 타입이 둘 이상

타입이 동일한데 이름이 다른 빈이 2개 이상 있으면, NoUniqueBeanDefinitionException오류가 발생한다.
이런 경우에는 빈 이름을 지정해서 검색해야한다.

ac.getBeansOfType(클래스) 사용하여 클래스의 빈들을 모두 조회할 수 있다.
Map<빈 이름, 빈> 의 형태로 반환된다.

Map<String, MemberRepository> beansOfType = ac.getBeansOfType(MemberRepository.class);
for (String key : beansOfType.keySet()) {
	System.out.println("key = " + key + " value = " + beansOfType.get(key));
}

상속 관계

부모 타입으로 조회하면, 자식 타입도 함께 조회한다.
그래서 최상위 객체인 Object 타입으로 조회하면, 모든 스프링 빈을 조회한다.

Map<String, Object> beansOfType = ac.getBeansOfType(Object.class);
for (String key : beansOfType.keySet()) {
	// 모든 스프링 빈을 출력
	System.out.println("key = " + key + " value=" + beansOfType.get(key));
}

BeanFactory와 ApplicationContext

  • BeanFactory
    • 스프링 컨테이너의 최상위 인터페이스
    • 스프링 빈을 관리하고 조회(getBean())하는 역할을 담당한다.
  • ApplicationContext
    • BeanFactory 기능을 모두 상속받아서 제공한다.
    • BeanFactory에 더해서, 각종 부가기능을 제공한다.

ApplicationContext가 제공하는 부가기능

public interface ApplicationContext extends
	EnvironmentCapable,
    ListableBeanFactory,
    HierarchicalBeanFactory,
    MessageSource,
    ApplicationEventPublisher,
    ResourcePatternResolver { ... }
  • EnvironmentCapable
    • 환경(Environment)에 관한 정보를 제공한다.
    • 이를 통해 환경 속성(프로퍼티)에 액세스하고 환경 관련 설정을 읽어올 수 있다.
    • 주로 프로파일(Profiles) 관리 및 환경 관련 설정 정보에 사용된다.
  • ListableBeanFactory
    • BeanFactory를 상속받는다.
    • 빈 검색/조회 기능을 제공한다.
      (특정 타입의 빈 조회, 빈 이름 목록 조회, 어노테이션 기반 빈 검색)
  • HierarchicalBeanFactory
    • BeanFactory를 상속받는다.
    • 상속 관계의 빈 검색/조회 기능을 제공한다.
  • MessageSource
    • 국제화 및 로컬화지원을 위한 메시지 소스를 제공한다.
    • 다국어 지원 애플리케이션에서 메시지를 다국어로 제공하고 관리하는 데 사용한다.
  • ApplicationEventPublisher
    • 이벤트 기반 프로그래밍을 지원하는 인터페이스이다.
    • 애플리케이션 내에서 이벤트를 발행하는 퍼블리셔 및 수신하는 리스너를 등록할 수 있다.
    • 스프링 애플리케이션 내에서 이벤트 처리 및 통신을 가능하게 한다.
  • ResourcePatternResolver
    • classPath나 리소스(이미지 등 정적 파일)를 검색하고 로드하는 기능을 제공한다.

스프링 빈 설정 메타 정보 - BeanDefinition

스프링은 다양한 방법으로 빈을 생성하고 관리할 수 있다.
예를 들면 우리가 설정한 AppConfig.class 처럼 어노테이션 기반으로 관리할 수 있고, xml 형태로도 관리할 수 있다.

이렇게 다양한 방법을 지원하는 핵심은 BeanDefinition을 추상화하는 것이다.

이러한 추상화를 통해, 스프링 컨테이너는 구성 정보가 자바 코드인지, xml인지 알 필요 없이, BeanDefinition만 보면 된다.
(새로운 형태로 구성 정보를 설정하더라도, BeanDefinition만 구현하면 된다.)

BeanDefinition을 확인하기 위해서 getBeanDefinition()을 사용하면 된다.

String[] beanDefinitionNames = ac.getBeanDefinitionNames();

for (String beanDefinitionName : beanDefinitionNames) {
	BeanDefinition beanDefinition = ac.getBeanDefinition(beanDefinitionName);
	if (beanDefinition.getRole() == beanDefinition.ROLE_APPLICATION) {
		System.out.println("beanDefinitionName" + beanDefinitionName + " beanDefinition = " + beanDefinition);
	}
}

BeanDefinition 정보

  • BeanClassName
    생성할 빈의 클래스 명(자바 설정 처럼 팩토리 역할의 빈을 사용하면 없음)
  • factoryBeanName
    팩토리 역할의 빈을 사용할 경우 이름, 예) appConfig
  • factoryMethodName
    빈을 생성할 팩토리 메서드 지정, 예) memberService
  • Scope
    singleton(기본값), prototype, request...
  • lazyInit
    지연 생성 여부 (스프링 컨테이너를 생성할 때 빈을 생성할지? 실제 빈을 사용할 때 생성할지?)
  • InitMethodName
    빈을 생성하고, 의존관계를 적용한 뒤에 호출되는 초기화 메서드 명
  • DestroyMethodName
    빈의 생명주기가 끝나서 제거하기 직전에 호출되는 메서드 명
  • Constructor arguments, Properties
    의존관계 주입에서 사용 (자바 설정 처럼 팩토리 역할의 빈을 사용 하면 없음)

싱글톤 컨테이너

웹 애플리케이션과 싱글톤

웹 어플리케이션은 많은 사용자들이 API를 호출한다.
만약 싱글톤이 아니라면, 매번 새로운 객체를 만들어서 반환한다.
-> 메모리 낭비가 심하다

싱글톤 패턴

싱글톤 패턴은 객체의 인스턴스가 1개만 생성되는걸 보장하는 패턴이다.
자바에서는 static 키워드를 통해 생성할 수 있다.

public class Singleton {
	// 1. static 영역에 객체를 딱 1개만 생성해둔다.
	private static final Singleton instance = new Singleton();

	// 2. public으로 열어서 객체 인스턴스가 필요하면 이 static 메서드를 통해서만 조회하도록 허용한다.
	public static Singleton getInstance() {
		return instance;
	}

	// 3. 생성자를 private으로 선언해서 외부에서 new 키워드를 사용한 객체 생성을 못하게 막는다.
	private SingletonService() {}

	public void logic() {
		System.out.println("싱글톤 객체 로직 호출");
	}
}

외부에서는 아래와 같이 호출한다.

Singleton singleton = Singleton.getInstance();
singleton.logic();

싱글톤을 생성하는 방식에는 여러가지가 있다.
여담이지만, 신입 면접 단골질문이니, 취준생이라면 관련 지식을 익혀두자.

싱글톤을 직접 구현해서 사용하면 생기는 문제점들도 있다.

  • 싱글톤 패턴을 구현하는 코드 낭비
  • 의존관계상 클라이언트가 구체 클래스에 의존한다. > DIP를 위반
  • 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.
  • 테스트하기 어렵다.
  • 내부 속성을 변경하거나 초기화 하기 어렵다.
  • private 생성자로 자식 클래스를 만들기 어렵다.
  • 결론적으로 유연성이 떨어진다.
  • 안티패턴으로 불리기도 한다.

스프링이 제공해주는 싱글톤 컨테이너를 사용해보자.

싱글톤 컨테이너

스프링 컨테이너는 개발자가 별도로 싱글톤패턴을 구현하지 않아도,
빈으로 관리되는 객체를 내부적으로 싱글톤으로 관리한다.
-> 위에 있는 모든 문제점을 해결한다.

기본적으로는 빈들을 싱글톤으로 관리하지만, 설정에 따라 다르게 관리시킬 수도 있다.

  • DI만 스프링 컨테이너에 위임하고, 매번 새로운 객체를 꺼냄
  • 각 request에 대해서 싱글톤으로 관리
    ...

객체를 재사용하기 때문에 메모리 낭비가 없다.

싱글톤 방식의 주의점

싱글톤은 객체 하나만을 사용하기 때문에 무상태성(stateless)으로 설계해야한다.
특정 클라이언트를 위한 필드를 사용하면 안된다는 의미.

가급적 읽기전용으로 사용되어야 하며,
공유되는 필드 대신 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야한다.

-> 그러지 않으면 정말 큰 장애를 유발할 수 있음.

@Configuration과 싱글톤

위에서도 작성했지만,
@Bean 을 싱글톤으로 관리하기 위해서는 클래스에 @Configuration을 작성해야만 한다.
@Configuration을 작성하지 않으면, Bean이 싱글톤으로 관리되지 않는다.

스프링은 이렇게 @Bean이 싱글톤으로 관리하는 것을 CGLIB 이라는 바이트코드 조작 라이브러리를 사용한다.

ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

//AppConfig도 스프링 빈으로 등록된다.
AppConfig bean = ac.getBean(AppConfig.class);

System.out.println("bean = " + bean.getClass());
//출력: bean = class AppConfig$$EnhancerBySpringCGLIB$$bd479d70

순수한 자바 클래스라면 bean = class AppConfig가 출력되어야 하지만
이상한 문자열이 함께 출력된다. 이것이 CGLIB을 이용해 프록시를 적용한 것이다.

AppConfig$$EnhancerBySpringCGLIB$$bd479d70는 AppConfig 를 상속받기 때문에, AppConfig처럼 사용할 수 있다.

CGLIB 예상 코드

CGLIB 라이브러리의 코드는 매우 복잡하지만, 결과물을 보면 AppConfig@CGLIB 클래스의 코드는 아래같은 형태라고 예상할 수 있다.

AppConfig@CGLIB 예상 코드

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

컴포넌트 스캔

컴포넌트 스캔과 의존관계 자동 주입 시작하기

Bean을 등록할 때마다, 매번 @Configuration어노테이션과 @Bean을 통해 등록하는 것은 번거로운 일이다. 또한, 그 개수가 늘어나면 설정 정보도 커지고 누락하는 상황도 발생할 수 있다.

스프링에서는 컴포넌트 스캔을 통해 설정 정보를 별도로 작성하지 않더라도 등록된 컴포넌트를 Bean으로 등록해주는 기능을 제공한다.
위 예제에서 AppConfig를 Bean으로 별도 등록하지 않고, @Configuration만 붙여도 Bean으로 관리되는 것도 동일한 맥락이다. (후술하겠지만, @Configuration 어노테이션 안에는 @Component 라는 어노테이션이 있어서 컴포넌트로서 관리된다.)

컴포넌트를 스캔하여 빈으로 등록하기 위해서는, 구성정보로 사용할 클래스에 @ComponentScan 어노테이션을 붙여주면 된다.

@Configuration
@ComponentScan
public class AutoAppConfig { }

AutoAppConfig 클래스는 Bean을 등록해야 했던 AppConfig와 다르게, 내용이 비어있는게 맞다. AppConfig는 Bean을 직접 등록했던 반면, AutoAppConfig는 @ComponentScan을 통해 @Component 어노테이션이 붙은 클래스를 자동으로 Bean을 등록해주기 때문이다.

이제 Bean으로 등록할 클래스에 @Component 어노테이션을 붙여 컴포넌트 스캔 대상으로 만들어준다.

@Component
public class MemoryMemberRepository implements MemberRepository { ... }
@Component
public class RateDiscountPolicy implements DiscountPolicy { ... } 
@Component
public class MemberServiceImpl implements MemberService {
	private final MemberRepository memberRepository;

	@Autowired // Autowired 어노테이션을 통해 Bean을 자동으로 주입받을 수 있다.
	public MemberServiceImpl(MemberRepository memberRepository) {
		this.memberRepository = memberRepository;
	}
}
@Component
public class OrderServiceImpl implements OrderService {
	private final MemberRepository memberRepository;
	private final DiscountPolicy discountPolicy;
	
    @Autowired // 2개 이상의 Bean도 주입받을 수 있다.
	public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
		this.memberRepository = memberRepository;
		this.discountPolicy = discountPolicy;
	}
}

Autowired에 대한 소개는 이곳에서 확인할 수 있다.

@ComponentScan@Component가 붙은 모든 클래스를 스프링 빈으로 지정한다.
이 때, bean의 기본 이름은 클래스 명의 앞글자만 소문자로 변환한다.
즉, 위 예시에서 MemoryMemberRepository 의 bean 기본 이름은 memoryMemberRepository 가 된다.
만약 기본 bean 이름이 아니라, 별도로 지정하고 싶다면 @Component(name = 새로운 bean 이름) 으로 지정할 수 있다.

의존관계 주입에서 @Autowired는, 기본적으로 동일한 클래스를 탐색하여 주입한다. (getBean(XXX.class)와 동일) 다른 주입 방식은 뒤에서 다시 소개한다.

탐색 위치와 기본 스캔 대상

@ComponentScan어노테이션은 기본적으로 해당 클래스의 패키지 및 하위 패키지에 있는 @Component어노테이션을 탐색하여 Bean으로 등록한다.

만약 직접 스캔 시작 위치를 정하고싶다면, 아래 옵션을 사용할 수도 있다.
basePackages : 지정한 패키지와 그 하위 패키지들을 탐색
basePackageClasses : 지정한 클래스의 위치와 그 하위를 탐색

@ComponentScan(basePackages = "hello.core")
@ComponentScan(basePackages = {"hello.core", "hello.service"})
@ComponentScan(basePackageClasses = MemoryMemberRepository.class)
@ComponentScan(basePackageClasses = {MemoryMemberRepository.class, OrderServiceImpl.class})

하지만, 직접적으로 위치를 지정하는 것은 일부 패키지의 누락이 발생하거나 불필요한 패키지의 컴포넌트도 스캔할 수 있다. 따라서, Springboot 가 시작되는 위치에 구성정보와 함께 @ComponentScan을 사용하는 것이 일반적이다.

Springboot 프로젝트를 생성하면 시작지점에 @SpringBootApplication 어노테이션이 붙어있는 것을 확인할 수 있는데, 이 어노테이션을 들어가보면 @ComponentScan이 붙어있는 것을 확인할 수 있다.
(그래서 우리가 별도로 @ComponentScan을 작성하지 않더라도, 프로젝트 내에서 빈 등록을 자유롭게 할 수 있는 것이다.)

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
@ComponentScan(excludeFilters = { @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
		@Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication { ... }

컴포넌트 스캔은 @Component어노테이션을 직접적으로 작성하지 않더라도
@Component 를 포함하는 어노테이션도 탐색 대상에 포함한다.
(*어노테이션에는 상속관계가 없기 때문에, 이 기능은 자바 언어가 제공하는 기능이 아니라 스프링이 지원하는 기능이다.)
대표적인 예시는 아래와 같다.

@Controller : 스프링 MVC 컨트롤러에서 사용
@Service : 스프링 비즈니스 로직에서 사용
@Repository : 스프링 데이터 접근 계층에서 사용
@Configuration : 스프링 설정 정보에서 사용

위 어노테이션의 내부를 살펴보면, 전부 @Component가 붙어있는 것을 알 수 있다.
추가로, 위 어노테이션은 단순히 @Component를 붙여주는것 뿐만 아니라, 추가적인 작업도 수행한다.

@Controller : 스프링 MVC 컨트롤러로 인식
@Repository : 스프링 데이터 접근 계층으로 인식하고, 데이터 계층의 예외를 스프링 예외로 변환해준다.
@Configuration : 앞서 보았듯이 스프링 설정 정보로 인식하고, 스프링 빈이 싱글톤을 유지하도록 추가 처리를 한다.
@Service : 사실 @Service 는 특별한 처리를 하지 않는다. 대신 개발자들이 핵심 비즈니스 로직이 여기에 있겠구나 라고 비즈니스 계층을 인식하는데 도움이 된다

필터

@ComponentScan 어노테이션에 필터 옵션을 통해, 컴포넌트 스캔 대상을 예외적으로 추가하거나, 제외할 수 있다.

  • includeFilters : 예외적으로 스캔 대상으로 추가한다.
  • excludeFilters : 예외적으로 스캔 대상에서 제거한다.
@ComponentScan(
	includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = MemberRepository.class),
	excludeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = OrderService.class)
)

FilterType 의 종류는 아래와 같다.

  • ANNOTATION: 기본값, 애노테이션을 인식해서 동작한다.
    ex) org.example.SomeAnnotation
  • ASSIGNABLE_TYPE: 지정한 타입과 자식 타입을 인식해서 동작한다.
    ex) org.example.SomeClass
  • ASPECTJ: AspectJ 패턴 사용
    ex) org.example..*Service+
  • REGEX: 정규 표현식
    ex) org.example.Default.*
  • CUSTOM: TypeFilter 이라는 인터페이스를 구현해서 처리
    ex) org.example.MyTypeFilter

중복 등록과 충돌

지금까지 정리했던 내용을 보면, 빈 등록은
수동 (@Configuration - @Bean)과
자동 (@ComponentScan - @Component) 로 정리할 수 있다.

만약 빈 등록 과정에서 충돌이 발생하면 어떻게 될까?

수동 vs 수동, 자동 vs 자동

스프링 시작 시 ConflictingBeanDefinitionException 예외 발생

수동 vs 자동

스프링에서는 항상 구체적인게 우선권을 가진다.
따라서 이 경우에서 수동 빈 등록이 우선권을 가지며, 자동 등록된 빈을 오버라이딩 시킨다.

이러한 경우는 보통 개발자가 의도한 경우는 없기 때문에, 최근 스프링 부트에서는 수동 vs 자동 충돌에서도 에러를 발생시킨다.

Consider renaming one of the beans or enabling overriding by setting
spring.main.allow-bean-definition-overriding=true
profile
A fast learner.

0개의 댓글