Freemarker 라이브러리 사용간 파라미터를 부분적용 하는 방법

겔로그·2024년 7월 30일
0

오늘은 Freemarker Template에 템플릿 파라미터를 잘 적용하는 방법에 대해 짧게 작성해보려고 합니다.

What is Apache FreeMarker™?를 보시면 Apache FreeMarker는 Email에서 사용하는 템플릿 엔진 중 하나입니다.

html에 "$"키워드를 활용하여 변수를 추가해줘 템플릿을 만들어 놓고 이와 매핑되는 java object를 파라미터로 넣어주면 하나의 html로 변환해주는 Java 라이브러리입니다.

문제상황

로직은 다음과 같습니다.

	private static final Configuration configuration = new Configuration(Configuration.VERSION_2_3_23);

    public static String process(final String content, final Map<String, Object> parameter) throws IOException, TemplateException {
        final Template template = new Template(DEFAULT_PATH, new StringReader(content), configuration);

        StringWriter writer = new StringWriter();
        template.process(parameter, writer);
        return writer.toString();

    }

content에 paramter 값을 적용하는 로직이며 writer에 파라미터가 적용된 html 데이터가 존재하고 이를 toString() 메서드를 통해 반환하는 메소드 입니다.

이 때 2개의 파라미터 인자를 템플릿에 추가하고 한개의 파라미터만 추가할 경우 어떻게 동작하는지 테스트 코드를 작성해 보았습니다.(테스트 코드는 junit4 기반으로 구성되어 있습니다. 레거시...)

    @Test(expected = TemplateException.class)
    public void test_process_MismatchWithParameter() throws IOException, TemplateException {
        // given
        final String target = "Name: ${name} Age: ${age}";
        // parameter에 "age" 값 없는 경우
        Map<String, Object> parameter = new HashMap<>();
        parameter.put("name", "MR. KIM");

        // when

        final String processedTarget = FreeMarkerProcessor.process(target, parameter);
        // then
        // throws freemarker.core.InvalidReferenceException

    }

에러 로그

Jul 31, 2024 12:14:54 AM freemarker.log._JULLoggerFactory$JULLogger error
심각: Error executing FreeMarker template
FreeMarker template error:
The following has evaluated to null or missing:
==> age  [in template "" at line 1, column 22]

----
Tip: If the failing expression is known to be legally refer to something that's sometimes null or missing, either specify a default value like myOptionalVar!myDefault, or use <#if myOptionalVar??>when-present<#else>when-missing</#if>. (These only cover the last step of the expression; to cover the whole expression, use parenthesis: (myOptionalVar.foo)!myDefault, (myOptionalVar.foo)??
----

----
FTL stack trace ("~" means nesting-related):
	- Failed at: ${age}  [in template "" at line 1, column 20]
----

Java stack trace (for programmers):
----
freemarker.core.InvalidReferenceException: [... Exception message was already printed; see it above ...]
	at freemarker.core.InvalidReferenceException.getInstance(InvalidReferenceException.java:131)
	at freemarker.core.EvalUtil.coerceModelToString(EvalUtil.java:355)
	at freemarker.core.Expression.evalAndCoerceToString(Expression.java:82)
	at freemarker.core.DollarVariable.accept(DollarVariable.java:41)
	at freemarker.core.Environment.visit(Environment.java:324)
	at freemarker.core.MixedContent.accept(MixedContent.java:54)
	at freemarker.core.Environment.visit(Environment.java:324)
	at freemarker.core.Environment.process(Environment.java:302)
	at freemarker.template.Template.process(Template.java:325)
	at com.toast.cloud.notification.template.FreeMarkerProcessor.process(FreeMarkerProcessor.java:33)
	at com.toast.cloud.notification.template.FreeMarkerProcessorTest.test_process_MismatchWithParameter(FreeMarkerProcessorTest.java:64)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:59)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:56)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.internal.runners.statements.ExpectException.evaluate(ExpectException.java:19)
	at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
	at org.junit.runners.BlockJUnit4ClassRunner$1.evaluate(BlockJUnit4ClassRunner.java:100)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:366)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:103)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:63)
	at org.junit.runners.ParentRunner$4.run(ParentRunner.java:331)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:79)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:329)
	at org.junit.runners.ParentRunner.access$100(ParentRunner.java:66)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:293)
	at org.junit.runners.ParentRunner$3.evaluate(ParentRunner.java:306)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:413)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:69)
	at com.intellij.rt.junit.IdeaTestRunner$Repeater$1.execute(IdeaTestRunner.java:38)
	at com.intellij.rt.execution.junit.TestsRepeater.repeat(TestsRepeater.java:11)
	at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:35)
	at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:232)
	at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:55)

제 목표는 파라미터가 일부만 들어와도 템플릿 엔진에서 에러없이 파라미터 적용을 하는 것을 목표로 디버깅을 진행하였습니다.

디버깅을 해보니...

