본문 바로가기

Java/자바 성능 개선

11. JSP와 Servlet, Spring에서 발생할 수 있는 여러 문제점들

💡 본 게시글은 이상민 저자의 '자바 성능 튜닝 이야기' 교재를 공부하고, 이에 대해 정리한 내용입니다.

들어가며

  최근 개발을 공부하면서 간단한 프로젝트를 진행하던 중, 제가 만든 웹사이트가 느리게 동작하는 것을 발견했습니다. 그래서 제 웹사이트가 느리게 동작하는 원인이 무엇인지 알아보고자 했습니다.

 

 그 과정에서 병목 현상이 발생하는 주요 부분이 UI 부분비즈니스 로직 부분이라는 것을 알게 되었습니다. 그리고 그 중에서도, 서버에서 화면을 만들어 주는 UI 부분에서 문제가 많이 발생한다는 것을 깨달았습니다. 이를 해결하기 위해선, 화면을 만드는 자바스크립트가 사용자의 컴퓨터에서 잘 동작하도록 만드는 것이 중요하다는 것을 알게 되었습니다.

 

 또한, 서버에서 화면을 만들어 주는 부분은 주로 JSP나 서블릿 같은 기술을 사용하는데, 이 과정에서 여러 가지 문제가 생길 수 있다는 것을 배웠습니다. 이런 문제를 이해하고 해결하면 프로젝트의 성능을 크게 향상시킬 수 있을 것이라는 생각이 들었습니다. 그래서 이제 JSP, 서블릿, 그리고 Spring에서 생기는 문제를 더 자세히 알아보려고 합니다.

 

 해당 교재에서는 자바 기반의 시스템에서 WAS에서 병목 현상이 발생할 수 있는 부분을 세밀하게 나누면, UI 부분과 비즈니스 로직 부분으로 나눌 수 있다고 설명하고 있습니다. 여기서 UI 부분이란 서버에서 구성되는 UI를 말하며, 자바스크립트로 구성된 UI는 사용자의 컴퓨터에서 실행됩니다. 또한, 자바 기반의 서버단 UI를 구성하는 대부분의 기술은 JSP와 서블릿을 확장하는 기술이라고 설명하고 있습니다.


1) JSP와 Servlet의 기본적인 동작 원리는 꼭 알아야 한다.

JSP와 Servlet의 동작 원리를 이해하는 것은 매우 중요합니다.

 

 일반적으로 웹 화면을 처리하는 JSP와 같은 부분에서는 많은 시간이 소요되지 않습니다. JSP는 처음 호출될 때만 시간이 소요되며, 그 이후에는 이미 컴파일된 서블릿 클래스가 수행되기 때문입니다.

 

※ JSP의 라이프 사이클은 다음과 같은 단계를 거칩니다.

 1) JSP URL 호출

 2) 페이지 번역

 3) JSP 페이지 컴파일

 4) 클래스 로드

 5) 인스턴스 생성

 6) jspInit 메서드 호출

 7) jspService 메서드 호출

 8) jspDestroy 메서드 호출

 

 이 중 이미 컴파일된 JSP 페이지가 변경되지 않았다면, 시간이 많이 걸리는 2~4 단계는 생략됩니다.

일부 서버에서는 서버가 시작될 때 미리 컴파일을 수행하는 Pre-compile 옵션을 제공합니다. 이를 활용하면, 서버에 최신 버전을 반영한 후 처음 호출될 때의 응답 시간을 줄일 수 있습니다.

 

하지만 이 옵션을 개발 중에 활성화하면, 서버 시작 시마다 컴파일이 수행되므로 시간이 오래 걸릴 수 있습니다. 따라서 이 옵션은 상황에 따라 적절하게 사용해야 합니다.

 

  Servlet의 라이프 사이클을 살펴보겠습니다.

=> WAS의 JVM이 시작한 후,

1) Servlet 객체가 자동으로 생성 및 초기화되거나,

