| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- 비동기처리
- god object
- redissearch
- springboot
- 객체지향적사고
- 시맨틱캐싱
- DLT
- 메세지브로커
- 테스트코드
- aof
- 데이터유실방지
- 임베딩
- 백엔드
- retry
- 배치처리
- rdb
- blockingqueue
- redis
- 장애복구
- 레디스스트림
- 코사인
- 마케팅 #퍼플카우 #새스고딘 #혁신 #독서 #이북
- 메시지브로커
- 레디스
- 자연어캐싱
- OOP
- SaaS
- jedis
- redisstreams
- Kafka
- Today
- Total
pandaterry's 개발로그
JPA와 도메인 모델: 복잡한 조회 쿼리의 딜레마와 실전 해결책 본문
JPA와 도메인 모델을 함께 사용하면서 객체지향적으로 설계하다 보면, 조회 쿼리가 복잡해지는 문제에 직면하게 됩니다. 특히 페이지네이션이 필요할 정도의 복잡한 조회에서는 이 문제가 더욱 두드러집니다.
이는 해외 스프링부트 및 자바 커뮤니티에서도 오래 논의된 주제입니다.
실무에서 겪는 문제들
객체지향과 SQL의 근본적 불일치
도메인 모델은 비즈니스 로직과 불변성 보장을 위해 풍부한 연관관계를 가져야 합니다. 하지만 이는 조회 쿼리를 복잡하게 만듭니다. JPA는 CRUD 보일러플레이트를 피할 뿐만 아니라 관리되는 엔티티에서 작업할 때 대부분의 수동 레포지토리 호출을 피할 수 있게 해줍니다. 하지만 동작이 신비롭게 느껴질 수 있으며, 쓰기에는 편리하지만 읽기에는 제어권이 부족한 것이 문제입니다.
N+1 문제
연관관계를 로딩할 때마다 추가 쿼리가 발생합니다. Reddit의 한 개발자는 "거의 모든 JPA 메서드는 결국 N+1 유사 쿼리를 생성한다. findAll()을 호출하면 자식이 즉시 로딩으로 설정된 경우 각 부모 엔티티에 대해 N개의 추가 쿼리가 발생한다"고 지적합니다.
메모리 페이징 문제
JOIN FETCH와 페이지네이션을 함께 사용하면 Hibernate가 경고를 출력합니다:
WARN: HHH90003004: firstResult/maxResults specified with collection fetch;
applying in memory
이는 메모리에서 페이징이 발생한다는 의미입니다. 실행 계획을 보면 25개 Post를 가져오려고 했는데 실제로는 100,000개 행(10,000 Post × 10 Comment)을 조회합니다. 쿼리 실행 시간이 368ms나 걸립니다.
불필요한 데이터 로딩과 동적 쿼리의 복잡성
필요한 컬럼만 조회하기 어렵고, @Query는 정적 쿼리만 지원하며, Criteria API와 Specification은 지나치게 복잡하고 제한이 많습니다. 한 개발자는 "왜 Spring Boot JPA가 이렇게 흔한 문제를 해결하지 못하는지 이해할 수 없다"고 불만을 표출하며, 이 질문은 24 upvotes를 받았습니다.
커뮤니티에서 오가는 실제 논의
Reddit r/SpringBoot의 세 진영
"복잡한 SQL 쿼리를 어떻게 구현하나요?" 라는 질문에서 전형적인 고민이 드러납니다. 개발자는 "여러 조인, 중첩 쿼리, 다양한 WHERE 절이 있는 복잡한 쿼리를 Spring JPA로 구현하면 비실용적이고 @Query 어노테이션은 읽기 어려운 코드가 된다"고 토로합니다.
이에 대한 커뮤니티 답변들은 크게 세 진영으로 나뉩니다:
- JOOQ 진영: "JOOQ를 써라, 나중에 고마워할 것이다. 복잡하고 타입 안전한 쿼리를 작성하기 정말 쉽다" (5 upvotes)
- 네이티브 쿼리 진영: "JDBC와 매퍼를 사용해 ResultSet을 매핑하라. JPA는 SQL의 모든 구문을 지원하지 않는다"
- JPA 옹호 진영: "뷰를 만들어서 JPA로 조회하거나, JdbcTemplate을 사용하라. 복잡한 쿼리가 몇 개만 있다면 JOOQ는 오버킬일 수 있다" (5 upvotes)
StackOverflow의 근본적 질문
"JPA 엔티티를 도메인 모델로 쓸 수 있나?"라는 질문에서 가장 많은 추천을 받은 답변(5 upvotes)은 세 가지 접근법을 제시합니다:
- 규칙을 완화하기: "DDD에 '고정된 규칙'이 있다고 말하는 사람은 없다. JPA 엔티티를 도메인 모델로 사용하되 DDD 규칙을 따르라"
- 캐시 사용: "JPA 엔티티를 도메인 엔티티로 변환하기 전에 캐시에 저장하라"
- JPA를 버리고 SQL 직접 사용: "도메인 엔티티가 이미 있다면, SQL을 직접 작성하면 JPA 오버헤드 없이 완벽하게 코드를 튜닝할 자유를 얻는다"
해결책 비교 분석
CQRS 패턴: 읽기/쓰기 분리
핵심 개념: Command(명령)는 CUD 작업을 도메인 모델로 객체지향적으로 처리하고, Query(조회)는 별도 모델로 분리하여 성능 최적화에 집중합니다.
장점: 쓰기와 읽기 모델 분리로 각각 최적화 가능, 도메인 모델의 풍부함 유지하면서 조회 성능 향상
단점: 코드 복잡도 증가, 두 모델 간 동기화 필요
적용 시나리오: 복잡한 도메인 모델과 성능이 중요한 조회가 공존할 때
모놀리식 적용: 물리적으로 별도 데이터베이스를 두지 않고 같은 DB 내에서 논리적으로 분리하는 방식이 일반적입니다. Reddit의 한 개발자는 "풀 DDD 방식으로 엔티티, 값 객체, 애그리거트를 만들었고, 조회 모델은 이 모든 걸 우회해서 Dapper 쿼리로 엔드포인트가 반환할 모델에 직접 매핑했다. 단일 스키마에서 모두 처리했다"고 공유합니다 (12 upvotes).
DTO Projection: 필요한 데이터만 조회
개념: 엔티티가 아닌 DTO로 직접 조회하여 필요한 컬럼만 SELECT합니다.
장점: 메모리 사용량 감소, 스냅샷 생성이나 변경 감지가 없어 성능 향상, Vlad Mihalcea는 쿼리 시간이 368ms에서 8ms로 단축된다고 실측 데이터를 보여줍니다.
단점: DTO 클래스 추가 관리 필요
적용 시나리오: 특정 조회에서 일부 필드만 필요한 경우
QueryDSL: 타입 안전한 동적 쿼리
개념: 타입 안전한 동적 쿼리를 작성할 수 있는 도구입니다.
장점: 컴파일 타임 검증, 동적 쿼리 작성 용이, JPA의 Pageable과 자연스럽게 통합
단점: 학습 곡선, 빌드 설정 필요
적용 시나리오: 복잡한 동적 조건이 많은 검색 기능
Blaze-Persistence Entity Views: 자동 쿼리 최적화
개념: JPA 위에서 동작하며, 인터페이스로 View를 정의하면 자동으로 최적화된 쿼리를 생성해줍니다.
장점: 인터페이스 기반으로 간단한 정의, 자동 최적화, JOIN FETCH가 있는 쿼리를 보고 자동으로 id 서브쿼리를 생성하여 페이지네이션 쿼리를 만듭니다
단점: 추가 라이브러리 의존성
적용 시나리오: 복잡한 연관관계와 페이지네이션이 함께 필요한 경우
실전 코드 예제
문제 상황: 도메인 엔티티와 문제가 있는 조회
@Entity
public class Post {
@Id
@GeneratedValue
private Long id;
private String title;
private String content;
@OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
private List<Comment> comments = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
private User author;
}
@Entity
public class Comment {
@Id
@GeneratedValue
private Long id;
private String content;
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
}
// 문제가 있는 조회 코드
@Query("select p from Post p left join fetch p.comments")
Page<Post> findAllWithComments(Pageable pageable);
이 쿼리는 메모리 페이징을 발생시키고, N+1 문제를 야기합니다.
해결책 1: CQRS 패턴 구현
Command Repository (쓰기용)
public interface PostCommandRepository extends JpaRepository<Post, Long> {
// 쓰기 작업은 도메인 모델 사용
Post save(Post post);
void deleteById(Long id);
}
Query Repository (읽기용, DTO 직접 조회)
public interface PostQueryRepository {
Page<PostDto> findAllPosts(Pageable pageable);
}
@Repository
@RequiredArgsConstructor
public class PostQueryRepositoryImpl implements PostQueryRepository {
private final EntityManager em;
@Override
@Transactional(readOnly = true)
public Page<PostDto> findAllPosts(Pageable pageable) {
// DTO로 직접 조회
String jpql = "SELECT new com.example.dto.PostDto(p.id, p.title, u.name, COUNT(c.id)) " +
"FROM Post p " +
"LEFT JOIN p.author u " +
"LEFT JOIN p.comments c " +
"GROUP BY p.id, p.title, u.name";
List<PostDto> content = em.createQuery(jpql, PostDto.class)
.setFirstResult((int) pageable.getOffset())
.setMaxResults(pageable.getPageSize())
.getResultList();
Long total = em.createQuery("SELECT COUNT(DISTINCT p.id) FROM Post p", Long.class)
.getSingleResult();
return new PageImpl<>(content, pageable, total);
}
}
Service 레이어
@Service
@RequiredArgsConstructor
public class PostService {
private final PostCommandRepository postCommandRepository;
private final PostQueryRepository postQueryRepository;
// 쓰기: 도메인 모델 사용
public Post createPost(String title, String content, User author) {
Post post = new Post(title, content, author);
return postCommandRepository.save(post);
}
// 읽기: DTO 직접 조회
public Page<PostDto> getPosts(Pageable pageable) {
return postQueryRepository.findAllPosts(pageable);
}
}
해결책 2: DTO Projection 구현
인터페이스 기반 프로젝션
public interface PostProjection {
Long getId();
String getTitle();
String getAuthorName();
Long getCommentCount();
}
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT p.id as id, p.title as title, u.name as authorName, " +
"COUNT(c.id) as commentCount " +
"FROM Post p " +
"LEFT JOIN p.author u " +
"LEFT JOIN p.comments c " +
"GROUP BY p.id, p.title, u.name")
Page<PostProjection> findAllProjected(Pageable pageable);
}
클래스 기반 DTO 프로젝션
public class PostDto {
private Long id;
private String title;
private String authorName;
private Long commentCount;
public PostDto(Long id, String title, String authorName, Long commentCount) {
this.id = id;
this.title = title;
this.authorName = authorName;
this.commentCount = commentCount;
}
// getters...
}
@Query("SELECT new com.example.dto.PostDto(p.id, p.title, u.name, COUNT(c.id)) " +
"FROM Post p " +
"LEFT JOIN p.author u " +
"LEFT JOIN p.comments c " +
"GROUP BY p.id, p.title, u.name")
Page<PostDto> findPostDtos(Pageable pageable);
@Transactional(readOnly = true) 최적화
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
@Transactional(readOnly = true) // 스냅샷 생성 생략, 변경 감지 생략
public Page<PostDto> getPosts(Pageable pageable) {
return postRepository.findPostDtos(pageable);
}
}
해결책 3: QueryDSL 구현
build.gradle 설정
dependencies {
implementation 'com.querydsl:querydsl-jpa:5.0.0'
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jpa'
annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
annotationProcessor 'jakarta.annotation:jakarta.annotation-api'
}
QueryDSL을 활용한 동적 쿼리
@Repository
@RequiredArgsConstructor
public class PostQueryRepository {
private final JPAQueryFactory queryFactory;
public Page<PostDto> searchPosts(PostSearchCondition condition, Pageable pageable) {
// 데이터 조회 쿼리
List<PostDto> content = queryFactory
.select(new QPostDto(
post.id,
post.title,
user.name,
comment.id.count()
))
.from(post)
.leftJoin(post.author, user)
.leftJoin(post.comments, comment)
.where(
titleContains(condition.getTitle()),
authorNameEq(condition.getAuthorName()),
hasComments(condition.getHasComments())
)
.groupBy(post.id, post.title, user.name)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// count 쿼리 최적화 (join 제거)
JPAQuery<Long> countQuery = queryFactory
.select(post.countDistinct())
.from(post)
.leftJoin(post.author, user)
.leftJoin(post.comments, comment)
.where(
titleContains(condition.getTitle()),
authorNameEq(condition.getAuthorName()),
hasComments(condition.getHasComments())
);
// PageableExecutionUtils: 첫/마지막 페이지에서 불필요한 count 쿼리 생략
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
private BooleanExpression titleContains(String title) {
return hasText(title) ? post.title.contains(title) : null;
}
private BooleanExpression authorNameEq(String authorName) {
return hasText(authorName) ? user.name.eq(authorName) : null;
}
private BooleanExpression hasComments(Boolean hasComments) {
return hasComments != null && hasComments
? comment.id.isNotNull()
: null;
}
}
해결책 4: 페이지네이션 N+1 문제 해결
3단계 페이지네이션
@Repository
@RequiredArgsConstructor
public class PostRepository {
private final EntityManager em;
public Page<Post> findAllWithComments(Pageable pageable) {
// 1단계: 부모 ID만 페이징하여 조회
List<Long> postIds = em.createQuery(
"SELECT p.id FROM Post p ORDER BY p.id DESC", Long.class)
.setFirstResult((int) pageable.getOffset())
.setMaxResults(pageable.getPageSize())
.getResultList();
if (postIds.isEmpty()) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
// 2단계: ID로 부모 엔티티 조회
List<Post> posts = em.createQuery(
"SELECT DISTINCT p FROM Post p WHERE p.id IN :ids", Post.class)
.setParameter("ids", postIds)
.getResultList();
// 3단계: 자식 엔티티를 bulk로 조회 (IN 절 활용)
Map<Long, List<Comment>> commentsMap = em.createQuery(
"SELECT c FROM Comment c WHERE c.post.id IN :postIds", Comment.class)
.setParameter("postIds", postIds)
.getResultList()
.stream()
.collect(Collectors.groupingBy(c -> c.getPost().getId()));
// Java에서 조합하여 반환
posts.forEach(post -> post.setComments(
commentsMap.getOrDefault(post.getId(), Collections.emptyList())));
Long total = em.createQuery("SELECT COUNT(p) FROM Post p", Long.class)
.getSingleResult();
return new PageImpl<>(posts, pageable, total);
}
}
@BatchSize 어노테이션 활용
@Entity
public class Post {
@Id
@GeneratedValue
private Long id;
@OneToMany(mappedBy = "post")
@BatchSize(size = 100) // N+1 문제를 N/batchSize + 1로 줄여줌
private List<Comment> comments = new ArrayList<>();
}
해결책 5: Cursor 기반 페이지네이션
public interface PostRepository extends JpaRepository<Post, Long> {
// Offset 방식의 한계를 극복
List<Post> findTop10ByIdLessThanOrderByIdDesc(Long cursor);
}
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public List<PostDto> getPostsByCursor(Long cursor, int size) {
List<Post> posts = cursor == null
? postRepository.findTop10ByOrderByIdDesc()
: postRepository.findTop10ByIdLessThanOrderByIdDesc(cursor);
return posts.stream()
.map(this::toDto)
.collect(Collectors.toList());
}
}
해결책 6: JPA Specification 패턴
public interface PostRepository extends JpaRepository<Post, Long>,
JpaSpecificationExecutor<Post> {
}
public class PostSpecification {
public static Specification<Post> hasTitle(String title) {
return (root, query, cb) ->
hasText(title) ? cb.like(root.get("title"), "%" + title + "%") : null;
}
public static Specification<Post> hasAuthor(String authorName) {
return (root, query, cb) ->
hasText(authorName)
? cb.equal(root.join("author").get("name"), authorName)
: null;
}
}
@Service
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public Page<Post> search(PostSearchCondition condition, Pageable pageable) {
Specification<Post> spec = Specification
.where(PostSpecification.hasTitle(condition.getTitle()))
.and(PostSpecification.hasAuthor(condition.getAuthorName()));
return postRepository.findAll(spec, pageable);
}
}
실무 선택 가이드
Stack Overflow와 Reddit의 Spring Boot 커뮤니티에서 제시되는 실무 패턴은 다음과 같습니다:
- 단순 조회: Spring Data JPA의 Pageable + DTO 프로젝션
- 중간 복잡도: QueryDSL + PageableExecutionUtils
- 매우 복잡한 조회: Native Query 또는 Blaze-Persistence
- 도메인 로직: 객체지향적 도메인 모델 유지
- 아키텍처: CQRS로 읽기/쓰기 명확히 분리
가장 중요한 것은 조회와 변경의 요구사항이 다르다는 점을 인식하고, 각각에 최적화된 방식을 적용하는 것입니다. 도메인 모델의 객체지향적 장점은 유지하면서도, 복잡한 조회에서는 성능을 위해 실용적인 접근을 취하는 균형이 필요합니다.
결론
모놀리식에서도 CQRS는 매우 일반적이며, 오히려 페이지네이션이 필요한 복잡한 조회 문제를 해결하기 위한 가장 현실적인 접근법으로 받아들여지고 있습니다.
문제 상황에 맞는 해결책을 선택하는 것이 중요하며, 단일 모델로 모든 것을 해결하려는 시도의 한계를 인식하고 읽기와 쓰기를 분리하는 것이 자연스러운 귀결입니다.
실무에서는 도메인 모델의 풍부함을 유지하면서도, 복잡한 조회에서는 DTO Projection, QueryDSL, 3단계 페이지네이션 등의 기법을 활용하여 성능을 최적화하는 것이 핵심입니다.
'개발 > OOP' 카테고리의 다른 글
| 모던 자바 안티패턴: 설계 의도를 벗어나는 API 사용 사례 (1) | 2025.11.21 |
|---|---|
| [책 | 오브젝트] CH5. 책임 할당하기 (0) | 2025.11.16 |
| [책 | 오브젝트] CH3. 역할/책임/협력 (0) | 2025.11.09 |
| [OOP 안티패턴] God Object? 이 정도는 괜찮지 않나요 (2) | 2025.06.23 |