본문 바로가기
CS/SQL

결재 검증 SQL의 실행 흐름 개선 (EXISTS, Short-Circuit 적용)

by HWK 2026. 2. 3.

📌 업무 배경

  • 공통된 컬럼을 사용하는 12개의 업무 테이블이 존재
    • 테이블: APP_A_MST ~ APP_L_MST
    • 공통컬럼: doc_id, biz_year, biz_quarter, biz_round
  • 각 업무 결재가 완료되면, 다른 업무들의 상태가 결재완료인지 조회, 다음 과정을 수행하는데 필요한 데이터를 생성해야 함
  • 결재가 완료시 결재문서의 doc_id가 반환됨
  • 특정 문서(doc_id)가 어느 테이블에 속해 있는지 모름
  • 해당 문서가 속한 연도 / 분기 / 차수를 기준으로
    👉 모든 업무 테이블에서 결재 완료 상태인지 검증
  • APP_A_MST ~ APP_L_MST 가 아닌, 하나의 테이블을 사용하면 좋겠지만, 그럴 수 없는 상황

1️⃣ 1차 버전 – UNION ALL 기반 전체 스캔

🔧 접근 방식

  • Dynamic SQL을 사용하여 12개 테이블을 UNION ALL 로 모두 합침
  • 각 테이블별 결재 완료 여부를 Y/N으로 산출
  • MIN(passyn) 으로 최종 결과 도출
더보기
DECLARE @doc_id NVARCHAR(40) = :doc_id;

DECLARE @biz_year    NVARCHAR(10);
DECLARE @biz_quarter NVARCHAR(10);
DECLARE @biz_round   NVARCHAR(10);

DECLARE @i INT = 1;
DECLARE @tbl NVARCHAR(50);
DECLARE @sql NVARCHAR(MAX);

-- 1. doc_id가 속한 테이블에서 기준 값 찾기
WHILE @i <= 12
BEGIN
	SET @tbl = 'APP_' + CHAR(64 + @i) + '_MST';

	SET @sql = '
		SELECT
			@by = biz_year,
			@bq = biz_quarter,
			@br = biz_round
		FROM ' + @tbl + '
		WHERE doc_id = ''' + @doc_id + '''
	';

	EXEC sp_executesql
		@sql,
		N'@by NVARCHAR(10) OUTPUT, @bq NVARCHAR(10) OUTPUT, @br NVARCHAR(10) OUTPUT',
		@by = @biz_year OUTPUT,
		@bq = @biz_quarter OUTPUT,
		@br = @biz_round OUTPUT;

	IF (@biz_year IS NOT NULL) BREAK;
	SET @i += 1;
END;

-- 2. 결재 완료 여부 전체 검사
DECLARE @unionSql NVARCHAR(MAX) = '';
DECLARE @j INT = 1;

WHILE @j <= 12
BEGIN
	SET @tbl = 'APP_' + CHAR(64 + @j) + '_MST';

	SET @unionSql += '
		SELECT
			CASE WHEN COUNT(*) > 0 THEN ''Y'' ELSE ''N'' END AS pass_yn
		FROM ' + @tbl + ' T
		INNER JOIN DOC_MASTER D ON D.doc_id = T.doc_id
		WHERE D.del_yn = ''N''
		  AND D.approval_status = ''APPROVED''
		  AND T.biz_year = ''' + @biz_year + '''
		  AND T.biz_quarter = ''' + @biz_quarter + '''
		  AND T.biz_round = ''' + @biz_round + '''
	';

	IF @j < 12 SET @unionSql += ' UNION ALL ';
	SET @j += 1;
END;

-- 3. 이관 테이블 중복 체크
SET @unionSql += '
	UNION ALL
	SELECT
		CASE WHEN COUNT(*) = 0 THEN ''Y'' ELSE ''N'' END
	FROM MOVE_MST M
	INNER JOIN DOC_MASTER D ON D.doc_id = M.doc_id
	WHERE D.del_yn = ''N''
	  AND M.biz_year = ''' + @biz_year + '''
	  AND M.biz_quarter = ''' + @biz_quarter + '''
	  AND M.biz_round = ''' + @biz_round + '''
';

