0. 추가된 요구사항
이미 개발된 API 에 추가되는 기능이 발생했다고 생각해보겠습니다.
아래 Controller 는 쇼핑몰에 입점한 판매자가 해당 브랜드의 쿠폰을 등록하는 API 입니다.
@RequiredArgsConstructor
@RestController
public class CouponController {
private final CouponService couponService;
@PostMapping("/coupons")
public ResponseEntity<CouponResponse> create(@RequestBody CouponCreateRequest couponCreateRequest) {
CouponCreateDto couponCreateDto = couponCreateRequest.toDto();
Long couponId = couponService.create(couponCreateDto);
return ResponseEntity.created(URI.create("/coupons/"+couponId)).body(
new CouponResponse(couponId, "쿠폰이 정상적으로 등록되었습니다.")
);
}
}
여기에 각 요청의 실행시간을 알고 싶다고 한다면 어떻게 해야될까요?
가장 간단한(?) 방법은 각 API의 시작과 종료 시의 시간을 측정하는 방법일 것입니다.
@Slf4j
@RequiredArgsConstructor
@RestController
public class CouponController {
private final CouponService couponService;
@PostMapping("/coupons")
public ResponseEntity<CouponResponse> create(@RequestBody CouponCreateRequest couponCreateRequest) {
// 1. 로직 시작 시간 측정
long start = System.currentTimeMillis();
CouponCreateDto couponCreateDto = couponCreateRequest.toDto();
Long couponId = couponService.create(couponCreateDto);
// 2. return 직전에 종료 시간 측정
long end = System.currentTimeMillis();
log.info("CouponController 소요 시간 => {}ms", end-start);
return ResponseEntity.created(URI.create("/coupons/"+couponId)).body(
new CouponResponse(couponId, "쿠폰이 정상적으로 등록되었습니다.")
);
}
}
메서드의 시작 부분과 return 직전 부분에 시간을 측정하는 로직을 삽입한 형태입니다.
하지만, 위 코드에는 2가지의 문제점이 있습니다.
- 메서드 상하의 동일한 로직은 모든 API 에 추가해야 한다.
- 주요 로직과는 관계없는 부가기능이 한 곳에서 같이 처리되고 있다.
위 2가지의 문제 모두 생각해봐야할 문제들이지만, 이번 포스팅에선 두번 째 문제에 초점을 맞춰볼까 합니다.
현재 위 메서드는 크게 2가지의 기능을 수행하고 있습니다.
- 쿠폰등록정보를 요청받아 쿠폰을 등록하기 위해 service 를 호출한다. -> 핵심기능
- 해당 로직의 총 실행시간을 측정한다. -> 부가기능
핵심기능은 반드시 실행해야 하는 기능을 의미하며, CouponController.save() 의 기능의 경우 쿠폰정보를 생성하는 기능을 주요하게 다루기 때문에, 이 경우 쿠폰 생성 기능이 핵심기능이 됩니다.
반면에, 부가기능은 핵심기능을 보조하되, 주요 로직에 영향을 주진 않는 기능을 의미하며, CouponController.save() 내의 실행시간을 측정하는 기능이 부가기능이 됩니다.
이런 부가기능이 여러 개가 생길 경우, 핵심기능이 부가기능에 가려져 코드의 가독성이 매우 떨어질 우려가 있습니다.
이를 방지하기 위해, 부가기능을 핵심기능과 분리해서 핵심기능의 가독성을 유지할 수 있어야 하는데, 이 때 사용되는 객체를 프록시객체라고 부릅니다.
1. 프록시(Proxy)
프록시의 사전적 정의는 "대리(행위)나 대리권, 대리 투표, 대리인" 등을 뜻하는 말로, 무엇가를 대신한다는 의미를 가집니다.
IT 분야 내에서는 "클라이언트가 서버에 보낸 요청을 대리로 수행하는 중간자" 라는 의미로 사용되곤 합니다.
여기서 말하는 클라이언트/서버란, 웹 개발에서 분류되는 클라이언트(ex. 브라우저, 모바일 등) 과 서버(ex. 백엔드 서버 등)로 국한되는 개념이 아닌, 특정 동작을 요구하는 주체(클라이언트) 와 요구에 맞게 동작을 수행해주는 주체(서버)로 더 넓은 개념을 의미합니다.
요청 또한 웹에서의 Request 로 국한되지 않고, 클라이언트가 서버로 동작을 요구하는 모든 행위를 의미합니다.
IT 분야 내에서도 프록시 라는 단어는 여러 가지 의미로 사용되지만, 본 글에서 언급할 프록시는 특정 객체의 동작을 보조하기 위한 프록시 객체를 의미합니다. 소프트웨어 아키텍처의 관점에서 클라이언트 객체와 서버 객체 사이에 프록시 객체가 위치하면 아래와 같은 구조로 애플리케이션이 동작하게 됩니다.
그럼 왜 굳이, 번거롭게 프록시를 사용하는 것일까요?
프록시를 사용하는 목적은 크게 아래 2가지 입니다.
- 접근제어 : 클라이언트가 서버로 접근하는 행위를 제어합니다. ex. 지연로딩, 캐싱, 권한에 따른 접근 제어
- 부가기능 : 서버에서 수행해야할 보조기능을 대신 수행합니다. ex. 요청/응답 변환, 요청부터 응답까지의 시간 측정 등
2. 프록시 적용해보기
스프링에서 프록시를 재현하기 위해서는 아래 조건들을 충족해야 합니다.
- 프록시 객체와 구현체 모두 같은 인터페이스를 상속받아야 한다.
- 클라이언트는 인터페이스를 의존하여, 프록시 객체가 주입되는 사실을 몰라야 한다.
- 프록시 객체는 자신이 상속받은 인터페이스를 필드변수로 가지며, 반드시 실제 로직을 호출해야 한다.
위 조건들을 토대로 객체 간의 의존관계를 그려보면 아래와 같은 형태로 그려집니다.
위 구조를 바탕으로 간단하게 프록시를 사용하는 코드를 구현해보면 아래와 같이 구현될 수 있습니다.
먼저, interface 를 생성해줍니다.
public interface ServerInterface {
void operation();
}
operation() 메서드를 구현해야 하는 interface 입니다.
그리고, 위 interface 를 구현한 구현체를 하나 만들어줍니다.
@Slf4j
public class ServerInterfaceImpl implements ServerInterface {
@Override
public void operation() {
log.info("ServerInterfaceImpl start");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
log.info("serverInterfaceImpl end");
}
}
ServerInterface 를 implements 한 구현체입니다. log 를 출력하면서 시간의 흐름을 확인하기 위해 중간에 1초의 sleep 을 걸었습니다.
그리고, ServerInterface 를 실제로 호출하는 Client 객체를 하나 생성해줍니다.
이 때, 앞서 제시한 조건에 맞게 구현체를 의존하는 것이 아닌 인터페이스를 의존한다는 것을 주의합니다.
@Slf4j
@RequiredArgsConstructor
public class ClientObject {
private final ServerInterface serverInterface;
public void execute() {
log.info("ClientObject execute start");
serverInterface.operation();
log.info("ClientObject execute end");
}
}
인터페이스를 주입하여, operation() 메서드를 실행해줍니다.
위 코드가 어떤 결과를 나타내는지는 간단한 테스트코드를 통해 확인해보겠습니다.
public class ServerClientTest {
@Test
@DisplayName("프록시 객체를 사용하지 않은 상태로 실행하기.")
void no_proxy() {
ServerInterface serverInterface = new ServerInterfaceImpl();
ClientObject clientObject = new ClientObject(serverInterface);
clientObject.execute();
}
}
실제 구현체인 ServerInterfaceImpl 객체를 ClientObject 에 전달하여, clientObject 에서 execute() 를 실행하는 코드입니다.
결과는 아래와 같이 나오게 됩니다.
아직 프록시 객체를 적용하지 않았기 때문에, ClientObject.execute() 다음으로 바로 ServerInterfaceImpl.operation() 이 실행되었음을 알 수 있습니다.
그럼 여기에서 ServerInterface 의 실행시간을 측정하는 프록시 객체를 추가해보겠습니다.
먼저, ServerInterface 를 상속받는 프록시 객체를 아래와 같이 생성해줍니다.
@Slf4j
@RequiredArgsConstructor
public class TimeServerProxy implements ServerInterface {
private final ServerInterface target;
@Override
public void operation() {
log.info("TimeServerProxy start");
long start = System.currentTimeMillis();
target.operation();
long end = System.currentTimeMillis();
log.info("target execute time = {}ms", end-start);
log.info("TimeServerProxy end");
}
}
프록시 구현을 위해 ServerInterface 를 상속받고, 다른 ServerInterface 를 주입받습니다.
보통 프록시 객체를 생성할 때, 실행대상(필드변수로 선언된 ServerInterface)을 target 이라고 부릅니다.
프록시 객체는 자신의 기능(TimeServerProxy 의 경우, 시간 측정)을 수행함과 동시에 target 의 메서드(operation)를 반드시 실행해주어야 합니다.
위와 같이 프록시 객체를 생성하면, 객체 의존관계는 다음과 같습니다.
위 구조를 테스트코드로 작성하면 다음과 같습니다.
public class ServerClientTest {
@Test
@DisplayName("시간 측정을 위한 프록시를 사용하여 실행하기.")
void use_time_proxy() {
ServerInterface serverInterface = new ServerInterfaceImpl();
ServerInterface timeServerProxy = new TimeServerProxy(serverInterface);
ClientObject clientObject = new ClientObject(timeServerProxy);
clientObject.execute();
}
}
테스트 결과를 보면, ClientObject.execute() -> TimeServerProxy.operation() -> ServerInterfaceImpl.operation() 순으로 실행됨을 알 수 있습니다.
위와 같은 구조로 얻을 수 있는 이점은 아래와 같습니다.
- 부가기능을 ServerInterfaceImpl 코드 수정없이 자유롭게 추가할 수 있다.
- 부가기능을 추가해도, ServerInterface 를 의존하는 ClientObject 의 코드를 수정하지 않아도 된다.
위 과정은 프록시 객체를 통해 기능을 추가하는 기본적인 예시라고 할 수 있습니다.
3. 프록시 패턴 & 데코레이터 패턴
프록시를 사용하는 디자인 패턴으로는 프록시 패턴과 데코레이터 패턴 2가지가 있습니다.
두 가지 디자인 패턴은 프록시 객체를 통해 부가기능을 핵심기능과 분리한다는 점과 그로 인해 프록시 객체에 구현체를 주입한다는 점은 동일합니다. 두 디자인 패턴은 생김새로는 구분하기 어렵고, 프록시를 사용하고자 하는 의도에 따라 구분됩니다.
- 프록시 패턴(Proxy Pattern) : 클라이언트의 접근을 제어하기 위해 프록시를 사용하는 패턴. 대표적인 예로 캐싱.
- 데코레이터 패턴(Decorator Pattern) : 서버의 부가기능을 구현하기 위해 프록시를 사용하는 패턴. 대표적으로 로그추적기.
의도에 따라 다른 이름을 사용하기 때문에, 두 패턴의 구조적 차이는 크게 없습니다.
앞에서 간단하게 구현해본 TimeServerProxy 의 경우, 시간을 측정하는 부가 기능을 구현한 데코레이터 패턴이라고 볼 수 있습니다.
이번에는 접근 제어를 위한 프록시 패턴의 예시를 간단히 보겠습니다.
아래와 같은 새로운 인터페이스를 생성합니다.
public interface DataInterface {
String getData();
}
특정 문자열 데이터를 조회하는 인터페이스 입니다.
이를 구현한 구현체를 아래와 같이 구현합니다.
@Slf4j
public class DataInterfaceImpl implements DataInterface{
@Override
public String getData() {
log.info("DataInterfaceImpl getData() call");
return "data";
}
}
데이터를 return 하기 전에, 실제 구현체가 호출됨을 의미하는 log 를 한 줄 출력합니다.
그리고 이를 의존하는 클라이언트 객체를 생성합니다.
@RequiredArgsConstructor
public class DataClient {
private final DataInterface dataInterface;
public void execute() {
dataInterface.getData();
}
}
이를 테스트해보기 위한 테스트 코드를 아래와 같이 작성하고 실행해봅니다.
public class ServerClientTest {
@Test
@DisplayName("데이터를 조회하기 위해 구현체를 호출한다.")
void no_proxy_data() {
DataInterface dataInterface = new DataInterfaceImpl();
DataClient dataClient = new DataClient(dataInterface);
dataClient.execute();
dataClient.execute();
dataClient.execute();
}
}
클라이언트의 로직을 3번 실행하기 때문에, 구현체를 실제로 3번 호출하는 결과가 나타납니다.
이제 접근제어를 목적으로 하는 프록시 객체를 생성해보겠습니다.
클라이언트가 서버로 접근하는 것을 제어하는 목적이기 때문에, 서버에서 조회한 데이터를 캐싱하는 프록시를 아래와 같이 구현해봅니다.
@Slf4j
@RequiredArgsConstructor
public class CachingProxy implements DataInterface {
private final DataInterface dataInterface;
private String cachingValue;
@Override
public String getData() {
log.info("CachingProxy call");
if(cachingValue == null) {
cachingValue = dataInterface.getData();
}
return cachingValue;
}
}
서버에서 조회한 데이터를 캐싱하는 프록시입니다.
캐싱된 정보가 없는 경우, 실제 구현체를 호출하여 데이터를 조회해서 보관합니다.
다시 호출된 경우, 캐싱된 정보가 있기 때문에 구현체를 호출하지 않고 캐싱된 정보를 반환합니다.
이를 확인해보기 위해 테스트 코드를 아래와 같이 작성하여 의존관계를 변경해줍니다.
public class ServerClientTest {
@Test
@DisplayName("캐싱 프록시 사용하기")
void use_caching_proxy() {
DataInterface dataInterface = new DataInterfaceImpl();
DataInterface cachingProxy = new CachingProxy(dataInterface);
DataClient dataClient = new DataClient(cachingProxy);
dataClient.execute();
dataClient.execute();
dataClient.execute();
}
}
앞선 테스트처럼 클라이언트를 3번 실행했지만, 실제 구현체는 1번만 실행됨을 알 수 있습니다.
4. 실제로 적용해보기
앞에서 구현해본 데코레이터 패턴을 활용하여, 본 글 초반에 있던 CouponController.save() 를 부가기능을 제거한 형태로 구현해 보겠습니다.
먼저, Controller 를 리팩토링하기 전에 검증을 위한 테스트 코드를 아래처럼 작성해줍니다.
@WebMvcTest(CouponController.class)
class CouponControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
CouponService couponService;
@Test
@DisplayName("쿠폰을 생성한다.")
void create() throws Exception {
when(couponService.create(any())).thenReturn(1L);
CouponCreateRequest couponCreateRequest = new CouponCreateRequest("쿠폰 #1", "VIP", 10);
String requestBody = objectMapper.writeValueAsString(couponCreateRequest);
mockMvc.perform(post("/coupons")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.couponId", is(1)))
.andExpect(jsonPath("$.message", is("쿠폰이 정상적으로 등록되었습니다.")));
}
}
맨 앞에서 본 CouponController.create() 요청을 검증하기 위한 MVC 테스트 입니다.
현재 Controller 로 위 테스트 코드를 실행하면 아래와 같이 성공하게 됩니다.
이제 이 테스트코드를 기반으로 Controller 에서 시간을 측정하는 부가기능을 분리해보겠습니다.
먼저, Controller 의 interface 를 생성해줍니다.
@ResponseBody
@RequestMapping
public interface CouponControllerInterface {
@PostMapping("/coupons")
ResponseEntity<CouponResponse> create(@RequestBody CouponCreateRequest couponCreateRequest);
}
Controller 를 interface 로 사용하기 위해, @RestController 대신 @ResponseBody 와 @RequestMapping 으로 분리해서 작성해줍니다. Spring 내부에선 @RequestMapping annotation 을 확인하면, Controller 처럼 동작할 수 있게 처리해줍니다.
(이는 SpringBoot 2.x 버전에 대한 내용이고, SpringBoot 3.x 버전 이후는 interface 에도 @RestController 사용이 가능합니다.)
그리고 기존 Controller 를 인터페이스를 상속한 형태로 수정해줍니다.
@Slf4j
@RequiredArgsConstructor
public class CouponController implements CouponControllerInterface{
private final CouponService couponService;
@Override
public ResponseEntity<CouponResponse> create(CouponCreateRequest couponCreateRequest) {
// 1. 로직 시작 시간 측정
long start = System.currentTimeMillis();
CouponCreateDto couponCreateDto = couponCreateRequest.toDto();
Long couponId = couponService.create(couponCreateDto);
// 2. return 직전에 종료 시간 측정
long end = System.currentTimeMillis();
log.info("CouponController 소요 시간 => {}ms", end-start);
return ResponseEntity.created(URI.create("/coupons/"+couponId)).body(
new CouponResponse(couponId, "쿠폰이 정상적으로 등록되었습니다.")
);
}
}
인터페이스의 상속을 받았으니, Controller 구현체에선 @RestController 와 @PostMapping() 을 제거합니다.
그리고, Configuration 을 생성하여, Controller 를 Bean 으로 등록해줍니다.
@Configuration
public class CouponProxyConfig {
@Autowired
private CouponService couponService;
@Bean
public CouponControllerInterface couponControllerInterface() {
CouponControllerInterface couponController = new CouponController(couponService);
return couponController;
}
}
편의상, CouponService 는 @Service 를 통해 Component Scan 으로 처리했습니다.
ControllerInterface 에 @Component, @Controller, @RestController 와 같이 ComponentScan 의 대상이 되는 정보가 없기 때문에 수동으로 Bean 으로 등록해줍니다.
이 상태로 테스트 코드를 실행해서, 정상적으로 동작하는지 확인해줍니다.
테스트가 성공하는 것을 확인했다면, 이제 시간 측정을 위한 프록시를 생성해줍니다.
@Slf4j
@RequiredArgsConstructor
public class TimeControllerProxy implements CouponControllerInterface {
private final CouponControllerInterface target;
@Override
public ResponseEntity<CouponResponse> create(CouponCreateRequest couponCreateRequest) {
long start = System.currentTimeMillis();
log.info("CouponController start - by proxy");
ResponseEntity<CouponResponse> result = target.create(couponCreateRequest);
long end = System.currentTimeMillis();
log.info("CouponController end - by proxy => {}ms", end - start);
return result;
}
}
앞에서 구현해본대로, 같은 인터페이스를 구현한 구현체를 주입받아 target으로 지정하고, 해당 target을 실행하기 전/후에 log를 남기는 프록시 입니다.
이제 이 프록시를 적용하기 위해 앞에서 설정했던 Configuration 을 아래처럼 수정해줍니다.
@Configuration
public class CouponProxyConfig {
@Autowired
private CouponService couponService;
@Bean
public CouponControllerInterface couponControllerInterface() {
CouponControllerInterface target = new CouponController(couponService);
return new TimeControllerProxy(target);
}
}
그리고 원래 CouponController 로 돌아가, 기존에 작성되어있던 시간 측정 로직을 제거하면 됩니다.
@Slf4j
@RequiredArgsConstructor
public class CouponController implements CouponControllerInterface{
private final CouponService couponService;
@Override
public ResponseEntity<CouponResponse> create(CouponCreateRequest couponCreateRequest) {
// == 시간측정은 이제 여기가 아닌 프록시에서!
// 1. 로직 시작 시간 측정
// long start = System.currentTimeMillis();
CouponCreateDto couponCreateDto = couponCreateRequest.toDto();
Long couponId = couponService.create(couponCreateDto);
// 2. return 직전에 종료 시간 측정
// long end = System.currentTimeMillis();
// log.info("CouponController 소요 시간 => {}ms", end-start);
return ResponseEntity.created(URI.create("/coupons/"+couponId)).body(
new CouponResponse(couponId, "쿠폰이 정상적으로 등록되었습니다.")
);
}
}
그 후, 테스트 코드를 실행하면 테스트 성공과 동시에 아래와 같이 시간을 측정한 로그가 표시됩니다.
5. 마치며
본 포스팅에선 소프트웨어에서 사용되는 프록시 객체와 프록시 객체를 활용한 두 가지 디자인 패턴, 프록시 패턴과 데코레이터 패턴에 대해 정리해봤습니다.
정리하면서 느낀 점은.... 매우매우매우 불편하다! 였습니다.
물론, 부가기능과 핵심기능을 분리해 코드를 더욱 간결하게 하고, 객체지향 SOLID 원칙 중 하나인 단일 책임 원칙(Single Responsibility Policy) 를 준수할 수 있다는 점에서 적용할만한 가치가 있다고 생각하지만... 그 과정은 너무나 귀찮 그 자체인 것 같습니다.
하지만, 스프링에선 이러한 디자인 패턴을 기반으로 부가기능과 핵심기능을 쉽게 분리하는 AOP 라는 기능을 제공합니다.
Spring AOP 에 대한 내용도 정리되는대로 업로드 해보겠습니다.
참고 및 출처.
https://namu.wiki/w/%ED%94%84%EB%A1%9D%EC%8B%9C
소스코드.
https://github.com/dongha-byun/spring-proxy-playground
'Spring' 카테고리의 다른 글
[Spring] Spring AOP - 관심사 분리(부가기능과 핵심기능) (0) | 2024.02.26 |
---|---|
[Design Pattern] 템플릿/콜백 패턴(Template/Callback Pattern) (0) | 2023.12.08 |
[Spring Rest Docs] 테스트 코드를 통한 API 문서 만들기 (0) | 2023.10.20 |
[ArgumentResolver] 로그인 여부를 ArgumentResolver로 처리하기 (0) | 2023.04.25 |
스프링의 객체지향 (1) | 2023.03.06 |
댓글