도메인 분석 및 설계
- 김영한님의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 강의를 기반으로 요구사항 분석부터 도메인 모델 설계, 테이블 설계, 그리고 엔티티 클래스 개발까지의 과정을 정리함
요구사항 분석
기능 목록
- 회원 기능
- 회원 등록
- 새로운 회원 가입
- 회원 조회
- 등록된 회원 목록 조회
- 회원 등록
- 상품 기능
- 상품 등록
- 새로운 상품 등록
- 상품 수정
- 기존 상품 정보 수정
- 상품 조회
- 등록된 상품 목록 조회
- 상품 등록
- 주문 기능
- 상품 주문
- 상품 선택 및 주문
- 주문 내역 조회
- 주문한 내역 확인
- 주문 취소
- 주문 취소 처리
- 상품 주문
기타 요구사항
| 요구사항 | 설명 |
|---|---|
| 재고 관리 | 상품은 재고 관리가 필요 |
| 상품 종류 | 도서, 음반, 영화 3가지 타입 |
| 카테고리 | 상품을 카테고리로 구분 가능 |
| 배송 정보 | 주문 시 배송 정보 입력 가능 |
도메인 모델 설계
도메인 모델 다이어그램

상품 상속 구조

엔티티 관계 설명
- 회원(Member)과 주문(Order)
- 일대다 (1:N)
- 회원은 여러 상품을 주문할 수 있음
- 주문(Order)과 주문상품(OrderItem)
- 일대다 (1:N)
- 한 번 주문 시 여러 상품 선택 가능함
- 연관관계 주인
- OrderItem (외래 키 보유)
- 주문상품(OrderItem)과 상품(Item)
- 다대일 (N:1)
- 주문상품은 하나의 상품을 참조함
- 단방향
- 주문(Order)과 배송(Delivery)
- 일대일 (1:1)
- 주문 시 하나의 배송 정보 생성됨
- 양방향
- 카테고리(Category)와 상품(Item)
- 다대다 (N:M)
연관관계 매핑 전략

- 연관관계 주인 선택 원칙
- 외래 키가 있는 곳을 연관관계의 주인이라 정하기
예시 연관관계 주인 이유 자동차 ↔ 바퀴 바퀴 외래 키가 바퀴 테이블에 존재 주문 ↔ 회원 주문 외래 키(member_id)가 주문 테이블에 존재 주문 ↔ 배송 주문 외래 키(delivery_id)가 주문 테이블에 존재
테이블 설계
테이블 ERD

테이블 설계 특징
- MEMBER 테이블
- 임베디드 타입인 주소 정보(CITY, STREET, ZIPCODE)가 테이블에 직접 포함됨
- ITEM 테이블 (단일 테이블 전략)
- 상속 전략으로
SINGLE_TABLE을 사용함 DTYPE컬럼으로 타입을 구분함 (B: Book, A: Album, M: Movie)- 자식 엔티티의 모든 컬럼이 한 테이블에 있으므로 NULL 허용됨
- 상속 전략으로
- ORDERS 테이블
- 테이블명은 SQL 예약어인
ORDER대신ORDERS를 사용함
- 테이블명은 SQL 예약어인
- DELIVERY 테이블
- ORDERS와 일대일 관계이며 주소 정보를 포함함
네이밍 컨벤션
| 구분 | 엔티티 | 테이블 |
|---|---|---|
| 케이스 | 카멜 케이스 | 소문자 + 언더스코어 |
| 예시 1 | memberPoint | member_point |
| 예시 2 | OrderItem | order_item |
| 점(.) 처리 | member.name | member_name |
엔티티 클래스 개발
엔티티 클래스 구조

