• Feed
  • Explore
  • Ranking
/
/
    🖥 백엔드

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

    풍부한 비즈니스 레이어를 향하여
    SpringBackendJavaArchitecture
    O
    Octoping
    2024.11.11
    ·
    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과 같은 쿼리 빌더가 존재한다.

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

    ㅤ

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







    - 컬렉션 아티클