1부에서 만든 spring-boot2-velocity 모듈을 VelocityLayoutView를 사용하는 프로젝트에 적용했더니 정상작동 하지 않았다.
많은 웹사이트를 보면 위와 같이 레이아웃은 그대로 있고 페이지 이동에 따라 본문 영역의 내용만 바뀌는 걸 볼 수 있다. Velocity에서도 그러한 기능을 지원하며, Spring에서 쉽게 사용할 수 있게 구현된 클래스가 바로 VelocityLayoutView이다.
VelocityLayoutView를 사용하기 위해 VelocityLayoutViewResovler를 Bean으로 등록하려던 중 문제를 발견했다.
기존 프로젝트는 SiteMesh를 이용해서 layout을 적용하고 있어서
/popup/
, /admin/
과 같은 path 분기에 따라 적용시키는 layout이 여러개였는데, VelocityLayoutVeiwResolver는 path를 지정하는 기능이 없었다.
미리 만들어져 있으면 좋았겠지만, 없으니 PathBasedVelocityLayoutViewResolver 만들어서 해결하고자 했다.
public class PathBasedVelocityLayoutViewResolver extends VelocityLayoutViewResolver {
private static final String PATH_SEPARATOR = "/";
private final List<String> acceptPaths = new ArrayList<>();
private final List<String> ignorePaths = new ArrayList<>();
private final AntPathMatcher pathMatcher = new AntPathMatcher();
public void setAcceptPaths(String... acceptPaths) {
this.acceptPaths.addAll(Arrays.asList(acceptPaths));
}
public void setIgnorePaths(String... ignorePaths) {
this.ignorePaths.addAll(Arrays.asList(ignorePaths));
}
@Override
protected AbstractUrlBasedView buildView(String viewName) throws Exception {
String viewNameStartWithSeparator = makeViewNameStartWithSeparator(viewName);
boolean isAcceptPath = acceptPaths.stream()
.anyMatch(path -> pathMatcher.match(path, viewNameStartWithSeparator));
boolean isIgnorePath = ignorePaths.stream()
.anyMatch(path -> pathMatcher.match(path, viewNameStartWithSeparator));
if(isAcceptPath && !isIgnorePath) {
return super.buildView(viewNameStartWithSeparator);
}
return new NonExistentView();
}
private String makeViewNameStartWithSeparator(String viewName) {
return viewName.startsWith(PATH_SEPARATOR) ? viewName : PATH_SEPARATOR + viewName;
}
private static class NonExistentView extends AbstractUrlBasedView {
@Override
protected boolean isUrlRequired() {
return false;
}
@Override
public boolean checkResource(Locale locale) throws Exception {
return false;
}
@Override
protected void renderMergedOutputModel(Map<String, Object> model,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
// 호출되지 않을 영역
}
}
}
Bean을 등록 할 때 acceptPath와 ignorePath를 입력받은 뒤 찾는 view의 path에 acceptPath에 등록된 path가 포함되어 있을 때만 해당 PathBasedVelocityLayoutViewResolver에서 처리하도록 구현했다.
이로써 acceptPath로 각각 /popup/**
, /admin/**
을 갖는 PathBasedVelocityLayoutViewResolver를 등록하면 path에 따라 다른 레이아웃이 적용될 수 있게 되었다.
path를 포함하지 않을 때 null이 아니라 NonExistentView를 반환하고 있는데, 이는 VelocityLayoutViewResolver가 UrlBasedViewResolver를 상속했기 때문이다.
UrlBasedViewResolver는 loadView 메서드의 구조상 null인 view를 반환받을 때는 Exception이 일어나게 되어 다음 ViewResolver에서 View를 찾을 수 없게 된다.(참고 : UrlBasedViewResolver.loadView(String viewName, Locale locale))
PathBasedVelocityLayoutViewResolver를 만들어서 viewResovler를 등록 후 사용해 보니
여전히 작동하지 않았다. 🤬
로그를 보니 VelocityLayoutViewResolver에서 toolbox.xml을 찾지 못해서 생기는 문제였다.
구글님의 힘을 빌어 검색을 해보니... Known Issue였고, Spring Boot는 VelocityLayoutViewResolver를 지원하지 않는다는 답변을 찾을 수 있었다.
VelocityLayoutViewResolver 내부에서 사용하는 VelocityToolboxView에서 Spring Boot 환경인 경우 class path resource인 toolbox.xml을 찾지 못해서 생기는 문제였고, Spring Boot로 포팅 후 class path resource를 못찾는건 다행히 익숙한 이슈였다(참고 : classpath-resource-not-found-when-running-as-jar)
이 문제를 해결하기 위해 spring 팀에서 EmbeddedVelocityToolboxView, EmbeddedVelocityViewResolver는 만들어 두었는데 EmbeddedVelocityLayoutView와 EmbeddedVelocityLayoutViewResolver는 만들어 두지 않았던 것이다.
만드는 김에 함께 만들면 될텐데 왜 안만들었는지는 모르겠지만(아마 이슈 진행 중 velocity 지원을 종료하는게 결정 되었을 것 같다.) 목마른 자가 우물을 파야하니 EmbeddedVelocityLayoutView와 EmbeddedVelocityLayoutViewResolver도 함께 구현하기로 했다.
public class EmbeddedVelocityLayoutView extends EmbeddedVelocityToolboxView {
public static final String DEFAULT_LAYOUT_URL = "layout.vm";
public static final String DEFAULT_LAYOUT_KEY = "layout";
public static final String DEFAULT_SCREEN_CONTENT_KEY = "screen_content";
private String layoutUrl = "layout.vm";
private String layoutKey = "layout";
private String screenContentKey = "screen_content";
public EmbeddedVelocityLayoutView() {
}
public void setLayoutUrl(String layoutUrl) {
this.layoutUrl = layoutUrl;
}
public void setLayoutKey(String layoutKey) {
this.layoutKey = layoutKey;
}
public void setScreenContentKey(String screenContentKey) {
this.screenContentKey = screenContentKey;
}
public boolean checkResource(Locale locale) throws Exception {
if (!super.checkResource(locale)) {
return false;
} else {
try {
this.getTemplate(this.layoutUrl);
return true;
} catch (ResourceNotFoundException var3) {
throw new NestedIOException("Cannot find Velocity template for URL [" + this.layoutUrl + "]: Did you specify the correct resource loader path?", var3);
} catch (Exception var4) {
throw new NestedIOException("Could not load Velocity template for URL [" + this.layoutUrl + "]", var4);
}
}
}
protected void doRender(Context context, HttpServletResponse response) throws Exception {
this.renderScreenContent(context);
String layoutUrlToUse = (String)context.get(this.layoutKey);
if (layoutUrlToUse != null) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Screen content template has requested layout [" + layoutUrlToUse + "]");
}
} else {
layoutUrlToUse = this.layoutUrl;
}
this.mergeTemplate(this.getTemplate(layoutUrlToUse), context, response);
}
private void renderScreenContent(Context velocityContext) throws Exception {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Rendering screen content template [" + this.getUrl() + "]");
}
StringWriter sw = new StringWriter();
Template screenContentTemplate = this.getTemplate(this.getUrl());
screenContentTemplate.merge(velocityContext, sw);
velocityContext.put(this.screenContentKey, sw.toString());
}
}
기존 VelocityLayoutView가 VelocityToolboxView를 상속했던것에 반해
EmbeddedVelocityLayoutView는 EmbeddedVelocityToolboxView를 상속하도록 했다.
나머지 내부 메서드들은 VelocityLayoutView의 내용을 그대로 사용했다.
public class EmbeddedVelocityLayoutViewResolver extends VelocityViewResolver {
private String layoutUrl;
private String layoutKey;
private String screenContentKey;
public EmbeddedVelocityLayoutViewResolver() {
}
protected Class<?> requiredViewClass() {
return EmbeddedVelocityLayoutView.class;
}
public void setLayoutUrl(String layoutUrl) {
this.layoutUrl = layoutUrl;
}
public void setLayoutKey(String layoutKey) {
this.layoutKey = layoutKey;
}
public void setScreenContentKey(String screenContentKey) {
this.screenContentKey = screenContentKey;
}
protected AbstractUrlBasedView buildView(String viewName) throws Exception {
EmbeddedVelocityLayoutView view = (EmbeddedVelocityLayoutView)super.buildView(viewName);
if (this.layoutUrl != null) {
view.setLayoutUrl(this.layoutUrl);
}
if (this.layoutKey != null) {
view.setLayoutKey(this.layoutKey);
}
if (this.screenContentKey != null) {
view.setScreenContentKey(this.screenContentKey);
}
return view;
}
}
기존 VelocityLayoutViewResolver는 buildView 메서드 안에서 VelocityLayoutView를 사용했으나, EmbeddedVelocityLayoutViewResolver는 EmbeddedVelocityLayoutView를 사용하도록 했다. 나머지 내부 메서드들은 VelocityLayoutViewResolver의 내용을 그대로 사용했다.
마지막으로 PathBasedVelocityLayoutViewResolver가 기존 VelocityLayoutViewResolver를 상속하는 대신 EmbeddedVelocityLayoutViewResolver를 상속하도록 수정했다.
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
private final VelocityProperties velocityProperties;
public WebConfiguration(VelocityProperties velocityProperties) {
this.velocityProperties = velocityProperties;
}
@Bean
public EmbeddedVelocityLayoutViewResolver firstLayoutVelocityViewResolver() {
PathBasedVelocityLayoutViewResolver velocityLayoutViewResolver = new PathBasedVelocityLayoutViewResolver();
velocityLayoutViewResolver.setOrder(0);
velocityLayoutViewResolver.setAcceptPaths("/first/**", "/third/**");
velocityLayoutViewResolver.setLayoutKey("firstLayout");
velocityLayoutViewResolver.setScreenContentKey("body");
velocityLayoutViewResolver.setLayoutUrl("/firstLayout.vm");
this.velocityProperties.applyToViewResolver(velocityLayoutViewResolver);
return velocityLayoutViewResolver;
}
@Bean
public EmbeddedVelocityLayoutViewResolver secondLayoutVelocityViewResolver() {
PathBasedVelocityLayoutViewResolver velocityLayoutViewResolver = new PathBasedVelocityLayoutViewResolver();
velocityLayoutViewResolver.setOrder(1);
velocityLayoutViewResolver.setAcceptPaths("/second/**");
velocityLayoutViewResolver.setLayoutKey("secondLayout");
velocityLayoutViewResolver.setScreenContentKey("body");
velocityLayoutViewResolver.setLayoutUrl("/secondLayout.vm");
this.velocityProperties.applyToViewResolver(velocityLayoutViewResolver);
return velocityLayoutViewResolver;
}
@Bean
public EmbeddedVelocityViewResolver embeddedVelocityViewResolver() {
EmbeddedVelocityViewResolver embeddedVelocityViewResolver = new EmbeddedVelocityViewResolver();
this.velocityProperties.applyToViewResolver(embeddedVelocityViewResolver);
embeddedVelocityViewResolver.setOrder(2);
return embeddedVelocityViewResolver;
}
}
EmbeddedVelocityLayoutViewResolver를 Bean으로 등록해준다.
firstLayoutVelocityViewResolver는 /first/**
경로의 viewName을 받으면 firstLayout.vm
을 레이아웃으로 씌워줄 것이고,
secondLayoutVelocityViewResolver는 /second/**
경로의 viewName을 받으면 secondLayout.vm
을 레이아웃으로 씌워줄 것이다.
/first/**
도, /second/**
도 아닌 경로의 viewName일 때는 EmbeddedVelocityViewResolver를 이용해서 레이아웃이 씌워지지 않는 velocity view를 반환하게 설정해 두었다.
<!-- firstLayout.vm -->
<h1> firstLayout header</h1>
<div class="well">
${body}
</div>
<h1> firstLayout footer</h1>
<!-- /first/firstContent.vm -->
This is FirstContent
// layout을 씌우지 않는 view와 씌우는 view 반환
@GetMapping("/test")
public ModelAndView test() {
List<String> testList = new ArrayList<>();
testList.add("test1");
testList.add("test2");
testList.add("test3");
testList.add("test4");
ModelAndView modelAndView = new ModelAndView("main");
modelAndView.addObject("tests", testList);
return modelAndView;
}
@GetMapping("/first/content")
public ModelAndView layoutTest() {
ModelAndView modelAndView = new ModelAndView("/first/firstContent");
return modelAndView;
}
/first/content
를 호출한 결과 layout 안에 content가 입혀져서 출력됨을 확인할 수 있었다.
/test
를 호출한 결과 layout 없이 content만 출력됨을 확인할 수 있었다.
의존성분리라는 프로그래밍의 원칙이 코드 짤 때 뿐만 아니라 덩어리가 큰 업무를 쪼갤도 유용하다는걸 깨달을 수 있어서 좋았다.
spring-boot2-velocity를 만들어 둔 덕에 계기가 된 서비스 뿐만 아니라 다른 서비스들도 좀 더 수월하게 Spring Boot 2.3 이상의 버전으로 옮겨갈 수 있게 되어 큰 보람을 느낄 수 있었다.