Spring 공부 기록

Sungmin Oh·2021년 3월 25일
post-thumbnail

다음과 같은 가상의 웹 서비스를 개발하는 상황을 가정해 스프링의 핵심 원리에 대해 공부해보자.

  • 회원
    • 회원을 가입하고 조회할 수 있다.
    • 회원은 일반과 VIP 두 가지 등급이 있다.
    • 회원 데이터는 자체 DB를 구축할 수도 있고, 외부 시스템과 연동할 수도 있다. (미확정)
  • 주문과 할인 정책
    • 회원은 상품을 주문할 수 있다.
    • 회원 등급에 따라 할인 정책을 적용할 수 있다.
    • 할인 정책은 모든 VIP는 1000원을 할인해주는 고정 금액 할인을 적용해달라. (나중에 변경 될 수 있다.)
    • 할인 정책은 변경 가능성이 높다. 회사의 기본 할인 정책을 아직 정하지 못했고, 오픈 직전까지 고민을 미루고 싶다. 최악의 경우 할인을 적용하지 않을 수 도 있다. (미확정)

위와 같은 서비스를 개발하고자 한다. 여기서 주목할 점은 아직 확정되지 않은 부분이다. 아직 기획 단계에서 결정되지 않았기 때문에 개발을 미루고 기다려야 할까? 아니다. 다형성을 활용한 객체 지향 설계를 통해 개발할 수 있다.

회원 도메인 설계


위의 사진을 보면, 클라이언트가 회원 서비스를 이용해 회원가입, 회원조회 등을 하고, 가입된 회원의 정보를 회원 저장소에 저장하는 것을 알 수 있다. 이때, 아직 회원의 정보를 자체 DB에 저장할지 외부 시스템에 저장할지 결정하지 못했기 때문에 가능성을 열어두고 개발해야 한다는 것이 그림에 잘 나타나있다.

이럴 땐 인터페이스를 활용하면 된다. 일단 MemberRepository라는 인터페이스를 생성하고, 이 인터페이스를 구현하는 구현체만 개발하면 언제든 바꿔 낄 수 있도록 설계한다.

주문과 할인 도메인 설계


약간 복잡해졌지만 위의 회원 도메인 설계 부분과 거의 비슷하다. 클라이언트가 주문 서비스 역할에 회원 ID와 상품명, 상품 가격의 정보를 전달한다. 그러면 주문 서비스 역할이 회원 저장소에 저장된 회원이 정보를 확인한다. 이 때, 해당 회원이 VIP라면 할인을 적용하고 일반 회원이라면 할인을 적용하지 않는다. 이에 따라 산출된 최종 가격을 클라이언트에 반환하도록 개발하면 된다.

이때, 주문 서비스 역할에서는 회원 저장소가 자체 DB에 구현되어있든 메모리에 구현되어있든 전혀 상관이 없다. 단지 회원 저장소 역할이라는 추상화된 인터페이스에 의존하여 작동하기 때문에 우리가 필요할 때 구현체만 바꿔 낄 수 있다. 마찬가지로 할인 정책이 정액제로 구현되든 정률제로 구현되든 주문 서비스 역할 입장에서는 전혀 상관이 없도록 개발하게 된다.

이러한 설계를 구현하기 위해 우리는 인터페이스를 활용하고, 각 인터페이스를 구현하는 구현체를 따로 만들어서 사용한다.

위 방식의 문제점

public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository = new MemoryMemberRepository;
    private final DiscountPolicy discountPolicy = new FixDiscountPolicy;

클라이언트 코드의 일부이다. (OrderServiceImpl) 이 코드를 보면 인터페이스에만 의존해야 하는 클라이언트임에도 불구하고, MemoryMemberRepository와 FixDiscountPolicy가 코드에 직접 언급되며 구현체에도 의존하고 있는 것을 알 수 있다. 이를 그림으로 보면 아래와 같다.

이를 해결하기 위해 위의 코드를 아래와 같이 수정했다.

public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

이제 OrderServiceImpl 클래스가 구현체에 의존하지 않는다. 그런데 이대로 실행하면 오류가 발생한다. 인터페이스만 의존하고 있긴 하지만, 그 인터페이스를 어떤 구현체가 구현할지 누구도 결정해주지 않았기 때문이다. 구현체를 OrderServiceImpl 클래스에서 직접 결정하면 DIP에 위반되기 때문에 외부에서 이를 결정해줄 새로운 클래스를 도입할 것이다.

