Entity 간의 관계를 매핑하는 방법, 단방향과 양방향의 차이, 연관관계의 주인 개념을 학습합니다.
// 외래키를 그대로 필드로 가지는 경우
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long customerId; // 외래키 직접 보관 (객체지향적이지 않음)
private Long productId;
}
// 사용 시 불편함
Order order = orderRepository.findById(orderId).orElseThrow();
Long customerId = order.getCustomerId();
Customer customer = customerRepository.findById(customerId).orElseThrow(); // 추가 조회 필요
// 객체 그래프 탐색 불가
// order.getCustomer().getName() ← 불가능!// 객체 참조로 연관관계 설정
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer; // 객체 참조!
}
// 사용 시 편리함
Order order = orderRepository.findById(orderId).orElseThrow();
Customer customer = order.getCustomer(); // 바로 접근 가능!
String customerName = order.getCustomer().getName(); // 객체 그래프 탐색방향 (Direction)
단방향: 한쪽만 참조 / 양방향: 양쪽 모두 참조
다중성 (Multiplicity)
다대일(N:1), 일대다(1:N), 일대일(1:1), 다대다(N:M)
연관관계의 주인 (Owner)
양방향 관계에서 외래키를 관리하는 쪽. 주인만 외래키 등록/수정 가능
Customer
고객
Order
주문
Order
주문
OrderItem
주문상품
Category
카테고리
Product
상품
가장 기본적인 연관관계입니다. 한쪽 Entity에서만 다른 Entity를 참조합니다.실무에서는 단방향으로 시작하고, 필요할 때만 양방향을 추가하는 것이 좋습니다.
여러 주문이 한 고객에게 속합니다. 외래키가 있는 Order 쪽에서 Customer를 참조합니다.
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 다대일 단방향
@ManyToOne(fetch = FetchType.LAZY) // 지연 로딩 권장
@JoinColumn(name = "customer_id") // FK 컬럼명
private Customer customer;
private LocalDateTime orderDate;
private BigDecimal totalAmount;
// 연관관계 편의 메서드
public void setCustomer(Customer customer) {
this.customer = customer;
}
}
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// Customer에서는 Order를 참조하지 않음 (단방향)
}// 저장
Customer customer = customerRepository.findById(customerId).orElseThrow();
Order order = new Order();
order.setCustomer(customer); // 연관관계 설정
order.setOrderDate(LocalDateTime.now());
order.setTotalAmount(new BigDecimal("50000"));
orderRepository.save(order);
// INSERT INTO orders (customer_id, order_date, total_amount) VALUES (?, ?, ?)
// 조회
Order order = orderRepository.findById(orderId).orElseThrow();
Customer customer = order.getCustomer(); // 객체 그래프 탐색
System.out.println(customer.getName()); // 지연 로딩 시 이 시점에 SELECT| 속성 | 설명 | 기본값 |
|---|---|---|
| name | 외래키 컬럼명 | 필드명_참조테이블PK |
| referencedColumnName | 참조하는 대상 테이블의 컬럼명 | 참조 테이블의 PK |
| foreignKey | 외래키 제약조건 이름 (DDL) | - |
| nullable | null 허용 여부 | true |
// 기존 DB 외래키 컬럼명에 맞추기
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(
name = "cust_id", // 기존 FK 컬럼명
referencedColumnName = "customer_id", // 참조 대상 PK (보통 생략)
foreignKey = @ForeignKey(name = "fk_order_customer") // FK 제약조건명
)
private Customer customer;@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// 일대일 단방향
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
}
@Entity
public class Delivery {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status;
}단방향 연관관계의 장점
권장: 처음에는 단방향으로 설계하고, 양방향이 꼭 필요할 때만 추가
양방향 연관관계는 양쪽 Entity가 서로를 참조합니다. 이때 연관관계의 주인을 정해야 합니다.
핵심 규칙
// Order: 연관관계의 주인 (외래키 보유)
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 주인: 외래키 관리
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// 연관관계 편의 메서드
public void setCustomer(Customer customer) {
this.customer = customer;
customer.getOrders().add(this); // 양방향 동기화
}
}
// Customer: 주인이 아님 (mappedBy)
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// 주인이 아님: mappedBy로 읽기 전용
// "order의 customer 필드에 의해 매핑됨"
@OneToMany(mappedBy = "customer")
private List<Order> orders = new ArrayList<>();
// 연관관계 편의 메서드
public void addOrder(Order order) {
orders.add(order);
order.setCustomer(this);
}
}order.setCustomer(customer);@OneToMany(mappedBy = "customer")// 잘못된 코드 - DB에 반영 안됨!
Customer customer = new Customer();
Order order = new Order();
customer.getOrders().add(order); // 주인이 아닌 쪽에만 설정
// order.setCustomer(customer); // 주인 쪽 설정 누락!
customerRepository.save(customer);
orderRepository.save(order);
// 결과: orders 테이블의 customer_id = NULL
// 올바른 코드 - 양쪽 모두 설정
Customer customer = new Customer();
Order order = new Order();
order.setCustomer(customer); // 주인 쪽 설정 (필수!)
customer.getOrders().add(order); // 객체 그래프 일관성 (권장)
// 또는 연관관계 편의 메서드 사용
customer.addOrder(order); // 내부에서 양쪽 모두 설정양방향 관계에서 양쪽 객체를 동기화하는 메서드를 한쪽에 만들어 사용합니다.
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// 연관관계 편의 메서드 (주인 쪽에 작성)
public void changeCustomer(Customer customer) {
// 기존 관계 제거
if (this.customer != null) {
this.customer.getOrders().remove(this);
}
// 새 관계 설정
this.customer = customer;
if (customer != null) {
customer.getOrders().add(this);
}
}
}
// 사용
Order order = new Order();
order.changeCustomer(customer); // 양쪽 모두 자동 설정양방향 매핑 정리
| 속성 | 설명 | 기본값 |
|---|---|---|
| fetch | 로딩 전략 | EAGER (즉시 로딩) |
| cascade | 영속성 전이 | 없음 |
| optional | null 허용 여부 | true |
| targetEntity | 연관 Entity 타입 | 자동 추론 |
// 권장 설정
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private Customer customer;| 속성 | 설명 | 기본값 |
|---|---|---|
| mappedBy | 연관관계 주인 필드명 | - |
| fetch | 로딩 전략 | LAZY (지연 로딩) |
| cascade | 영속성 전이 | 없음 |
| orphanRemoval | 고아 객체 자동 삭제 | false |
// 양방향 매핑
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Order> orders = new ArrayList<>();일대다 단방향의 문제점
"일" 쪽에서 외래키를 관리하면 추가 UPDATE 쿼리가 발생합니다.
// 일대다 단방향 (비권장)
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
// 외래키가 Member 테이블에 있지만 Team에서 관리
@OneToMany
@JoinColumn(name = "team_id") // Member 테이블의 FK
private List<Member> members = new ArrayList<>();
}
// 문제: INSERT 후 UPDATE 추가 발생
team.getMembers().add(member);
// INSERT INTO member (name) VALUES (?)
// UPDATE member SET team_id = ? WHERE id = ? ← 추가 쿼리!권장: 다대일 양방향으로 변경하세요
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
// 양방향: Order가 OrderItem의 생명주기 관리
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> orderItems = new ArrayList<>();
private LocalDateTime orderDate;
@Enumerated(EnumType.STRING)
private OrderStatus status;
// 연관관계 편의 메서드
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this);
}
public void removeOrderItem(OrderItem orderItem) {
orderItems.remove(orderItem);
orderItem.setOrder(null);
}
// 비즈니스 메서드
public BigDecimal getTotalPrice() {
return orderItems.stream()
.map(OrderItem::getTotalPrice)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
@Entity
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
private Integer quantity;
private BigDecimal orderPrice; // 주문 시점 가격
public BigDecimal getTotalPrice() {
return orderPrice.multiply(BigDecimal.valueOf(quantity));
}
// 생성 메서드
public static OrderItem createOrderItem(Product product, int quantity) {
OrderItem orderItem = new OrderItem();
orderItem.product = product;
orderItem.quantity = quantity;
orderItem.orderPrice = product.getPrice();
product.removeStock(quantity); // 재고 차감
return orderItem;
}
}// 주문 생성
@Transactional
public Long createOrder(Long customerId, List<OrderItemRequest> items) {
Customer customer = customerRepository.findById(customerId).orElseThrow();
Order order = new Order();
order.setCustomer(customer);
order.setOrderDate(LocalDateTime.now());
order.setStatus(OrderStatus.PENDING);
for (OrderItemRequest item : items) {
Product product = productRepository.findById(item.getProductId()).orElseThrow();
OrderItem orderItem = OrderItem.createOrderItem(product, item.getQuantity());
order.addOrderItem(orderItem); // 연관관계 편의 메서드
}
orderRepository.save(order); // cascade로 OrderItem도 함께 저장
return order.getId();
}일대일 관계는 양쪽 모두 하나의 대상만 가집니다. 외래키를 어느 테이블에 둘지 선택해야 합니다.
주로 접근하는 테이블에 FK 배치
일대일 → 일대다 변경 시 유리
// Order가 주 테이블, Delivery가 대상 테이블
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 일대일 단방향: Order → Delivery
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id", unique = true)
private Delivery delivery;
// 배송 정보 설정
public void setDelivery(Delivery delivery) {
this.delivery = delivery;
}
}
@Entity
public class Delivery {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status = DeliveryStatus.READY;
// Order를 참조하지 않음 (단방향)
}@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 주인: 외래키 관리
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
// 연관관계 편의 메서드
public void setDelivery(Delivery delivery) {
this.delivery = delivery;
delivery.setOrder(this);
}
}
@Entity
public class Delivery {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 주인 아님: mappedBy
@OneToOne(mappedBy = "delivery", fetch = FetchType.LAZY)
private Order order;
@Embedded
private Address address;
@Enumerated(EnumType.STRING)
private DeliveryStatus status = DeliveryStatus.READY;
public void setOrder(Order order) {
this.order = order;
}
}자주 조회하는 기본 정보와 가끔 필요한 상세 정보를 분리합니다.
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String name;
// 상세 정보는 필요할 때만 로딩
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "customer_detail_id")
private CustomerDetail detail;
}
@Entity
public class CustomerDetail {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne(mappedBy = "detail")
private Customer customer;
private LocalDate birthDate;
private String phoneNumber;
@Embedded
private Address address;
@Lob
private String memo;
private Integer point = 0;
private String grade = "BRONZE";
}일대일 관계 설계 팁
실무에서 @ManyToMany 사용 금지!
// @ManyToMany 사용 (비권장)
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
@ManyToMany
@JoinTable(
name = "product_category",
joinColumns = @JoinColumn(name = "product_id"),
inverseJoinColumns = @JoinColumn(name = "category_id")
)
private List<Category> categories = new ArrayList<>();
}
@Entity
public class Category {
@Id @GeneratedValue
private Long id;
@ManyToMany(mappedBy = "categories")
private List<Product> products = new ArrayList<>();
}
// 문제점:
// 1. 중간 테이블(product_category)에 추가 컬럼 불가
// - 등록일, 순서, 메인 카테고리 여부 등 저장 불가
// 2. 중간 테이블이 숨겨져 있어 예상치 못한 쿼리 발생
// 3. 비즈니스 요구사항 변경에 대응 어려움다대다 관계를 일대다 + 다대일로 풀어서 설계합니다.
// 중간 Entity: ProductCategory
@Entity
@Table(name = "product_category")
public class ProductCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
// 추가 컬럼 가능!
private Integer displayOrder; // 표시 순서
private Boolean isMainCategory; // 메인 카테고리 여부
private LocalDateTime createdAt; // 등록일
// 생성 메서드
public static ProductCategory create(Product product, Category category,
int order, boolean isMain) {
ProductCategory pc = new ProductCategory();
pc.product = product;
pc.category = category;
pc.displayOrder = order;
pc.isMainCategory = isMain;
pc.createdAt = LocalDateTime.now();
return pc;
}
}
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ProductCategory> productCategories = new ArrayList<>();
// 카테고리 추가
public void addCategory(Category category, int order, boolean isMain) {
ProductCategory pc = ProductCategory.create(this, category, order, isMain);
productCategories.add(pc);
}
// 메인 카테고리 조회
public Category getMainCategory() {
return productCategories.stream()
.filter(ProductCategory::getIsMainCategory)
.findFirst()
.map(ProductCategory::getCategory)
.orElse(null);
}
}
@Entity
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "category")
private List<ProductCategory> productCategories = new ArrayList<>();
// 해당 카테고리의 상품 수
public int getProductCount() {
return productCategories.size();
}
}// 상품에 카테고리 추가
@Transactional
public void addCategoryToProduct(Long productId, Long categoryId,
int order, boolean isMain) {
Product product = productRepository.findById(productId).orElseThrow();
Category category = categoryRepository.findById(categoryId).orElseThrow();
product.addCategory(category, order, isMain);
}
// 상품의 카테고리 목록 조회
public List<CategoryDto> getProductCategories(Long productId) {
Product product = productRepository.findById(productId).orElseThrow();
return product.getProductCategories().stream()
.sorted(Comparator.comparing(ProductCategory::getDisplayOrder))
.map(pc -> new CategoryDto(
pc.getCategory().getId(),
pc.getCategory().getName(),
pc.getIsMainCategory()
))
.toList();
}
// 카테고리별 상품 조회 (JPQL)
@Query("SELECT pc.product FROM ProductCategory pc " +
"WHERE pc.category.id = :categoryId " +
"ORDER BY pc.displayOrder")
List<Product> findProductsByCategory(@Param("categoryId") Long categoryId);다대다 관계 정리
@ManyToMany (금지)
연결 Entity (권장)
Customer (1) ←──────── (N) Order (1) ←──────── (N) OrderItem (N) ──────→ (1) Product
│ │ │
│ │ │
└── CustomerDetail └── Delivery │
(1:1) (1:1) │
│
ProductCategory ──┘
(N) (N)
│ │
└──────┘
Category
// Customer
@Entity
public class Customer extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String name;
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "customer_detail_id")
private CustomerDetail detail;
@OneToMany(mappedBy = "customer")
private List<Order> orders = new ArrayList<>();
}
// Order
@Entity
@Table(name = "orders")
public class Order extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
@Enumerated(EnumType.STRING)
private OrderStatus status;
private LocalDateTime orderDate;
}
// OrderItem
@Entity
public class OrderItem {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id")
private Product product;
private Integer quantity;
private BigDecimal orderPrice;
}
// Product
@Entity
public class Product extends BaseEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
private Integer stockQuantity;
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
private List<ProductCategory> productCategories = new ArrayList<>();
}
// Category
@Entity
public class Category {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
@OneToMany(mappedBy = "parent")
private List<Category> children = new ArrayList<>();
@OneToMany(mappedBy = "category")
private List<ProductCategory> productCategories = new ArrayList<>();
}단방향으로 시작
필요할 때만 양방향 추가
외래키가 있는 곳이 연관관계의 주인
다대일에서 "다" 쪽이 주인
양방향은 편의 메서드 필수
양쪽 객체 동기화
@ManyToMany 사용 금지
연결 Entity로 풀어서 설계
fetch = LAZY 기본 설정
N+1 문제 예방 (다음 세션에서 상세)
다음 세션에서는 연관관계의 성능 문제와 해결 방법을 학습합니다.
N+1 문제란?
연관 Entity 조회 시 발생하는 추가 쿼리 문제
Fetch Join
JPQL JOIN FETCH로 한 번에 조회
@EntityGraph
어노테이션 기반 페치 전략
@BatchSize
IN 쿼리로 배치 로딩