avatar
Octoping Blog

'조회' 쿼리에서 로직 빼기

풍부한 비즈니스 레이어를 향하여
SpringBackendJavaArchitecture
a month ago
·
14 min read

쿼리에서 로직 빼기

내가 좋아하는 최범균님의 영상 중에서, 처음 보고 제일 충격을 받았던 영상이다.

영상을 다 보면 물론 좋겠지만, 시간 단축을 위해 핵심만 짚어본다.

쿼리에 로직이 들어간 코드

// 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;
    }
}

이렇게 하면 회원 탈퇴에 대한 로직이 어플리케이션 단으로 옮겨지므로 아까의 단점이 해소된다.

코드의 가독성도 올라가고, 상태 변경 기능과 관련된 쿼리가 단순해진다.

그러니 쿼리에서 로직을 빼라는 것이다.

그래서, 이걸로 모든 것이 해결됐나요?

2075

명령(Command)에 대한 로직은 쿼리에서 빼내서 어플리케이션 단으로 옮기라는 것. 완벽히 이해했어!

그렇다면 조회(Query)는 어떨까?

조회에는 로직이 없는가?

조회에도 당연히 로직이 있다. 언틸의 예제를 가져와보자.

언틸에는 그룹 블로그가 있다. 토스, 배민 같은 팀 블로그를 모든 유저가 손쉽고 자유롭게 만들 수 있고, 같이 블로그를 꾸미고 싶은 유저를 초대할 수도 있다. (많이 써주세요)

아무튼.. 그리고 언틸에는 비공개 글을 작성할 수 있는 로직이 있는데.

언틸 글 공개 로직을 공개합니다

그룹 블로그에서 글을 조회하는 로직이 어떻게 될까?

  1. 삭제된 그룹의 글은 조회할 수 없다

  2. 삭제된 글은 아무도 볼 수 없다.

  3. 그룹의 공개 글은 누구나 볼 수 있다.

  4. 그룹의 멤버는 비공개 글을 볼 수 있다.

  5. 그룹에 속해있지 않은 유저는 비공개 글을 볼 수 없다.

  6. 미로그인 유저도 비공개 글을 볼 수 없다.

  7. 해당 비공개 글을 작성한 작성자는 탈퇴했더라도 글을 볼 수 있다.

이러한 로직이 있는데.

그렇다면 그룹 블로그에 접속했을 경우 글 목록이 보여야하는데, 이것은 코드로 어떻게 짤 수 있을까?

조회 쿼리에서 로직 빼기..?

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과 같은 쿼리 빌더가 존재한다.

하지만 이것 또한 결국 도메인 레이어가 아니라 영속성 레이어에 비즈니스 로직이 들어간 것이라고 생각한다.

따라서 최대한 쿼리 쪽으로 들어가는 로직을 줄이고 이를 파라미터화 해서 외부에서 주입해줄 수 있는 쪽을 선택해보자.


- 컬렉션 아티클






반갑습니다 😄