appConfig

public class AppConfig {
    public MemberService memberService() {
        return new MemberServiceImpl(new MemoryMemberRepository());
    }
    
    public OrderService orderService() {
        return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
    }
}

appConfig 클래스가 외부에서 어떤 구현체에 의존할지를 결정해주고 있다. 이를 "의존 관계를 주입해준다." 라고 표현할 수 있고, DI(Dependency Injection)이라고 한다.

그리고, 위의 그림에서의 appConfig처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 것을 DI 컨테이너라고 한다.

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

위에서는 개발자가 appConfig를 사용해서 직접 객체를 생성하고 DI를 했지만, 이제부터는 스프링 컨테이너를 사용한다. 스프링 컨테이너는 @Configuration 이 붙은 appConfig 를 설정(구성) 정보로 사용한다. 여기서 @Bean이라 적힌 메서드를 모두 호출해서 반환된 객체를 스프링 컨테이너에 등록한다. 이렇게 스프링 컨테이너에 등록된 객체를 스프링 빈이라 한다. 이전에는 개발자가 필요한 객체를 appConfig를 사용해서 직접 조회했지만, 이제부터는 스프링 컨테이너 를 통해서 필요한 스프링 빈(객체)를 찾아야 한다. 스프링 빈은 applicationContext.getBean() 메서드를 사용해서 찾을 수 있다.
기존에는 개발자가 직접 자바코드로 모든 것을 했다면 이제부터는 스프링 컨테이너에 객체를 스프링 빈으로 등록하고, 스프링 컨테이너에서 스프링 빈을 찾아서 사용하도록 변경되었다.

  • 스프링 빈에는 모두 다른 이름을 부여해야 한다.

싱글톤 컨테이너

싱글톤의 필요성


웹 어플리케이션의 특징상 여러 고객이 동시에 요청을 한다. 고객 트래픽이 초당 100이 나오면 초당 100개 (혹은 그 이상)의 객체가 생성되고 소멸되어야 하는데 이는 메모리 낭비가 매우 심하다. 따라서 객체가 딱 1개만 생성되고, 공유되도록 설계해야 한다. 이것을 싱글톤 패턴이라고 한다.

싱글톤 패턴

클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다. static 영역에서 인스턴스를 미리 하나 생성해서 올려두고, 이 객체 인스턴스가 필요하면 getInstance() 메소드를 통해서만 조회할 수 있다. 생성자를 private으로 해두어서 외부에서 new 키워드로 새로운 객체 인스턴스가 생성되는 것을 막는다.

package hello.core.singleton;

public class SingletonService {

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

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

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

	}

	public void logic() {
	System.out.println("싱글톤 객체 로직 호출");
	}
}
  • 싱글톤 패턴의 문제점
    • 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
    • 클라이언트가 구체 클래스에 의존한다. (DIP 위반)
    • 유연성이 떨어진다.

싱글톤 컨테이너

스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤으로 관리해준다. 이전에 학습한 스프링 빈이 바로 싱글톤으로 관리되는 빈이다. 스프링 컨테이너가 이러한 역할을 해주기 때문에 싱글톤 패턴을 위한 지저분한 코드가 들어가지 않아도 되고, DIP, OCP 등을 위반하지 않을 수 있다.

싱글톤 컨테이너 사용 시 주의할 점

여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지하게 설계하면 안된다. 즉, 무상태로 설계해야한다.

  • 특정 클라이언트에 의존적인 필드가 있으면 안된다.
  • 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
  • 가급적 읽기만 가능해야 한다.
package hello.core.singleton;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;