-- 4. 최종 결과
EXEC ('
	SELECT
		''' + @biz_year + '''    AS biz_year,
		''' + @biz_quarter + ''' AS biz_quarter,
		''' + @biz_round + '''   AS biz_round,
		MIN(pass_yn)             AS pass_yn
	FROM (' + @unionSql + ') X
');

⚠️ 문제점

불필요한 전체 스캔

  • 이미 하나라도 실패가 확정돼도 끝까지 실행됨

동적 SQL 문자열 직접 결합

  • SQL Injection 위험
  • 유지보수 난이도 높음

2️⃣ 2차 버전 – EXISTS 논리식으로 개선

🔧 접근 방식

  • UNION ALL 제거
  • EXISTS + AND 조건으로 논리 평가
더보기
DECLARE @doc_id NVARCHAR(40) = :doc_id;

DECLARE @biz_year    NVARCHAR(10);
DECLARE @biz_quarter NVARCHAR(10);
DECLARE @biz_round   NVARCHAR(10);

DECLARE @i INT = 1;
DECLARE @tbl NVARCHAR(50);
DECLARE @sql NVARCHAR(MAX);

-- 1. doc_id가 속한 테이블에서 기준 값 찾기
WHILE @i <= 12
BEGIN
	SET @tbl = 'APP_' + CHAR(64 + @i) + '_MST';

	SET @sql = '
		SELECT
			@by = biz_year,
			@bq = biz_quarter,
			@br = biz_round
		FROM ' + @tbl + '
		WHERE doc_id = ''' + @doc_id + '''
	';

	EXEC sp_executesql
		@sql,
		N'@by NVARCHAR(10) OUTPUT, @bq NVARCHAR(10) OUTPUT, @br NVARCHAR(10) OUTPUT',
		@by = @biz_year OUTPUT,
		@bq = @biz_quarter OUTPUT,
		@br = @biz_round OUTPUT;

	IF (@biz_year IS NOT NULL) BREAK;
	SET @i += 1;
END;

DECLARE @result CHAR(1);
DECLARE @sql NVARCHAR(MAX) = 'SELECT @r = CASE WHEN ';
DECLARE @j INT = 1;
DECLARE @tbl NVARCHAR(50);

WHILE @j <= 12
BEGIN
	SET @tbl = 'APP_' + CHAR(64 + @j) + '_MST';

	SET @sql += '
		EXISTS (
			SELECT 1
			FROM ' + @tbl + ' T
			INNER JOIN DOC_MASTER D ON D.doc_id = T.doc_id
			WHERE D.del_yn = ''N''
			  AND D.approval_status = ''APPROVED''
			  AND T.biz_year = ''' + @biz_year + '''
			  AND T.biz_quarter = ''' + @biz_quarter + '''
			  AND T.biz_round = ''' + @biz_round + '''
		)
	';

	IF @j < 12 SET @sql += ' AND ';
	SET @j += 1;
END;

-- 이관 테이블 중복 방지
SET @sql += '
	AND NOT EXISTS (
		SELECT 1
		FROM MOVE_MST M
		INNER JOIN DOC_MASTER D ON D.doc_id = M.doc_id
		WHERE D.del_yn = ''N''
		  AND M.biz_year = ''' + @biz_year + '''
		  AND M.biz_quarter = ''' + @biz_quarter + '''
		  AND M.biz_round = ''' + @biz_round + '''
	)
	THEN ''Y'' ELSE ''N'' END
';

EXEC sp_executesql
	@sql,
	N'@r CHAR(1) OUTPUT',
	@r = @result OUTPUT;

SELECT @biz_year, @biz_quarter, @biz_round, @result AS pass_yn;

✅ 개선점

✔️ 불필요한 결과셋 제거

  • COUNT, UNION 제거 → 메모리 사용 감소

⚠️ 문제점

 동적 SQL 문자열 직접 결합

  • SQL Injection 위험
  • 유지보수 난이도 높음

단락 평가 불가

  • SQL 특성상 모든 EXISTS가 평가됨

3️⃣ 3차(최종) – 단락 평가 + 파라미터화 + 조기 종료

🔧 접근 방식

  • 하나라도 실패하면 즉시 종료
  • SQL Injection 방지
더보기
DECLARE @doc_id NVARCHAR(40) = :doc_id;

DECLARE @biz_year    NVARCHAR(10);
DECLARE @biz_quarter NVARCHAR(10);
DECLARE @biz_round   NVARCHAR(10);

DECLARE @i INT = 1;
DECLARE @tbl NVARCHAR(50);
DECLARE @sql NVARCHAR(MAX);

-- 1. doc_id가 속한 테이블에서 기준 값 찾기
WHILE @i <= 12
BEGIN
	SET @tbl = 'APP_' + CHAR(64 + @i) + '_MST';

	SET @sql = '
		SELECT
			@by = biz_year,
			@bq = biz_quarter,
			@br = biz_round
		FROM ' + @tbl + '
		WHERE doc_id = ''' + @doc_id + '''
	';

	EXEC sp_executesql
		@sql,
		N'@by NVARCHAR(10) OUTPUT, @bq NVARCHAR(10) OUTPUT, @br NVARCHAR(10) OUTPUT',
		@by = @biz_year OUTPUT,
		@bq = @biz_quarter OUTPUT,
		@br = @biz_round OUTPUT;

	IF (@biz_year IS NOT NULL) BREAK;
	SET @i += 1;
END;

DECLARE @pass_yn CHAR(1) = 'Y';
DECLARE @j INT = 1;
DECLARE @tbl SYSNAME;
DECLARE @sql NVARCHAR(MAX);

WHILE @j <= 12
BEGIN
	SET @tbl = 'APP_' + CHAR(64 + @j) + '_MST';

	SET @sql = '
		IF NOT EXISTS (
			SELECT 1
			FROM ' + QUOTENAME(@tbl) + ' T
			INNER JOIN DOC_MASTER D ON D.doc_id = T.doc_id
			WHERE D.del_yn = ''N''
			  AND D.approval_status = ''APPROVED''
			  AND T.biz_year = @y
			  AND T.biz_quarter = @q
			  AND T.biz_round = @r
		)
		SET @p = ''N''
	';

	EXEC sp_executesql
		@sql,
		N'@y NVARCHAR(10), @q NVARCHAR(10), @r NVARCHAR(10), @p CHAR(1) OUTPUT',
		@y = @biz_year,
		@q = @biz_quarter,
		@r = @biz_round,
		@p = @pass_yn OUTPUT;

	IF @pass_yn = 'N' BREAK;
	SET @j += 1;
END;

-- 이관 테이블 중복 체크
IF @pass_yn = 'Y'
BEGIN
	SET @sql = '
		IF EXISTS (
			SELECT 1
			FROM MOVE_MST M
			INNER JOIN DOC_MASTER D ON D.doc_id = M.doc_id
			WHERE D.del_yn = ''N''
			  AND M.biz_year = @y
			  AND M.biz_quarter = @q
			  AND M.biz_round = @r
		)
		SET @p = ''N''
	';

	EXEC sp_executesql
		@sql,
		N'@y NVARCHAR(10), @q NVARCHAR(10), @r NVARCHAR(10), @p CHAR(1) OUTPUT',
		@y = @biz_year,
		@q = @biz_quarter,
		@r = @biz_round,
		@p = @pass_yn OUTPUT;
END;

SELECT
	@biz_year    AS biz_year,
	@biz_quarter AS biz_quarter,
	@biz_round   AS biz_round,
	@pass_yn     AS pass_yn;

 


🔥 핵심 개선 포인트

조기 종료 (Short-circuit Evaluation)

IF NOT EXISTS (...)
	SET @pass_yn = 'N'
	BREAK;

✔️ 하나라도 결재 미완료 → 즉시 루프 종료

✔️ 최악의 경우 12회 → 평균 2~3회 검사

📉 실행 시간 대폭 감소

 

sp_executesql + 파라미터화

AND T.biz_year = @y 
AND T.biz_quarter = @q 
AND T.biz_round = @r

✔️ SQL Injection 방지
✔️ 실행 계획 재사용 가능
✔️ 문자열 결합 최소화

 

QUOTENAME 사용

FROM + QUOTENAME(@targetTable)

✔️ 테이블명 주입 공격 차단
✔️ 동적 SQL 보안 강화


🧠 실행계획 관점에서 본 최종 쿼리 구조

처리 흐름 요약

  • doc_id가 속한 업무 테이블 탐색
  • 기준 값(biz_year, biz_quarter, biz_round) 확보
  • 12개 업무 테이블을 순차적으로 EXISTS 검증
  • 승인 완료 데이터가 없으면 즉시 종료(Short-circuit)
  • 이관 테이블 중복 여부 확인
  • 최종 Y / N 반환

인덱스 구성 및 실행계획 고려 사항

  • DOC_MASTER 테이블은 이미 PK 기반 인덱스가 적용되어 있음
  • APP_A_MST ~ APP_L_MST 테이블에는 기존에 (doc_id) 인덱스가 존재
  • 본 쿼리에서는 biz_year, biz_quarter, biz_round 조건으로 접근하므로
    해당 컬럼 조합에 대한 인덱스를 추가하여 Index Seek 기반 실행계획을 유도

⭐ 최종 버전의 장점

✅ 성능

  • 불필요한 테이블 접근 제거
  • 조기 종료로 평균 실행 시간 최소화

✅ 보안

  • 파라미터화
  • QUOTENAME 적용
  • 문자열 직접 결합 최소화

✅ 유지보수성

  • 비즈니스 규칙이 명확
  • 조건 추가/변경이 쉬움
  • Dynamic SQL 치고는 디버깅 포인트 명확

✍️ 마무리

아래는 성능개선표이다.

구분 1차 2차 3차
UNION 사용 O X X
결과셋 생성 O X X
조기 종료 X X O
파라미터화 X O
평균 테이블 접근 수 12 12 2~3

 

이번 개선을 통해

SQL에서도 “끝까지 계산하지 않아도 되는 구조”가
성능과 안정성에 얼마나 큰 영향을 주는지 체감할 수 있었다.

'CS > SQL' 카테고리의 다른 글

MSSQL - 행마다 새로운 시퀀스 부여  (0) 2026.01.28
Index Merge  (0) 2026.01.06
Covering Index (커버링 인덱스)  (0) 2026.01.06
인덱스 설계 사고방식 2  (0) 2026.01.06
인덱스 설계 사고방식 1  (0) 2026.01.06