스프링에서 JPA를 쓰는 이유: DAO 반복 코드를 줄이고 데이터 접근을 더 안정적으로 설계하는 법
빠른 답
- JPA는 반복 CRUD와 DAO 구현을 줄여 도메인 로직에 더 집중하게 해준다.
- Spring Data JPA를 쓰면 메서드 이름 쿼리, 페이징, 정렬, 감사 기능을 같은 리포지토리 모델로 다룰 수 있다.
- 변경 감지와 트랜잭션 관리 덕분에 업데이트 로직이 단순해진다.
- 다만 생성된 SQL을 믿고 넘기면 안 되고, 로그와 실행 계획으로 성능을 확인해야 한다.
목차
흐름으로 보기
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
댓글
댓글 쓰기