JPA를 쓰는데 SQL을 왜 알아야 할까요?

Jayson·2025년 9월 24일
0
post-thumbnail

2025년 9월 24일 ・ 읽는 시간 15분

ORM의 역설: JPA 개발자는 왜 SQL을 마스터해야 할까요?

Java Persistence API(JPA) 같은 객체-관계 매핑(ORM) 프레임워크, 정말 편하죠. 반복적인 CRUD SQL 코드를 줄여주고, 객체 지향적으로 개발에 집중할 수 있게 도와주니까요. 개발자는 SQL보다 비즈니스 로직에 더 많은 시간을 쏟을 수 있게 됐어요.

하지만 이런 편리함 뒤에는 종종 잊히는 사실이 있어요. JPA가 모든 데이터베이스 문제를 해결해 주는 '만능 열쇠'는 아니라는 점이에요. 오히려 JPA를 더 깊이 있게 사용하고 진짜 데이터 전문가로 성장하려면, 역설적이게도 순수 SQL(Native SQL)을 잘 알아야 해요.

JPA가 힘들어하는 몇 가지 지점들을 살펴볼까요?

  • 복잡한 리포팅 및 분석 쿼리: 수십 개 테이블을 조인하고 여러 단계로 데이터를 집계해야 하는 복잡한 통계 쿼리는 JPQL이나 Criteria API만으로는 작성하기 어렵거나 비효율적일 수 있어요. SQL은 이런 작업을 위해 만들어진 언어라 훨씬 직관적이고 강력하죠.
  • 성능 최적화의 한계: JPA가 만들어주는 SQL은 대부분 효율적이지만, 가끔은 최적이 아닐 때가 있어요. 실행 계획을 분석하고 인덱스 사용을 유도하거나, 데이터베이스별 힌트(Hint)를 사용해 성능을 극한까지 끌어올려야 할 땐 네이티브 SQL을 직접 다루는 게 유일한 해결책일 수 있습니다.
  • 데이터베이스 고유 기능 활용: JPQL은 특정 데이터베이스에 종속되지 않는 것을 목표로 해요. 그래서 Oracle, PostgreSQL 등이 제공하는 강력한 고유 함수를 쓸 수 없어요. 예를 들어 분석에 유용한 윈도우 함수(Window Functions)나 계층 데이터를 다루는 재귀 쿼리(Recursive Queries)는 네이티브 SQL로만 사용할 수 있죠.

이런 기술적인 이유 말고도, 기술 면접에서 SQL 실력을 물어보는 건 지원자가 프레임워크 사용법만 아는 게 아니라 데이터 처리의 근본 원리를 이해하는지 확인하기 위해서예요. SQL을 아는 개발자는 문제가 생겼을 때 더 깊이 원인을 분석하고 해결할 수 있거든요.

결국 JPA와 SQL은 서로를 대체하는 관계가 아니라, 힘을 합치는 상호 보완 관계예요.


1부: 데이터 조작의 초석 - 필수 SQL 패턴

모든 복잡한 쿼리는 기본적인 패턴의 조합이에요. 여기서는 가장 핵심적인 JOIN과 GROUP BY를 깊이 있게 다뤄볼게요.

점들을 연결하다: JOIN 연산 심층 분석

관계형 데이터베이스의 힘은 데이터를 여러 테이블에 나눠 저장하고, 필요할 때 합쳐서 의미 있는 정보를 만드는 데 있어요. JOIN이 바로 이 '합치는' 역할을 하죠.

INNER JOIN (교집합)
두 테이블에 공통으로 존재하는 데이터, 즉 교집합만 가져오는 가장 기본적인 조인이에요.

  • 이럴 때 써요: "한 번이라도 주문한 모든 사용자의 정보가 궁금해요."
    • users 테이블과 orders 테이블 양쪽에 모두 user_id가 있는 사용자만 찾아야 해요.
SELECT
    u.user_id,
    u.name,
    u.email,
    o.order_id,
    o.order_date
FROM
    users u
INNER JOIN
    orders o ON u.user_id = o.user_id;

LEFT JOIN (기준 테이블 + 선택적 데이터)
왼쪽(먼저 선언된) 테이블의 모든 데이터를 포함하고, 오른쪽 테이블에서는 조건에 맞는 데이터만 가져와요. 맞는 데이터가 없으면 그 자리는 NULL로 채워지죠. '데이터가 없는 경우'를 찾을 때 아주 중요해요.

  • 이럴 때 써요: "모든 사용자 목록과 주문 날짜를 보여주세요. 주문한 적 없는 사용자도 꼭 포함해서요."
    • 기준이 되는 users 테이블의 모든 데이터를 유지해야 하므로 LEFT JOIN이 필요해요.
