Spring Boot 2.x에서 Velocity 사용하기 - 2부

Ted·2021년 7월 20일
0

JAVA

목록 보기
2/2

Spring Boot 2.x에서 Velocity 사용하기 - 1부

1부에서 만든 spring-boot2-velocity 모듈을 VelocityLayoutView를 사용하는 프로젝트에 적용했더니 정상작동 하지 않았다.

VelocityLayoutView ?

많은 웹사이트를 보면 위와 같이 레이아웃은 그대로 있고 페이지 이동에 따라 본문 영역의 내용만 바뀌는 걸 볼 수 있다. Velocity에서도 그러한 기능을 지원하며, Spring에서 쉽게 사용할 수 있게 구현된 클래스가 바로 VelocityLayoutView이다.

PathBasedVelocityLayoutViewResolver

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))

EmbeddedVelocityLayoutView

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를 상속하도록 수정했다.

EmbeddedVelocityLayoutViewResolver Bean 등록

@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를 반환하게 설정해 두었다.

Velocity File 및 Controller Handler Method 작성

<!-- 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 이상의 버전으로 옮겨갈 수 있게 되어 큰 보람을 느낄 수 있었다.

profile
Aiming for being a reliable developer

0개의 댓글