interface MemberRepository {
fun findById(id: Long): MemberDto
}
이번 포스팅에서는 MyBatis에서 엔티티를 분리하는 방법에 대해 알아보겠습니다.
여기서 말하는 엔티티는 JPA에서의 엔티티가 아닌 도메인 관점에서의 엔티티를 의미합니다.
MyBatis를 예로 들었지만 다른 SQL Mapper에서도 적용가능합니다.
예제코드는 코틀린으로 진행합니다.
배경
왜 분리를 하려고하는지부터 짚고 넘어가면 좋을 것 같습니다.
SQL Mapper를 사용하면 필연적으로 비즈니스 로직을 sql로 처리하도록 작성하게 됩니다.
이렇게 되면 Mapper가 점점 늘어나게 되고 결국에는 DB에서 모든 비즈니스 로직을 처리하게되어 장애 대응이나 관리가 힘들어집니다.
또한 사용하고 있는 DB에 종속될 수 밖에 없습니다.
잘 관리하지 않으면 간단한 기능을 구현하는데도 관련한 여러 개의 클래스를 생성하게되서 훗날 유지보수하는데 크게 어려움을 겪게 됩니다.
자주 발생하는 문제
우선 MyBatis를 사용하면서 보통 마주하게되는 문제에 대해 알아보겠습니다.
1. 하나의 요구사항에 하나 이상의 쿼리가 생성된다.
이 문제는 어떻게 보면 당연할 수 있는 문제입니다.
예를들어 유저의 이메일을 변경하라는 요구사항이 있다고 생각해면
서비스에 해당 요구사항을 구현하는 메서드가 하나 생기게 됩니다.
@Service
@Transactional
class MemberServiceV1(
private val memberMapperV1: MemberMapperV1
) {
fun getMember(memberId: Long): MemberDto {
val memberDto = memberMapperV1.getMember(memberId)
return memberDto
}
// 이메일 변경을 위해 추가
fun changeEmail(memberId: Long, email: String) {
memberMapperV1.changeEmail(memberId, email)
}
}
당연히 Mapper와 xml에도 관련 메서드와 쿼리가 생성됩니다.
@Mapper
interface MemberMapperV1 {
fun getMember(@Param("memberId") memberId: Long): MemberDto
fun createMember(memberDto: MemberDto)
fun changeEmail(memberId: Long, email: String)
}
<mapper namespace="com.blog.code.mybatisdomain.memberMybatis.infra.MemberMapperV1">
<select id="getMember" resultType="com.blog.code.mybatisdomain.memberMybatis.application.dto.MemberDto">
select id, username, email
from members
where id = ${memberId}
</select>
<insert id="createMember" useGeneratedKeys="true" keyProperty="id" parameterType="com.blog.code.mybatisdomain.memberMybatis.application.dto.MemberDto">
insert into members(username, email) values (#{username}, #{email})
</insert>
<update id="changeEmail" >
update members set email = #{email} where id = #{memberId}
</update>
</mapper>
만약 여기에서 추가사항이 생긴다면 어떻게 될까요?
유저의 상태를 체크하는 로직 추가
만약 이메일을 변경하는게 마지막 변경 후 5일이 지난 시점에만 가능하다는 추가 요구사항이 생겼다면 어떻게 해야할까요?
검색해서 가져온 객체에 로직을 작성하는 방법도 있지만 xml에서 쿼리를 작성해 비즈니스 로직을 처리하는 방법도 있습니다.
@Mapper
interface MemberMapperV1 {
fun getMember(@Param("memberId") memberId: Long): MemberDto
fun createMember(memberDto: MemberDto)
fun changeEmail(memberId: Long, email: String)
fun findCanModifyMember(memberId: Long): MemberDto?
}
<mapper namespace="com.blog.code.mybatisdomain.memberMybatis.infra.MemberMapperV1">
<select id="getMember" resultType="com.blog.code.mybatisdomain.memberMybatis.application.dto.MemberDto">
select id, username, email, last_modify_date
from members
where id = ${memberId}
</select>
<insert id="createMember" useGeneratedKeys="true" keyProperty="id"
parameterType="com.blog.code.mybatisdomain.memberMybatis.application.dto.MemberDto">
insert into members(username, email, last_modify_date)
values (#{username}, #{email}, #{lastModifyDate})
</insert>
<update id="changeEmail">
update members
set email = #{email}
where id = #{memberId}
</update>
<select id="findCanModifyMember" resultType="com.blog.code.mybatisdomain.memberMybatis.application.dto.MemberDto">
SELECT id, username, email, last_modify_date
FROM members
WHERE id = #{memberId}
AND (last_modify_date IS NULL OR last_modify_date <![CDATA[<]]> DATEADD('DAY', -5, CURRENT_TIMESTAMP));
</select>
</mapper>
fun changeEmail(memberId: Long, email: String) {
memberMapperV1.findCanModifyMember(memberId)?.let { // 비즈니스 로직을 쿼리로 처리
memberMapperV1.changeEmail(memberId, email)
}
}
이 예시는 극단적인 예시이기 때문에 억지스러울 수 있겠지만
실제로 여러사람이 개발하는 프로젝트 내에서는 종종 간단한 비즈니스 로직이더라도 쿼리를 사용해 처리하는 경우가 있습니다.
점점 유지보수하기 어려운 프로젝트가 되어서 결국에는 늘어나는 DTO와 XML을 감당하지 못해 간단한 요구상이어도 거의 모든 로직을 새롭게 작성해야하는 경우가 발생합니다.
DB접근 객체와 도메인 모델을 분리해보자
가장 먼저 해야할 일은 repository 레이어를 추가하는 것입니다.
기존의 예제코드의 구조는 위의 사진처럼 되어있습니다.
컨트롤러 -> 서비스 -> DAO(Mapper) -> DB 의 순서로 데이터를 불러오게 됩니다.
도메인 모델과 DB 접근 객체를 분리하는 핵심은 Repository 레이어를 하나 더 추가하는 것입니다.
DB에서 값을 불러오기 전에 Repository를 거쳐서 DB 접근 객체와 도메인 객체를 분리하는 것입니다.
주의할 점은 JPA에서의 Repository가 아닌 Repository Pattern의 Repository라는 점입니다.
예제코드를 통해 살펴보겠습니다.
기존의 패키지 구조는 다음과 같습니다.
├── api
│ └── MemberControllerV1.kt
├── application
│ ├── MemberServiceV1.kt
│ └── dto
│ └── MemberDto.kt
└── infra
└── MemberMapperV1.kt
Repository를 추가한 패키지 구조는 다음과 같습니다.
├── api
│ └── MemberControllerV2.kt
├── application
│ ├── MemberServiceV2.kt
│ └── domain
│ └── Member.kt
├── infra
│ ├── MemberMapperV2.kt
│ ├── MyBatisMemberRepository.kt
│ └── vo
│ └── MemberVO.kt
└── repository
└── MemberRepository.kt
MeberVO가 데이터 베이스에 접근할 때 사용할 클래스이고 Member는 도메인 모델로 사용할 클래스입니다.
도메인 모델로 사용할 Member의 코드입니다.
class Member(
val id: Long?,
var username: String,
var email: String,
var lastModifyDate: LocalDateTime?,
) {
}
이제 해당 클래스가 도메인 로직을 담당하게 됩니다.
DB 접근에 사용할 MemberVO입니다.
data class MemberVO(
val id: Long?,
val username: String,
val email: String,
val lastModifyDate: LocalDateTime?,
) {
fun toDomain(): Member {
return Member(
id = this.id,
username = this.username,
email = this.email,
lastModifyDate = lastModifyDate,
)
}
}
DB를 조회해서 가져온 값을 toDomain 메서드를 호출해 도메인 모델 객체로 반환합니다.
이렇게 조회를 한 뒤에 도메인 객체로 생성해서 반환해주는 것이 MyBatis에서 도메인 객체를 분리하기 위한 핵심입니다.
그 다음으로는 Repository는 인터페이스와 그 구현체를 살펴보겠습니다.
interface MemberRepository {
fun findById(id: Long): Member
}
반환값은 도메인 모델입니다.
예제에서는 MyBatis를 사용하므로 구현체에 이를 명시해주는게 좋습니다.
@Repository
class MyBatisMemberRepository(
val memberMapper: MemberMapperV2
): MemberRepository {
override fun findById(id: Long): Member {
val memberVO = memberMapper.findById(id)
return memberVO.toDomain()
}
}
당연히 XML 과 Mapper에서도 반환값은 VO로 받습니다.
이제 좀 전의 요구사항을 다시 구현해보겠습니다.
- 이메일을 변경하는게 마지막 변경 후 5일이 지난 시점에만 가능하다
Member의 코드입니다.
class Member(
val id: Long?,
var username: String,
var email: String,
var lastModifyDate: LocalDateTime?,
) {
fun canChangeEmail(): Boolean {
return lastModifyDate?.isBefore(LocalDateTime.now().minusDays(5)) ?: true
}
fun changeEmail(email: String) {
this.email = email
this.lastModifyDate = LocalDateTime.now()
}
}
비즈니스 요구사항을 쿼리가 아닌 객체 내부에 작성했습니다.
이를 사용하는 서비스 코드입니다.
@Service
@Transactional
class MemberServiceV2(
private val memberRepository: MemberRepository
) {
fun getMember(memberId: Long): Member {
return memberRepository.findById(memberId) ?:throw BadRequestException("Member Not Found")
}
fun changeEmail(memberId: Long, email: String) {
memberRepository.findById(memberId)?.let {member: Member ->
if (member.canChangeEmail()) {
member.changeEmail(email)
memberRepository.save(member)
}
}
}
}
JPA를 사용하는 것이 아니기 때문에 명시적으로 save를 호출합니다.
Repository 내부는 다음과 같습니다.
@Repository
class MyBatisMemberRepository(
val memberMapper: MemberMapperV2
): MemberRepository {
override fun findById(id: Long): Member {
val memberVO = memberMapper.findById(id)
return memberVO.toDomain()
}
override fun save(member: Member) {
if (member.id == null) {
memberMapper.save(MemberVO.of(member))
} else {
memberMapper.update(MemberVO.of(member))
}
}
}
save를 구현하는데 두가지 선택지가 있습니다.
- update와 save를 분리하는 것
- update와 save를 save 메서드 내부에서 분리하는 것
선택은 자유입니다.
저는 서비스에서 해당 객체가 db에 저장되어있는 값인지 아닌지를 굳이 판단할 이유가 없을 것 같아 두번째 방법으로 구현했습니다.
예제 코드가 너무 간단해서 아쉽지만 이쯤에서 마무리해보도록 하겠습니다.
그 밖에 주의할 점
늘 그렇듯 설계는 트레이드 오프입니다.
성능을 중요시하신다면 쿼리로 로직을 처리하는 것도 좋은 방법일 수 있습니다.
또한 Repository가 추가되었기 때문에 같은 작업이라도 작업속도가 더 느릴 수 있습니다.
대신 코드를 좀 더 안정적으로 관리할 수 있게 됩니다!
여기서 몇가지 의문점이 들을 수 있을 텐데요,
연관관계
지금의 구조에서 엔티티 간의 연관관계는 안하는게 좋습니다.
연관관계 설정까지 하시려면 차라리 JPA를 사용하는 게 훨씬 좋습니다.
연관관계를 구현하려고 할 때 배보다 배꼽이 더 커지게 됩니다.
작업 속도나 훗날 유지보수를 고려했을 때 JPA가 아닌이상 연관관계를 고려하는 건 추천하지 않습니다.
조회시
복잡한 쿼리가 들어가거나 혹은 join을 통해 다른 테이블을 조회하게 된다면 Command와 Query를 분리를 고려해보시는 걸 추천합니다.
이미 MyBatis가 도입되어있기 때문에 분리하는 건 더 쉬울 것이라 생각합니다.
마무리
이번 포스팅에서는 MyBatis에서 도메인 모델을 분리하는 방법에 대해 간략히 알아봤습니다.
예시로 든 내용들이 너무 간단해서 아쉽지만.... 예제코드는 깃허브에 있습니다!
'개발 공부 > Spring' 카테고리의 다른 글
TDD 맛보기 (1) - TDD란? (0) | 2022.01.10 |
---|---|
인텔리J 프로젝트 돌리는게 느리다면? 이 설정 확인해보세요. (0) | 2021.12.08 |
스프링부트 JSON 응답처리와 예외처리 (2) | 2020.04.22 |
댓글