Post

SQL JOIN도, JPA Fetch Join도 쓰지 않았던 이유

“우리 팀에서는 JOIN을 쓰지 않습니다. JPA Fetch Join도 마찬가지입니다.”

입사하고 얼마 지나지 않아 팀장님에게 들은 말이다.

솔직히 속으로는 이렇게 생각했다.

‘DB가 가장 잘하는 일을 왜 굳이 애플리케이션에서 하지?’

JOIN 한 번이면 끝날 일을 여러 번 조회해서 합치면 네트워크 비용은 늘고, 코드도 길어진다. 데이터베이스 옵티마이저는 괜히 있는 것이 아니고, 정합성까지 생각하면 JOIN을 사용하는 편이 훨씬 자연스러워 보였다.

더 당황스러웠던 것은 SQL JOIN만 금지된 것이 아니라는 점이었다. JPA를 사용하면서 N+1 문제를 만났을 때 가장 먼저 떠올리게 되는 fetch join도 사용할 수 없었다.

그때의 나는 확신했다.

이 규칙은 비효율적이라고!

그런데 여러 개발자의 입사와 퇴사를 지켜보고, 그들이 남긴 복잡한 쿼리를 유지보수하면서 생각이 완전히 바뀌었다.

JOIN을 금지한 이유는 데이터베이스가 느려서가 아니었다.

사람이 바뀌어도 시스템은 계속 살아남아야 했기 때문이다.


먼저 결론: SQL JOIN과 JPA Fetch Join을 쓰지 않은 이유

우리 팀이 SQL JOIN을 피한 이유는 성능이 아니라 유지보수성 때문이었다.

우리 팀에서는 SQL JOIN뿐 아니라 JPA가 연관 객체를 함께 조회하도록 제공하는 fetch join도 사용하지 않았다. 여러 도메인을 가로지르는 JOIN이 각 영역을 강하게 결합했고, 담당자가 바뀔 때마다 쿼리를 이해하고 수정하는 비용을 키웠기 때문이다. 대신 필요한 데이터를 도메인별로 일괄 조회하고, 오케스트레이션 서비스에서 결과를 병렬로 조합했다.


SQL 한 줄 뒤에 숨은 비용

JOIN은 강력하다. 필요한 데이터가 여러 테이블에 흩어져 있어도 한 번의 쿼리로 원하는 결과를 만들 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
select
    o.id,
    o.order_status,
    m.name,
    p.product_name,
    c.coupon_name
from orders o
join member m on m.id = o.member_id
join order_item oi on oi.order_id = o.id
join product p on p.id = oi.product_id
left join coupon c on c.id = o.coupon_id
where o.id = ?;

처음 작성한 개발자에게는 아주 명확한 쿼리다. 주문에 회원, 상품, 쿠폰 정보를 붙였을 뿐이다.

하지만 시간이 지나면 상황이 달라진다.

  • 회원의 탈퇴 정책이 바뀐다.
  • 상품 데이터의 관리 기준이 달라진다.
  • 쿠폰 테이블이 개편된다.
  • 주문 상태를 판단하는 조건이 추가된다.
  • 원래 쿼리를 작성한 개발자는 회사를 떠난다.

그때부터 이 SQL은 단순한 조회문이 아니다. 여러 도메인의 사정과 예외가 한곳에 눌러 담긴 압축 파일이 된다.

누군가 조건 하나를 수정하려면 먼저 테이블 관계를 풀어보고, 각 JOIN이 왜 필요한지 추적하고, 중복 행이 생기지는 않는지 확인해야 한다. 실행 계획과 인덱스도 다시 살펴야 한다. 잘못 건드리면 전혀 예상하지 못한 화면이나 배치가 영향을 받을 수도 있다.

쿼리는 한 번에 실행됐지만, 그 쿼리를 이해하기 위해 여러 사람의 시간이 반복해서 사용됐다.

결국 우리 팀이 아끼고 싶었던 것은 몇 번의 DB 호출이 아니었다.

개발자의 시간이었다.


JPA Fetch Join도 예외는 아니었다

JPA를 사용하는 개발자에게 fetch join은 아주 매력적인 선택지다. 지연 로딩된 연관 객체를 하나씩 조회하면서 발생하는 N+1 문제를 한 번의 쿼리로 해결할 수 있기 때문이다.

예를 들어 주문과 주문 상품을 함께 가져오려면 보통 다음과 같은 JPQL을 작성한다.

1
2
3
4
5
6
7
@Query("""
    select distinct o
    from Order o
    join fetch o.orderItems
    where o.id = :orderId
    """)
Optional<Order> findDetailById(Long orderId);

코드는 짧고 의도도 분명하다. 하지만 JPA의 fetch join은 마법이 아니다. 영속성 제공자는 이 JPQL을 연관 테이블에 대한 INNER JOIN 또는 LEFT JOIN SQL로 변환한다. 결국 SQL JOIN을 사용하지 않는다는 팀의 규칙은 fetch join에도 그대로 적용됐다.

