← 목록

서브쿼리 대신 조인, 애플리케이션 조립, 뷰

서브쿼리 없이 같은 결과를 내는 방법이 셋 있습니다. 조인으로 바꾸거나, SQL을 나눠 던지고 애플리케이션에서 붙이거나, 뷰로 빼두는 것입니다.

셋 다 됩니다. 대신 각각 뭔가를 내주게 돼요. 무엇을 내주는지 알고 고르자는 게 이 글입니다.

예로 드는 상황은 하나입니다. 상품 목록에 대표 이미지 한 장씩을 붙이는 화면. 상품 하나에 이미지는 여러 장 달리고, 그중 is_main = 'Y'인 것을 대표로 씁니다.

조인으로 바꾸기

WHERE절의 IN 서브쿼리는 조인으로 다시 쓸 수 있습니다.

-- 서브쿼리
SELECT p.name
  FROM product p
 WHERE p.product_id IN (SELECT i.product_id FROM product_image i);

-- 조인
SELECT p.name
  FROM product p
  JOIN product_image i ON i.product_id = p.product_id;

결과가 다릅니다. 위는 이미지가 5장인 상품도 한 번 나오고, 아래는 다섯 번 나옵니다.

INEXISTS“있느냐”만 묻고 끝내기 때문에 왼쪽 행이 불어나지 않습니다. 조인은 짝을 다 맞춰 늘어놓으니 상대가 1:N이면 그만큼 늘어나요. 조인으로 바꾼 뒤 DISTINCT를 붙이게 됐다면, 그건 대개 애초에 EXISTS가 맞았다는 신호입니다.

그러면 언제 조인이 맞을까요. 상대 테이블의 컬럼이 결과에 필요할 때입니다. IN이나 EXISTS는 판단만 하고 값을 못 가져옵니다. 이미지 URL을 화면에 뿌려야 한다면 조인해야 해요.

정리하면 이렇습니다. 값이 필요하면 조인, 존재 여부만 필요하면 EXISTS. 행이 불어나도 되는지가 그 선택을 다시 검증해 줍니다.

애플리케이션에서 쪼개 조회하고 조립하기

두 번째는 SQL을 나눠 던지고 자바에서 붙이는 방법입니다. 상품 목록과 대표 이미지를 이렇게 얻습니다.

// 1) 상품 목록을 한 번 조회한다
List<Product> products = productMapper.findAll();

// 2) 상품 id를 모아서 이미지를 "한 번에" 조회한다
List<Long> ids = products.stream().map(Product::getId).toList();
List<ProductImage> images = imageMapper.findMainImagesByProductIds(ids);

// 3) 메모리에서 붙인다
Map<Long, String> urlByProductId = images.stream()
        .collect(Collectors.toMap(ProductImage::getProductId, ProductImage::getUrl));

for (Product p : products) {
    p.setMainImage(urlByProductId.get(p.getId()));
}

이 방식의 좋고 나쁨은 2번을 어떻게 짰느냐로 갈립니다. 위처럼 id를 모아 한 번에 조회하면 DB 왕복이 2번입니다. 그런데 이렇게 짜면요.

for (Product p : products) {
    p.setMainImage(imageMapper.findMainImageByProductId(p.getId()));  // 상품마다 한 번씩
}

왕복이 상품 수만큼 늘어납니다. 상품이 100개면 1 + 100 = 101번이에요. 이게 N+1 문제입니다. 같은 “쪼개서 조립하기”인데 하나는 2번이고 하나는 101번입니다. 쪼개기로 마음먹었다면 여기가 갈림길이에요.

그리고 3번 줄에 함정이 하나 더 있습니다. Collectors.toMap키가 중복되면 IllegalStateException을 던집니다. 대표 이미지가 두 장인 상품이 있으면 여기서 터져요. SQL에서 ORA-01427로 터지던 문제가, 자바로 옮겨오면서 다른 예외로 모습만 바꿔 따라온 겁니다. 1행 보장이 없는 데이터는 조립 위치를 옮겨도 그대로 따라옵니다.

1행 보장이 없는 데이터는 어디서 조립하든 문제가 남습니다. 조립 위치를 옮기는 건 해결이 아니에요.

이 방식이 실제로 잃는 것들을 정리하면 이렇습니다.

  • 한 시점의 데이터라는 보장을 잃습니다. Oracle은 하나의 질의가 돌려주는 데이터가 한 시점의 커밋된 데이터임을 보장하는데(문장 수준 읽기 일관성), 이건 한 문장 안에서의 보장입니다. 두 문장으로 쪼개면 그 사이에 데이터가 바뀔 수 있어요. 같은 트랜잭션으로 묶고 격리 수준을 조정하면 다룰 수 있지만, 그건 별도로 챙겨야 하는 일입니다.
  • DB에서 거르고 정렬하는 능력을 잃습니다. “대표 이미지가 있는 상품만 100개”를 뽑는다고 해봅시다. 이미지를 앱에서 붙이면 몇 개를 가져와야 100개가 남는지 알 수 없어요. 페이징이 깨집니다.
  • IN 목록에 상한이 있습니다. Oracle은 목록의 식이 1000개를 넘으면 ORA-01795를 냅니다. 상품 3000개의 이미지를 한 번에 조회하려면 1000개씩 잘라 여러 번 보내야 해요.
  • 다 메모리에 올라옵니다. 조인이라면 DB가 걸러 보냈을 행까지 앱으로 넘어옵니다.

반대로 얻는 것도 분명합니다.

  • SQL이 단순해집니다. 중첩이 얕아지고 각 쿼리를 따로 테스트하기 쉬워집니다.
  • DB가 아닌 곳의 데이터를 섞을 수 있습니다. 이미지 URL이 DB가 아니라 외부 스토리지 API에 있다면 애초에 조인할 방법이 없어요.
  • 캐시를 붙이기 쉽습니다. 자주 안 바뀌는 코드성 데이터라면 두 번째 조회를 아예 안 할 수도 있습니다.

그래서 판단 기준은 이렇습니다. 한 시점의 일관성이 중요하거나, 페이징이나 정렬을 DB에 맡겨야 하면 한 문장으로 둡니다. 반대로 데이터 출처가 여럿이거나 한쪽을 캐시할 수 있으면 쪼개는 게 낫습니다. 다만 쪼갤 거면 반드시 일괄 조회로 짜야 하고, 그렇지 않으면 N+1이 됩니다.

뷰로 빼기

같은 인라인뷰가 여러 쿼리에 반복해서 나온다면 CREATE VIEW로 뽑아둘 수 있습니다. 이름이 생기고 재사용됩니다.

트레이드오프는 성격이 다릅니다. 뷰는 스키마 객체라 만들려면 권한이 있어야 하고, 배포 절차를 타야 하며, 지우거나 바꿀 때 누가 쓰는지 추적해야 합니다. 그리고 뷰 위에 뷰를 얹기 시작하면, 나중에 어떤 쿼리가 실제로 무엇을 읽는지 따라가기 어려워집니다.

한 쿼리 안에서만 쓰고 끝날 것이면 인라인뷰나 WITH로 충분합니다. 여러 쿼리가 같은 정의를 공유해야 하고, 그 정의가 바뀌면 다 같이 바뀌어야 할 때 뷰가 값을 합니다.


참고