[JPA] 다양한 value type 매핑 방법

신명철·2022년 10월 15일
0

JPA

목록 보기
11/14

들어가며

DB를 설계하다 보면 엔티티와 라이프 사이클을 함께 하지만 식별자를 가지지 않고 오직 값만 가지고 있는 Value Type(값 타입) 을 종종 사용하게 된다. 본 포스트는 Entity와 Value Type을 매핑하는 다양한 방법에 대해서 공부하기 위해 작성했다.

1. AttributeConverter

  • 두 개 이상의 property를 갖고 있는 밸류 타입을 한 개의 컬럼에 매핑하기 위해서 사용한다.
  • 예를 들어서 사람의 키를 의미하는 Length는 값(ex. 152.5)과 단위(ex. cm) 를 가지지만 152.5cm 라는 하나의 컬럼으로 표현할 수 있다.
@Entity
class Member {
	@Id @Column(name="member_id")
	private Long id;
    @Column(name="member_length")
    private Length length;
}

@Data
class Length {
	private double value;
    private String unit;
    public Length getLength() {
    	return Double.toString(value) + unit;
	}
    public Length(String value){
    	this.value = Double.valueOf(value.substring(0, value.length()-2));
        this.unit = value.substring(value.length()-2);
    }
}

@Converter(autoApply=true)
public class LengthConverter implements AttributeConverter<Length, String> {

	@Override
    public String convertToDatabaseColumn(Length length) {
    	return length == null ? null : length.getLength(length);
    }
    @Override
    public Length convertToEntityAttribute(String value){	
    	return value == null ? null : new Length(value);
    }
}
  • AttributeConverter를 구현해서 사용할 수 있다.
  • @ConverterautoApply의 기본 값은 false이고, 만약 false를 사용한다면 Length Field에 다음과 같이 @Converter 어노테이션을 달아줘야 한다.
@Convert(converter = LengthConverter.class)
private Length length;

2. 별도 테이블 매핑 - @CollectionTable

  • Value Type 객체들이 하나가 아니라 다수개가 매핑된다면 Collection을 이용하고, 별도의 테이블로 만들어서 Value Type을 저장할 수 있다.
@Entity
class Member {
	@Id @Column(name="member_id")
	private Long id;
    
    @ElementColection(fetch = FetchType.EAGER)
    @CollectionTable(name = "name_car",
    	joinColumns = @JoinColumn(name = "member_id"))
	@OrderColumn(name = "car_idx")
    private List<Car> cars;
}

@Embeddable
public class Car {
	@Column(name = "number")
    private int number;
}
  • @OrderColumn
    • Car의 테이블에 List의 인덱스 값을 저장하기 위한 Property가 생성된다.
  • @CollectionTable
    • Value를 저장할 테이블을 선언한다. 이름을 지정하고, Value 테이블에서 사용할 외부 키도 지정한다.

3. 한 개 컬럼 매핑

  • 컬렉션을 구분자를 넣어서 하나의 컬럼으로 매핑하고 싶을 때 사용한다.
  • 위에서 사용했었던 AttributeConverter를 활용한다.
  • 예를 들어서, 차 번호를 의미하는 car_number를 콤마를 기준으로 구분해서 하나의 컬럼으로 매핑하고자 할 때 사용한다.
@Entity
class Member {
	@Id @Column(name="member_id")
	private Long id;
    
    @Column(name="member_car")
    @Convert(converter = CarSetConverter.class)
    private Carset cars;
}

@Data
class Car {
	private int number;
}

@Data
class CarSet {
    private Set<Car> cars = new HashSet<>();
	
    public CarSet(Set<Car> cars){
    	this.cars.addAll(cars);
    }
}

public class CarSetConverter implements AttributeConverter<CarSet, String> {
	@Override
    public String convertToDatabaseColumn(CarSet attribute) {
    	if(attribute == null) return null;
    	return attribute.getCars().stream()
        		.map(car -> Integer.toString(car.getNumber()))
                .collect(Collectors.joining(","));
    }
    @Override
    public CarSet convertToEntityAttribute(String dbData){	
    	if(dbData == null) return null;
        String[] numbers = dbData.split(",");
        Set<Car> carSet = Arrays.stream(numbers)
        		.map(number -> new Car(Integer.valueOf(number.getNumber)))
                .collect(Collectors.toSet());
		return new CarSet(carSet);
    }
}

4. 밸류를 이용한 ID 매핑

  • 식별자라는 의미를 부각시키기 위해서 식별자 자체를 Value Type으로 만들 수도 있다.
  • @Id 대신 @EmbeddedId 를 사용한다.
@Entity
class Member {
	@EmbeddedID
	private MemberId id;
}

@Embeddable
class MemberId implements Serializable{
    private String name;
    private String number;
}
  • JPA에서는 식별자 타입은 Serializable 이어야 하기 때문에 MemberId이라는 Value Type에 Serializable 인터페이스를 꼭 상속받아야 한다.
  • 이 외 식별자 타입에 @Embeddable 를 선언해줘야 한다.

5. 별도 테이블에 저장하는 밸류 매핑 - @SecondaryTable

  • Value Type 을 별도의 테이블에 저장하고자 하는 경우에 사용한다.
@Data
@Entity
@SecondaryTable(
		name = "article_content",
		pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
)
public class Article {

	@Id
	@Column(name = "id")
	private String id;
	
	private String title;
	
