본문 바로가기

IT

싱글톤 유의해서 쓰자...(Feat Freemarker Configuration), Lock

728x90

리소스를 효율적으로 사용하겠다는 섣부른 판단에 객체를 스프링 빈으로 등록해 싱글톤으로 사용할 경우 필자와 같은 참사가 이루어질 수 있으니 이 글을 읽는다면 도움이 되기를 바란다. 물론 잘 알고 쓰면 싱글톤이 안티패턴이라는 불리는 것이 와닿지 않을 것이다.

 

기술스펙 (Springboot2.7, Freemarker2.3)

 

문제는 다음과 같았다.

DB에 Freemarker로 작성된 A라는 내용이 들어가야하는 상황인데 아주 간혈적으로, 데이터 상 거의 1/50000의 확률로 B라는 내용이 들어갔다.  비즈니스를 바탕으로 소스를 분석하고 문제가 될 만한 코드를 찾기는 쉽지 않았다. 1/50000로 생각하다보니 무엇보다 상황을 재현하는 것이 불가능한 것처럼 느껴졌다. 

 

비슷한 예제코드를 보자.

import freemarker.cache.StringTemplateLoader;
import freemarker.ext.beans.BeansWrapper;
import freemarker.template.Configuration;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.TemplateExceptionHandler;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.StringWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class CodeRenderer {

    private final Configuration config;
    private final StringTemplateLoader templateLoader;

    public CodeRenderer(Configuration configuration, StringTemplateLoader templateLoader) {
        this.config = configuration;
        this.templateLoader = templateLoader;
    }

    public String render(String templatePath, Map<String,Object> data) throws IOException, TemplateException {
        String source;
        try (InputStream is = ResourceReader.getResourceAsStream(templatePath);
            BufferedReader buffer = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
            source = buffer.lines().collect(Collectors.joining("\n"));
        }
        setFreeMarkerConfig(source)	
        try (Writer writer = new StringWriter()) {
            Template template = config.getTemplate("template");
            template.process(data, writer);
            return writer.toString();
        }
    }
    private void setFreeMarkerConfig(String source){
    	config.clearTemplateCache();
        templateLoader.putTemplate("template", source);
        config.setTemplateLoader(templateLoader);
        config.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
        config.setObjectWrapper(new BeansWrapper(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS));
        config.setWhitespaceStripping(true);
    }
}

단순히 템플릿 경로에 있는 freemarker파일 템플릿을 받아 데이터를 동적으로 바인딩해주고 해당 값을 반환하는 예제코드이다. 

 

혹시 예제코드에 이상한 점이 보이는가? 해당 포스팅의 제목이 도움이 되기를 바란다. 

 

문제는 생성자 주입으로 스프링 빈으로 등록된 freemarker의 Configuration 객체를 주입받았는데 이 설정 정보를 set 하는 과정이였다.  

private final Configuration config

***

private void setFreeMarkerConfig(String source){
    	config.clearTemplateCache();
        templateLoader.putTemplate("template", source);
        config.setTemplateLoader(templateLoader);
        config.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
        config.setObjectWrapper(new BeansWrapper(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS));
        config.setWhitespaceStripping(true);
}

코드만 봐서는 문제를 인식하지 못할 수 있다. config에 등록된 템플릿을 먼저 clear해주기 때문에 큰 문제가 될까 싶지만 멀티스레드 환경에서 싱글톤 객체를 매요청마다 저런 방식으로 설정 값을 바꿔주는 것은 굉장히 위험하다. 

 

두 요청이 동시에 들어와서 ThreadA, ThreadB가 해당 요청을 각각 처리한다고 가정해보자.

1. ThreadA는 템플릿 경로에 있는 값을 받아와 config 템플릿A를 등록한다. (config -> template A)

2. ThreadB가 0.01초 늦게 요청을 처리하였고 config에 템플릿 B를 등록한다. (config -> template B)

3. config는 스프링 컨테이너에 의해 싱글톤으로 관리되고 있다.

4. ThreadA는 config에 template을 받아온다.

Template template = config.getTemplate("template");

5. 이미 config는 ThreadB에 의해 templateA가 아닌 templateB를 반환하게 된다.

 

