쿼리에서 로직 빼기
내가 좋아하는 최범균님의 영상 중에서, 처음 보고 제일 충격을 받았던 영상이다.
영상을 다 보면 물론 좋겠지만, 시간 단축을 위해 핵심만 짚어본다.
ㅤ
쿼리에 로직이 들어간 코드
// update member m set m.status = 20 where m.id = ? and status = 10
int cnt = dao.회원탈퇴(id);
if (cnt == 0) {
// 해당 id에 해당하는 회원이 없음.
// 변경 건이 없으므로 변경 실패 처리
} else {
// 변경 성공했으므로 로직
}
다음은 mybatis로 짜여진 전형적인 코드다.
Mybatis로 UPDATE나 DELETE 쿼리를 실행하면, 그 결과로 변경된 행의 개수를 int로 반환해준다.
그걸 이용해서, cnt가 0이면 해당 멤버가 없었다는 것 (혹은 이미 탈퇴 상태라 탈퇴시킬 회원이 없었던 것)이므로 로직 실패 코드를 짜둔 것이다.
ㅤ
이렇게 코드를 짜면 SELECT로 멤버를 얻어오고 UPDATE로 값을 변경시키는 2번의 쿼리를 한번으로 줄일 수 있으므로 얼핏 보면 그냥 좋은 코드인 것처럼 보인다.
하지만 이렇게 로직이 쿼리에 들어가면 (현재 코드는 회원탈퇴를 이미한 유저는 탈퇴할 수 없다는 로직이 쿼리에 있다) 다음과 같은 문제가 생긴다.
주요 로직이 흩어짐
어플리케이션 코드, 쿼리로 분산 -> 코드 분석 어려워짐
유사한 쿼리를 중복해서 만들게 됨
미세하게 다른 조건으로 다른 변경 처리
주요 로직을 단위 테스트하기 어려움
쿼리를 실행해야 로직 검증 가능
ㅤ
이렇게 고치자
Member member = dao.findById(id);
if (member == null) // 실패처리
if (!member.isActive()) // 실패처리
member.탈퇴();
dao.update(member);
/////
class Member {
public boolean isActive() {
return this.status == 10;
}
public void 탈퇴() {
this.status = 20;
}
}
이렇게 하면 회원 탈퇴에 대한 로직이 어플리케이션 단으로 옮겨지므로 아까의 단점이 해소된다.
코드의 가독성도 올라가고, 상태 변경 기능과 관련된 쿼리가 단순해진다.
그러니 쿼리에서 로직을 빼라는 것이다.
ㅤ
그래서, 이걸로 모든 것이 해결됐나요?
명령(Command)에 대한 로직은 쿼리에서 빼내서 어플리케이션 단으로 옮기라는 것. 완벽히 이해했어!
그렇다면 조회(Query)는 어떨까?
ㅤ
조회에는 로직이 없는가?
조회에도 당연히 로직이 있다. 언틸의 예제를 가져와보자.
언틸에는 그룹 블로그가 있다. 토스, 배민 같은 팀 블로그를 모든 유저가 손쉽고 자유롭게 만들 수 있고, 같이 블로그를 꾸미고 싶은 유저를 초대할 수도 있다. (많이 써주세요)
아무튼.. 그리고 언틸에는 비공개 글을 작성할 수 있는 로직이 있는데.
ㅤ
언틸 글 공개 로직을 공개합니다
그룹 블로그에서 글을 조회하는 로직이 어떻게 될까?
삭제된 그룹의 글은 조회할 수 없다
삭제된 글은 아무도 볼 수 없다.
그룹의 공개 글은 누구나 볼 수 있다.
그룹의 멤버는 비공개 글을 볼 수 있다.
그룹에 속해있지 않은 유저는 비공개 글을 볼 수 없다.
미로그인 유저도 비공개 글을 볼 수 없다.
해당 비공개 글을 작성한 작성자는 탈퇴했더라도 글을 볼 수 있다.
ㅤ
이러한 로직이 있는데.
그렇다면 그룹 블로그에 접속했을 경우 글 목록이 보여야하는데, 이것은 코드로 어떻게 짤 수 있을까?
ㅤ
조회 쿼리에서 로직 빼기..?
findAll() 후 필터링하기
물론 조회 쿼리에서 로직을 빼는 가장 간단한 방법이 있다.
findAll()
로 모든 데이터를 불러온 후, 어플리케이션 단에서 필터링하는 것이다.
public List<Article> getGroupBlogArticles(Long requestUserId, Long groupId) {
List<Article> articles = articleRepository.findAll();
Group group = groupRepository.findById(groupId);
return articles.stream()
.filter(it -> group.isActive())
.filter(it -> it.isWrittenIn(group))
.filter(it -> it.isNotDeleted())
.filter(it -> {
if (it.isAuthor(requestUserId)) return true;
if (isMember(group, requestUserId)) return true;
return false;
})
.toList()
.pagination(start, size); // 이런 메소드는 없지만 있다고 하자
}
하지만.. 이런 짓을 하기에는 우리의 서버의 컴퓨팅 용량은 충분치 않다.
전체 게시글이 몇 개인데 이런 짓을 했다간 말아먹기 딱이다.
ㅤ
페이지네이션도 해야해
public List<Article> getGroupBlogArticles(Long requestUserId, Long groupId) {
List<Article> articles = articleRepository.findAllWithPage(start, size);
Group group = groupRepository.findById(groupId);
return articles.stream()
.filter(it -> group.isActive())
.filter(it -> it.isWrittenIn(group))
.filter(it -> it.isNotDeleted())
.filter(it -> {
if (it.isAuthor(requestUserId)) return true;
if (isMember(group, requestUserId)) return true;
return false;
})
.toList();
}
전체 데이터를 다 불러와서 어플리케이션 코드로 데이터를 조작하는 건 글렀다.
어차피 페이지네이션해야하는데, 해당 페이지의 데이터만 먼저 불러오고 어플리케이션에서 필터링할 수는 없을까?
ㅤ
당연히 안되죠? 1페이지의 글 30개를 불러왔는데 그 후 로직으로 필터링을 해서 전부 필터링 되어버린다면..
페이지를 조회했는데 아무 것도 안 나오는 기현상을 마주할 수 있을 것이다.
ㅤ
절충할 수 밖에 없다
결국 조회 메소드에선 애초에 DB에서 얻어오는 결과가, 로직이 전부 처리된 데이터여야 한다.
그렇다고 모든 것을 놓고 쌩 쿼리로 돌아가자는 말은 아니다. 절충할 줄 알아야 한다. 그렇다고 로직을 처리해서 DB에서 값을 얻어올 수 있는, 프로시저를 쓰자는 것도 아니다.
어떻게 어플리케이션 코드로 최대한 로직을 나타낼 수 있을까?
ㅤ
쿼리에서 로직 조금 빼기
일단 아까의 로직을 그냥 쌩 SQL로 나타내보자.
SELECT a.*
FROM article a
JOIN profile group ON a.blog_id = group.id
WHERE a.blog_id = :groupId
AND a.status = 'PUBLISHED'
AND group.is_deleted = false
AND (
# 그룹의 공개 글은 누구나 볼 수 있다.
a.is_public or
# 미로그인 유저는 비공개 글을 볼 수 없다
:userId IS NOT NULL or
# 해당 글을 작성한 작성자는 탈퇴했더라도 글을 볼 수 있다.
a.author_id = :userId or
# 그룹의 멤버는 비공개 글을 볼 수 있다.
:userId IN (
SELECT member_id
FROM group_member
WHERE group_id = :groupId
)
)
뭐 서브쿼리도 들어가고 복잡하다.. ㅋㅋ
회사의 비즈니스 로직급의 요구사항이 될 경우 더더욱 쿼리는 번잡해질 것이다.
조회에 필요한 몇 몇 부분은 쿼리를 구성하는 데에 필수적이겠지만, 그 중 필수적이지 않은 것들은 최대한 어플리케이션으로 일임하고 쿼리를 단순화하자.
ㅤ
삭제된 그룹의 글은 조회할 수 없다
삭제된 그룹의 글을 조회할 수는 없다는 것은 이 쿼리를 구성하는 데에 필수적이지 않은 것 같다.
미리 그룹을 조회하고, 삭제된 그룹이라면 에러를 미리 던지자.
그러면 WHERE문도 하나 빠지고, 그룹의 정보를 얻기 위해 JOIN할 필요도 없을 것 같다.
public List<Article> getGroupBlogArticles(Long requestUserId, Long groupId) {
Group group = groupRepository.findById(groupId);
if (!group.isActive()) {
throw new IllegalStateException("존재하지 않는 그룹입니다.");
}
return articleRepository.findFromGroupBlog(group, requestUserId);
}
SELECT a.*
FROM article a
WHERE a.blog_id = :groupId
AND a.status = 'PUBLISHED'
AND (
# 그룹의 공개 글은 누구나 볼 수 있다.
a.is_public or
# 미로그인 유저는 비공개 글을 볼 수 없다
:userId IS NOT NULL or
# 해당 글을 작성한 작성자는 탈퇴했더라도 글을 볼 수 있다.
a.author_id = :userId or
# 그룹의 멤버는 비공개 글을 볼 수 있다.
:userId IN (
SELECT member_id
FROM group_member
WHERE group_id = :groupId
)
)
ㅤ
그룹의 멤버는 비공개 글을 볼 수 있다.
그룹의 멤버는 비공개 글을 볼 수 있다는 것은 쿼리에서 빠지기 힘들어보인다.
그래도 해당 유저가 미로그인인지, 그룹의 멤버인지 체크하는 것은 어플리케이션에서 할 수 있을 것 같다.
public List<Article> getGroupBlogArticles(Long requestUserId, Long groupId) {
Group group = groupRepository.findById(groupId);
if (!group.isActive()) {
throw new IllegalStateException("존재하지 않는 그룹입니다.");
}
boolean isMember = isMember(group, requestUserId);
return articleRepository.findFromGroupBlog(group, isMember);
}
private boolean isMember(Group group, Long userId) {
if (userId == null) {
return false;
}
return groupMemberRepository.existsByGroupAndUserId(group, userId);
}
SELECT a.*
FROM article a
WHERE a.blog_id = :groupId
AND a.status = 'PUBLISHED'
AND (
# 그룹의 공개 글은 누구나 볼 수 있다.
a.is_public or
# 해당 글을 작성한 작성자는 탈퇴했더라도 글을 볼 수 있다.
a.author_id = :userId or
# 그룹의 멤버는 비공개 글을 볼 수 있다.
:isMember
)
ㅤ
최종 결과
# Before
SELECT a.*
FROM article a
JOIN profile group ON a.blog_id = group.id
WHERE a.blog_id = :groupId
AND a.status = 'PUBLISHED'
AND group.is_deleted = false
AND (
a.is_public or
:userId IS NOT NULL or
a.author_id = :userId or
:userId IN (
SELECT member_id
FROM group_member
WHERE group_id = :groupId
)
)
# After
SELECT a.*
FROM article a
WHERE a.blog_id = :groupId
AND a.status = 'PUBLISHED'
AND (
a.is_public or
a.author_id = :userId or
:isMember
)
ㅤ
정리
물론 쿼리에 로직을 전부 빼진 못했다.
하지만 좀 더 코드에 가독성이 생겼고, 비즈니스 로직을 수정할 때에도 훨씬 쉬워졌다.
ㅤ
조회에 로직을 모두 어플리케이션으로 뺄 수 없다는 단점을 보완하기 위해 QueryDSL과 같은 쿼리 빌더가 존재한다.
하지만 이것 또한 결국 도메인 레이어가 아니라 영속성 레이어에 비즈니스 로직이 들어간 것이라고 생각한다.
ㅤ
따라서 최대한 쿼리 쪽으로 들어가는 로직을 줄이고 이를 파라미터화 해서 외부에서 주입해줄 수 있는 쪽을 선택해보자.