백오피스 튜닝기 2

백오피스 튜닝기 2

개발일기

 

최근 회사에서 백오피스 리팩토링에 전력을 기울이고 있다.

(너무 느려서 -ㅅ-.. 여유도 많이 있어서..)

당장 눈에보이는 문제들을 모두 걷어내고 보니

APM에서 영문을 모르게 병목이 생기는 구간들이 발견됐다.

 

 

정말 단순한 로직이고 서버단에서 초스피드로 모든 처리가 끝났으나 HttpResponse가 안 날아가고 병목이 생기는 것이었다.

정말 HttpResponse 문제인지 확인해보기 위해 한 가지를 더 점검해봤다.

 

 

TTFB(Time to first byte)는 클라이언트가 서버에 요청(Request)을 보내고 응답(Response)을 받기까지 걸리는 시간을 말하는데, 서버 모니터링에서 병목이 생기는 부분과 딜레이가 아주 흡사했다.

 

발견한 수상한 점들을 정리하자면

 

  • 문제가 발생하는 요청을 처리할 때 서버의 CPU 사용량이 폭증하는 현상 발생
  • 서버의 HttpResponse 처리 시간 폭증
  • 클라이언트 TTFB 폭증
  • 위의 문제들로 인해 사용자 입장(우리 회사 직원들과 나….😭)에서 잦은 빈도로 약 2~4초의 로딩 발생

 

즉, 서버는 요청을 즉시 받아 모든 처리를 광속으로 끝냈으나, 모종의 이유로 클라이언트에 응답을 보내는데 많은 시간이 걸린다는 게 결론이었다.

문제가 있다는 걸 몰랐으면 모르되, 알고도 넘길 수는 없다.

 

이 문제를 해결하기 위해 하루 동안 모든 콜스택을 살펴보니 의심되는 조건이 세 가지 있었다.

 

  1. RestController가 아닌 Controller에서 DispatcherServletViewResolverModelAndView를 넘기고 받는 과정
  2. Spring Boot + JSP의 조합으로 인한 호환성 문제
  3. org.apache.catalina.webresources.JarWarResourceSet.getArchiveEntries()가 매우 자주 보임

 

백오피스는 레거시라 Spring + JSP를 사용하고 있었는데, 최근 Spring에서 Spring-Boot으로 마이그레이션했다.

오피셜에 따르면 Spring-Boot은 기본적으로 단일 실행파일(bootJar)로 빌드해 내장 톰캣을 사용하기 때문에 서블릿의 일종인 JSP와 궁합이 좋지 않아 JSP를 공식적으로 지원하지 않으며 가급적 ThymeleafFreemarker 등의 템플릿 엔진을 사용해 bootJar로 빌드한 후 내장 톰캣으로 구동하라고 권고하고 있다.

 

하지만 JSP를 당장에 템플릿 엔진으로 바꾸자니 공수가 너무 많이 들어 우선 Spring-Boot과 JSP조합을 사용하였고, 이를 Spring-Boot이 공식적으로 지원하지 않아 bootJar로 패키징 하는데 애로사항이 있어 war로 패키징하여 내장 톰캣을 사용하고 있었다.

 

 

아무튼 콜스택에서 의심스러운 org.apache.catalina.webresources.JarWarResourceSet.getArchiveEntries()에 대해 찾아보니 war 내부의 jar 파일들을 스캔하는 내장 톰캣의 일부 로직이었다.

 

@Override
protected Map<String,JarEntry> getArchiveEntries(boolean single) {
    synchronized (archiveLock) {
        if (archiveEntries == null) {
            JarFile warFile = null;
            InputStream jarFileIs = null;
            archiveEntries = new HashMap<>();
            boolean multiRelease = false;
            try {
                warFile = openJarFile();
                JarEntry jarFileInWar = warFile.getJarEntry(archivePath);
                jarFileIs = warFile.getInputStream(jarFileInWar);

                try (TomcatJarInputStream jarIs = new TomcatJarInputStream(jarFileIs)) {
                    JarEntry entry = jarIs.getNextJarEntry();
                    while (entry != null) {
                        archiveEntries.put(entry.getName(), entry);
                        entry = jarIs.getNextJarEntry();
                    }
                    Manifest m = jarIs.getManifest();
                    setManifest(m);
                    if (m != null && JreCompat.isJre9Available()) {
                        String value = m.getMainAttributes().getValue("Multi-Release");
                        if (value != null) {
                            multiRelease = Boolean.parseBoolean(value);
                        }
                    }
                        // Hack to work-around JarInputStream swallowing these
                        // entries. TomcatJarInputStream is used above which
                        // extends JarInputStream and the method that creates
                        // the entries over-ridden so we can a) tell if the
                        // entries are present and b) cache them so we can
                        // access them here.
                    entry = jarIs.getMetaInfEntry();
                    if (entry != null) {
                        archiveEntries.put(entry.getName(), entry);
                    }
                    entry = jarIs.getManifestEntry();
                    if (entry != null) {
                        archiveEntries.put(entry.getName(), entry);
                    }
                }
                if (multiRelease) {
                    processArchivesEntriesForMultiRelease();
                }
            } catch (IOException ioe) {
                // Should never happen
                archiveEntries = null;
                throw new IllegalStateException(ioe);
            } finally {
                if (warFile != null) {
                    closeJarFile();
                }
                if (jarFileIs != null) {
                    try {
                        jarFileIs.close();
                    } catch (IOException e) {
                        // Ignore
                    }
                }
            }
        }
        return archiveEntries;
    }
}

 

위의 모든 조건을 종합하고 상황을 보니 런타임에 JSP에 관련된 작업이 처리되기 시작하면 위의 로직이 호출되고 내장 톰캣은 war 내부의 jar를 풀스캔 때리기 시작하며 동시에 CPU 사용률이 폭증하는 현상이 발생한다는 가정이 나왔다.

그리고 위의 가정을 키워드로 다시 검색을 시작했다.

 

📜 Performance - Spring Boot - Server Response Time

 

📜 Provide fat jar aware implementations of Tomcat’s Resource and ResourceSet to speed up resource loading from executable wars

 

Spring-Boot repository issue를 살펴보니 비슷한 문제제기가 있었고, Spring-Boot의 대빵(?) 개발자인 Andy Wilkinson에 따르면 이 문제는 두 가지 정도의 해결방법이 있다고 하는 것 같았다.

또한 회피책이 존재하므로 우선순위가 높지 않다고 판단해 해당 문제는 Spring-Boot 2.x.x대에 해결할 계획이 없다고 밝혔다.

 

  1. JSP + WAR를 사용하지 말고 템플릿 엔진 + JAR를 사용할 것
  2. JSP + WAR를 사용하겠다면 내장 톰캣을 사용하지 말고 외장 톰캣을 사용 할 것

 

우선 이 정도까지 파악을 마쳤고, 당장에 템플릿 엔진으로 마이그레이션 할 수는 없는 상황이니 우선 외장 톰캣을 이용해 보기로 결정했다.

일단 문제는 문제고 퇴근시간 됐으면 퇴근은 해야지… (여유가 없는 것도 아니고 😙)

 


© 2022. All rights reserved.