ABP 애플리케이션 페이지 로딩 속도 개선

최근 프로젝트에서 ABP(ASP.NET Boilerplate Project) 아키텍처를 적극적으로 활용하고 있습니다. ABP는 모범 사례와 최신 기술을 기반으로 현대적인 웹 애플리케이션을 개발하기 위한 프레임워크이자 프로젝트 템플릿입니다. 이는 도메인 주도 설계(DDD) 원칙에 따라 계층화된 구조를 구현하며, 일반적으로 다음 네 가지 계층으로 구성됩니다:

  • 프레젠테이션 계층 (Presentation): 사용자 인터페이스를 제공하고 사용자 상호작용을 처리합니다.
  • 애플리케이션 계층 (Application): 프레젠테이션 계층과 도메인 계층 간의 조율을 담당하며, 특정 애플리케이션 작업을 수행하기 위한 비즈니스 객체를 조정합니다. 비즈니스 로직을 직접 포함하지는 않습니다.
  • 도메인 계층 (Domain): 비즈니스 객체와 규칙을 포함하며, 애플리케이션의 핵심 계층입니다.
  • 인프라 계층 (Infrastructure): 상위 계층을 지원하는 일반적인 기술을 제공합니다. 예를 들어, 리포지토리(Repository)를 통해 ORM을 이용한 데이터베이스 상호작용을 구현합니다.

이러한 ABP 기반 시스템은 훌륭한 구조를 제공하지만, 출시 초기 웹 페이지 로딩 속도에서 예상치 못한 문제가 발생했습니다. 거의 모든 페이지가 2-5초 동안 흰 화면을 보여주며 사용자 경험을 저해했습니다. 이 문제를 해결하기 위해 심층적인 분석과 최적화 과정을 거쳐, 일반 페이지는 1초 이내, 특정 복잡한 페이지도 3초 이내로 로딩되도록 개선할 수 있었습니다. 배포 후에는 약 60ms의 응답 시간을 달성했습니다.

해당 애플리케이션은 서버 측에서 ASP.NET MVC 5, Web API 2, C# 5.0, DDD(Entities, Repositories, Domain Services, Application Services, DTOs 등), Castle Windsor(DI 컨테이너), Entity Framework 6 또는 NHibernate, Log4Net, AutoMapper를 활용했습니다. 클라이언트 측에서는 Bootstrap, Less, AngularJs, jQuery, Modernizr 및 기타 유틸리티 JS 라이브러리(jQuery.validate, jQuery.form, jQuery.blockUI, json2)를 사용했습니다.

일반적인 페이지는 상단, 중앙, 하단으로 구성됩니다. 이 중 중앙 영역은 왼쪽의 기능 트리와 오른쪽의 기능 영역으로 나뉩니다. 기능 영역은 여러 탭(카드)으로 이루어져 있으며, 각 탭은 상단에 도구 모음이 있고 하단에 목록 또는 상세 정보가 표시됩니다. 도구 모음은 검색 조건 영역을 확장하여 모듈별로 구성된 다양한 조건으로 조합 검색을 수행할 수 있도록 했습니다. 예를 들어, 계획 발표 모듈은 20~50개의 데이터를 페이지당 표시하며 10~30개의 필드를 보여주었습니다. 이러한 구조의 페이지 로딩 시간이 15초에 달하는 경우도 있었습니다.

페이지 실행 주기는 다음과 같은 흐름으로 이루어집니다: 브라우저 요청 -> IIS 애플리케이션 서버 -> DI 컨테이너(Castle Windsor)의 컨트롤러 로케이션 -> 페이지 스크립트 해석 -> Entity Framework를 통한 데이터 접근 -> 페이지 생성 -> 페이지 반환 -> 브라우저 UI 요소 재구성.

느린 페이지 로딩의 원인을 파악하기 위해 여러 관점에서 접근했습니다:

  1. 네트워크 전송 시간
  2. 데이터베이스 작업
  3. 비즈니스 로직 실행
  4. Razor 렌더링
  5. 브라우저 페이지 렌더링
  6. C# 프로그래밍 로직
  7. DB 서버 최적화 (메모리, 디스크, 쿼리 캐시, 임시 테이블, 인덱스 등)
  8. 애플리케이션 서비스 (IIS 재활용, 유휴 시간 초과 등)

