자바 아키텍처·Spring 학습 노트 목차

Spring Data JPA — 인터페이스만 선언하면 쿼리가 된다

앞의 JPA 편이 영속성 컨텍스트라는 저수준 엔진을 들여다본 것이었다면, 여기서는 그 위에 얹힌 리포지토리라는 고수준 도구를 본다. Spring Data JPA의 첫인상은 거의 마술에 가깝다 — 메서드 이름만 선언했는데 쿼리가 만들어지고, 페이징과 프로젝션이 공짜로 따라온다. 그 마술이 어떤 내부 장치로 도는지, 그리고 편한 만큼 다시 발을 헛디디기 쉬운 페이징·save·락의 함정이 어디인지를 짚어 보자.

구현을 쓰지 않는데 동작하는 이유 — 리포지토리 프록시

시작은 황당할 정도로 간단하다.

public interface OrderRepository extends JpaRepository<Order, Long> {
}   // 이게 끝이다. save·findById·findAll·delete가 이미 동작한다

구현 클래스를 한 줄도 안 썼는데 기본 CRUD가 돈다. 그 정체는 런타임에 만들어지는 프록시다 — Spring Data의 RepositoryFactory가 이 인터페이스의 프록시를 만들고, 그 프록시에 인터셉터를 끼운다. 메서드가 호출되면 인터셉터가 *"이게 쿼리 메서드인가?"*를 판단해 — 맞으면 그 메서드로 만든 RepositoryQuery를 실행하고, 아니면(기본 CRUD면) **SimpleJpaRepository**의 대응 메서드(save·delete 등, 내부에서 EntityManager를 쓴다)로 넘긴다. (출처: Spring Data JPA Internals.) 02편의 동적 프록시가 여기서도 일하는 셈이라, 우리는 추상에만 의존하고 구현은 신경 쓰지 않는다.

메서드 이름이 곧 쿼리 — PartTree 파서

기본 CRUD를 넘어서는 조회도, 놀랍게도 메서드 이름만으로 만들어진다.

List<Order> findByMemberIdAndStatusOrderByCreatedAtDesc(Long memberId, Status status);

이걸 푸는 건 **PartTree**라는 손으로 짠 top-down 파서다 — 메서드 이름을 *주어(subject: find·count·exists…)*와 *술어(predicate: MemberIdAndStatus…)*로 쪼개고, 술어를 다시 And·Or·Between·In·OrderBy 같은 키워드로 파싱해 where member_id = ? and status = ? order by created_at desc생성한다. (출처: Defining Query Methods.) 다만 편리함이 지나치면 역효과라, 조건이 늘어 이름이 findByAAndBOrCAndD…처럼 길어지면 오히려 읽기가 힘들어진다 — 그땐 미련 없이 @Query로 JPQL을(복잡하면 nativeQuery = true로 네이티브 SQL을) 직접 적는 게 낫다.

페이징 — Page와 Slice 중 무엇을

목록을 페이지로 끊어 줄 때, Page를 받을지 Slice를 받을지성능을 가른다. **Page**는 데이터와 함께 *전체 개수(totalElements)*를 주는데, 그걸 세느라 count 쿼리가 한 번 더 나간다 — "1 2 3 … 10 페이지" 같은 번호 UI엔 필요하지만 큰 테이블에선 의외로 비싸다. **Slice**는 전체 개수 없이 다음 페이지가 있는지만(limit+1을 읽어 판단) 알려줘 count 쿼리가 없다 — "더 보기"·무한 스크롤엔 훨씬 가볍다. (출처: Paging vs Slice.) 복잡한 @Query에 Page를 쓰면 자동 생성된 count 쿼리가 비효율일 수 있어, countQuery를 따로 최적화한다.

프로젝션 — 필요한 만큼만, 동적으로도

화면에 주문 id와 금액만 보여 주는데 엔티티를 통째로 가져올 이유가 없다. 그럴 땐 프로젝션으로 필요한 컬럼만 조회한다.

