JPA-DDD 2 (readonly로 관계 맺기 / N+1 / ...)

readonly?

  • department - line 관계를 맺고싶을때?

  • 어떤 department가있다. 이department가 포함된 market - factory의 어떤 생산 line에서 제품을만든다

  • 강결합정도에 따라

    • 상호 참조 하지않고 service에서 로직으로 연관데이터 조합
    • readonly로 데이터 참조
    • read & write가능 (-> 지양하기, 하위 도메인취급이 되어버림)
  • 보수적으로 설계 -> 필요할때 하나씩 열어주는 방식으로 고려하면 :+1:

case 1 기존 rule대로 약결합으로 구현하면 생기는 문제

  • merge data -> stream / loop
  • 의존성은 약하지만 비효율적임
  • 하나의db / 하나의application이기때문
  • 분리가되면 db도, 모듈도 분리가되어버린다.

case 2 read only 로 설정방법

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

@Table(name = "suppliment")
@Entity
...
public class Suppliment {
@Id
private long suppliment;

//1. 키 데이터 O, 데이터 직접 참조 X
@Column(name="market_id")
private long marketId;
@Column(name="factory_id")
private long factoryId;


//2. 데이터 직접 참조 O,
//save 될때 side effect -> cascade에 따라 발생 가능 <- 위험
//데이터 직접 바꾸는것 가능 O

@ManyToOne(cascade=CascadeType.ALL)
@JoinColumn(name="market_id")
private Market marketId;

@ManyToOne(cascade=CascadeType.ALL)
@JoinColumn(name="factory_id")
private Factory factoryId;

//3. 데이터 직접 참조 O,
//save 될때 side effect -> cascade 설정을 하지않아서 함깨 바뀌는것을 방지.
//updatable, insertable 을 false로 두어 root도메인을 건드리지못하게한다.

@ManyToOne
@JoinColumn(name="market_id", updatable = false, insertable = false)
private Market market;

@ManyToOne
@JoinColumn(name="factory_id", updatable = false, insertable = false)
private Factory factory;
}

더 작은 서비스로..

  • root 에그리거트를, module로 분리하게되면?

    • applicaton의 기술스택 뿐 아니라 db도 분리가가능
    • db 분리시 기존에 사용하던 db말고 다른 플랫폼을 사용 할 수 있다
      • join이 당연히 불가능함 <– 고려사항
    • 실제로 insert/update 용 DB와 select용 DB를 따로 쓰도록 설계하기도한다.(CQRS)
  • @OnetoOne 의 optional=false와, @Column의 nullable=falses

repository

  • Market 도메인을 위한 repository를 만들어보자.
  • JpaRepository<Entity, Type of EntityKey> 를 extends 한 interface를 만든다.
1
2
3
4
5
6
7
8
public interface MarketRepository extends JpaRepository<Market, Long> {

Market findByMarketId(Long marketId);
//findAll
//saveAll
//....
// 이 자동으로 포함되어있다
}

select

1
2
3
4
5
6
7
public Collection<Market> findAll() {
Collection<Market> allMarket = marketRepository.findAll();
}

public Collection<Market> findById(Long key) {
Market market = marketRepository.findByMarketId(key);
}

insert

1
2
3
public void insert(Market market) {
marketRepository.save(market);
}

-> 1:n에서 불필요하게 query가 많이 날아가는경우가있다. (N+1 문제라고한다. 뒤에 설명)

update

save가 같은역할을 함

1
2
3
public void update (Market market) {    
marketRepository.save(market)
}

N+1 문제 ★★★★

N+1 문제란?

부모객체의 데이터를 가지고오는 경우 select쿼리 호출 횟수가 기하급수로 늘어나는현상.
부모데이터 select 1번 + 자식 데이터 수만큼 select
성능에 막대한 영향을 미침.

Q 아래와 같이 데이터가 쌓인경우, query 호출횟수는?

  • market - 10 row
    • address - 10 row
    • department - 각각 10row
      • car - 각 10row
      • building - 각 10 row

답 :
10 + 10x(department)
= 10 + 10x(10+ 10x(cars + building))
= 10 + 10x(10+ 10x(10 + 10))
= 2110

–> 한번에 join을 걸어서 가지고오면 좋을탠데..

jpql

  • Java Persistence Query Language
  • JPQL은 SQL과 비슷한 문법을 가진 객체 지향 쿼리
  • String jpql = "select c from Category c "; 처럼 사용

