JPA 설계를 시작할 때 꼭 알아야 할 다섯 가지 원칙
JPA는 Java 진영에서 널리 사용되는 ORM 프레임워크다. 코드 몇 줄만으로 테이블을 매핑하고, 복잡한 SQL 없이도 객체 단위로 데이터를 다룰 수 있다는 점에서 생산성이 높다. 하지만 구조와 동작 원리를 충분히 이해하지 않은 채 사용하는 경우, 데이터 정합성 오류나 성능 문제를 겪게 된다.
이 글에서는 JPA를 도입할 때 반드시 고려해야 할 설계 원칙 다섯 가지를 정리한다. 단순한 사용법보다는, 왜 그렇게 설계해야 하는지, 그리고 그렇지 않았을 때 발생하는 문제를 중심으로 설명한다.
1. Setter는 최소화하고 생성자 또는 정적 팩토리 메서드를 사용하자
JPA에서는 엔티티의 모든 필드를 @Entity
에 선언하면 매핑이 완료된다. 이때 흔히 모든 필드에 setter
를 작성하곤 한다. 하지만 이렇게 되면 객체의 내부 상태가 언제든 외부에서 바뀔 수 있다. 이는 불변성을 무너뜨리고, 테스트나 유지보수에서 예기치 못한 문제를 유발한다.
권장되는 설계 방식은 다음과 같다:
- 모든 필드를
private
으로 선언 - 필수값은 생성자 또는 정적 팩토리 메서드에서만 설정
setter
는 정말 필요한 필드에만 제한적으로 사용
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
protected Member() {} // JPA 프록시용
public static Member create(String name) {
Member member = new Member();
member.name = name;
return member;
}
}
@Builder
를 사용할 수도 있지만, 필수값 누락 가능성을 줄이기 위해서는 정적 팩토리 메서드가 더 명확하다.
2. 연관관계는 단방향을 기본으로, 양방향은 꼭 필요한 경우에만
JPA에서 연관관계는 양방향으로 설정할 수 있지만, 꼭 그렇게 해야 하는 건 아니다. 사실 객체지향 설계에서는 한 방향으로의 참조가 자연스럽다.
// Member.java
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
// Team.java
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
여기서 중요한 개념이 연관관계의 주인(owner)
이다. @JoinColumn
이 선언된 쪽이 실제로 외래 키를 관리하며, 이 쪽만 DB에 반영된다. 반대편에 mappedBy
가 설정된 쪽은 읽기 전용이다.
양방향 연관관계의 단점:
- 데이터 정합성 오류 발생 가능성
- 양쪽 모두 값을 설정하지 않으면 DB와 객체 상태 불일치
- 유지보수가 어려워짐
그래서 보통 다음과 같이 설계한다:
- 1:N → 단방향
- N:1 → 단방향 (조회 시 필요하면 양방향 고려)
- N:M → 다대다 매핑 대신 중간 테이블을 엔티티로 만들어 해결
3. fetch 전략은 무조건 LAZY, EAGER는 절대 기본값으로 두지 말자
JPA에서는 연관된 엔티티를 로딩할 때 LAZY(지연 로딩)과 EAGER(즉시 로딩) 중 하나를 선택할 수 있다. @ManyToOne
과 @OneToOne
은 기본이 EAGER
인데, 이 상태로 여러 개의 엔티티를 조회하면 예상하지 못한 N+1 문제가 발생한다.
예시:
// 10개의 Post 조회 → 각 Post 마다 author (User) 즉시 로딩
SELECT * FROM post;
SELECT * FROM user WHERE id = ? -- 10번 반복
해결책은 다음과 같다:
- 연관관계는 항상
fetch = LAZY
로 명시 - 조회 시점에 필요한 연관 데이터를 fetch join이나 EntityGraph로 불러온다
@Query("SELECT p FROM Post p JOIN FETCH p.author WHERE p.id = :id")
Optional<Post> findWithAuthor(@Param("id") Long id);
또는
@EntityGraph(attributePaths = {"author"})
Optional<Post> findById(Long id);
페이징이 필요한 경우 fetch join은 사용할 수 없기 때문에, @BatchSize
를 활용하여 연관된 엔티티들을 한 번에 조회하는 방식도 고려할 수 있다.
4. equals/hashCode는 식별자(id)만 기준으로 구현하자
JPA에서는 객체를 식별할 때 equals()
와 hashCode()
를 사용할 수 있다. 그런데 연관 엔티티나 모든 필드로 비교를 하면 StackOverflowError 또는 무한 루프가 발생할 수 있다. 가장 안전한 구현 방식은 식별자 기반 비교이다.
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Member)) return false;
Member other = (Member) o;
return id != null && id.equals(other.id);
}
@Override
public int hashCode() {
return Objects.hashCode(id);
}
주의할 점:
id
는 영속성 컨텍스트에 등록되어야 값이 생성되므로, 비교 시점이 중요하다- 즉, 생성 직후에는 equals 결과가 예상과 다를 수 있다
이런 이유로 식별자 기반 비교는 엔티티가 식별 가능한 상태(영속 상태)일 때만 의미가 있다.
5. 생성 시점에 유효한 상태를 갖도록 객체를 설계하자
JPA의 특성상, 엔티티는 다음과 같은 순서로 초기화된다:
- 기본 생성자 호출 (프록시 또는 리플렉션 기반)
- 필드 값 주입
- 영속성 컨텍스트에 등록
이 과정 중에 객체가 '불완전한 상태'로 존재할 수 있다. 이를 방지하려면 객체가 생성되는 시점부터 비즈니스 규칙을 만족하는 상태를 갖도록 설계해야 한다.
예시 – 올바르지 않은 방식:
Member m = new Member(); // 아직 name 없음
m.setName("홍길동"); // 이제서야 유효
예시 – 권장 방식:
Member m = Member.create("홍길동"); // 생성 시점부터 유효
특히 테스트 작성 시 이런 설계는 객체를 안정적으로 다룰 수 있게 해준다. Kotlin을 사용할 경우 val
키워드를 적극 활용하여 불변 객체 설계를 강화하는 것도 좋은 전략이다.
참고자료
- JPA 공식 문서 – Hibernate ORM
- 김영한, 『자바 ORM 표준 JPA 프로그래밍』
- Spring Data JPA 공식 문서: https://docs.spring.io/spring-data/jpa/docs/current/reference/html/
- Vlad Mihalcea 블로그 – Hibernate 성능 최적화
'spring' 카테고리의 다른 글
Entity와 DTO, 어디까지 분리해야 할까? (0) | 2025.08.08 |
---|---|
[Spring-OAuth2] OAuth2AuthorizationServerConfiguration.applyDefaultSecurity - Deprecated 해결 (0) | 2025.01.18 |
[SpringBoot] JDBC - Connection Pool을 미리 생성하여 초기 속도 개선 (0) | 2024.03.31 |
[Springboot] spring properties 파일 한글 깨짐 오류 해결 (0) | 2023.09.06 |
[Springboot]@Transactional(readOnly = true) 에러 (0) | 2023.04.06 |
댓글