우선적으로 클라이언트 브라우저, 웹 서버, 데이터베이스 서버의 네트워크 환경이 정상임을 확인했습니다. JS, CSS는 번들링 및 압축되었고, 데이터베이스 작업도 단순 조회 위주였으므로 네트워크 및 단순 DB 작업은 초기 원인에서 제외했습니다. DB 서버 및 애플리케이션 서비스 최적화는 별도의 광범위한 분석이 필요하므로, 여기서는 다루지 않았습니다.

다음으로 비즈니스 로직 실행을 점검했습니다. 검색 조건에 따라 데이터를 조회하고 JSON으로 반환하는 비교적 간단한 로직이었고, JSON도 압축 처리되었습니다. EF + LINQ를 사용했을 때 응답 시간이 "허용 가능한 몇 초" 수준이었으나, 10밀리초 이내에는 미치지 못했습니다. 이에 동료 개발자와 협력하여 SQL 쿼리와 Dapper를 사용하여 10밀리초 수준으로 최적화했습니다.

페이지 렌더링은 Bootstrap-table을 사용했습니다. 필드 수가 많을 때는 렌더링이 느렸지만, 필드 수가 적을 때는 특별한 최적화 없이도 약 50ms 수준으로 예상되었습니다. 따라서 이 부분도 전체 5초 이상의 로딩 시간을 설명할 주된 원인은 아니라고 판단했습니다.

이제 Razor 렌더링에 주목했습니다. MVC 2.0부터 Razor를 사용하여 다중 페이지 애플리케이션을 개발해왔기에, Razor 엔진의 효율이 이렇게 낮을 리 없다고 생각했습니다. 템플릿 엔진 실행이 5초나 걸린다는 것은 비정상적이었습니다. 이 부분이 가장 의심스러웠으므로, Log4Net을 사용하여 뷰 렌더링에 걸리는 시간을 측정해 보기로 했습니다.

뷰 렌더링 시간을 모니터링하기 위해 다음과 같이 ActionFilterAttribute를 활용하여 측정할 수 있습니다. 이는 ResultExecuting 단계에서 시작 시간을 기록하고 ResultExecuted 단계에서 종료 시간을 기록하여 경과 시간을 계산합니다.


using System.Diagnostics;
using System.Web.Mvc; // ASP.NET MVC Context

/// <summary>
/// 뷰 렌더링 성능을 측정하는 Action 필터
/// </summary>
public class ViewPerformanceMonitorAttribute : ActionFilterAttribute
{
    private const string StopwatchKey = "ViewRenderingStopwatch"; // 스톱워치 저장을 위한 키

    public override void OnResultExecuting(ResultExecutingContext filterContext)
    {
        // 뷰 렌더링 시작 시 스톱워치 시작 및 HttpContext.Items에 저장
        var stopwatch = Stopwatch.StartNew();
        filterContext.HttpContext.Items[StopwatchKey] = stopwatch;
        base.OnResultExecuting(filterContext);
    }

    public override void OnResultExecuted(ResultExecutedContext filterContext)
    {
        // 뷰 렌더링 완료 후 스톱워치 중지 및 경과 시간 로깅
        if (filterContext.HttpContext.Items.Contains(StopwatchKey))
        {
            var stopwatch = (Stopwatch)filterContext.HttpContext.Items[StopwatchKey];
            stopwatch.Stop(); // 스톱워치 중지

            // 로거를 사용하여 실제 로그 시스템에 기록하는 것이 좋습니다.
            // 여기서는 Debug 출력으로 대체합니다.
            System.Diagnostics.Debug.WriteLine(
                $"뷰 렌더링 시간 ({filterContext.RequestContext.HttpContext.Request.RawUrl}): {stopwatch.ElapsedMilliseconds} ms"
            );
            
            filterContext.HttpContext.Items.Remove(StopwatchKey); // 사용 후 제거
        }
        base.OnResultExecuted(filterContext);
    }
}

