[Spring Boot]Entity 설계

이민재·2024년 6월 10일
post-thumbnail

Spring Boot에서 Entity란?

데이터베이스에 쓰일 테이블과 칼럼을 정의한다

Product(상품정보, 공급업체번호, 상품이름, 상품가격, 상품재고, 상품분류번호, 상품 생성 일자, 상품 정보 변경 일자)

ProductDetail(상품정보번호, 상품번호, 상품설명, , 상품 정보 생성 일자, 상품 정보 변경 일자)

Provider(공급업체번호, 업체 이름, 업체 생성 일자, 업체 정보 변경 일자)

Product와 ProductDetail은 일대일 관계

Product와 ProductDetail은 다대일 관계

@Entity
@Getter
@NoArgsConstructor
@ToString(callSuper=true)
@EqualsAndHashCode(callSuper=true)
@Table(name="product")
public class Product extends BaseEntity{

	@id // 모든 Entity는 @id 어노테이션이 꼭 필요하다.
	@GenerateValue(strategy = GenerationType.IDENTITY) //기본값 생성을 데이터베이스에 위임하는 방식, 데이터베이스의 AUTO_INCREMENT를 사용해 기본값을 생성
	private Long id;
	
	@Column(nullable=false) // 이 어노테이션을 명시하지 않아도 클래스의 필드는 자동으로 테이블의 칼럼으로 매핑됨
	private String name;
	
	@Column(nullable=false) 
	private String price;
	
	@Column(nullable=false) 
	private String stock;
	
	@ManyToOne
	@JoinColumn(name="provider_id")
	@ToString.Exclude
	private Provider provider;
}
@Entity
@Getter
@NoArgsConstructor
@ToString(callSuper=true)
@EqualsAndHashCode(callSuper=true)
@Table(name="product_detail")
public class ProductDetail extends BaseEntity{

	@id // 모든 Entity는 @id 어노테이션이 꼭 필요하다.
	@GenerateValue(strategy = GenerationType.IDENTITY) //기본값 생성을 데이터베이스에 위임하는 방식, 데이터베이스의 AUTO_INCREMENT를 사용해 기본값을 생성
	private Long id;
	
	@Column(nullable=true) // 이 어노테이션을 명시하지 않아도 클래스의 필드는 자동으로 테이블의 칼럼으로 매핑됨
	private String description;
	
	@OneToOne
	@JoinColumn(name="product_number") 
	private Product product;
}
@Entity
@Getter
@NoArgsConstructor
@ToString(callSuper=true)
@EqualsAndHashCode(callSuper=true)
@Table(name="provider")
public class Provider extends BaseEntity{

	@id // 모든 Entity는 @id 어노테이션이 꼭 필요하다.
	@GenerateValue(strategy = GenerationType.IDENTITY) //기본값 생성을 데이터베이스에 위임하는 방식, 데이터베이스의 AUTO_INCREMENT를 사용해 기본값을 생성
	private Long id;
	
	@Column(nullable=true) // 이 어노테이션을 명시하지 않아도 클래스의 필드는 자동으로 테이블의 칼럼으로 매핑됨
	private String name;
	
}

엔티티 관련 기본 어노테이선

엔티티를 작성할 때는 어노테이션을 많이 사용한다. 그중엔 테이블과 매핑하기 위해 사용하는 어노테이션도 있고, 다른 테이블과의 연관관계를 정의하기 위해 사용하는 어노테이션 자동으로 값을 주입하기위한 어노테션도있다

@Entity

  • 해당 클래스가 엔티티임을 명시하기 위한 어노테이션이다. 클래스 자체는 테이블과 일대일로 매칭되며 해당클래스의 인스턴스는 매핑되는 테이블에서 하나의 레코드를 의미한다.

