Spring Boot

Spring Boot JPA 엔티티 설계 원칙(연관관계 포함) — 실무에서 덜 고생하는 기본기

IT Lab 2026. 3. 12. 20:00

Spring Boot 3.x 기준으로 JPA 엔티티를 설계할 때 꼭 지켜야 할 원칙(식별자 전략, 연관관계 방향, Lazy 기본, 엔티티 코드 규칙)을 예제 코드로 정리합니다.

도입 (문제 상황)

Spring Data JPA로 Repository는 금방 만들었는데, 엔티티 설계에서부터 막히는 경우가 많아요. 연관관계를 어디에 두어야 할지, LAZY로 두면 언제 터지고 EAGER로 두면 왜 느려지는지, 식별자는 어떤 전략이 안전한지 고민이 시작됩니다. 이 글에서는 “나중에 운영에서 덜 고생하는” 엔티티 설계 원칙을 기준으로 정리해 볼게요.

핵심 개념 (Spring Boot JPA 엔티티 설계 원칙)

1) 엔티티는 “DB 테이블”이 아니라 “도메인 모델”로 설계합니다

엔티티는 단순 DTO가 아니라 상태와 규칙을 가진 도메인 객체에 가까워요. 너무 많은 필드를 공개(setter 남발)하면, 변경 지점이 흩어져서 버그가 늘어납니다.
실무에서는 기본 생성자는 protected, 변경은 **의미 있는 메서드(예: changeAddress)**로 제한하는 방식이 유지보수에 유리합니다.

2) 식별자(@Id) 전략: 기본은 IDENTITY보다 SEQUENCE/UUID를 고민합니다

JPA 식별자는 “엔티티 동일성”의 기준이라서, 전략 선택이 성능/운영에 영향을 줍니다.

  • MySQL 계열: 보통 IDENTITY(auto_increment)를 많이 씁니다. 다만 IDENTITY는 INSERT 후에야 PK를 알 수 있어 배치 INSERT 최적화가 제한될 수 있습니다(하이버네이트가 한 번에 모아서 INSERT하기 어려움).
  • PostgreSQL/Oracle: SEQUENCE가 자연스럽고, JPA도 최적화 옵션(allocations)을 활용하기 좋습니다.
  • 분산 환경/마이그레이션: 숫자 증가 PK 대신 UUID(또는 ULID 등)를 고려하면 충돌 위험을 줄일 수 있습니다. 대신 인덱스/정렬 비용이 늘 수 있어요.

아래 표처럼 “DB/요구사항”에 맞춰 선택하는 게 안전합니다.

전략 장점 단점/주의 추천 상황
IDENTITY 단순, MySQL에 익숙 배치 INSERT 최적화 제약, INSERT 전 id 없음 단순 CRUD, MySQL
SEQUENCE 성능/최적화 유리, INSERT 전 id 확보 가능 DB가 시퀀스를 지원해야 함 PostgreSQL/Oracle, 트래픽 많은 서비스
UUID 분산/병합에 강함 인덱스 비대, 정렬 비용 멀티 리전/데이터 병합/외부 노출 PK 회피

3) 연관관계 방향: “조회가 필요한 쪽”에 두되, 양방향은 최소화합니다

JPA 연관관계 주인과 mappedBy 개념 다이어그램

연관관계는 설계의 핵심인데, 요점은 이거예요.

  • 단방향이 기본입니다. 필요할 때만 양방향을 씁니다.
  • 양방향은 편해 보이지만, 관리 포인트가 늘어요(연관관계 편의 메서드, 무한 루프 직렬화, 예기치 않은 로딩 등).
  • “항상 함께 조회”가 아니라면, 컬렉션을 무작정 열어두지 않는 편이 안전합니다.

또 하나 중요한 사실: JPA에서 연관관계의 주인은 외래키를 가진 쪽(@ManyToOne) 입니다.
즉, Order -> MemberOrder가 주인이 되고, Member.orders는 보통 mappedBy로 읽기 전용에 가깝습니다.