이 측정 결과, 뷰 렌더링에 무려 5초 이상이 소요된다는 것이 확인되었습니다! 무엇이 이런 성능 저하를 일으키는 것일까요?

Razor 관련 기술 정보를 찾아보고 두 가지 개선 사항을 적용했습니다:

  1. Global.asax 파일의 Application_Start() 메서드에서 Razor 뷰 엔진만 사용하도록 설정했습니다. 기본적으로 MVC는 WebForm 뷰 엔진을 먼저 로드하지만, 이 프로젝트에서는 Razor 엔진만 사용하므로 불필요한 로드를 제거했습니다.

// Razor 엔진만 사용하도록 설정
ViewEngines.Engines.Clear();
ViewEngines.Engines.Add(new RazorViewEngine());
  1. Web.config 파일에서 디버그 모드를 false로 설정했습니다.

<compilation debug="false" targetFramework="4.0" />

이 설정은 뷰 파일 주소 캐싱, 요청 시간 초과 활성화, 번들 기능 활성화 등 여러 최적화를 가능하게 합니다. 하지만 이러한 변경 후에도 페이지 로딩 시간이 거의 줄어들지 않았습니다. 여전히 뷰 렌더링에 2초 이상이 소요되는 현상이 지속되었습니다.

만족스럽지 않은 결과에 따라, 더 심층적인 분석을 위해 성능 모니터링 도구인 MiniProfiler를 도입했습니다. MiniProfiler를 페이지에 통합한 후 다시 페이지를 열었을 때, 빨간색으로 표시된 모니터링 정보를 보고 문제의 원인을 즉시 파악할 수 있었습니다.

요청 과정에서 SQL 쿼리가 전체 시간의 90% 이상을 차지하고 있었으며, 무려 31개의 SQL 쿼리가 실행되고 있었습니다. 더 충격적인 것은 이 중 20개 이상이 중복된 쿼리였다는 점입니다! 비즈니스 로직 코드를 검토했을 때 이렇게 많은 SQL 호출이 발생할 것이라고는 예상하지 못했으며, 로그에 기록된 시간 소모가 뷰 렌더링에 포함되어 계산되고 있었기 때문에 초기에 발견하기 어려웠습니다.

문제는 Razor 뷰(.cshtml) 코드 내부에서 반복적으로 데이터베이스에 접근하는 방식에 있었습니다. 일반적으로 .cshtml에서는 모델 데이터를 완전히 준비하여 전달받아 직접 사용해야 합니다. 하지만 이 경우, @model PagedList<UserDtos>와 같은 모델을 사용하면서도 뷰 코드 내에서 model.UserName과 같은 직접적인 속성 외에 H_Employee.id와 같이 직원의 이름을 번역하거나, 지연 로딩(Lazy Loading)되는 탐색 속성(Navigation Property)을 통해 데이터를 조회하는 과정에서 수많은 데이터베이스 접근이 발생하고 있었습니다. 이는 전형적인 N+1 쿼리 문제였습니다.

해결책은 백엔드의 LINQ 쿼리에서 Eager Loading(즉시 로딩)을 활용하는 것이었습니다. 예를 들어, .Include(x=>x.H_Employee)와 같은 구문을 사용하여 필요한 연관 데이터를 한 번의 쿼리로 미리 로드하도록 변경했습니다. 나아가, 데이터 접근 계층을 Dapper로 전환하여 필요한 데이터를 한 번에 가져오도록 완전히 수정했습니다. 이러한 변경 후 페이지 로딩 시간을 다시 측정했습니다.

결과는 놀라웠습니다. 페이지 로딩 시간은 기존 2769ms에서 656ms로 약 4분의 3이 감소하여 1초 이내로 들어왔습니다. 배포 후에는 약 60ms까지 단축되었으며, 31개에 달하던 SQL 쿼리는 단 5개로 줄어들어 비즈니스 로직을 완벽하게 처리할 수 있게 되었습니다. 이는 ASP.NET MVC 애플리케이션이 보여야 할 최소한의 성능 수준을 달성한 결과였습니다.

태그: ABP ASP.NET MVC 성능 최적화 N+1 문제 지연 로딩

9월 12일 10:08에 게시됨