그럼 정말 ThreadA는 templateB를 받아오게 되는지 확인해보자.

Thread threadA = new Thread(() -> {
        try {
            // Thread A executing render method
            System.out.println("Thread A executing render method");
            this.render("templates/freemarker/singleton/test1.ftl", testDataA);
            System.out.println("Thread A completed" + ":" + Thread.currentThread().getName()+ ":" + config.getTemplate("template"));

        } catch (IOException | TemplateException e) {
            e.printStackTrace();
        }
    }, "ThreadA");

    // Creating thread B
Thread threadB = new Thread(() -> {
        try {
            // Thread B waiting for 30 seconds
            System.out.println("Thread B waiting for 5 seconds");
            Thread.sleep(5000);

            // Thread B executing render method after 30 seconds
            System.out.println("Thread B executing render method");
            this.render("templates/freemarker/singleton/test2.ftl", testDataA);
            System.out.println("Thread B completed" + ":" + Thread.currentThread().getName()+ ":" + config.getTemplate("template"));
        } catch (IOException | TemplateException | InterruptedException e) {
            e.printStackTrace();
        }
    }, "ThreadB");

// Starting the threads
threadA.start();
threadB.start();
}

threadA, threadB를 만들었고 B는 5초 이후에 실행되며 두 스레드 모두 render 함수를 실행시킨다.

threadA는 test1.ftl 경로이며 threadB는 test2.ftl 경로이다. ftl에 작성된 내용은 다음과 같다.

 

test1.ftl

threadA's template

test2.ftl

threadB's template

 

threadA가 threadB에 의해 영향을 받는지 config에서 template을 꺼내기 전에 sleep을 걸어보자.

setFreeMarkerConfig(source)