flowchart LR
  A["Order(주인, FK 보유)"] -->|ManyToOne| B["Member"]
  B -->|OneToMany(mappedBy)| A

연관관계의 주인은 외래키를 가진 쪽이며, 반대편은 mappedBy로 매핑합니다.

4) Lazy 로딩은 기본값처럼 사용합니다(특히 ToOne/ToMany 모두)

JPA 기본값을 그대로 쓰면 위험한 지점이 있습니다.

  • @ManyToOne, @OneToOne의 기본 fetch는 EAGER 입니다. 그대로 두면 예상치 못한 조인/추가 쿼리로 성능이 흔들릴 수 있어요.
  • 그래서 실무에서는 ToOne도 명시적으로 fetch = LAZY 를 붙이는 습관이 중요합니다.
  • @OneToMany/@ManyToMany는 기본이 LAZY지만, 컬렉션 접근 시점에 쿼리가 나가므로 서비스 계층에서 트랜잭션 범위를 의식해야 합니다.

다음 글(#17)에서 다룰 N+1 문제가 여기서 시작되는 경우가 많습니다.

5) equals/hashCode, toString, JSON 직렬화는 조심합니다

  • 엔티티에 toString()을 자동 생성해 연관관계를 찍으면, LAZY 로딩이 터지거나 무한 루프가 날 수 있어요.
  • equals/hashCode를 연관관계/가변 필드 기반으로 만들면 Set/Map에서 동작이 이상해집니다.
    실무에서는 식별자 기반(단, 영속화 전 null 고려) 또는 비즈니스 키(유일/불변) 기반으로 신중히 선택합니다.
  • API 응답은 엔티티를 그대로 내보내기보다 DTO로 변환하는 편이 안전합니다(직렬화 중 프록시 초기화 문제 예방).

코드 예제 (Spring Boot 3.x / Java 17, 연관관계 + Lazy + 식별자 전략)

아래 코드는 “주문(Order)–회원(Member)–주문상품(OrderItem)–상품(Product)” 모델로, 연관관계 방향과 LAZY 기본 원칙을 담은 실행 가능한 예제입니다.

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.2'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain { languageVersion = JavaLanguageVersion.of(17) }
}

repositories { mavenCentral() }

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.h2database:h2'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') { useJUnitPlatform() }

application.yml

spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
        highlight_sql: true
        use_sql_comments: true

logging:
  level:
    org.hibernate.SQL: debug
    org.hibernate.orm.jdbc.bind: trace

엔티티 코드

package com.example.demo.domain;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "members")
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    // 양방향은 필요할 때만: 여기서는 "회원의 주문 목록 조회"가 잦다고 가정
    @OneToMany(mappedBy = "member", orphanRemoval = true)
    private final List<Order> orders = new ArrayList<>();

    protected Member() {}

    public Member(String name) {
        this.name = name;
    }

    public Long getId() { return id; }
    public String getName() { return name; }

    // 연관관계 편의 메서드(양쪽 동기화)
    void addOrder(Order order) {
        orders.add(order);
    }
}
package com.example.demo.domain;

import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "orders")
public class Order {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // ToOne은 기본이 EAGER이므로 반드시 LAZY 명시
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "member_id")
    private Member member;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private final List<OrderItem> orderItems = new ArrayList<>();

    protected Order() {}

    private Order(Member member) {
        this.member = member;
        member.addOrder(this);
    }

    public static Order create(Member member) {
        return new Order(member);
    }

    public Long getId() { return id; }
    public Member getMember() { return member; }
    public List<OrderItem> getOrderItems() { return orderItems; }

    public void addItem(Product product, int quantity) {
        OrderItem item = OrderItem.create(this, product, quantity);
        orderItems.add(item);
    }
}
package com.example.demo.domain;

import jakarta.persistence.*;

