Skip to content

실무에서 바로 쓰는 JPA (5편) - 실무 안티패턴 총정리

실무에서 바로 쓰는 JPA (5편) - 실무 안티패턴 총정리




TL;DR

  • 문제: N+1, 영속성 컨텍스트, 쿼리 최적화, 엔티티 설계 등 JPA 실무 안티패턴이 복합적으로 발생
  • 원인: 각 주제를 개별적으로 다뤘지만, 실무에서는 여러 문제가 동시에 나타남
  • 해결: JPA 시리즈 1~4편 내용을 한 곳에 모아 체크리스트로 정리
  • 효과: 코드 리뷰 시 빠른 확인, 리팩토링 가이드, 신규 팀원 온보딩 자료
  • 한계: 시리즈 전체를 읽어야 완전히 이해, 체크리스트만으로는 깊이 부족, 실전 경험 필요


환경

  • 프레임워크: Spring Boot 3.x + JPA (Hibernate)
  • DB: MySQL 8.0, InnoDB
  • 시리즈: JPA 실무 안티패턴 종합 정리 (1~4편 요약)
  • 목표: 코드 리뷰 & 리팩토링 체크리스트




시리즈 안내

이 글은 “실무에서 바로 쓰는 JPA” 시리즈의 다섯 번째 편(최종편)입니다.

1편: N+1 문제 해결

연관관계 조회 시 발생하는 성능 문제와 해결 방법을 다룹니다.

2편: 영속성 컨텍스트와 흔한 실수들

JPA의 내부 동작 원리와 실수하기 쉬운 포인트들을 짚어봅니다.

3편: 쿼리 최적화 실전

복잡한 쿼리를 효율적으로 작성하는 노하우를 공유합니다.

4편: 엔티티 설계 마스터하기

유지보수하기 좋은 엔티티 설계 원칙을 다룹니다.

5편 (현재 글): 실무 안티패턴 총정리

실무에서 자주 발생하는 실수와 개선 방법을 총정리합니다.

시리즈 요약:

1편에서 N+1 문제를, 2편에서 영속성 컨텍스트를, 3편에서 쿼리 최적화를, 4편에서 엔티티 설계를 다뤘습니다. 각 주제를 깊이 있게 다뤘지만, 실무에서는 이런 문제들이 복합적으로 나타납니다.

이번 최종편에서는 지금까지 다룬 내용을 한 곳에 모아 정리했습니다. 코드 리뷰할 때나 리팩토링할 때 빠르게 확인할 수 있는 체크리스트로 활용하시면 됩니다.





들어가며

회사에서 레거시 코드를 리팩토링하다 보면, 성능 문제의 원인이 대부분 JPA 사용법에 있더군요.

특히 신입 개발자분들이 작성한 코드를 보면, 기능은 정상 동작하지만 성능이 좋지 않은 경우가 많습니다. 코드는 동작하니까 별문제 없다고 생각하지만, 데이터가 늘어나면서 서서히 문제가 드러나기 시작합니다.

저도 처음 JPA를 배울 때 비슷한 실수를 많이 했었습니다. 이번 포스팅에서는 실무에서 자주 목격하는 JPA 안티패턴들과 개선 방법을 공유합니다.





반복문 안에서 save() 호출

문제 상황

가장 흔하게 보는 패턴입니다. 엑셀 파일을 업로드해서 대량의 데이터를 저장하는 기능을 구현할 때 이런 코드를 자주 봅니다.

// Bad - 반복문 안에서 개별 save
@Transactional
public void saveUsers(List<UserDto> userDtos) {
    for (UserDto dto : userDtos) {
        User user = new User(dto.getName(), dto.getEmail());
        userRepository.save(user);
    }
}

1,000건의 데이터를 저장한다면 1,000번의 INSERT 쿼리가 나갑니다. 각 쿼리마다 네트워크 왕복이 발생하니 당연히 느릴 수밖에 없습니다.

저희 팀에서 측정했을 때, 1,000건 기준으로 약 15초 정도 걸렸습니다.

해결 방법

JPA의 saveAll()을 사용하고, spring.jpa.properties.hibernate.jdbc.batch_size 설정을 추가하면 됩니다.

// Good - saveAll + batch insert
@Transactional
public void saveUsers(List<UserDto> userDtos) {
    List<User> users = userDtos.stream()
        .map(dto -> new User(dto.getName(), dto.getEmail()))
        .collect(Collectors.toList());

    userRepository.saveAll(users);
}
# application.yml
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 100
        order_inserts: true
        order_updates: true

