이 페이지에서는 Query서비스와 Command서비스에 대해 알아봅시다.
디렉토리 구조 및 설명 포스팅에서 설명했던 CQRS패턴의 C와 Q입니다.
Query는 조회만 수행합니다. 한건, 다건, 페이지 조회까지 알아보도록 합시다.
@Service
@Transactional(readOnly = true)
class MemberQueryService(
private val ...
) {
fun...
}
먼저 서비스 클래스를 작성 해 봅시다. Component Scan으로 해당 서비를 읽어 Bean으로 들여올 수 있도록 @Service 어노테이션을 붙여줍시다.
@Transactional 어노테이션을 이용해 이 클래스의 메서드들이 트랜잭션을 수행할 수 있도록 해 주고 readOnly = true 옵션을 적어줍니다. readOnly = true 옵션은 스프링의 Hibernate Session Flush 모드를(em.setFlushMode(FlushModeType.COMMIT)) 등.. MANUAL로 설정 해 주기 때문에 CUD 작업이 동작하지 않고, 변경감지 등의 업무를 수행하지 않아서 조회 성능이 향상됩니다. 영속성 컨텍스트를 비우는 것은 아닙니다!
또, MultiDataSource + QueryDslSupport 설정 에서 DB를 분리할 때 어느 한 쪽을 읽기 전용으로만 사용한다면 Sub DB에 연결된 모든 서비스에 readOnly = true옵션을 달아주면 됩니다.
MemberQueryService
@Service
@Transactional(readOnly = true)
class MemberQueryService(
private val memberRepository: MemberRepository,
private val commonQueryService: CommonQueryService
) {
// 단건 조회 예
fun getByOid(memberOid: Long): MemberOut {
return MemberOut.fromEntity(memberRepository.getByOid(memberOid))
}
// 다건 조회 예
fun getListByMemberRole(MemberRole: String): List<MemberOut> {
return memberRepository.getListByMemberRole(memberRole).map { MemberOut.fromEntity(it) }
}
// 페이지 조회 예
fun getPagedResultByMemberRole(
memberRole: String?,
pageable: Pageable,
sort: Sort
): Page<MemberOut> {
return memberRepository.getPagedResultByMemberRole(memberRole)
.map { MemberOut.fromEntity(it) }
}
}
OUT DTO
data class MemberOut(
val oid: Long?,
val memberRole: String,
val memberName: String,
val memberId: String,
) {
companion object {
fun fromEntity(e: Member): MemberOut {
return MemberOut(
oid = e.oid,
memberRole = e.memberRole(),
memberName = e.memberName(),
memberId = e.memberId(),
)
}
}
}
Repository Custom
interface MemberRepositoryCustom {
fun getByOid(oid: Long?): Member
fun getListByMemberRole(memberRole: String): List<Member>
fun getPagedResultByMemberRole(
memberRole: String?,
pageable: Pageable,
sort: Sort): Page<Member>
}
Repository 구현부
class MemberRepositoryImpl : PrimaryQueryDslSupport(Member::class.java), MemberRepositoryCustom {
private val qMember = QMember.member
override fun getByOid(memberOid: Long?): Member {
return from(qMember)
.where(
qMember.deleted.isFalse,
qMember.oid.eq(memberOid)
)
.fetchOne() ?: throw DomainEntityNotFoundException(memberOid!!, Member::class, "요청하신 사용자는 존재하지 않습니다.")
}
override fun getListByMemberRole(memberRole: String): List<Member> {
return from(qMember)
.where(
qMember.deleted.isFalse,
qMember.memberRole.eq(memberRole)
)
.fetchAll() ?: emptyList()
}
override fun getPagedResultByMemberRole(memberRole: String): Page<Member>(
memberRole: String?,
pageable: Pageable,
sort: Sort
): Page<Member> {
val result = from(qMember)
.where(
qMember.deleted.isFalse,
eqMemberRole(memberRole)
)
.orderBy(
*sort.map {
OrderSpecifier(
if (it.isAscending) Order.ASC else Order.DESC,
Expressions.path(String::class.java, qMember, it.property),
)
}.toList().toTypedArray()
)
.fetchAll()
val pagedResult = querydsl!!.applyPagination(pageable, result).fetch() ?: emptyList()
return PageableExecutionUtils.getPage(pagedResult, pageable) { result.fetchCount() }
}
private fun eqMemberRole(memberRole): BooleanExpression? {
retrun if(memberRole == null) null
else qMember.memberRole.eq(memberRole)
}
}
위처럼 Repo에서 데이터를 조회해 View에 전달하는 객체에는 OUT, 컨트롤러에서 들어오는 객체는 IN이라는 접미사를 붙여 생성 해 줍니다.
data class에 전달 해 주고자 하는 멤버 프로퍼티를 적어주고 아래 Companion object 를 정의 해줍니다.
Companion object는 자바의 Static과 유사합니다.
더 자세한 설명은 참고
companion object에는 위처럼 메서드를 작성할 수 도 있고, 인터페이스도 만들 수 있습니다. OUT DTO에는 엔티티를 매개변수로 받아서 조회 한 엔티티 클래스 중 원하는 값을 원하는 구성으로 뽑아주는 메서드를 만들어줍니다.
인터페이스인 커스텀 Repo에 구현 할 메서드과 리턴타입을 적어줍시다.
커스텀 Repo의 메서드를 오버라이드로 구현 해 줍니다.
Repository 구현부의 메서드 일부, 조회
private val qMember = QMember.member
override fun getByOid(memberOid: Long?): Member {
return from(qMember)
.where(
qMember.deleted.isFalse,
qMember.oid.eq(memberOid)
)
.fetchOne() ?: throw DomainEntityNotFoundException(memberOid!!, Member::class, "요청하신 사용자는 존재하지 않습니다.")
}
queryDSL을 사용하기 위한 Q클래스를 선언하고 from절부터 작성 해 주면 됩니다. 단건은 fetchOne, 다건은 fetchAll/fetct 를 붙여줍니다.
페이지 조회
override fun getPagedResultByMemberRole(memberRole: String): Page<Member>(
memberRole: String?,
pageable: Pageable,
sort: Sort
): Page<Member> {
val result = from(qMember)
.where(
qMember.deleted.isFalse,
eqMemberRole(memberRole)
)
.orderBy(
*sort.map {
OrderSpecifier(
if (it.isAscending) Order.ASC else Order.DESC,
Expressions.path(String::class.java, qMember, it.property),
)
}.toList().toTypedArray()
)
.fetchAll()
val pagedResult = querydsl!!.applyPagination(pageable, result).fetch() ?: emptyList()
return PageableExecutionUtils.getPage(pagedResult, pageable) { result.fetchCount() }
}
private fun eqMemberRole(memberRole): BooleanExpression? {
retrun if(memberRole == null) null
else qMember.memberRole.eq(memberRole)
}
페이지 조회는 3가지 단계를 단계를 거쳐서 값을 조회합니다.
간혹 여러가지의 검색조건을 가지고 있을 때 몇몇가지는 null값을 보내준다고 생각 해 봅시다. 위 예시에서는 memberRole이 null이면 어떤 역할이던 상관없이 조회 하라는 예시입니다.
private fun eqMemberRole(memberRole): BooleanExpression? {
retrun if(memberRole == null) null
else qMember.memberRole.eq(memberRole)
}
Impl클래스 내부에 private으로 메서드를 하나 만들어 줍니다. BooleanExpression은 동적쿼리 처리를 위한 리턴타입으로서 메서드 안에는 조건문을 담아줍니다. BooleanExpression이 null을 리턴하면 이 메서드를 사용하는 조건절은 where 구문에서 아예 빠지게 됩니다. 그래서 위처럼 값이 없으면 memberRole을 파라미터에서 제거, null이 아니라면 일치하는 memerRole이 where문에 들어가게 됩니다.
sort는 컨트롤러 단에서 먼저 선언해준 후 전달 해 줍니다. 따라서 컨트롤러의 파라미터에는 넣지 않습니다. 원한다면 파라미터로 받아와서 바인딩 해 줘도 됩니다.
val sort = Sort.by(Sort.Order.desc("memberNo"))
이렇게 작성 해 주고 sort를 메서드의 매개변수로 담아주면, Impl클래스에서 페이지 처리할 때 memberNo이라는 컬럼 이름을 기준으로 오름/내림차순 정렬해 줍니다.
.orderBy(
*sort.map {
OrderSpecifier(
if (it.isAscending) Order.ASC else Order.DESC,
Expressions.path(String::class.java, qMember, it.property),
)
}.toList().toTypedArray()
)
.fetchAll()
where절이 끝나는 부분부터 orderBy를 적용해 줍니다.
fetchAll까지만 작성하면 Page처리가 되지 않은 일반 리스트를 리턴 하게 됩니다.
val pagedResult = querydsl!!.applyPagination(pageable, result).fetch() ?: emptyList()
return PageableExecutionUtils.getPage(pagedResult, pageable) { result.fetchCount() }
우리는 조회한 List를 queryDsl의 applyPagination을 적용해 페이지로 변환 후 리턴 해 주면 됩니다.