도메인 주도 개발 4장

김재연·2025년 4월 20일
post-thumbnail

JPA를 이용한 리포지터리 구현

이번 장에서는 ORM의 표준인 JPA를 이용해 리포지터리와 애그리거트를 구현하는 방법에 대해서 살펴보자.

모듈 위치

출처 : 도메인 주도 개발 시작하기

이전 장에서부터 계속 다루었던 것처럼, 구현체는 infra 영역에 두어, 최대한 의존성을 분리하는 모습을 볼 수 있다.

리포지터리 기본 기능 구현

ID로 애그리거트 조회하기, 애그리거트 저장하기 등을 직접 구현하기 위해서는 EntityManager의 find, persist 메서드를 적절히 활용하면 된다.

하지만, 이렇게 직접 구현할 필요는 없다. Spring Data JPA를 사용하게 되면, 구현체는 JPA가 알아서 구현해주기 때문이다.

스프링 데이터 JPA를 이용한 리포지터리 구현

Spring Data JPA는 지정한 규칙에 맞게 리포지터리 인터페이스를 정의하면, 리포지터리를 구현한 객체를 알아서 만들어 Spring Bean으로 등록해준다. 이 이유로 인해, @Repository 어노테이션을 붙이지 않아도 JpaRepository를 extends 하게 되면 알아서 컨테이너에 등록이 되는 것 같다.

그리고, 지정된 규칙을 지켜 도메인 Repository 인터페이스 내에 메서드를 정의하면 Spring Data JPA에서 구현해준 메서드를 사용할 수 있다.

매핑 구현

주문의 애그리거트 루트는 Order이다.

그렇기 때문에 이를 @Entity로 표현하고, 밸류 타입 프로퍼티는 @Embedded로, 밸류에는 @Embeddable로 어노테이션을 붙여준 뒤 구현해준다.

엔티티와 밸류 기본 매핑 구현

적절하게 @AttributeOverrides를 사용하면 실제로 Embedded 된 컬럼 명을 그대로 따르는 것이 아니라, 다른 컬럼명을 사용할 수 있다.

기본 생성자

데이터베이스에서 데이터를 불러오고, 객체에 매핑해주려면, 빈 객체를 생성한 뒤, 데이터를 넣어야 한다는 기술적인 제약이 있기 때문에, JPA를 사용할 때에는 꼭 기본 생성자를 작성해주어야한다.

만일, 이러한 기본생성자가 실제 로직에는 전혀 필요가 없다면 protected로 작성하는 방법이 있다.

이를 어노테이션으로도 해결할 수 있다. (@NoArgsConstructor (access = AccessLevel.PROTECTED))

필드 접근 방식 사용

JPA는 필드와 메서드의 두 가지 방식으로 매핑을 처리할 수 있는데, 메서드 방식을 사용하려면 get/set 메서드를 추가해야한다. (책에서는 @Access(AccessType.PROPERTY)로 처리하고 있다.)

하지만, 이 방식은 객체의 불변을 깨버릴 뿐만 아니라, 캡슐화도 불가능하게끔하고, 또한 객체 내부의 값을 변경할 때, 해당 동작의 의미를 부여하기 힘들게 만든다.

AttributeConverter를 이용한 밸류 매핑 처리

도메인 주도 개발 시작하기

만일 다음과 같이, 하나의 컬럼에 여러개의 프로퍼티를 매핑하고 싶을 때에는, AttributeConverter를 사용할 수 있따고 한다. (좋은 방법인지는... 잘 모르겠는데, 처음 보는 방법이긴하다.)


도메인 주도 개발 시작하기

어쨌든 위와 같은 방식으로 할 수 있다고 한다.

X는 밸류타입이고, Y는 DB 타입이다. 즉, 첫번째 메서드는 밸류타입->DB, 두번째 메서드는 DB->밸류타입으로 매핑해주는 것이다.

package com.myshop.common.jpa;

import com.myshop.common.model.Money;

import javax.persistence.AttributeConverter;
import javax.persistence.Converter;

@Converter(autoApply = true)
public class MoneyConverter implements AttributeConverter<Money, Integer> {

	@Override
	public Integer convertToDatabaseColumn(Money money) {
		return money == null ? null : money - getValue();
	}

	@Override
	public Money convertToEntityAttribute(Integer value) {
		return value == null ? null : new Money(value);
	}

}