배치 사이즈를 100으로 설정하면 100개씩 묶어서 INSERT합니다. 같은 1,000건을 저장하는 데 약 2초로 단축됐습니다.

1,000건 데이터 저장 성능 비교

⚠️반복문 save()
15초
1000번의 INSERT 쿼리
⚡️saveAll() + batch
2초
10번의 배치 INSERT
결과약 7.5배 향상
⚠️주의!

@GeneratedValue(strategy = GenerationType.IDENTITY)를 사용하면 배치 인서트가 동작하지 않습니다. MySQL의 auto_increment를 사용한다면 SEQUENCE 전략으로 바꾸거나, TABLE 전략을 고려해야 합니다.



N+1 문제 방치

문제 상황

이건 정말 클래식한 문제입니다. 엔티티 간 연관관계가 있을 때 무심코 조회하다 보면 쿼리가 폭발합니다.

// Bad - N+1 발생
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> orderItems;
}

// 컨트롤러나 서비스에서
List<Order> orders = orderRepository.findAll();  // 1번의 쿼리

for (Order order : orders) {
    String userName = order.getUser().getName();  // N번의 쿼리
    int itemCount = order.getOrderItems().size(); // N번의 쿼리
}

주문이 100건이면 1 + 100 + 100 = 201번의 쿼리가 나갑니다.

실제로 저희 서비스에서 주문 목록 API가 느리다는 제보를 받고 확인해보니, 주문 50건에 대해 151번의 쿼리가 발생하고 있었습니다.

해결 방법

Fetch Join이나 EntityGraph를 사용해서 한 번에 가져옵니다.

// Good - Fetch Join으로 한 방에 조회
public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("SELECT o FROM Order o " +
           "JOIN FETCH o.user " +
           "JOIN FETCH o.orderItems")
    List<Order> findAllWithUserAndItems();
}

또는 @EntityGraph를 사용할 수도 있습니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"user", "orderItems"})
    @Query("SELECT o FROM Order o")
    List<Order> findAllWithGraph();
}

두 방법 모두 1번의 쿼리로 모든 데이터를 가져옵니다. 응답 시간이 약 800ms에서 150ms로 줄었습니다.

주문 목록 조회 API 성능

⚠️N+1 발생
800ms
151번의 쿼리 (1 + 100 + 50)
⚡️Fetch Join
150ms
1번의 쿼리
결과약 5.3배 향상
🚨MultipleBagFetchException 주의!

컬렉션을 2개 이상 Fetch Join하면 예외가 발생합니다. 이런 경우에는 @BatchSize를 사용하거나, 쿼리를 나눠서 처리해야 합니다.

// 컬렉션이 2개 이상일 때는 @BatchSize 활용
@Entity
public class Order {
    // ...

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> orderItems;

    @BatchSize(size = 100)
    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderHistory> orderHistories;
}


영속성 컨텍스트를 이해하지 못한 채 사용

문제 상황

JPA 초보분들이 가장 많이 헷갈려하는 부분입니다. 엔티티를 조회했는데도 DB에 반영이 안 되거나, 명시적으로 save를 호출하지 않았는데 UPDATE가 발생하는 경우를 혼란스러워합니다.

// Bad - 불필요한 save 호출
@Transactional
public void updateUser(Long userId, String newName) {
    User user = userRepository.findById(userId)
        .orElseThrow(() -> new IllegalArgumentException("User not found"));

    user.setName(newName);
    userRepository.save(user);  // 사실 필요 없음
}

영속성 컨텍스트가 변경 감지(Dirty Checking)를 해주기 때문에, @Transactional 안에서 엔티티를 수정하면 트랜잭션 커밋 시점에 자동으로 UPDATE 쿼리가 나갑니다. save()를 호출할 필요가 없습니다.

💡변경 감지(Dirty Checking)란?

JPA는 트랜잭션 커밋 시점에 영속성 컨텍스트에 있는 엔티티의 스냅샷과 현재 상태를 비교합니다. 변경된 부분이 있으면 자동으로 UPDATE 쿼리를 생성해서 DB에 반영합니다.

제대로 이해하기

// Good - 변경 감지 활용
@Transactional
public void updateUser(Long userId, String newName) {
    User user = userRepository.findById(userId)
        .orElseThrow(() -> new IllegalArgumentException("User not found"));

    user.setName(newName);
    // save 호출 없이도 트랜잭션 커밋 시 자동 UPDATE
}

