VIEW 기초: 복잡한 리포트 쿼리를 “가상 테이블”로 고정해 재사용하기
포인트 시스템을 만들다 보면 쿼리가 점점 길어집니다. 회원별 잔액, 최근 30일 적립/사용, 최근 활동 시각, 이상 징후용 집계까지 한 번에 뽑으려면 JOIN과 서브쿼리가 늘어나고, 같은 로직이 여러 곳에서 반복되기 시작합니다.
이때 도움이 되는 도구가 VIEW(뷰)입니다. VIEW는 데이터를 저장하는 테이블이 아니라, 특정 SELECT 쿼리에 이름을 붙여 “가상 테이블처럼” 쓰는 기능입니다. 즉, “자주 쓰는 쿼리 조합을 한 번 정의해두고, 이후에는 SELECT로 간단히 가져오는 방식”입니다.
이 단원의 목적: “반복되는 리포트 쿼리”를 한 곳에서 관리하기
VIEW를 도입하는 이유는 속도를 올리기 위해서가 아니라, 복잡도를 낮추기 위해서인 경우가 많습니다. 이번 글에서는 아래 3가지를 목표로 합니다.
- VIEW가 무엇이고, 테이블과 무엇이 다른지 이해한다
- 포인트 시스템에서 자주 쓰는 “회원별 요약” VIEW를 만든다
- VIEW를 쓸 때의 주의점(성능, 권한, 업데이트 가능 여부)을 정리한다
1) VIEW는 “저장된 SELECT”다
VIEW는 실행 결과를 저장하는 게 아니라, 정의된 SELECT를 매번 실행해서 결과를 보여줍니다(일반적인 VIEW 기준). 그래서 VIEW는 아래처럼 생각하면 이해가 쉽습니다.
- 테이블: 데이터가 실제로 저장됨
- 뷰: SQL 정의가 저장됨(결과는 요청할 때 계산)
즉, VIEW를 만든다고 해서 데이터가 복제되거나 늘어나지 않습니다. 대신 “이 SELECT를 앞으로는 view_name이라는 이름으로 부르겠다”는 약속을 만들어주는 것입니다.
2) 첫 번째 VIEW: 사용자별 현재 잔액(원장 기반)
가장 기본은 잔액 뷰입니다. point_history 원장을 uid별로 SUM해서 “현재 잔액”처럼 보이게 만들 수 있습니다. hide=1(숨김)은 제외한다고 가정합니다.
CREATE OR REPLACE VIEW v_user_balance AS SELECT uid, SUM(amount) AS balance, MAX(created_at) AS last_point_activity_at FROM point_history WHERE hide = 0 GROUP BY uid; 이제 잔액 조회는 이렇게 단순해집니다.
SELECT * FROM v_user_balance ORDER BY balance DESC LIMIT 20; 이 뷰는 원장만 기반으로 하므로, 포인트 기록이 한 번도 없는 사용자는 결과에 나타나지 않습니다. “회원 전체 목록”을 만들려면 users와 LEFT JOIN이 필요합니다.
3) 두 번째 VIEW: 최근 30일 요약(적립/사용/순변동)
운영에서 매우 자주 보는 리포트는 “최근 N일 요약”입니다. 여기서는 최근 30일을 고정해 VIEW로 만들어봅니다. 날짜 구간이 동적으로 바뀌는 리포트는 VIEW 대신 파라미터가 가능한 방식(애플리케이션 쿼리, 저장 프로시저 등)으로 처리하는 경우도 많지만, 학습 단계에서는 “대표 리포트 하나를 뷰로 고정”하는 것이 이해에 도움이 됩니다.
CREATE OR REPLACE VIEW v_user_30d_summary AS SELECT uid, SUM(CASE WHEN action_type IN ('CHARGE','USE_CANCEL','ADJUST') THEN amount ELSE 0 END) AS earned_30d, SUM(CASE WHEN action_type IN ('USE','REFUND','EXPIRE') THEN -amount ELSE 0 END) AS spent_30d, SUM(amount) AS net_delta_30d FROM point_history WHERE hide = 0 AND created_at >= NOW() - INTERVAL 30 DAY GROUP BY uid; 이 뷰를 쓰면 “최근 30일 요약”을 빠르게 조합할 수 있습니다.
SELECT * FROM v_user_30d_summary ORDER BY net_delta_30d DESC LIMIT 20; 4) VIEW 조합: users와 붙여 “회원 목록 + 요약” 만들기
이제 users 테이블에 뷰를 붙이면, 복잡한 서브쿼리 없이도 화면용 결과를 만들 수 있습니다.
SELECT u.uid, u.display_name, COALESCE(b.balance, 0) AS balance, b.last_point_activity_at, COALESCE(s.earned_30d, 0) AS earned_30d, COALESCE(s.spent_30d, 0) AS spent_30d, COALESCE(s.net_delta_30d, 0) AS net_delta_30d FROM users u LEFT JOIN v_user_balance b ON b.uid = u.uid LEFT JOIN v_user_30d_summary s ON s.uid = u.uid ORDER BY balance DESC; 이 쿼리는 길어 보이지만, 핵심 로직(집계)은 뷰 안에 숨겨져 있고, 바깥 쿼리는 “붙이고 보여준다”에 집중하고 있습니다. 앞으로 요약 규칙이 바뀌면 VIEW 정의만 바꾸면 되고, 화면용 쿼리는 그대로 유지할 수 있습니다.
5) VIEW를 쓸 때 꼭 알아야 할 주의점 5가지
(1) VIEW는 기본적으로 성능 향상을 보장하지 않는다
일반 VIEW는 실행될 때마다 내부 SELECT가 수행됩니다. 즉, 복잡한 집계 뷰를 자주 호출하면 그만큼 집계를 자주 수행하게 됩니다. 따라서 VIEW는 “재사용과 관리”에 강점이 있고, “빠름”은 인덱스와 쿼리 구조에 달려 있습니다.
(2) NOW() 같은 현재 시간 함수가 들어간 VIEW는 결과가 계속 달라진다
v_user_30d_summary는 NOW() - INTERVAL 30 DAY를 사용합니다. 이 뷰는 호출 시점마다 “최근 30일”이 달라집니다. 이건 의도된 동작이지만, “특정 기준일로 고정된 리포트”가 필요하다면 다른 방식이 더 적합할 수 있습니다.
(3) VIEW 위에 또 VIEW를 쌓으면 디버깅이 어려워질 수 있다
뷰는 편하지만, 너무 계층적으로 쌓으면 “어디서 느려졌는지”, “어디서 값이 틀어졌는지” 추적이 어려워질 수 있습니다. 따라서 핵심 리포트 몇 개만 뷰로 고정하는 정도가 유지보수에 유리합니다.
(4) VIEW로 UPDATE가 가능한 경우가 있지만, 원장에서는 권장되지 않는다
일부 단순 뷰는 UPDATE/INSERT가 가능하지만, 집계 뷰나 JOIN 뷰는 대부분 업데이트가 불가능합니다. 그리고 포인트 원장 같은 데이터는 애초에 “뷰로 수정”하는 흐름을 만들지 않는 편이 안전합니다.
(5) 권한(permissions) 관점에서 VIEW는 오히려 더 안전할 수 있다
운영 환경에서는 원장 테이블을 직접 열어주는 대신, 필요한 컬럼만 포함한 VIEW를 만들어 읽기 권한을 주는 방식도 흔합니다. 예를 들어 memo나 내부 ref_id 일부는 숨기고, 요약 결과만 보여주는 뷰를 제공하는 식입니다.
6) 간단 검증: VIEW 결과가 원장과 일치하는지 확인하기
뷰는 “쿼리를 숨기는 것”이기 때문에, 값이 틀어지면 원인을 못 찾는 상황이 생길 수 있습니다. 그래서 특정 uid 하나를 골라 직접 비교하는 검증 습관이 중요합니다.
SET @uid := '11111111-1111-1111-1111-111111111111'; -- (1) 뷰 결과 SELECT * FROM v_user_balance WHERE uid = @uid; -- (2) 원장 직접 계산 SELECT SUM(amount) AS balance, MAX(created_at) AS last_point_activity_at FROM point_history WHERE uid = @uid AND hide = 0; 다음 글 예고: 저장 프로시저(Stored Procedure)로 “포인트 사용”을 DB에서 하나의 함수처럼 만들기
지금까지는 SQL을 “외부에서 실행한다”는 관점으로 다뤘습니다. 다음 글에서는 반대로, 포인트 적립/사용 같은 핵심 로직을 DB 안에 저장해 하나의 호출로 처리하는 방식(저장 프로시저)을 다룹니다. 요청 선점, 잔액 조건부 차감, 원장 기록, 상태 업데이트까지 DB 레벨에서 한 번에 실행하는 흐름을 예시로 정리하겠습니다.
댓글
댓글 쓰기