QueryDSL ★★

  • 원하는대로 join 가능
  • 복잡한쿼리 가능
  • custom하게 where절 만들기 가능
  • (매우매우 단순한 applicatoin 이 아니라면 쓰게 custom 한 쿼리를 짜게됨…)

jpql에 비해 얻을수 있는점

  • IDE의 코드 자동 완성 기능 사용
  • 문법적으로 잘못된 쿼리를 허용하지 않음
  • 도메인 타입과 property를 안전하게 참조할 수 있음
  • 더 많은 타이핑이필요

예를들면 아래와 같은것들이 가능하다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Market find(String, departmentsName, String carName){
return from(market)
.innerJoin(market.departments, department )
.fetchJoin()
.innerJoin(department.cars, car)
.fetchJoin()
.where(
department.name.eq(departmentsName),
car.name.eq(carName)
// market.registerDatetime.after(xxx)
// market.registerDatetime.before(xxx)
//...
)
}

설정방법

  1. buildscript에 plugin 추가

build.gralde (root)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
buildscript {
ext {
springBootVersion = '2.0.5.RELEASE'
querydslPluginVersion = '1.0.10'
}
repositories {
mavenCentral()
jcenter()
maven {
url "https://plugins.gradle.org/m2/"
}
}
dependencies {
classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
classpath("gradle.plugin.com.ewerk.gradle.plugins:querydsl-plugin:${querydslPluginVersion}")
}
}
  1. 의존성 추가

build.gradle (module)

1
2
3
4
5
6
7
8
9
10
11
12
dependencies {
compile("org.springframework.boot:spring-boot-starter-data-jpa")
...
compile ("com.querydsl:querydsl-core:${querydslVersion}")
compile ("com.querydsl:querydsl-apt:${querydslVersion}")
compile ("com.querydsl:querydsl-jpa:${querydslVersion}")
...

}

// query dsl 에 관한 내용은 별도파일로 관리했음.
apply from: "$projectDir/queryDsl.gradle"
  1. 플러그인적용 및 querydsl Task 추가

queryDsl.gradle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// querydsl 적용
apply plugin: "com.ewerk.gradle.plugins.querydsl" // Plugin 적용

def querydslSrcDir = 'src/main/generated' // QClass 생성 위치

querydsl {
library = "com.querydsl:querydsl-apt"
jpa = true
querydslSourcesDir = querydslSrcDir
}

sourceSets {
main {
java {
srcDirs = ['src/main/java', querydslSrcDir]
}
}
}

image.png

repository에 적용

