본문 바로가기
Dev/DataBase

『DataBase』 Oracle SQL & MyBatis Mapper 부먹

by Day-T 2024. 12. 22.

복잡한 결재 시스템을 구축한다고 가정하고, Oracle SQL과 MyBatis Mapper를 조합해서 다양한 쿼리들을 구현해 보자.

간단한 예제 쿼리로는 복잡한 비즈니스 로직이 녹아있는 실무의 쿼리를 이해할 수 없기에 조금 복잡한 내용들을 다루어 보려고 한다.

 

우선 쿼리를 작성하려면 테이블이 존재해야 되기 때문에 아래와 같이 4개의 예제 테이블을 정의하겠다.

 

하나의 사용자는 여러 결재 요청을 생성할 수 있어 둘 사이에는 1대 N(One-to-Many) 관계가 성립한다.

 

동시에 결재 단계 테이블을 매개로 하나의 결재 요청은 여러 사용자에게 결재를 받을 수 있고, 한 사용자도 여러 결재 요청의 승인자가 될 수 있어 결재 단계와 사용자 사이에는 N대 N(Many-to-Many) 관계가 성립한다.

 

또한 사용자는 한 부서에 소속되므로 N대 1(Many-to-One) 관계이며, 부서자기 자신을 참조해 상위-하위 부서로 이루어지는 계층 구조를 가진다.

 

 

복잡한 예제

1. CASE, DECODE + 동적 조건 (if)

  • 결재 요청 상태를 CASE/DECODE로 변환
  • startDate, endDate, statusList(여러 상태 코드) 파라미터에 따라 동적으로 WHERE 절 조합
  • statusList는 <foreach>를 활용

 

Oracle SQL 문법/함수

   
CASE
  • CASE ... WHEN ... THEN ... END 구문을 사용하여 특정 조건을 만족할 때 문자열이나 값을 반환한다.
  • 여기서는 approval_status가 'P'일 땐 'Pending', 'C'일 땐 'Completed'로 변환한다.
  • CASE문은 가독성이 좋아서 여러 분기 처리를 직관적으로 표현할 때 유용하다.
DECODE
  • DECODE는 Oracle에서만 지원하는 함수로, CASE와 유사하지만 함축적으로 작성할 수 있다.
  • DECODE(a.approval_status, 'P', '대기중', 'C', '완료', 'R', '반려', '기타') 처럼 상태 코드에 맞춰 결과를 변환할 수 있다.
  • CASE에 비해 코드가 짧지만, 복잡한 로직은 CASE가 더 읽기 편할 수 있다.