2) 사용자가 해당 Servlet을 처음 호출했을 때 생성 및 초기화됩니다.

 그 후에는 '사용 가능' 상태로 대기하며, 예외 발생 시 '사용 불가능' 상태로 전환되다가 다시 '사용 가능' 상태로 돌아옵니다. 서블릿이 필요 없어지면 '파기' 상태로 넘어가서 JVM에서 '제거'됩니다.

 

 그러나 중요한 것은, Servlet은 JVM 내에서 여러 객체로 생성되지 않는다는 점입니다. 즉, WAS가 시작하고 '사용 가능' 상태가 된 후에는 대부분의 서블릿이 JVM에서 계속 존재하며, 여러 스레드에서 해당 서블릿의 service() 메서드를 공유하여 호출합니다.

 

 서블릿 클래스의 service() 메서드에서 멤버 변수(인스턴스 변수)를 사용하면 어떤 문제가 발생할 수 있는지 예를 들어 설명하겠습니다. 아래의 코드를 참고해주세요.

package com.perf.servlet;

import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class DontUserLikeThisServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;
    String successFlag = "N";
    public DontUserLikeThisServlet() {
        super();
    }
  
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        successFlag = request.getParameter("successFlag");
    }
}

 

 위의 코드에서 successFlag는 멤버 변수로 선언되어 있습니다. 이 successFlag 값은 여러 스레드에서 접근하며 값이 계속 바뀔 수 있습니다. 이렇게 여러 스레드에서 동시에 접근하면 데이터가 꼬여 원치 않는 값이 출력될 수 있습니다. 이는 static 변수를 사용했을 때와 거의 동일한 결과를 초래합니다.

 

 따라서, service() 메서드를 구현할 때는 멤버 변수나 static한 클래스 변수를 선언하여 지속적으로 변경하는 작업을 피하는 것이 좋습니다. 이렇게 하면 데이터가 꼬이는 문제를 방지할 수 있습니다.


2) 적절한 include 사용하기

 JSP에서는 include 기능을 사용해 여러 JSP 파일을 하나의 JSP로 결합할 수 있습니다.

 이때 사용할 수 있는 방법은 '정적 include'와 '동적 include' 두 가지입니다.

 

  • 정적 include는 JSP의 라이프 사이클 중 번역 및 컴파일 단계에서 필요한 JSP를 메인 JSP의 자바 소스와 클래스에 포함시키는 방식입니다. 이 방식을 사용하려면 `<%@ include file="관련 URL"%>` 구문을 사용하면 됩니다.
  • 동적 include는 페이지 호출 시마다 지정된 페이지를 불러와 수행하는 방식입니다. 이 방식을 사용하려면 `<jsp:include page="realtiveURL"/>` 구문을 사용하면 됩니다.


 이 두 방법 중 어느 방식이 더 빠를까요? 결과적으로 정적 include 방식이 동적 include 방식보다 응답 속도가 훨씬 빠릅니다. 실제로 비교해 보면 동적 방식이 정적 방식보다 약 30배 더 느립니다.

 

 따라서, 빠른 성능을 원한다면 정적 include 방식을 사용하는 것이 좋습니다. 하지만 모든 화면을 정적 방식으로 구성하면 예상치 못한 오류가 발생할 수 있습니다.

 

 예를 들어, 정적 include 방식을 사용하면 메인 JSP에 포함된 JSP가 생기는데, 이때 동일한 이름의 변수가 존재하면 큰 오류가 발생할 수 있습니다. 그러므로 상황에 따라 적절한 include 방식을 선택하여 사용하는 것이 중요합니다.


3) 자바 빈즈(Java Beans), 잘 사용하면 약 / 못 사용하면 독

자바 빈즈(Java Beans)는 UI와 서버 사이에서 데이터를 처리하는 컴포넌트입니다. 하지만 사용방법에 따라 성능에 영향을 미칠 수 있습니다.

 

자바 빈즈를 사용하면, JSP 처리 시간이 증가할 수 있습니다. 아래의 예시를 참고해주세요.

<jsp:useBean id="list" scope="request" class="java.util.ArrayList" type="java.util.List" />
<jsp:useBean id="count" scope="request" class="java.lang.String" />
<jsp:useBean id="pageNo" scope="request" class="java.lang.String" />
<jsp:useBean id="pageSize" scope="request" class="java.lang.String" />
...
//약 20개의 useBean 태그

 

 위 코드는 자바 빈즈를 활용한 예시로, 전체 처리 시간이 97ms이며, 그 중 JSP 처리 시간이 57ms입니다. 이 중 자바 빈즈 처리에 47ms가 소요되므로, 전체 응답 시간의 약 48%가 이 부분에 사용됩니다.

 

 이 시간을 줄이기 위해 TO(Transfer Object) 패턴을 사용하는 것이 효과적입니다. 하나의 TO 클래스를 생성하고, 위 예시에서 사용된 각 문자열, HashMap, List를 클래스의 변수로 지정하여 사용하면, 전체 응답 시간의 약 48%를 줄일 수 있습니다.

 

 따라서, 자바 빈즈를 한두 개 사용하는 것은 문제가 되지 않지만, 10~20개 가량을 사용하면 성능에 영향을 줄 수 있습니다. 이런 경우, TO 패턴을 활용하는 것이 좋습니다.


