2024. 4. 17. 16:14ㆍSpring
1. 스프링 컨테이너가 지원해주는 것들
이전 글에서는 스프링 컨테이너와 서비스 로케이터가 왜 필요한 지에 관해 이야기를 했었다. 이번 편에서는 스프링 컨테이너가 어떤 것을 지원해주고 그 기능을 어떻게 사용하는지 알아보자.
개인적으로 스프링 컨테이너가 지원해주는 기능 중 가장 크다고 생각하는 부분은 아래의 두 가지이다.
스프링 컨테이너의 역할
○ 애플리케이션 설정 정보를 통한 자동 의존 관계 주입
○ 애플리케이션 내부에서 빈으로 등록한 객체에 대한 싱글톤 관리
스프링 컨테이너는 빈으로 등록한 객체를 싱글톤으로 관리해주며, 애플리케이션 내부의 설정 정보를 통해 자동 의존 관계 주입을 해준다. 그럼 이게 어떤 장점이 있는 지 알아야 할 것 같다. 그래서 알아보고자 하는 내용은 아래와 같다.
첫 번째로는 빈은 무엇이고 어떻게 등록하며 자동 의존 관계 주입이 어떻게 일어나는지 등에 관해 알아볼 예정이다.
두 번째로는 첫 번째 과정을 이해하면 충돌이 일어났을 때는 어떻게 해결하는지, 추상화된 빈을 동시에 사용하고 싶은 경우에는 어떻게 해결할 수 있는 지에 관해 알아볼 예정이다.
세 번째로는 등록된 빈이 싱글톤으로 관리되었을 때 어떤 장점이 있는 지, static 영역으로 가져다 놓는 방법과는 무슨 차이가 있는 지에 관해서 알아볼 예정이다.
2. 스프링 빈과 자동 의존 관계 주입
스프링 빈은 스프링 컨테이너에 의해서 자동적으로 관리되고 있는 재사용 가능한 컴포넌트라고 한다. 말이 잘 와닿지 않지만, 스프링 컨테이너는 특정 클래스 객체를 보관하고 클래스 객체가 필요할 때마다 제공하는 역할을 한다.
일단 사용하는 방법을 보는 게 더 좋을 것 같다. 스프링 빈을 등록하는 방법은 컴포넌트 스캔을 통한 자동 방식과 수동 빈 등록 방식이 있는데, 우선 자동 빈 등록 방식부터 알아보고자 한다.
자동 빈 등록 방식은 @Component 애노테이션과 @ComponentScan 애노테이션에 의해서 일어나게 된다. @Component는 직접 작성하는 컨트롤러, 서비스, 리포지토리 등의 클래스에 설정하고 @ComponentScan은 메인 애플리케이션에 설정한다.
그러면 @ComponentScan에서 설정된 패키지 범위의 하위 패키지를 모두 검색하며 @Component 애노테이션이 달린 클래스들을 찾아 스프링 컨테이너 내부에 빈으로 등록하고 @Autowired가 붙어있는 애노테이션을 찾아 남는 컨테이너 내부에 있는 값을 찾아 주입해준다. 이게 스프링 컨테이너가 지원하는 자동의존관계 주입 방식이다.
사용방법은 아래와 같다. 예시로는 쿠폰 서비스를 통한 할인 정책을 시도할 예정인데, 각각의 쿠폰마다의 별도의 할인이 있다. 서비스는 사용자가 요청한 쿠폰에 따라 할인 가격을 알려주는 역할을 할 예정이다. 그래서 구조는 아래와 같다.
코드는 아래처럼 작성하였는데, 생성자를 직접 작성한 이유는 Test 작동 과정에서 애플리케이션 생성 시점에 해당 생성자의 호출 여부를 확인하기 위해서이다.
@Service
public class CouponService {
private final CouponPolicy couponPolicy;
public CouponService(CouponPolicy couponPolicy) {
System.out.println("CouponService generated");
this.couponPolicy = couponPolicy;
}
public int discount(int price) {
return couponPolicy.discount(price);
}
}
public interface CouponPolicy {
public int discount(int price);
}
@Component
public class Percent10Coupon implements CouponPolicy {
private final int DISCOUNT_10PERCENT_RATE = 10;
public Percent10Coupon() {
System.out.println("percent10Coupon generated");
}
@Override
public int discount(int price) {
int reducePrice = price / DISCOUNT_10PERCENT_RATE;
if(reducePrice > 1500)
reducePrice = 1500;
return price - reducePrice;
}
}
아래처럼 작성을 하고 CouponService에서 윈도우 기준으로 ctrl + shift + T 버튼을 통해 테스트를 만들어줄 수 있다. 해당 테스트에서 확인하고 싶은 내용은 couponService 객체 내에 추상화된 쿠폰이 제대로 주입이 되었는가와 주입된 쿠폰의 인스턴스 타입이 Percent10Coupon인가이다. 그래서 테스트를 아래처럼 작성하였다. 작동을 해보면 정상적으로 잘 작동하고 있음을 알 수 있다.
@SpringBootTest
class CouponServiceTest {
@Autowired
private CouponService couponService;
@Autowired
private CouponPolicy couponPolicy;
@Test
void couponServiceGenerateTest() {
assertTrue(couponService.getCouponPolicy() == couponPolicy);
assertThat(couponPolicy).isInstanceOf(Percent10Coupon.class);
}
}
3. 스프링 컨테이너 내부에서 빈 충돌이 일어났을 때
궁금한 점이 생겼다. 지금은 인터페이스를 상속받은 CouponPolicy가 1개여서 문제가 없는게 아닌가? 만약에 회사에서 10퍼센트 쿠폰이 아니라 이번엔 15퍼센트짜리 쿠폰을 만들어달라고 하고 최대 3000원까지 할인된다는 정책을 냈다고 하자. 그래서 다음처럼 쿠폰을 만들었다.
@Component
public class Percent15Coupon implements CouponPolicy {
private final int DISCOUNT_15PERCENT_RATE = 15;
public Percent15Coupon() {
System.out.println("percent15Coupon generated");
}
@Override
public int discount(int price) {
int reducePrice = price / DISCOUNT_15PERCENT_RATE;
if(reducePrice > 3000)
reducePrice = 3000;
return price - reducePrice;
}
}
그리고 이전에 만들었던 테스트를 다시 돌려보면? 아래와 같은 에러와 함께 스프링 서버 자체가 띄워지지 않는다. 그 이유는 지금 스프링 컨테이너에서 couponService의 CouponPolicy에 적절한 타입을 찾아주어야 하는데, 컨테이너 내부에서 2개가 발견되었고 추가적인 정보가 부족하여 둘 중에 무엇을 선택할 지 모르겠으니, 난 모르겠고 너가 좀 더 힌트를 줘라라는 의미이다.
IllegalArgumentException : Failed to load ApplicationContext for ----
Error creating bean with name 'couponService' defined
'Mystudy.spring.springcontainer.coupon.CouponPolicy' available: expected single matching bean but found 2
이 문제를 해결하는 방법을 이해하기 위해선 스프링 컨테이너 내부에 어떻게 객체가 저장되어있고 어떻게 가져올 수 있는가를 알아야 한다. 더 많은 정보가 있지만, 간단하게 스프링 컨테이너 내부에 객체가 저장되어 있는 방식을 설명하면 아래와 같다.
저장 이름 | 저장 타입 | 실제 객체 |
couponService | CouponService.class | couponService 객체 |
percent10Coupon | Percent10Coupon.class | percent10Coupon 객체 |
percent15Coupon | Percent15Coupon.class | percent15Coupon 객체 |
대략 규칙을 보면 저장 이름은 실제 타입 클래스의 맨 앞 글자를 소문자로 바꾼 것임과 내부에서 조회할 때 저장 이름과 저장 타입으로 조회할 수 있음을 알 수 있다. 또한 상위 클래스인 BeanFactory.class에 가보면 아래의 메서드들을 확인할 수 있는데, 이름과 타입으로 컨테이너 내부의 빈을 조회해서 가져올 수 있다.
public interface BeanFactory {
Object getBean(String name) throws BeansException;
<T> T getBean(Class<T> requiredType) throws BeansException;
}
또한 어떻게 사용되는지 정확한 구조는 모르고 알 필요는 없지만, BeanFactory 클래스와 ApplicationContext 클래스의 중간 계층을 보면 계층적 조회와 관련된 클래스들이 존재함을 알 수 있다. 그래서, 2개의 객체가 충돌이난다는 에러 메시지를 통해서 추론할 수 있는 내용은 다음과 같다.
'지금 couponService의 필드는 CouponPolicy 타입의 객체이니, CouponPolicy의 하위 계층을 모두 찾은 것이구나. 그러면 저장 방식과 조회 방식을 알았으니 service 칸에서 원하는 정보를 찾을 수 있게 구체 클래스로 선언하거나 이름을 바꿔보는 게 좋지 않을까?'
결론만 말하면, 구체 클래스로 변경하는 방법도 있지만 이는 관련 클래스를 매번 바꾸어야 하는 문제가 있으니, 스프링에서는 Qualifier를 사용한 방식을 제공한다. 주입 받은 파라미터에 혹은 @Autowired 애노테이션 아래에 @Qualifier 표시를 하고 구체적인 이름을 가져오면 된다.
@Service
@Getter
public class CouponService {
private final CouponPolicy couponPolicy;
@Autowired
public CouponService(@Qualifier("percent10Coupon") CouponPolicy couponPolicy) {
System.out.println("CouponService Generated");
this.couponPolicy = couponPolicy;
}
public int discount(int price) {
return couponPolicy.discount(price);
}
}
@SpringBootTest
class CouponServiceTest {
@Autowired
private CouponService couponService;
@Autowired
@Qualifier("percent10Coupon")
private CouponPolicy couponPolicy;
@Test
void couponServiceGenerateTest() {
assertTrue(couponService.getCouponPolicy() == couponPolicy);
assertThat(couponPolicy).isInstanceOf(Percent10Coupon.class);
}
}
그런데, 위의 방식이 문제 해결을 위한 옳은 방식인지가 맞는지 고민을 해볼 필요가 있다. 보통 테스트 코드 때와 불변성 유지와 같은 이유로 DI 방식을 생성자 주입으로 선택을 한다. 그런데 쿠폰을 보통 사용하는 건 사용자가 직접 여러 종류의 쿠폰 중 하나를 골라서 사용한다. 런타임 시점에서 저렇게 코드를 짜면 할인 방식을 바꿀 수 없다. 위의 상황은 이벤트 같은 거라서 모두에게 동일한 할인률을 놓는 것에나 어울릴 법하다.
이 문제를 해결하는 방법은 Map이나 List를 이용하는 방법이다. 아래처럼 CouponService를 변경해보자. 그러면 과거에는 충돌이 났던 것과 달리 모든 CouponPolicy가 저 맵에 String, CouponPolicy 쌍으로 저장이 된다. String 이름은 당연히 저장된 내용에서 앞 글자만 소문자로 바꾼 이름이다.
@Service
@RequiredArgsConstructor
public class CouponService {
private final Map<String, CouponPolicy> couponPolicies;
public int discount(int price, String couponName) {
return couponPolicies.get(couponName).discount(price);
}
}
따라서 테스트는 다음과 같이 변경될 수 있다. 이전과 달리 String으로 파라미터를 하나 더 받아서 Map에서 이름으로 객체를 찾아서 적절한 할인을 제공해주는 것이다. 개수가 적으면 Enum으로 하든, DB에 쿠폰과 이름을 저장하건 자유롭게 해서 고객이 설정한 쿠폰에 따른 할인을 적용시킬 수 있다.
@SpringBootTest
class CouponServiceTest {
@Autowired
private CouponService couponService;
@Test
void couponServiceTest() {
assertThat(couponService.discount(50000, "percent10Coupon"))
.isEqualTo(48500);
}
}
자동 주입 방식이 알아서 @Component만 붙여주면 알아서 찾아서 객체 만들어주고 DI로 조립까지 알아서 해주니 편하다. 그런데, 위의 @Qualifier를 이용한 방법과 Map을 이용한 방법이 둘 다 마음에 안드는 부분이 하나 있다. 뭔가 나중에 수정이 일어나거나 남이 보면 도대체 무슨 종류의 쿠폰을 가지고 있는 것인지에 대한 가독성이 떨어진다. 특히나 @Qualifier를 사용하면 이름을 보고 클래스를 검색할 수라도 있지, Map을 이용한 방식은 패키지 내부의 이름을 모두 확인해야 한다.
따라서, 수동으로 빈을 등록하는 방법도 알아두면 좋다. 개인적으로는 위의 문제가 발생한다면 수동 빈 등록과 주석을 남길 것 같다. 아래처럼 말이다. 그러면 CouponConfig에 가면 클래스 명칭으로 등록된 쿠폰 종류와 동작 예상에 관해 추론할 수 있다. 또한, CouponService의 couponPolicies 내용을 모를 때, CouponConfig로 가면 모든 내용이 있음을 알 수 있다.
@Configuration
public class CouponConfig {
@Bean
public CouponPolicy percent10Coupon() {
return new Percent10Coupon();
}
@Bean
public CouponPolicy percent15Coupon() {
return new Percent15Coupon();
}
}
@Service
@RequiredArgsConstructor
public class CouponService {
// See CouponConfig
private final Map<String, CouponPolicy> couponPolicies;
public int discount(int price, String couponName) {
return couponPolicies.get(couponName).discount(price);
}
}
4. 스프링 컨테이너와 싱글톤
스프링 컨테이너의 또 다른 역할은 객체를 싱글톤으로 관리하게 해준다. 싱글톤이라는 건 전체 애플리케이션 내에서 해당 클래스의 객체가 딱 1개임을 보장해주는 것을 말한다. 객체가 딱 1개임을 보장해줬을 때 장점은 다음과 같다.
장점
○ 객체 생성 및 GC 과정에서 드는 비용 절약이 가능
○ 동일한 객체를 여러 개 생성함으로써 메모리 절약 가능
○ 공유되는 자원이 필드에 존재하는 클래스의 경우(ex. 메모리 세션) 동일한 필드에 접근하게도 보장
단점
○ 모든 상황을 고려해서 싱글톤 객체를 개발자가 하나하나 만들기 쉽지 않음.
○ 구체 클래스에 의존해서 OCP와 DIP를 위반함
○ 싱글톤 보장을 위한 private 생성자가 상속을 어렵게 함.
싱글톤 패턴 자체는 장점 하나 살리겠다고 잃을 게 너무 많다. 특히나 싱글톤이 알기로는 방법이 7가지인데, 그거 하나하나 일일히 선택하기도 쉽지 않다. 만약 싱글톤 패턴이 궁금하다면 다음 글이 제일 잘 정리되어있다고 생각하니 해당 글을 읽어보길 바란다.
그런데, 스프링 컨테이너를 사용하면 싱글톤을 보장해준다는데 무슨 말인가 싶다. 이 말을 알아보기 위해서 앞의 CouponService와 완전히 동일한 CouponService2를 만들었다. 이전에 생성자를 직접 만들었던 게 기억이 나는지 모르겠지만, 아무튼 그걸 이제 쓸 때가 되었다.
기억이 안날까봐 아래에 코드를 가져왔다 각각 생성자가 호출되면 어떤 클래스의 생성자가 호출되는지를 콘솔을 통해서 알 수 있게 해놨다.
public class Percent10Coupon implements CouponPolicy {
private final int DISCOUNT_10PERCENT_RATE = 10;
public Percent10Coupon() {
System.out.println("percent10Coupon generated");
}
@Override
public int discount(int price) {
int reducePrice = price / DISCOUNT_10PERCENT_RATE;
if(reducePrice > 1500)
reducePrice = 1500;
return price - reducePrice;
}
}
@Service
public class CouponService {
// See CouponConfig
private final Map<String, CouponPolicy> couponPolicies;
public Map<String, CouponPolicy> getCouponPolicies() {
return couponPolicies;
}
public CouponService(Map<String, CouponPolicy> couponPolicies) {
System.out.println("CouponService generated");
this.couponPolicies = couponPolicies;
}
public int discount(int price, String couponName) {
return couponPolicies.get(couponName).discount(price);
}
}
메인 애플리케이션을 보면, 로그에 아래처럼 4줄만 나오는 걸 확인할 수 있다.
percent10Coupon generated
percent15Coupon generated
CouponService generated
CouponService2 generated
추가적으로 아래와 같이 쿠폰 서비스2를 필드로 테스트에 주입하고 추가 테스트를 진행해도 동일한 객체임을 확인할 수 있다.
@Test
void 모든_쿠폰서비스는_동일한_쿠폰객체를_주입받는다() {
CouponPolicy couponPolicyFromCouponService =
couponService.getCouponPolicies().get("percent10Coupon");
CouponPolicy couponPolicyFromCouponService2 =
couponService2.getCouponPolicies().get("percent10Coupon");
assertTrue(couponPolicyFromCouponService == couponPolicyFromCouponService2);
}
참고로 수동 빈에서 새로운 객체를 만들어서 조립하는 방식에서 아무리 새로운 쿠폰 객체를 CouponService 생성자 내에서 호출해도 동일하다. 그 이유는 스프링 컨테이너 내부에서 CGLIB 같은 바이트 코드 조작 방식으로 빈으로 등록되는 객체는 생성자가 호출될 때 프록시 방식으로 기능이 추가되기 때문이다. 이는 아래와 같은 테스트 코드로 확인할 수 있다.
@Test
void 스프링빈에_수동으로_등록된_객체는_CGLIB기술로_조작된다() {
ApplicationContext ac = new AnnotationConfigApplicationContext(CouponConfig.class);
CouponConfig findConfigBean = ac.getBean(CouponConfig.class);
System.out.println("findConfigBean = " + findConfigBean);
}
스프링 컨테이너에 등록된 빈 객체들은 싱글톤임에서 주의할 점이 있다. 스프링은 단일 쓰레드 환경이 아니다. 미리 커넥션 풀 쓰레드를 Tomcat에서 설정한 값만큼 미리 만들어놓고 요청에 따라 할당 후 끝나면 다시 넣는 방식을 사용한다. 따라서, 필드의 값을 변경할 수 있게 만들면 여러 요청이 오는 경우, 특정 객체에 대해서 동시에 접근할 수 있다. 이 때 경쟁상황이 올 수 있기 때문에 매우 각별한 주의를 해야한다. 또한 쓰레드 로컬 같은 걸 사용할 때에도 반드시 커넥션 생명주기가 끝나기 전에 어떻게든 정보를 지울 방법도 고민해야 한다. 이 점을 반드시 기억하고 넘어갔으면 좋겠다.
마지막으로 알아보고자 하는 내용은 싱글톤을 Static으로 구현하면 안되는가에 대한 내용이다. 굉장히 많은 이유가 있을 것 같지만, 주제에 맞게 이야기를 해보고자 한다.
정적 메서드나 정적 클래스가 생성되는 시점은 개별 객체가 생성되는 시점보다 빠르다. 맨 처음 왜 이 글을 썼는지로 돌아가보자. 스프링 컨테이너를 사용하려고 하는 이유는 상속대신 조립과 DI를 통해 더 유연한 구조를 만들기 위해서였다. 그런데 조립 후 DI를 하려면 객체를 만들어진 상태로 올라가야 하는데 static 표기 때문에 애플리케이션을 돌리기 전부터 컴파일러에서 이를 거부할 것이다. 따라서, 결국엔 클래스 내부에서 직접 객체를 생성하게 된다. 그러면 더 유연한 구조를 만든다는 목표와 멀어지게 된다. 아마도 이런 이유 때문에 싱글톤 패턴보다 더 나쁜 구조를 가지기 때문이 아닐까 싶다.
[ 참고 자료 ]
https://tecoble.techcourse.co.kr/post/2020-11-07-singleton/
스프링 핵심 원리 기본편 - 김영한
'Spring' 카테고리의 다른 글
JPA, Json 사용 필수 메서드와 주의사항 (0) | 2024.03.27 |
---|---|
스프링의 Layered Architecture와 폴더 구조 (1) | 2024.03.27 |
스프링 시큐리티와 JPA 변경감지 동작 오작동 해결 (1) | 2024.03.25 |
스프링 MVC 1편 - 서블릿과 서블릿 컨테이너 (0) | 2024.02.02 |
스프링 MVC 1편 - Web Server와 WAS (0) | 2024.02.02 |