회원 엔티티 (Member)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter @Setter
public class Member {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id;
private String name;
@Embedded
private Address address;
@OneToMany(mappedBy = "member")
private List<Order> orders = new ArrayList<>();
}
@Entity- JPA 엔티티임을 선언
@Id- 기본 키 매핑
@GeneratedValue- 기본 키 자동 생성
@Embedded- 임베디드 타입 사용
@OneToMany(mappedBy = "member")- 양방향 관계에서 연관관계 주인이 아님을 명시
주문 엔티티 (Order)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
//==연관관계 편의 메서드==//
public void setMember(Member member) {
this.member = member;
member.getOrders().add(this); // Member의 orders에도 Order 추가
}
public void addOrderItem(OrderItem orderItem) {
orderItems.add(orderItem);
orderItem.setOrder(this); // OrderItem에도 Order 세팅
}
public void setDelivery(Delivery delivery) {
this.delivery = delivery;
delivery.setOrder(this); // Delivery에도 Order 세팅
}
}
@Table(name = "orders")ORDER는 SQL 예약어이므로ORDERS사용
fetch = FetchType.LAZY- 지연 로딩 설정 (필수)
cascade = CascadeType.ALL- 영속성 전이
@Enumerated(EnumType.STRING)- Enum을 문자열로 저장
연관관계 편의 메서드- 양방향 연관관계 세팅 시 양쪽 모두 값을 설정해야 함
- 원자적으로 처리하기 위해 별도의 메서드를 제공함
주문상품 엔티티 (OrderItem)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import jpabook.jpashop.domain.item.Item;
@Entity
@Getter @Setter
public class OrderItem {
@Id @GeneratedValue
@Column(name = "order_item_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "item_id")
private Item item;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id")
private Order order;
}
상품 엔티티 (Item)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package jpabook.jpashop.domain.item;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "dtype")
@Getter @Setter
public abstract class Item {
@Id @GeneratedValue
@Column(name = "item_id")
private Long id;
private String name;
@ManyToMany(mappedBy = "items")
private List<Category> categories = new ArrayList<>();
}
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)- 상속 관계 매핑 전략을 지정함
SINGLE_TABLE- 서비스 규모가 크지 않고, 단순한 구조일 때 유리함
@DiscriminatorColumn(name = "dtype")- 하위 클래스를 구분하는 컬럼을 지정함
- 기본값은
DTYPE임
상품 하위 클래스
-
도서 (Book)
1 2 3 4 5 6 7
@Entity @DiscriminatorValue("B") @Getter @Setter public class Book extends Item { private String author; private String isbn; }
-
음반 (Album)
1 2 3 4 5 6 7
@Entity @DiscriminatorValue("A") @Getter @Setter public class Album extends Item { private String artist; private String etc; }
-
영화 (Movie)
1 2 3 4 5 6 7
@Entity @DiscriminatorValue("M") @Getter @Setter public class Movie extends Item { private String director; private String actor; }
카테고리 엔티티 (Category)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package jpabook.jpashop.domain;
import jakarta.persistence.*;
import jpabook.jpashop.domain.item.Item;
import java.util.ArrayList;
import java.util.List;
@Entity
@Getter @Setter
public class Category {
@Id @GeneratedValue
@Column(name = "category_id")
private Long id;
private String name;
@ManyToMany
@JoinTable(name = "category_item",
joinColumns = @JoinColumn(name = "category_id"),
inverseJoinColumns = @JoinColumn(name = "item_id"))
private List<Item> items = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
@OneToMany(mappedBy = "parent")
private List<Category> child = new ArrayList<>();
}
- 전체 코드 보기
@ManyToMany는 실무에서 사용을 금지하며, 중간 엔티티를 만들어@OneToMany,@ManyToOne으로 풀어내야 함
주소 값 타입 (Address)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package jpabook.jpashop.domain;
import jakarta.persistence.Embeddable;
import lombok.Getter;
@Embeddable
@Getter
public class Address {
private String city;
private String street;
private String zipcode;
protected Address() {
}
public Address(String city, String street, String zipcode) {
this.city = city;
this.street = street;
this.zipcode = zipcode;
}
}
-
값 타입 설계 원칙
- 불변 객체
- 값의 변경으로 인한 부작용을 원천 차단함
- Setter 제거
- 생성 후 값 변경을 막음
- protected 생성자
- JPA 스펙 준수 및 무분별한 생성 방지
- 불변 객체
연습 문제
-
일대다(One-to-Many) 관계에서 외래 키(Foreign Key)는 일반적으로 어느 쪽에 위치해야 할까요?
a. ‘다(Many)’ 쪽에 위치합니다.
- 일대다 관계에서 ‘다’에 해당하는 엔티티가 ‘일’에 해당하는 엔티티의 식별자를 외래 키로 가짐
- JPA 매핑 시 소유자도 외래 키가 있는 쪽이 됨
-
관계형 데이터베이스에서 다대다(Many-to-Many) 관계를 직접 표현하기 어려운 경우, 일반적으로 어떻게 해결해야 할까요?
a. 중개(Intermediate) 테이블을 사용하여 One-to-Many 또는 Many-to-One 관계로 분해합니다.
- 관계형 DB는 다대다 관계를 직접 표현할 수 없으므로, 중간에 연결 테이블을 두어 각각 일대다/다대일 관계로 풀어냄
- 이를 통해 연결 속성 추가 등 유연성이 확보됨
-
JPA에서 엔티티 연관 관계를 페치(Fetch)할 때, 실무에서 기본적으로 추천하는 로딩 전략은 무엇이며 그 이유는 무엇인가요?
a. LAZY 로딩; 필요할 때 데이터를 지연 로딩하여 성능 최적화에 유리합니다.
- LAZY 로딩은 연관된 엔티티를 실제 사용하는 시점에 로딩하므로 불필요한 데이터 조회를 막아 성능상 이점을 가짐
- EAGER는 예측 어렵고 N+1 문제를 유발할 수 있음
-
JPA에서 N+1 문제는 어떤 상황에서 발생하며, 주로 어떤 로딩 전략과 관련이 있을까요?
a. EAGER 로딩 사용 시; 부모 엔티티 조회 후 자식 엔티티를 각각 추가 조회할 때.
- N+1 문제는 주로 EAGER 로딩 설정된 연관 객체나 지연 로딩된 컬렉션을 반복 접근할 때 발생함
- 부모 N개를 조회한 후, 각 부모에 대해 자식 1개씩 추가 조회하여 N+1번의 쿼리가 나감
-
엔티티 클래스를 설계할 때, 무분별한 Setter 사용을 지양하고 비즈니스 메서드를 선호하는 주된 이유는 무엇일까요?
a. Setter는 데이터의 무결성을 쉽게 훼손할 수 있기 때문입니다.
- Setter를 사용하면 객체의 상태가 언제 어디서든 변경될 수 있어 데이터 변경 지점을 파악하기 어렵고, 의미 없는 값으로 상태가 변하여 데이터 무결성이 깨지기 쉬움
- 비즈니스 메서드는 의미 있는 상태 변화만 허용함
요약 정리
- 도메인 모델은 회원, 상품, 주문/배송 등 핵심 비즈니스 로직을 포함하는 구조로 설계함
- 엔티티 설계 시
@Getter는 열어두되@Setter는 꼭 필요한 경우에만 사용하며, 값 타입은 불변 객체로 설계함 - 테이블 설계는 엔티티와 비슷하지만 일대다 관계의 외래 키는 항상 ‘다’ 쪽에 있으며, 주문과 배송 같은 일대일 관계는 주 테이블에 외래 키를 두는 것을 권장함
- 연관관계 매핑에서 ‘외래 키가 있는 곳’을 연관관계의 주인으로 정해야 혼란을 줄일 수 있음
- 상속 관계는
SINGLE_TABLE전략이 성능상 유리하고 관리가 편하지만, 데이터 무결성 측면에서는JOINED전략도 고려 대상임