📌 DDD 설계
Spring Data JDBC 기반의 데이터 액세스 계층을 연동하기 위해 제일 먼저 해야될 일은 바로 데이터베이스의 테이블과 도메인 엔티티 클래스의 설계이다.
도메인
도메인(Domain)은 주로 비즈니스적인 어떤 업무 영역과 관련이 있다.
예를 들어, 새로운 배달 주문 앱을 만든다고 할 때 고객과 음식점, 배달원, 카드사, 은행 등 배달 주문 앱을 구현하기 위해 필요한 업무들을 자세히 알면 알수록 퀄리티가 높은 애플리케이션을 만들 가능성이 높다. 즉, 고객이 음식을 주문하는 과정, 주문받은 음식을 처리하는 과정, 조리된 음식을 배달하는 과정 등이 도메인(Domain)을 통해 세분화되어 서비스 계층에서 비즈니스 로직으로 구현된다.
애그리거트
애그리거트(Aggregate)는 비슷한 업무 도메인들의 묶음을 말한다.
배달 주문자 정보, 배당 음식 정보, 주문 정보, 배달 추적 정보, 배달 주소 정보 등의 도메인들은 모두 "주문"이라는 키워드로 묶을 수 있다. 이와 같이 비슷한 도메인들을 한 데 묶어 OO애그리거트라고 표현한다.
애그리거트 루트
애그리거트 안의 대표 도메인을 애그리거트 루트(Aggregate Root)라고 말한다.
배달 주문자 정보, 배달 음식 정보, 배달 추적 정보, 배달 주소 정보는 결국 주문 정보라는 핵심 도메인을 거쳐간다. 이처럼 애그리거트 안에서 도메인들에 직간접적으로 연관이 있는 핵심 도메인을 애그리거트 루트(Aggregate Root)라고 표현한다.
데이터베이스의 테이블 간 관계로 보자면, 애그리거트 루트는 부모 테이블이 되고, 애그리거트 루트가 아닌 다른 도메인들은 자식 테이블이 된다. 즉, 애그리거트 루트(Aggregate Root)의 기본 키 정보를 다른 도메인들이 외래 키 형태로 가지고 있다고 생각할 수 있다.
📌 Spring Data JDBC
Spring에서 데이터와 객체를 연계하기 위해 제공하는 Spring Data의 ORM 모듈 중 하나이다.
기본적으로 Spring Data의 기능을 제공하기 때문에 Spring Data의 강력한 리파지토리(Spring Data Repository) 구현 기능을 통해 JDBC 기반 리파지토리를 쉽게 구현할 수 있다.
Spring Data JDBC는 기본적으로 쉬움을 목표로 삼는다.
이를 위해 캐싱(Caching), 지연 로딩(Lazy Loading), 쓰기 지연 또는 기타 JPA에서 제공하는 여러 기능을 지원하지 않는다. JPA가 가지는 복잡한 부분들을 많이 덜어내어 가볍게 DB를 조회하는 것에 초점을 둔 라이브러리라고 할 수 있다.
Spring Data JDBC는 DDD(Domain Driven Design) 기반 설계를 고려했다.
Spring Data 리파지토리는 Eric Evans의 책, Domain Driven Design에 설명된 하나의 Aggregate에는 하나의 리파지토리를 가진다는 도메인 모델링에 영감을 받아 만들어졌다. Aggregate는 Aggregate Root에 대한 메소드 호출 간에 일관된 모델을 제공한다.
주요 특징
- JPA에 비해 상대적으로 쉽다.
- 사용자 정의가 가능한 단순 CRUD 기능 Query메서드를 제공한다.
- @Query 애너테이션를 지원한다.
- MyBatis 쿼리를 지원한다.
- 양방향 관계를 지원하지 않는다.
- Page 클래스를 지원한다.
- AggregateRoot 메서드에서 사용할 수 있는 Event 애너테이션 기능을 제공한다.
기본 구성
Dialect
Spring Data JDBC에서는 Dialect라는 인터페이스를 제공한다.
JDBC API와 비슷하게 다양한 데이터베이스들을 지원한다. 일반적으로 AbstractJdbcConfiguration에서 사용하는 데이터베이스를 결정하고 Dialect에 등록한다.
그런데 만약 Dialect에서도 사용하려는 데이터베이스를 지원하지 않는 경우, 애플리케이션이 실행되지 않는다.
이러한 경우 Dialect를 반환하는 JdbcDialectProvider를 사용자가 구현해서 사용하면 된다.
📌 애그리거트 객체 매핑 규칙
Spring Data JDBC는 다음과 같은 애그리거트 매핑 규칙을 가진다.
- 모든 엔티티 객체의 상태는 애그리거트 루트를 통해서만 변경할 수 있다.
배달 음식 정보, 배달 주소 정보, 배달 추적 정보 등의 애그리거트 루트는 주문 정보이다. 따라서 배달 정보의 변경을 위한 모든 외래키 참조는 애그리 루트인 주문 정보를 통해서만 이루어진다. - 동일한 애그리거트 내에서의 외래키 참조는 엔티티 간에 객체로 참조한다.
동일한 애그리거트 내에서의 외래키 참조가 이루어지는 경우 엔티티 간에 객체를 외래키로 가져 참조한다. - 애그리거트 루트 대 애그리거트 루트 간의 외래키 참조는 ID로 참조한다.
- 1-1, 1-N 관계일 때는 테이블 간의 외래키 방식과 동일하다. ID를 참조한다.
- N-N 관계일 때는 외래키 방식인 ID 참조와 객체 참조 방식이 함께 사용된다.
📌 Spring Data JDBC의 사용
의존 라이브러리 추가
Spring Boot 프로젝트에서는 다음과 같이 의존성 라이브러리를 추가해 간단하게 사용할 수 있다.
dependencies {
...
...
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
runtimeOnly 'com.h2database:h2'
}
Configuration
Spring Boot 프로젝트가 아닌 경우 별도의 Config 클래스를 만들어 Configuration 애너테이션을 통해 빈을 등록해줘야 한다.
@Configuration
@EnableJdbcRepositories
class ApplicationConfig extends AbstractJdbcConfiguration {
@Bean
public DataSource dataSource() {
EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder();
return builder.setType(EmbeddedDatabaseType.HSQL).build();
}
@Bean
NamedParameterJdbcOperations namedParameterJdbcOperations(DataSource dataSource) {
return new NamedParameterJdbcTemplate(dataSource);
}
@Bean
TransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
@EnableJdbcRepositories
Repository 인터페이스 구현체를 생성한다.
AbstractJdbcConfiguration 상속
Spring Data JDBC가 요구하는 기본적인 빈을 제공한다.
dataSource() 메서드
DB에 연동되는 DataSource를 생성한다.
namedParameterJdbcOperations
dataSource 기반으로 DataSource를 생성한다.
transactionManager
JDBC 템플릿에선 기본적으로 트랜잭션을 처리할 때 AbstactPlatformTransactionManager 인터페이스를 사용한다. DataSourceTransactionManager는 AbstactPlatformTransactionManager의 구현체 중 하나.
엔티티 모델 구성
엔티티에서의 사용(1-N)
Member 클래스와 Order 클래스는 1-N의 관계이다.
Member 클래스(1-N 관계)
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
@Getter
@Setter
public class Member {
@Id
private Long memberId;
private String email;
private String name;
private String phone;
}
memberId 멤버 변수에 @Id 애너테이션을 붙여 Spring Data JDBC 엔티티 식별자로 지정할 수 있다. 해당 Member 클래스는 데이터베이스 테이블에서 MEMBER 테이블과 매핑된다. (@Table 애너테이션을 통해 별도의 테이블명을 설정할 수 있다)
Order 클래스
import com.alpha.member.entity.Member;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.jdbc.core.mapping.AggregateReference;
import org.springframework.data.relational.core.mapping.Table;
...
...
@Getter
@Setter
@Table("ORDERS")
public class Order {
@Id
private long orderId;
// 테이블 외래키처럼 memberId를 추가해서 참조하도록 한다.
private AggregateReference<Member, Long> memberId;
...
...
}
- Member 클래스는 회원이라는 애그리거트의 루트 클래스이다.
- Order 클래스는 주문이라는 애그리거트의 루트 클래스이다.
- Member 클래스와 Order 클래스는 1-N의 관계이다.
애그리거트 객체 매핑 규칙에서 애그리거트 루트와 애그리거트 루트 간에는 객체를 직접 참조하는 것이 아니라 ID로 참조한다. 때문에 Spring Data JDBC에서 제공하는 AggregateReference 클래스를 사용하여 Member 클래스를 감싸 외래키처럼 추가해준다. 이런 식으로 애그리거트 루트 간의 직접적인 참조가 아닌 ID 참조가 이루어지게 된다.
엔티티에서의 사용(N-N)
Order 클래스와 Coffee 클래스는 N-N관계이다.
엔티티끼리의 N-N관계는 기본적으로 두 엔티티 사이에 참조 엔티티(조인테이블)를 만들어 1-N, N-1로 풀어 사용한다.
Order 클래스(1-N 관계)
import com.alpha.member.entity.Member;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.jdbc.core.mapping.AggregateReference;
import org.springframework.data.relational.core.mapping.Table;
import java.util.LinkedHashSet;
import java.util.Set;
@Getter
@Setter
@Table("ORDERS")
public class Order {
@Id
private long orderId;
private AggregateReference<Member, Long> memberId;
@MappedCollection(idColumn = "ORDER_ID") // N-N 관계가 OrderCoffee를 통해 1-N 관계가 된 모습
private Set<OrderCoffee> orderCoffees = new LinkedHashSet<>();
...
...
}
@MappedCollection 애너테이션을 통해 엔티티 클래스 간에 연관 관계를 맺어준다.
ORDERS에서 외래키에 해당하는 컬럼명을 적어주면 된다.
Order 엔티티와 OrderCoffee 엔티티는 같은 Aggregate이다.
따라서 애그리거트 객체 매핑 규칙에 따라 객체 참조를 사용한다.
객체 참조에 대한 규칙은 다음과 같다.
- Set<some entity>는 1-N 관계로 간주한다.
- Map<Type, Entity>는 1-N 관계로 간주한다.
- List<Entity>는 Map의 규칙(Map<Integer, Entity>)으로 적용되며 1-N 관계로 간주한다.
OrderCoffee 클래스(N-1 관계)
package com.alpha.order.entity;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.data.relational.core.mapping.Table;
@Getter
@AllArgsConstructor
public class OrderCoffee {
private long coffeeId;
private int quantity;
}
Coffee 클래스
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
@Getter
@Setter
public class Coffee {
@Id
private long coffeeId;
private String korName;
private String engName;
private int price;
}
Coffee 클래스와 OrderCoffee 클래스는 Member 클래스와 Order 클래스와 같이 1-N 관계이다.
Member 클래스와 Order 클래스에서 했던 것처럼 AggregateReference로 OrderCoffee 클래스의 coffeeId를 감싸줘야할 것 같은데 그렇게 하고 있지 않다. 그 이유는 Spring Data JDBC가 양방향 관계를 제공하지 않기 때문이다. 이러한 이유로 Spring Data JDBC에서는 N-1 관계에서는 별도의 설정을 해주지 않는다.
리파지토리의 사용
import com.alpha.member.entity.Member;
import org.springframework.data.repository.CrudRepository;
import java.util.Optional;
public interface MemberRepository extends CrudRepository<Member, Long> {
Optional<Member> findByEmail(String email);
}
하나의 Aggregate는 하나의 리포지토리를 가진다.
같은 Aggregate에 포함된 도메인들의 쿼리 메서드들은 같은 리파지토리에서 관리된다. (하나의 Aggregate, 하나의 리파지토리)
기본적으로 Spring Data Commons에서 제공하는 CrudRepository를 상속받는다.
CrudRepository를 상속받음에 따라 Spring Data Commons에서 제공하는 다양한 기능들을 사용할 수 있다.
Spring Data JDBC에서는 쿼리 메서드 기능을 제공한다.
find + By + WHERE절 컬럼명 + (WHERE절 조건 데이터) 형식으로 쿼리 메서드를 정의하면 메서드 호출 시 조건에 맞는 데이터를 테이블에서 조회한다.
Spring Data JDBC에서는 Optional 래퍼 클래스를 지원한다.
Optional 래퍼 클래스로 리턴할 클래스를 매핑시키면 Optional에서 제공하는 다양한 유효성 검사 메서드들을 사용할 수 있다. (null인지 등..) Optional을 이용해 코드를 좀 더 효율적이면서 간결하게 구성할 수 있다.
댓글