@Table

  • 엔티티 클래스는 테이블과 매핑되므로 특별한 경우가 아니면 이 어노테이션이 필요하지 않다. 사용하는 경우는 클래스의 이름과 테이블의 이름을 다르게 지정해야하는 경우이다. @Table 어노테이션을 명시하지 않으면 테이블의 이름과 클래스의 이름이 동일하다는 의미이며, 서로다른 이름을 쓰려면 @Table(name=값) 형태로 데이터베이스의 테이블명의 명시해야한다. 대체졸 자바의 명명법과 데이터베이스가 사용하는 명명법이 다르기 때문에 자주 사용된다

@Id

  • 엔티티 클래스의 필드는 테이블과 칼럼과 매핑된다. @Id 어노테이션이 선언된 필드는 테이블의 기본값 역할로 사용된다. 모든 엔티티는 @Id 어노테이션이 필요하다.

@GeneratedValue

  • 일반적으로 @Id 어노테이션과 함께 사용된다.

AUTO

  • @GeneratedValue의 기본 설정값
  • 기본값을 사용하는 데이터베이스에 맞게 자동 생성

IDENTITY

  • 기본값 생성을 데이터베이스에 위임하는 방식
  • 데이터베이스의 AUTO_INCREMENT를 사용해 기본값을 생성

SEQUENCE

  • @SequenceGenerator 어노테이션으로 식별자 생성기를 설정하고 이를 통해 값을 자동 주입 받는다.
  • SequenceGenerator를 정의할때는 name, sequenceName, allocationSize를 활용한다
  • @GeneratedValue 생성기를 설정한다

TABLE

  • 어떤 DBMS를 사용하더라도 동일하게 동작하기를 원할 경우 사용한다
  • 식별자로 사용할 숫자의 보관 테이블을 별도로 생성해서 엔티티를 생성할 때마다 값을 갱신하며 사용한다
  • @TableGenerator 어노테이션으로 테이블 정보를 설정한다.

@Column

엔티티 클래스의 필드는 자동으로 테이블 칼럼으로 매핑된다. 그래서 별다른 설정을 하지 않을 예정이라면 이 어노테이션을 명시하지 않아도 괜찮다

@Column 어노테이션에서 많이 사용하는 요소는 다음과 같다

  • name : 데이터베이스의 칼럼명을 설정하는 속성입니다. 명시하지 않으면 필드명으로 지정된다
  • nullable : 레코드를 생성할때 컬럼 값에 null처리가 가능한지를 명시하는 속성이다
  • length : 데이터베이스에서 저장하는 데이터의 최대길이를 설정한다
  • unique : 해당 칼럼을 유니크로 설정한다

@Transient

엔티티 클래스에는 선언돼 있는 필드지만 데이터베이스에는 필요없을 경우 이 어노테이션을 사용해 데이터베이스에서 이용하지 않게 할 수 있다.

NoArgsConstructor

  • 매개변수가 없는 생성자를 자동 생성한다

AllArgsConstructor

  • 모든 필드를 매개변수로 갖는 생성자를 자동 생성한다

RequiredArgsConstructor

  • 필드 중 final이나 @NotNull이 설정된 변수를 매개변수로 갖는 생성자를 자동 생성한다

ToString

  • 이름 그대로 toString() 메서드를 생성하는 어노테이션이다.
  • exclued속성을 사용해 특정 필드를 자동생성에서 제외 할 수 있ㄸ

EqualsAndHashCode

  • 객체의 동등성과 동일성을 비교하는 연산 메서드를 생성한다

Data

  • @Data는 앞서 설명한 @Getter/@Setter, @RequiredArgsConstructor, @ToString, @EqualsAndHashCode를 모두 포괄하는 어노테이션이다

연관관계

단방향 : 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식

양방향 : 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식

연관관계가 설정되면 한 테이블에서 다른 테이블의 기본값을 외래키로 갖게 된다

일반적으로 외래키를 가진 테이블이 그 관계의 주인이 되며, 주인을 외래키를 사용할수 있으나 상대 엔티티는 읽는 작업만 수행 할 수있다