SELECT
    u.user_id,
    u.name,
    o.order_id,
    o.order_date
FROM
    users u
LEFT JOIN
    orders o ON u.user_id = o.user_id;

이 쿼리를 실행하면, 주문 기록이 없는 사용자의 order_idorder_date는 NULL로 표시돼요.

이 특징을 응용하면 특정 데이터가 '존재하지 않음'을 필터링할 수 있어요.

  • 응용편: "지금까지 한 번도 주문하지 않은 사용자를 찾아주세요."
SELECT
    u.user_id,
    u.name
FROM
    users u
LEFT JOIN
    orders o ON u.user_id = o.user_id
WHERE
    o.order_id IS NULL; -- 주문 정보가 없는(NULL) 사용자만 필터링

SQL JOIN Cheat Sheet

JOIN 유형설명주요 비즈니스 질문 예시
INNER JOIN두 테이블에 공통으로 존재하는 행만 반환합니다."결제 기록이 있는 고객은 누구인가?"
LEFT JOIN왼쪽 테이블의 모든 행과, 오른쪽 테이블에서 일치하는 행을 반환합니다. (불일치 시 NULL)"모든 고객 목록과 그들의 구매 내역을 보여줘. 구매 안 한 고객도 포함해서."
RIGHT JOIN오른쪽 테이블의 모든 행과, 왼쪽 테이블에서 일치하는 행을 반환합니다. (불일치 시 NULL)"구매된 모든 상품과 구매한 고객 정보를 보여줘. 아직 팔리지 않은 상품은 제외."
FULL OUTER JOIN두 테이블 중 어느 한쪽에라도 데이터가 존재하면 모든 행을 반환합니다. (불일치 시 NULL)"영업 사원 목록과 고객 목록을 모두 보여주고, 서로 매칭되는 담당 관계를 표시해줘."

데이터 요약: GROUP BY와 집계 함수 마스터하기

로우 데이터(raw data)를 의미 있는 정보로 요약하는 과정은 필수적이에요.

핵심 집계 함수, 뭐가 있을까요?

  • COUNT(): 행의 개수를 세요.
  • SUM(): 숫자 컬럼의 합계를 계산해요.
  • AVG(): 숫자 컬럼의 평균을 계산해요.
  • MIN(): 컬럼의 최솟값을 찾아요.
  • MAX(): 컬럼의 최댓값을 찾아요.

GROUP BY 절은 이런 집계 함수들이 특정 그룹별로 동작하게 만들어요. 이게 바로 데이터 분석의 핵심이죠.

  • 시나리오 1: "각 사용자별로 총 몇 건의 주문을 했는지 계산하라."
SELECT
    user_id,
    COUNT(order_id) AS order_count
FROM
    orders
GROUP BY
    user_id;

WHERE와 HAVING, 뭐가 다른가요?
둘 다 데이터를 필터링하지만, 동작하는 시점이 완전히 달라요. 면접 단골 질문이기도 하죠.

  • WHERE: 그룹화 , 개별 행에 대해 필터링해요.
  • HAVING: 그룹화 , 집계 결과에 대해 필터링해요.

WHERE 절에서는 SUM(), COUNT() 같은 집계 함수를 쓸 수 없어요. 집계 값은 GROUP BY가 실행된 후에야 계산되니까요.

  • 시나리오: "2023년 이후에 3번 이상 주문한 우수 고객을 찾아라."
SELECT
    user_id,
    COUNT(order_id) AS order_count
FROM
    orders
WHERE
    order_date >= '2023-01-01' -- 1. 그룹화 전, 개별 행 필터링
GROUP BY
    user_id
HAVING
    COUNT(order_id) >= 3; -- 2. 그룹화 후, 집계 결과 필터링

2부: 복잡한 로직에서 우아한 쿼리로 - 고급 SQL 기법

기본기를 넘어, 복잡한 비즈니스 문제를 해결하는 고급 SQL 기법들을 알아볼게요.

복잡성 길들이기: 서브쿼리와 CTE의 기술

