기본 콘텐츠로 건너뛰기

스프링에서 JPA를 쓰는 이유: DAO 반복 코드를 줄이고 데이터 접근을 더 안정적으로 설계하는 법

스프링에서 JPA를 쓰는 이유: DAO 반복 코드를 줄이고 데이터 접근을 더 안정적으로 설계하는 법

빠른 답

  • JPA는 반복 CRUD와 DAO 구현을 줄여 도메인 로직에 더 집중하게 해준다.
  • Spring Data JPA를 쓰면 메서드 이름 쿼리, 페이징, 정렬, 감사 기능을 같은 리포지토리 모델로 다룰 수 있다.
  • 변경 감지와 트랜잭션 관리 덕분에 업데이트 로직이 단순해진다.
  • 다만 생성된 SQL을 믿고 넘기면 안 되고, 로그와 실행 계획으로 성능을 확인해야 한다.

흐름으로 보기

스프링에서 JPA를 쓰는 이유: DAO 반복 코드를 줄이고 데이터 접근을 더 안정적으로 설계하는 법 흐름 다이어그램

JPA를 쓰는 핵심은 단순히 코드를 덜 쓰는 것이 아닙니다. 엔티티로 테이블 구조를 표현하고, 리포지토리로 조회 의도를 선언하고, 영속성 컨텍스트와 트랜잭션으로 변경을 반영하는 흐름을 일관되게 가져가는 데 있습니다. 그래서 JPA를 제대로 쓰려면 “코드가 짧아졌다”보다 “어떤 SQL이 언제 실행되는가”를 더 먼저 봐야 합니다.

왜 JPA가 필요한가

JDBC나 DAO 중심으로 데이터 접근 계층을 직접 쌓으면 처음에는 명확해 보입니다. 하지만 기능이 조금만 늘어나도 SQL 문자열, 파라미터 바인딩, 결과 매핑, 예외 처리, 트랜잭션 경계가 여러 곳에 반복됩니다. 조회 조건이 늘어나면 DAO 메서드는 금방 비대해지고, 서비스 계층은 비즈니스 규칙보다 데이터 접근 세부사항을 더 많이 신경 쓰게 됩니다.

예를 들어 주문 목록 하나만 생각해도 금방 복잡해집니다. 상태별 조회, 기간 조건, 정렬, 페이지네이션, 총 개수 조회, 수정 시 동시성 고려가 붙는 순간 DAO는 단순한 저장소가 아니라 쿼리 조립기처럼 변합니다. 이런 상황에서 JPA는 객체와 테이블 매핑, 엔티티 상태 관리, 변경 감지, 쿼리 모델을 공통 규칙으로 묶어 줍니다. 결국 애플리케이션 전체가 같은 방식으로 데이터를 읽고 쓰게 됩니다.

JPA와 Spring Data JPA가 줄여주는 반복

여기서 먼저 구분할 점이 있습니다. JPA는 표준 명세이고, Spring Data JPA는 그 위에서 리포지토리 작성 경험을 더 편하게 만드는 도구입니다. 실무에서는 둘을 같이 쓰지만 역할은 다릅니다.

JPA가 담당하는 핵심은 다음입니다.

  • 엔티티와 테이블 매핑
  • 영속성 컨텍스트
  • 변경 감지
  • JPQL
  • 트랜잭션 안에서의 엔티티 상태 관리

Spring Data JPA는 여기에 다음을 더해 줍니다.

  • JpaRepository 기반 기본 CRUD
  • 메서드 이름으로 만드는 조회 쿼리
  • Pageable, Sort
  • 감사 필드 관리
  • 리포지토리 인터페이스 중심 개발

덕분에 개발자는 매번 DAO 구현 클래스를 만들지 않아도 됩니다. 다만 반복 코드가 줄어드는 대신, 보이지 않는 SQL이 늘어날 수 있습니다. 그래서 생산성 이점은 “로그와 실행 계획을 확인하는 습관”이 있을 때만 유지됩니다.

엔티티와 영속성 컨텍스트는 어떻게 움직이나

아래 예시는 주문을 조회하고 상태를 결제로 바꾸는 전형적인 구조입니다. 중요한 점은 pay()에서 save()를 다시 호출하지 않는다는 것입니다. 조회된 엔티티가 이미 영속 상태라면, 트랜잭션 안에서 바뀐 값을 JPA가 커밋 시점에 반영합니다.