실제로 구현하게 되면 다음과 같이 구현이 될 것이다.

여기서 autoApply = true가 되어있는 것을 볼 수 있는데, 이는 실제로 모델에서 발생하는 모든 Money 타입의 프로퍼티에 대해, 컨버터를 자동적으로 적용해주는 것이다.

만일 이를 false로 하게 되면 프로퍼티 상단에다가 @Converter를 붙여 사용해야한다.

만일 선택적으로 사용해야 하는 경우에는 해당 방법을 사용해야 할 것 같다.

밸류 컬렉션: 별도 테이블 매핑

OrderLine은 List로 가질 수 있는데, 밸류 타입이다.
이러한 밸류 타입을 리스트로 가지고 있으려면, @ElementCollection, @CollectionTable을 사용하면 된다. (List로 지정하게 되는 경우 순서를 유지해야하기 때문에, @OrderColumn도 추가해주는 편이 좋은 것 같다.)

밸류 컬렉션: 한 개 컬럼 매핑

오우, 이거는 제 1정규형 원칙에 어긋나는 것 아닌가.. 라는 생각이 들기는한다. 하지만, 편한 방법인 것 같다.

Email을 여러개 가지고 있다고 가정하고, DB에 하나의 컬럼에 저장하고 싶을 때에도 Converter를 사용해서, Converter에 사용될 타입을 정의하고, Converter에서 방식을 지정할 수 있다.

다시 말하지만, 좋은 방법인지는 모르겠다.

밸류를 이용한 ID 매핑

도메인 주도 개발 시작하기

이런 형식으로 OrderNo과 같이 식별자를 따로 밸류타입으로 표현할 수 있고, 이 경우 @Id가 아닌 @EmbeddedId를 사용해야한다.

이 방식의 장점은 식별자에도 기능을 넣을 수 있다는 것이다. 다음은 시스템 세대의 주문번호를 구분할 때 첫 글자를 사용할 경우, 이를 구분하기 위해 기능을 구현한 것을 볼 수 있다.

별도 테이블을 저장하는 밸류 매핑

별도의 테이블에 저장된다고 해서 꼭 Entity로 정의해야 하는 것은 아니다. 단적인 예로 OrderLine도 별도의 테이블에 저장되지만, 밸류 타입으로 표현한 것을 볼 수 있다.

만일 Entity로 꼭 정의해야 한다면, 다른 애그리거트인지를 의심해야한다.

생명주기를 살펴보고, 혹은 고유 식별자를 갖는지를 확인하고 이를 나누어보자.

도메인 주도 개발 시작하기

하지만, 다음과 같이 잘못된 매핑을 시켜, 별도의 테이블로 매핑되어 식별자를 갖는다고 하더라도 설계에 대한 의심을 해야지, 이가 다른 애그리거트라고 생각하면 안된다.

여기에서는 ArticleContent를 Entity로 생각할 수 있지만, 그냥 밸류로 생각하는 것이 맞다.

도메인 주도 개발 시작하기

그래서 이를 올바르게 바꾼다면 다음과 같이 바꿀 수 있는 것이다.

여기서 설명하는 방법으로 List가 아닌 밸류타입을 별도의 테이블로 저장하는 방법이 있는데, 설명하는 방법대로 구현을 진행하면 Article을 조회할 때 자동적으로 조인되어 조회되게 된다. 하지만, 지연 로딩을 하고 싶은 경우에는 어떻게 할 수 있을까?

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

도메인 주도 개발 시작하기

제품의 이미지 업로드 방식에 따라 이미지 경로와, 섬네일 이미지 제공여부가 달라지는 경우 다음과 같이 설계를 진행해야한다.

JPA는 Embeddable 타입의 클래스 상속 매핑을 지원하지 않는다.

그렇기 때문에, Entity 타입을 사용해야한다.

도메인 주도 개발 시작하기

또한 구현 클래스를 구분하기 위해 타입 식별 칼럼을 추가해야한다.

이를 위한 설계는 다음과 같다.

한 테이블에 Image와 그 하위 클래스를 매핑하므로 Image클래스에 다음 설정을 사용해야한다.

도메인 주도 개발 시작하기

Image Entity를 구현하고, @DiscriminatorValue를 활용해서 상속받아 구현하면 된다.

그리고 그 이후에 cascade 설정, OrderColumn을 설정해주면 된다.