	@AttributeOverrides({
		@AttributeOverride(
				name = "content",
				column = @Column(table = "article_content", name = "content")),
		@AttributeOverride(
				name = "contentType",
				column = @Column(table = "article_content", name = "content_type"))
	})
	@Embedded
	private ArticleContent content;	
}


@Embeddable
@NoArgsConstructor
@AllArgsConstructor
public class ArticleContent {

	private String content;
	private String contentType; 
	
	
}
  • ArticleContent 테이블에 Aticle의 PK를 의미하는 id 컬럼이 생긴다.
Article fineOne = articleService.findById("id");
  • 다만 위와 같은 밸류 타입 생성 방식은 Article 를 조회했을 때 @SecondaryTable의 밸류타입을 조인해서 가지고 오기 때문에 필요하지 않은 데이터도 읽어 온다는 문제가 있다.
  • ArticleContent를 Entity로 만들어서 매핑하고 지연 로딩으로 설정하는 방법이 있지만 이는 좋은 방법이 아니다.

6. 밸류 컬렉션을 @Entity 로 매핑하기

  • 개념적으로는 Value Type 이지만 구현 기술의 한계나 팀 표준 때문에 @Entity를 사용해야 할 때도 있다.
  • 만약 밸류 타입이 계층 구조를 갖는다고 가정해보자.
@Data
@Entity
@SecondaryTable(
		name = "article_content",
		pkJoinColumns = @PrimaryKeyJoinColumn(name = "article_id")
)
public class Article {

	@Id
	@Column(name = "article_id")
	private String id;
	
	private String title;
	
	@AttributeOverrides({
		@AttributeOverride(
				name = "content",
				column = @Column(table = "article_content", name = "content")),
		@AttributeOverride(
				name = "contentType",
				column = @Column(table = "article_content", name = "content_type"))
	})
	@Embedded
	private ArticleContent content;
	
	@OneToMany(
			cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
			orphanRemoval = true)
	@JoinColumn(name = "article_id")
	@OrderColumn(name = "list_idx")
	private List<Image> images = new ArrayList<>();
}

@Data
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "image_type")
public abstract class Image {
	
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "image_id")
	private Long id;
	
	@Column(name = "image_path")
	private String path;

	@Temporal(TemporalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;
	
	protected Image() {}

	public Image(String path, Date uploadTime) {
		this.path = path;
		this.uploadTime = uploadTime;
	}
	
	protected String getPath() {
		return this.path;
	}
	
	public Date getUploadTime() {
		return this.uploadTime;
	}
	
	public abstract String getURL();
	public abstract boolean hasThumbnail();
	public abstract String getThumbnailURL();
}
  • Image는 업로드 방식에 따라 InternalImage와 ExternalImage 로 나뉘어 진다.
  • 밸류 타입인 ImageArticle과 라이프 사이클을 동일하게 가져가기 때문에 orphanRemoval, cascade 를 설정해줬다.
@Entity
@DiscriminatorValue("EI")
public class ExternalImage extends Image{
	...
}
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image{
	...
}
  • @OneToMany 컬렉션 매핑에서는 컬렉션 clear() 시 삭제 과정이 효율적이지 않다.
  • 예를 들어서 Image 테이블에 총 4 개의 컬럼이 존재하고 List<Image>를 삭제하게 되면 Image의 컬럼들을 조회하기 위한 select 쿼리 1번, 4 개의 컬럼을 삭제하기 위한 delete 쿼리 4번 이 발생하게 된다.
  • 이러한 문제를 해결하기 위해서는 상속을 포기하고, @OneToMany 방식이 아닌 @Embeddable를 이용해 단일 클래스로 밸류 타입을 매핑해줘야 한다. JPA는 @Embedded 컬렉션을 삭제할 때는 컬렉션에 속한 객체들을 조회하지 않고 1번의 delete 쿼리로 데이터를 삭제한다.
@Data
@Embeddable
public abstract class Image {
	
    @Column(name = "image_type")
	private String imageType;
	@Column(name = "image_path")
	private String path;

	@Temporal(TemporalType.TIMESTAMP)
	@Column(name = "upload_time")
	private Date uploadTime;
    
	...
    
    // 성능을 위해 상속을 포기하고 if~else 사용
    public boolean hasThumbnail(){
    	if(imageType.equals("II")){
        	return true;
        }
        return false;
	}
}

7. ID참조와 조인 테이블을 이용한 단방향 M-N 매핑

  • Aggregate 간 집합 연관은 성능 상의 이유로 피해야 한다. 하지만 요구 사항을 구현하는데 집합 연관을 사용하는게 더 유리하다면 ID 참조를 이용한 단방향 집합 연관을 사용할 수 있다.
@Entity
public class Product {
	@EmbeddedId
    private ProductId id;
    
    @ElementCollection
    @CollectionTable(
    	name="product_category",
        joinColumns=@JoinColumn(name="product_id"))
    )
    private Set<CategoryId> categoryIds;
}
  • Product에서Category로의 M-N연관은 ID 참조 방식으로 구현되었다. 밸류 컬렉션 매핑과 동일한 방식으로 설정되었지만, 차이점이 있다면 컬렉션의 원소 타입으로 Category의 객체 타입이 아닌 식별자가 들어간다는 점이다.

참고 자료

  • DDD START! 도메인 주도 설계 구현과 핵심 개념 익히기
profile
내 머릿속 지우개

0개의 댓글