내가 겪은 메모리 누수 이야기 - 두 번째

블로그를 날려먹었다가 복구해서 이미지가 깨져있습니다 ㅠㅠ
(사내 위키에 썼었던 글을 옮깁니다.)

실험 결과로서 타임아웃을 줄이는 것이 메모리와 관계가 있다는 것은 알았지만 왜 그런지 궁금하여 조사해 본 결과를 정리해 보았습니다. (원래 스프링 시큐리티의 구조를 간단히 설명하는 글을 저도 공부하면서 써볼까 했는데 시간이 오래 걸려 미루었습니다...)

Filter Chain

스프링 시큐리티는 요청을 필터 체인으로 동작합니다. 세션을 사용 하는 필터는 SecurityContextPersistenceFilterSessionManagementFilter가 있는데 간략화해서 SecurityContextPersistenceFilter의 동작에 대해서만 기술하려고 합니다.

spring security filter chain

SecurityContextHolder

스프링 시큐리티에서 인증된 사용자의 정보를 저장하는 곳 입니다. 인증된 사용자는 Context에 정보가 채워지게 됩니다. ThreadLocal로 구현되어 있습니다.

spring security context holder

SecurityContextPersistenceFilter

이 필터는 필터 체인 중에 상위에 위치하여 SecurityContextRepository 인터페이스로 부터 얻는 정보로 SecurityContextHolder를 채워 줍니다. 코드를 보면 아래와 같은 순서로 수행됩니다.

  1. SecurityContextRepository에서 SecurityContext를 가지고 옴. 불러온 SecurityContext를 SecurityContextHolder에 설정.
  2. 체인 필터 수행.
  3. SecurityContextHolder 클리어.
  4. 저장되어 있던 SecurityContextHolder를 SecurityContextRepository에 저장.
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

    .
    .
    .

    HttpRequestResponseHolder holder = new HttpRequestResponseHolder(request,
            response);
    SecurityContext contextBeforeChainExecution = repo.loadContext(holder);

    try {
        SecurityContextHolder.setContext(contextBeforeChainExecution);

        chain.doFilter(holder.getRequest(), holder.getResponse());

    }
    finally {
        SecurityContext contextAfterChainExecution = SecurityContextHolder
                .getContext();
        // Crucial removal of SecurityContextHolder contents - do this before anything
        // else.
        SecurityContextHolder.clearContext();
        repo.saveContext(contextAfterChainExecution, holder.getRequest(),
                holder.getResponse());
        request.removeAttribute(FILTER_APPLIED);

        .
        .
        .
    }
}

기본적으로 SecurityContextRepository는 HttpSession을 사용하는 HttpSessionSecurityContextRepository으로 설정되어 Session에 저장하게 됩니다. 요약하자면, 인증 필터들이 수행되기 전에 인증 정보를 세션에서 꺼내서 설정 해주고 필터가 수행되면 다시 세션에 저장해 줍니다. 그렇다면… 세션에 설정한다는 것이 어떤 의미를 가지고 있을까요?

Session

나무위키에는 아래와 같이 설명하고 있습니다.

웹 서버에서 임시로 클라이언트의 데이터를 갈무리하는 것을 뜻한다. 쿠키와 역할이 비슷한데, 쿠키는 클라이언트 측에 데이터를 갈무리하는 반면에 세션은 서버 측에 데이터를 갈무리해 놓는다는 차이점이 있다. 주로 로그인, 온라인 쇼핑몰의 장바구니 등에 쓰인다.

클라이언트의 정보를 서버에 저장하는 것 정도가 되겠습니다. 세션은 파일, DB, In-Memory, Redis 등등에 저장할 수 있는데 우리가 쓰는 Undertow에서는 InMemorySessionManager를 기본으로 사용하여 In-Memory에 세션을 저장합니다. 다시 위의 요약으로 돌아가서 세션에서 꺼내 인증 상태로 만드다는 것은 클라이언트와 서버에서 사용하는 HTTP 프로토콜은 무상태성(Stateless)을 가지고 있는데 동일한 세션이고 이미 인증되었다면 인증된 상태로 만들어 주는 것이라고 할 수 있겠습니다.

그래서?