@Entity
@Table(
    name = "orders",
    indexes = {
        @Index(name = "idx_orders_status_created_at", columnList = "status, created_at")
    }
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "customer_name", nullable = false)
    private String customerName;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private OrderStatus status;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    public void pay() {
        if (status != OrderStatus.READY) {
            throw new IllegalStateException("READY 상태만 결제할 수 있습니다.");
        }
        this.status = OrderStatus.PAID;
    }
}

public interface OrderRepository extends JpaRepository<Order, Long> {
    Page<Order> findByStatusOrderByCreatedAtDesc(OrderStatus status, Pageable pageable);
}

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {

    private final OrderRepository orderRepository;

    public Page<Order> findPaidOrders(int page) {
        return orderRepository.findByStatusOrderByCreatedAtDesc(
            OrderStatus.PAID,
            PageRequest.of(page, 20)
        );
    }

    @Transactional
    public void pay(Long orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new IllegalArgumentException("주문이 없습니다. id=" + orderId));

        order.pay();
    }
}

이 방식의 장점은 서비스 코드가 “업데이트 SQL 작성”이 아니라 “도메인 규칙 적용”에 집중한다는 점입니다. 반대로 함정도 분명합니다. 트랜잭션 밖에서 지연 로딩이 발생하면 예외가 날 수 있고, 대량 수정 쿼리를 실행한 뒤 영속성 컨텍스트를 정리하지 않으면 메모리에 남아 있는 엔티티 상태와 DB 상태가 어긋날 수 있습니다.

Spring Boot에서 먼저 켜야 할 최소 설정

JPA를 붙일 때 가장 먼저 해야 할 일은 SQL이 보이게 만드는 것입니다. 생성된 쿼리와 바인딩 값을 확인할 수 있어야 메서드 이름 쿼리든 @Query든 성능을 검증할 수 있습니다.

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/app
    username: app
    password: secret
  jpa:
    hibernate:
      ddl-auto: validate
    open-in-view: false
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

ddl-auto: validate는 엔티티와 실제 스키마가 맞는지 검증만 하고, 스키마를 함부로 바꾸지 않게 해 줍니다. open-in-view: false는 웹 요청 끝까지 영속성 컨텍스트를 늘리지 않아서 컨트롤러나 직렬화 구간에서 예상치 못한 추가 SQL이 나가는 일을 줄이는 데 유리합니다.

이 설정만으로도 “왜 느린지 모르겠다” 상태에서 “어떤 SQL이 몇 번 나갔는지 알겠다” 상태로 넘어갈 수 있습니다.

메서드 이름 쿼리와 @Query를 고르는 기준

Spring Data JPA의 생산성이 가장 크게 드러나는 구간은 단순 조회입니다. 상태, 기간, 정렬, 상위 N개 정도는 메서드 이름만으로 충분히 읽히는 경우가 많습니다. 하지만 조건이 길어지거나 조인이 늘어나면 메서드 이름은 오히려 의도를 흐립니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    List<Order> findTop10ByStatusAndCreatedAtAfterOrderByCreatedAtDesc(
        OrderStatus status,
        LocalDateTime from
    );

    @Query("""
        select o
        from Order o
        where o.status = :status
          and o.customerName like concat(:prefix, '%')
        order by o.createdAt desc
        """)
    Page<Order> searchByCustomerPrefix(
        @Param("status") OrderStatus status,
        @Param("prefix") String prefix,
        Pageable pageable
    );
}

실전에서는 보통 이렇게 나눠 생각하면 됩니다.

  • 단순 조건 조회: 메서드 이름 쿼리
  • 조건이 길거나 조인이 포함된 조회: @Query
  • 동적 조건이 많고 조합이 복잡함: Querydsl 같은 별도 도구
  • 읽기 전용 응답이 크고 복잡함: 엔티티보다 DTO 프로젝션 검토

여기서 특히 주의할 점은 Page입니다. Page는 편하지만 목록 조회 외에 count 쿼리를 추가로 실행합니다. 화면에 총 개수가 꼭 필요하지 않다면 Slice가 더 가벼울 수 있습니다.

예제 쿼리와 결과를 먼저 읽어야 한다

JPA를 쓸 때도 결국 데이터베이스는 SQL을 실행합니다. 예를 들어 “결제 완료된 주문 최신 3건”을 가져오는 쿼리는 아래처럼 해석할 수 있습니다.

SELECT id, customer_name, status, created_at
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 3;

이 쿼리의 실제 결과가 아래처럼 나왔다고 해보겠습니다.