//6초 대기 
if(Thread.currentThread().getName().equals("ThreadA")) {
    try {
        System.out.println("Thread A waiting for 6 seconds..." + ": now " + Thread.currentThread().getName()+ ":" + config.getTemplate("template"));
        Thread.sleep(6000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

try (Writer writer = new StringWriter()) {
    Template template = config.getTemplate("template");

template config에 설정 정보를 저장한 이후 ThreadA는 6초라는 시간동안 중지된 이후 config에서 template을 받아온다.

 

실행 결과

ThreadA의 템플릿이 바뀜

 

실행 결과 threadA는 처음 실행될 때 threadA's template을 정상적으로 잘 받아오지만 6초가 잠든 이후에는 threadB's template을 받아온다.

 

https://docs.spring.io/spring-framework/reference/web/webmvc-view/mvc-freemarker.html

스프링 docs 예제에서도 Freemarker Configuration을 빈으로 등록해서 사용하는 코드를 볼 수 있다.  

 

이것이 싱글톤을 쓸 때 유의할 부분 중 한 가지인 것 같다. 공유되는 객체에 대한 상태를  변경함에 있어 수정 사항이 없는지에 대해 고민해볼 필요가 있다.

 

그렇다면 어떻게 해결하면 좋을까?

아무래도 가장 편한 방법은 매 요청마다 새로 만드는 것이다.

객체를 매번 new 해서 만들거나 clone() 함수를 활용하는 것이다.

* freemarker의 Configuration의 clone() 메서드는 Object의 clone() 메서드를 사용하고 있음

Configuration config = new Configuration(new Version("2.3.31"));

or

Configuration cloneConfig = (Configuration)config.clone();

 

https://freemarker.apache.org/docs/pgui_quickstart_createconfiguration.html

freemarker Configuration expensive

하지만 프리마커 공식문서에서는 Configuration 객체를 새로 만들면 캐시를 활용하지 못하는 cost에 대한 단점을 설명해주고 있다. 

 

또 다른 lock 을 거는 방법도 있는데 많이 쓰는 방식이 synchronized 블럭으로 감싸는데 필자는 ReentrantLock을 활용해봤다.

synchronized 블럭은 자동으로 lock이 잠기고 풀리기 때문에 편리하지만, ReentrantLock 클래스를 이용하면 다양한 고급기능을 사용할 수 있다.

  • Lock polling을 지원한다.
  • 코드가 단일 블록 형태를 넘어서는 경우 사용 가능 하다.
  • 타임 아웃을 지정할 수 있다.
  • Condition을 적용해서 대기 중인 쓰레드를 선별적으로 깨울 수 있다.
  • lock 획득을 위해 waiting pool에 있는 쓰레드에게 인터럽트를 걸 수 있다.

예제 코드

@Component
public class CodeRenderer {

	private static final ReentrantLock configLock = new ReentrantLock();

    public String render(String templatePath, Map<String,Object> data) throws IOException, TemplateException {
        String source;
        try (InputStream is = ResourceReader.getResourceAsStream(templatePath);
            BufferedReader buffer = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
            source = buffer.lines().collect(Collectors.joining("\n"));
        }
        
        **
        configLock.lock();
        **
        
        setFreeMarkerConfig(source)	
        try (Writer writer = new StringWriter()) {
            Template template = config.getTemplate("template");
            template.process(data, writer);
            return writer.toString();
        } finally {
        	**
            configLock.unlock();
            **
        }
    }
}

 

config를 설정하고 받아오는 과정에 lock() 을 사용했다. ( 사용 후에는 꼭 unlock()을 호출하여 락을 반환해주자)

 

threadA의 템플릿

 

대신 ThreadA가 처리할 때까지 lock()이 걸린다는 큰 단점이 있다. 

https://freemarker.apache.org/docs/pgui_misc_multithreading.html

프리마커 공식문서를 참고하자면 멀티스레드 상황에 대한 보장하지 못한다는 내용도 있으니 참고하면 좋을 듯 하다.

 

또한 서버가 여러대인경우는 동시 공유할 수 있는 락 객체를 활용하기 위해 레디스와 같은 인프라레벨에서의 서버를 사용하기도 한다.  

 

Freemarker Configuration를 만든 개발자 의도는 무엇일까? 

필자는 다음과 같이 싱글톤을 유지하면서 활용하는 방법에 대해 고민해보았고 다음과 같이 채택했다.

@Component
public class CodeRenderer {

    private final Configuration config;
    private final StringTemplateLoader templateLoader;

    public CodeRenderer(Configuration configuration, StringTemplateLoader templateLoader) {
        this.config = configuration;
        this.templateLoader = templateLoader;
        config.setTemplateLoader(templateLoader);
        config.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER);
        config.setObjectWrapper(new BeansWrapper(Configuration.DEFAULT_INCOMPATIBLE_IMPROVEMENTS));
        config.setWhitespaceStripping(true);
    }

    public String render(String templatePath, HashMap<String,Object> data) throws IOException, TemplateException {

        String source;
        try (InputStream is = ResourceReader.getResourceAsStream(templatePath);
            BufferedReader buffer = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
            source = buffer.lines().collect(Collectors.joining("\n"));

        }

        templateLoader.putTemplate(templatePath, source);

        try (Writer writer = new StringWriter()) {
            Template template = config.getTemplate(templatePath);
            template.process(data, writer);
            return writer.toString();
        }
    }
}

 

생성자에 두 빈에 대한 설정 값을 초기화하였고 굳이 템플릿을 clear 할 필요를 못느꼈다. ftl 파일 갯수는 제한되어있고 cache를 잘 활용하면 오히려 좋지 않을까 생각이 들었고 어차피 template을 받아와 데이터를 바인딩하는 과정은 매 스레드마다 새로운 Template 객체를 생성하기에 동시성에 문제가 되지 않았다.

 

싱글톤의 경우에는 이 객체를 싱글톤으로 쓰는게 맞을지 고민해보는 것도 좋을 것이며 해당 기능을 개발한 개발자의 의도를 파악하는 것도 좋은 경험이 될 것 같다.

'IT' 카테고리의 다른 글

gitlab-ci.yml release version tag ( multi pomfile )  (0) 2024.03.22
gRPC란? RPC, gRPC, REST API  (0) 2024.03.05
@Async와 ThreadPoolTaskExecutor  (0) 2024.02.19
[Java] CompletableFuture의 이해와 활용  (1) 2024.02.19
Gitlab-Runner .m2 Caching  (0) 2024.02.16