spring

JPA 설계를 시작할 때 꼭 알아야 할 다섯 가지 원칙

태오님 2025. 8. 6.

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의 특성상, 엔티티는 다음과 같은 순서로 초기화된다:

  1. 기본 생성자 호출 (프록시 또는 리플렉션 기반)
  2. 필드 값 주입
  3. 영속성 컨텍스트에 등록

이 과정 중에 객체가 '불완전한 상태'로 존재할 수 있다. 이를 방지하려면 객체가 생성되는 시점부터 비즈니스 규칙을 만족하는 상태를 갖도록 설계해야 한다.

예시 – 올바르지 않은 방식:

Member m = new Member();  // 아직 name 없음
m.setName("홍길동");       // 이제서야 유효

예시 – 권장 방식:

Member m = Member.create("홍길동");  // 생성 시점부터 유효

특히 테스트 작성 시 이런 설계는 객체를 안정적으로 다룰 수 있게 해준다. Kotlin을 사용할 경우 val 키워드를 적극 활용하여 불변 객체 설계를 강화하는 것도 좋은 전략이다.


참고자료

댓글