사고번호 하나 입력했을 뿐인데 계약정보·피보험자·지급이력이 한 화면에 쏟아진다
그 뒤에서 무슨 일이 벌어지고 있는가
회사 업무 시스템에 처음 들어가면 이런 생각을 한다. "이 화면에 정보가 저장되어 있겠구나." 틀렸다. 화면은 단지 View(뷰)일 뿐이다. 정보는 전혀 다른 곳, 데이터베이스(DB) 안에 테이블로 쌓여 있다.
보험사 직원이 사고 접수 화면을 열고 사고번호를 입력한 뒤 조회 버튼을 누른다. 그 순간 어떤 일이 일어날까?
| 증권번호 | 상품명 | 계약일 | 보험료 | 상태 |
|---|---|---|---|---|
| POL-20210831-002 | 무배당 실손의료비보험 | 2021-08-31 | 72,300원 | 유효 |
| 성명 | 생년월일 | 관계 | 연락처 |
|---|---|---|---|
| 김수현 | 1988-04-12 | 피보험자 본인 | 010-****-3847 |
| 지급일 | 지급사유 | 지급금액 | 담당자 |
|---|---|---|---|
| 2023-02-14 | 입원의료비 (맹장수술) | 1,240,000원 | 이담당 |
| 2024-01-07 | 통원의료비 | 48,500원 | 박사원 |
직원 눈에는 하나의 화면이지만, 그 뒤에서는 최소 3~5개 테이블을 동시에 끌어오는 SQL이 돌아간다. 계약 테이블, 피보험자 테이블, 지급 이력 테이블... 각각 따로 존재하는 데이터를 사고번호 하나로 엮어서 한 화면에 뿌려주는 것이다.
아까 그 한 화면을 만들기 위해 DB 안에는 이런 테이블들이 따로 존재한다. 각 테이블은 자기 담당 데이터만 가지고 있다.
PK (Primary Key)는 그 테이블에서 행을 구분하는 고유 식별자다. 주민번호처럼 절대 중복되지 않는다. FK (Foreign Key)는 다른 테이블의 PK를 참조하는 연결고리다. 이 FK들이 바로 JOIN의 재료가 된다.
쪼개진 테이블을 화면에 하나로 보여주려면 다시 합쳐야 한다. 이때 쓰는 게 JOIN이다. 공통 키(PK ↔ FK)를 기준으로 여러 테이블의 행을 옆으로 붙이는 연산이다.
아까 그 업무화면을 만드는 SQL은 실제로 이런 모양이다:
-- 사고번호 하나로 화면 전체 데이터를 끌어오는 쿼리 SELECT -- 계약 정보 P.POL_NO, P.PROD_CODE, P.CONT_DATE, P.PREMIUM, P.POL_STATUS, -- 피보험자 정보 C.CUST_NM, C.BIRTH_DT, C.PHONE, -- 사고 정보 A.ACC_DATE, A.ACC_TYPE FROM ACCIDENT A JOIN POLICY P ON A.POL_NO = P.POL_NO JOIN CUSTOMER C ON P.CUST_ID = C.CUST_ID WHERE A.ACC_NO = 'ACC-2024-003847'
지급 이력은 한 사고에 여러 건이 붙기 때문에 별도로 조회한다:
-- 지급 이력 목록 조회 SELECT CP.PAY_DT, CP.PAY_RSN, CP.PAY_AMT, E.EMP_NM AS HANDLER_NM FROM CLAIM_PAY CP JOIN EMPLOYEE E ON CP.HANDLER_ID = E.EMP_ID WHERE CP.ACC_NO = 'ACC-2024-003847' ORDER BY CP.PAY_DT DESC
ER 모델(Entity-Relationship Model)은 테이블들이 어떻게 연결되어 있는지 그림으로 보여주는 설계도다. 건물의 도면처럼, DB를 처음 설계할 때 또는 기존 구조를 파악할 때 쓴다.
ER 다이어그램에서 가장 중요한 건 1:N 관계다. 고객 1명이 계약 N개를 가질 수 있다. 계약 1개에 사고 N건이 생길 수 있다. 사고 1건에 지급이 N번 이루어질 수 있다. 이 관계가 FK가 어느 테이블에 들어가는지를 결정한다.
ER 모델도 없고, 문서도 없다. 그래도 원하는 데이터를 뽑아야 한다. 그럴 때 쓰는 게 화면 → SQL 역추적이다. 업무 화면에 이미 뿌려지고 있는 SQL을 찾아서, 거기서 테이블 구조를 파악하는 방법이다.
회사 시스템은 어딘가에 SQL을 저장해 둔다. 매퍼 파일(MyBatis의 .xml), 프로시저(Stored Procedure), DAO 클래스, 또는 쿼리 관리 파일. 화면 이름으로 검색하면 대부분 찾을 수 있다.
FROM 뒤, JOIN 뒤에 나오는 단어들이 모두 테이블명이다. 별칭(Alias)이 붙어 있으면 AS 앞의 단어가 진짜 테이블명이다. 이것만 모아도 해당 화면의 데이터 구조가 절반은 보인다.
SELECT * FROM 테이블명 WHERE ROWNUM <= 10 으로 10행만 뽑아보자. 컬럼명과 데이터 형태를 보면 이 테이블이 무엇을 담당하는지 바로 보인다. 날짜 컬럼이 많으면 이력 테이블, 코드 컬럼이 많으면 코드 테이블이다.
JOIN A ON B.COL = A.COL 패턴에서 등호(=) 양쪽을 보면 어떤 컬럼이 두 테이블을 연결하는지 나온다. 이게 FK다. 이 관계들을 메모해두면 나만의 미니 ER 다이어그램이 완성된다.
구조를 파악했으면 WHERE 절에 조건을 하나씩 바꿔가며 원하는 데이터가 나오는지 확인한다. 화면에서 보이는 특정 값을 넣어보면 어떤 컬럼에 그 값이 들어 있는지 바로 확인된다.
화면에는 보이지 않지만 DB에서는 뽑을 수 있는 정보들이 있다. 업무화면은 담당자 한 명의 데이터를 보여주지만, SQL을 직접 쓰면 전체 집계, 기간별 추이, 이상 패턴 등을 한 번에 뽑을 수 있다.
-- 2024년 지급금액이 500만원 이상인 사고 현황 SELECT A.ACC_NO, C.CUST_NM, P.PROD_CODE, SUM(CP.PAY_AMT) AS TOTAL_AMT, COUNT(CP.PAY_SEQ) AS PAY_CNT FROM ACCIDENT A JOIN POLICY P ON A.POL_NO = P.POL_NO JOIN CUSTOMER C ON P.CUST_ID = C.CUST_ID LEFT JOIN CLAIM_PAY CP ON A.ACC_NO = CP.ACC_NO WHERE CP.PAY_DT BETWEEN '2024-01-01' AND '2024-12-31' GROUP BY A.ACC_NO, C.CUST_NM, P.PROD_CODE HAVING SUM(CP.PAY_AMT) >= 5000000 ORDER BY TOTAL_AMT DESC
업무화면에서는 사고번호 하나를 직접 입력해야 정보가 나온다. 하지만 SQL을 알면 "2024년에 500만 원 넘게 지급된 사고를 전부 뽑아라" 같은 분석 쿼리를 직접 만들 수 있다. 화면이 허용하는 것 이상을 볼 수 있는 것이다.
화면은 비즈니스의 얼굴이고, SQL은 비즈니스의 언어다.
화면만 보던 사람이 SQL을 읽기 시작하는 순간,
시스템 전체가 투명하게 보이기 시작한다.
서브쿼리는 SQL 안에 또 다른 SQL을 넣는 것이다. 한 번의 쿼리로 표현하기 어려운 복잡한 조건을 단계적으로 풀어낼 수 있다.
-- 평균 보험료보다 높은 계약자 목록 SELECT C.CUST_NM, P.PREMIUM FROM POLICY P JOIN CUSTOMER C ON P.CUST_ID = C.CUST_ID WHERE P.PREMIUM > ( SELECT AVG(PREMIUM) FROM POLICY WHERE POL_STATUS = '유효' )
-- 2024년에 한 번이라도 사고가 난 계약의 계약자 조회 SELECT CUST_NM, PHONE FROM CUSTOMER WHERE CUST_ID IN ( SELECT DISTINCT P.CUST_ID FROM POLICY P JOIN ACCIDENT A ON P.POL_NO = A.POL_NO WHERE A.ACC_DATE BETWEEN '2024-01-01' AND '2024-12-31' )
-- 고객별 총 지급액을 먼저 구한 뒤, 상위 10명만 뽑기 SELECT * FROM ( SELECT C.CUST_NM, SUM(CP.PAY_AMT) AS TOTAL_AMT FROM CUSTOMER C JOIN POLICY P ON C.CUST_ID = P.CUST_ID JOIN ACCIDENT A ON P.POL_NO = A.POL_NO JOIN CLAIM_PAY CP ON A.ACC_NO = CP.ACC_NO GROUP BY C.CUST_NM ) SUMMARY WHERE TOTAL_AMT >= 10000000 ORDER BY TOTAL_AMT DESC FETCH FIRST 10 ROWS ONLY -- Oracle / LIMIT 10 (MySQL)
WITH 절로 이름을 붙이면 여러 번 참조할 수 있고 읽기도 쉬워진다.
WITH CUST_SUMMARY AS ( SELECT C.CUST_ID, C.CUST_NM, SUM(CP.PAY_AMT) AS TOTAL_AMT, COUNT(A.ACC_NO) AS ACC_CNT FROM CUSTOMER C JOIN POLICY P ON C.CUST_ID = P.CUST_ID JOIN ACCIDENT A ON P.POL_NO = A.POL_NO JOIN CLAIM_PAYCP ON A.ACC_NO = CP.ACC_NO GROUP BY C.CUST_ID, C.CUST_NM ) -- CTE를 두 번 참조 SELECT CUST_NM, TOTAL_AMT, ACC_CNT, TOTAL_AMT / ACC_CNT AS AVG_PER_ACC FROM CUST_SUMMARY WHERE ACC_CNT >= 2 ORDER BY TOTAL_AMT DESC
데이터를 뽑을 때 단순 조회에서 끝나지 않고, 조건에 따라 값을 다르게 표시하거나 분류해야 할 때가 많다. CASE WHEN이 그 역할을 한다.
-- 지급금액에 따라 등급 부여 SELECT A.ACC_NO, SUM(CP.PAY_AMT) AS TOTAL_PAY, CASE WHEN SUM(CP.PAY_AMT) >= 10000000 THEN '대형사고' WHEN SUM(CP.PAY_AMT) >= 3000000 THEN '중형사고' WHEN SUM(CP.PAY_AMT) > 0 THEN '소형사고' ELSE '지급없음' END AS ACC_GRADE FROM ACCIDENT A LEFT JOIN CLAIM_PAY CP ON A.ACC_NO = CP.ACC_NO GROUP BY A.ACC_NO
-- 월별 사고건수를 가로로 펼치기 (피벗) SELECT TO_CHAR(ACC_DATE, 'YYYY') AS YEAR, SUM(CASE WHEN TO_CHAR(ACC_DATE,'MM')='01' THEN 1 ELSE 0 END) AS JAN, SUM(CASE WHEN TO_CHAR(ACC_DATE,'MM')='02' THEN 1 ELSE 0 END) AS FEB, SUM(CASE WHEN TO_CHAR(ACC_DATE,'MM')='03' THEN 1 ELSE 0 END) AS MAR, -- ... 12월까지 반복 COUNT(*) AS TOTAL FROM ACCIDENT GROUP BY TO_CHAR(ACC_DATE, 'YYYY') ORDER BY YEAR
ORDER BY CASE WHEN STATUS='처리중' THEN 1 WHEN STATUS='완료' THEN 2 ELSE 3 ENDGROUP BY로 집계하면 행이 줄어든다. 하지만 윈도우 함수는 집계 결과를 원래 행에 붙인 채로 유지한다. 순위, 누적합, 전월 대비 등 분석에서 가장 많이 쓰이는 기법이다.
함수명() OVER (PARTITION BY 그룹컬럼 ORDER BY 정렬컬럼)PARTITION BY = GROUP BY처럼 그룹 나누기 (없으면 전체가 하나의 그룹)ORDER BY = 그룹 안에서 순서 기준
-- 고객별로 사고 순서 번호 붙이기 (가장 최근 사고가 1번) SELECT C.CUST_NM, A.ACC_NO, A.ACC_DATE, ROW_NUMBER() OVER ( PARTITION BY P.CUST_ID ORDER BY A.ACC_DATE DESC ) AS ACC_RANK, COUNT(*) OVER ( PARTITION BY P.CUST_ID ) AS TOTAL_ACC_CNT -- 이 고객의 총 사고 건수 FROM ACCIDENT A JOIN POLICY P ON A.POL_NO = P.POL_NO JOIN CUSTOMER C ON P.CUST_ID = C.CUST_ID
-- 월별 지급액과 전월 대비 증감 WITH MONTHLY AS ( SELECT TO_CHAR(PAY_DT, 'YYYY-MM') AS MON, SUM(PAY_AMT) AS MON_AMT FROM CLAIM_PAY GROUP BY TO_CHAR(PAY_DT, 'YYYY-MM') ) SELECT MON, MON_AMT, LAG(MON_AMT) OVER (ORDER BY MON) AS PREV_MON_AMT, MON_AMT - LAG(MON_AMT) OVER (ORDER BY MON) AS DIFF FROM MONTHLY ORDER BY MON
-- 사고별 지급액의 누적합 (시간 순서대로) SELECT PAY_DT, PAY_AMT, SUM(PAY_AMT) OVER ( PARTITION BY ACC_NO ORDER BY PAY_DT ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS CUMUL_AMT -- 이 사고의 누적 지급액 FROM CLAIM_PAY WHERE ACC_NO = 'ACC-2024-003847'
ROW_NUMBER: 1, 2, 3 (무조건 다른 번호)RANK: 1, 1, 3 (동점이면 같은 순위, 다음은 건너뜀)DENSE_RANK: 1, 1, 2 (동점이면 같은 순위, 다음은 안 건너뜀)
입사하면 문서도 없고 아는 사람도 없는데 DB는 눈앞에 있다. 이때 쓰는 게 데이터 사전(Data Dictionary) 쿼리다. DB가 스스로 자신의 구조를 알고 있는 테이블들이 있다. 그걸 조회하면 된다.
-- 접근 가능한 테이블 전체 목록 SELECT TABLE_NAME, OWNER, NUM_ROWS FROM ALL_TABLES WHERE TABLE_NAME LIKE '%CLAIM%' -- 이름에 CLAIM 포함 ORDER BY TABLE_NAME -- 특정 테이블의 컬럼 구조 조회 SELECT COLUMN_NAME, DATA_TYPE, DATA_LENGTH, NULLABLE, COMMENTS -- 컬럼 설명이 있으면 여기 나옴 FROM ALL_TAB_COLUMNS C LEFT JOIN ALL_COL_COMMENTS CM ON C.TABLE_NAME = CM.TABLE_NAME AND C.COLUMN_NAME = CM.COLUMN_NAME WHERE C.TABLE_NAME = 'CLAIM_PAY' ORDER BY COLUMN_ID
-- 테이블 목록 SHOW TABLES LIKE '%claim%'; -- 컬럼 구조 DESCRIBE claim_pay; -- 또는 더 자세하게 SELECT COLUMN_NAME, DATA_TYPE, COLUMN_COMMENT FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'claim_pay' ORDER BY ORDINAL_POSITION
-- Oracle: 특정 테이블에 걸린 FK 찾기 SELECT A.TABLE_NAME AS "FK가 있는 테이블", A.COLUMN_NAME AS "FK 컬럼", C.TABLE_NAME AS "참조하는 테이블", C.COLUMN_NAME AS "참조 컬럼(PK)" FROM ALL_CONS_COLUMNS A JOIN ALL_CONSTRAINTS B ON A.CONSTRAINT_NAME = B.CONSTRAINT_NAME JOIN ALL_CONS_COLUMNS C ON B.R_CONSTRAINT_NAME = C.CONSTRAINT_NAME WHERE B.CONSTRAINT_TYPE = 'R' -- R = Foreign Key AND A.TABLE_NAME LIKE '%CLAIM%'
① ALL_TABLES에서 키워드로 관련 테이블 찾기 → ② ALL_TAB_COLUMNS로 컬럼 구조 보기 → ③ SELECT * … ROWNUM <= 5로 실제 데이터 확인 → ④ FK 쿼리로 연결 관계 파악 → ⑤ 기존 SQL 파일에서 FROM/JOIN 구조 확인. 이 순서로 돌리면 문서 없이도 DB 구조를 3시간 안에 파악할 수 있다.
쿼리가 돌긴 도는데 10초씩 걸린다면? 데이터가 많아서가 아닐 수 있다. 대부분 인덱스가 없거나 타지 않는 것이 원인이다.
WHERE ACC_NO = '...'처럼 자주 쓰는 조건 컬럼에 인덱스가 있으면 수백만 행도 순식간에 찾는다.
-- Oracle: 실행계획 확인 (쿼리 앞에 붙임) EXPLAIN PLAN FOR SELECT * FROM CLAIM_PAY WHERE ACC_NO = 'ACC-2024-003847'; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY); -- MySQL: 실행계획 EXPLAIN SELECT * FROM claim_pay WHERE acc_no = 'ACC-2024-003847';
실행계획에서 FULL TABLE SCAN 또는 type: ALL이 보이면 인덱스를 안 타고 있다는 뜻이다. INDEX RANGE SCAN 또는 type: ref가 보이면 인덱스를 잘 타고 있는 것이다.
WHERE TO_CHAR(ACC_DATE, 'YYYY') = '2024' — 컬럼에 함수를 씌우면 인덱스 무력화WHERE ACC_DATE BETWEEN '2024-01-01' AND '2024-12-31' 로 바꿔야 함WHERE ACC_NO LIKE '%3847' — 앞에 %가 붙은 LIKE는 인덱스 무력화WHERE ACC_NO LIKE 'ACC-2024%' 처럼 앞부분이 고정이어야 함WHERE PAY_AMT + 1000 > 500000 — 컬럼 연산도 인덱스 무력화WHERE PAY_AMT > 499000 으로 바꿔야 함