안녕하세요 Youl입니다!😆 이번에는 스프링 의존성없이 스프링을 밑단부터 구현 해보는 과정에서
FrontController 패턴을 이용하여 스프링의 DispatchServlet과 같은 기능을 구현해 보았습니다.
dependencies {
implementation "ch.qos.logback:logback-classic:1.4.14"
implementation "org.thymeleaf:thymeleaf:3.1.2.RELEASE"
testImplementation "org.mockito:mockito-core:5.4.0"
testImplementation "org.junit.jupiter:junit-jupiter-api:5.10.1"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.10.1"
}
(정말 심플한 프로젝트 의존성)
그 과정중에서 고민하였던 내용들을 이번글을 통해 녹였으니 같이 진행을 해보면 좋을 것 같습니다.
- Controller 추상화 방법 고민
- 정적 파일 처리 방법 고민
- Model과 View를 담기 위한 방법 고민
🏗️ Controller 추상화 방법 고민
먼저 MVC 패턴을 구현하다보면 Controller가 클라이언트의 요청을 받아서 처리하게 됩니다.
하지만 매번 다른 요청마다 공통으로 처리해야 할 로직이 있을 경우에도 각기 다른 컨트롤러가 호출이 되다 보니,
위의 사진과 같이 공통으로 처리할 내용이 컨트롤러마다 구현이 되어야 하는 문제가 발생하게 됩니다.
이는 코드의 중복을 발생 시키고 코드의 누락으로도 이어 질 수 있는 문제입니다.
FrontController 란?
이름에서도 유추 해 볼 수 있듯이 최앞단에서 위치한 컨트롤러로써,
웹 사이트 에 대한 모든 요청을 처리하는 컨트롤러입니다 .
이 때 FrontController 패턴을 적용하게 되면 공통 코드의 로직을 한곳에서 관리하여 중복을 최소화 할 수 있고 개발자가 누락시키는 일을 피할 수 있어 일관성을 지킬 수도 있으며, 유연하게 라우팅도 할 수 있습니다.
이를 통해 스프링에서도 FrontController 패턴을 적용한 DispatcherServlet을 사용하게 됩니다.
저도 이러한 장점을 익히 들어 알고 있었기에 제 눈으로 직접 보며 개발해보면 기억에 남을 것 같아 이번 스밑구 프로젝트에서 간단하게 FrontController로 구현해 보았는데요.
이를 위해 앞단에서 요청을 먼저 받은 FrontController가 각 요청을 유연하게 서비스할 컨트롤러로 라우팅을 할 수 있도록 구현을 해야하는데요. 이를 위해서는 다형성을 활용하여 Controller을 추상화시키면 더욱 효율적으로 유연하게 전달을 할 수 있을 것 입니다.
따라서 Controller Interface를 작성하여 줍니다.👀
public interface Controller {
void service(HttpRequest request, HttpResponse httpResponse);
}
다음으로는 Template Method 패턴을 사용하여 추상 클래스를 통해 뼈대를 구축하여 주었습니다.
public abstract class AbstractController implements Controller {
@Override
public void service(HttpRequest request, HttpResponse response) {
switch (request.getMethod()) {
case GET -> doGet(request, response);
case POST -> doPost(request, response);
case PATCH -> doPatch(request, response);
case PUT -> doPut(request, response);
case DELETE -> doDelete(request, response);
default -> throw new IllegalArgumentException("HTTP method 정보가 올바르지 않습니다.");
}
}
public void doGet(HttpRequest request, HttpResponse response) {}
public void doPost(HttpRequest request, HttpResponse response) {}
public void doPatch(HttpRequest request, HttpResponse response) {}
public void doPut(HttpRequest request, HttpResponse response) {}
public void doDelete(HttpRequest request, HttpResponse response) {}
}
이는 실제 서비스 로직의 구현을 하위 클래스에게 맡길 수 있어 각 구현 클래스(컨트롤러)는 자신의 서비스 로직의 구현에만 집중할 수 있게 됩니다. 또한 이렇게 하면 새로운 컨트롤러를 추가하거나 기존의 컨트롤러를 수정할 때 AbstractController의 변경 없이도 각 컨트롤러의 특정 동작을 쉽게 변경할 수 있습니다.
그럼 이제 FrontController 코드를 함께 살펴보실까요? 🔬
먼저 RequestHandlerMapping이라는 클래스에 컨트롤러들을 모두 등록하여 줍니다.
public class RequestHandlerMapping {
private final static Map<String, Controller> controllerMap = new HashMap<>();
static {
saveControllerMap();
}
private RequestHandlerMapping() {}
private static void saveControllerMap() {
controllerMap.put("/", DefaultController.INSTANCE);
controllerMap.put("/login", LoginController.INSTANCE);
controllerMap.put("/user", UserController.INSTANCE);
controllerMap.put("/register", UserRegisterController.INSTANCE);
}
public static Controller getController(String url) {
return controllerMap.get(url);
}
}
그런 다음 FrontController는 RequestHandlerMapping을 통해 각 핸들러를 찾아 service()를 실행하게 되고
public class FrontController {
private FrontController() {}
public static void process(HttpRequest request, HttpResponse response) {
...
getHandler(request).service(request, response);
...
}
private static Controller getHandler(HttpRequest request) {
Controller controller = RequestHandlerMapping.getController(request.getUrl());
if (controller == null) {
return NotFoundController.INSTANCE;
}
return controller;
}
}
이는 아까 구현한 AbstractController의 템플릿메소드 패턴에 의해서 각 doGet(), doPost()로 잘 전달되어 수행되게 됩니다.
public class LoginController extends AbstractController {
public static final LoginController INSTANCE = new LoginController();
private LoginController() {}
@Override
public void doGet(HttpRequest request, HttpResponse response) {
response.setViewName("/login");
}
@Override
public void doPost(HttpRequest request, HttpResponse response) {
...
}
}
(여기선 컨트롤러를 싱글턴으로 반환을 하기 위해 static INSTANCE를 만들어 사용하였습니다.)
저는 이런식으로 FrontController의 요청을 다형성을 활용한 추상 Controller을 사용하여 문제를 해결하여 보았습니다.
🎇 스프링은 이를 더 편리하고 고급지게??
이미 스프링에서 이를 더 사용자가 편리하고 고급지게 @RequestMapping이라는 어노테이션을 사용하여 리플렉션을 통해 Method를 동적으로 invoke() 하여 호출하고 있는 방법을 구현하고 있습니다.
이는 아래 글에서 실제 스프링 코드로 DispatcherServlet 동작과정 및 @RequestMapping이 등록되는 과정도 간단하게 설명을 해두었으니 궁금하신 분들을 참고 하시면 좋을 것 같습니다!
🖼️ 정적 파일 처리 방법 고민
다음으로 고민하였던 것은 정적 파일 처리에 대해서 입니다.
🤔 이부분을 고민하게 된 이유는..
단순하게 index.html 파일을 하나만을 전송하였을 경우에도 해당 파일에서 사용중인 CSS, JS등의 정적 파일들의 요청마저도 여러번에 걸쳐 도착하게 되는데 그 때마다 FrontController까지 들어와서 처리가 되는 것이였습니다.
사실 이 부분은 HTTP 요청이 RequestHandlerMapping에서 걸리지 않았다면,
정적파일을 요청하는 것인지 한번 더 검사하고 StaticResourceController라는 이름으로 클래스를 생성하여 해당 컨트롤러에서 모든 정적파일 처리를 위임하는 방법도 괜찮겠지만! 저는 단순 정적파일이 FrontController까지 온다는 것이 좀 불만족스러웠고 스프링은 어떻게 처리를 하고 있는지 궁금해졌습니다.
그래서 찾아본 결과 스프링은 ResourceHttpRequestHandler를 사용하여 정적파일들을 디스패처서블릿 이후에 처리하고 있었고, 이를 통해 보안 및 유연한 확장성과 커스터마이징 기능들을 제공하고 있었습니다.
하지만 이는 보통 웹서버에서 이미 왠만한 정적파일의 리소스를 처리하고 있기에 디스패처 서블릿까지 들어오게 된 요청은 안정적인 보안을 거쳐 응답하게 된 구조였고, 제가 구현하고 있던 상황에서는 거의 톰캣 자체를 구현하고 있는 것과 다름이 없고, 웹서버도 없어 정적파일 요청인지를 미리 파악하고 FrontController 이전에 처리하는 것이 맡다고 판단하여 개발하게 되었습니다.
따라서 제가 개발한 코드에 대해서 간단하게 살펴보자면
정적파일 체크 여부는 실제로는 훨씬 더 많은 확장자 들이 있지만 우선은 대표적인 확장자들로 정의를 하였습니다.
public enum FileType {
HTML(".html", ContentType.TEXT_HTML),
CSS(".css", ContentType.TEXT_CSS),
SCSS(".scss", ContentType.TEXT_CSS),
JS(".js", ContentType.APPLICATION_JAVASCRIPT),
JPG(".jpg", ContentType.IMAGE_JPEG),
PNG(".png", ContentType.IMAGE_PNG),
GIF(".gif", ContentType.IMAGE_GIF),
ICO(".ico", ContentType.IMAGE_X_ICON);
private final String extension;
private final ContentType contentType;
FileType(String extension, ContentType contentType) {
this.extension = extension;
this.contentType = contentType;
}
public static FileType valueOfFilename(String filename) {
if (filename == null || filename.indexOf(".") == -1) {
return null;
}
String fileExtension = filename.substring(filename.indexOf("."));
for (FileType value : FileType.values()) {
if (value.extension.equals(fileExtension)) {
return value;
}
}
return null;
}
public static boolean isStaticFile(String filename) {
FileType fileType = valueOfFilename(filename);
return fileType != null && !FileType.HTML.equals(fileType);
}
public ContentType getContentType() {
return this.contentType;
}
}
(모든 HTML확장자는 타임리프로 동적으로 처리하기 위해 제외하였습니다.)
그리고 ServerSocket에게 받은 사용자 요청에 대한 Connection의 URL을 추출하여 정적 파일을 요구하는지 체크합니다.
public class Http11Processor implements Runnable, Processor {
...
@Override
public void process(final Socket connection) {
try (final InputStream inputStream = connection.getInputStream();
final OutputStream outputStream = connection.getOutputStream()) {
HttpRequest request = new HttpRequest(new BufferedReader(new InputStreamReader(inputStream)));
HttpResponse response = new HttpResponse();
if (FileType.isStaticFile(request.getUrl())) {
staticFileProcess(request, response);
} else {
FrontController.process(request, response);
}
...
}
...
}
private void staticFileProcess(HttpRequest request, HttpResponse response) {
response.setResponseBody(
new ResponseBody(
FileUtil.getStaticFile(request.getUrl()),
FileType.valueOfFilename(request.getUrl()).getContentType()
)
);
}
}
이렇게 FrontController 앞에서 정적파일 여부를 검증을 하게 되면 FrontController는 정적인 파일의 처리를 하지않고 오직 비즈니스적인 웹서버의 요청만을 처리 할 수 있어 각 요청에 대한 핸들러를 찾는 비용이 줄어들고 정적파일을 요구하는 요청들도 재빨리 응답해줄 수 있다는 장점을 가지게 됩니다.
😤 그러나 이 접근 방식에도 일부 단점이 존재합니다.
세상에는 수 많은 확장자들의 파일들이 존재하여 초기에 정의할 때 분명 놓치게 될 수도 있는데
- 그 때마다 추가 및 수정을 한다면 서버를 내렸다가 올려야 하고,
- 또는 이를 해결하기 위해 인메모리나 DB에 추가하는 식으로 진행 할 수도 있겠지만 이 역시도 좋지 못합니다.
- 이는 개발자들은 바쁘므로 자동화를 시켜야 유지보수에 좋은 반면,
- 새로운 확장자를 발견할 때마다 계속 추가해주어야 하므로 유지보수에 좋지 나쁜 영향을 끼칠 수 있습니다.
그럼에도 내가 이렇게 구현한 이유!
제가 이렇게 구현을 생각한 것으로는
- 이번 프로젝트에선 서버에서 제공하여 줄 정적 리소스의 파일 형태는 더 이상 추가될 일이 없다고 판단하였고,
- 정적파일인 경우 바로 응답을 하고 서비스 요청에 대해서만 FrontContoller에 접근을 하여,
- 서버 응답에 있어서도 더 빠르므로 사용자 경험에 더 좋을 것 같다고 생각하여 이렇게 구현하였습니다.
🤔 만약 이 서비스가 커져서 파일유형 확장 및 많은 정적파일양 고려되는 문제 였다면?
- 우선 확장자명 체크보단 static 디렉토리에서 URL과 일치하는 파일들을 찾아보는 방식을 선택하였을 것 같은데,
- 이는 서비스가 성장함에 따라 정적 파일의 양도 많아질 것이고,
- 디렉토리에서 일치하는 파일들을 찾아보는 과정에서 비싼 I/O 작업도 많이 발생하여 오래 걸리게 되므로,
- 정적파일 처리를 위해서 중요한 서비스 로직들의 응답을 늦추는 것은 효율적이지 않다고 생각이 들었습니다.
따라서 이러한 경우였다면 아까 위에서 제시했던 방법처럼 StaticResourceController를 사용하여 스프링과 동일하게 FrontController 접근 이후에 처리를 했을 것 같습니다.
🎁 Model과 View를 담기 위한 방법 고민
마지막 고민은 동적으로 보여줄 웹페이지(View)와 그에 따른 동적데이터(Model)을 어떻게 전달해줄 것 인지였습니다.
아래 구현을 해놓은 것을 봐보시면 이미 간단해보이지만 실제로는 어떻게 구현해야 깔끔하게 구현될지는 상당히 고민을 많이 했습니다.
처음에는 각 컨트롤러에서 처리하며 뷰 리졸버를 직접 호출을 할지 고민을 하였었는데,
이는 앞서 나온 내용처럼 코드의 중복이 많아지고 놓칠수도 있을 것 같아서 스프링의 뷰리졸버를 떠올리고 프론트 컨트롤러에서 공통적으로 반환할 뷰가 있는지 검사하여 제공하여 주는것도 좋을것 같다고 생각하여 이렇게 구현하였습니다.
따라서 저는 response 객체에 아래와 같은 ModelAndView라는 객체를 만들어 넣어준 뒤.
public class ModelAndView {
private final Map<String, Object> model = new HashMap<>();
private String viewName;
public ModelAndView() {}
public List<String> getModelKeys() {
return new ArrayList<>(model.keySet());
}
public Object getModelValue(String key) {
return model.get(key);
}
public String getViewName() {
return viewName;
}
public void setModel(String key, Object value) {
model.put(key, value);
}
public void setViewName(String viewName) {
this.viewName = viewName;
}
}
아까 위에서 처럼 구체화 된 LoginController 에서 ViewName에 보여줄 웹페이지 이름을 넣어주고 로직이 종료되면 FrontController에선 ViewResolver가 실행이 되고
public class FrontController {
...
public static void process(HttpRequest request, HttpResponse response) {
try {
getHandler(request).service(request, response);
if (response.getViewName() != null && !"".equals(response.getViewName())) {
ViewResolver.execute(response);
}
}
}
...
}
ViewResolver에선 타임리프를 사용하여 동적으로 웹을 변경해주도록 설계를 해보았습니다.😊
public class ViewResolver {
private final static TemplateEngine templateEngine = initializeTemplateEngine();
private static final String TEMPLATE_PREFIX = "/templates/";
private static final String TEMPLATE_SUFFIX = ".html";
private ViewResolver() {}
private static TemplateEngine initializeTemplateEngine() {
var resolver = new ClassLoaderTemplateResolver();
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding(StandardCharsets.UTF_8.name());
resolver.setPrefix(TEMPLATE_PREFIX);
resolver.setSuffix(TEMPLATE_SUFFIX);
TemplateEngine engine = new TemplateEngine();
engine.setTemplateResolver(resolver);
return engine;
}
public static void execute(HttpResponse response) {
if (response.getViewName() == null || "".equals(response.getViewName())) {
return;
}
response.setResponseBody(
new ResponseBody(
viewThymeleafHtml(response.getModelAndView()),
ContentType.TEXT_HTML
)
);
}
private static String viewThymeleafHtml(ModelAndView modelAndView) {
Context context = new Context();
for (String key : modelAndView.getKeys()) {
context.setVariable(key, modelAndView.getValue(key));
}
return templateEngine.process(modelAndView.getViewName(), context);
}
}
보시는 것처럼 Model과 View를 함께 받아 템플릿을 통해 그리기 전 View를 찾고, 해당 View에 Model 값을 함께 넣어주어 웹페이지에서 필요한 데이터 들을 렌더링 할 수 있게 됩니다.
🍀 결론
이번 정적파일을 직접 처리해보며 Spring은 왜 요청을 처리 할 때
기본적으로 최우선순위를 RequestMappingHandlerMapping 처럼 사용자 로직을 먼저 찾아 보고,
없으면 뒤늦게 ResourceHttpRequestHandler와 같은 정적파일들을 찾아 보도록 했는지를 깨닫게 되었는데!
- 너무 많은 확장파일들이 존재하므로 파일타입 화이트리스트로 정적파일을 걸러내기엔 어려움이 있음.
- 따라서 Staitc 파일이 많은 경우 디렉토리의 파일이름과 요청 URL을 직접 비교해보며 찾는 것은 I/O 작업이 오래 걸림.
- 이는 비즈니스 로직 수행 전에 Static 파일을 먼저 찾기에는 비즈니스 수행 시간이 너무 지연 될 가능성이 높음.
이렇게 스프링이 정적 파일을 디스패처서블릿 이후에 응답하여 준 것은 단순히 보안 및 유연한 확장성과 커스터마이징 기능을 위해서만이 아니라 이를 서버의 성장과 정적 파일이 많아지게 될 경우의 성능 측면에서 고려를 했었다는 것도 진정성 있게 깨딛게 된 계기였던 것 같습니다.
그리고 Controller와 같이 추상화하며 코드로 구현을 해보는 과정에서도 이 과정에서 어떤 고충이 있었을지 몸소 깨닫게 되었고, 스프링은 이 문제들을 어떻게 접근하여 해결하였을까를 제가 한 고민과 덧붙여 비교해보다보니 더욱 스프링을 이해하기 쉬웠던 것 같아 재밌었습니다.😆
'스밑구 (스프링 밑단부터 구현하기)' 카테고리의 다른 글
[스밑구] Web Server와 Web Application Server 차이가 뭘까? (3) | 2023.11.20 |
---|---|
[스밑구] 스프링을 밑단부터 구현해보며 이해해보자! (0) | 2023.11.18 |