interface OrderSummary { Long getId(); long getAmount(); }   // 인터페이스 프로젝션
List<OrderSummary> findByStatus(Status status);

<T> List<T> findByStatus(Status status, Class<T> type);      // 동적 프로젝션

인터페이스 프로젝션은 게터만 선언하면 그 필드만 SELECT하고, 클래스(DTO) 프로젝션JPA 생성자 표현식으로 DTO를 만든다. 호출 시점에 어떤 프로젝션을 쓸지 고르고 싶으면 메서드에 Class<T> 파라미터를 두는 동적 프로젝션을 쓴다. (출처: Projections.) 왜 굳이? 엔티티를 통째로 가져오면 모든 컬럼 + 연관 프록시가 딸려 와 무겁고 N+1로 번진다 — 조회 전용 화면은 프로젝션·DTO가 정석이다(05편의 그 맥락이다). 한 가지 주의 — 정렬에 쓴 속성은 프로젝션에도 포함해야 키셋 추출이 깨지지 않는다.

save()의 숨은 분기 — persist냐 merge냐

repository.save()는 단순해 보이지만, 내부에서 새 엔티티인지를 판단해 갈린다 — 새것이면 persist, 아니면 merge. 그 "새것인지" 판단 규칙이 중요하다. 먼저 비원시 타입 @Version 필드가 있으면 그 값이 null일 때 새것으로 보고, 없으면 식별자(@Id)가 null일 때 새것으로 본다(엔티티가 Persistable을 구현하면 그 isNew()에 위임). (출처: Persisting Entities.)

여기서 조용한 함정이 나온다. 식별자를 직접 할당하는(예: UUID를 애플리케이션이 생성) 엔티티는 — id가 이미 차 있어 save()가 "기존 것"으로 오해해 merge를 부른다. merge는 먼저 SELECT로 DB를 확인하는 불필요한 쿼리를 한 번 더 날린다(준영속 병합 절차). 이걸 피하려면 Persistable을 구현해 isNew()명시하거나, 새 엔티티 표식을 둔다. merge가 전달한 객체 자신이 아니라 복사된 새 영속 인스턴스를 반환한다는 점(05편)도 함께 기억하자.

동시 갱신과 동적 검색

"읽고 → 수정"하는 사이 다른 트랜잭션이 먼저 갱신해 내 갱신이 덮어써지는(lost update) 걸 막으려면 을 건다. 낙관적 락(@Version — 갱신 시 버전이 그새 바뀌었으면 OptimisticLockException)은 락을 미리 잡지 않아 동시성이 좋고 충돌이 드물 때 유리하고, 비관적 락(@Lock(PESSIMISTIC_WRITE)select … for update로 미리 잠금)은 갱신을 직렬화해 처리량은 줄지만 충돌이 잦을 때 재시도 비용을 아낀다. (출처: Fetching and locking strategies.) 그리고 검색 필터처럼 조건이 동적으로 바뀌는 쿼리는 문자열 JPQL을 잇지 말고 Specification(Criteria 기반)·QueryDSL타입 안전하게 조립한다. 변경을 모아 처리하는 @Modifying 벌크 쿼리는 영속성 컨텍스트를 우회하므로 clearAutomatically = true로 불일치를 막는 것도 잊지 말자(05편).

정리하면, Spring Data JPA는 런타임 프록시 + 인터셉터로 인터페이스만 선언해도 쿼리가 돌게 하고(쿼리 메서드는 PartTree 파서가, 기본 CRUD는 SimpleJpaRepository가 처리), 페이징은 count가 정말 필요한지로 Page와 Slice를 가르며, 조회 전용은 *프로젝션·DTO(동적도 가능)*로 가볍게 가져온다. save()isNew 판단으로 persist/merge가 갈리니 직접 할당 식별자의 merge 함정을 조심하고, 동시 갱신은 낙관/비관 락으로, 동적 검색은 Specification/QueryDSL로, 벌크는 컨텍스트 우회를 의식하며 다룬다.

JPA·영속성 — 영속성 컨텍스트·N+1트랜잭션 — 전파·격리·롤백 함정