생성된 테스크를 수행하면 generated 폴더에 QClass가 생성됨

  • QClass란?
    • @Entity 가 붙은 class들을 찾아 자동으로 생성함
    • EntityPathBase 과같이 `EntityPathBse 를 상속함
    • entity 의 구성요소 등을 파악하여 쿼리를 생성할수있게 도아줌
    • query dls 은 해당 class를 기반으로 table -> from / member variable -> columns 로 매칭하여 쿼리를 만들수 있게 도아줌

image.png

적용방법

MarketRepository.java

1
2
3
4
5
6
7
8
public interface MarketRepository extends JpaRepository<Market, Long> , MarketRepositoryCustom {

Market findByMarketId(Long marketId);
//findAll
//saveAll
//....
// 이 자동으로 포함되어있다
}

MarketRepositoryCustom.java

1
2
3
public interface MarketRepositoryCustom {
Market findWithoutBuildingByDepartmentNameAndCarName(String DepartmentName, String CarName);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MarketRepositoryCustomImpl extends QuerydslRepositorySupport implements MarketRepositoryCustom {
QMarket market = QMarket.market;
QDepartment department = QDepartment.department;
QCar car = Qcar.car;

public Market findWithoutBuildingByDepartmentNameAndCarName(String DepartmentName, String CarName) {
return from(market)
.innerJoin(market.departments, department )
.fetchJoin()
.innerJoin(department.cars, car)
.fetchJoin()
.distinct() //중복제거
.where(
department.name.eq(departmentsName),
car.name.eq(carName)
)
}
}

MarketService.java

1
2
3
public getSomething(){
marketRepository.findWithoutBuildingByDepartmentNameAndCarName("payco", "bungbung");
}

구조

1
2
3
4
5
6
7
8
9
10
11
12
interface MarketRepository
interface MarketRepositoryCustom
interface JpaRepository
class MarketRepositoryCustomImpl
class MarketService

MarketRepository <|- JpaRepository : extends
MarketRepository <|- MarketRepositoryCustom : extends
MarketRepositoryCustom --> MarketRepositoryCustomImpl : implments

MarketService <-- MarketRepository : DI

quertdsl where절 활용하기

where절 조회 파라메터가 empty 값일때 무시하기 1

  • null check 후 eq를 비교한다
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public Market findWithoutBuildingByDepartmentNameAndCarName(String DepartmentName, String CarName) {
    return from(market)
    .innerJoin(market.departments, department )
    .fetchJoin()
    .innerJoin(department.cars, car)
    .fetchJoin()
    .distinct() //중복제거
    .where(
    equalsIfNotNull( department.name, departmentsName),
    equalsIfNotNull(car.name, carName)
    car.name.eq(carName)
    )
    }

    private BooleanExpression equalsIfNotNull(StringPath column, String param) {
    return param== null ? null : column.eq(param);
    }

    private BooleanExpression equalsIfNotEquals(StringPath column, String param) {
    return StringUtils.isEmpty(param) ? null : column.eq(param);
    }
    ....
  • 단점 : string 같은경우 empty check를해야함
  • 날짜의 경우 before/after를 비교하고싶음
  • ….

where절 조회 파라메터가 empty 값일때 무시하기 2

1
2
3
4
5
6
7
8
9
10
11
12
13
public Market findWithoutBuildingByDepartmentNameAndCarName(String DepartmentName, String CarName) {
return from(market)
.innerJoin(market.departments, department )
.fetchJoin()
.innerJoin(department.cars, car)
.fetchJoin()
.distinct() //중복제거
.where(
WhereClauseBuilder.optionalAnd( department.name, department.name.eq(departmentsName)),
WhereClauseBuilder.optionalAnd( car.name, car.name.eq(carName))
)
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class WhereClauseBuilder {

public static <T> BooleanExpression optionalAnd(T param, BooleanExpression booleanExpression) {

if (param instanceof String) {
String stringParam = (String) param;
if (StringUtils.isEmpty(stringParam)) {
return null;
} else {
return booleanExpression;
}
}

if (param != null) {
return booleanExpression;
}
return booleanExpression
}
}

where절 조회 파라메터가 empty 값일때 무시하기 3

  • 조금만더 발전시켜보자
  • lazy loading
1
2
3
4
5
6
7
8
9
10
11
12
public Market findWithoutBuildingByDepartmentNameAndCarName(String DepartmentName, String CarName) {
return from(market)
.innerJoin(market.departments, department )
.fetchJoin()
.innerJoin(department.cars, car)
.fetchJoin()
.distinct() //중복제거
.where(
WhereClauseBuilder.optionalAnd( department.name, ()->department.name.eq(departmentsName)),
WhereClauseBuilder.optionalAnd( car.name, ()->car.name.eq(carName))
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class WhereClauseBuilder {

public static <T> BooleanExpression optionalAnd(T param, LazyBooleanExpression booleanExpression) {

if (param instanceof String) {
String stringParam = (String) param;
if (StringUtils.isEmpty(stringParam)) {
return null;
} else {
return booleanExpression.get();
}
}

if (param != null) {
return booleanExpression.get();
}
return null;
}
}
1
2
3
4
5
@FunctionalInterface
public interface LazyBooleanExpression {

BooleanExpression get();
}

where절 조회 특정 조건일때 무시하기 3

1
public static BooleanExpression ignoreAnd(boolean isTrue, LazyBooleanExpression booleanExpression)

one to one에서 방향

  • 기존 mysql rdb 기반에서의 구조
    • child가 자신의 id를 가지고, 부모의 키를 fk로 가진다
    • one to one 관계에서 lazy loading이 불가능해진다.
  • JPA에서 추구하는 방향
    • 관계의 맺고 끊음 주체가 되는 도메인, 즉 부모측에서 key를 관리 한다.
    • 매핑이 되는 key를 공통으로 사용하더라도 이슈가 없다.
  • eticket에서는?
    • 1:1은 부모와 자식을 같은키로 사용

tips

boolean

1
2
3
@Type(type = "yes_no")
@Column(name = "pin_search_success_yn")
private boolean isSucceeded;
  • @Type(type = “yes_no”) 붙여주면 db값의 y,n 을 true/false로 바꿔주어 boolean으로 사용가능하다

Comments