처음에는 이 부분이 가장 답답했다.

‘JOIN도 안 되고 fetch join도 안 되면 N+1은 어떻게 막으라는 거지?’

중요한 것은 fetch join을 단순히 삭제하는 것이 아니었다. 연관 관계를 LAZY로 둔 채 반복문에서 접근하면 조회 쿼리가 데이터 개수만큼 실행되어 오히려 상황이 나빠진다.

우리 팀은 하나의 엔티티 그래프를 통째로 불러오는 대신, 필요한 식별자를 먼저 구한 뒤 각 도메인에서 필요한 데이터를 일괄 조회했다.

1
2
3
4
5
6
7
8
Order order = orderService.getOrder(orderId);
List<Long> productIds = orderItemService.getProductIds(orderId);

Member member = memberService.getMember(order.getMemberId());
List<Product> products = productService.getProducts(productIds); // WHERE id IN (...)
Coupon coupon = couponService.getCoupon(order.getCouponId());

return OrderDetailResponse.of(order, member, products, coupon);

상품이 100개라고 해서 상품 조회 쿼리를 100번 실행하는 것이 아니다. 상품 ID를 모아 IN 조건으로 한 번에 조회한다. 이렇게 하면 fetch join을 사용하지 않으면서도 N+1을 피하고, 상품 조회 규칙은 ProductService 안에 남길 수 있다.

물론 fetch join 자체가 나쁜 기능이라는 뜻은 아니다. 하나의 도메인이나 애그리거트 안에서 필요한 연관 객체를 명확하게 함께 읽어야 한다면 여전히 간결하고 효과적인 방법이다. 우리 팀은 그 편리함보다 도메인 사이의 경계를 지키는 일을 더 중요하게 선택했을 뿐이다.


문제는 JOIN이 아니라 경계가 사라지는 것이었다

테이블 몇 개를 JOIN했다고 코드가 곧바로 나빠지는 것은 아니다. 문제는 하나의 쿼리가 서로 다른 도메인의 규칙까지 알기 시작할 때 생긴다.

주문을 조회하는 쿼리가 회원의 상태를 해석하고, 상품의 판매 정책을 판단하고, 쿠폰의 유효 조건까지 계산한다면 어떻게 될까?

회원 정책이 바뀌었는데 주문 쿼리를 수정해야 하고, 쿠폰 구조를 개편했는데 상품 조회 API까지 테스트해야 한다. 도메인 하나의 변화가 거대한 쿼리를 타고 다른 영역으로 번진다.

코드는 분명 한곳에 모여 있지만 책임은 오히려 흐릿해진다.

그리고 개발자가 자주 바뀌는 팀에서는 이 비용이 더 크게 돌아왔다. 새로 합류한 사람은 기능 하나를 수정하기 위해 업무 규칙보다 테이블 관계부터 공부해야 했다. 어렵게 파악한 지식은 문서보다 개인의 머릿속에 남았고, 그 사람이 떠나면 다음 사람이 같은 비용을 다시 지불했다.

우리 팀이 JOIN을 피하려 했던 진짜 이유는 여기에 있었다.

쿼리의 복잡도가 조직의 기억력에 의존하지 않게 만들자.


우리가 선택한 방법: 도메인은 조회하고, 오케스트레이터는 조합한다

팀은 복잡한 JOIN 하나 대신 역할을 나누기로 했다.

각 도메인 서비스는 자신이 소유한 데이터와 규칙만 책임진다. 여러 도메인의 결과가 필요한 화면이나 API에서는 오케스트레이션 서비스가 각각을 호출한 뒤 최종 응답을 조합한다.

1
2
3
4
5
6
7
8
OrderOrchestrationService
 ├─ OrderService       → 주문 조회
 ├─ OrderItemService   → 상품 식별자 조회
 ├─ MemberService      → 회원 조회
 ├─ ProductService     → 상품 조회
 └─ CouponService      → 쿠폰 조회
                ↓
          최종 응답 조합

서로 의존하지 않는 조회는 병렬로 실행해 지연 시간을 줄였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public OrderDetailResponse getOrderDetail(Long orderId) {
    Order order = orderService.getOrder(orderId);
    List<Long> productIds = orderItemService.getProductIds(orderId);

    CompletableFuture<Member> memberFuture =
        CompletableFuture.supplyAsync(() -> memberService.getMember(order.getMemberId()));

    CompletableFuture<List<Product>> productsFuture =
        CompletableFuture.supplyAsync(() -> productService.getProducts(productIds));

    CompletableFuture<Coupon> couponFuture =
        CompletableFuture.supplyAsync(() -> couponService.getCoupon(order.getCouponId()));

    CompletableFuture.allOf(memberFuture, productsFuture, couponFuture).join();

    return OrderDetailResponse.of( order,
                                   memberFuture.join(),
                                   productsFuture.join(),
                                   couponFuture.join() );
}