101, kim, PAID, 2026-04-05 14:11:03
98, lee, PAID, 2026-04-05 14:10:58
91, park, PAID, 2026-04-05 14:10:12

결과 자체는 단순합니다. 중요한 것은 이 결과를 얻기 위해 데이터베이스가 몇 행을 읽었는지입니다. 3건만 반환했더라도 내부적으로 수만 건을 스캔했다면 API 응답 시간은 계속 불안정해질 수 있습니다. 그래서 결과를 본 다음 바로 실행 계획으로 넘어가야 합니다.

생성된 SQL과 실행 계획은 반드시 확인해야 한다

JPA 성능 점검은 “리포지토리 메서드가 잘 동작한다”에서 끝나지 않습니다. Hibernate 로그와 DB 실행 계획을 같이 봐야 합니다. 아래는 findByStatusOrderByCreatedAtDesc 같은 메서드가 실행될 때 나올 수 있는 로그 예시입니다.

2026-04-05T14:21:07.381+09:00 DEBUG org.hibernate.SQL :
    select
        o1_0.id,
        o1_0.customer_name,
        o1_0.status,
        o1_0.created_at
    from orders o1_0
    where o1_0.status=?
    order by o1_0.created_at desc
    limit ?, ?

2026-04-05T14:21:07.382+09:00 TRACE org.hibernate.orm.jdbc.bind :
    binding parameter (1:VARCHAR) <- [PAID]
    binding parameter (2:INTEGER) <- [0]
    binding parameter (3:INTEGER) <- [20]

2026-04-05T14:21:07.389+09:00 DEBUG org.hibernate.SQL :
    select count(o1_0.id)
    from orders o1_0
    where o1_0.status=?

여기서 바로 읽어야 할 포인트는 두 가지입니다.

  • 목록 조회 쿼리 외에 count 쿼리가 한 번 더 나간다.
  • 바인딩 값까지 확인해야 인덱스를 기대한 조건이 실제로 들어갔는지 알 수 있다.

실행 계획도 같이 보겠습니다.

mysql> EXPLAIN ANALYZE
SELECT id, customer_name, status, created_at
FROM orders
WHERE status = 'PAID'
ORDER BY created_at DESC
LIMIT 20;

-> Limit: 20 row(s) (actual time=11.9..12.0 rows=20 loops=1)
    -> Sort: orders.created_at DESC (actual time=11.9..11.9 rows=20 loops=1)
        -> Filter: (orders.status = 'PAID') (actual time=0.8..8.5 rows=18367 loops=1)
            -> Table scan on orders (actual time=0.2..5.2 rows=102311 loops=1)

이 출력에서 Table scan은 전체 테이블을 읽었다는 뜻이고, Sort는 필터링 뒤에 다시 정렬 비용을 치렀다는 뜻입니다. 즉, 조건도 정렬도 인덱스를 제대로 못 썼습니다. 이런 경우 status, created_at 순서의 복합 인덱스를 우선 검토해야 합니다. 상태로 먼저 범위를 좁히고, 그 안에서 생성일 역순으로 읽을 수 있어야 하기 때문입니다.

인덱스와 성능 포인트를 같이 봐야 하는 이유

JPA는 인덱스를 자동으로 설계해 주지 않습니다. 엔티티에 인덱스 힌트를 남길 수는 있어도, 실제로 어떤 인덱스가 필요한지는 쿼리 패턴으로 판단해야 합니다.

실무에서 자주 보는 성능 포인트는 아래와 같습니다.

  • WHERE status = ? ORDER BY created_at DESC가 많다면 status, created_at 복합 인덱스를 먼저 본다.
  • LIKE 'kim%'처럼 앞부분 일치 검색은 인덱스를 탈 수 있지만, LIKE '%kim%'은 대개 어렵다.
  • offset 페이지는 뒤로 갈수록 느려지므로 데이터가 많아지면 커서 기반 조회를 검토한다.
  • Page는 항상 count 비용을 동반하므로 첫 화면 목록에 총 개수가 꼭 필요한지 먼저 따진다.
  • 연관 엔티티를 목록에서 순회하며 접근하면 N+1 문제가 생기기 쉽다.

특히 N+1은 JPA를 처음 쓸 때 가장 많이 만나는 함정입니다. 주문 목록 20건을 가져온 뒤 각 주문의 회원 정보를 접근했더니 회원 조회 SQL이 20번 추가로 나가는 식입니다. 이 문제는 fetch join, 엔티티 그래프, DTO 조회로 해결할 수 있지만, 핵심은 먼저 로그에서 이상 징후를 발견하는 것입니다.

