
Java 레거시 환경의 입출결 조회 API를 Python(FastAPI + SQLAlchemy)으로 마이그레이션하면서 예상치 못한 성능 저하를 겪었어요.기능은 완전히 동일한데도, 학생 118명 기준 응답 시간이 약 5초나 걸렸죠.
처음엔 "비동기 프레임워크를 썼는데 왜 더 느려졌을까?"라는 의문부터 들었어요. 하지만 병목 구간을 추적해 보니 프레임워크 자체의 문제는 아니었습니다. 레거시 SQL 패턴을 그대로 가져온 점, 실행 계획과 어긋난 인덱스, 불필요한 데이터 조회, 그리고 비동기 환경에 맞지 않는 세션 관리가 복합적으로 얽혀 있었거든요.
이번 글에서는 단순히 쿼리 하나를 튜닝한 경험을 넘어, SQL 패턴과 실행 계획부터 인덱스, 애플리케이션의 호출 구조까지 전반적으로 개선해 응답 시간을 5초에서 0.5초로 줄인 5가지 최적화 과정을 공유할게요.
💡 이런 분들께 도움이 될 거에요
어떤 프레임워크가 더 빠르다는 단순한 성능 비교가 아니에요. 동일한 기능을 구현하면서 마이그레이션 과정에서 발생한 숨은 비효율을 어떻게 찾아내고 개선했는지에 집중했어요.
특히 다음과 같은 고민을 하고 계신 분들께 이 경험을 공유하고 싶어요.
- Java/Spring 기반의 레거시 시스템을 Python과 FastAPI로 전환하고 계신 분
- SQLAlchemy, 다중 DB, 비동기 구조가 얽힌 환경에서 성능 병목을 겪고 계신 분
- “레거시 SQL을 그대로 옮겼을 뿐인데 왜 더 느려졌지?”라는 벽에 부딪힌 분
시스템 배경과 제약 사항
이번에 마이그레이션한 대상은 입출결 뷰 API예요. 학생 목록을 기준으로 메인 데이터를 먼저 조회하고, 여기에 출결 정보와 관리 메모 등 사용자가 원하는 정보를 본인이 선택해서 해당 데이터를 조합해서 응답을 내려주는 구조였죠.
사용한 기술 스택은 다음과 같아요.
- API: FastAPI
- ORM: SQLAlchemy
- Main DB: MySQL
- Legacy DB: MSSQL
여기서 문제를 복잡하게 만든 핵심은 다중 DB 환경이었다는 점이에요. MySQL에서 메인 데이터를, MSSQL에서 부가 정보를 각각 조회한 뒤 애플리케이션 레벨에서 하나로 합쳐야 했거든요.
마이그레이션 초기에는 기존 Java 환경에서 쓰던 SQL 쿼리를 최대한 그대로 옮겼어요. 겉보기엔 똑같은 SQL이니 잘 동작할 줄 알았죠. 하지만 레거시에서는 자연스러웠던 쿼리 패턴이 Python, SQLAlchemy, 그리고 비동기 세션(AsyncSession)이 조합된 새로운 환경에서는 찰떡같이 맞아떨어지지 않더라고요.
증상: 학생 118명 기준, 응답 시간 5초
마이그레이션 후 처음 문제를 인지한 건 너무 느린 API 응답 시간 때문이었어요. 학생 118명 기준으로 데이터를 조회하는 데 무려 5초나 걸렸거든요. 기능 자체는 정상적으로 동작했지만, 실제 서비스에 적용하기엔 체감 속도가 심각하게 느렸죠.
이때 단순히 "느리다"고 생각하기보다, 어느 구간에서 병목이 발생하는지 분리해서 확인하는 작업이 필요했어요. 당시 API의 전체적인 처리 흐름은 다음과 같았습니다.
- 학생 목록 조회
- 학생별 최신 관리 메모 조회
- 출결 관련 부가 정보 조회
- 응답 데이터 조합
처음엔 새로 도입한 비동기 구조 자체를 의심했어요. 하지만 병목 지점을 하나씩 추적해 보니, 진짜 원인은 네트워크 통신이 아니라 DB 쿼리와 데이터를 조합하는 과정에 있었습니다.
특히 학생별로 최신 데이터를 1건씩 가져오는 N+1 형태의 쿼리, 실행 계획(Execution Plan)이 비효율적으로 잡힌 복합 조건 쿼리, 그리고 굳이 필요 없는 불필요한 조회까지 한꺼번에 겹치면서 응답 시간이 눈덩이처럼 불어난 거였어요.
원인 분석 및 5단계 최적화 흐름
이번에 적용한 5가지 최적화는 단순히 독립적인 팁들을 모아둔 게 아니에요. 데이터베이스 깊은 곳부터 애플리케이션 레벨까지 뼈대를 다시 세우는 하나의 유기적인 흐름이었죠.
개선 작업은 크게 다음 세 가지 단계로 나누어 진행했어요.
- 쿼리 패턴 변경: 비효율적인 레거시 SQL 구조를 Python과 ORM에 맞게 재구성
- 실행 계획과 인덱스 최적화: DB가 쿼리를 가장 효율적으로 처리할 수 있도록 조정
- 애플리케이션 최적화: 불필요한 조회를 걷어내고 비동기 세션 사용을 최소화
학생별 최신 1건 조회: 상호연관 서브쿼리에서 윈도우 함수로 전환
가장 먼저 개선한 곳은 "학생별 최신 1건"을 조회하는 쿼리였어요. 레거시 Java 코드에서 가져온 기존 SQL은 대략 이런 형태였습니다.
SELECT * FROM TManagementMemo m
WHERE m.CNo = (
SELECT MAX(sub.CNo) FROM TManagementMemo sub
WHERE sub.CCode = m.CCode AND ...
)
AND m.CCode IN (?, ?, ... 118개)
결과는 정확하게 나왔지만, 학생 수가 늘어날수록 성능이 급격히 떨어지는 비효율적인 구조였죠. 치명적인 단점은 크게 두 가지였습니다.
- 같은 테이블을 매번 다시 뒤져야 하는 상호연관 서브쿼리(Correlated Subquery) 구조
- 불필요하게 방대해지는
IN절 파라미터
즉, 단순히 최신 데이터 1건을 찾기 위해 DB가 감당해야 할 스캔 비용이 너무 컸어요. 그래서 이 부분을 ROW_NUMBER() 윈도우 함수를 활용해 다음과 같이 재작성했습니다.
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY CCode ORDER BY CNo DESC
) AS rn
FROM TManagementMemo
WHERE CCode IN (?, ... 118개)
) ranked
WHERE rn = 1
이렇게 바꾸면 IN 절로 필요한 학생 데이터를 한 번에 추려낸 뒤, 그 안에서 그룹별 순위를 매겨 최신 1건(rn = 1)만 깔끔하게 뽑아낼 수 있어요. 이 구조가 주는 장점은 확실합니다.
- 최신 1건을 조회하겠다는 개발자의 의도가 쿼리에 명확히 드러남
- 테이블 접근 횟수가 줄어들어 스캔 비용 최소화
이번 사례에서는 이 쿼리 패턴 하나를 바꾼 것만으로도 병목이 크게 해소되었고, 다음 단계인 실행 계획과 인덱스 튜닝을 진행하기가 훨씬 수월해졌습니다. "그룹별 최신 1건"을 찾을 때는 무거운 서브쿼리 대신 윈도우 함수를 활용하는 것이 성능과 가독성 모두를 잡는 확실한 방법이에요.
쿼리를 바꿨는데도 느리다면? 정답은 실행 계획과 인덱스
쿼리 패턴을 윈도우 함수로 개선했지만, 여전히 "충분히 빠르다"는 체감은 들지 않았어요. 여기서 깨달은 건, SQL 구문만 바꾼다고 최적화가 끝나는 게 아니라는 점이었습니다.
실제로 MSSQL 쪽 실행 계획을 확인해 보니, DB가 효율적인 'Index Seek' 대신 무거운 'Index Scan'을 타며 테이블을 훑고 있었어요. 쿼리 자체는 깔끔해졌지만, 정작 DB는 그 쿼리를 효율적으로 실행하지 못하고 있던 거죠.
범인을 찾기 위해 MSSQL과 MySQL 각각의 실행 계획을 직접 뜯어봤습니다.
-- MSSQL: I/O와 시간 통계를 켜고 실행 계획 확인
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (...) AS rn
FROM TManagementMemo
WHERE CBranchCode = ? AND CBranchStartDate = ?
AND CCode IN (...) AND CRegStaff = ?
) ranked
WHERE rn = 1;
-- MySQL: EXPLAIN ANALYZE로 실행 계획 확인
EXPLAIN ANALYZE
SELECT * FROM EPX_ATTEND_PLAN
WHERE CStaffId = ? AND CCode IN (...) AND is_delete = 0;
병목의 핵심은 새로운 쿼리 패턴에 기존 인덱스가 들어맞지 않는다는 데 있었습니다. 예를 들어, 기존에 걸려있던 IX_Memo_Old (CCode, CBranchCode, ...) 인덱스는 새롭게 바뀐 WHERE 조건의 탐색 순서와 어긋나 있었어요.
그래서 다음 세 가지 핵심 원칙을 기준으로 인덱스를 재설계했습니다.
- 등호(
=) 조건 컬럼을 가장 앞에 둔다. - 선택도(Selectivity)가 높은 컬럼을 앞쪽에 배치한다.
IN조건 컬럼은 상대적으로 뒤로 보낸다.
이 원칙에 따라 인덱스를 IX_Memo_Optimized (CBranchCode, CBranchStartDate, CRegStaff, CCode) 순서로 재조정했습니다.
이론으로 아는 것과 실제 결과는 다르죠. 인덱스를 조정한 뒤 다시 실행 계획을 열어보니, 드디어 Scan이 Seek로 바뀌면서 I/O 비용과 실행 시간이 극적으로 줄어드는 것을 눈으로 확인할 수 있었습니다.
이번 마이그레이션에서 가장 짜릿한 순간이기도 했어요. 단순히 "윈도우 함수를 썼더니 빨라졌다"가 아니라, 쿼리 변경과 실행 계획 확인, 그리고 인덱스 재설계가 톱니바퀴처럼 완벽하게 맞물려야만 비로소 안정적인 성능을 얻을 수 있다는 걸 확실히 체감했거든요.
레거시 SQL에 남아 있던 습관적인 DISTINCT 걷어내기
실행 계획 다음으로 눈길을 돌린 곳은 DISTINCT 키워드였어요. 레거시 쿼리를 마이그레이션하다 보면 도대체 왜 들어갔는지 명확한 이유를 알 수 없는 DISTINCT를 자주 마주치게 되죠.
이번 API 쿼리도 마찬가지였습니다. JOIN 조건만으로도 이미 데이터의 고유성(Uniqueness)이 완벽하게 보장되는데 굳이 DISTINCT가 붙어 있더라고요.
-- AS-IS: 습관적으로 붙어 있던 DISTINCT
SELECT DISTINCT s.*, sa.*
FROM TStudent s
JOIN TStudentAttend sa ON ...
데이터 구조를 꼼꼼히 다시 확인해 중복이 발생할 수 없다는 걸 검증한 뒤, 지체 없이 DISTINCT를 걷어냈습니다.
-- TO-BE: 불필요한 연산 제거
SELECT s.*, sa.*
FROM TStudent s
JOIN TStudentAttend sa ON ...
물론 이 키워드 하나 지웠다고 해서 극적인 성능 차이가 발생한 건 아니에요. 하지만 DISTINCT는 데이터베이스 내부적으로 중복을 걸러내기 위해 무거운 정렬(Sort)이나 추가 연산을 유발하므로 생각보다 실행 비용이 매우 큽니다.
레거시 마이그레이션을 진행할 때 DISTINCT는 원래 있었으니까 유지하는 코드가 아니라, 정말 필요한지 반드시 의심해 봐야 할 검증 대상이에요. 데이터 정합성을 확인하고 이런 습관적인 연산만 덜어내도 쿼리가 한결 가볍고 단순해집니다.
가장 빠른 쿼리는 '실행하지 않는 쿼리': 불필요한 애플리케이션 조회 제거
성능 병목을 마주하면 보통 SQL 튜닝부터 떠올리기 쉽죠. 하지만 애플리케이션 코드를 찬찬히 뜯어보면, 굳이 하지 않아도 될 조회를 무의미하게 반복하는 경우가 꽤 많습니다.
대표적인 예가 firstAttendDate(최초 출결일) 데이터였어요. 응답 템플릿에 해당 컬럼이 포함되지 않아 결과를 아예 쓰지 않는 상황에서도, 기존 코드에서는 무조건 DB를 찔러 데이터를 가져오고 있었습니다.
# AS-IS: 결과 사용 여부와 무관하게 매번 쿼리 실행
first_attend_map = await history_repo.find_first_attend_dates(...)
이 부분을 응답 템플릿의 컬럼 정의를 먼저 확인하여, 데이터가 진짜 필요할 때만 쿼리를 실행하도록 조건부 로직으로 변경했습니다.
# TO-BE: 템플릿에 해당 컬럼이 있을 때만 조건부 조회
has_first_attend_col = any("firstAttendDate" in c.col_key for c in col_definitions)
if has_first_attend_col and self._history_repo:
first_attend_map = await history_repo.find_first_attend_dates(...)
이렇게 개선하면 두 가지 확실한 이점이 생깁니다.
- 데이터가 필요 없는 상황에서는 쿼리 실행 자체가 생략되어 성능이 크게 향상됩니다.
- 코드를 읽는 다른 개발자도 이 조회는 특정 컬럼이 요청될 때만 필요하다는 의도를 명확하게 파악할 수 있어요.
이번 최적화를 진행하며 더 빠른 쿼리를 짜는 것만큼이나, 아예 안 해도 되는 조회를 걷어내는 것이 시스템 전반에서 훨씬 강력한 최적화 전략이 될 수 있다는 걸 다시 한번 체감했습니다.
세션 팩토리 직접 호출 제거 및 DI(의존성 주입)된 Repository 재사용
마지막으로 손본 곳은 데이터베이스 세션 관리 방식이었어요. 마이그레이션 초기에는 Java 레거시의 흔적이 남아, 비즈니스 로직 안에서 필요할 때마다 세션 팩토리를 열고 Repository(이하 Repo) 객체를 직접 생성하는 패턴을 사용하고 있었습니다.
# AS-IS: 컴포넌트 내부에서 세션과 Repo를 직접 생성
async def _fetch_legacy_db_data(self):
async with self._legacy_session_factory() as session:
repo = ManagementMemoRepository(session)
# ...
단일 조회에서는 큰 문제가 없어 보이지만, 비동기(Async) 환경에서 여러 조회가 얽히기 시작하면 이 방식은 코드를 걷잡을 수 없이 복잡하게 만듭니다. 가장 큰 문제는 하나의 컴포넌트가 비즈니스 로직 처리, 세션 수명 관리, Repo 객체 구성이라는 세 가지 책임을 모두 떠안게 된다는 점이었죠.
특히 Python의 AsyncSession을 Java의 동기(Sync) 커넥션 풀을 다루던 감각으로 접근하면 흐름을 제어하기가 훨씬 까다로워집니다. 그래서 세션 생성의 책임을 외부로 완전히 분리하고, 의존성 주입(DI)을 통해 이미 생성된 Repo를 그대로 가져다 쓰도록 구조를 개편했습니다.
# TO-BE: DI로 주입된 Repo의 메서드만 깔끔하게 호출
async def _fetch_legacy_db_data(self):
await self._memo_repo.find_latest_by_students(...)
이렇게 호출 구조를 단순화하고 나니 코드의 역할이 아주 선명해졌어요.
- 책임 분리: 세션의 생성과 수명 관리는 외부(미들웨어나 프레임워크)가 전담
- 비즈니스 집중: 현재 컴포넌트는 본연의 역할인 '데이터 조회 요청'에만 집중
- 가독성 향상: Repo 재사용이 쉬워지고, 복잡한 비동기 흐름도 직관적으로 파악 가능
이 최적화는 단순히 팩토리를 쓰면 느리다는 뜻이 아닙니다. 레거시의 동기식 세션 관리 패턴을 비동기 애플리케이션에 억지로 끼워 맞출 때 발생하는 구조적 비효율을 걷어냈다는 데 진짜 의미가 있어요. 이번 마이그레이션에서는 쿼리 튜닝 못지않게, 객체의 책임을 분리해 아키텍처를 단단하게 다진 것이 유지보수 측면에서 가장 든든한 성과였습니다.
결국 무엇이 가장 큰 차이를 만들었을까?
이번 5가지 최적화 과정에서 5초의 응답 시간을 0.5초로 끌어내린 일등 공신은 단연 첫 번째와 두 번째 작업이었어요.
- 무거운 상호연관 서브쿼리를 윈도우 함수로 걷어낸 것
- 새로운 쿼리 패턴에 맞춰 실행 계획을 분석하고 인덱스를 재설계한 것
결국, SQL 패턴의 구조적 개선과 그에 딱 맞는 인덱스 튜닝이 문제 해결의 핵심 마스터키였습니다.
여기에 아래 세 가지 디테일한 코드 레벨의 정리가 더해지면서, 비로소 응답 속도가 흔들림 없이 안정화될 수 있었죠.
- 습관적인
DISTINCT제거 - 조건부 로직을 통한 불필요한 DB 조회 스킵
- 의존성 주입(DI)을 활용한 세션 및 Repo 호출 구조 단순화
이번 마이그레이션 경험을 통해 확실히 깨달은 점이 있습니다. 시스템의 성능 병목은 결코 단 한 곳에서만 발생하지 않는다는 사실이에요.
가장 굵직한 데이터베이스 병목을 먼저 시원하게 뚫어내고, 그 주변 애플리케이션에 얽힌 자잘한 비효율과 불필요한 연산을 차근차근 걷어내는 접근 방식. 이것이 레거시를 전환할 때 성능과 코드 품질을 동시에 잡는 가장 확실한 최적화 전략이었습니다.
✨ 결과: 5초에서 0.5초로, 10배의 성능 향상
모든 최적화를 마친 후, 학생 118명 기준의 API 응답 시간은 5초에서 0.5초로 뚝 떨어졌어요. 무려 10배에 달하는 극적인 성능 개선을 이뤄낸 거죠. 속도만 빨라진 게 아닙니다. 기존에 작성해 둔 156개의 회귀 테스트(Regression Test)도 모두 무사히 통과하며 비즈니스 로직의 안정성까지 완벽하게 지켜냈습니다.
이번 마이그레이션 과정을 거치며 확실히 체감한 점이 있어요. 성능 이슈를 단숨에 해결해 주는 '마법 같은 한 줄의 코드'는 없다는 사실입니다. 이번 0.5초의 성과는 아래 네 가지 개선이 톱니바퀴처럼 정교하게 맞물린 결과였어요.
- SQL 패턴 변경: 비효율적인 쿼리를 윈도우 함수 등으로 구조적 개편
- 실행 계획과 인덱스 재설계: DB가 가장 일하기 좋은 탐색 환경 구축
- 불필요한 조회 제거: 애플리케이션 레벨의 무의미한 연산 스킵
- 세션 및 리포지토리 구조 정리: 비동기 환경에 맞는 명확한 책임 분리
즉, 문제를 DB나 애플리케이션 어느 한쪽 레이어에서만 단편적으로 바라보지 않고, 시스템 전체의 관점에서 두 레이어를 함께 조율했을 때 비로소 진정한 최적화가 완성된다는 것을 배운 소중한 경험이었습니다.
이번 마이그레이션에서 얻은 4가지 교훈
이번 최적화 과정을 거치며 머리로만 알던 이론들을 실무에서 다시 한번 뼈저리게 체감할 수 있었어요.
- 레거시 쿼리 맹신 금지: Java 환경에서 문제없던 SQL을 Python과 ORM 환경에 그대로 옮기면 숨은 비효율이 따라올 수 있어요. 특히 상호연관 서브쿼리(Correlated Subquery)처럼 결과만 맞고 성능 비용은 막대한 패턴을 철저히 경계해야 합니다.
- 비동기 전환은 곧 아키텍처 재설계: 단순히 async/await 키워드만 붙인다고 비동기 전환이 끝나지 않아요. 세션을 어디서 열고 닫을지, Repository를 어떤 단위로 주입하고 재사용할지 등 코드의 근본적인 책임과 구조를 다시 고민해야 했죠.
- 쿼리를 고쳤다면 실행 계획 확인은 필수: 쿼리를 윈도우 함수 등으로 예쁘게 개선했다 하더라도 반드시 실행 계획(Execution Plan)을 눈으로 뜯어봐야 해요. 아무리 논리적으로 훌륭한 쿼리라도 기존 인덱스와 핏(fit)이 맞지 않으면 DB는 여전히 비효율적으로 동작하니까요.
- 가장 훌륭한 최적화는 '안 하는 것': 성능 개선이 무조건 '더 빠른 SQL 만들기'를 뜻하진 않습니다. 불필요한 데이터를 찔러보는 조회를 과감히 걷어내고, 호출 구조를 단순하게 덜어내는 애플리케이션 레벨의 다이어트가 때로는 훨씬 더 극적인 차이를 만듭니다.
이번 마이그레이션 성능 이슈를 겪으며 확실히 깨달은 점이 있어요. 문제의 원인은 결코 "Python이라서 느리다"거나 "비동기를 썼더니 오히려 느려졌다" 같은 단순한 프레임워크 탓이 아니었다는 겁니다.
진짜 원인은 코드와 쿼리 곳곳에 숨어 있었죠.
만약 지금 비슷한 마이그레이션 작업을 진행하며 성능 문제로 끙끙 앓고 계신다면, 가장 먼저 언어나 프레임워크의 한계를 의심하기 전에 코드를 한 번 더 들여다보시길 추천해요.
레거시의 무거운 SQL 패턴을 생각 없이 그대로 가져오진 않았는지, DB의 실행 계획은 제대로 타는지, 그리고 정말 필요한 데이터만 콕 집어 조회하고 있는지부터 점검해 보세요. 어쩌면 그곳에 가장 빠르고 확실한 해결책이 기다리고 있을지도 모릅니다.
안녕하세요, 저는 주니어 개발자 박석희 입니다. 언제든 하단 연락처로 연락주세요 😆