JPA(Java Persistence API)

2025. 10. 7. 10:03·Backend

JPA

JPA를 쓰다 보면 이론만 알았을 때는 상상하지 못한 문제들이 터집니다.
저도 개인 프로젝트에서 여러 번 부딪혔고, 그때마다 구글링 + 디버깅으로 해결책을 찾아야 했습니다.
이번 글에서는 제가 실제로 겪었던 문제들을 정리하면서, 여러 해결 방법과 각각의 장단점까지 비교해보겠습니다.


1. N+1 문제

문제 상황

게시판 프로젝트에서 Post와 Comment를 1:N 관계로 매핑했습니다.
게시글 목록을 조회하면서 댓글 수도 함께 보여주고 싶었는데, 단순히 postRepository.findAll()을 호출하니 쿼리가 엄청나게 많이 나가더군요.

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String title;

    @OneToMany(mappedBy = "post")
    private List<Comment> comments = new ArrayList<>();
}

 

조회 코드:

List<Post> posts = postRepository.findAll();
for (Post post : posts) {
    System.out.println(post.getComments().size());
}

 

실행 결과 (쿼리 로그):

select * from post;
select * from comment where post_id = 1;
select * from comment where post_id = 2;
select * from comment where post_id = 3;
...

 

👉 게시글이 100개라면 댓글 조회 쿼리도 100번 실행되는 N+1 문제가 발생했습니다.


해결 방법 1: fetch join

@Query("select p from Post p join fetch p.comments")
List<Post> findAllWithComments();
  • 장점
    • 가장 단순하고 성능상 이점 큼
    • 쿼리 한 번으로 연관된 데이터 모두 가져올 수 있음
  • 단점
    • 페이징 불가 (fetch join + collection → hibernate가 메모리에서 페이징)
    • 쿼리가 복잡해질수록 가독성이 떨어짐

해결 방법 2: @EntityGraph

@EntityGraph(attributePaths = {"comments"})
@Query("select p from Post p")
List<Post> findAllWithComments();
  • 장점
    • 선언적이고 코드 가독성이 높음
    • repository 레벨에서 쉽게 적용 가능
  • 단점
    • 복잡한 조건(join, where 등)이 필요한 경우 한계 있음
    • 내부적으로 fetch join을 쓰지만, 제어력이 부족함

👉 정리:

  • 단순 조회 + 컬렉션 fetch → @EntityGraph
  • 복잡 조건 쿼리 → fetch join

2. LazyInitializationException

문제 상황

REST API에서 엔티티를 바로 반환했더니 LazyInitializationException이 터졌습니다.
(댓글이 LAZY로 설정된 상태였는데, 트랜잭션이 끝난 뒤 컨트롤러에서 JSON 변환을 시도했기 때문입니다.)

@RestController
public class PostController {
    @GetMapping("/posts/{id}")
    public Post getPost(@PathVariable Long id) {
        return postRepository.findById(id).orElseThrow();
    }
}

 

👉 comments를 직렬화하려는 순간, 세션이 이미 닫혀 있어서 에러가 발생했습니다.


해결 방법 1: DTO 변환

public class PostDto {
    private String title;
    private List<CommentDto> comments;
}
@GetMapping("/posts/{id}")
public PostDto getPost(@PathVariable Long id) {
    Post post = postRepository.findById(id).orElseThrow();
    return new PostDto(post.getTitle(),
                       post.getComments().stream()
                           .map(c -> new CommentDto(c.getContent()))
                           .toList());
}
  • 장점
    • 엔티티 노출 방지 → 유지보수성, 보안성 향상
    • 필요한 데이터만 추출 가능 → 응답 최적화
  • 단점
    • DTO 변환 코드가 많아짐
    • 작은 프로젝트에서는 다소 번거로움

해결 방법 2: OpenEntityManagerInView (OIV)

spring: 
	jpa: 
    	open-in-view: true
  • 장점
    • 추가 코드 없이 Lazy 로딩 가능
    • 빠른 개발, 간단한 테스트 프로젝트에는 편리
  • 단점
    • 트랜잭션 범위가 컨트롤러까지 확장 → 성능 이슈 발생
    • 실무 대규모 서비스에서는 권장되지 않음

👉 정리:

  • 실무, 장기 유지보수 → DTO 변환
  • 개인 토이 프로젝트, 빠른 프로토타입 → OIV 허용

3. 벌크 연산과 영속성 컨텍스트 불일치

문제 상황

사용자 상태를 일괄 업데이트하는 쿼리를 벌크 연산으로 실행했습니다.

@Modifying
@Query("update User u set u.status = 'INACTIVE' where u.lastLogin < :date")
int deactivateOldUsers(LocalDate date);

👉 그런데 같은 트랜잭션 안에서 다시 User를 조회하니 status 값이 여전히 이전 값으로 남아 있었습니다.

 


해결 방법 1: clearAutomatically = true