JPA를 어디까지 맡기고 어디서 SQL을 더 직접 봐야 하나

JPA가 가장 강한 영역은 쓰기 모델과 기본 조회입니다. 단건 조회, 상태 변경, 일반적인 CRUD, 단순 페이지네이션, 감사 필드 관리 같은 작업은 JPA와 Spring Data JPA의 생산성이 매우 높습니다. 팀 차원에서 리포지토리 규칙을 통일하기도 쉽습니다.

반면 아래 구간은 SQL을 더 직접 의식해야 합니다.

  • 조인이 많은 목록 API
  • 집계와 통계 쿼리
  • 대량 업데이트와 대량 삭제
  • 인덱스 설계가 성능을 좌우하는 검색 기능
  • 응답 시간이 민감한 핵심 화면
  • N+1이 자주 터지는 연관관계 조회

이때 중요한 것은 “JPA를 버릴까”가 아니라 “어떤 작업을 JPA에 맡기고 어떤 작업은 더 직접 다룰까”입니다. 쓰기 모델은 엔티티 중심으로 유지하고, 읽기 성능이 중요한 화면은 DTO 프로젝션이나 네이티브 쿼리, JdbcTemplate을 함께 쓰는 방식이 현실적입니다.

JPA를 쓸 때 자주 틀리는 지점

JPA를 쓰면 SQL을 몰라도 된다고 생각하기 쉽지만, 실제로는 반대입니다. JPA는 SQL을 숨겨 주는 도구이지, SQL 비용을 없애 주는 도구는 아닙니다.

자주 틀리는 지점은 대체로 비슷합니다.

  • 메서드 이름 쿼리가 읽기 쉬운 범위를 넘어섰는데도 계속 붙여 쓴다.
  • Page를 습관처럼 사용해 불필요한 count 쿼리를 반복한다.
  • 연관관계 조회에서 지연 로딩이 터져 N+1을 만든다.
  • 대량 수정 쿼리 뒤에 영속성 컨텍스트를 비우지 않아 오래된 엔티티 상태를 본다.
  • 인덱스 없이 “JPA가 알아서 최적화하겠지”라고 기대한다.

결국 JPA를 쓰는 이유는 반복 코드를 줄이면서도 데이터 접근 계층의 규칙을 일관되게 가져가기 위해서입니다. 하지만 그 장점은 자동 생성 SQL을 꾸준히 확인하고, 실행 계획과 인덱스로 검증할 때만 유지됩니다. 생산성과 성능은 대립하지 않습니다. 자동화는 JPA에 맡기되, 병목의 판단은 개발자가 직접 해야 합니다.

원문 참고

https://www.maeil-mail.kr/question/25

댓글

이 블로그의 인기 게시물

아이콘 폰트 (icomoon 사용법)

 장난감 프로젝트를 만들다 보면, 아이콘이 필요한 경우가 있다. 간단하게 아이콘을 인터넷에서 검색하여, 이미지로 넣어두고 이미지 태그를 이용하여, 사용하는 경우가 일반적이였지만...  요즘에는 대부분 폰트를 이용하여 아이콘을 노출 한다. 나 같은 경우에도 기본적으로  https://material.io/resources/icons 를 참고하여 아이콘 폰트를 이용할 수 있도록 처리하고, 추가적으로 필요한 아이콘이고, 일상적으로 사용 되지 않는 아이콘의 경우에는  https://icomoon.io 에서 제작하여, 아이콘 폰트로 이용 하곤 한다.  그래서 이번에는 아이콘  https://icomoon.io 의 사용법을 간단히 공유하고자 한다.   들어가자 마자 위의 icoMoonApp버튼을 누르면 아래와 같은 화면이 나타난다.  icomoon에서 무료로 제공하는 아이콘들이 보이면 위에 파란색으로 표시 되어있는 집 모양 세가지를 선택한 후, 아래의 빨간색으로 표시되어있는 Generate Font를 눌러보자.  그리고 나서 바로 다운로드를 요청해보자. icomoon.zip이 다운로드가 될텐데, 압축을 해제해 보면, 아래의 폴더 및 파일들이 있다. 아래에서 중요한 것은 font 폴더와 style.css이다. demo-files fonts demo.html Read Me.txt selection.json style.css <!doctype html > <html> <head> <link rel ="stylesheet" href ="style.css" ></head> </head> <body> <span class ="icon-home" ></span> <span class ="icon-home2" ></span> <span class ="icon-hom...