4) 태그 라이브러리도 잘 사용해야 한다.

 태그 라이브러리는 JSP에서 반복적으로 사용되는 코드를 클래스로 만들어 HTML 태그처럼 사용할 수 있게 하는 라이브러리입니다. 현재 대다수의 프레임워크에서 다양한 태그 라이브러리를 제공하고 있습니다.

 

 태그 라이브러리는 XML 기반의 tld 파일과 태그 클래스로 구성되어 있으며, 사용하려면 web.xml 파일에 tld의 URL과 파일 위치를 정의해야 합니다.

<web-app>
<tablib>
<taglib-uri>/tagLibURL/</taglib-uri>
<taglib-location>
/WEB-INF/tlds/tagLib.tld
</taglib-location>
</tablib>
</web-app>

tld 파일은 다음과 같은 형식으로 정의되어 있습니다.

<?xml version="1.0" encoding="ISO-8859-1"?>
<taglib 
        xmlns="http://java.sun.com/xml/ns/j2ee"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    	xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-jsptaglibrary_2_1.xsd"
    	version="2.1">
<tlibversion>1.0</tlibversion>
<jspversion>1.1</jspversion>
<shortname>tagLibSample</shortname>
<uri/>
<tag>
<name>tagLibSample</name>
<tagclass>com.perf.jsp.TagLibSample</tagclass>
<bodycontent>JSP</bodycontent>
</tag>
</taglib>

 

 이 중 'tag' 태그 내부에서 태그 라이브러리의 이름과 클래스를 지정하며, 'bodycontent' 태그내에서는 태그 내부에 허용되는 내용의 종류를 지정합니다.

 

태그 라이브러리를 사용하려면 JSP의 상단에 다음과 같이 지정하면 됩니다.

<%@ taglib uri="/tagLibURI" prefix="myPreFix" />

 

이렇게 지정한 후에는 다음과 같이 JSP 태그를 사용할 수 있습니다.

<myPreFix:tagLibSample>
<%=contents%>
</myPreFix:tagLibSample>

 

태그 라이브러리를 사용하면서 성능상 문제가 발생하는 경우는 두 가지입니다. 첫째, 태그 라이브러리 클래스를 잘못 작성한 경우, 둘째, 태그 라이브러리 클래스로 많은 양의 데이터를 전송하는 경우입니다.

 

 실제 프로젝트에서 100~500 건의 데이터를 처리할 때, WAS와 DB 사이의 소요 시간을 비교한 결과, DB에서 소요되는 시간이 훨씬 많았습니다. 하지만 검색 가능한 목록에 제한이 없어 한 번에 4,000건 이상의 데이터를 조회하는 경우, WAS와 DB에서의 응답 시간이 동일하게 나타났습니다.


 태그 라이브러리는 태그 사이에 있는 데이터를 전달해야 하는데, 이 데이터는 대부분 문자열 타입입니다. 따라서 데이터가 많을수록 처리해야 하는 내용이 많아져 태그 라이브러리 클래스에서 처리하는 시간이 증가합니다. 따라서 대용량의 데이터를 처리할 경우 태그 라이브러리의 사용을 자제하는 것이 좋습니
다.


5) 스프링 프레임워크 기본 개념

 스프링 프레임워크는 자바 기반 프로젝트를 진행할 때 국내에서 많이 사용되는 도구입니다. 웹 프레임워크로 오해받기도 하지만, 실제로는 데스크톱, 웹, 간단한 애플리케이션부터 엔터프라이즈 애플리케이션까지 범용적으로 사용되는 애플리케이션 프레임워크입니다.

 

 스프링의 주요 특징 중 하나는 복잡한 애플리케이션이라도 POJO(Plain Old Java Object)를 통해 개발할 수 있다는 점입니다. 이로 인해 개발자들이 보다 쉽게 코드를 테스트하고 문제를 신속하게 발견할 수 있어 개발 생산성이 향상됩니다.

 

  스프링의 핵심 기술

 스프링의 핵심 기술은 Dependency Injection, Aspect Oriented Programming, 그리고 Portable Service Abstraction입니다. 

 