이제 회원 정책은 MemberService에서, 상품 정책은 ProductService에서 확인할 수 있다. 주문 상세 응답에 무엇이 필요한지는 OrderOrchestrationService를 보면 된다.

코드는 SQL 한 줄보다 길어졌다. 대신 데이터가 어디서 오고, 누가 그 데이터를 책임지며, 어떤 순서로 조합되는지가 눈에 보이기 시작했다.

특히 사람이 바뀌었을 때 차이가 컸다. 새로 온 개발자가 전체 테이블 구조를 한꺼번에 외우지 않아도 맡은 도메인부터 이해할 수 있었다. 변경의 영향 범위도 이전보다 좁고 명확해졌다.

DB의 JOIN을 코드로 옮긴 것이 아니라, 뒤섞여 있던 책임을 다시 나눈 셈이다.


애플리케이션 조합도 공짜는 아니다

여기까지만 읽으면 오케스트레이션이 JOIN보다 항상 좋은 방법처럼 보일 수 있다. 전혀 그렇지 않다.

애플리케이션에서 데이터를 조합하면 분명한 비용이 생긴다.

  • DB 또는 서비스 호출 횟수가 늘어난다.
  • 병렬 실행을 위한 스레드 풀과 타임아웃 관리가 필요하다.
  • 일부 조회만 실패했을 때의 처리 기준을 정해야 한다.
  • 한 트랜잭션 안에서 조회하는 것과 같은 시점의 정합성을 보장하기 어렵다.
  • 조회 결과가 많으면 애플리케이션의 메모리 사용량이 커질 수 있다.
  • fetch join을 제거한 뒤 연관 객체를 무심코 순회하면 N+1 문제가 더 심각해질 수 있다.

위 예제 역시 개념을 단순하게 보여주기 위한 코드다. 실제 운영에서는 공용 풀을 그대로 사용하기보다 작업 특성에 맞는 실행기를 두고, 타임아웃과 예외 처리, 취소와 모니터링까지 함께 설계해야 한다.

그러므로 우리의 결정은 “JOIN은 나쁘다”가 아니었다.

성능과 정합성에서 얻는 이점보다 도메인 분리와 유지보수성에서 얻는 이점이 더 큰 구간에서는 JOIN을 피한다에 가까웠다.


SQL·Fetch Join과 애플리케이션 조합 비교

두 방식의 차이를 정리하면 다음과 같다.

판단 기준SQL JOIN·JPA Fetch Join애플리케이션 조합
호출 비용한 번의 쿼리로 처리 가능여러 조회가 필요하며 병렬화할 수 있음
데이터 정합성한 트랜잭션의 일관된 조회에 유리조회 시점이 달라질 수 있음
대량 데이터 처리DB에서 필터링·집계하기에 유리전송량과 애플리케이션 메모리 부담이 커질 수 있음
도메인 결합도여러 도메인을 가로지르면 결합도가 커질 수 있음도메인별 책임과 소유권을 분리하기 쉬움
장애 처리쿼리 성공과 실패가 비교적 명확함부분 실패, 타임아웃, 재시도를 설계해야 함
유지보수관계가 단순할 때 간결함코드는 길어지지만 데이터 흐름이 명시적임

어느 한쪽이 항상 우월한 것은 아니다. 데이터의 성격과 팀이 감당할 수 있는 비용에 따라 선택해야 한다.


이제야 이해한 말

처음에는 무조건 JOIN을 사용하는 것이 이득이라고 생각했다. JPA에서는 fetch join까지 사용할 수 있으니 N+1도 간단히 해결할 수 있었다. DB 호출은 적고, 쿼리 한 번이면 원하는 결과를 얻을 수 있으니 기술적으로도 깔끔해 보였다.

하지만 팀의 관점에서 보니 계산식이 달랐다.

1
2
3
4
5
6
7
8
시스템의 비용
= 쿼리 실행 시간
    + 코드를 이해하는 시간
    + 변경의 영향을 확인하는 시간
    + 잘못된 수정에서 복구하는 시간
    + 떠난 사람의 지식을 다시 배우는 시간
    

우리는 가장 적은 쿼리를 선택한 것이 아니었다. 가장 오래 유지할 수 있는 구조를 선택한 것이었다.

물론 이 방식이 모든 팀의 정답은 아니다. 데이터의 규모, 정합성 요구사항, 팀의 숙련도와 조직 구조가 다르면 답도 달라진다.

결국 좋은 설계는 유명한 원칙을 그대로 따르는 일이 아니라, 우리 팀이 계속 감당할 수 있는 비용을 선택하는 일에 가깝다.

그때는 이해하지 못했던 팀장님의 말을 이제는 이렇게 받아들인다.

가장 좋은 쿼리는 한 번에 모든 것을 가져오는 쿼리가 아니라, 다음 사람이 안심하고 바꿀 수 있는 쿼리일지도 모른다.

정답은 없다. 그 시점의 팀이 감당할 수 있는 최선의 선택만 있을 뿐이다.


함께 읽으면 좋은 글