IT/개발지식

쿼리에서 비즈니스 로직을 걷어내자

김솔샤르 2022. 4. 17. 16:23

소스 코드를 보다보면 간혹 복잡하고 긴 쿼리를 맞딱뜨릴 일이 있다. 문제는 100라인이 넘어가는 긴 쿼리를 분석하고 수정해야 하는 경우다. 특히 쿼리가 어딘가 잘못되어 빠르게 고쳐야만 하는 상황이라면 눈앞이 아득하다.

 

아래 예시를 보자.

SELECT
      A.ID,
      A.NAME,
      B.ADDRESS,
      A.NVL(GRADE, ""),
      CASE WHEN A.GRADE >= 4 THEN "Y"
      	   ELSE "N" END AS SCHOLARSHIP_CANDIDATE,
      C.PROFESSOR,
      A.PHONE,
      A.COMMENT
  FROM
      STUDENT A,
      CAMPUS B,
      PROFESSOR C,
      LECTURE D,
      MAJOR E
  WHERE
    A.PROFESSOR_ID = C.ID
    AND A.SCHOOL_ID = B.ID
    AND A.MAJOR_ID = D.ID
    AND B.CAMPUS_CODE = "COE"
    AND A.GRADE >= 3
    AND A.MAJOR_ID = E.ID
    AND E.STUDENT_COUNT = (
                           SELECT
                                  MAX(STUDENT_COUNT)
                           FROM
                           	  MAJOR A, CAMPUS B
                           WHERE
                           	  A.CAMPUS_ID = B.ID
                             	  AND B.CAMPUS_CODE = "COE"
                        )             
  ORDER BY
      A.MAJOR_ID,
      A.NAME

한눈에 잘 들어오지 않는 긴 쿼리인데 사실 이정도는 예시로 만들어본 것이라 간결한 편이고 개인적으로 실무에서 수백라인을 넘어가는 쿼리를 수도 없이 목격했다. 쿼리가 길어지는 이유 중 하나는 비즈니스 로직을 쿼리에 내포했기 때문이다(불필요하게도). 하지만 쿼리의 비즈니스 로직을 서비스 코드로 이전시키고, 쿼리를 최대한 간결하게 유지하는 것이 많은 경우 이득이라고 생각한다.

 

분석의 용이성

예컨대 CASE 구문은 정말 끔찍하다. 그 자체가 비즈니스 로직을 내포하고 있기 때문이다. 예컨대 위 예제에서 보면 이런 구문이 쓰였다.

  CASE WHEN A.GRADE >= 4 THEN "Y"
       ELSE "N" END AS SCHOLARSHIP_CANDIDATE,

평점이 4점 이상인 학생을 장학생 후보로 선정하기 위해 "Y"로 라벨링을 하려는 목적인데 이 로직을 꼭 쿼리에서 처리해야할 이유는 없다. 아니, 그러면 안된다. 나중에 누군가 쿼리를 분석해야한다면 4라는 숫자에 내포된 의미를 파악하는데도 시간이 걸릴 수 있을뿐더러(4라는 평점은 학과 내에서의 기준일까? 아니면 학교 전체의 하한선일까?) 만약 로직이 변경된다면 쿼리를 수정해주어야 한다. 쿼리는 문법 체크와 자동 완성 등 에디터의 편의 기능을 이용하기도 어려울뿐더러 검증 비용 또한 크다.

 

이 로직을 서비스 레벨의 코드로 이전하면 훨씬 더 읽기 쉬워진다. 낯간지러워서 좋아하는 표현은 아니지만 보다 우아하고 아름다워진다. 자바로 작성해본다면 아래와 같이 쓸 수 있을 것 같다.

enum ScholarshipBase{
    MAJOR_MIN_BASE(4.0),
    SCHOOL_MIN_BASE(3.0);
	
    @Getter
    private double grade;

    ScholarshipBase(double grade){
		this.grade = grade;
    }
}

class ScholarshipService{
    public List<Student> getScholarshipCandidates(List<Student> students){
        	return students.stream()
                       	 	.filter(student -> student.getGrade() >= ScholarshipBase.MAJOR_MIN_BASE.getGrade())
                        	.collect(Collectors.toList());
    }
}

장학생 선정 기준 평점에 대해서 의미를 분명히 하기 위해 Enum을 사용했고, 전체 학생중에 해당 기준을 넘기는 학생을 stream에서 필터링하였다. 이 자바 코드는 나중에 분석하게 될 일이 생기더라도 훨씬 쉽고 빠르게 파악이 가능하다. 여기서 더 보기좋고 아름답게 리팩토링할 수도 있다. 아무튼 끔찍한 CASE 구문을 쿼리에서 제거할 수 있게 되었다.


NVL 구문 역시 끔찍하다. 자바에서 Optional을 활용하는 편이 낫다고 생각한다. 문자열을 파싱하거나, 숫자(Numeric) 데이터를 가공(Sum, Max) 하는 등 쿼리에서 이루어지는 많은 행위들을 다 서비스 코드 레벨로 이전시키자. 불필요하고 복잡한 쿼리 구문을 간결하고 아름다운 코드로 대체할 수 있다. 

 

테스트의 용이성

로직 개선 등의 이유로 쿼리를 수정 했을때 수정된 쿼리를 검증을 위해서는 쿼리를 실행시켜보는 방법밖에 없다. 이는 비용이 많이 드는 일이다. 모든 케이스를 검증하기 위한 데이터를 준비해야 하기 때문이다. 쿼리에서 많은 테이블을 join하는 경우는 더 그렇다. 또, in-memory DB와 junit 테스트 자동화 도구 등을 이용한다고 하더라도 쿼리를 실제로 돌려보기 위해서는 기본적으로 시간이 오래 소요되기도 한다.

 

하지만 비즈니스 로직이 서비스 코드에 있다면 해당 로직만을 검증하는 단위 테스트를 작성하면 된다. 위 예제에서 장학생 선정 기준이 변경된다고 하더라도 오직 getScholarshipCandidates 메서드만을 검증하면 되는 것이다. 여러 테스트 케이스를 작성하여 테스트를 돌려보고, 코드를 수정하고, 다시 테스트를 돌려보는 행위를 빠르게 반복하여 개발 시간을 단축할 수 있다.

 

이렇듯 쿼리의 비즈니스 로직을 서비스 레벨로 이전시키는 것만으로 큰 이점을 얻을 수 있다. 다시 말하지만 쿼리를 수정하는 것은 비용이 큰 일이다. 쿼리는 최소한의 수정만이 일어날 수 있도록 Stable하게 유지하고, 가능하면 모든 비즈니스 로직을 서비스 코드로 풀어내자. 그 편이 더 읽기 쉽고, 수정 및 테스트가 용이하다.

 

예외적인 상황

물론 항상 그렇다는 것은 아니다. 성능적인 이유로 불가피하게 쿼리가 길어져야 하는 경우는 물론 있다. 대표적으로 join 쿼리인데, 서비스 코드에서 merge를 하면 더 명확하고 우아하게 의미를 담은 코드를 작성할 수 있으나 성능적인 이유로 DB 레벨에서 join을 수행해야 하는 경우가 분명히 있다.

 

또, 나는 경험해보지 못했지만 몇 마이크로초를 다투는 성능에 민감한 서비스들 역시 불가피하게 DB 레벨에서 많은 일을 수행해야할 수도 있을 것 같다. 하지만 말그대로 예외적인 상황일뿐이다. 

 

- 끝 -

반응형