HttpServlet 클래스 : 전통적인 서블릿 개발에서는 HttpServlet 클래스를 상속받아 사용한다. 이 클래스는 HTTP 요청을 처리하는 메서드(doGet(), doPost(), doPut(), doDelete() 등)를 제공하는데,
개발자는 각 HTTP 메서드에 맞게 doGet(), doPost() 등을 구현해야 하며, 요청 데이터를 수동으로 파싱하고 응답을 생성하는 방식으로 API를 구현한다.
직접적인 요청 처리: 각 서블릿은 URL에 직접 매핑되며, URL로부터 요청을 받아 로직을 처리하고 응답을 반환한다.
@Controller 어노테이션: Spring MVC에서는 @Controller 어노테이션을 사용하여 컨트롤러 클래스를 정의하고, 이 컨트롤러는 웹 요청을 처리하는 메서드들을 포함한다.
@RequestMapping 어노테이션: 메서드에 @RequestMapping 어노테이션을 사용하여 특정 URL 패턴에 대한 처리를 지정할 수 있고, 이를 통해 HTTP 메서드와 URL을 처리할 로직을 매핑한다.
DispatcherServlet: 모든 요청을 받아 적절한 컨트롤러 메서드에 전달한다. 설정과 다양한 웹 요청 처리 로직을 중앙에서 관리할 수 있게 해 준다.
중앙 집중화된 설정: DispatcherServlet을 통해 모든 요청을 중앙에서 관리하며, 각 요청을 알맞은 컨트롤러로 라우팅한다. 이는 설정을 간소화하고 개발자가 비즈니스 로직에 집중할 수 있게 한다.
선언적 매핑: @RequestMapping 등의 어노테이션을 사용하여 URL 매핑을 선언적으로 처리할 수 있다. 이는 코드의 가독성과 유지 보수성을 높여 준다.
다양한 편의 기능: 데이터 바인딩, 유효성 검사, 예외 처리 등을 위한 풍부한 어노테이션과 클래스를 제공한다.
Dispatcher Servlet은 HTTP 프로토콜로 들어오는 요청을 가장 먼저 받고, 적합한 세부 컨트롤러에 보내주는 역할을 한다. 즉 컨트롤러를 구현해두기만 하면 Dispathcer Servlet이 적합한 컨트롤러로 요청을 위임해준다. HandlerMapping과 HandlerAdapter는 다음과 같은 역할을 한다.
HandlerMapping : 요청을 적절한 컨트롤러에 매핑
HandlerAdapter : 컨트롤러의 메서드를 실행
이를 실행하는 Dispatcher Servlet의 주요 메서드를 보면 다음과 같다.
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
for (HandlerMapping hm : this.handlerMappings) {
HandlerExecutionChain handler = hm.getHandler(request);
if (handler != null) {
return handler;
}
}
return null;
}
protected HandlerAdapter getHandlerAdapter(Object handler) throws ServletException {
for (HandlerAdapter ha : this.handlerAdapters) {
if (ha.supports(handler)) {
return ha;
}
}
throw new ServletException("No adapter for handler [" + handler +
"]: The DispatcherServlet configuration needs to include a HandlerAdapter that supports this handler");
}
protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
HttpServletRequest processedRequest = request;
HandlerExecutionChain mappedHandler = null;
ModelAndView mv = null;
try {
mappedHandler = getHandler(processedRequest);
if (mappedHandler == null) {
noHandlerFound(processedRequest, response);
return;
}
HandlerAdapter ha = getHandlerAdapter(mappedHandler.getHandler());
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
processDispatchResult(processedRequest, response, mappedHandler, mv, null);
} catch (Exception ex) {
dispatchException(processedRequest, response, mappedHandler, ex);
} finally {
cleanup(processedRequest, response, mappedHandler);
}
}
private void processDispatchResult(HttpServletRequest request, HttpServletResponse response,
HandlerExecutionChain mappedHandler, ModelAndView mv, Exception exception)
throws Exception {
boolean errorView = false;
if (exception != null) {
if (exception instanceof ModelAndViewDefiningException) {
mv = ((ModelAndViewDefiningException) exception).getModelAndView();
} else {
Object handler = (mappedHandler != null ? mappedHandler.getHandler() : null);
mv = processHandlerException(request, response, handler, exception);
errorView = (mv != null);
}
}
// Render the ModelAndView, if requested.
if (mv != null && !mv.wasCleared()) {
render(mv, request, response);
if (errorView) {
WebUtils.clearErrorRequestAttributes(request);
}
} else {
if (logger.isTraceEnabled()) {
logger.trace("No view rendering, null ModelAndView returned.");
}
}
if (WebAsyncUtils.getAsyncManager(request).isConcurrentHandlingStarted()) {
return;
}
if (mappedHandler != null) {
mappedHandler.triggerAfterCompletion(request, response, null);
}
}
Spring Web MVC에서는 WebApplicationInitializer를 구현하면 컨테이너 초기화 작업을 처리해준다.
일반적으로는 스프링 컨테이너를 하나 만들고, 디스패처 서블릿도 하나만 생성하고 매핑 경로도 / 로 설정해서 하나의 디스패처 서블릿을 통해 모든 것을 처리하도록 한다.
public class MyWebApplicationInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext) {
// 스프링 컨테이너 생성
AnnotationConfigWebApplicationContext context = new AnnotationConfigWebApplicationContext();
context.register(AppConfig.class);
// DispatcherServlet 생성 및 등록(컨테이너에 연결)
DispatcherServlet servlet = new DispatcherServlet(context);
ServletRegistration.Dynamic registration = servletContext.addServlet("app", servlet);
registration.setLoadOnStartup(1);
// /app/* 요청이 디스패처 서블릿을 통하도록 설정
registration.addMapping("/app/*");
}
}
Aspect-Oriented Programming (AOP)은 객체 지향 프로그래밍(OOP)을 보완하는 프로그래밍 패러다임이다.
OOP에서는 클래스가 모듈화의 기본 단위이지만, AOP에서는 Aspect가 그 역할을 한다. Aspect는 여러 타입과 객체에 걸쳐 있는 관심사(예: 트랜잭션 관리)를 모듈화하는 데 사용된다.
Spring loC 컨테이너는 AOP에 의존하지 않지만, AOP는 Spring loC를 보완하여 유능한 미들웨어 솔루션을 제공한다.
관점 지향 프로그래밍이라고 하는데, 어떤 로직을 핵심적 관점, 부가적 관점으로 나누어서 보고 그 관점을 기준으로 각각 모듈화하겠다는 것이다.
AOP는 이러한 부가적 관점을, Aspect라는 별도의 모듈로 분리한다. Aspect 모듈은 특정 "조인 포인트"(Join points)에서 "어드바이스"(Advice)를 통해 핵심적인 비즈니스 로직에 적용된다. 조인 포인트는 애플리케이션 실행 중 Aspect가 적용될 수 있는 특정 지점을 의미하며, 어드바이스는 그 지점에서 실행되어야 할 코드를 말한다.
AOP는 다음과 같은 이유로 필요하다고 할 수 있다.
중복 코드 감소: 로깅, 에러 처리, 트랜잭션 관리와 같은 반복적인 코드를 분리하여 관리할 수 있다.
모듈성 향상: 관련된 코드를 독립된 모듈(Aspect)로 분리하여 각 모듈이 주 업무 로직에 미치는 영향을 최소화한다.
유지보수 용이: Crosscutting concerns의 중앙집중화된 관리로 인해 시스템 전반의 변경이 용이해진다.
OOP (Object-Oriented Programming): OOP는 데이터와 기능을 객체로 묶어서 관리한다. 이런 접근법은 코드의 재사용, 확장 및 유지 관리를 쉽게 한다. 기본 원칙으로 상속, 다형성, 캡슐화를 포함한다.
AOP (Aspect-Oriented Programming): AOP는 부가적인 기능을 핵심 로직에서 분리해서 모듈화한다. 이는 코드의 중복을 줄이고, 유지 보수를 간편하게 만든다.
Advice: 코드 실행의 특정 지점에서 실행되는 코드 블록이다. Before, After 같은 다양한 타입이 있다.
Join Point: 프로그램 실행 중에 Aspect의 코드가 적용될 수 있는 지점이다. 예를 들어 메소드 호출이나 필드 접근 등이다.
Pointcut: Advice가 적용될 Join Points를 정의한다. 이는 특정 조건에 맞는 Join Point를 선별한다.
Aspect: 부가적인 기능을 모듈화한 클래스다. 하나 이상의 Advice와 Pointcut을 포함할 수 있다.
Weaving: 컴파일 시간, 로드 시간, 또는 런타임에 Aspect를 주요 로직에 삽입하는 과정이다.
컴파일 타임 위빙: 소스 코드를 컴파일하는 단계에서 Aspect가 적용된다. AspectJ 같은 도구가 이를 사용한다.
런타임 위빙: 애플리케이션을 실행하는 동안에 Aspect가 동적으로 적용된다. Spring AOP가 이 방법을 사용한다.
Spring AOP는 프록시 기반 AOP를 사용한다. 이는 런타임 위빙을 통해 이루어지며, 스프링 빈에 등록해야 적용이 가능하다.
프록시 패턴은 구조적 디자인 패턴 중 하나인데, 특정 객체에 대한 접근을 제어하거나 추가 기능을 제공하기 위해 그 객체의 대리자나 대체물을 제공하는 방법이다.
프록시는 실제 객체와 같은 인터페이스를 구현하므로, 클라이언트는 프록시를 통해 실제 객체를 사용하는 것처럼 동작할 수 있다.
프록시 패턴은 접근을 제어하거나, 부가 기능을 추가하고 싶을 때 사용한다. 스프링 AOP는 런타임시 동적으로 프록시객체를 만들어준다. 이 부분에서 중복 코드를 줄일 수 있는 것이다.
<!-- Spring AOP 의존성 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
프록시 생성: @Aspect 어노테이션을 사용하여 로깅을 수행할 Aspect 클래스를 정의하고, @Component 어노테이션을 통해 빈으로 등록해준다.
프록시의 메서드 호출 : 객체의 메소드를 호출할 때 실제로는 프록시의 메소드가 먼저 호출된다. 프록시는 필요한 Advice 로직을 실행한 다음, 실제 객체의 메소드를 호출한다. @Service 어노테이션을 사용하여 구현하는 서비스 클래스에서, Advice를 적용해준다.
부가 기능 실행: Advice에 정의된 부가 기능이 실행되고, 이로 인해 핵심 로직은 부가 기능과 분리된다.
이전 프로젝트에서 AOP가 필요한 상황에 적용을 해보았다.
기존 컨트롤러 코드
// 홈 화면 통계 조회
@GetMapping("/insight")
@Operation(summary = "홈 화면 독서 통계 조회", description = "사용자의 전체 책 수와 상태별 책 수를 반환합니다.")
public ApiResponse<BookShelfDTO.BooksInsightDTO> getBooksInsight(@AuthenticationPrincipal CustomUserDetails userDetails) {
BookShelfDTO.BooksInsightDTO insight = bookshelfService.viewBooksInsight(userDetails.getUser());
return ApiResponse.onSuccess(insight,SuccessCode.OK);
}
BookShelfService 코드
// 서재 통계 조회
@Transactional(readOnly = true)
public BookShelfDTO.BooksInsightDTO viewBooksInsight(User user) {
return userBookshelfRepository.getBooksInsight(user);
}
@Aspect
@Component
@Slf4j
public class TimeTraceAspect {
// 통계 관련 서비스 메소드만 타겟팅
@Around("execution(* umc.nook.records.service.RecordService.view*(..)) " +
"|| execution(* umc.nook.bookshelf.service.BookShelfService.view*(..))")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
String method = joinPoint.getSignature().toShortString();
log.info("[START] {}", method);
try {
return joinPoint.proceed();
} finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
log.info("[END] {} 실행시간={}ms", method, timeMs);
}
}
}
@Aspect : 해당 클래스가 AOP 기능을 가진다고 선언하는 어노테이션이다.
@Component : 스프링 빈으로 등록해서 자동으로 적용되도록 해준다.
@Around : AOP 대상이 되는 메소드의 실행 전후를 감싸주는데, 원하는 시점에 실행이 가능해도록 해준다. 즉 실행 전,후,예외 발생하는 경우 모두 제어가 가능하다. execution(..) 표현식으로, 어떤 메소드에 적용할지를 지정해준다. 서재에 등록한 책들을 조회하는 메서드와 독서 기록을 조회하는 메서드들의 실행 시간을 측정하고 싶은 것이기 때문에, view로 시작하는 모든 메서드들을 적용 대상으로 지정해준다.
실행 시작 시간을 먼저 기록해준다.
JoinPoint : Advice가 적용될 수 있는 지점인데, 메소드 실행 시점을 말한다. 실행 시작 시간을 기록한 뒤에, 어떤 메소드를 적용했는지, 메소드명을 String으로 받아온다.
ProceedingJoinPoint : proceed()를 호출해서, 대상이 되는 메소드의 실제 실행을 제어할 수 있다. 실제 대상이 되는 메소드(독서 통계 관련)를 proceed() 를 통해 실행해해준다.
이제 실행이 끝났으니 finally 블록에서는 먼저 실행 종료 시간을 기록하고, 실행 시간을 계산해준다.
로그에 실행이 종료되었음을 명시해주고, 실행시간을 표시해준다.
시간 측정 AOP를 등록해봤다. 프록시 역할을 하기 때문에, 의존 관계가 다음과 같이 변화한다. AOP 등록 전에는 사용자가 API를 호출하면 다음과 같은 흐름으로 들어온다.
Spring이 @Aspect로 등록된 클래스를 보고, 해당 빈 대신에 프록시 객체를 생성해둔다. 즉 메소드가 호출되면, 프록시가 가로채가는 것이다. execution(..) 안의 표현식과 같은 조건에 부합하는지 확인한다. 부합하면 @Around Advice를 실행한다. 즉 통계 메소드라면, 위의 코드를 먼저 실행하는 것이다. 통계가 아니라면 원래 메소드를 바로 실행한다.
실행 시간, 결과 로그를 출력하고, 트랜잭션을 종료한다.
joinPoint.proceed()가 호출될 경우 대상 서비스의 비즈니스 메소드가 실행되고, 결과가 다시 @Around로 돌아와서 로그를 출력한다.그리고 실행한 메소드의 결과값을 클라이언트에 출력한다.
전체적으로 다음과 같이 변화한다. 메서드 호출 시, proxy가 먼저 호출된다.