@JoinColumn

  • @JoinColumn어노테이션을 사용해 매핑할 외래키를 설정
  • @JoinColumn어노테이션은 기본값이 설정돼 있어 자동으로 이름을 매핑하지만 의도한 이름이 들어가지 않기 때문에 name 속성을 사용해 원하는 컬럼명을 지정하는것이 좋다
  • @JoinColumn어노테이션을 선언하지않으면 엔티티를 매핑하는 중간 테이블이 생기면서 관리 포인트가 늘어나 좋지 않다

속성

name : 매핑할 외래키의 이름을 설정

referencedColumnName : 외래키가 참조할 상대 테이블의 칼럼명을 지정

foreignkey : 외래키를 생성하면서 지정할 제약 조건을 설정(unique, nullable, insertable, updatable)

엔티티를 조회할 때 연관된 엔티티도 함께 조회하는것을 ‘즉시 로딩’ 이라고 한다.

@OneToOne 어노테이션은 기본 fetch 전략으로 EAGER, 즉 즉시로딩 전략으로 채택되어있다. 그리고 optional() 메서드는 기본값으로 true가 설정돼있다. 기본값이 true인 상태는 매핑되는 값이 nullable이라는 것을 의미한다. 만약 반드시 값이 있어야 한다면 @OneToOne(optional = false)로 설정하면된다. 이러면 null을 허용하지않게 된다. 그리고 쿼리문이 left outer join이 inner join으로 바뀌어 실행된다.

JPA에서 지연로딩과 즉시로딩은 중요한 개념이다. 엔티티라는 객체의 개념으로 데이터베이스를 구현 했기 때문에 연관 관계를 가진 각 엔티티 클래스에는 연관관계가 있는 객체들이 필드에 존재하게 된다. 연관관계와 상관없이 즉각 해당 엔티티의 값만 조회하고 싶거나 연관관계를 가진 테이블의 값도 조회하고 싶은 경우 등 여러 조건들을 만족하기 위해 등장한 개념이 지연로디오가 즉시로딩이다.

단방향

	@OneToOne
	@JoinColumn(name="product_number") 
	private Product product;

양방향으로 설정을 하면

	@OneToOne
	@JoinColumn(name="product_number") 
	private Product product;
	@OneToOne(mappedBy="product")
	private ProductDetail productDetail;

여러 테이블 끼리 연관관계가 설정돼 있어 여러 letf outer join이 설정되는것은 괜찮으나 위와 같이 양쪽에서 외래키를 가지고 left outer join이두번이나 수행되는 경우는 효율성이 떨어진다. 실제 데이터베이스에서도 테이블 간 연관 관계를 맺으면 한쪽 테이블이 외래키를 가지는 구조로 이뤄진다. JPA에서도 실제 데이터베이스의 연관관계를 반영해서 한쪽의 테이블에서만 외래키를 바꿀수 있도록 정하는것이좋다. 이 경우 양방향으로매핑하되 한쪽에게만 외래키를 줘야하는데 이때 사용되는 속성값이 mappedBy 이다. mappedBy는 어떤 객체가 주인인지 표시하는 속성이다.

위처럼 코드를 작성하면 ProductDetail엔티티가 Product엔티티의 주인이 된다.

양방향으로 연관관계가 설정되면 ToString을 사용할 때 순환참조가 발생하기 때문에 필요한 경우가 아니라면 대체로 단방향으로 연관관계를 설정하거나 양방향 설정이 필요한 경우에는 순환참조 제거를 위해 아래와 같이 exclude를 사용해 ToString에서 제외 설정을 하는것이 좋ㄷ

	@OneToOne(mappedBy="product")
	@ToString.Exclude
	private ProductDetail productDetail;

다대일, 일대다 매핑

단방향

	@ManyToOne
	@JoinColumn(name="provider_id")
	@ToString.Exclude
	private Provider provider;

양방향

	@ManyToOne
	@JoinColumn(name="provider_id")
	@ToString.Exclude
	private Provider provider;
