Project/ERP 프로젝트

레거시 쿼리 리팩토링으로 응답속도 99.8% 개선하기 (3.569s → 0.005s)

쉬지마 이굥진 2026. 4. 22. 20:07

필자는 직전 회사에서 ERP 급여 모듈 유지보수 및 고도화를 맡고 있었다.

GS 인증 취득을 위해 성능 기준 통과를 해야 하는 상황이 생겼는데, 데이터 건수가 많지 않음에도 불구하고 급여 모듈 곳곳에서 응답속도가 3~4초에 달하는 화면들이 발견됐다. (테스트 데이터 건수들은 약 100건)

💡GS(Good Software) 인증이란?
가전제품에 붙는 KS 마크식품의 HACCP 인증처럼, 쉽게 말해 "이 소프트웨어는 기능도 제대로 동작하고, 성능 기준도 충족한다"는 걸 공인 기관인 한국정보통신기술협회(TTA)이 검증해주는 것이다.
공공기관이나 기업에 소프트웨어를 납품할 때 GS 인증이 있으면 신뢰도 면에서 유리하고, 일부 공공 조달 시장에서는 사실상 필수 요건으로 작용하기도 한다.
인증 심사 항목 중에는 응답속도 기준이 포함되어 있는데 특정 시간 내에 응답이 완료되지 않으면 해당 항목에서 탈락한다.


이번 포스팅에서는 원인을 어떻게 파악했는지, 어떤 방식으로 리팩토링했는지를 정리해보려 한다.

(코드 보안상 테이블명·함수명은 예시로 대체했습니다)

 

문제 상황

급여 모듈의 여러 화면에서 조회 버튼 클릭 시 응답이 3초 이상 걸리는 현상이 발생했다.

DBeaver에서 직접 쿼리를 실행해봤을 때도 결과는 마찬가지였다.

  • A 화면 사원 조회: 2.235s
  • B 화면 사원 조회: 3.569s

데이터 건수가 많지 않은데 왜 이렇게 느린 걸까? 🤔

 

원인 분석

실제로 돌아가는 쿼리를 콘솔에서 복사해 분석해봤다.

-- 문제가 된 쿼리 (테이블명·함수명은 예시로 대체)
SELECT
    e.emp_no,
    e.emp_name,
    d.dept_name,
    GET_CODE_NAME('SAL_GRP', s.salary_group, s.company_cd, null) AS salary_group,
    GET_CODE_NAME('JOB_LV',  e.job_level,    e.company_cd, null) AS job_level
FROM employees e
LEFT JOIN departments d
    ON e.company_cd = d.company_cd AND e.dept_cd = d.dept_cd
LEFT JOIN salary_auth s
    ON e.company_cd = s.company_cd AND e.emp_no = s.emp_no
WHERE e.company_cd = 'XXX'
  AND e.resign_yn  = '0'
ORDER BY s.salary_group, e.dept_cd, e.emp_name;

 

SELECT 절에서 GET_CODE_NAME 이라는 사용자 정의 함수(UDF, User-Defined Function)를 호출하고 있었다.

 

이 함수는 코드값을 코드명으로 변환해주는 공통 함수인데, 함수를 까보니 이거 좀 수상하다(?) 싶은 구조가 눈에 들어왔다.

-- 예시로 치환한 GET_CODE_NAME 함수 내부 (일부 발췌)
CREATE FUNCTION GET_CODE_NAME(
    p_type     VARCHAR(2000),
    p_code     VARCHAR(2000),
    p_company  VARCHAR(2000),
    p_year     VARCHAR(2000)
) RETURNS VARCHAR(2000)
BEGIN
    DECLARE result VARCHAR(2000) DEFAULT '';

    IF SUBSTR(p_type, 1, 1) = '^' THEN
        SELECT code_name INTO result
        FROM code_master
        WHERE company_cd = p_company
          AND code_id    = p_type
          AND code_cd    = p_code;

    ELSEIF p_type = 'DEPT' THEN
        SELECT dept_name INTO result
        FROM departments
        WHERE company_cd = p_company
          AND dept_cd    = p_code;

    ELSEIF p_type = 'SAL_GRP' THEN
        SELECT code_name INTO result
        FROM code_master
        WHERE company_cd = p_company
          AND code_cd    = p_code
          AND code_id    = '^SAL_GRP';

    -- ... 이하 동일한 패턴의 ELSEIF 분기가 수십 개 반복됨

    END IF;

    RETURN result;