BETWEEN ... AND + TO_DATE
  • a.created_at BETWEEN TO_DATE(#{startDate}, 'YYYY-MM-DD') AND TO_DATE(#{endDate}, 'YYYY-MM-DD')
  • 특정 날짜 범위(예: 2024-01-01 ~ 2024-01-31) 안에 있는지 확인한다.
  • TO_DATE 함수를 사용해서 문자열을 날짜 형식으로 변환한다.
WHERE 1=1
  • 흔히 동적 쿼리에서 사용되는 기법으로, 조건을 유연하게 추가하거나 제거할 때 쓰인다.
  • 뒤이어 <if> 문법으로 조건을 더할 때, AND 키워드 앞에 불필요한 구문오류가 생기지 않도록 하는 역할이다.

 

MyBatis 문법

   
<if test="startDate != null and endDate != null">
  • 파라미터에 startDate, endDate가 모두 있을 때만 해당 조건을 SQL에 추가한다.
  • 없으면 그 부분이 아예 제거되어 전체 기간 조회가 가능하다.
<if test="statusList != null and statusList.size() > 0"> + <foreach>
  • 상태 리스트(예: ['P','C'])가 존재하고 1개 이상이면 IN (...) 절을 동적으로 생성한다.
  • <foreach>로 컬렉션을 순회해 쉼표로 구분된 상태 코드 목록을 만든다.
parameterType="map", resultType="Approval"
  • 파라미터는 Map 형태(키-값)로 전달되며,
  • 쿼리 결과는 Java의 Approval 객체 리스트로 매핑된다는 뜻이다.

 

 

2. 계층구조(Connect By), LAG/LEAD, 동적 부서 필터

  • 부서 계층 구조를 조회
  • departmentId 파라미터가 있으면 해당 부서 이하 계층만 조회
  • LAG, LEAD 윈도우 함수로 이전/다음 부서명 참조

 

Oracle SQL 문법/함수

   
WITH ... AS (CTE: Common Table Expression)
  • WITH DeptHierarchy AS (...) 구문으로 서브쿼리를 미리 정의하고,
  • 아래에서 SELECT * FROM DeptHierarchy로 결과를 재활용한다.
  • 복잡한 쿼리를 단계적으로 작성할 수 있어 가독성이 좋아진다.
LEVEL
  • 계층형 쿼리(Connect By)에서 트리 깊이를 나타내는 의사 컬럼이다.
  • 루트 노드는 LEVEL=1, 그 자식은 LEVEL=2 식으로 표시된다.
CONNECT BY / START WITH
  • 테이블에 자기참조(부서 → 상위 부서) 구조가 있을 때 계층형 쿼리를 작성하는 문법이다.
  • START WITH d.parent_department_id IS NULL는 최상위 부서를 찾고,
  • CONNECT BY PRIOR d.department_id = d.parent_department_id는 상위 부서(부모)와 하위 부서(자식)를 연결한다.
윈도우 함수(LAG, LEAD)
  • LAG(col) OVER (ORDER BY ...)는 이전 행의 값을 가져오고,
  • LEAD(col) OVER (ORDER BY ...)는 다음 행의 값을 가져온다.
  • 여기서는 이전/다음 부서 이름을 미리 확인할 수 있게 한다.

 

MyBatis 문법

   
<if test="departmentId != null">
  • 파라미터에 departmentId가 주어지면 AND department_id = #{departmentId} 조건이 추가되어, 해당 부서만 필터링한다.
  • 부서 ID가 없으면 전체 계층 구조를 조회한다.
resultType="DepartmentHierarchy"
  • 결과를 매핑받을 DTO/VO 클래스(예: DepartmentHierarchy)가 있다고 가정한다.

 

 

3. 집계/분석: PIVOT, RANK + 동적 기간/부서 조건

  • 특정 기간 내 결재 건수를 부서별/월별 집계하고 PIVOT
  • RANK로 상위 부서 뽑기
  • minCount 파라미터로 특정 이상 건수인 부서만 조회
  • departmentIdList 파라미터가 있을 경우 해당 부서만 필터링

 

Oracle SQL 문법/함수

   
EXTRACT(MONTH FROM a.created_at)
  • 날짜 컬럼에서 ‘월’만 추출한다.
  • EXTRACT 함수는 YEAR, MONTH, DAY 등 특정 부분을 뽑을 수 있어 날짜 집계 시 유용하다.
GROUP BY
  • GROUP BY u.department_id, EXTRACT(MONTH FROM a.created_at)로 부서와 월별로 결재 건수를 합산.
  • COUNT(*) AS approval_count처럼 건수를 센다.
PIVOT
  • 행 형태로 누적된 데이터를 열(Column) 형태로 바꾸는 기능이다.
  • FOR month_num IN (1 AS "M1", 2 AS "M2", 3 AS "M3") 부분이 핵심.
  • 1월, 2월, 3월에 해당하는 값들을 각각 "M1", "M2", "M3"라는 열로 생성한다.
RANK() OVER (ORDER BY ...)
  • 윈도우 함수로, 정렬 기준(여기서는 M1+M2+M3 합계)에 따라 순위를 매긴다.
  • 합계가 높은 부서가 rank 1이 된다.
NVL
  • NVL(expr, default_value) : expr가 NULL이면 default_value를 사용한다.
  • 여기서는 "M1", "M2", "M3"가 NULL이면 0으로 바꿔 합산한다.

 

MyBatis 문법

   
<if test="startDate != null and endDate != null">
  • 기간이 주어지면 BETWEEN 조건을 추가해 그 기간만 조회한다.
<if test="departmentIdList != null and departmentIdList.size() > 0">
  • 여러 부서 ID가 전달되면, IN ( ... ) 구문을 생성한다.
  • 비어있으면 조건이 빠져서 전체 부서를 대상으로 한다.
<if test="minCount != null">
  • 최종 쿼리에서 M1+M2+M3 >= minCount 필터를 걸어, 일정 이상 건수가 있는 부서만 보여준다.

 

 

4. WITH 절(CTE), 서브쿼리 팩터링 + 동적 조건

  • 완료된 결재들만 추출하는 CTE
  • 사용자 정보와 JOIN 후 부서별 완료 건수 집계
  • completeOnly 파라미터가 true일 경우 완료 결재만 필터링
  • excludeDeptId 파라미터로 특정 부서 제외

 

Oracle SQL 문법/함수

   
WITH (CTE) 여러 단계
  • FilteredApprovals : 조건에 따라 승인완료(C)된 결재만 필터링.
  • JoinedData : 위에서 필터된 결재와 users 테이블 JOIN.
  • CTE를 연속적으로 선언하면, 여러 중간 데이터를 단계적으로 생성할 수 있다.
JOIN
  • FROM FilteredApprovals f JOIN users u ON f.requester_id = u.user_id
  • 결재 요청 정보와 사용자 정보를 합쳐서 한 번에 가져온다.
COUNT(*) + GROUP BY
  • 최종적으로 부서(department_id)별 결재 건수를 센다.
  • GROUP BY j.department_id로 부서별 합계를 낸다.

 

MyBatis 문법

   
<if test="completeOnly == true">
  • completeOnly 파라미터가 참일 때만 approval_status='C' 조건이 추가되어, 완료된 결재만 걸러낸다.
<if test="excludeDeptId != null">
  • 특정 부서를 제외하고 싶으면 j.department_id != #{excludeDeptId} 조건을 넣는다.
  • 파라미터가 null이면 조건이 없다.

 

 

5. 데이터 조합(UNION, INTERSECT, MINUS) + 동적 파라미터

  • 이번 달/지난달 승인 건을 UNION ALL로 결합
  • comparePrevious 파라미터가 true일 경우 지난달 데이터 포함, 아니면 이번 달만
  • approverId 파라미터로 특정 결재자를 포함하는 결재만 필터링(동적으로 if문 적용)

 

Oracle SQL 문법/함수

   
TRUNC(SYSDATE, 'MONTH')
  • 현재 날짜(SYSDATE)의 월 부분만 남기고 일/시/분/초를 1일 00:00:00로 만든다.
  • 예: 2024-03-15 14:20:00 → 2024-03-01 00:00:00.
  • 이번 달의 시작일을 구할 때 유용하다.
ADD_MONTHS(date, -1)
  • 인자로 받은 날짜에서 한 달을 뺀다.
  • ADD_MONTHS(TRUNC(SYSDATE, 'MONTH'), -1)는 지난 달 시작일이 된다.
UNION ALL
  • 두 SELECT 결과를 합친다(중복된 행도 그대로 포함).
  • 여기선 이번 달 건 + 지난 달 건을 합쳐서 비교하는 로직.
IN (SELECT ...)
  • 서브쿼리를 통해 특정 조건을 만족하는 approval_id 목록만 필터링.
  • 여기서는 approval_steps 테이블에서 approver_id가 일치하는 행만 추린다.

 

MyBatis 문법

   
<if test="comparePrevious == true"> ...

<if test="comparePrevious == false"> ...
  • 한 쿼리 안에서 두 가지 시나리오를 분기한다.
  • comparePrevious가 true면 이번 달 + 지난 달, false면 이번 달만 검색.
<if test="approverId != null">
  • 결재자 ID(approverId)가 주어지면 서브쿼리로 필터를 건다.
  • 파라미터가 null이면 추가 조건이 없다(모든 승인자 포함).

 

 

6. 문자열/날짜 처리: NVL/NVL2, REGEXP, EXTRACT + 동적 키워드 검색

  • 결재 제목에서 REGEXP_LIKE로 키워드 필터(keyword 파라미터)
  • NVL/NVL2로 NULL 처리
  • yearFilter 파라미터가 있으면 특정 연도만 필터
  • useNvl2 파라미터에 따라 NVL2 사용 여부 동적 적용

 

Oracle SQL 문법/함수

   
NVL2(expr, value_if_not_null, value_if_null)
  • expr가 NOT NULL이면 value_if_not_null, NULL이면 value_if_null 반환.
  • NVL2(a.approval_title, 'Title Exists', 'No Title') → approval_title이 있으면 "Title Exists", 없으면 "No Title".
DECODE
  • DECODE(a.approval_title, NULL, 'No Title', 'Title Exists')
  • approval_title이 NULL이면 'No Title', 그렇지 않으면 'Title Exists'를 반환.
REGEXP_LIKE
  • 정규 표현식을 사용해 문자열 일치를 검사. 'i'는 대소문자를 구분하지 않는다는 뜻.
  • REGEXP_LIKE(a.approval_title, #{keyword}, 'i') → approval_title에 keyword가 있으면 true.
EXTRACT(YEAR FROM a.created_at)
  • 날짜에서 연도만 추출.
  • EXTRACT(MONTH, EXTRACT(DAY 등으로 달, 일을 구할 수도 있다.
NVL((SELECT SUM(amount) ...), 0)
  • 서브쿼리에서 금액이 NULL이면 0으로 대체.
  • 결재 건에 budget 정보가 없으면 0을 반환한다.

 

MyBatis 문법

   
<if test="useNvl2 == true"> ... <if test="useNvl2 == false">
  • useNvl2 값에 따라 NVL2 또는 DECODE 중 하나를 사용한다.
  • 동적으로 SQL 구문을 바꿀 수 있어 유연한 로직 구현 가능.
<if test="keyword != null and keyword != ''">
  • 검색어(keyword)가 없으면 조건 미적용, 있으면 REGEXP_LIKE 추가.
<if test="yearFilter != null">
  • 특정 연도로 필터링하고 싶으면 EXTRACT(YEAR FROM a.created_at) = yearFilter 조건을 넣는다.
  • null이면 전체 연도 조회.