@OneToMany(mappedBy="provider", fetch=FetchType.EAGER)
@ToString.Exclude
private List<Product> productlist = new ArrayList<>();

일대다 연관관계의 경우 여러 상품 엔티티가 포함 될 수 있어 컬렉션 형식으로 필드를 생성한다.

@OneToMany의 기본 fetch 전략은 Lazy이다

일대다 단방향 매핑

일대다 양방향 매핑은 어느 엔티티 클래스에서도 연관관계의 주인이 될수 없기 때문에 다루지 않는다.

@OneToMany(fetch=FetchType.EAGER)
@JoinColumn(name="category_id")
private List<Product> productlist = new ArrayList<>();

이렇게 상품 분류 엔티티에서 @OneToMany와 @JoinColumn을 사용하면 상품 엔티티에서 별도의 설정을 하지 않아도 일대다 단방향 연관관계가 매핑된다. 이어노테이션을 사용하지 않으면 중간 테이블로 Join 테이블이 생성되는 전략이 채택된다.

다대다 매핑

다대다 연관관계는 실무에서 거의 사용되지 않는 구성이다. 각 엔티티에서 서로를 리스트로 가지는 구조가 만들어진다. 이런 경우에는 교차 엔티티라고 부르는 중간 테이블을 생성해서 다대다 관계를 일대다 또는 다대일관계로 해소한다

리스트로 필드를 가지는 객체에서는 외래키를 가지지 않기 때문에 별도의 @JoinColumn은 설정하지 않아도 된다.

@ManyToMnay
@ToString.Exclue
private List<Product> products = new ArrayList<>();

영속성 전이

영속성 전이(cascade)란 특정 엔티티의 역속성 상태를 변경할 때 그 엔티티와 연관된 엔티티의 영속성에도 영향을 미쳐 영속성 상태를 변경하는것의 의미.

종류설명
ALL모든 영속 상태 변경에 대해 영속성 전이를 적용
PERSIST엔티티가 영속화할 때 연관된 엔티티도 함께 영속화
MERGE엔티티를 영속성 컨텍스트에 병합할 때 연관된 엔티티도 병합
REMOVE엔티티를 제거할 때 연관된 엔티티도 새로고침
REFRESH엔티티를 새로고침할때 연관된 엔티티도 새로고침
DETACH엔티티를 영속성 컨텍스트에서 제외하면 연관된 엔티티도 제외
@OneToMany(mappedBy="provider", cascade=CascadeType.PERSIST)
@ToString.Exclude
private List<Product> productlist = new ArrayList<>();

고아 객체

JPA에서 고아란 부모 엔티티와 연관관계가 끊어진 엔티티를 의미

JPA에는 이런한 고아 객체를 자동으로 제거하는 기능이 있음

자식 엔티티가 다른 엔티티와 연관관계를 가지고 있다면 이 기능은 사용하지 않는 것이 좋다

@OneToMany(mappedBy="provider", cascade=CascadeType.PERSIST, orphanRemoval=true)
@ToString.Exclude
private List<Product> productlist = new ArrayList<>();

orphanRemoval=true속성은 고가 객체를 제거하는 기능이다

@Embedded

  • 아래의 코드를 보면 User엔티티는 id, 이름, 이메일, 성별, 주소정보의 데이터를 갖고 있는데 주소 정보가 도시, 구, 상세주소, 우편번호 등으로 여러개의 컬럼으로 나눠져 있는 것을 볼 수 있다. -> 이렇게 상세한 데이터를 그대로 갖고 있는 것은 객체지향적이지 않으며 응집력을 떨어뜨린다.
  • 이럴때 임베디드 타입을 사용하면 더욱더 객체지향적인 코드를 만들 수 있다.
// user.java
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NonNull
    private String name;

    @NonNull
    private String email;

    @Enumerated(value = EnumType.STRING)
    private Gender gender;
    
    // 주소 정보
    private String city; // 도시
    private String district; // 구
    private String detail; // 상세주소
    private String zipCode; // 우편번호

}