@Modifying(clearAutomatically = true)
@Query("update User u set u.status = 'INACTIVE' where u.lastLogin < :date")
int deactivateOldUsers(LocalDate date);
  • 장점
    • 벌크 연산 후 자동으로 영속성 컨텍스트 초기화
    • 불일치 문제 해결
  • 단점
    • 영속성 컨텍스트의 모든 엔티티가 초기화됨 → 불필요한 flush 발생 가능

해결 방법 2: 수동 em.clear()

@PersistenceContext
private EntityManager em;

...
userRepository.deactivateOldUsers(date);
em.clear();
 
  • 장점
    • 컨트롤 가능 → 원하는 시점에 초기화 가능
    • 성능을 세밀하게 조정 가능
  • 단점
    • 실수로 clear 누락 시 버그 발생
    • 코드 가독성이 떨어짐

👉 정리:

  • 안전하게 전역 적용 → clearAutomatically
  • 성능 튜닝 필요 → 수동 em.clear()

4. equals & hashCode 문제

문제 상황

User와 Role을 @ManyToMany로 매핑했는데, Set에 담은 후 비교가 이상하게 동작했습니다.
알고 보니 엔티티의 equals와 hashCode를 잘못 구현했기 때문이었습니다.


해결 방법 1: 비즈니스 키 기반 equals/hashCode

@Entity
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class User {
    @Id @GeneratedValue
    private Long id;

    @EqualsAndHashCode.Include
    private String email;
}
  • 장점
    • 중복 방지 가능
    • 비즈니스 키(email, username 등) 기준으로 안정적인 비교 가능
  • 단점
    • email 같은 키가 바뀌면 equals/hashCode 결과도 달라짐

해결 방법 2: 식별자(id) 기반 equals/hashCode

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof User)) return false;
    return id != null && id.equals(((User) o).id);
}
  • 장점
    • id 기반이라 변경 가능성 적음
    • 단순하고 안전
  • 단점
    • 영속화 전에는 id가 null이라 비교가 어려움

👉 정리:

  • 변하지 않는 유니크 컬럼 존재 → 비즈니스 키 기반
  • 그런 컬럼이 없다 → id 기반

5. Pagination 성능 문제

문제 상황

Post 목록을 페이지네이션해서 조회할 때, 단순히 Pageable을 적용했는데도 count 쿼리가 너무 무겁게 동작했습니다.


해결 방법

Spring Data JPA에서 제공하는 Pageable은 자동으로 count 쿼리를 실행합니다.
데이터가 수백만 건이라면 count가 병목이 됩니다.

해결 방법 1: Slice 사용

Slice<Post> posts = postRepository.findByStatus("ACTIVE", pageable);
  • 장점
    • count 쿼리 생략 → 성능 향상
    • “다음 페이지 여부만 필요”한 경우 적합
  • 단점
    • 전체 페이지 수를 알 수 없음

해결 방법 2: QueryDSL로 count 최적화

JPAQuery<Post> query = queryFactory
    .selectFrom(post)
    .where(post.status.eq("ACTIVE"));

List<Post> result = query.limit(pageSize + 1).fetch();
  • 장점
    • count 쿼리를 커스터마이징 가능
    • 성능 최적화에 유리
  • 단점
    • 코드 복잡도가 높아짐
    • Repository 로직이 무거워짐

👉 정리:

  • 단순 무한 스크롤 → Slice
  • 정확한 페이지 수 필요 → QueryDSL 최적화

마무리

제가 개인 프로젝트에서 경험한 JPA 문제와 해결책을 요약하면

  1. N+1 문제 → fetch join(성능 최적) vs EntityGraph(가독성)
  2. LazyInitializationException → DTO 변환(안정적) vs OIV(간단)
  3. 벌크 연산 불일치 → clearAutomatically(안전) vs em.clear()(세밀)
  4. equals/hashCode 문제 → 비즈니스 키(유연) vs id 기반(안전)
  5. Pagination 성능 문제 → Slice(간단) vs QueryDSL 최적화(정밀)

👉 결국 JPA에는 “정답”이 있는 게 아니라, 상황에 맞는 선택이 중요하다는 걸 배웠습니다.

저작자표시 (새창열림)
'Backend' 카테고리의 다른 글
  • 온프레미스 환경에서 무중단 배포(Zero Downtime Deployment) 구축 방안 탐구기
  • REST API
  • 쿠키 vs 세션 vs JWT
  • 캐시(Cache)
쭈니어 개발자
쭈니어 개발자
    홈 |
  • 쭈니어 개발자
    주니어 개발자 공부 기록
    쭈니어 개발자
  • 글쓰기 관리
  • 전체
    오늘
    어제
  • GitHub

    Notion

    • 분류 전체보기 (134)
      • Frontend (4)
      • Backend (21)
      • Database (4)
      • Data Structure & Algorithm (41)
      • Network (16)
      • IT Education (48)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 인기 글

  • 태그

    코테
    자바
    트리의 지름
    백준
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.4
쭈니어 개발자
JPA(Java Persistence API)
상단으로

티스토리툴바