package springmsa.springmsa_user_service.service;
import jakarta.ws.rs.NotFoundException;
import lombok.RequiredArgsConstructor;
import org.modelmapper.ModelMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import springmsa.springmsa_user_service.dto.ResponseOrderDto;
import springmsa.springmsa_user_service.dto.UserDto;
import springmsa.springmsa_user_service.entity.Users;
import springmsa.springmsa_user_service.repository.UserRepository;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final ModelMapper modelMapper;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
public Users createUser(UserDto userDto) {
userDto.setUserId(UUID.randomUUID().toString().substring(0, 8));
Users users = modelMapper.map(userDto, Users.class);
users.setEncryptedPwd(bCryptPasswordEncoder.encode(userDto.getPwd()));
userRepository.save(users);
return users;
}
@Override
public UserDto findUserById(Long id) {
Optional<Users> user = userRepository.findById(id);
if (user.isEmpty()) {
throw new NotFoundException("User not found");
}
UserDto userDto = modelMapper.map(user.get(), UserDto.class);
List<ResponseOrderDto> orders = new ArrayList<>();
userDto.setOrders(orders);
return userDto;
}
@Override
public Iterable<Users> findAll() {
return userRepository.findAll();
}
}
findUserById(Long id)와 findAll() 메서드를 구현하는데 내용은 간단하다.
findAll()은 repository에 위임하는것이 끝이고 findUserById(Long id)는 유저 아이디를 파라미터로 받으면 repository에서 먼저 유저를 찾은 후 있다면 ModelMapper를 이용해서 DTO로 변환한다. 유저는 추후에 만들 Order MicroService에 존재하는 주문 내역을 가지는데 우선은 Order MicroService를 만들지 않았으니 유저가 가지고 있는 주문 내역은 빈 리스트로 넣어 반환한다.
컨트롤러를 보면 getUsers()와 getUser(@PathVariable Long id)가 있다.
전체 조회 코드를 먼저 보면, 서비스로부터 전체 유저 데이터를 받아온다. 그 다음 받아온 결과를 DTO로 변환해주는 코드가 필요하다.
항상 컨트롤러에서 데이터를 반환할 땐 엔티티 자체가 아닌 DTO로 변환하여 돌려주어야 한다. 그래야 해당 엔티티의 변화에도 API 스펙에 영향이 가지 않을 뿐더러 (사실 이게 제일 중요) 엔티티를 리턴하는 것 자체가 좋은 방법이 아니다. 불필요한 데이터까지 API에 모두 태울 수 있으니.
단일 조회 코드를 보면, URI로부터 유저 ID를 받아온다. 그 ID로 서비스로부터 유저를 조회하여 받아온다. 받아온 유저를 역시나 DTO로 변환한다. 굳이 ResponseUserDto와 ResponseUsersDto로 구분지은 이유는 전체 유저를 조회할 땐 유저의 주문 내역을 반환하지 않기 위해서다.
@Aspect 애노테이션을 붙이고 그 안에 @Around, @AfterReturning, @Before, ... 등등의 애노테이션만 달아주면 어떻게 여기서 작성한 포인트컷에 해당하는 객체들이 프록시로 등록되고 그 안에 로직이 어드바이스가 될까? 즉, 결국 그 메서드가 어떻게 Advisor가 되어 프록시 팩토리에 의해 프록시를 만들까? 정답은 이 빈 후처리기다.
빈 후처리기
스프링에서@Bean이나 컴포넌트 스캔으로 스프링 빈을 등록하면 스프링은 대상 객체를 생성하고 스프링 컨테이너 내부의 빈 저장소에 등록한다. 그리고 이후에는 스프링 컨테이너를 통해 등록한 스프링 빈을 조회해서 사용하면 된다.
스프링이 빈 저장소에 등록할 목적으로 생성한 객체를 빈 저장소에 등록하기 직전에 조작하고 싶다면 빈 후처리기를 사용하면 된다.
BeanPostProcessor는 번역하면 빈 후처리기로, 이름 그대로 빈을 생성한 후에 무언가를 처리하는 용도로 사용한다.
빈 후처리기는 강력하다. 객체를 조작하는게 가능하고 완전히 다른 객체로 바꿔치기 하는 것도 가능하다. 빈 후처리기 과정을 자세히 살펴보자.
빈 후처리기 과정
1. 생성: 스프링 빈 대상이 되는 객체를 생성한다 (@Bean, 컴포넌트 스캔 모두 포함)
2. 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.
3. 후 처리 작업: 빈 후처리기는 전달된 스프링 빈 객체를 조작하거나 다른 객체로 바꿔치기 할 수 있다.
4. 등록: 빈 후처리기는 빈을 반환한다. 전달된 빈을 그대로 반환하면 해당 빈이 등록되고, 바꿔치기 하면 다른 객체가 빈 저장소에 등록된다.
여기서 '3. 후 처리 작업'을 보면 스프링 빈 객체를 조작 또는 바꿔치기 한다고 되어 있는데 이 말은 무슨 말일까? 다음 그림을 보자.
빈 후처리기에서 객체 A를 객체 B로 바꿔버린 모습을 볼 수 있다. 그리고 그 바꾼 객체 B를 스프링 빈 저장소에 전달하면 최초 객체 A가 객체 B로 최종 등록된다. 이것을 객체를 조작 또는 바꿔치기한다 말한다.
빈 후처리기를 사용하려면 BeanPostProcessor 인터페이스를 구현하고, 스프링 빈으로 등록하면 된다.
이 BeanPostProcessor 인터페이스는 두 개의 메서드를 제공한다.
postProcessBeforeInitialization: 객체 생성 이후에 @PostConstruct 같은 초기화가 발생하기 전에 호출되는 포스트 프로세서
postProcessAfterInitialization: 객체 생성 이후에 @PostConstruct 같은 초기화가 발생한 다음에 호출되는 포스트 프로세서
이 빈후처리기를 통해 특정 객체를 다른 객체로 변경해버리는 예시 코드를 작성해보자.
빈 후처리기 - 예제 코드1
BeanPostProcessorTest
package cwchoiit.springadvanced.proxy.postprocessor;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.BeanCreationException;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import static org.assertj.core.api.Assertions.*;
@Slf4j
public class BasicTest {
@Test
void basicConfig() {
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(BasicConfig.class);
A a = applicationContext.getBean("beanA", A.class);
a.helloA();
assertThatThrownBy(() -> applicationContext.getBean(B.class))
.isInstanceOf(NoSuchBeanDefinitionException.class);
}
@Configuration
static class BasicConfig {
@Bean(name = "beanA")
public A a() {
return new A();
}
}
static class A {
public void helloA() {
log.info("helloA");
}
}
static class B {
public void helloB() {
log.info("helloB");
}
}
}
new AnnotationConfigApplicationContext(BasicConfig.class) → 스프링 컨테이너를 생성하면서 BasicConfig.class를 넘겨주었다. BasicConfig.class 설정 파일은 스프링 빈으로 등록된다.
빈 등록
@Configuration
static class BasicConfig {
@Bean(name = "beanA")
public A a() {
return new A();
}
}
beanA라는 이름으로 A 객체를 스프링 빈으로 등록했다.
빈 조회
A a = applicationContext.getBean("beanA", A.class);
빈 후처리기를 사용하려면, BeanPostProcessor 인터페이스를 구현하고, 스프링 빈으로 등록하면 된다.
참고로, 스프링 부트를 사용하면 이미 수많은 빈 후처리기가 자동 등록된 상태이다. 우리도 필요하면 추가적으로 빈 후처리기를 이렇게 등록할 수 있다.
postProcessBeforeInitialization: 객체 생성 이후에 @PostConstruct 같은 초기화가 발생하기 전에 호출되는 포스트 프로세서이다.
postProcessAfterInitialization: 객체 생성 이후에 @PostConstruct 같은 초기화가 발생한 다음에 호출되는 포스트 프로세서이다.
BeanPostProcessorTest
package cwchoiit.springadvanced.proxy.postprocessor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Slf4j
public class BeanPostProcessorTest {
@Test
void basicConfig() {
AnnotationConfigApplicationContext applicationContext = new AnnotationConfigApplicationContext(BeanPostProcessorConfig.class);
B b = applicationContext.getBean("beanA", B.class);
b.helloB();
assertThatThrownBy(() -> applicationContext.getBean(A.class))
.isInstanceOf(NoSuchBeanDefinitionException.class);
}
@Configuration
static class BeanPostProcessorConfig {
@Bean(name = "beanA")
public A a() {
return new A();
}
@Bean
public AToBPostProcessor toBPostProcessor() {
return new AToBPostProcessor();
}
}
static class A {
public void helloA() {
log.info("helloA");
}
}
static class B {
public void helloB() {
log.info("helloB");
}
}
static class AToBPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
log.info("beanName = {}, bean = {}", beanName, bean);
if (bean instanceof A) {
return new B();
}
return bean;
}
}
}
이번엔 빈 후처리기를 직접 구현해봤다. AToBPostProcessor는 인터페이스인 BeanPostProcessor를 구현하고, 스프링 빈으로 등록하면 스프링 컨테이너가 빈 후처리기로 인식하고 동작한다.
이 빈 후처리기는 A 객체를 새로운 B 객체로 바꿔치기한다. 파라미터로 넘어오는 빈 객체가 A의 인스턴스이면 새로운 B 객체를 생성해서 반환한다. 여기서 A 대신에 반환된 값인 B가 스프링 컨테이너에 등록된다. 다음 실행결과를 보면 beanName = beanA, bean = A 객체의 인스턴스가 빈 후처리기에 넘어온 것을 확인할 수 있다.
실행 결과
beanName = beanA, bean = cwchoiit.springadvanced.proxy.postprocessor.BeanPostProcessorTest$A@50916ff4
..B - hello B
실행 결과를 보면, 최종적으로 beanA 라는 스프링 빈 이름에 A 객체 대신에 B 객체가 등록된 것을 확인할 수 있다. A는 스프링 빈으로 등록조차 되지 않는다. 이게 빈 후처리기이다.
정리
이렇게 빈 후처리기를 통해 스프링이 빈 저장소(스프링 컨테이너)에 등록할 객체를 강력한 방식으로 조작하고 변경할 수 있다. 여기서 조작이란 메서드를 호출함을 의미한다. 일반적으로 스프링 컨테이너가 등록하는, 특히 컴포넌트 스캔의 대상이 되는 빈들은 중간에 조작할 방법이 없는데 빈 후처리기를 사용하면 개발자가 등록하는 모든 빈을 중간에 조작할 수 있게 된다. 이 말은 빈 객체를 프록시로 교체하는 것도 가능하다는 뜻이다. 약간 느낌이 애노테이션 프로세싱과 비슷하다. 중간에 후킹을 할 수 있는 어떤 포인트를 만들어주는 것이 유사하다.
실제로 스프링은 AOP를 구현할 때 빈 후처리기를 통해 컴포넌트 스캔으로 등록되는 빈 중 프록시로 만들어져야 하는 객체를 포인트컷을 통해 찾아 프록시로 변경하여 등록해준다.
참고로, @PostConstruct는 스프링 빈 생성 이후에 빈을 초기화 하는 역할을 한다. 그런데 생각해보면 빈의 초기화라는 것이 단순히 @PostConstruct 애노테이션이 붙은 초기화 메서드를 한번 호출만 하면 된다. 쉽게 이야기해서 생성된 빈을 한번 조작하는 것이다. 따라서 빈을 조작하는 행위를 하는 적절한 빈 후처리기가 있으면 될 것 같다. 스프링은 CommonAnnotationBeanPostProcessor라는 빈 후처리기를 자동으로 등록하는데, 여기에서 @PostConstruct 애노테이션이 붙은 메서드를 호출한다.
빈 후처리기 - 적용
빈 후처리기를 사용해서 실제 객체 대신 프록시를 스프링 빈으로 등록해보자. 이렇게 하면 수동으로 등록하는 빈은 물론이고, 컴포넌트 스캔을 사용하는 빈까지 모두 프록시를 적용할 수 있다. 더 나아가서 설정 파일에 있는 수많은 프록시 생성 코드도 한번에 제거할 수 있다.
이 빈 후처리기는 원본 객체를 프록시 객체로 변환하는 역할을 한다. 이때 프록시 팩토리를 사용한다. 프록시 팩토리는 advisor가 필수이기 때문에 이 부분은 외부에서 주입받도록 했다.
모든 스프링 빈들에 프록시를 적용할 필요도 해서도 안된다. 여기서는 특정 패키지와 그 하위에 위치한 스프링 빈들만 프록시를 적용한다. 그러기 위해 외부에서 특정 패키지 경로를 주입받는다. 외부에서 프록시가 적용되길 원하는 특정 패키지 경로를 주입받고 postProcessAfterInitialization 메서드에서 이 패키지 또는 이 패키지 하위 경로인지 체크한다.
BeanPostProcessorConfig
package cwchoiit.springadvanced.proxy.config.v4_postprocessor;
import cwchoiit.springadvanced.proxy.config.AppV1Config;
import cwchoiit.springadvanced.proxy.config.AppV2Config;
import cwchoiit.springadvanced.proxy.config.v3_proxyfactory.advice.LogTraceAdvice;
import cwchoiit.springadvanced.proxy.config.v4_postprocessor.postprocessor.PackageLogTracePostProcessor;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.Advisor;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Slf4j
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class BeanPostProcessorConfig {
@Bean
public PackageLogTracePostProcessor packageLogTracePostProcessor(LogTrace trace) {
return new PackageLogTracePostProcessor("cwchoiit.springadvanced.proxy.app", getAdvisor(trace));
}
private Advisor getAdvisor(LogTrace trace) {
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save*");
LogTraceAdvice logTraceAdvice = new LogTraceAdvice(trace);
return new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
}
}
빈 후처리기를 구현했으면 스프링 빈으로 등록해야 한다. 빈 후처리기는 스프링 빈으로 등록만 하면 자동으로 동작한다. 여기에 프록시를 적용할 패키지 정보와 어드바이저를 넘겨준다.
이제 프록시를 생성하는 코드가 설정 파일에는 필요가 없다. 순수한 빈 등록만 고민하면 된다. 프록시를 생성하고 프록시를 스프링 빈으로 등록하는 것은 빈 후처리기가 모두 처리해준다.
실행 결과
...
2025-01-09T16:03:55.688+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = org.springframework.boot.autoconfigure.http.JacksonHttpMessageConvertersConfiguration, bean = class org.springframework.boot.autoconfigure.http.JacksonHttpMessageConvertersConfiguration
2025-01-09T16:03:55.688+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = org.springframework.boot.autoconfigure.http.client.HttpClientAutoConfiguration, bean = class org.springframework.boot.autoconfigure.http.client.HttpClientAutoConfiguration
2025-01-09T16:03:55.689+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = spring.http.client-org.springframework.boot.autoconfigure.http.client.HttpClientProperties, bean = class org.springframework.boot.autoconfigure.http.client.HttpClientProperties
2025-01-09T16:03:55.691+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = clientHttpRequestFactoryBuilder, bean = class org.springframework.boot.http.client.JdkClientHttpRequestFactoryBuilder
2025-01-09T16:03:55.692+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = clientHttpRequestFactorySettings, bean = class org.springframework.boot.http.client.ClientHttpRequestFactorySettings
2025-01-09T16:03:55.693+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = spring.info-org.springframework.boot.autoconfigure.info.ProjectInfoProperties, bean = class org.springframework.boot.autoconfigure.info.ProjectInfoProperties
2025-01-09T16:03:55.693+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration, bean = class org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration
2025-01-09T16:03:55.694+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration, bean = class org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration
2025-01-09T16:03:55.694+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = spring.sql.init-org.springframework.boot.autoconfigure.sql.init.SqlInitializationProperties, bean = class org.springframework.boot.autoconfigure.sql.init.SqlInitializationProperties
2025-01-09T16:03:55.695+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = org.springframework.boot.autoconfigure.task.TaskSchedulingConfigurations$ThreadPoolTaskSchedulerBuilderConfiguration, bean = class org.springframework.boot.autoconfigure.task.TaskSchedulingConfigurations$ThreadPoolTaskSchedulerBuilderConfiguration
2025-01-09T16:03:55.695+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = spring.task.scheduling-org.springframework.boot.autoconfigure.task.TaskSchedulingProperties, bean = class org.springframework.boot.autoconfigure.task.TaskSchedulingProperties
2025-01-09T16:03:55.696+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = threadPoolTaskSchedulerBuilder, bean = class org.springframework.boot.task.ThreadPoolTaskSchedulerBuilder
2025-01-09T16:03:55.697+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = org.springframework.boot.autoconfigure.task.TaskSchedulingConfigurations$SimpleAsyncTaskSchedulerBuilderConfiguration, bean = class org.springframework.boot.autoconfigure.task.TaskSchedulingConfigurations$SimpleAsyncTaskSchedulerBuilderConfiguration
2025-01-09T16:03:55.698+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = simpleAsyncTaskSchedulerBuilder, bean = class org.springframework.boot.task.SimpleAsyncTaskSchedulerBuilder
2025-01-09T16:03:55.698+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration, bean = class org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration
2025-01-09T16:03:55.698+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration, bean = class org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration
2025-01-09T16:03:55.698+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = httpMessageConvertersRestClientCustomizer, bean = class org.springframework.boot.autoconfigure.web.client.HttpMessageConvertersRestClientCustomizer
2025-01-09T16:03:55.699+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = restClientSsl, bean = class org.springframework.boot.autoconfigure.web.client.AutoConfiguredRestClientSsl
2025-01-09T16:03:55.699+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = restClientBuilderConfigurer, bean = class org.springframework.boot.autoconfigure.web.client.RestClientBuilderConfigurer
2025-01-09T16:03:55.699+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration, bean = class org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration
2025-01-09T16:03:55.700+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = org.springframework.boot.autoconfigure.web.embedded.EmbeddedWebServerFactoryCustomizerAutoConfiguration, bean = class org.springframework.boot.autoconfigure.web.embedded.EmbeddedWebServerFactoryCustomizerAutoConfiguration
2025-01-09T16:03:55.700+09:00 INFO 72763 --- [springadvanced] [ main] c.s.p.c.v.p.PackageLogTracePostProcessor : param beanName = multipartResolver, bean = class org.springframework.web.multipart.support.StandardServletMultipartResolver
...
무수히 많은 로그가 찍힌다. 왜일까? 자동으로 등록되는 스프링 빈이 이렇게 많다는 것이다. 그리고 빈 후처리기는 그 모든 빈들에 대해서 호출되기 때문에 당연히 이렇게 많은 로그가 찍힌다. 그래서 위에서 특정 패키지로 제한한 것이다.
우리의 프록시로 등록된 녀석들을 확인하기 위해 /v1/request, /v2/request, /v3/request 를 호출해보자. 아래와 같이 로그가 잘 찍히는 것을 확인할 수 있다.
빈 후처리기는 스프링 빈을 생성한 후에 초기화 하기 직전 또는 직후 이 빈을 조작하거나 바꿔치기할 수 있는 기술이다. 그리고 이 빈 후처리기를 우리가 직접 만들어서 어떤 문제를 해결했는지 보자.
문제1 - 너무 많은 설정
프록시를 직접 스프링 빈으로 등록하는 ProxyFactoryConfigV1, ProxyFactoryConfigV2와 같은 설정 파일은 프록시 관련 설정이 지나치게 많다는 문제가 있다. 예를 들어, 애플리케이션에 스프링 빈이 100개가 있다면 여기에 프록시를 통해 부가 기능을 적용하려면 100개의 프록시 설정 코드가 들어가야 한다.
문제2 - 컴포넌트 스캔
V3처럼 컴포넌트 스캔을 사용하는 경우, 프록시 설정 코드를 직접 작성하는 것으로 프록시를 등록하지도 못한다. 왜냐하면 컴포넌트 스캔으로 이미 스프링 컨테이너에 실제 객체를 스프링 빈으로 등록을 다 해버린 상태이기 때문이다.
문제 해결
빈 후처리기를 사용해서 프록시를 생성하는 부분을 하나로 집중할 수 있다. (바로 빈 후처리기를 작성하는 코드).
그리고 컴포넌트 스캔처럼 스프링이 직접 대상을 빈으로 등록하는 경우에도 중간에 빈 등록 과정을 가로채서 원본 대신에 프록시를 스프링 빈으로 등록할 수 있다. 덕분에 애플리케이션에 수많은 스프링 빈이 추가되어도 프록시와 관련된 코드는 전혀 변경하지 않아도 된다. 그리고 컴포넌트 스캔을 사용해도 프록시가 모두 적용된다.
하지만 개발자의 욕심은 끝이 없다.
스프링은 프록시를 생성하기 위한 빈 후처리기를 이미 만들어서 제공한다.
중요!
프록시의 적용 대상 여부를 여기서는 간단하게 패키지를 기준으로 설정했다. 그런데 잘 생각해보면 포인트컷을 사용하면 더 깔끔할 것 같다. 포인트컷은 이미 클래스, 메서드 단위의 필터 기능을 가지고 있기 때문에, 프록시 적용 대상 여부를 정밀하게 설정할 수 있다. 참고로 어드바이저는 포인트컷을 가지고 있다. 따라서 어드바이저를 통해 포인트컷을 확인할 수 있다. 스프링 AOP는 포인트컷을 사용해서 프록시 적용 대상 여부를 체크한다. 결과적으로 포인트컷은 다음 두 곳에서 사용된다.
프록시 적용 대상 여부를 체크해서 꼭 필요한 곳에만 프록시를 적용한다. (빈 후처리기 - 자동 프록시 생성)
프록시의 어떤 메서드가 호출 되었을 때 어드바이스를 적용할 지 판단한다. (프록시 내부)
스프링이 제공하는 빈 후처리기1
결론을 먼저 말하자면, 저렇게 직접 빈 후처리기를 만들 일은 없다. 적어도 프록시를 만들기 위해서라면 말이다.
왜냐? 스프링이 저 역할을 하는 빈 후처리기를 이미 다 만들어서 제공해주기 때문이다. 그것들을 사용하려면 다음 라이브러리를 추가해야한다.
이 라이브러리를 추가하면 aspectjweaver 라는 aspectJ 관련 라이브러리를 등록하고, 스프링 부트가 AOP 관련 클래스를 자동으로 스프링 빈에 등록한다. 스프링 부트가 없던 시절에는 @EnableAspectJAutoProxy를 직접 사용해야 했는데, 이 부분을 스프링 부트가 자동으로 처리해준다. 스프링 부트가 활성화하는 빈은 AopAutoConfiguration인데 이 빈을 활성화하면 자동 프록시 생성기라는 빈 후처리기가 스프링 빈에 자동으로 등록된다.
AnnotationAwareAspectJAutoProxyCreator
이 녀석이 스프링 부트가 자동으로 스프링 빈으로 등록해주는 빈 후처리기다.
이름 그대로 자동으로 프록시를 생성해주는 빈 후처리기. 이 빈 후처리기는 스프링 빈으로 등록된 Advisor들을 자동으로 찾아서 프록시가 필요한 곳에 자동으로 프록시를 적용해준다.
Advisor안에는 Pointcut과 Advice가 이미 모두 포함되어 있다. 따라서 Advisor만 알고 있으면 그 안에 있는 Pointcut으로 어떤 스프링 빈에 프록시를 적용해야 할지 알 수 있다. 그리고 Advice로 부가 기능을 적용하면 된다.
그리고 @Aspect도 자동으로 인식해서 프록시를 만들고 AOP를 적용해준다.
자동 프록시 생성기의 작동 과정
1. 생성: 스프링이 스프링 빈 대상이 되는 객체를 생성한다. (@Bean, 컴포넌트 스캔 모두 포함)
2. 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 저 AnnotationAwareAspectJAutoProxyCreator 빈 후처리기에 전달한다.
3. 모든 Advisor 빈 조회:AnnotationAwareAspectJAutoProxyCreator 빈 후처리기는 스프링 컨테이너에서 모든 Advisor를 조회한다.
4. 프록시 적용 대상 체크: 앞서 조회한 Advisor에 포함되어 있는 포인트컷을 사용해서 해당 객체가 프록시를 적용할 대상인지 아닌지 판단한다. 이 때 객체의 클래스 정보는 물론이고, 해당 객체의 모든 메서드를 포인트컷에 하나하나 모두 매칭해본다. 하나라도 조건이 만족하면 프록시 적용 대상이 된다. 예를 들어 10개의 메서드 중에 하나만 포인트컷 조건에 만족해도 프록시 적용 대상이 된다.
5. 프록시 생성: 프록시 적용 대상이면 프록시 팩토리를 사용해 프록시를 생성하고 반환해서 프록시를 스프링 빈으로 등록한다. 만약 프록시 적용 대상이 아니라면 원본 객체를 반환해서 원본 객체를 스프링 빈으로 등록한다.
6. 빈 등록: 반환된 객체는 스프링 빈으로 등록된다.
코드를 통해 바로 적용해보자.
AutoProxyConfig
package cwchoiit.springadvanced.proxy.config.v5_autoproxy;
import cwchoiit.springadvanced.proxy.config.AppV1Config;
import cwchoiit.springadvanced.proxy.config.AppV2Config;
import cwchoiit.springadvanced.proxy.config.v3_proxyfactory.advice.LogTraceAdvice;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import org.springframework.aop.Advisor;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.aop.support.NameMatchMethodPointcut;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration
@Import({AppV1Config.class, AppV2Config.class})
public class AutoProxyConfig {
@Bean
public Advisor getAdvisor(LogTrace trace) {
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedNames("request*", "order*", "save*");
LogTraceAdvice logTraceAdvice = new LogTraceAdvice(trace);
return new DefaultPointcutAdvisor(pointcut, logTraceAdvice);
}
}
이전에 했던 방식을 잠깐 떠올려보자. 분명 내가 직접 만든 빈 후처리기를 빈으로 등록했다. 그런데 이제 그럴 필요가 없다. 왜냐? 스프링이 프록시를 만들어주는 빈 후처리기를 이미 자동으로 등록해주기 때문이다.
그래서 나는 Advisor만 빈으로 등록하면 된다. 그럼 이 안에 있는 포인트컷을 참고해서 프록시로 만들어야 할 녀석들이 누구인지 AnnotationAwareAspectJAutoProxyCreator는 알아서 판단 후 프록시로 만들어준다.
강조: 포인트컷은 2가지에 사용된다.
1. 프록시 적용 여부 판단 - 프록시를 생성하는 단계
AnnotationAwareAspectJAutoProxyCreator는 등록된 모든 Advisor, @Aspect를 찾고 그 안에 포인트컷을 사용해서 해당 빈이 프록시를 생성할 필요가 있는지 없는지 체크한다.
클래스 + 메서드 조건을 모두 비교한다. 이 때 모든 메서드를 체크하는데, 포인트컷 조건에 하나하나 매칭해본다. 만약 조건에 맞는 것이 하나라도 있으면 프록시를 생성하고, 조건에 맞는 것이 하나도 없을 땐 프록시를 생성하지 않는다.
2. 어드바이스 적용 여부 판단 - 프록시를 사용하는 단계
프록시가 호출되었을 때 부가 기능인 어드바이스를 적용할지 말지 포인트컷을 보고 판단한다. 즉, 특정 메서드가 호출됐을 때 그 메서드가 포인트컷 조건에 만족하는 메서드인지 확인 한다는 뜻이다.
프록시를 모든 곳에 생성하는 것은 비용 낭비라고 했고 AnnotationAwareAspectJAutoProxyCreator는 포인트컷으로 한번 필터링해서 어드바이스가 사용될 가능성이 있는 곳에만 프록시를 생성한다고 했다. 그럼 만약 여러개의 포인트컷 조건을 만족한다고 하면 프록시는 여러개가 생길까? 아니다. 프록시는 딱 하나만 생기고 그 안에 여러 어드바이저가 등록되는 것이다.
AnnotationAwareAspectJAutoProxyCreator 상황별 정리
advisor1의 포인트컷만 만족: 프록시 1개 생성, 프록시에 advisor1만 포함
advisor1, advisor2의 포인트컷을 모두 만족: 프록시 1개 생성, 프록시에 advisor1, advisor2 모두 포함
advisor1, advisor2의 포인트컷을 모두 만족하지 않음: 프록시가 생성되지 않음
스프링이 제공하는 빈 후처리기2
위 방식대로 애플리케이션을 실행하면 원하는 기능은 제대로 동작하는데 애플리케이션 로딩 로그를 자세히 보면 이상하다.
Advice: 프록시가 제공하는 추가 기능에 대한 로직을 가지고 있는 곳을 말한다. (조언)
Pointcut: 프록시가 제공하는 추가 기능을 어디에적용할것인가?을 가지고 있는 곳을 말한다. (어디에?)
Advisor: Advice와 Pointcut을 한 개씩 가지고 있는 곳을 말한다. (조언자)
그리고 ProxyFactory는 Advisor가 필수이다. 근데 저번 포스팅에서는 Advisor를 안 사용했고 addAdvice()만 호출해서 Advice만 넘겼는데 이렇게 하면 기본 Advisor에 모든 곳에 적용하는 Pointcut으로 할당된다. 단순 편의 메서드인 것 뿐이다.
역할과 책임
이렇게 구분한 것은 역할과 책임을 명확하게 분리한 것이다.
포인트컷은 대상 여부를 확인하는 필터 역할만 담당한다.
어드바이스는 깔끔하게 부가 기능 로직만 담당한다.
둘을 합치면 어드바이저가 된다. 스프링의 어드바이저는 하나의 포인트컷 + 하나의 어드바이스로 구성된다.
위 그림은 이해를 돕기 위해 만들어진 그림이다. 실제 구현은 약간 다를 수 있지만, 흐름은 동일하다.
클라이언트가 프록시를 호출하면, 이 프록시는 먼저 Advice 적용 여부를 확인한다. 만약 적용되지 않았다면 부가 기능을 적용하지 않은 채로 실제 객체만 호출하고, 적용 대상이라면 부가 기능을 적용한다.
한번 Advisor, Advice, Pointcut을 적용해 보는 코드를 작성해보자.
예제 코드1 - 어드바이저
AdvisorTest
package cwchoiit.springadvanced.proxy.advisor;
import cwchoiit.springadvanced.proxy.common.advice.TimeAdvice;
import cwchoiit.springadvanced.proxy.common.service.ServiceImpl;
import cwchoiit.springadvanced.proxy.common.service.ServiceInterface;
import org.junit.jupiter.api.Test;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;
public class AdvisorTest {
@Test
void advisorTest1() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
}
이전 포스팅에서 사용했던 ServiceInterface와 TimeAdvice를 그대로 사용해보자. 그리고 이제 어드바이저를 만들어보자. 그 코드는 다음과 같다.
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(Pointcut.TRUE, new TimeAdvice());
Advisor 인터페이스의 가장 일반적인 구현체이다. 생성자를 통해 하나의 포인트컷과 하나의 어드바이스를 넣어주면 된다. 어드바이저는 하나의 포인트컷과 하나의 어드바이스로 구성된다.
Pointcut.TRUE는 항상 true를 반환하는 포인트컷이다. 즉, 모든곳에 프록시의 부가 기능이 적용이 된다는 의미이다.
proxyFactory.addAdvisor(advisor);
프록시팩토리에 적용할 어드바이저를 지정한다. 어드바이저는 내부에 포인트컷과 어드바이스를 모두 가지고 있다. 따라서 어디에 부가 기능을 적용해야 할지 어드바이저 하나로 알 수 있다. 프록시 팩토리를 사용할 때 어드바이저는 필수이다.
실행 결과
실행 결과를 보면, save(), find() 각각 모두 어드바이스가 적용된 것을 확인할 수 있다.
예제 코드2 - 직접 만든 포인트컷
이번에는, save() 메서드에는 어드바이스 로직을 적용하지만, find() 메서드에는 어드바이스 로직을 적용하지 않도록 해보자. 물론 과거에 했던 코드와 유사하게 어드바이스에 로직을 추가해서 메서드 이름을 보고 코드를 실행할지 말지 분기를 타도 된다. 하지만 이런 기능에 특화되어서 제공되는 것이 바로 포인트컷이다. 그리고 그렇게하면 어드바이스의 역할과 책임이 너무 많아진다(=유지보수가 안 좋아진다)
Pointcut 관련 인터페이스 - 스프링 제공
public interface Pointcut {
ClassFilter getClassFilter();
MethodMatcher getMethodMatcher();
}
public interface ClassFilter {
boolean matches(Class<?> clazz);
}
public interface MethodMatcher {
boolean matches(Method method, Class<?> targetClass);
//..
}
포인트컷은 크게 ClassFilter, MethodMatcher 둘로 이루어진다. 이름 그대로 하나는 클래스가 맞는지, 하나는 메서드가 맞는지 확인할 때 사용한다. 둘 다 true로 반환해야 어드바이스를 적용할 수 있다.
일반적으로 스프링이 이미 만들어둔 구현체를 사용하지만, 학습 차원에서 한번 간단히 직접 구현해보자.
@Test
@DisplayName("직접 만든 포인트컷")
void advisorTest2() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(new MyPointcut(), new TimeAdvice());
proxyFactory.addAdvisor(advisor);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.save();
proxy.find();
}
/**
* 직접 만들일은 없음, 스프링이 만들어주는 Pointcut을 사용하면 되지만 한번 만들어보자.
* 클래스와 메서드 둘 다 'true' 를 리턴해야만 Pointcut에 적합한 요청이라고 판단하여 Advice를 적용한다.
* */
static class MyPointcut implements Pointcut {
/**
* 클래스를 기준으로 필터링
* ClassFilter.TRUE 를 반환하면 모든 클래스에 대해 Advice 적용을 허용
* */
@Override
public ClassFilter getClassFilter() {
return ClassFilter.TRUE;
}
/**
* 메서드를 기준으로 필터링
* MethodMatcher를 구현해야 한다.
* */
@Override
public MethodMatcher getMethodMatcher() {
return new MyMethodMatcher();
}
}
static class MyMethodMatcher implements MethodMatcher {
private String matchName = "save";
@Override
public boolean matches(Method method, Class<?> targetClass) {
boolean result = method.getName().equals(matchName);
log.info("포인트컷 호출 method = {} targetClass= {}", method.getName(), targetClass);
log.info("포인트컷 결과 result = {}", result);
return result;
}
@Override
public boolean isRuntime() {
return false;
}
@Override
public boolean matches(Method method, Class<?> targetClass, Object... args) {
return false;
}
}
MyPointcut
직접 구현한 포인트컷이다. Pointcut 인터페이스를 구현한다.
현재 메서드 기준으로 로직을 적용하면 된다. 클래스 필터는 항상 true를 반환하도록 했고, 메서드 비교 기능은 MyMethodMatcher를 사용한다.
MyMethodMatcher
직접 구현한 MethodMatcher이다. MethodMatcher 인터페이스를 구현한다.
matches() → 이 메서드에 method, targetClass 정보가 넘어온다. 이 정보로 어드바이스를 적용할지 적용하지 않을지 판단할 수 있다. 여기서는 메서드 이름이 'save'인 경우에 true를 반환하도록 판단 로직을 적용한다.
isRuntime(), matches(..., args) → isRuntime() 이 값이 true이면 matches(..., args) 메서드가 matches() 대신 호출된다. 동적으로 넘어오는 매개변수를 판단 로직으로 사용할 수 있다. isRuntime()이 false인 경우, 클래스의 정적 정보만 사용하기 때문에 스프링이 내부에서 캐싱을 통해 성능 향상이 가능하지만, isRuntime()이 true인 경우 매개변수가 동적으로 변경된다고 가정하기 때문에 캐싱을 하지 않는다. 크게 중요한 부분은 아니니 참고만 하자. 어차피 포인트컷을 직접 만들일은 없다.
위에서 모든 대상에 대해 Advice를 적용했던 예시 코드에 비교해서 바뀌는 부분은 딱 Pointcut이 달라지는것 말고 없다. 우리가 만든 MyPointcut을 전달한다. 대신 실행 결과가 달라질 것이다. 'save()'가 아닌 'find()'에는 Advice는 적용되지 않는다.
실행 결과
위 실행 결과의 흐름을 그림으로 비교해보자.
클라이언트가 프록시의 save()를 호출한다.
포인트컷에게 Service 클래스의 save() 메서드에 어드바이스를 적용해도 될지 물어본다.
포인트컷이 true를 반환한다. 따라서 어드바이스를 호출해서 부가 기능을 적용한다.
이후 실제 인스턴스의 save()를 호출한다.
클라이언트가 프록시의 find()를 호출한다.
포인트컷에게 Service 클래스의 find() 메서드에 어드바이스를 적용해도 될지 물어본다.
포인트컷이 false를 반환한다. 따라서 어드바이스를 호출하지 않고, 부가 기능도 적용되지 않는다.
실제 인스턴스를 호출한다.
예제 코드3 - 스프링이 제공하는 포인트컷
사실, 포인트컷을 저렇게 직접 구현해서 사용할 일은 앞으로 없다. 이미 스프링은 여러 포인트컷을 제공하고 있고 그 중 하나를 골라 사용하거나 결국엔 끝판왕인 AspectJExpressionPointcut을 사용하게 될 것이다. 한번 스프링이 제공하는 여러 포인트컷 중 하나를 골라서 사용해보자.
@Test
@DisplayName("스프링이 제공하는 포인트컷")
void advisorTest3() {
// 실제 객체 서비스
ServiceInterface target = new ServiceImpl();
// ProxyFactory 객체 생성 후 실제 객체를 전달
ProxyFactory proxyFactory = new ProxyFactory(target);
// 스프링이 제공하는 포인트 컷 중 하나인 NameMatchMethodPointcut
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
// Method 명이 save인 애들에게 Advice를 적용해주는 Pointcut을 만든다.
pointcut.setMappedName("save");
DefaultPointcutAdvisor advisor = new DefaultPointcutAdvisor(pointcut, new TimeAdvice());
// ProxyFactory에 advisor 추가
proxyFactory.addAdvisor(advisor);
// ProxyFactory 로부터 proxy 를 꺼내온다.
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
// Pointcut에 의하여 Advice 적용된다.
proxy.save();
// Pointcut에 의하여 Advice 적용되지 않는다.
proxy.find();
}
스프링이 제공하는 NameMatchMethodPointcut을 사용해보자.
NameMatchMethodPointcut pointcut = new NameMatchMethodPointcut();
pointcut.setMappedName("save");
말 그대로 메서드의 이름으로 Pointcut을 설정하는 Pointcut이다. 위 코드처럼 'save'라는 값을 setMappedName()에 넘겨주면 이 Pointcut은 'save'라는 메서드명을 가진 요청에 한하여 Advice를 적용한다. 나머지는 동일하다.
실행 결과
save()가 호출됐을 땐 TimeProxy가 동작하고 그렇지 않은 find()가 호출됐을 땐 Advice가 적용되지 않았다. 이렇게 스프링이 제공해주는 Pointcut으로 편리하게 Pointcut을 만들 수 있다.
예제 코드4 - 여러 어드바이저 함께 적용
어드바이저는 하나의 포인트컷과 하나의 어드바이스를 가지고 있다. 만약, 여러 어드바이저를 하나의 target에 적용하려면 어떻게 해야할까? 쉽게 이야기해서 하나의 target에 여러 어드바이스를 적용하려면 어떻게 해야할까? 지금 떠오르는 방법은 프록시를 여러개 만들면 될 것 같다.
프록시 여러개로 구현
package cwchoiit.springadvanced.proxy.advisor;
import cwchoiit.springadvanced.proxy.common.advice.TimeAdvice;
import cwchoiit.springadvanced.proxy.common.service.ServiceImpl;
import cwchoiit.springadvanced.proxy.common.service.ServiceInterface;
import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.Pointcut;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.support.DefaultPointcutAdvisor;
@Slf4j
public class MultiAdvisorTest {
@Test
@DisplayName("여러 프록시")
void multiAdvisorTest1() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory1 = new ProxyFactory(target);
DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
proxyFactory1.addAdvisor(advisor1);
ServiceInterface proxy1 = (ServiceInterface) proxyFactory1.getProxy();
ProxyFactory proxyFactory2 = new ProxyFactory(proxy1);
DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
proxyFactory2.addAdvisor(advisor2);
ServiceInterface proxy2 = (ServiceInterface) proxyFactory2.getProxy();
proxy2.find();
}
static class Advice1 implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("Advice1 invoked");
return invocation.proceed();
}
}
static class Advice2 implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("Advice2 invoked");
return invocation.proceed();
}
}
}
코드상에 새로운 개념은 없다. 프록시를 여러개 입혔다.
실행 결과
11:35:11.287 [Test worker] INFO cwchoiit.springadvanced.proxy.advisor.MultiAdvisorTest -- Advice2 invoked
11:35:11.289 [Test worker] INFO cwchoiit.springadvanced.proxy.advisor.MultiAdvisorTest -- Advice1 invoked
11:35:11.289 [Test worker] INFO cwchoiit.springadvanced.proxy.common.service.ServiceImpl -- find 호출
차례대로 Advice2, Advice1이 실행된다. 그리고 실제 객체인 target의 로직까지 실행됐다.
그러나, 이 방법이 잘못된 것은 아니지만 프록시를 2번 생성해야 한다는 문제가 있다. 만약 적용해야 하는 어드바이저가 10개라면 10개의 프록시를 생성해야 한다.
하나의 프록시, 여러 어드바이저로 구현
스프링은 이 문제를 해결하기 위해 하나의 프록시에 여러 어드바이저를 적용할 수 있게 만들어 두었다.
@Test
@DisplayName("하나의 프록시, 여러 어드바이저")
void multiAdvisorTest2() {
DefaultPointcutAdvisor advisor1 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice1());
DefaultPointcutAdvisor advisor2 = new DefaultPointcutAdvisor(Pointcut.TRUE, new Advice2());
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvisors(advisor2, advisor1);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
proxy.find();
}
프록시 팩토리에 원하는 어드바이저를 addAdvisors(...)로 등록하면 끝이다. 먼저 넣은 어드바이저가 먼저 실행된다.
정리
결과적으로, 여러 프록시를 사용할 때와 비교해서 결과는 같으나 성능은 더 좋다. 스프링 AOP도 이와 같다. AOP 적용 수만큼 프록시가 생성되는 것이 아니다! 스프링은 AOP를 적용할 때, 최적화를 진행해서 지금처럼 프록시는 하나만 만들고, 하나의 프록시에 여러 어드바이저를 적용한다. 정리하면 하나의 target에 여러 AOP가 적용되어도, 스프링의 AOP는 target마다 하나의 프록시만 생성한다. 꼭 기억하자!
프록시팩토리 - 적용1
지금까지 학습한 프록시 팩토리를 사용해서 애플리케이션에 프록시를 만들어보자. 먼저 인터페이스가 있는 V1 애플리케이션에 LogTrace 기능을 프록시 팩토리를 통해서 프록시를 만들어 적용해보자.
Advice를 만들기 위해 MethodInterceptor를 구현하는 LogTraceAdvice 클래스를 만들었다. MethodInterceptor가 구현해야 하는 invoke()를 기존에 계속 사용했던 LogTrace 기능으로 채워넣었다. Advice는 실제 객체를 주입받지 않아도 되기 때문에 편리함을 준다.
이제 Advice를 만들었으니까 ProxyFactory를 통해서 프록시를 만들고 스프링 빈으로 등록해보자.
끝이다. Advice도 Pointcut도 이미 만들어 놓은거니까 가져다가 사용만 하면 된다.
'/v2/request' 로 요청하면 마찬가지로 LogTrace의 정보를 출력한다.
정리
확실히 프록시로 사용될 코드도 Advice 하나만 만들면 되고, 동적 프록시를 만들기 때문에 프록시를 일일이 만들어 줄 필요도 없으며 구체클래스냐 인터페이스냐에 따라 나뉘어지는 동적 프록시 생성 방법을 스프링의 도움을 받아 프록시 팩토리를 사용하므로써 고민하지 않게됐다.
훨씬 개선되었지만 여전히 불편함은 남아있다.
너무 많은 설정 - ProxyFactoryConfigV1, ProxyFactoryConfigV2와 같이 설정 파일이 지나치게 많다. 예를 들어, 애플리케이션에 스프링 빈이 100개가 있다면 여기에 프록시를 통해 부가 기능을 적용하려면 100개의 동적 프록시 생성 코드를 만들어야 한다. 무수히 많은 설정 파일 때문에 설정 지옥을 경험하게 될 것이다. 최근에는 스프링 빈을 수동으로 등록하는 케이스보다 컴포넌트 스캔을 사용하는게 일반적인데 이렇게 직접 등록하는 것도 모자라서 프록시를 적용하는 코드까지 빈 생성 코드에 넣어야 한다.
컴포넌트 스캔 - 애플리케이션 V3처럼 컴포넌트 스캔을 사용하는 경우, 지금까지 학습한 방법으로는 프록시 적용이 불가능하다. 왜냐하면 실제 객체를 컴포넌트 스캔으로 스프링 컨테이너에 스프링 빈으로 등록을 다 해버린 상태이기 때문이다. 지금까지 학습한 방법으로 프록시를 적용하려면, 실제 객체를 스프링 컨테이너에 빈으로 등록하는 것이 아니라, ProxyFactoryConfigV2에서 한 것처럼, 부가 기능이 있는 프록시를 실제 객체 대신 스프링 컨테이너에 빈으로 등록해야 한다.
JDK 동적 프록시를 사용할 때는 인터페이스가 필수이다. 그렇다면, 이전 포스팅에서 만들었던 V2 애플리케이션처럼 인터페이스 없이 클래스만 있는 경우에는 어떻게 동적 프록시를 적용할 수 있을까? 이것은 일반적인 방법으로는 어렵고 CGLIB라는 바이트코드를 조작하는 특별한 라이브러리를 사용해야 한다.
CGLIB - 소개
CGLIB: Code Generator Library
CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리이다.
CGLIB를 사용하면 인터페이스가 없어도 구체클래스만 가지고 동적 프록시를 만들어낼 수 있다.
CGLIB는 원래는 외부 라이브러리인데, 스프링 프레임워크가 스프링 내부 소스 코드에 포함했다. 따라서 스프링을 사용한다면 별도의 외부 라이브러리를 추가하지 않아도 사용할 수 있다.
참고로, 우리가 CGLIB를 직접 사용하는 경우는 거의 없다. 이후에 설명할 스프링의 ProxyFactory라는 것이 이 기술을 편리하게 사용하게 도와주기 때문에, 너무 깊이있게 파기보다는 CGLIB가 무엇인지 대략 개념만 잡으면 된다. 예제 코드로 CGLIB를 간단히 이해해보자.
공통 예제 코드
앞으로 다양한 상황을 설명하기 위해서 먼저 공통으로 사용할 예제 코드를 만들어보자.
인터페이스와 구현이 있는 서비스 클래스 - ServiceInterface, ServiceImpl
package cwchoiit.springadvanced.proxy.cglib.code;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
@Slf4j
@RequiredArgsConstructor
public class TimeMethodInterceptor implements MethodInterceptor {
private final Object target;
@Override
public Object intercept(Object obj,
Method method,
Object[] args,
MethodProxy proxy) throws Throwable {
log.info("TimeMethodInterceptor 실행");
long startTime = System.currentTimeMillis();
Object result = proxy.invoke(target, args);
long endTime = System.currentTimeMillis();
log.info("TimeMethodInterceptor 종료 {} ms", endTime - startTime);
return result;
}
}
TimeMethodInterceptor는 MethodInterceptor 인터페이스를 구현해서 CGLIB 프록시의 실행 로직을 정의한다.
JDK 동적 프록시를 만들어낼 때 예제와 거의 같은 코드이다. 여기에다가 프록시가 적용해줄 공통 기능 코드를 작성하고 실제 객체를 호출하면 된다.
Object target → 프록시가 호출할 실제 대상
proxy.invoke(target, args) → 실제 대상을 동적으로 호출한다. JDK 동적 프록시는 전달받는 Method를 사용해서 method.invoke(...)를 호출했는데, 여기에서는 그렇게 해도 무방하지만 CGLIB는 성능상 MethodProxy를 사용하는 것을 권장한다.
ConcreteService는 인터페이스가 없는 구체 클래스이다. 여기에 CGLIB를 사용해서 프록시를 생성해보자.
Enhancer →CGLIB는 Enhancer를 사용해서 프록시를 생성한다.
enhancer.setSuperclass(ConcreteService.class) → CGLIB는 구체 클래스를 상속받아서 프록시를 생성할 수 있다. 따라서, 어떤 구체 클래스를 상속 받을지 지정해야한다.
enhancer.create() → 프록시를 생성한다. 앞서 할당한 enhancer.setSuperclass(ConcreteService.class)에서 지정한 클래스를 상속 받아서 프록시가 만들어진다.
실행 결과
14:16:55.264 [Test worker] INFO cwchoiit.springadvanced.proxy.cglib.CglibTest -- target: class cwchoiit.springadvanced.proxy.common.service.ConcreteService
14:16:55.266 [Test worker] INFO cwchoiit.springadvanced.proxy.cglib.CglibTest -- proxy: class cwchoiit.springadvanced.proxy.common.service.ConcreteService$$EnhancerByCGLIB$$48ab274a
실행 결과를 보면 프록시가 정상 적용된 것을 확인할 수 있다.
CGLIB가 생성한 프록시 이름 → ConcreteService$$EnhancerByCGLIB$$48ab274a
참고로, JDK 동적 프록시가 생성한 클래스 이름은 이렇게 생겼다 → jdk.proxy3.$Proxy12
CGLIB의 제약
클래스 기반 프록시는 상속을 사용하기 때문에 몇가지 제약이 있다.
부모 클래스의 생성자를 체크해야 한다. → CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요하다.
클래스에 final 키워드가 붙으면 상속이 불가능하다. → 그 말은 CGLIB로 프록시를 만들어낼 수 없다.
메서드에 final 키워드가 붙으면 해당 메서드를 오버라이딩 할 수 없다. → 그 말은 CGLIB로 프록시를 만들어내도 그 메서드에는 프록시 로직이 동작하지 않는다.
정리
JDK 동적 프록시와 CGLIB를 사용해서 동적으로 프록시를 만들어 낼 수 있게 됐는데 문제는 JDK 동적 프록시는 인터페이스가 필수라서 구체클래스만 있는 경우 CGLIB를 사용해야 하고, 반대로 CGLIB로 프록시를 만들때는 구체 클래스를 기반으로 상속을 받아 프록시를 만들어내니 어쩔땐 이걸 쓰고 어쩔땐 저걸 쓰고 이래야 할까? 그러면 어쩔땐 InvocationHandler를 만들고, 어쩔땐 MethodInterceptor를 만들고 이래야 할까? 이렇게 특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되면 얼마나 좋을까? 그리고 보통 자바와 스프링은 이렇게 같은 역할을 두고 서로 다른 기술을 사용할때 항상 뭐다? 역할과 구현을 나눠서 표준을 제공하고 그 표준을 구현한 구현체를 제공해준다. 이제 스프링이 제공하는 ProxyFactory를 알아보자!
프록시 팩토리 - 소개
문제점을 다시 리마인드 해보자!
인터페이스가 있는 경우에는 JDK 동적 프록시를 적용하고, 그렇지 않은 경우에는 CGLIB를 적용하려면 어떻게 해야 할까?
두 기술을 함께 사용할 때 부가 기능을 제공하기 위해 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor를 각각 중복으로 만들어서 관리해야 할까?
특정 조건에 맞을 때 프록시 로직을 적용하는 기능도 공통으로 제공되었으면!?
스프링은 유사한 구체적인 기술들이 있을 때, 그것들을 통합해서 일관성 있게 접근할 수 있고, 더욱 편리하게 사용할 수 있는 추상화된 기술을 제공한다. 스프링은 동적 프록시를 통합해서 편리하게 만들어주는 프록시 팩토리(ProxyFactory)라는 기능을 제공한다. 이전에는 상황에 따라서 JDK 동적 프록시를 사용하거나, CGLIB를 사용해야 했다면, 이제는 이 프록시 팩토리 하나로 편리하게 동적 프록시를 생성할 수 있다. 프록시 팩토리는 인터페이스가 있으면 JDK 동적 프록시를 사용하고 구체 클래스만 있다면 CGLIB를 사용한다. 그리고 이 설정을 변경할 수도 있다.
그럼 추상화로 어떤 것을 사용해도 상관없게 해주는 것 까지는 이해했는데, 결국 JDK 동적 프록시는 InvocationHandler를 만들어야 하고 CGLIB는 MethodInterceptor를 각각 중복으로 만들어야 하는 건 변함없을까? 스프링은 이 문제를 해결하기 위해 부가 기능을 적용할 때 Advice라는 새로운 개념을 도입했다. 개발자는 InvocationHandler, MethodInterceptor를 신경쓰지 않고 Advice만 만들면 된다. 결과적으로 InvocationHandler나 MethodInterceptor는 우리가 만든 Advice를 호출하게 된다. 프록시 팩토리를 사용하면, Advice를 호출하는 전용 InvocationHandler, MethodInterceptor를 내부에서 사용한다.
그럼, 특정 조건에 맞을 때 프록시 로직을 적용하는 기능은 어떻게 하면 좋을까? 위에서 /no-log로 요청하는 것은 프록시 로직이 동작하지 않게끔 하게 했던 것 처럼 말이다. 어떤 경우엔 프록시를 적용하고 어떤 경우에는 프록시를 적용하지 않을지에 대한 기능을 스프링은 Pointcut 이라는 개념을 도입해서 이 문제를 일관성있게 해결한다.
프록시 팩토리 - 예제 코드1
Advice 만들기
Advice는 프록시에 적용하는 부가 기능 로직이다. 이것은 JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor의 개념과 유사하다. 둘을 개념적으로 추상화 한 것이다. 프록시 팩토리를 사용하면 둘 대신에 Advice를 사용하면 된다.
Advice를 만드는 방법은 여러가지가 있지만, 기본적인 방법은 MethodInterceptor 인터페이스를 구현하면 된다.
엥?! MethodInterceptor는 CGLIB꺼 아닌가요? → 이름이 똑같은데 패키지를 잘 보자. CGLIB것이 아니라 org.aopalliance.intercept이다.
그리고 생김새도 살짝 다르다. 얘는 MethodInvocation 이라는 딱 하나의 파라미터만 받는다. 이 내부에 실제 객체를 호출하는 것, 현재 프록시 객체 인스턴스, args, 메서드 정보 등이 다 포함되어 있다. JDK 동적 프록시의 InvocationHandler나, CGLIB의 MethodInterceptor에서 파라미터로 제공되는 부분들이 이 안으로 다 들어갔다고 생각하면 된다.
근데 Advice를 만든다더니 왜 이걸 구현해야 하나요? → 이 MethodInterceptor는 Interceptor를 상속받고, Interceptor는 Advice를 상속한다. 그래서 괜찮다.
TimeAdvice
package cwchoiit.springadvanced.proxy.common.advice;
import lombok.extern.slf4j.Slf4j;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
@Slf4j
public class TimeAdvice implements MethodInterceptor {
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
log.info("TimeAdvice 실행");
long startTime = System.currentTimeMillis();
Object result = invocation.proceed();
long endTime = System.currentTimeMillis();
log.info("TimeAdvice 종료 {} ms", endTime - startTime);
return result;
}
}
invoke()에서 invocation.proceed()를 호출하는데 이게 실제 객체의 메서드를 호출하는 부분이라고 보면 된다. 원래 기존에는 프록시는 항상 실제 객체를 주입받아야 한다고 했는데 여기에는 그런 코드가 없다. 이는 ProxyFactory를 만들 때 전달해주기 때문이다. 말보단 코드. ProxyFactory를 만들어내는 코드를 보면 자연스럽게 이해가 될 것이다.
new ProxyFactory(target) → 프록시 팩토리를 생성할 때, 생성자에 프록시의 호출 대상을 함께 넘겨준다. 프록시 팩토리는 이 인스턴스 정보를 기반으로 프록시를 만들어낸다. 만약, 이 인스턴스에 인터페이스가 있다면 JDK 동적 프록시를 기본으로 사용하고, 인터페이스가 없고 구체 클래스만 있다면 CGLIB를 통해서 동적 프록시를 생성한다. 여기서는 target이 new ServiceImpl()의 인스턴스이기 때문에 ServiceInterface 인터페이스가 있다. 따라서 이 인터페이스를 기반으로 JDK 동적 프록시를 생성한다. 그리고 이렇게 실제 호출 대상을 여기서 알려주기 때문에 Advice에는 실제 호출 대상을 받을 필요가 없는 것이다.
proxyFactory.addAdvice(new TimeAdvice()) → 프록시 팩토리를 통해서 만든 프록시가 사용할 부가 기능 로직을 설정한다. JDK 동적 프록시가 제공하는 InvocationHandler와 CGLIB가 제공하는 MethodInterceptor의 개념과 유사하다. 이렇게 프록시가 제공하는 부가 기능 로직을 Advice라 한다.
proxyFactory.getProxy() → 프록시 객체를 생성하고 그 결과를 받는다.
실행 결과
16:35:27.253 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- target : class cwchoiit.springadvanced.proxy.common.service.ServiceImpl
16:35:27.255 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- proxy : class jdk.proxy3.$Proxy13
16:35:27.257 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 실행
16:35:27.257 [Test worker] INFO cwchoiit.springadvanced.proxy.common.service.ServiceImpl -- find 호출
16:35:27.257 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 종료 0 ms
실행 결과를 보면 프록시가 정상 적용된 것을 확인할 수 있다. JDK 동적 프록시로 잘 적용되었다.
프록시 팩토리를 통해서 프록시 적용을 확인할 수도 있는데, 프록시 팩토리를 통해서 프록시가 생성되면 AopUtils라는 유틸리티 클래스를 사용해서 JDK 동적 프록시인지 CGLIB인지 확인할 수 있다.
ConcreteService는 구체 클래스다. 이 객체를 target으로 넘기면 ProxyFactory는 CGLIB로 동적 프록시를 만든다.
나머지 내용은 위 인터페이스를 넘긴것과 동일하다.
실행 결과
09:56:14.466 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- target : class cwchoiit.springadvanced.proxy.common.service.ConcreteService
09:56:14.468 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- proxy : class cwchoiit.springadvanced.proxy.common.service.ConcreteService$$SpringCGLIB$$0
09:56:14.469 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 실행
09:56:14.470 [Test worker] INFO cwchoiit.springadvanced.proxy.common.service.ConcreteService -- ConcreteService call 호출
09:56:14.470 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 종료 0 ms
proxyTargetClass 옵션
ProxyFactory한테 인터페이스를 넘겨도 CGLIB로 만들어달라고 할 수 있다.
ProxyFactory가 가지고 있는 setProxyTargetClass() 메서드를 사용하면 된다. 이 메서드는 이름 그대로 Proxy를 Target의 클래스로 설정하겠다는 의미다. 파라미터로 true를 넘기면 된다.
@Test
@DisplayName("ProxyTargetClass 옵션을 사용하면 인터페이스가 있어도 CGLIB를 사용하고, 클래스 기반 프록시 생성")
void proxyTargetClass() {
ServiceInterface target = new ServiceImpl();
ProxyFactory proxyFactory = new ProxyFactory(target);
proxyFactory.addAdvice(new TimeAdvice());
proxyFactory.setProxyTargetClass(true);
ServiceInterface proxy = (ServiceInterface) proxyFactory.getProxy();
log.info("target : {}", target.getClass());
log.info("proxy : {}", proxy.getClass());
proxy.find();
assertThat(AopUtils.isAopProxy(proxy)).isTrue();
assertThat(AopUtils.isJdkDynamicProxy(proxy)).isFalse();
assertThat(AopUtils.isCglibProxy(proxy)).isTrue();
}
이번엔 target이 인터페이스가 있지만, 프록시팩토리의 setProxyTargetClass(true) 옵션을 설정해서, CGLIB로 프록시를 만들고 클래스 기반 프록시를 생성하는 코드이다.
실행 결과
09:59:08.286 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- target : class cwchoiit.springadvanced.proxy.common.service.ServiceImpl
09:59:08.288 [Test worker] INFO cwchoiit.springadvanced.proxy.proxyfactory.ProxyFactoryTest -- proxy : class cwchoiit.springadvanced.proxy.common.service.ServiceImpl$$SpringCGLIB$$0
09:59:08.289 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 실행
09:59:08.289 [Test worker] INFO cwchoiit.springadvanced.proxy.common.service.ServiceImpl -- find 호출
09:59:08.289 [Test worker] INFO cwchoiit.springadvanced.proxy.common.advice.TimeAdvice -- TimeAdvice 종료 0 ms
실행 결과를 보면 프록시 클래스가 ServiceImpl$$SpringCGLIB$$...으로 되어있는 것을 볼 수 있다.
프록시 팩토리의 기술 선택 방법
대상에 인터페이스가 있으면 JDK 동적 프록시, 인터페이스 기반 프록시
대상에 인터페이스가 없으면 CGLIB, 구체 클래스 기반 프록시
setProxyTargetClass(true)를 사용하면 CGLIB, 구체 클래스 기반 프록시, 인터페이스 여부와 상관없음.
정리
프록시 팩토리의 서비스 추상화 덕분에 구체적인 CGLIB, JDK 동적 프록시 기술에 의존하지 않고 매우 편리하게 동적 프록시를 생성할 수 있다.
프록시의 부가 기능 로직도 특정 기술에 종속적이지 않게 Advice 하나로 편리하게 사용할 수 있었다. 이것은 프록시 팩토리가 내부에서 JDK 동적 프록시인 경우 InvocationHandler가 Advice를 호출하도록 개발해두고, CGLIB인 경우 MethodInterceptor가 Advice를 호출하도록 기능을 개발해두었기 때문이다.
참고로, 스프링 부트는 AOP를 적용할 때 기본적으로 proxyTargetClass=true로 설정해서 사용한다. 따라서 인터페이스가 있어도 항상 CGLIB를 사용해서 구체 클래스를 기반으로 프록시를 생성한다.
템플릿 콜백 패턴은 굉장히 자주 사용되는 패턴이다. 이전 포스팅에서 배웠던 전략 패턴에서 ContextV2는 변하지 않는 템플릿 역할을 한다. 그리고 변하는 부분은 파라미터로 넘어온 Strategy의 코드를 실행해서 처리한다. 이렇게 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 콜백이라고 한다.
프로그래밍에서 콜백(callback) 또는 콜애프터 함수는 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다. 좀 더 직관적으로 말하면 파라미터로 실행 가능한 코드를 넘겨주면 받는 쪽에서 그 코드를 실행하는 것이고, 이 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다.
근데 자바에서는 실행 가능한 코드를 인수로 넘기려면 객체가 필요하다. 자바8 이전에는 하나의 메소드를 가진 인터페이스를 구현하고 주로 익명 내부 클래스를 사용했다. 자바8 이후로 람다를 사용할 수 있기 때문에 최근에는 주로 람다를 사용한다.
템플릿 콜백 패턴
스프링에서는 이전 포스팅에서 본 ContextV2와 같은 방식의 전략 패턴을 템플릿 콜백 패턴이라고 한다. 전략 패턴에서 Context가 템플릿 역할을 하고, Strategy 부분이 콜백으로 넘어온다 생각하면 된다. 그러니까 이전에 봤던 ContextV2과 완벽하게 템플릿 콜백 패턴이라고 생각하면 된다.
참고로, 템플릿 콜백 패턴은 GOF 패턴은 아니고, 스프링 내부에서 이런 방식을 자주 사용하기 때문에 스프링 안에서만 이렇게 부른다. 전략 패턴에서 템플릿과 콜백 부분이 강조된 패턴이라 생각하면 된다.
스프링에서는 JdbcTemplate, RestTemplate, TransactionTemplate, RedisTemplate처럼 다양한 템플릿 콜백 패턴이 사용된다. 스프링에서 이름에 xxxTemplate이 있다면 템플릿 콜백 패턴으로 만들어져 있다 생각하면 된다.
템플릿 콜백 패턴 - 예제
템플릿 콜백 패턴을 구현해보자. ContextV2와 내용이 같고 이름만 다르다고 보면 된다.
Context → Template
Strategy → Callback
Callback
package cwchoiit.springadvanced.trace.strategy.code.template;
@FunctionalInterface
public interface Callback {
void call();
}
TimeLogTemplate
package cwchoiit.springadvanced.trace.strategy.code.template;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class TimeLogTemplate {
public void execute(Callback callback) {
long startTime = System.currentTimeMillis();
callback.call();
long endTime = System.currentTimeMillis();
long resultTime = endTime - startTime;
log.info("resultTime = {}ms", resultTime);
}
}
execute()는 파라미터로 Callback 객체를 받는다. (자바는 실행 가능한 코드를 인수로 넘기려면 객체를 전달해야 한다 했다.)
받은 객체가 가지고 있는 call()을 실행한다.
TemplateCallbackTest
package cwchoiit.springadvanced.trace.strategy;
import cwchoiit.springadvanced.trace.strategy.code.template.TimeLogTemplate;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
@Slf4j
public class TemplateCallbackTest {
@Test
void callbackV1() {
TimeLogTemplate template = new TimeLogTemplate();
template.execute(() -> log.info("비즈니스 로직1 실행"));
TimeLogTemplate template2 = new TimeLogTemplate();
template2.execute(() -> log.info("비즈니스 로직2 실행"));
}
}
템플릿 콜백 패턴을 테스트할 코드다. 콜백함수를 실행할 TimeLogTemplate 객체를 만든다. 이 객체가 가지고 있는 execute()는 파라미터로 Callback 객체를 받는다. Callback 객체를 전달하기 위해 익명 내부 클래스로 전달하거나, 람다로 전달하면 된다. 물론 별도의 클래스를 따로 만들어서 전달해도 무방하다. 재사용성이 빈번한 경우 그게 더 효율적이다. 그러나 그게 아니라면 위와 같이 람다를 사용하는 것이 좀 더 편리할 것 같다.
실행 결과
16:44:54.855 [Test worker] INFO com.example.advanced.trace.strategy.TemplateCallbackTest -- 비즈니스 로직1 실행
16:44:54.862 [Test worker] INFO com.example.advanced.trace.strategy.code.template.TimeLogTemplate -- resultTime=9
16:44:54.865 [Test worker] INFO com.example.advanced.trace.strategy.TemplateCallbackTest -- 비즈니스 로직2 실행
16:44:54.865 [Test worker] INFO com.example.advanced.trace.strategy.code.template.TimeLogTemplate -- resultTime=0
템플릿 콜백 패턴 - 적용
이제 템플릿 콜백 패턴을 애플리케이션에 적용해보자. LogTrace를 사용하는 것 말이다.
TraceCallback
package cwchoiit.springadvanced.trace.templatecallback;
@FunctionalInterface
public interface TraceCallback<T> {
T call();
}
콜백을 전달하는 인터페이스이다.
<T> 제네릭을 사용했다. 콜백의 반환 타입을 정의한다.
TraceTemplate
package cwchoiit.springadvanced.trace.templatecallback;
import cwchoiit.springadvanced.trace.TraceStatus;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
public class TraceTemplate {
private final LogTrace trace;
public <T> T execute(String message, TraceCallback<T> callback) {
TraceStatus status = null;
try {
status = trace.begin(message);
T result = callback.call();
trace.end(status);
return result;
} catch (Exception e) {
trace.exception(status, e);
throw e;
}
}
}
콜백 함수를 받아 실행할 템플릿 클래스이다.
콜백 함수를 받는 execute()는 공통 부분인 로그 추적 코드가 있고 그 사이에 콜백 함수를 실행한다. 콜백 함수의 반환값이 execute()의 반환값이 되고 그 반환값이 제네릭이므로 메서드 또한 제네릭이다.
OrderControllerV5
package cwchoiit.springadvanced.app.v5;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import cwchoiit.springadvanced.trace.templatecallback.TraceTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderControllerV5 {
private final OrderServiceV5 orderService;
private final TraceTemplate traceTemplate;
public OrderControllerV5(OrderServiceV5 orderService, LogTrace trace) {
this.traceTemplate = new TraceTemplate(trace);
this.orderService = orderService;
}
@GetMapping("/v5/request")
public String request(String itemId) {
return traceTemplate.execute("OrderControllerV1.request(String itemId)", () -> {
orderService.orderItem(itemId);
return "ok";
});
}
}
TraceTemplate을 주입 받는 방법은 다양하지만, 여기서는 생성자에서 new를 선언해서 인스턴스를 만든다. 그리고 필요한 LogTrace를 받아야한다.
물론, 이렇게 말고 TraceTemplate을 스프링 빈으로 아예 등록해서 주입받는 방법도 있다. 이렇게 하면 이 TraceTemplate도 딱 하나의 싱글톤 객체로 만들어진다.
장점과 단점이 있는데, 위 코드처럼 하면 장점은 테스트 코드 작성이 매우 수월해진다는 것이다. 테스트 코드를 작성할 때 TraceTemplate을 빈으로 등록해서 스프링 컨테이너에 추가할 필요가 없으니까. 단점은 이 클래스의 개수만큼 TraceTemplate이 객체로 생성된다는 점이다.
템플릿 콜백 패턴을 사용했더니 코드가 더 더 깔끔해졌다.
OrderServiceV5
package cwchoiit.springadvanced.app.v5;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import cwchoiit.springadvanced.trace.templatecallback.TraceTemplate;
import org.springframework.stereotype.Service;
@Service
public class OrderServiceV5 {
private final OrderRepositoryV5 orderRepository;
private final TraceTemplate traceTemplate;
public OrderServiceV5(OrderRepositoryV5 orderRepository, LogTrace trace) {
this.orderRepository = orderRepository;
this.traceTemplate = new TraceTemplate(trace);
}
public void orderItem(String itemId) {
traceTemplate.execute("OrderServiceV1.orderItem(String itemId)", () -> {
orderRepository.save(itemId);
return null;
});
}
}
OrderRepositoryV5
package cwchoiit.springadvanced.app.v5;
import cwchoiit.springadvanced.trace.logtrace.LogTrace;
import cwchoiit.springadvanced.trace.templatecallback.TraceTemplate;
import org.springframework.stereotype.Repository;
@Repository
public class OrderRepositoryV5 {
private final TraceTemplate traceTemplate;
public OrderRepositoryV5(LogTrace trace) {
this.traceTemplate = new TraceTemplate(trace);
}
public void save(String itemId) {
traceTemplate.execute("OrderRepositoryV1.save(String itemId)", () -> {
if (itemId.equals("ex")) {
throw new IllegalStateException("예외 발생!");
}
sleep(1000);
return null;
});
}
private void sleep(int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
정리
지금까지 우리는 변하는 코드와 변하지 않는 코드를 분리하고, 더 적은 코드로 로그 추적기를 적용하기 위해 고군분투했다.
템플릿 메서드 패턴, 전략 패턴, 그리고 템플릿 콜백 패턴까지 진행하면서 변하는 코드와 변하지 않는 코드를 분리했다. 그리고 최종적으로 템플릿 콜백 패턴을 적용하고 콜백으로 람다를 사용해서 코드 사용도 최소화 할 수 있었다.
한계
그런데 지금까지 설명한 방식의 한계는 아무리 최적화를 해도 결국 로그 추적기를 적용하기 위해서 원본 코드를 수정해야 한다는 점이다. 클래스가 수백개면 수백개를 더 힘들게 수정하는가 조금 덜 힘들게 수정하는가의 차이가 있을 뿐, 본질적으로 코드를 다 수정해야 하는 것은 마찬가지다.
개발자들의 게으름에 대한 욕심은 끝이 없다. 수많은 개발자들이 이 문제에 대해서 집요하게 고민해왔고, 여러가지 방향으로 해결책을 만들어왔다. 지금부터 원본 코드를 손대지 않고 로그 추적기를 적용할 수 있는 방법을 알아보자. 그게 바로 AOP가 될 것이다.
참고로, 템플릿 콜백 패턴은 실제 스프링 안에서 많이 사용되는 방식이다. xxxTemplate을 만나면 이번에 학습한 템플릿 콜백 패턴을 떠올려보면 어떻게 돌아가는지 쉽게 이해할 수 있을 것이다.
package com.example.tistoryuserservice.vo;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class CreateUser {
@NotNull(message = "Email must be required")
@Size(min = 2, message = "Email should be more than two characters")
@Email
private String email;
@NotNull
@Size(min = 2, message = "Name should be more than two characters")
private String name;
@NotNull
@Size(min = 8, message = "Password should be more than 8 characters")
private String password;
}
@NotNull, @Size, @Email과 같은 어노테이션은 방금 내려받은 dependency에 의해 사용할 수 있다. 이런 제약조건을 걸어놓으면 payload로 받은 데이터를 이 클래스에 담으려고 할 때 조건에 해당하지 않으면 담지 못한다. 이와 같이 유효성 검사를 간단하게 적용할 수 있다.
DTO
이제 DTO를 만들 차례다. 즉, 외부 요청에 의해 전달된 새로운 유저를 만들 데이터를 DB에 저장하기 전 DB에 들어갈 알맞은 형식의 데이터가 필요한데 그때 사용되는 클래스라고 보면 된다.
dto 패키지를 추가한 후 CreateUserDto라는 클래스로 만들고 위와 같이 작성했다. CreateUser 클래스에는 없는 userId, createdAt, encryptedPassword 필드는 DB에 넣기 전 서비스 클래스에서 추가될 내용이고 나머지는 CreateUser 클래스에서 받아올 거다.
CrudRepository
이제 CrudRepository를 사용해서 기본적인 CRUD API를 제공하는 JPA의 도움을 받을 것이다.
repository라는 패키지를 하나 만들고 그 안에 UserRepository 인터페이스를 생성하자.
package com.example.tistoryuserservice.service;
import com.example.tistoryuserservice.dto.CreateUserDto;
import com.example.tistoryuserservice.entity.User;
import com.example.tistoryuserservice.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
@Override
public CreateUserDto createUser(CreateUserDto createUserDto) {
createUserDto.setUserId(UUID.randomUUID().toString());
ObjectMapper mapper = new ObjectMapper();
User user = mapper.convertValue(createUserDto, User.class);
user.setEncryptedPassword("encrypted_password");
userRepository.save(user);
return createUserDto;
}
}
이 서비스 클래스에서 createUser()를 구현하고 있다. 여기서는 DTO에는 없는 userId와 encryptedPassword를 직접 추가해 준다. encryptedPassword를 만들어 내는 것을 구현하지 않았기 때문에 일단은 텍스트로 써넣는다. 이건 추후에 구현 예정이다.
DTO 데이터를 가지고 실제 데이터베이스에 들어갈 User라는 Entity로 타입 변환을 해준다. 그리고 그렇게 변환한 객체를 UserRepository를 주입받아서 save() 메서드를 호출한다. CrudRepository가 제공하는 save() 메서드에 어떠한 문제도 발생하지 않는다면 정상적으로 DTO 데이터를 다시 리턴한다.
이제 이 서비스 클래스를 호출할 Controller를 구현해야 한다. 실제로 유저가 사용할 API를 받아줄 수 있는.
JPA는 Java Persistence API의 약자로, RDB와 Java Objects 간 데이터 접근, 관리, 지속성에 대한 명세라고 생각하면 된다.
좀 더 간단하게는 Relational Database와 Java application이 상호작용하게 도움을 주는 녀석이라고 생각하자.
JPA의 핵심은 다음과 같다.
1. Object-Relational Mapping (ORM): JPA는 자바 오브젝트들과 데이터베이스 테이블을 양방향으로 매핑해 주는 방법을 제공한다. 이는 개발자들에게 코드상에서 자바 오브젝트들을 가지고 데이터베이스의 데이터들을 핸들링할 수 있게 한다.
2. Entity Classes: JPA는 엔티티 클래스를 정의할 수 있게 해주는데 엔티티 클래스라 함은 데이터베이스에 저장하고 싶은 데이터들을 또는 칼럼을 나타낸다. 이런 클래스들은 일반적으로 JPA 어노테이션들을 사용하여 데이터베이스를 구체화한다. (예: @Table(name = "users"), @Entity 등)
3. EntityManager: 엔티티 매니저는 JPA에서 핵심이 되는 인터페이스인데, 엔티티들을 관리하는데 사용된다. EntityManager는 CRUD에 대한 메서드들을 제공하며 JPQL(Java Persistence Query Language)를 사용해서 데이터베이스에 대한 쿼리를 수행할 수도 있다. 이 EntityManager는 하나의 트랜잭션에 하나가 쓰이게 된다. 즉, 고객의 요청에 의해 어떤 데이터베이스에 대한 작업을 하기 위해 트랜잭션이 시작된 후 모든 데이터베이스에 대한 작업이 끝나면 트랜잭션을 커밋하면 데이터베이스에 작업이 반영된다. 그 후 엔티티 매니저는 닫혀야한다.
4. JPQL(Java Persistence Query Language): JPQL은 SQL과 유사하나, JPA 엔티티와 이용될 수 있도록 설계되었다. JPQL을 사용해서 객체지향 방식으로 데이터베이스 쿼리를 수행할 수 있고 데이터를 가져올 수 있다.
5. Persistence Unit: Persistence Unit은 설정과 관련이 있고 이는 어떻게 JPA가 Java Application 내에서 사용되는지를 정의한다. data source, entity classes, 또는 다른 JPA와 관련된 세팅들을 명세한 설정이라고 생각하면 된다.
6. Transaction Management: JPA는 트랜잭션 관리를 지원하는데 트랜잭션 관리란 데이터베이스에 변화가 생겼을 때 그에 대한 지속성 또는 업데이트 처리를 말한다. 어노테이션을 사용하거나 코드 기반의 메서드를 통해 트랜잭션을 정의하고 관리할 수 있다.
JPA를 사용하므로써, 자바 개발자는 데이터베이스와 연관된 작업을 좀 더 객체 지향적으로, 표준화된 방식으로 작업할 수 있다.
API Gateway를 구현해보자. 그 전에 API Gateway가 있을 때 얻는 이점이 무엇이길래 이 녀석을 구현하는지 알아보자.
API Gateway가 없을 때 외부에서 어떤 요청을 하면 그 요청을 앞 단에서 해당 요청을 처리하는 서비스(뒷 단)와 통신하여 응답을 외부에게 돌려주는 구조가 될 것이다. 이 상태에서는 어떤 문제도 없지만 만약 기존에 있던 서비스들 중 어떤 것이 URL이 변경된다던지, 서비스를 운영하는 서버가 이전된다던지 등 어떤 변화가 생기게 되면 서비스의 URL같은 호출 정보가 변경된다. 호출 정보가 변경되면 그 변경 사항에 맞게 앞 단은 다시 설정 작업을 해야하고 그 작업으로 인해 서비스는 다운타임이 발생한다.
이와 반대로 API Gateway와 같은 중개자가 있는 구조를 살펴보자.
이런 구조를 가졌을 때 외부에서 요청이 들어오면 앞 단은 그 요청을 API Gateway에게 보내게 되고 API Gateway는 그 요청을 처리해주는 서비스에게 전달해주기만 하면 된다. 여기서 만약 위와 같은 상황인 서비스의 URL이 변경되거나, 서비스를 운영하는 서버가 이전된다거나 하더라도 앞 단에서 수정할 부분은 없다. 앞 단은 서비스가 무엇이 있는지조차 알 필요도 없다. 그저 API Gateway와 통신만 하면 되기 때문이다. 서비스를 운영하는 서버가 이전된 경우에 그 서버를 API Gateway에 등록(정확히는 Service discovery이지만 그림에서 표현하지 않았기에 편의상)하기만 하면 된다. 심지어 같은 서비스의 여러 인스턴스가 존재할 때 Load Balancing 처리도 해주기에 좋은 점은 늘어난다.
spring cloud gateway에 어떤 서비스들을 등록되어 라우팅될 것인지를 작성하는 부분인데 내가 만든 UserService를 이 gateway에 등록해서 UserService에 대한 요청이 들어오면 요청을 전달해준다. 그 때 작성하는 부분이 id, uri, predicates이다.
id는 고유값으로 서비스 이름을 설정했다. uri는 해당 서비스가 실행되고 있는 URL정보를 작성한다. predicates은 조건을 명시하는 부분인데 Path에 /user-service/**로 작성하게 되면 gateway로 들어오는 요청의 path 정보가 user-service가 붙고 그 뒤에 어떤 값이 들어오더라도 uri에 명시한 서비스로 요청을 전달한다.
이 application.yml 파일은 많은 변화가 있을 예정이지만 일단은 지금 상태로도 충분하다.
Start Gateway Server
이렇게 작성해놓고 Gateway Service를 실행시켜보면 다음처럼 정상적으로 실행됐다는 로그가 찍혀야한다.
정상적으로 Gateway Service가 올라왔고 이 Gateway를 통해 UserService를 호출했을 때 UserService로 요청이 전달되는지 확인해본다. 그러기 위해서는 UserService에 Controller가 필요하다.
UserService Controller
controller 패키지 하나를 만들고, 그 안에 StatusController.java 파일을 생성
package com.example.tistoryuserservice.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/")
public class StatusController {
@GetMapping("/welcome")
public String welcomePage() {
return "Welcome ! This is User Service.";
}
}
간단한 Welcome page를 만들었다. UserService의 /welcome으로 요청하면 "Welcome ! this is User Service."라는 문장이 노출되어야 한다. 그러나, gateway를 통해 요청해보면 다음과 같은 404 에러 화면을 마주하게 된다.
이런 현상이 발생하는 이유는 gateway를 통해 호출하는 경로 http://localhost:8000/user-service/welcome 이는 곧 gateway에서 routing 설정대로 http://localhost:8081/user-service/welcome으로 전달한다.
그러나, 만든 UserService의 welcome page 경로는 http://localhost:8081/welcome으로 되어 있다. (위 controller 코드 참고)
그렇기 때문에 404에러를 마주하게 된다. 이를 해결하기 위해 UserService의 Controller를 수정하자.
package com.example.tistoryuserservice.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user-service")
public class StatusController {
@GetMapping("/welcome")
public String welcomePage() {
return "Welcome ! This is User Service.";
}
}
StatusController 클래스의 @RequestMapping value를 "/user-service"로 명시했다. 이 클래스에 대한 context path를 지정한것과도 같다. 이렇게 설정한 후 다시 서버를 실행시켜서 확인해보면 정상 응답을 돌려받는다.
마무리
간략하게 Gateway Service를 구현해봤고 앞으로 더 많은 작업을 할 예정이다. 본 포스팅은 여기서 마무리.
요즘 한창 재미 들려 공부하는 MSA. 하나도 빠짐없이 배우고 공부한 내용을 기록해 보고자 한다.
MSA(MicroService Application)
우선 MSA는 Micro Service Application의 약자로, 어떤 서비스가 가진 기능을 제공할 때 하나의 큰 애플리케이션에서 모든 기능을 수행하는 것이 아니라 기능별, 특징별, 구성별로 서비스들을 작게 나누어 각 기능에 특화된 하나의 작은 서비스를 구축하고 그 구축한 여러 개의 서비스들이 모여 거대한 하나의 애플리케이션이 되는 형태를 말한다.
위 사진에서 우측 SERVICE A, SERVICE B가 있다. 이 두 개의 서비스들은 각자 자신이 수행해야 할 최소 단위의 기능들만을 모아 구성된 하나의 작은 서비스다. 예를 들어, 쇼핑몰 기능을 제공하는 하나의 서비스에 결제 관련 기능을 담당하는 SERVICE A, 장바구니 관련 기능을 담당하는 SERVICE B로 나뉘어 그렇게 각 모든 작은 서비스들이 모여 하나의 큰 서비스를 이루는 것처럼 말이다.
사용자는 이 쇼핑몰이 어떤 구조로 서비스를 제공하는지 알 필요 없이 단순하게 쇼핑몰 사이트에 들어가 원하는 행위를 하면 그 각각의 행위가 필요한 기능, API를 서비스 내에서 알아서 관리하고 호출, 신청, 반환, 응답하면서 서비스가 수행된다.
위 사진에서는 클라이언트에서 요청을 하는 게 첫 번째 흐름인데 요청을 SERVICE A 또는 SERVICE B에 직접적으로 하는 게 아닌 중간에 있는 API Gateway에게 요청한다. API Gateway 역시 Microservice가 된다. API Gateway는 외부의 모든 요청을 이 녀석이 책임지고 받아 이 요청을 처리할 수 있는 서비스를 찾아서 그 서비스에게 돌려주는 Service Discovery Server(Netflix Eureka)에게 전달한다.
여기서 잠깐 Eureka에 대해 얘기하자면, Eureka는 Netflix에서 분산된 서비스들을 등록하고 관리하는 도구이다. 이 Eureka는 분산된 시스템 구조에서 각 서비스들을 등록, 관리, 소통하게 해준다. 이 Eureka를 Service discovery tool이라고 한다.
Eureka 서버는 해당 요청을 처리할 수 있는 서비스를 찾아서 API Gateway에게 그 서비스를 알려주면 API Gateway는 해당 서비스에게 요청을 전달해 준다. 받은 요청을 처리할 수 있는 서비스는 해당 서비스를 처리 후 응답할 결과를 API Gateway에게 다시 돌려주고 최종적으로 클라이언트에게 돌아가게 된다.
전반적인 흐름을 봤을 때 이런 구조를 왜 굳이 가져야 하나 싶지만 이런 구조를 가지는 이유는 다음과 같다.
만약 각 마이크로서비스가 가져야 할 기능을 하나의 서버에서 전부 담당한다면 A가 처리할 기능 A, B가 처리할 기능 B를 모두 담당하고 있을 것이다. 이때 A가 처리할 기능 A를 수정 작업하는 상황이 생겼을 때 B는 어떠한 작업도 필요하지 않지만 서비스의 빌드와 배포가 다시 일어나야 하고 그렇기에 서버의 다운타임이 생긴다. 그 반대도 역시 마찬가지. 그 결과 사용자는 좋지 않은 사용자 경험을 할 수 있다. 그렇다면 이를 작은 단위로 서비스를 분리하여 특정 기능의 수정 및 추가가 일어날 때 해당 서비스만 변경 작업을 하고 해당 서비스가 제공하는 기능을 사용하는 서버 및 게이트웨이는 변경의 진행 여부조차 알 필요 없이 서비스는 계속해서 실행 상태를 유지할 수 있다.
API Gateway는 사용자의 요청을 받아 해당 요청에 대한 라우팅, 필터링, 요청에 대한 트래픽 관리를 해준다. 요청에 대한 트래픽 관리는 부하 분산과 관련이 있는데 이는 사용자에게 받은 요청에 대한 Load Balancing 처리를 해준다. 예를 들어, 쇼핑몰 서비스의 유저 관련 기능을 제공하는 하나의 마이크로서비스가 3개의 다른 포트로 실행되어 Eureka에 등록되면 사용자가 유저 조회와 같은 유저 관련 API를 호출할 때 현재 각 3개의 마이크로서비스 중 이 요청에 응답할 수 있는 서비스를 알아서 찾아 그 서비스로부터 데이터 요청 및 응답을 받는다.