END

 

타입 인자에 따라 분기하면서 각각 다른 테이블을 SELECT하는 구조였다. 그리고 이런 패턴의 분기가 실제 함수 내부에는 50개 이상 존재했다.

 


💡 왜 느릴까?

 

문제의 핵심은 이 함수가 놓인 위치였다.

 

이 함수는 SELECT 절에 위치하기 때문에 결과 행마다 독립적으로 실행된다. 즉, 결과가 100건이고 함수를 2번 호출하면, 내부 SELECT가 최대 200번 발생한다는 뜻이다. 이건 JPA/ORM에서 말하는 N+1 문제와 구조적으로 동일하다. 데이터가 많지 않아도 함수 내부 쿼리 때문에 전체 쿼리가 매우 느려진다는 것.

 

여기에 더해, UDF는 옵티마이저 입장에서 블랙박스다.

일반 쿼리라면 옵티마이저가 전체 SQL을 분석해서 인덱스 사용 여부를 결정할 수 있다. 하지만 UDF 내부는 들여다볼 수 없기 때문에, 옵티마이저는 데이터를 한 줄씩 읽을 때마다 함수를 실행하는 row-by-row 방식으로 처리할 수밖에 없다. 인덱스가 존재해도 충분히 활용되지 못하는 이유가 바로 이것이다.

 

추측한 원인들을 정리하면 이렇다.

원인 설명
Row-by-row 실행 SELECT 절의 UDF는 결과 행마다 독립 실행 → N+1과 동일한 구조
블랙박스 처리 옵티마이저가 UDF 내부를 분석할 수 없어 최적 실행계획 수립 불가
컨텍스트 스위칭 UDF 호출마다 SQL 엔진 ↔ 함수 실행 엔진 간 전환 오버헤드 발생

 

이 세 가지가 겹치면서 데이터 건수가 적어도 느린 현상이 발생한 것이다. 데이터 양의 문제가 아니라 쿼리 실행 구조 자체의 문제였다.

시험 삼아 함수 호출 부분만 제거하고 돌려봤더니? 0.005s. 원인은 명확했다 😮

 

해결 방법 검토

원인이 "행마다 코드 테이블을 반복 조회하는 것"이라는 걸 파악했으니, 해결 방향은 코드 테이블 조회를 한 번으로 줄이는 것이었다. 방법은 크게 세 가지를 검토했다.

 

1. 인라인 서브쿼리

SELECT
    e.emp_no,
    (SELECT c.code_name FROM code_master c
     WHERE c.company_cd = s.company_cd
       AND c.code_id = 'SAL_GRP'
       AND c.code_cd = s.salary_group) AS salary_group,
    ...

가장 직관적이지만, 여전히 행마다 서브쿼리가 실행되는 구조라 근본적인 해결이 아니라고 판단했다. UDF 호출과 같은 row-by-row 문제가 그대로 남는다 (실제로 팀 내에서도 선호하지 않는 방식이었다).

 

2. 애플리케이션 레벨 처리

코드 테이블 전체를 서버단에 캐싱해두고, 쿼리에서는 코드값만 가져온 뒤 변환하는 방법이다.

캐싱 전략이 더해지면 성능 면에서 가장 유리할 수 있지만, 레거시 시스템의 공통 함수 의존도가 높고 수십 개 화면에 일괄 적용하기엔 변경 범위가 너무 크고 사이드 이펙트 리스크도 있었다.

 

3. LEFT JOIN으로 코드 테이블 연결 (✔️채택)