public class StatefulServiceTest {
	@Test
	void statefulServiceSingleton() {
	ApplicationContext ac = new
	AnnotationConfigApplicationContext(TestConfig.class);
	StatefulService statefulService1 = ac.getBean("statefulService", StatefulService.class);
	StatefulService statefulService2 = ac.getBean("statefulService", StatefulService.class);
    
	//ThreadA: A사용자 10000원 주문
	statefulService1.order("userA", 10000);
    
	//ThreadB: B사용자 20000원 주문
	statefulService2.order("userB", 20000);
	//ThreadA: 사용자A 주문 금액 조회
	int price = statefulService1.getPrice();
	//ThreadA: 사용자A는 10000원을 기대했지만, 기대와 다르게 20000원 출력
	System.out.println("price = " + price);
	Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
static class TestConfig {
	@Bean
	public StatefulService statefulService() {
	return new StatefulService();
	}
}
}

컴포넌트 스캔

컴포넌트 스캔과 의존관계 자동 주입

지금까지 스프링 빈을 등록할 때 자바 코드의 @Bean 등을 통해서 직접 등록할 스프링 빈을 나열했다. 이러한 스프링 빈이 수십, 수백 개가 되면 일일히 등록하기 어렵기 때문에, 스프링에서는 설정 정보가 없어도 자동으로 스프링 빈을 등록하는 컴포넌트 스캔이라는 기능을 제공한다. 또, 의존관계를 자동으로 주입하는 @Autowired라는 기능도 제공한다.

package hello.core;

import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;

import static org.springframework.context.annotation.ComponentScan.*;

@Configuration
@ComponentScan(
	excludeFilters = @Filter(type = FilterType.ANNOTATION, classes = Configuration.class))
public class AutoAppConfig {

}

컴포넌트 스캔을 사용하려면 먼저 @ComponentScan을 설정 정보에 붙여주면 된다. 위 코드를 보면 @Bean으로 등록한 클래스가 없는 것을 볼 수 있다. 컴포넌트 스캔은 이름 그대로 @Component 애노테이션이 붙은 클래스를 스캔해서 스프링 빈으로 등록한다. 따라서 각 클래스가 컴포넌트 스캔의 대상이 되도록 @Component 애노테이션을 붙여주면 된다.

이때, 문제가 발생한다. 이전에는 AppConfig 파일에서 @Bean으로 직접 설정 정보를 작성하고 의존관계도 직접 명시했는데, 이제는 설정 정보 자체가 없기 때문에 의존관계 주입도 해당 클래스 내에서 해결해야 한다. 이를 위해 사용하는 것이 @Autowired이다.

탐색 위치와 기본 스캔 대상

  • 탐색 시작 위치 지정
    모든 자바 클래스를 다 컴포넌트 스캔하면 시간이 오래 걸린다. 그래서 꼭 필요한 위치만 탐색하도록 위치를 지정할 수 있다. 아래와 같이 basePackages를 사용하면 탐색 시작 패키지를 지정할 수 있다. 해당 패키지를 포함한 하위 패키지를 모두 탐색한다. basePackages = {"hello.core", "hello.service"}와 같이 여러 개의 시작 위치를 지정할 수도 있다. 만약 아무것도 지정하지 않으면 @ComponentScan이 붙은 설정 정보 클래스의 패키지가 시작 위치가 된다.
@ComponentScan(
	basePackages = "hello.core",
}
  • 컴포넌트 스캔 기본 대상
    컴포넌트 스캔은 @Component 뿐만 아니라 다음 항목들도 탐색 대상에 포함한다.
    • @Controller
    • @Service
    • @Repository
    • @Configuration

필터

  • includeFilters : 컴포넌트 스캔 대상을 추가로 지정한다.
  • exclideFilters : 컴포넌트 스캔에서 제외한 대상을 지정한다.

@Component면 충분해서 사실 사용할 일이 많이 없다. 따라서 나중에 필요할 때가 오면 찾아서 정리하도록 하겠다.

중복 등록 / 충돌

자동 빈 등록 vs 자동 빈 등록

컴포넌트 스캔으로 등록된 빈 두개가 이름이 같아서 충돌이 날 수 있다. 이 경우에 스프링이 ConflictingBeanDefinitionException이라는 예외를 발생시킨다. 이름이 겹치지 않도록 수정해주면 해결된다.

수동 빈 등록 vs 자동 빈 등록

수동으로 등록한 빈과 컴포넌트 스캔으로 등록된 빈 사이의 충돌이 발생하면 오류가 발생하지 않는다. 이런 상황에서는 수동 등록된 빈이 우선권을 가진다. 즉, 수동 빈이 자동 빈을 오버라이딩 해버린다. 이러한 상황이 의도된 것이면 문제가 없지만 대다수는 의도되지 않은 결과이다. 따라서 최근 스프링 부트에서 수동 빈과 자동 빈이 충돌했을 때 오류가 발생하도록 기본 값을 변경했다. 따라서 수동 빈과 자동 빈이 충돌을 일으켜도 오류가 발생한다.

profile
ambitious person

0개의 댓글