[스프링부트 #16] JPA 연관관계 성능 튜닝과 Dirty Checking 원리

도경원's avatar
Aug 25, 2025
[스프링부트 #16] JPA 연관관계 성능 튜닝과 Dirty Checking 원리
오늘은 JPA에서 연관관계를 가진 엔티티를 조회할 때 성능 저하를 막는 방법과,
엔티티 수정 시 Dirty Checking(더티 체킹)이 어떻게 동작하는지 정리합니다.
여기에 인증·권한 체크와 같은 부가 로직 분리 팁도 함께 다룹니다.

1. 연관관계와 성능 문제

JPA에서 @ManyToOne, @OneToMany 같은 연관관계를 매핑하면,
연관된 엔티티를 불러올 때 로딩 전략에 따라 쿼리 수가 크게 달라집니다.
특히 findAll()처럼 컬렉션을 조회할 때 EAGER 로딩을 사용하면 다음과 같은 일이 발생합니다.
  • Board 목록을 불러올 때마다, 각 Board마다 User를 추가로 조회 → N+1 문제
  • 결국 SQL이 N+1번 실행되어 성능 급격히 저하

원칙

  • EAGER 로딩 금지
  • 항상 LAZY 로딩으로 설정 후, 필요한 경우 fetch join 사용

2. 성능 개선 방법

연관 엔티티를 한 번에 가져오려면 크게 두 가지 방법이 있습니다.
  1. JOIN(fetch join)
  1. IN query
이번 예제에서는 fetch join을 사용했습니다.
public Board findByIdJoinUser(int id) { Query query = em.createQuery( "select b from Board b join fetch b.user where b.id = :id", Board.class ); query.setParameter("id", id); return query.getSingleResult(); }
이렇게 하면 boarduser를 한 번의 쿼리로 가져와
board.getUser().getUsername() 호출 시 추가 쿼리가 발생하지 않습니다.

3. Dirty Checking(더티 체킹)과 flush

동작 원리

JPA에서는 영속 상태의 엔티티가 변경되면 자동으로 변경 내용을 감지하여(DB 반영 준비)
트랜잭션 종료 시점에 UPDATE 쿼리를 실행합니다. 이 과정을 Dirty Checking이라 합니다.

flush 시점

  • em.flush() 직접 호출
  • 트랜잭션 종료 시점(@Transactional 메서드 종료)
  • JPQL/SQL 직접 실행 시
flush는 버퍼에 모아둔 SQL을 DB로 보내는 과정입니다.

예제

@Test public void update_test() { Board findBoard = boardJpaRepository.findById(1); // 1) 조회 → PC에 저장 findBoard.update("title change", "content change"); // 2) 상태 변경 // 3) flush 시점에 update SQL 실행 }
이 방식의 장점은 여러 UPDATE를 모아서 한 번에 반영 → 디스크 I/O 최소화입니다.
성능 최적화에서 알고리즘 연산 개선보다 이런 I/O 줄이기가 훨씬 중요한 경우가 많습니다.
notion image

흐름 정리

  1. findById(id)
      • JPA가 DB에서 Board 엔티티를 조회해서 **영속성 컨텍스트(1차 캐시)**에 올림.
      • 이 시점의 findboard 객체는 영속 상태.
  1. findboard.update(...)
      • 영속 상태의 엔티티 필드 값을 변경.
      • JPA는 "변경 전 스냅샷"과 "현재 값"을 비교하기 위해 이 변경을 트래킹.
  1. 트랜잭션 종료 시점 (@Transactional 종료)
      • JPA가 flush() 실행.
      • 변경 감지(Dirty Checking)로, 바뀐 필드만 UPDATE 쿼리 생성.
      • 쿼리 전송 후 커밋.

왜 더티 체킹인가?

  • update(...)는 DB에 직접 UPDATE 쿼리를 날리는 게 아니라, 엔티티 객체의 상태만 변경.
  • JPA가 알아서 트랜잭션 끝날 때 변경된 부분을 찾아서 UPDATE SQL로 만들어 실행.
  • 즉, SQL을 내가 안 써도 JPA가 변경 감지를 해줌.

4. 인증·권한 체크

브라우저로부터 받은 데이터는 절대 신뢰하지 말 것!

예시: 세션 로그인 여부 확인

User sessionUser = (User) session.getAttribute("sessionUser"); if (sessionUser == null) { throw new RuntimeException("로그인 하세요"); }
  • 인증 실패: 401 Unauthorized
  • 권한 실패: 403 Forbidden
    • (예: 내가 쓴 글이 아닌데 삭제 시도)

5. 부가 로직 분리

  • 인증, 권한 체크, 로깅 등은 부가 로직
  • 핵심 비즈니스 로직과 분리해야 유지보수가 쉬움
  • 나중에 Interceptor, Filter, AOP로 빼서 관리 가능

6. Board.java의 Setter

notion image
이 메서드는 Board 객체의 제목과 내용을 한 번에 바꾸는 기능이다. 즉, title 필드와 content 필드를 동시에 수정한다.
update(title, content)는 게시글의 제목·내용을 수정하는 도메인 전용 메서드로, @Setter 남발을 피하고, 의미 있는 이름으로 객체의 상태 변경을 안전하게 관리하기 위해 만든 것이다.

7. Java HttpSession과 getAttribute() 이해하기

User sessionUser = (User) session.getAttribute("sessionUser");
User sessionUser = (User) session.getAttribute("sessionUser");는 서버가 브라우저별로 제공하는 HttpSession에서 "sessionUser"라는 이름으로 저장된 로그인 사용자 객체를 꺼내와 User 타입으로 변환해 변수에 담는 코드다. 즉, 로그인 시 setAttribute로 보관한 유저 정보를 이후 요청에서 다시 꺼내 쓰는 과정이라고 볼 수 있다.

오늘 내용 한 장 요약

  • EAGER 금지, fetch join으로 N+1 방지
  • Dirty Checking은 변경 내용을 모아 한 번에 반영
  • flush 시점: 직접 호출 / 트랜잭션 종료 / 쿼리 실행 시
  • 성능 최적화에서 I/O 줄이기가 핵심
  • 인증(401) / 권한(403) 구분
  • 부가 로직은 핵심 로직과 분리
JPA를 잘 쓴다는 건 단순히 동작시키는 것보다 성능·보안·구조까지 고려하는 것입니다.
이번 내용은 JPA 튜닝의 기초지만, 실제 서비스에서도 필수적으로 적용해야 하는 부분입니다.
 
Share article

Gyeongwon's blog