우리 플랫폼에서 인증은 펠릭스가 만들어 준 람다에서 처리하고 결과를 모든 클라이언트 요청은 헤더에 담아서 보내줍니다. 그렇기 때문에 지금에야 드는 생각이지만, 요청 끼리 인증 결과를 공유하지 않아도 될 듯 합니다. 세션에서 인증된 결과를 불러와 SecurityContextPersistenceFilter뒤의 인증 필터들(UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter)에서의 인증 시도를 건너뛴다고 하더라도 인증 필터에서 오래 소요되는 작업을 하는 것이 아니기 때문에 큰 영향은 없을 듯 합니다. (타암아웃을 설정했을 때와 하지 않았을 때의 TPS를 측정해 보면 명확할 것 같습니다.)

찾아보니 Spring Security Config에는 SessionManagement에서 세션 관련 설정을 할 수 있도록 되어 있습니다. SessionManagement에는 설정 필드들 중에는 최대로 생성할 수 있는 세션 수를 설정하는 maximumSessions와 (기본값은 제한이 없습니다.) 세션 생성방식을 설정하는 sessionCreationPolicy가 있습니다. sessionCreationPolicy에는 아래와 같은 값을 설정할 수 있습니다.

  • ALWAYS - 항상 HttpSession을 생성합니다.
  • NEVER - HttpSession이 있을 때만 사용하고 만들지는 않습니다.
  • IF_REQUIRED - 필요할 때만 HttpSession을 만듭니다. (기본 값)
  • STATELESS - HttpSession을 만들지 않고 사용하지도 않습니다.

HttpSession을 만들고 사용하지 않기위해 STATELESS로 설정하려고 합니다. 값이 STATELESS일 때는 SecurityContextRepositoryNullSecurityContextRepository가 선택됩니다. NullSecurityContextRepository는 아래와 같이 SecurityContextPersistenceFilter의 필터에서 아무런 일도 하지 않습니다.

public final class NullSecurityContextRepository implements SecurityContextRepository {

	public boolean containsContext(HttpServletRequest request) {
		return false;
	}

	public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
		return SecurityContextHolder.createEmptyContext();
	}

	public void saveContext(SecurityContext context, HttpServletRequest request,
			HttpServletResponse response) {
	}

}

그럼 이제 테스트를 해봐야겠습니다.

테스트

커뮤니티 DEV를 대상으로 저번과 같이 댓글 조회하는 API를 호출하려고 합니다. Scouter 띄우고 nGinder로 부하를 줘 세션 타임아웃 기본 값(30m)으로 두었을 때와 적게 조절 (5m) 했을 때, 그리고 세션을 설정하지 않았을 때의 TPS와 Heap Memory 사용율을 확인해 보려고 합니다. nGinder에서 500명의 사용자로 10분간 계속 호출 하도록 합니다. JAVA_OPTS는 아래처럼 설정했습니다. CMS GC를 사용합니다.

java -javaagent:/home/webapp/scouter/agent.java/scouter.agent.jar
     -Dscouter.config=/home/webapp/scouter/agent.java/conf/scouter.conf
     -Dobj_name=community-dev
     -Dfile.encoding=UTF-8 -verbosegc
     -Xloggc:/var/log/gc.log
     -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:GCLogFileSize=10m -XX:NumberOfGCLogFiles=100
     -XX:+UseParNewGC -XX:+CMSParallelRemarkEnabled -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=75 -jar application.jar

세션 타임 아웃이 기본 값 (30분) 일 때

지난 번에 테스트 했던 상황입니다. Heap 메모리가 점차 차오르다가 6분정도가 지났을 때 TPS가 급감했습니다.

session timeout 30m tps

session timeout 30m heap

세션 타임 아웃을 5분으로 설정 했을 때

Heap 메모리가 차는 6분 전에 세션이 만료되기 때문에 TPS가 급감하는 현상이 나타나지 않았습니다.

session timeout 5m tps

session timeout 5m heap

세션을 생성하지 않았을 때

TPS는 큰 차이는 없었고 Heap 메모리가 요청에 따라 증가하는 현상이 없었습니다.

no session tps

no session heap

끝으로

  • SecurityContextHolder가 원인인줄 알았는데 Session에서 메모리를 많이 차지하는 현상으로 보입니다.
  • 인스턴스 사양을 변경하기 전에 세션 설정을 변경해야 겠습니다.
  • 그런데 실제로 Session을 사용하는 어플리케이션에서는 이런 현상을 어떻게 처리해야 할까요? 메모리를 많이 잡거나 타임아웃을 조절하는 수 밖에 없을까요?
  • 더 이상 메모리 문제는 없었으면 좋겠습니다.