@Entity
@Table(name = "order_items")
public class OrderItem {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // ToOne LAZY 명시
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "order_id")
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "product_id")
    private Product product;

    @Column(nullable = false)
    private int quantity;

    protected OrderItem() {}

    private OrderItem(Order order, Product product, int quantity) {
        this.order = order;
        this.product = product;
        this.quantity = quantity;
    }

    public static OrderItem create(Order order, Product product, int quantity) {
        return new OrderItem(order, product, quantity);
    }

    public Long getId() { return id; }
}
package com.example.demo.domain;

import jakarta.persistence.*;

@Entity
@Table(name = "products")
public class Product {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    protected Product() {}

    public Product(String name) {
        this.name = name;
    }

    public Long getId() { return id; }
    public String getName() { return name; }
}

Repository + 간단 실행(Controller)

package com.example.demo.repository;

import com.example.demo.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {}
package com.example.demo.repository;

import com.example.demo.domain.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {}
package com.example.demo.repository;

import com.example.demo.domain.Order;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderRepository extends JpaRepository<Order, Long> {}
package com.example.demo.web;

import com.example.demo.domain.Member;
import com.example.demo.domain.Order;
import com.example.demo.domain.Product;
import com.example.demo.repository.MemberRepository;
import com.example.demo.repository.OrderRepository;
import com.example.demo.repository.ProductRepository;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/demo")
public class DemoController {

    private final MemberRepository memberRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;

    public DemoController(MemberRepository memberRepository,
                          ProductRepository productRepository,
                          OrderRepository orderRepository) {
        this.memberRepository = memberRepository;
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
    }

    @PostMapping("/seed")
    @Transactional
    public String seed() {
        Member member = memberRepository.save(new Member("kim"));
        Product p1 = productRepository.save(new Product("keyboard"));
        Product p2 = productRepository.save(new Product("mouse"));

        Order order = Order.create(member);
        order.addItem(p1, 1);
        order.addItem(p2, 2);

        orderRepository.save(order);
        return "ok";
    }

    @GetMapping("/orders/{id}")
    @Transactional(readOnly = true)
    public String read(@PathVariable Long id) {
        Order order = orderRepository.findById(id).orElseThrow();

        // LAZY 로딩은 접근 시점에 쿼리가 나갑니다(트랜잭션 안에서 안전)
        String memberName = order.getMember().getName();
        int itemCount = order.getOrderItems().size();

        return "orderId=" + order.getId() + ", member=" + memberName + ", items=" + itemCount;
    }
}

 

실무 팁

💡 실무에서는: 엔티티의 기본 규칙을 “템플릿”으로 정해두면 편합니다

  • ToOne은 무조건 fetch = LAZY를 명시해 보세요(기본 EAGER 함정 회피).
  • 기본 생성자는 protected, setter는 최소화, 생성/변경 메서드로 의도를 드러내면 유지보수 비용이 줄어듭니다.
  • API 응답은 엔티티 직접 반환 대신 DTO 변환을 기본 규칙으로 두는 편이 안전합니다(프록시/순환참조 방지).

💡 실무에서는: 양방향 연관관계는 “읽기 모델”에서 특히 조심합니다

  • 양방향을 늘리면 편의는 생기지만, N+1/직렬화/연관관계 동기화 같은 비용도 같이 따라옵니다.
  • “정말 필요한 화면/쿼리”가 생길 때 fetch join이나 EntityGraph로 해결하는 접근이 더 예측 가능합니다. (다음 글 #17에서 이어집니다)

핵심 요약: 엔티티는 도메인 모델로 설계하고 setter를 줄이세요.
핵심 요약: 연관관계는 단방향 + ToOne LAZY를 기본으로, 양방향은 최소화하세요.
핵심 요약: 식별자 전략은 DB/운영 요구에 맞춰 선택하고, 기본값의 함정을 의식하세요.

다음 글: [#17 N+1 문제 원인과 해결(fetch join/EntityGraph)]