하나의 문제를 여러 단계로 나눠 해결해야 할 때, 서브쿼리(Subquery)나 CTE(Common Table Expression)를 사용해요.

서브쿼리 (Nested Queries)
다른 SQL 문 안에 중첩된 SELECT 문이에요.

  • 스칼라 서브쿼리 (SELECT 절): 단일 값을 반환해요.

    • 예시: "각 상품 가격과 전체 상품의 평균 가격을 나란히 표시하라."
    SELECT
        name,
        price,
        (SELECT AVG(price) FROM products) AS overall_average_price
    FROM
        products;
  • 다중 행 서브쿼리 (WHERE... IN 절): 목록을 반환하며 IN, NOT IN 등과 함께 쓰여요.

    • 예시: "'JPA 마스터클래스' 책을 주문한 모든 사용자를 찾아라."
    SELECT *
    FROM users
    WHERE user_id IN (
        SELECT o.user_id
        FROM orders o
        JOIN order_items oi ON o.order_id = oi.order_id
        JOIN products p ON oi.product_id = p.product_id
        WHERE p.name = 'JPA 마스터클래스'
    );

공통 테이블 표현식 (CTEs - WITH 절)
서브쿼리가 중첩되어 쿼리가 복잡해지면 가독성이 떨어져요. CTE는 WITH 키워드를 사용해 복잡한 서브쿼리를 대체하는 현대적이고 우아한 대안이죠.

  • 장점 1: 가독성 향상

    • CTE는 쿼리의 논리적 단계를 순차적으로 명확하게 보여줘요.
  • 장점 2: 재사용성

    • 하나의 CTE는 주 쿼리 내에서 여러 번 참조될 수 있어 반복을 줄여줘요.
  • 시나리오: "각 상품 카테고리별로 가장 많이 팔린 상품의 이름과, 해당 카테고리의 총 매출액을 함께 조회하라."

WITH category_sales AS (
    -- 1. 카테고리별 총 매출 계산
    SELECT
        p.category,
        SUM(oi.quantity * p.price) AS total_category_sales
    FROM products p
    JOIN order_items oi ON p.product_id = oi.product_id
    GROUP BY p.category
),
ranked_products_by_category AS (
    -- 2. 카테고리 내 상품별 판매량 순위 계산
    SELECT
        p.category,
        p.name AS product_name,
        ROW_NUMBER() OVER(PARTITION BY p.category ORDER BY SUM(oi.quantity) DESC) as rn
    FROM products p
    JOIN order_items oi ON p.product_id = oi.product_id
    GROUP BY p.category, p.name
)
-- 3. 두 CTE를 조인하여 최종 결과 도출
SELECT
    cs.category,
    cs.total_category_sales,
    rp.product_name AS top_selling_product
FROM category_sales cs
JOIN ranked_products_by_category rp ON cs.category = rp.category
WHERE rp.rn = 1;

분석가의 툴킷: 윈도우 함수로 통찰력 잠금 해제

윈도우 함수는 현재 행과 관련된 행들의 집합(윈도우)에 대해 계산을 수행하는 강력한 분석 함수예요. GROUP BY처럼 행을 하나로 합치지 않고, 각 행에 대한 계산 결과를 새 컬럼으로 추가하는 점이 가장 큰 특징이죠.

순위(Ranking) 매기기
"각 카테고리 내에서 가격이 비싼 순서대로 상품 순위를 매겨라" 같은 요구사항에 사용돼요.

함수설명동점 처리 방식 (점수: 100, 90, 90, 80)주요 사용 사례
ROW_NUMBER()고유한 행 번호를 부여합니다.1, 2, 3, 4페이징 처리나 고유 식별자 부여
RANK()동점자에게 동일 순위를 부여하고 다음 등수는 건너뜁니다.1, 2, 2, 4일반적인 순위 매기기
DENSE_RANK()동점자에게 동일 순위를 부여하고 다음 등수를 건너뛰지 않습니다.1, 2, 2, 3상위 N개 목록 추출
SELECT
    name,
    category,
    price,
    RANK() OVER(PARTITION BY category ORDER BY price DESC) as price_rank
FROM
    products;

누적 계산
"월별 매출 추이를 보면서, 각 월까지의 누적 매출을 함께 보고 싶다" 같은 요구사항에 쓰여요.

SELECT
    DATE_TRUNC('month', order_date)::DATE AS month,
    SUM(total_amount) AS monthly_sales,
    SUM(SUM(total_amount)) OVER (ORDER BY DATE_TRUNC('month', order_date)) AS cumulative_sales