코드 테이블을 JOIN 절에서 미리 연결해두면, 단 한 번의 쿼리 실행으로 코드명을 포함한 전체 결과를 가져올 수 있다. 옵티마이저도 JOIN 구조는 분석할 수 있기 때문에 인덱스를 제대로 활용할 수 있다. 변경 범위가 쿼리 XML 내부로 한정되고, 기존 동작과 결과도 동일하게 유지된다.

레거시 시스템에 가장 안전하면서도, 구조적으로 문제를 근본 해결할 수 있는 방법이라고 생각되어 이 방법으로 채택!

 

리팩토링 - LEFT JOIN 적용

-- 개선 후 쿼리
SELECT
    e.emp_no,
    e.emp_name,
    d.dept_name,
    c1.code_name AS salary_group,
    c2.code_name AS job_level
FROM employees e
LEFT JOIN departments d
    ON e.company_cd = d.company_cd AND e.dept_cd = d.dept_cd
LEFT JOIN salary_auth s
    ON e.company_cd = s.company_cd AND e.emp_no = s.emp_no
LEFT JOIN code_master c1
    ON c1.company_cd = s.company_cd
   AND c1.code_cd   = s.salary_group
   AND c1.code_id   = 'SAL_GRP'
LEFT JOIN code_master c2
    ON c2.company_cd = e.company_cd
   AND c2.code_cd   = e.job_level
   AND c2.code_id   = 'JOB_LV'
WHERE e.company_cd = 'XXX'
  AND e.resign_yn  = '0'
ORDER BY s.salary_group, e.dept_cd, e.emp_name;

 

변경 포인트는 딱 두 가지다.

  • GET_CODE_NAME('SAL_GRP', ...) ➡️ c1.code_name AS salary_group
  • GET_CODE_NAME('JOB_LV', ...) ➡️ c2.code_name AS job_level

코드명 변환을 함수 반복 호출 대신 JOIN 한 번으로 처리하도록 구조를 바꿨다.

 

한 화면에서 끝내지 않았다

처음 A 화면에서 원인을 파악한 뒤 바로 PR을 올리는 대신, 이 패턴이 모듈 전체에 얼마나 퍼져 있는지부터 확인했다.

 

예상대로였다. GET_CODE_NAME 함수 호출 패턴은 급여 모듈 내 수십 개 화면의 쿼리에 동일하게 적용되어 있었다. 공통 함수라는 이유로 편리하게 쓰였겠지만, 그만큼 성능 부채도 전체에 누적되어 있었던 것이다.

 

A 화면 하나에서 원인을 발견한 것을 기점으로, 동일 패턴이 적용된 쿼리를 전수 조사하고 일괄 리팩토링을 진행했다. 덕분에 급여 모듈 전반에 걸쳐 99% 이상의 성능 향상을 이끌어낼 수 있었다.

 

결과

화면 개선 전 개선 후 개선율
A 화면 사원 조회 2.235s 0.006s 약 99.73%
B 화면 사원 조회 3.569s 0.005s 99.8%
그 외 동일 패턴 화면들 측정값 유사 0.005s 수준 99% 이상

 

🔔비즈니스 관점에서의 의미

 

급여 모듈에는 급여 마감처럼 특정 시점에 수백~수천 건의 사원 데이터를 일괄 조회·처리하는 작업이 존재한다. 기존 구조라면 이 시점에 대량의 UDF 내부 쿼리가 폭발적으로 발생해 DB에 큰 부하를 줄 수 있었다.

 

리팩토링 이후에는 쿼리 실행 횟수 자체가 줄었기 때문에, 데이터가 많아지는 마감 시점에도 안정적인 응답이 보장된다. GS 인증 기준을 통과하는 것은 물론이고 실제 사용자가 업무 중단 없이 시스템을 사용할 수 있는 환경을 만들었다고 생각한다.

 

이 경험에서 배운 것들은 아래와 같다.

1. SELECT 절 UDF 호출은 SQL 레벨의 N+1이다

 