@Embedded, @Embeddable

  • 임베디드 타입을 적용하려면 새로운 Class를 만들고 해당 클래스에 임베디드 타입으로 묶으려던 Attribute들을 넣어준 뒤 @Embeddable를 붙여줘야합니다.
  • 사용 방법
    • @Embeddable : 값 타입을 정의하는 곳에 표시
    • @Embedded : 값 타입을 사용하는 곳에 표시
  • 아래의 임베디드 타입을 적용한 코드를 보면 주소의 관련된 속성들이 하나의 타입으로 바껴 사용되는 것을 볼 수 있습니다. 이를 보면 위의 코드보다 더욱 더 객체지향적이고 응집도 있는 코드로 바뀐 것을 확인할 수 있습니다.
// user.java
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NonNull
    private String name;

    @NonNull
    private String email;

    @Enumerated(value = EnumType.STRING)
    private Gender gender;
    
    @Embedded
    private Address address;

}
// Address.java
@Embeddable
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Address {
    // 주소 정보
    private String city; // 도시
    private String district; // 구

    @Column(name = "address_detail")
    private String detail; // 상세 주소
    private String zipCode; // 우편번호
}

@AttributeOverride : 속성명 재정의

  • 같은 종류의 Attribute에 대해서 중복된 코드를 적을 필요 없이 간단하고 직관성있게 선언 가능하다.
  • 예시로는 회사의 주소, 집 주소의 주소 형태는 시, 구, 상세주소, 우편번호로 동일하다. 이러한 경우 @Embedded와 @AttributeOverrides@AttributeOverride를 통해 하나의 class를 사용해 여러 표현을 할 수 있습니다. (객체의 재활용)
  • 아래의 코드는 객체를 재활용하는 대신 @AttributeOverrides@AttributeOverride를 사용해 column의 이름을 전부 재정의하여 사용하기에 코드가 지저분해 보일 수 있다. -> 객체를 재활용 하지 않고 따로 선언해서 하는 대신 깔끔하게 보이는 코드를 작성할 지, 객체의 재활용을 하는 코드를 작성할지는 개발자가 결정해야한다.
@Entity
public class User {

.....

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name = "city", column = @Column(name = "home_city")), // city를 home_city라는 column명으로 사용
            @AttributeOverride(name = "district", column = @Column(name = "home_district")),
            @AttributeOverride(name = "detail", column = @Column(name = "home_address_detail")),
            @AttributeOverride(name = "zipCode", column = @Column(name = "home_zipCode"))
    })
    private Address homeAddress;

    @Embedded
    @AttributeOverrides({
            @AttributeOverride(name = "city", column = @Column(name = "company_city")),
            @AttributeOverride(name = "district", column = @Column(name = "company_district")),
            @AttributeOverride(name = "detail", column = @Column(name = "company_address_detail")),
            @AttributeOverride(name = "zipCode", column = @Column(name = "company_zipCode"))
    })
    private Address companyAddress;
   
   	.....
}

주의할 점

    @Embedded
    private Address homeAddress;
    @Embedded
    private Address companyAddress;

하나의 class를 통해 여러개의 정보를 만들고 싶은데 위와 같이 @AttributeOverrides@AttributeOverride를 통해 column명을 재정의해주지 않으면 아래와 같이 Repeated column in mapping for entity 에러가 나오니 꼭 column명을 재정의해줘서 사용해야한다.

Caused by: javax.persistence.PersistenceException: [PersistenceUnit: default] Unable to build Hibernate SessionFactory; nested exception is org.hibernate.MappingException: Repeated column in mapping for entity: com.example.jpa_study.entity.User column: city (should be mapped with insert="false" update="false")
	at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.buildNativeEntityManagerFactory(AbstractEntityManagerFactoryBean.java:421)
	at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.afterPropertiesSet(AbstractEntityManagerFactoryBean.java:396)
	at org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean.afterPropertiesSet(LocalContainerEntityManagerFactoryBean.java:341)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.invokeInitMethods(AbstractAutowireCapableBeanFactory.java:1845)
	at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.initializeBean(AbstractAutowireCapableBeanFactory.java:1782)
	... 107 more