Chart js와 amchart 비교

Chart js 특징은 위의 그림으로 대체 할 수 있을 듯 하다. 오픈 소스이고, 기본으로 제공하는 차트 종류가 8가지 Canv a s를 이용해서 차트를 그리고, 반응형을 지원한다. amchart amchart는 기본적으로 유료이며, 기본으로 제공하는 차트 종류가 기본적인 차트 + 주식 처럼 보이는 차트 + 지도에 관련된 차트(?) 까지 하면, 기본 제공 하는 종류가 20개 내외 이려나, 일일이 세기에는 양이 좀 많아 보인다. 렌더링은 svg를 통하여 그려지고, 당연 반응형도 지원이 된다. 그러면, 이 둘중에 어떤것이 내 프로젝트에 적합 하냐는 것이 문제이다. 일단, 주식 처럼 보이는 차트나 지도에 관련된 차트(?)가 필요하면, amchart를 선택해야 되는 것은 맞다. 그건 당연한 것이니 빼고 얘기 해보자! 여러 종류의 차트가 필요하다면, 일단은 amchart를 염두해 두는 것이 좋다. 돈 낸 만큼은 하는 듯 하다. 하지만, 기본적인 막대 그래프, 도넛 차트 등, 아주 기본적인 차트들인데, Chart js도 amchart도 그러한 차트가 없을 때가 문제가 된다. 그렇다면, 조금이라도 커스텀이 용이한 것을 찾는 것이 좋을 것이다.  일단 amchart에서 custom이라고 검색 하였을 때, 검색 결과가 61가지가 나온다. 차트의 종류도 많고, 각 차트마다 들어가는 속성이 매우 많기 때문에, 웬만한 내용들은 속성 값을 어떻게 주느냐에 따라서 변경이 가능 하게 된다. 커스텀의 예를 들면, 기본적으로 도넛 파이의 형태를 띄면서, 화살표로 목표를 표시해주는 차트가 필요하다고 생각 해보자. 이것은 amchart로 만든 그래프이고 이것은 chart js로 만든 그래프이다. 모양이 살짝 다르긴 하지만, 완벽하게 똑같이 구현 할 수도 있다. amchart로 만든 그래프의 경우, 저것은 도넛그래프가 아닌 guage 그래프이다. 원래 게이지 그래프는 이와 같...

javascript 압축 파일 다운로드

이번에는 전 게시글의 응용판? 이라고 해야하나....? 어쨋든! 우리는 각각의 파일들을 다운로드 해보았다. 그런데 생각보다 귀찮음?을 느꼇을 것이다. 파일을 각각 다운 받아야 한다는 현실때문에! 그래 파일 두개야 뭐 그렇다 치지... 하지만, 개발자도 사용자도 게으름뱅이이다. 자 결국, 우리가 해야 하는 것은 파일을 한 번에 둘다! 다운 받는 것이다. 물론, 클릭 한번에 여러개의 함수를 엮어서 다운받게 하면 되지만! 크롬에서 자주 봤듯이, 여러개의 파일을 다운로드를 시도하면 <- 여러개의 파일을 다운로드 합니다. 허용 합니까? 하고 물어보는 것을 볼 수 있다. 게다가 다운로드 한 파일들을 찾기도 귀찮다는 것. 자 해결책을 제시해보자면, https://github.com/Stuk/jszip 클라이언트 단에서 파일을 zip파일로 압축을 할 수가 있다! 필요한 작업은 아래와 같다. 0. 데이터 준비 1. BLOB(binary large object)를 만든다. 2. Blob을 URL.createObjectURL을 사용하여, 해당 binary의 주소를 생성. 3. 다운로드가 필요한 파일들을 Zip 객체에 셋팅! 4. a태그를 이용하여, 해당 url 셋팅 하고, 다운로드. 전 게시물과 별로 달라진게 없네... 자 그럼 샘플! 샘플을 보자! http://embed.plnkr.co/NMprnRxqYG0fkHa2J55D/ var util = {} function fixBinary(bin) { //binary to arrayBuffer var length = bin.length var buf = new ArrayBuffer(length) var arr = new Uint8Array(buf) for (var i = 0; i < length; i++) { arr[i] = bin.charCodeAt(i) } return buf } window.onload = function() { ...