반대로 @Transactional이 없으면 변경 감지가 동작하지 않습니다.

// Bad - @Transactional이 없어서 변경 감지 안 됨
public void updateUser(Long userId, String newName) {
    User user = userRepository.findById(userId)
        .orElseThrow(() -> new IllegalArgumentException("User not found"));

    user.setName(newName);
    // DB에 반영 안 됨!
}

저희 팀 신입 개발자가 이 부분 때문에 한참 헤맸던 기억이 납니다. 로컬에서는 잘 되는데 프로덕션에서 업데이트가 안 된다고 하더군요. 확인해보니 서비스 메서드에 @Transactional이 빠져 있었습니다.



양방향 연관관계의 함정

문제 상황

양방향 연관관계를 설정해놓고 한쪽만 설정하는 경우입니다.

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;
}

// Bad - 한쪽만 설정
@Transactional
public void addMember() {
    Team team = new Team();
    teamRepository.save(team);

    Member member = new Member();
    member.setTeam(team);  // 연관관계 설정
    memberRepository.save(member);

    // team.getMembers().size();  // 0이 나옴!
}

연관관계의 주인인 Member 쪽만 설정했기 때문에 DB에는 정상적으로 저장됩니다. 하지만 영속성 컨텍스트에서는 Teammembers 리스트가 비어있습니다.

같은 트랜잭션 안에서 team.getMembers()를 호출하면 빈 리스트가 나옵니다. 이게 의외로 버그를 유발합니다.

해결 방법

편의 메서드를 만들어서 양쪽을 모두 설정합니다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;

    // 편의 메서드
    public void setTeam(Team team) {
        // 기존 팀과의 관계 제거
        if (this.team != null) {
            this.team.getMembers().remove(this);
        }

        this.team = team;

        // 새로운 팀에 멤버 추가
        if (team != null && !team.getMembers().contains(this)) {
            team.getMembers().add(this);
        }
    }
}

// Good - 편의 메서드 사용
@Transactional
public void addMember() {
    Team team = new Team();
    teamRepository.save(team);

    Member member = new Member();
    member.setTeam(team);  // 양방향 모두 설정됨
    memberRepository.save(member);

    // team.getMembers().size();  // 1이 나옴
}


즉시 로딩(EAGER) 남발

문제 상황

모든 연관관계를 FetchType.EAGER로 설정하는 경우를 종종 봅니다. 당장 필요하니까 미리 다 가져오자는 생각인 것 같은데, 이게 문제가 됩니다.

// Bad - EAGER 남발
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.EAGER)  // 항상 즉시 로딩
    private User user;

    @OneToMany(mappedBy = "order", fetch = FetchType.EAGER)
    private List<OrderItem> orderItems;

    @OneToMany(mappedBy = "order", fetch = FetchType.EAGER)
    private List<OrderHistory> orderHistories;
}

// 주문 목록만 조회하려고 했는데...
List<Order> orders = orderRepository.findAll();
// User, OrderItem, OrderHistory까지 전부 조인해서 가져옴

주문 목록 화면에서는 주문 정보만 필요한데, User와 OrderItem, OrderHistory까지 전부 가져옵니다. 데이터가 많아지면 엄청나게 무거워집니다.

더 큰 문제는 JPQL을 사용할 때 N+1 문제가 무조건 발생한다는 점입니다.

해결 방법

기본은 LAZY로 설정하고, 필요한 곳에서만 Fetch Join을 사용합니다.

// Good - 기본은 LAZY
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderItem> orderItems;

    @OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
    private List<OrderHistory> orderHistories;
}

// 필요한 경우에만 Fetch Join
@Query("SELECT o FROM Order o JOIN FETCH o.user WHERE o.id = :id")
Optional<Order> findByIdWithUser(@Param("id") Long id);

실무에서 EAGER를 사용할 일은 거의 없습니다. @ManyToOne, @OneToOne의 기본값이 EAGER인데, 이것도 명시적으로 LAZY로 바꾸는 게 좋습니다.



변경 감지 대신 벌크 연산을 사용하지 않음

문제 상황

대량의 데이터를 업데이트할 때 영속성 컨텍스트를 통해 하나씩 수정하는 경우입니다.