임베디드 타입과 null

  • 임베디드 타입 자체를 null로 지정했을 때와 임베디드 타입의 속성 값을 null로 설정했을 때는 모두 해당 column의 값이 null값으로 나오게 된다.
  • 아래의 코드를 수행하여 결과를 보면 관련 column들의 값이 모두 null인 것을 확인할 수 있다.
  • 즉, 임베디드 타입의 객체가 null인 경우 내부의 모든 column이 null인 것과 동일하게 처리가 된다

상속관계 테이블 전략

스프링에서는 상속전략을 지정해줄있음

@Inheritance(strategy= InheritanceType.JOINED)

@Inheritance(strategy= InheritanceType.SINGLE_TABLE)

@Inheritance(strategy= InheritanceType.TABLE_PER_CLASS)

@Entity
@Getter @Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {

    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "item_id")
    private Item item;
}
@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;
    private int price;
    private int stockQuantity;
}    
package jpabook.jpashop.domain.item;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("A")
@Getter
@Setter
public class Album extends Item {

    private String artist;
    private String etc;
}
package jpabook.jpashop.domain.item;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("B")
@Getter
@Setter
public class Book extends Item {

    private String author;
    private String isbn;
}
package jpabook.jpashop.domain.item;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.DiscriminatorValue;
import javax.persistence.Entity;

@Entity
@DiscriminatorValue("M")
@Getter
@Setter
public class Movie extends Item {

    private String director;
    private String actor;
}

@Enumerated

기본값 속성인 EnumType.ORDINAL은 인덱스로 들어간다. 인덱스로하면 중간에 다른 상태의 enum값을 넣으면 인덱스가 달라지기 때문에 속성타입을 꼭 STRING을 써야한다.

@Enumerated(EnumType.STRING)
private DeliveryStatus status; //READY, COMP

엔티티 설계시 주의점

엔티티에는 가급적 Setter를 사용하지 말자

Setter가 모두 열려있다 → 변경 포인트가 너무 많아서, 유지보수가 어렵다. 나중에 리펙토링으로 Setter 제거

모든 연관관계는 지연로딩으로 설정!

즉시로딩 지연로딩

@OneToOne, @ManyToOne → 기본 fetch가 EAGER

@OneToMany → 기본 fetch가 LAZY
즉시로딩( EAGER )은 예측이 어렵고, 어떤 SQL이 실행될지 추적하기 어렵다. 특히 JPQL을 실행할 때 N+1 문제가 자주 발생한다.
실무에서 모든 연관관계는 지연로딩( LAZY )으로 설정해야 한다.
연관된 엔티티를 함께 DB에서 조회해야 하면, fetch join 또는 엔티티 그래프 기능을 사용한다.
@XToOne(OneToOne, ManyToOne) 관계는 기본이 즉시로딩이므로 직접 지연로딩으로 설정해야 한다.

컬렉션은 필드에서 초기화 하자.
컬렉션은 필드에서 바로 초기화 하는 것이 안전하다.

null 문제에서 안전하다.
하이버네이트는 엔티티를 영속화 할 때, 컬랙션을 감싸서 하이버네이트가 제공하는 내장 컬렉션으로 변경한다. 만
getOrders() 처럼 임의의 메서드에서 컬력션을 잘못 생성하면 하이버네이트 내부 메커니즘에 문제가 발생
할 수 있다. 따라서 필드레벨에서 생성하는 것이 가장 안전하고, 코드도 간결하다.

 Member member = new Member();
 System.out.println(member.getOrders().getClass());
 em.persist(member);
 System.out.println(member.getOrders().getClass());

//출력 결과

 class java.util.ArrayList
 class org.hibernate.collection.internal.PersistentBag

0개의 댓글