FROM
    orders
GROUP BY
    month
ORDER BY
    month;

3부: 간극 메우기 - 네이티브 SQL과 Spring Data JPA 통합하기

새롭게 익힌 SQL 기술을 실제 Spring Boot 프로젝트에 적용하는 방법을 알아볼게요.

첫 번째 커스텀 쿼리: @Query(nativeQuery = true) 사용법

JpaRepository 인터페이스에서 네이티브 SQL을 실행하는 가장 직접적인 방법은 @Query 어노테이션을 사용하는 거예요.

  • nativeQuery=true 속성을 설정하면, value 속성의 문자열을 순수 SQL로 인식해요.
  • 이름 기반 파라미터(:paramName)를 사용하는 것이 훨씬 안전하고 직관적이에요.
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(
        value = "SELECT * FROM orders WHERE user_id = :userId AND status = 'COMPLETED'",
        nativeQuery = true
    )
    List<Order> findCompletedOrdersByUserId(@Param("userId") Long userId);
}

INSERT, UPDATE, DELETE 같은 쓰기 작업을 할 때는 @Modifying 어노테이션을 꼭 함께 붙여줘야 해요.

결과 매핑을 DTO로: 엔티티를 넘어서

네이티브 쿼리 결과는 단일 엔티티에 딱 맞지 않는 경우가 많아요. 이때 조회 결과를 담을 전용 DTO(Data Transfer Object)로 매핑하는 것이 가장 좋은 방법이에요.

전략 1: 인터페이스 기반 프로젝션 (The Spring Data Way)
가장 간단하고 권장되는 방법이에요. 조회하려는 SQL 컬럼의 별칭(alias)과 일치하는 getter 메소드를 가진 자바 인터페이스를 정의하면 Spring Data JPA가 알아서 결과를 채워줘요.

  1. 프로젝션 인터페이스 정의

    public interface OrderSummary {
        Long getOrderId();
        java.time.LocalDate getOrderDate();
        String getUserName();
        java.math.BigDecimal getTotalAmount();
    }
  2. Repository 메소드 작성 (컬럼 별칭 사용)

    public interface OrderRepository extends JpaRepository<Order, Long> {
    
        @Query(
            value = "SELECT " +
                    "    o.order_id AS orderId, " +
                    "    o.order_date AS orderDate, " +
                    "    u.name AS userName, " +
                    "    o.total_amount AS totalAmount " +
                    "FROM orders o " +
                    "JOIN users u ON o.user_id = u.user_id " +
                    "WHERE u.user_id = :userId",
            nativeQuery = true
        )
        List<OrderSummary> findOrderSummariesByUserId(@Param("userId") Long userId);
    }

전략 2: 클래스 기반 프로젝션과 @SqlResultSetMapping (The Standard JPA Way)
좀 더 복잡하지만, 표준 JPA가 제공하는 강력한 방법이에요. 엔티티 클래스에 @NamedNativeQuery@SqlResultSetMapping을 함께 정의해서 SQL 쿼리 결과와 DTO 클래스의 생성자를 명시적으로 매핑해요.

항목인터페이스 기반 프로젝션클래스 기반 프로젝션 (@SqlResultSetMapping)
구현 노력낮음. 인터페이스 정의만으로 충분.높음. DTO 클래스, 여러 어노테이션 정의 필요.
Spring 의존성높음. Spring Data JPA의 핵심 기능.낮음. 표준 JPA 명세.
최적 사용 사례대부분의 Read-only 네이티브 쿼리.프레임워크 독립적인 코드가 필요하거나 매우 복잡한 매핑이 필요한 경우.

결론: 당신의 SQL

JPA는 의심할 여지 없이 훌륭한 도구예요. 하지만 진정한 데이터 처리 전문가는 도구에 지배당하지 않고, 도구를 지배하죠.

SQL에 대한 깊은 지식은 JPA 개발자에게 '활력'과도 같아요. 복잡한 데이터 문제를 만났을 때, 데이터베이스에서 우아하고 효율적인 쿼리로 문제를 해결할 수 있는 능력을 갖추게 되는 거죠.

꾸준한 연습을 통해 SQL을 여러분의 가장 강력한 무기 중 하나로 만드시길 바랍니다.

profile
Small Big Cycle

0개의 댓글