2025년 9월 24일 ・ 읽는 시간 15분
Java Persistence API(JPA) 같은 객체-관계 매핑(ORM) 프레임워크, 정말 편하죠. 반복적인 CRUD SQL 코드를 줄여주고, 객체 지향적으로 개발에 집중할 수 있게 도와주니까요. 개발자는 SQL보다 비즈니스 로직에 더 많은 시간을 쏟을 수 있게 됐어요.
하지만 이런 편리함 뒤에는 종종 잊히는 사실이 있어요. JPA가 모든 데이터베이스 문제를 해결해 주는 '만능 열쇠'는 아니라는 점이에요. 오히려 JPA를 더 깊이 있게 사용하고 진짜 데이터 전문가로 성장하려면, 역설적이게도 순수 SQL(Native SQL)을 잘 알아야 해요.
JPA가 힘들어하는 몇 가지 지점들을 살펴볼까요?
이런 기술적인 이유 말고도, 기술 면접에서 SQL 실력을 물어보는 건 지원자가 프레임워크 사용법만 아는 게 아니라 데이터 처리의 근본 원리를 이해하는지 확인하기 위해서예요. SQL을 아는 개발자는 문제가 생겼을 때 더 깊이 원인을 분석하고 해결할 수 있거든요.
결국 JPA와 SQL은 서로를 대체하는 관계가 아니라, 힘을 합치는 상호 보완 관계예요.
모든 복잡한 쿼리는 기본적인 패턴의 조합이에요. 여기서는 가장 핵심적인 JOIN과 GROUP BY를 깊이 있게 다뤄볼게요.
관계형 데이터베이스의 힘은 데이터를 여러 테이블에 나눠 저장하고, 필요할 때 합쳐서 의미 있는 정보를 만드는 데 있어요. 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_id
와 order_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) 사용자만 필터링
JOIN 유형 | 설명 | 주요 비즈니스 질문 예시 |
---|---|---|
INNER JOIN | 두 테이블에 공통으로 존재하는 행만 반환합니다. | "결제 기록이 있는 고객은 누구인가?" |
LEFT JOIN | 왼쪽 테이블의 모든 행과, 오른쪽 테이블에서 일치하는 행을 반환합니다. (불일치 시 NULL) | "모든 고객 목록과 그들의 구매 내역을 보여줘. 구매 안 한 고객도 포함해서." |
RIGHT JOIN | 오른쪽 테이블의 모든 행과, 왼쪽 테이블에서 일치하는 행을 반환합니다. (불일치 시 NULL) | "구매된 모든 상품과 구매한 고객 정보를 보여줘. 아직 팔리지 않은 상품은 제외." |
FULL OUTER JOIN | 두 테이블 중 어느 한쪽에라도 데이터가 존재하면 모든 행을 반환합니다. (불일치 시 NULL) | "영업 사원 목록과 고객 목록을 모두 보여주고, 서로 매칭되는 담당 관계를 표시해줘." |
로우 데이터(raw data)를 의미 있는 정보로 요약하는 과정은 필수적이에요.
핵심 집계 함수, 뭐가 있을까요?
COUNT()
: 행의 개수를 세요.SUM()
: 숫자 컬럼의 합계를 계산해요.AVG()
: 숫자 컬럼의 평균을 계산해요.MIN()
: 컬럼의 최솟값을 찾아요.MAX()
: 컬럼의 최댓값을 찾아요.GROUP BY
절은 이런 집계 함수들이 특정 그룹별로 동작하게 만들어요. 이게 바로 데이터 분석의 핵심이죠.
SELECT
user_id,
COUNT(order_id) AS order_count
FROM
orders
GROUP BY
user_id;
WHERE와 HAVING, 뭐가 다른가요?
둘 다 데이터를 필터링하지만, 동작하는 시점이 완전히 달라요. 면접 단골 질문이기도 하죠.
WHERE
절에서는 SUM()
, COUNT()
같은 집계 함수를 쓸 수 없어요. 집계 값은 GROUP BY
가 실행된 후에야 계산되니까요.
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. 그룹화 후, 집계 결과 필터링
기본기를 넘어, 복잡한 비즈니스 문제를 해결하는 고급 SQL 기법들을 알아볼게요.
하나의 문제를 여러 단계로 나눠 해결해야 할 때, 서브쿼리(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
등과 함께 쓰여요.
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: 가독성 향상
장점 2: 재사용성
시나리오: "각 상품 카테고리별로 가장 많이 팔린 상품의 이름과, 해당 카테고리의 총 매출액을 함께 조회하라."
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;
새롭게 익힌 SQL 기술을 실제 Spring Boot 프로젝트에 적용하는 방법을 알아볼게요.
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(Data Transfer Object)로 매핑하는 것이 가장 좋은 방법이에요.
전략 1: 인터페이스 기반 프로젝션 (The Spring Data Way)
가장 간단하고 권장되는 방법이에요. 조회하려는 SQL 컬럼의 별칭(alias)과 일치하는 getter 메소드를 가진 자바 인터페이스를 정의하면 Spring Data JPA가 알아서 결과를 채워줘요.
프로젝션 인터페이스 정의
public interface OrderSummary {
Long getOrderId();
java.time.LocalDate getOrderDate();
String getUserName();
java.math.BigDecimal getTotalAmount();
}
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 네이티브 쿼리. | 프레임워크 독립적인 코드가 필요하거나 매우 복잡한 매핑이 필요한 경우. |
JPA는 의심할 여지 없이 훌륭한 도구예요. 하지만 진정한 데이터 처리 전문가는 도구에 지배당하지 않고, 도구를 지배하죠.
SQL에 대한 깊은 지식은 JPA 개발자에게 '활력'과도 같아요. 복잡한 데이터 문제를 만났을 때, 데이터베이스에서 우아하고 효율적인 쿼리로 문제를 해결할 수 있는 능력을 갖추게 되는 거죠.
꾸준한 연습을 통해 SQL을 여러분의 가장 강력한 무기 중 하나로 만드시길 바랍니다.