// Bad - 변경 감지로 대량 업데이트
@Transactional
public void deactivateOldUsers() {
    List<User> oldUsers = userRepository.findByLastLoginBefore(
        LocalDateTime.now().minusMonths(6)
    );

    for (User user : oldUsers) {
        user.setStatus(UserStatus.INACTIVE);
    }
    // 변경 감지로 하나씩 UPDATE - 매우 느림
}

6개월 이상 로그인하지 않은 사용자가 10,000명이라면, 10,000번의 UPDATE 쿼리가 나갑니다.

더 큰 문제는 10,000개의 엔티티를 영속성 컨텍스트에 올리면서 메모리도 많이 사용한다는 점입니다.

해결 방법

벌크 연산(@Modifying)을 사용합니다.

// Good - 벌크 연산
public interface UserRepository extends JpaRepository<User, Long> {

    @Modifying(clearAutomatically = true)
    @Query("UPDATE User u SET u.status = :status " +
           "WHERE u.lastLogin < :date")
    int updateStatusByLastLoginBefore(
        @Param("status") UserStatus status,
        @Param("date") LocalDateTime date
    );
}

@Transactional
public void deactivateOldUsers() {
    LocalDateTime sixMonthsAgo = LocalDateTime.now().minusMonths(6);
    int updatedCount = userRepository.updateStatusByLastLoginBefore(
        UserStatus.INACTIVE,
        sixMonthsAgo
    );
    log.info("Updated {} users to INACTIVE", updatedCount);
}

1번의 쿼리로 모든 데이터를 업데이트합니다. 10,000건 기준으로 약 30초에서 1초로 단축됐습니다.

10,000건 대량 업데이트 성능

⚠️변경 감지
30초
10,000번의 UPDATE + 메모리 부하
⚡️벌크 연산
1초
1번의 UPDATE 쿼리
결과약 30배 향상
⚠️영속성 컨텍스트 주의!

벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 날립니다. 그래서 clearAutomatically = true 옵션을 줘서, 벌크 연산 후 영속성 컨텍스트를 자동으로 초기화해야 합니다.



연관관계 편의 메서드 누락

문제 상황

양방향 연관관계에서 편의 메서드를 만들지 않고, 비즈니스 로직에서 양쪽을 직접 설정하는 경우입니다.

// Bad - 편의 메서드 없이 직접 설정
@Transactional
public void createOrder(Long userId, List<Long> itemIds) {
    User user = userRepository.findById(userId)
        .orElseThrow(() -> new IllegalArgumentException("User not found"));

    Order order = new Order();
    order.setUser(user);
    user.getOrders().add(order);  // 헷갈리기 쉬움

    for (Long itemId : itemIds) {
        Item item = itemRepository.findById(itemId)
            .orElseThrow(() -> new IllegalArgumentException("Item not found"));

        OrderItem orderItem = new OrderItem();
        orderItem.setOrder(order);
        orderItem.setItem(item);
        order.getOrderItems().add(orderItem);  // 깜빡하기 쉬움
    }

    orderRepository.save(order);
}

양쪽을 설정하는 걸 깜빡하기 쉽고, 코드가 지저분합니다.

해결 방법

도메인 모델에 편의 메서드를 만들어서 캡슐화합니다.

// Good - 편의 메서드로 캡슐화
@Entity
public class Order {
    // ...

    // 연관관계 편의 메서드
    public void setUser(User user) {
        this.user = user;
        if (!user.getOrders().contains(this)) {
            user.getOrders().add(this);
        }
    }

    public void addOrderItem(OrderItem orderItem) {
        orderItems.add(orderItem);
        orderItem.setOrder(this);
    }
}

@Transactional
public void createOrder(Long userId, List<Long> itemIds) {
    User user = userRepository.findById(userId)
        .orElseThrow(() -> new IllegalArgumentException("User not found"));

    Order order = new Order();
    order.setUser(user);  // 양방향 모두 설정됨

    for (Long itemId : itemIds) {
        Item item = itemRepository.findById(itemId)
            .orElseThrow(() -> new IllegalArgumentException("Item not found"));

        OrderItem orderItem = new OrderItem(item);
        order.addOrderItem(orderItem);  // 양방향 모두 설정됨
    }

    orderRepository.save(order);
}

비즈니스 로직이 훨씬 깔끔해지고, 실수할 여지가 줄어듭니다.



@Transactional을 클래스 레벨에만 붙이기

문제 상황

모든 메서드에 트랜잭션이 필요하지 않은데, 클래스에 @Transactional을 붙여버리는 경우입니다.