(1) Dependency Injection (DI, 의존성 주입)

 DI는 객체 간의 의존성 관계를 관리하는 기술입니다. 다른 객체와 협업하여 일을 처리하는 객체는 의존성을 최소화하는 것이 좋습니다. 이를 위해 객체가 필요로 하는 객체를 직접 생성하는 대신 외부로부터 주입받습니다. 스프링은 XML이나 어노테이션 등을 통해 의존성을 주입하는 방법을 제공합니다. 이는 생성자 주입, 세터 주입, 필드 주입 등 다양한 방법으로 이루어집니다.

 

(2) Aspect Oriented Programming (AOP, 관점 지향 프로그래밍)

  AOP는 핵심 비즈니스 로직과 분리하여 트랜잭션, 로깅, 보안 체크와 같은 코드를 작성할 수 있게 도와줍니다. 이를 통해 코드의 가독성을 높일 수 있습니다. 스프링은 AspectJ와의 연동을 지원하며, 스프링 AOP를 통해 더 간편하게 이를 활용할 수 있습니다.

 

(3) Portable Service Abstraction (PSA, 이식 가능한 서비스 추상화)

 PSA는 다양한 라이브러리나 프레임워크를 사용해 비슷한 기술을 구현할 때 추상화를 제공합니다. 이를 통해 사용하는 기술이 변경되어도 비즈니스 로직에 영향을 미치지 않도록 도와줍니다. 예를 들어, 특정 라이브러리를 사용하다가 성능 문제가 발견되어 다른 라이브러리를 사용해야 하는 상황이 발생한다면, 스프링의 의존성 주입 기능을 통해 사용할 객체를 간편하게 변경할 수 있습니다. 이는 개발 초기에 문제를 발견할 경우 큰 도움이 됩니다.


6) 스프링 프레임워크 사용 시 주의

  스프링 프레임워크를 사용하면서 다양한 문제가 발생할 수 있습니다. 이 중에서도 성능과 관련된 문제는 주로 '프록시(Proxy)'의 사용과 관련이 있습니다.

 

(1) 프록시 이슈

 스프링 프록시는 실행 시에 생성되며, 이는 개발 중 적은 요청에서 문제가 없어 보이지만, 실제 운영 상황에서 많은 요청이 발생하면 문제가 나타날 수 있습니다.

 

 트랜잭션을 다루는 @Transactional 어노테이션을 사용하거나, 개발자가 직접 AOP를 이용해 기능을 추가하는 경우 프록시를 사용하는데, 이러한 경우에서 성능 문제가 발생할 수 있습니다.

 

 특히, 개발자가 작성한 AOP 코드는 예상치 못한 성능 문제를 보일 가능성이 높으므로, 성능 테스트를 반드시 진행해야 합니다.

 

(2) 내부 메커니즘 캐시 사용 주의

  스프링의 내부 메커니즘에서 사용하는 캐시도 조심해서 사용해야 합니다.

 

 예를 들어, 스프링 MVC에서 문자열을 리턴하는 경우, 스프링은 해당 문자열에 해당하는 뷰 객체를 찾는 메커니즘을 사용합니다. 이 때, 동일한 문자열에 대한 뷰 객체를 매번 찾는 것이 아니라, 이미 찾아본 뷰 객체를 캐싱해두면 성능 향상을 기대할 수 있습니다.

 

 하지만, 매번 다른 문자열이 생성될 가능성이 높고, 많은 수의 캐시 값이 생성될 여지가 있는 상황에서는 메모리 누수가 발생할 수 있습니다. 이럴 때는 문자열을 반환하는 대신 뷰 객체 자체를 반환하는 방법을 사용하는 것이 좋습니다.

public class SampleController {
    @RequestMapping("/member/{id}")
    public View hello(@PathVariable int id) {
        return new RedirectView("/member/" + id);
    }
}

 

이러한 주의점들을 인지하고 스프링 프레임워크를 사용하면, 효율적이고 안정적인 애플리케이션 개발이 가능할 것입니다.