ORM을 쓰지 않는 레거시 환경에서도 N+1은 발생한다. SELECT 절에 함수를 넣는 순간 (그 함수가 내부에서 쿼리를 실행한다면) 행 수만큼 쿼리가 발생한다.

여기에 더해, UDF는 옵티마이저 입장에서 블랙박스다. 일반 쿼리라면 전체 SQL을 분석해서 인덱스 사용 여부를 결정할 수 있지만, UDF 내부는 들여다볼 수 없기 때문에 옵티마이저는 데이터를 한 줄씩 읽을 때마다 함수를 실행하는 row-by-row 방식으로 처리한다. 인덱스가 존재해도 충분히 활용되지 못하는 이유가 바로 이것이다.

결과적으로 "데이터가 적어도 느린" 현상이 발생한다. 데이터 건수의 문제가 아니라 쿼리 실행 구조 자체의 문제이기 때문이다.

 

2. 원인 분석을 먼저, 수정은 그 다음

 

처음엔 단순히 "느린 화면 하나를 빠르게 고치자"는 생각으로 접근했다. 그런데 원인을 파악하고 나니 자연스럽게 이런 의문이 생겼다. "이 함수가 이 화면에만 쓰이고 있을까?"

결국 한 화면에서 원인을 제대로 이해한 것이 모듈 전체를 개선하는 출발점이 됐다. 빠르게 고치는 것도 중요하지만, 왜 느린지를 먼저 정확히 이해하는 것이 더 중요하다는 걸 이번에 다시 한 번 느꼈다.

 

3. 10년 넘은 레거시 코드, '무조건 리팩토링'이 능사가 아니다

 

이 시스템은 10년이 넘은 레거시 코드였다. 레거시라고 하면 흔히 '낡고 바꿔야 할 것'으로 인식하기 쉽지만, 1n년 동안 수많은 엣지 케이스를 거치며 검증된 비즈니스 로직이 녹아 있는 코드라고 생각한다. 그 맥락을 모른 채 섣불리 건드리게 되면 예상치 못한 곳에서 문제가 반드시 터질 것이라고 생각했다.

그래서 이번 리팩토링에서 가장 중요하게 생각한 건 변경 범위를 최소화하는 것이었다. 인라인 서브쿼리나 애플리케이션 캐싱 같은 방법도 검토했지만, 결국 쿼리 XML 내부만 수정하는 LEFT JOIN 방식을 선택했다. 기존 비즈니스 로직과 결과는 그대로 유지하면서 성능 문제만 구조적으로 해결할 수 있는 방법이었기 때문이다.

레거시를 다룰 때는 '얼마나 많이 바꾸느냐'보다 '얼마나 안전하게 바꾸느냐' 가 더 중요한 기준이 된다는 걸 이번에 체감했다.

 

4. 실행계획 분석 습관의 중요성

 

이번 이슈는 코드 리뷰만으로는 잡기 어려운 종류의 문제였다. 쿼리 자체는 멀쩡해 보이고, 함수 호출도 관행적으로 쓰이던 방식이여서 실제 실행 시간과 실행계획을 직접 확인하기 전까지는 어디가 문제인지 전혀 보이지 않았다.

실행계획(EXPLAIN)을 보는 습관이 있으면 이런 문제를 훨씬 빨리 발견할 수 있다. 풀스캔이 발생하고 있는지, 인덱스를 제대로 타고 있는지, 서브쿼리나 함수 호출이 반복 실행되고 있지는 않은지를 한눈에 파악할 수 있기 때문이다. 기능이 '동작하는지'는 테스트로 잡을 수 있지만, 쿼리가 '효율적으로 동작하는지'는 실행계획을 봐야만 알 수 있다.

개발하면서 성능이 의심될 때마다 EXPLAIN 을 찍어보는 습관 하나가, 나중에 시스템 전체의 병목을 찾는 실마리가 될 수 있다는 걸 이번 경험을 통해 실감했다 😊