// Bad - 불필요한 트랜잭션
@Service
@Transactional
public class UserService {

    // 단순 조회인데도 트랜잭션이 열림
    public List<UserDto> getUsers() {
        List<User> users = userRepository.findAll();
        return users.stream()
            .map(UserDto::from)
            .collect(Collectors.toList());
    }

    // 실제로 트랜잭션이 필요한 메서드
    public void createUser(UserDto dto) {
        User user = new User(dto.getName(), dto.getEmail());
        userRepository.save(user);
    }
}

단순 조회성 메서드에도 트랜잭션이 열리면서 불필요한 리소스를 사용합니다. 필요한 메서드에만 @Transactional을 붙이는 것이 좋습니다.

조회성 메서드에는 @Transactional(readOnly = true)를 붙여서 성능 최적화를 할 수 있습니다. 읽기 전용 트랜잭션은 CUD 작업을 하지 않기 때문에 변경 감지를 위한 스냅샷을 만들지 않습니다.

저희 팀에서 측정했을 때, 대량 조회 API에서 readOnly = true 옵션으로 약 10-15% 정도 성능이 개선됐습니다.





시스템 점검 체크리스트

저도 코드 리뷰할 때 이 항목들을 확인합니다. JPA를 사용한다면 참고하시면 좋을 것 같습니다.

  • N+1 문제 확인: 연관관계 조회 시 Fetch Join, @EntityGraph, 또는 Batch Size를 적용했는가?
  • 영속성 컨텍스트 이해: 1차 캐시와 변경 감지 메커니즘을 이해하고 사용하는가?
  • 쿼리 최적화: 필요한 컬럼만 조회(Projection)하는가? 전체 엔티티를 로드하지 않는가?
  • 엔티티 설계: 양방향 연관관계를 최소화하고, setter 대신 Builder를 사용하는가?
  • 트랜잭션 범위: 읽기는 readOnly=true, 쓰기는 적절한 전파 속성을 설정했는가?



결론

JPA는 강력한 도구지만, 제대로 이해하지 않으면 오히려 성능 문제를 만드는 주범이 됩니다.

저도 처음에는 “ORM 쓰면 SQL 몰라도 되겠네”라고 생각했었습니다. 하지만 실제로는 SQL을 더 잘 알아야 JPA를 제대로 쓸 수 있더군요. 어떤 쿼리가 나가는지, 언제 조인이 발생하는지를 정확히 알아야 합니다.

실무에서 JPA를 사용할 때 제가 항상 하는 습관은 이겁니다.

개발 환경에서 spring.jpa.show-sql=truespring.jpa.properties.hibernate.format_sql=true를 켜놓고, 내가 예상한 쿼리가 나가는지 확인하는 것.

예상과 다른 쿼리가 나간다면, 그건 JPA를 잘못 사용하고 있다는 신호입니다.

마지막으로 한 가지 더. JPA는 편리하지만 만능은 아닙니다. 복잡한 통계 쿼리나 대량 데이터 처리에는 MyBatis나 QueryDSL, 또는 네이티브 쿼리를 사용하는 게 나을 때도 많습니다.

상황에 맞는 도구를 선택하는 것, 그게 시니어 개발자의 역할이라고 생각합니다.

실무 필수 습관

개발 환경에서 spring.jpa.show-sql=truespring.jpa.properties.hibernate.format_sql=true를 켜놓고, 예상한 쿼리가 나가는지 확인하세요. 예상과 다르다면 JPA를 잘못 사용하고 있다는 신호입니다.


시리즈를 마치며:

5편에 걸쳐 JPA를 실무에서 제대로 사용하는 방법을 다뤘습니다. N+1 문제, 영속성 컨텍스트, 쿼리 최적화, 엔티티 설계, 그리고 안티패턴까지. 2년간 시행착오를 겪으며 배운 내용들입니다.

같은 실수를 반복하는 분들이 줄어들길 바랍니다. 그리고 저도 여전히 배우고 있습니다.





참고 :

JPA 공식 문서
Hibernate 공식 문서
자바 ORM 표준 JPA 프로그래밍 - 김영한
실전 스프링 부트와 JPA 활용 - 김영한




읽어주셔서 감사합니다.🖐


Ramsbaby
Written byRamsbaby
이 블로그는 직접 개발/운영하는 블로그이므로 당신을 불쾌하게 만드는 불필요한 광고가 없습니다.

#My Github#소개 페이지#Blog OpenSource Github#Blog OpenSource Demo Site