티스토리 뷰

목차


    데이터베이스 쿼리 성능 최적화와 복잡한 비즈니스 로직 구현을 위해 반드시 숙지해야 하는 핵심 기술, 바로 서브쿼리(Subquery)입니다. 단순히 쿼리 안에 쿼리가 들어간다는 기초적인 문법 구조를 넘어, 각 서브쿼리가 데이터베이스 내부 엔진에서 어떻게 파싱되고 물리적으로 실행되는지, 그리고 비용 기반 옵티마이저(CBO)가 이를 어떻게 변환(Query Transformation)하여 성능을 튜닝하는지 실무적인 관점에서 아주 깊이 있게 다루어 보겠습니다. 이 가이드를 통해 서브쿼리로 인한 성능 지연(Slow Query) 문제를 스스로 진단하고 해결하여, 빠르고 효율적인 고성능 SQL 문장을 작성하는 데이터베이스 전문가로 거듭나실 수 있습니다.

     

     

     

     

     

    1. 서브쿼리(Subquery)의 핵심 개념과 필수 작성 규칙

    서브쿼리(Subquery)란 하나의 거대한 SQL 문(메인 쿼리, Main Query) 내부에 부품처럼 중첩되어 포함되어 있는 또 다른 독립적인 SELECT 문을 의미합니다. 관계형 데이터베이스(RDBMS)에서 조인(Join) 연산만으로는 처리하기 까다로운 복잡한 데이터 필터링 조건을 부여하거나, 실행 시점에 동적으로 결과 집합(가상 테이블)을 생성하여 메인 쿼리에 공급하는 매우 중요한 역할을 수행합니다. 마치 프로그래밍 언어에서 메인 함수 내부에 헬퍼 함수를 호출하는 것과 유사한 논리적 구조를 가지며, 바깥쪽의 쿼리를 외부 쿼리, 안쪽의 쿼리를 내부 쿼리라고 명명하기도 합니다. 서브쿼리를 올바르게 사용하기 위해서는 몇 가지 문법적이고 논리적인 제약 사항들을 반드시 숙지해야만 런타임 에러를 방지할 수 있습니다.

     

    가장 기본적인 작성 규칙은 모든 서브쿼리가 반드시 소괄호 '()' 기호로 둘러싸여 있어야 한다는 점입니다. 이는 데이터베이스 파서(Parser)가 메인 쿼리의 영역과 서브쿼리의 영역을 명확하게 분리하여 구문 분석을 수행할 수 있도록 돕습니다. 또한, 인라인 뷰를 활용한 TOP-N 페이징 처리와 같은 아주 예외적인 특수 상황을 제외하고는, 서브쿼리 내부에서는 ORDER BY 절을 절대로 사용할 수 없습니다. 정렬 작업은 데이터베이스의 CPU와 메모리(PGA) 자원을 극심하게 소모하는 무거운 연산이기 때문에, 최종적인 데이터 결과가 도출되지 않은 중간 집합 단계에서 데이터를 정렬하는 것은 심각한 성능 낭비를 초래합니다. 따라서 정렬은 반드시 모든 가공이 끝난 후 데이터를 반환하는 메인 쿼리의 가장 마지막 줄에서 단 한 번만 수행하는 것이 데이터베이스 튜닝의 기본 원칙입니다.

    추가적으로 서브쿼리가 반환하는 결과의 형태, 즉 반환하는 열(Column)의 개수와 행(Row)의 개수에 따라 메인 쿼리와 연결되는 비교 연산자의 종류가 엄격하게 결정됩니다. 서브쿼리가 단 하나의 값만 반환한다면 등호(=)나 부등호(<, >)와 같은 단일 행 연산자를 사용할 수 있지만, 2개 이상의 값을 반환할 가능성이 조금이라도 있다면 반드시 IN, ANY, ALL, EXISTS와 같은 다중 행 연산자를 매핑해야 합니다. 이를 어기고 다중 행을 반환하는 서브쿼리에 단일 행 연산자를 사용하면 'ORA-01427: 단일 행 하위 질의에 2개 이상의 행이 리턴되었습니다'와 같은 치명적인 예외(Exception)가 발생하여 애플리케이션의 작동이 중지될 수 있습니다.

     

    요약: 서브쿼리는 메인 쿼리 내부에 괄호로 중첩된 SELECT 문으로, 동적 데이터 제공과 복잡한 필터링을 담당합니다. 무의미한 성능 저하를 막기 위해 내부 ORDER BY 사용이 엄격히 금지되며, 반환되는 행의 개수에 맞춰 단일 행 연산자(=) 또는 다중 행 연산자(IN, EXISTS)를 정확히 매칭해야 런타임 오류를 예방할 수 있습니다.

     

    [데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서
    [데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류, 특징, 그리고 실행 순서

     

     

    2. 스칼라 서브쿼리(Scalar Subquery)와 캐싱 알고리즘

    SQL 문장의 SELECT 절에 위치하여 마치 일반적인 컬럼이나 함수처럼 취급되는 서브쿼리를 '스칼라 서브쿼리(Scalar Subquery)'라고 부릅니다. 스칼라(Scalar)라는 수학적 용어가 암시하듯, 이 서브쿼리는 메인 쿼리의 출력 결과 각 행(Row)에 대하여 반드시 단 1개의 컬럼에서 단 1개의 데이터 값만을 반환해야 한다는 극도로 엄격한 성질을 지니고 있습니다. 만약 스칼라 서브쿼리가 실행되었는데 메인 쿼리의 조인 조건에 부합하는 데이터가 테이블에 아예 존재하지 않는다면, 데이터베이스는 에러를 뱉어내는 대신 조용히 NULL 값을 결과로 반환합니다. 이러한 논리적 특성 덕분에 스칼라 서브쿼리는 메인 쿼리 테이블을 기준으로 LEFT OUTER JOIN을 수행한 것과 정확히 동일한 결과 집합을 만들어내며, 실무에서는 복잡한 아우터 조인 문장을 직관적으로 간소화할 때 자주 애용됩니다.

     

    스칼라 서브쿼리의 성능을 결정짓는 가장 핵심적인 데이터베이스 내부 엔진의 기술은 바로 '스칼라 서브쿼리 캐싱(Scalar Subquery Caching)' 메커니즘입니다. 메인 쿼리에서 10만 건의 데이터를 추출한다고 가정할 때, 원리적으로는 SELECT 절에 있는 스칼라 서브쿼리도 10만 번 반복 실행되어야 하므로 엄청난 디스크 I/O 부하가 발생해야 정상입니다. 하지만 오라클(Oracle)을 비롯한 현대의 RDBMS는 서브쿼리에 입력으로 들어오는 조건 값(예: 부서 코드)과 그 실행 결과로 반환된 값(예: 부서 명)을 메모리 상의 해시 테이블(Hash Table)에 키-값(Key-Value) 쌍으로 임시 저장해 둡니다. 이후 메인 쿼리가 다음 행을 처리할 때 입력값이 방금 전과 동일하다면, 무거운 쿼리를 다시 실행하여 디스크 블록을 읽는 대신 메모리 캐시에서 즉시 결과를 꺼내오는(Cache Hit) 마법을 부립니다.

    따라서 스칼라 서브쿼리는 조인 조건으로 사용되는 컬럼의 값 종류(Distinct Value)가 적고 중복 데이터가 무수히 많을 때 극적인 성능 향상을 가져다줍니다. 반대로 고객의 주민등록번호나 고유 주문 번호처럼 매 행마다 값이 전부 다르게 입력되는 상황이라면 캐시 적중률(Cache Hit Ratio)이 0%에 수렴하게 됩니다. 이 경우 메모리에 해시 테이블을 생성하고 관리하는 오버헤드만 가중되어 일반적인 해시 조인(Hash Join)을 수행하는 것보다 쿼리 응답 속도가 수십 배 이상 느려지는 끔찍한 성능 재앙을 초래할 수 있습니다. 데이터베이스 튜너들은 실행 계획을 분석하여 스칼라 서브쿼리의 캐시 효율이 떨어진다고 판단되면, 이를 즉각 FROM 절의 명시적인 조인 구문으로 리팩토링(Refactoring)하는 작업을 최우선으로 수행합니다.

     

    요약: SELECT 절에 작성되는 스칼라 서브쿼리는 단 하나의 값만 반환하며 LEFT OUTER JOIN과 동일한 논리적 결과를 냅니다. 데이터베이스 엔진은 반복 연산을 줄이기 위해 입력값과 결과값을 메모리에 저장하는 '캐싱'을 수행하므로, 입력값의 종류가 적고 중복이 많을 때 극강의 성능을 발휘하지만 고유값이 많을 때는 조인으로 대체해야 합니다.

     

     

     

     

    3. 인라인 뷰(Inline View)를 활용한 동적 집합 생성과 뷰 머징

    FROM 절에 괄호를 사용하여 명시되는 서브쿼리를 '인라인 뷰(Inline View)'라고 지칭합니다. CREATE VIEW 명령어를 통해 데이터베이스 데이터 딕셔너리에 물리적인 객체로 영구 저장되는 일반적인 뷰(View)와는 성격이 다릅니다. 인라인 뷰는 해당 SQL 쿼리문이 데이터베이스 엔진에 의해 파싱되고 실행되는 그 찰나의 순간에만 데이터베이스의 임시 테이블스페이스(Temporary Tablespace)나 메모리 영역에 동적으로 생성되었다가, 쿼리 처리가 모두 완료되어 클라이언트에게 결과가 반환되는 즉시 메모리에서 소멸되는 휘발성 가상 테이블입니다. 복잡한 다중 테이블 조인이나 깊은 수준의 그룹화(GROUP BY) 집계 연산이 필요할 때, 쿼리의 복잡도를 논리적인 단계별로 분리하여 가독성을 극대화하고 개발자의 실수를 줄여주는 훌륭한 도구가 됩니다.

    실무 환경에서 인라인 뷰가 가장 광범위하게 활용되는 대표적인 사례는 웹 애플리케이션의 게시판 페이징 처리를 위한 'TOP-N 분석 쿼리'입니다. 수백만 건의 데이터 중에서 최신순으로 정렬된 상위 10건의 게시글만 가져와야 할 때, 데이터베이스의 처리 구조상 무작위로 10건을 먼저 추출한 뒤에 정렬을 하면 완전히 잘못된 엉뚱한 데이터가 반환됩니다. 이러한 문제를 완벽하게 회피하기 위해, 먼저 인라인 뷰 내부에서 ORDER BY 절을 사용해 전체 데이터를 최신순으로 완벽하게 정렬된 임시 결과 집합을 구성합니다. 그 후 바깥쪽의 메인 쿼리에서 ROWNUM(Oracle)이나 LIMIT(MySQL, PostgreSQL) 절을 사용하여 정렬된 가상 테이블의 상단에서부터 안전하게 10개의 행만을 커팅하여 가져오는 방식을 채택하게 됩니다.

     

    이러한 인라인 뷰를 처리할 때 비용 기반 옵티마이저(CBO)는 성능 향상을 위해 '뷰 머징(View Merging)'이라는 매우 정교한 쿼리 변환 기술을 시도합니다. 임시 테이블을 메모리에 물리적으로 생성(Materialization)하는 작업은 필연적으로 오버헤드를 동반하기 때문에, 옵티마이저는 가능하면 인라인 뷰를 허물고 그 내부의 테이블과 조건식들을 메인 쿼리 레벨로 끌어올려 병합하려고 합니다. 뷰 머징이 성공하면 옵티마이저는 메인 쿼리와 인라인 뷰의 테이블들을 통틀어 가장 최적화된 조인 순서(Driving Table 결정)와 조인 알고리즘(Nested Loop, Hash 등)을 재수립할 수 있어 성능이 비약적으로 상승합니다. 다만 인라인 뷰 내부에 GROUP BY나 DISTINCT, 집합 연산자(UNION) 등이 포함되어 있으면 논리적으로 병합이 불가능하므로 뷰 머징은 차단됩니다.

     

    요약: FROM 절에 위치하는 인라인 뷰는 쿼리 실행 시점에만 존재하는 동적인 임시 테이블로, 복잡한 데이터 가공이나 TOP-N 페이징 처리에 필수적입니다. 데이터베이스 옵티마이저는 임시 집합 생성 비용을 줄이기 위해 인라인 뷰를 메인 쿼리와 결합하는 '뷰 머징' 기술을 사용하여 최적의 조인 경로를 찾아냅니다.

     

    [데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서
    [데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류, 특징, 그리고 실행 순서

     

     

    4. 중첩 서브쿼리(Nested Subquery)와 옵티마이저 언네스팅

    데이터를 최종적으로 필터링하여 원하는 결과만을 걸러내기 위해 WHERE 절이나 HAVING 절에 조건식과 함께 사용되는 서브쿼리를 '중첩 서브쿼리(Nested Subquery)'라고 합니다. 중첩 서브쿼리는 반환하는 결과의 건수에 따라 사용할 수 있는 연산자가 엄격하게 나뉘는데, 단일 행 서브쿼리의 경우 등호나 부등호를 사용하지만 실무에서는 주로 다중 행 서브쿼리를 다루며 IN과 EXISTS 연산자를 양대 산맥으로 활용합니다. IN 연산자는 서브쿼리가 반환한 수많은 값들로 이루어진 집합 중에서 메인 쿼리의 컬럼 값과 단 하나라도 일치하는 것이 있는지를 확인하는 조건입니다. 반면 EXISTS 연산자는 데이터의 실제 값을 비교하는 것이 아니라, 서브쿼리 내부에 메인 쿼리의 조건을 만족하는 행이 '존재하는지' 그 유무만을 참과 거짓의 불리언(Boolean) 형태로 판별합니다.

    특히 대용량 데이터베이스 환경에서 EXISTS 연산자는 매우 강력한 무기가 될 수 있습니다. 메인 쿼리에서 한 건의 데이터를 읽어 서브쿼리로 넘겼을 때, 서브쿼리 테이블에 조건을 만족하는 데이터가 수만 건이 있더라도 EXISTS는 첫 번째 일치하는 데이터를 발견하는 그 즉시 검색을 중단(Short-circuit Evaluation)하고 참(True)을 반환한 뒤 다음 메인 쿼리 행으로 넘어갑니다. 따라서 서브쿼리 측 테이블의 데이터가 방대할수록 끝까지 스캔해야 하는 IN 연산자보다 압도적으로 빠른 응답 속도를 보여줄 때가 많습니다. 하지만 이러한 단순 비교 규칙은 과거 규칙 기반 옵티마이저(RBO) 시절의 이야기이며, 최신 RDBMS 환경에서는 엔진이 쿼리를 자체적으로 재작성하기 때문에 상황이 다릅니다.

    현대의 비용 기반 옵티마이저(CBO)는 WHERE 절 깊숙한 곳에 갇혀 있는 중첩 서브쿼리를 그대로 실행하면 루프(Loop) 연산으로 인한 심각한 성능 저하가 발생한다는 것을 알고 있습니다. 따라서 이를 바깥쪽 메인 쿼리 레벨로 끄집어내어 동등한 위치에서 조인을 수행하도록 구조를 변환하는데, 이를 '서브쿼리 언네스팅(Subquery Unnesting, 중첩 해제)'이라고 부릅니다. 언네스팅이 이루어지면 데이터베이스는 서브쿼리를 '세미 조인(Semi-Join)'이라는 고성능 내부 알고리즘으로 처리하게 되며, 이 덕분에 메인 쿼리와 서브쿼리 중 어느 쪽 테이블을 먼저 읽어들일지(Driving)를 통계 정보를 바탕으로 유연하게 결정할 수 있어 데이터가 페타바이트 단위로 커져도 안정적인 성능을 유지할 수 있게 됩니다.

     

    요약: WHERE 절의 중첩 서브쿼리는 데이터 필터링을 목적으로 하며, 대용량 처리 시 검색을 조기 종료하는 EXISTS 연산자가 유리할 때가 많습니다. 하지만 최신 옵티마이저는 '서브쿼리 언네스팅'을 통해 중첩된 구조를 해체하고 고성능의 세미 조인으로 변환하여 스스로 최적의 실행 계획을 도출해냅니다.

     

     

     

     

    5. 연관성(Correlation) 기반 논리적 실행 순서와 튜닝 인사이트

    서브쿼리가 데이터베이스 내부에서 언제 실행되는지 정확한 타이밍을 파악하려면, SQL 구문의 논리적 처리 순서(FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY)뿐만 아니라 서브쿼리의 '연관성(Correlation)' 여부를 복합적으로 이해해야 합니다. 서브쿼리 내부에 메인 쿼리 테이블의 컬럼을 전혀 참조하지 않는 독립적인 쿼리를 '비연관 서브쿼리(Uncorrelated Subquery)'라고 합니다. 이 경우 데이터베이스는 바깥쪽 메인 쿼리보다 서브쿼리를 가장 먼저 단 한 번만 실행하여 상수 형태의 완벽한 결과 집합을 도출해 냅니다. 그 후 메인 쿼리는 이 고정된 상숫값을 바탕으로 조건 필터링이나 데이터 추출 작업을 일괄적으로 수행하므로, CPU 사이클 낭비가 전혀 없는 매우 효율적인 처리 방식을 보입니다.

     

    반면에 서브쿼리 내부의 조인 조건이나 WHERE 절에 바깥쪽 메인 쿼리의 특정 컬럼을 명시적으로 참조하고 있다면, 이는 '연관 서브쿼리(Correlated Subquery)'로 분류되며 엔진의 실행 패러다임이 180도 뒤바뀝니다. 연관 서브쿼리는 절대로 혼자서 먼저 실행될 수 없습니다. 반드시 메인 쿼리가 먼저 실행되어 테이블에서 레코드 한 줄을 읽어들인 다음, 해당 행의 식별자(ID) 값을 서브쿼리 내부로 주입해 주어야만 비로소 서브쿼리가 동작할 수 있습니다. 즉, 메인 쿼리에서 100만 건의 데이터를 추출한다면, 그 100만 건의 각 행마다 서브쿼리가 매번 새롭게 호출되어 무려 100만 번의 반복 실행(Looping)이 발생하게 되는 셈입니다. 이러한 구조는 필연적으로 디스크 I/O 폭주를 일으키는 병목 구간(Bottleneck)이 됩니다.

    따라서 실무 데이터베이스 튜닝의 핵심은 불필요하게 작성된 연관 서브쿼리를 색출하여 제거하는 데 있습니다. 만약 옵티마이저가 통계 정보의 오류나 복잡한 쿼리 구조로 인해 서브쿼리 언네스팅(Unnesting)에 실패하고 반복 연산을 수행하는 최악의 실행 계획을 세웠다면, 개발자는 직접 개입해야 합니다. 서브쿼리를 삭제하고 메인 쿼리의 FROM 절에 두 테이블을 나란히 배치하여 명시적인 일반 조인(Inner Join)으로 변경하거나, 스칼라 서브쿼리 떡칠로 인해 속도가 느려진다면 인라인 뷰로 데이터를 한 번에 집계한 뒤 조인하는 방식으로 구조를 전면 개편(Rewrite)해야 합니다. 특히 NOT IN 연산자를 쓸 때 서브쿼리 결과에 단 하나의 NULL 값이라도 섞여 있으면 전체 결과가 0건으로 증발해버리는 치명적인 논리적 함정이 있으므로, 반드시 NOT EXISTS로 대체하거나 IS NOT NULL 조건을 추가하는 방어적 SQL 작성을 생활화해야 합니다.

     

    요약: 메인 쿼리를 참조하지 않는 비연관 서브쿼리는 단 1회만 먼저 실행되어 빠르지만, 메인 쿼리 컬럼을 참조하는 연관 서브쿼리는 행 건수만큼 무한 반복 실행되어 심각한 성능 저하를 일으킬 수 있습니다. 실무 튜닝에서는 이러한 반복 연산을 막기 위해 명시적인 조인 구문으로 쿼리를 리팩토링하고, NOT IN 사용 시 NULL 예외 처리를 철저히 하는 것이 필수입니다.

     

    [데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서[데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류&amp;#44; 특징&amp;#44; 그리고 실행 순서
    [데이터베이스] 서브쿼리(Subquery)의 완벽 이해: 종류, 특징, 그리고 실행 순서