로그 내용의 Environment 클래스의 visit 메소드를 보면 다음과 같은 로직으로 구성되어 있습니다.

    void visit(TemplateElement element)
    throws TemplateException, IOException
    {
        pushElement(element);
        try {
            element.accept(this);
        }
        catch (TemplateException te) {
            handleTemplateException(te);
        }
        finally {
            popElement();
        }
    }

element.accept가 정상적으로 동작하지 않을 경우, TemplateException이 발생하고,handleTemplateException() 메소드가 실행됩니다.

 private void handleTemplateException(TemplateException templateException)
        throws TemplateException
    {
        // Logic to prevent double-handling of the exception in
        // nested visit() calls.
        if(lastThrowable == templateException) {
            throw templateException;
        }
        lastThrowable = templateException;

        // Log the exception, if logTemplateExceptions isn't false. However, even if it's false, if we are inside
        // an #attempt block, it has to be logged, as it certainly won't bubble up to the caller of FreeMarker.
        if(LOG.isErrorEnabled() && (isInAttemptBlock() || getLogTemplateExceptions())) {
            LOG.error("Error executing FreeMarker template", templateException);
        }

        // Stop exception is not passed to the handler, but
        // explicitly rethrown.
        if(templateException instanceof StopException) {
            throw templateException;
        }

        // Finally, pass the exception to the handler
        getTemplateExceptionHandler().handleTemplateException(templateException, this, out);
    }
    
    public TemplateExceptionHandler getTemplateExceptionHandler() {
        return templateExceptionHandler != null
                ? templateExceptionHandler : parent.getTemplateExceptionHandler();
    }

여기까지 호출되니 이제 TemplateExceptionHandler가 궁금해졌고, 디버깅해보니 다음과 같이 설정되어 있었습니다.

그럼 이 TemplateExceptionHandler는 뭘까요?

TemplateExceptionHandler

TemplateExceptionHandler는 예외가 발생할 경우 어떻게 핸들링할 것이냐를 설정하는 클래스입니다.

TemplateExceptionHandler 문서 참고

총 타입은 4개입니다. 핸들러의 특징은 다음과 같습니다.

  • IGNORE_HANDLER: 에러시 무시
  • RETHROW_HANDLER: 에러시 예외 던짐
  • DEBUG_HANDLER: 에러시 예외 던짐 + stackTrace
  • HTML_DEBUG_HANDLER: 에러시 예외 던짐 + stackTrace

회사 요구사항은 "파라미터 적용간 예외가 발생하더라도 무시되게 처리해라"였기 때문에 DEBUG_HANDLER에서 IGNORE_HANDLER로만 변경해주면 문제가 해결되는 상황이었습니다.

그런데... 이걸 어디서 설정할까요?

    public Configurable(Configurable parent) {
        this.parent = parent;
        locale = null;
        numberFormat = null;
        classicCompatible = null;
        templateExceptionHandler = null;
        properties = new Properties(parent.properties);
        customAttributes = new HashMap();
    }
    
    public class Configuration extends Configurable implements Cloneable {
    ... 이하 생략
    }

Template 내부 인터페이스를 따라가다보면 다음과 같은 inner class를 맞이할 수 있는데요. 우리가 찾는 templateExceptionHandler가 초기화 되는 것을 볼 수 있습니다.

그럼 이 클래스는 어디서 정의할까요? 정답은 맨 위에 잠깐 나온 Configuration에서 정의할 수 있었습니다.

그럼 다시 로직으로 되돌아가보겠습니다.

    // IGNORE_HANDLER 적용시 에러 발생시 무시하고 진행
    public static String processUsingIgnoreHandler(final String target, final Map<String, Object> parameter) throws IOException, TemplateException {
        final Configuration previewConfiguration = new Configuration(Configuration.VERSION_2_3_23);
        previewConfiguration.setTemplateExceptionHandler(TemplateExceptionHandler.IGNORE_HANDLER);

        final Template template = new Template(DEFAULT_PATH, new StringReader(target), previewConfiguration);

        StringWriter writer = new StringWriter();
        template.process(parameter, writer);
        return writer.toString();
    }

다음과 같이 Configuration 설정시 TemplateExceptionHandler 설정을 IGNORE_HANDLER로 적용할 경우 일부 파라미터가 누락되더라도 템플릿 엔진이 정상적으로 html변환을 진행해 줍니다.

느낀점

사실 apache freemarker 라이브러리에 존재하는 클래스 목록만 열심히 봤어도 디버깅까지는 안해도 됐을 문제였습니다 ㅎㅎ..

어떻게 개선해야할지 막막해 제공하는 라이브러리를 커스텀하게 구현해야하나? 생각을 먼저 했었는데 앞으로는 먼저 어떤 클래스들이 어떤 정의로 구현되어 있는지 확인해보려는 노력을 해야겠다는 생각을 가지게 되었습니다.

다만, 디버깅 하는 과정에서 apache freemarker가 내부적으로 어떻게 동작하는지 이해하는 시간을 가졌기 때문에 쓸모없는 삽질까진 아니었던 것 같고 좋은 경험이었던 것 같습니다.

읽어주셔서 감사합니다.

profile
Gelog 나쁜 것만 드려요~

0개의 댓글