프로덕트와 이미지가 하나의 애그리거트로 묶이게 될 것이라고 생각하지는 못했는데.. YAPP이 아닌 다른 곳에서 진행하고 있는 프로젝트의 설계를 의심해봐야겠다.

이렇게 Entity로 List 밸류타입을 사용하면 편의성에서 좋지만, 성능상 문제가 생길 수 있다. 다량의 Image를 삭제하게 될 때, 현재 방식은 전부 다 불러왔다가, 하나씩 삭제하는데, 이를 다시 Embedded 타입으로 바꾸게 되면 한번에 삭제를 할 수 있다. 하지만 다형성을 포기해야 한다.

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

이거는 진짜 신박한 것 같다. 집합 연관은 성능 상의 이유로 피해야하긴 하지만, 요구사항을 구현하는데 해당 방식이 편하다면 다음과 같은 방식으로 구현할 수 있다.

도메인 주도 개발 시작하기

@ElementCollection을 이용하기 떄문에 Product 를 삭제할 때 연관된 데이터들도 함께 삭제된다.

애그리거트 로딩 전략

즉시 로딩은 애그리거트에 한번에 속한 모든 객체를 불러 올 수 있다는 장점이 존재하지만, 성능상 문제를 불러올 수 있다.

애그리거트는 개념적으로 하나여야 하지만, 꼭 로딩하는 시점부터 그럴 필요는 없다.

또한 애그리거트가 완전해야 하는 이유는 상태를 변경하는 기능을 실행할 때 애그리거트 상태가 완전해야 하고, 표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하기 때문이다. 즉, 실제로 조회할 때 가져오기만 하면 되는 것이다.

하지만, 보통 조회가 더 많이 일어나기 때문에, 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 보통 문제가 되지 않는다.

하지만, 항상 선택을 진행할 때, 쿼리가 많이 발생한다는 단점을 확실히 알고 있어야 하기는 한다.

애그리거트의 영속성 전파

조회할 때 뿐만 아니라 저장, 삭제할 때에도 하나로 완전해야한다.

그래서 Entity로 연관되어 있는 경우는 cascade를 적절히 잘 설정하도록하자. (위험하기는 하지만, 편한 것은 맞는 것 같아요.)

식별자 생성 기능

보통 Identity 전략을 활용하여 id를 생성하지만, 도메인 규칙으로 생성할 수도 있다. 주문 번호를 타임스탬프로 구현해야 하는 경우 같은 것들을 예로 들 수 있다.

이를 Service에서 해줄 수도 있지만, 아래와 같이 Repository에서 해줄 수도 있다.

도메인 주도 개발 시작하기

이외의 보통 DB의 기능이나, JPA의 기능을 사용해 식별자를 생성하는 것은 모두 저장 시점에 식별자가 생긴다는 것을 유의하는 것이 좋다.

도메인 구현과 DIP

도메인 주도 개발 시작하기

처음에 찬기님이 이야기 하셨던 것 같은데, @Entity, @Table과 같은 것들은 다 인프라 관련된 코드들인데 domain 깊숙히 침투해있다는 것이 문제이다.

그렇기 위해서 위와 같은 구조를 사용할 수 있고, 결과적으로 Spring Data JPA의 Repository 인터페이스를 상속 받지 않도록 수정하고, 또한 Article 클래스에서 Entity나 Table과 같이 JPA에 특화된 어노테이션을 전부 지우고 인프라에 JPA를 연동하기 위한 클래스를 추가해야한다.

하지만, 이를 적용하기 이전에 DIP의 용도에 대해서 고민해 볼 필요가 있다고 생각한다고 한다.
저수준 구현이 변경되더라도 고수준에 영향을 최소화하기 위해서인데, 사실 DB를 바꾸는 일도 ORM을 바꾸는 일도 굉장히 적은 편이다. 그렇기 때문에, 이러한 변경이 거의 없는 상황에서 변경을 미리 대비하는 것은 과하다고 생각한다고 하시고, 어느정도 타협을 해서 편의를 얻고 계신다고 한다.

나도 이에 어느정도 동의를 하는 편이다.

이처럼 엄청난 편의를 주고, 따로 큰 불편함을 주지 않는 것을 굳이 채택하지 않을 필요는 없다고 생각한다.

profile
끊임없이 '성장'하는 개발자 김재연입니다.

0개의 댓글