infra
Platform

모듈 맵

[Database] 뷰(View)와 Stored Procedure의 운영 관점 장단점

0 / 37 완료

펼치기
0 / 37 완료0%

Database · 18 / 37

[Database] 뷰(View)와 Stored Procedure의 운영 관점 장단점

뷰로 복잡한 쿼리를 추상화하고 저장 프로시저의 장단점을 이해해 올바른 상황에 활용합니다

🚨INCIDENT ALERT
HIGH

같은 복잡한 조회가 여러 API에서 반복되면 쿼리 중복과 권한 관리가 어려워집니다. View와 Stored Procedure는 DB 안에 재사용 가능한 조회·로직 경계를 만드는 방법입니다. 언제 쓰고 언제 피해야 하는지 알아야 유지보수 비용을 줄일 수 있습니다.

이번 챕터에서 배울 것

View는 쿼리 추상화와 권한 제어에 효과적이고, Materialized View는 집계 성능 향상에 유용합니다. 저장 프로시저는 네트워크 왕복을 줄이지만 현대 개발에서는 유지보수 문제로 신중하게 사용해야 합니다.

  • 1View 기초 — CREATE VIEW 문법과 특성
  • 2View를 통한 권한 제어 — 민감 데이터 숨기기
  • 3Materialized View — 결과 캐싱과 갱신 전략
  • 4언제 View를 쓰고 언제 피하는가 — 비교 표
  • 5저장 프로시저(SP) — 기본 문법과 실행 방법
  • 6SP의 현대 개발에서의 위치 — 언제 써야 하는가
  • 7DB 함수(Function) vs 저장 프로시저 차이

뷰(View)와 저장 프로시저 — 언제 쓰고 언제 피하는가

View와 저장 프로시저는 데이터베이스에서 로직을 재사용하는 두 가지 방법입니다. View는 복잡한 쿼리를 단순하게 감추고, 저장 프로시저는 여러 SQL을 묶어 하나의 단위로 실행합니다. 하지만 둘 다 "항상 좋은 것"이 아니며, 잘못 사용하면 유지보수 악몽이 됩니다.


💡개념

View vs Materialized View — 조회 추상화의 두 가지 방식

복잡한 JOIN 쿼리가 여러 화면에서 반복됩니다. 한 곳에서 수정하면 다른 곳도 바꿔야 합니다. View를 쓰면 쿼리를 한 곳에 정의하고 테이블처럼 참조할 수 있습니다. 그런데 View를 써도 매번 원본 쿼리가 실행되어 대시보드가 느립니다. Materialized View는 결과를 캐싱해서 이 문제를 해결하지만 데이터가 실시간이 아닙니다.

View vs Materialized View — 조회 추상화의 두 가지 방식

일반 View — 쿼리의 별칭

일반 View는 SELECT 쿼리에 이름을 붙여 테이블처럼 사용할 수 있게 합니다. View를 조회할 때마다 정의된 쿼리가 실행되므로 항상 최신 데이터를 반환합니다. 데이터를 물리적으로 저장하지 않아 디스크 공간을 차지하지 않습니다.

주의사항: View 정의 안에 SELECT *를 쓰면 원본 테이블에 컬럼이 추가되어도 View는 생성 당시 컬럼만 반환합니다. 반드시 컬럼을 명시합니다.

SQL
CREATE VIEW v_order_summary AS
SELECT
    o.id           AS order_id,
    o.status,
    o.created_at,
    u.id           AS user_id,
    u.name         AS user_name,
    u.email        AS user_email,
    COUNT(oi.id)   AS item_count,
    SUM(oi.quantity * oi.unit_price) AS total_amount
FROM orders o
JOIN users u ON o.user_id = u.id
LEFT JOIN order_items oi ON o.id = oi.order_id
WHERE o.deleted_at IS NULL
GROUP BY o.id, o.status, o.created_at, u.id, u.name, u.email;

SELECT * FROM v_order_summary
WHERE user_id = 42
ORDER BY created_at DESC;

CREATE OR REPLACE VIEW v_order_summary AS
SELECT
    o.id AS order_id,
    o.status,
    o.created_at,
    u.name AS user_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.deleted_at IS NULL;

DROP VIEW IF EXISTS v_order_summary;

DROP VIEW IF EXISTS v_order_summary CASCADE;
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
🔍실행 후 확인할 것
  • View 결과원본 테이블 변경이 View 조회 결과에 즉시 반영되는지 확인합니다.
  • 권한 경계사용자가 직접 테이블 대신 View/Procedure만 접근하는지 봅니다.
  • 실행 비용재사용 로직이 성능 병목을 숨기지 않는지 점검합니다.

View를 통한 권한 제어

View는 민감한 컬럼을 숨기거나 특정 행만 노출하는 권한 제어 레이어로 활용할 수 있습니다. 분석팀에게 원본 테이블 접근은 차단하고 View만 허용하면, 개인정보를 노출하지 않고 분석에 필요한 데이터를 제공할 수 있습니다.

이메일 마스킹처럼 민감 정보를 일부 표시해야 하는 경우 View 정의 안에서 변환 함수를 적용합니다.

SQL
CREATE VIEW v_users_public AS
SELECT
    id,
    name,
    created_at,
    LEFT(email, 2) || '***@***' AS email_masked
FROM users
WHERE deleted_at IS NULL;

GRANT SELECT ON v_users_public TO analyst_role;
REVOKE SELECT ON users FROM analyst_role;

CREATE VIEW v_dept_employees AS
SELECT *
FROM employees
WHERE department_id = current_setting('app.current_department_id')::INT;

Materialized View — 결과를 디스크에 캐싱

Materialized View는 쿼리 결과를 물리적으로 디스크에 저장합니다. 조회 시 쿼리를 다시 실행하지 않으므로, 집계나 복잡한 JOIN을 포함한 무거운 쿼리도 빠르게 반환됩니다. 단점은 원본 데이터가 변경되어도 자동으로 반영되지 않고 REFRESH 명령을 수동으로 실행해야 한다는 것입니다.

WITH DATA로 생성하면 즉시 데이터가 채워지고, WITH NO DATA로 생성하면 구조만 만들어집니다. CONCURRENTLY 갱신을 위해서는 UNIQUE INDEX가 반드시 있어야 합니다.

SQL
CREATE MATERIALIZED VIEW mv_monthly_revenue AS
SELECT
    DATE_TRUNC('month', o.created_at) AS month,
    p.category,
    COUNT(DISTINCT o.id) AS order_count,
    COUNT(DISTINCT o.user_id) AS customer_count,
    SUM(oi.quantity * oi.unit_price) AS revenue
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
WHERE o.status = 'completed'
GROUP BY 1, 2
WITH DATA;

CREATE UNIQUE INDEX ON mv_monthly_revenue (month, category);

REFRESH MATERIALIZED VIEW mv_monthly_revenue;

REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_revenue;

언제 View를 쓰고 언제 피하는가

기준일반 ViewMaterialized ViewView를 피해야 할 때
데이터 신선도항상 최신마지막 REFRESH 기준
조회 성능쿼리 복잡도에 따라 다름매우 빠름 (인덱스 가능)
디스크 사용없음원본 데이터와 유사한 크기
갱신 방법자동 (항상 최신)REFRESH 명령 수동 실행
적합한 사례OLTP, 실시간 데이터, 권한 제어대시보드, 주기적 집계 리포트
피해야 할 사례View 위에 View 중첩, 잦은 갱신이 필요한 집계

View를 피해야 하는 상황: 복잡한 View 위에 다시 View를 쌓으면(중첩 뷰) 옵티마이저가 전체 쿼리 계획을 최적화하기 어려워집니다. 또한 실시간 집계가 필요한 데이터에 Materialized View를 쓰면 오래된 데이터를 제공하게 됩니다.

💼
실무 맥락복잡한 보고서 쿼리를 View로 추상화해 앱 코드를 단순화한 사례
현업 패턴

월별 매출 보고서 API가 20줄 이상의 복잡한 JOIN 쿼리를 직접 포함하고 있었습니다. 같은 쿼리가 3개 API 엔드포인트에 중복 존재해, 쿼리 수정 시 모든 곳을 동기화해야 했습니다.

월별 집계 데이터는 1시간 지연이 허용되었기 때문에 Materialized View로 결과를 저장하고, pg_cron으로 매 시간 REFRESH MATERIALIZED VIEW CONCURRENTLY를 실행하도록 설정했습니다.

API 코드는 SELECT * FROM mv_monthly_revenue WHERE month = $1처럼 단순해졌고, 평균 응답 시간이 3.2초에서 12ms로 감소했습니다. 쿼리 변경이 필요할 때도 View 정의 한 곳만 수정하면 됩니다.

Materialized View를 생성한 후 REFRESH 스케줄을 설정하지 않으면, 처음 생성 시점의 데이터가 그대로 유지됩니다. 대시보드에서 "오늘 주문 건수 0건"처럼 이상한 데이터가 표시될 때 이 문제를 의심합니다.

확인 방법: 아래 쿼리로 각 Materialized View의 마지막 갱신 시간을 확인합니다.

SQL
SELECT schemaname, matviewname, last_refresh
FROM pg_matviews
ORDER BY last_refresh ASC NULLS FIRST;

해결 방법: pg_cron 또는 외부 스케줄러(cron, Airflow 등)로 주기적 REFRESH를 설정합니다. 운영 중에는 조회를 차단하지 않는 REFRESH MATERIALIZED VIEW CONCURRENTLY를 사용하고, 이를 위해 UNIQUE INDEX가 반드시 존재해야 합니다.

💡개념

저장 프로시저(Stored Procedure) — 현대 개발에서의 위치

레거시 시스템에 수백 개의 저장 프로시저가 있습니다. 비즈니스 로직이 DB에 묻혀 있어서 어디서 무슨 일이 일어나는지 파악이 어렵습니다. 반면 새 시스템 설계에서는 "저장 프로시저 쓰지 마라"는 말이 나옵니다. 현대 개발에서 언제 쓰고 언제 피해야 하는지 알아야 기존 코드를 읽고, 새 설계에서 올바른 판단을 할 수 있습니다.

저장 프로시저 기본 문법

저장 프로시저(SP)는 여러 SQL 문을 하나의 이름으로 DB에 저장해두고 CALL 문으로 호출하는 기능입니다. DECLARE로 지역 변수를 선언하고, IF 분기, RAISE EXCEPTION으로 오류 처리, COMMIT/ROLLBACK으로 트랜잭션 제어가 가능합니다.

아래는 주문 취소 처리를 SP로 구현한 예시입니다. 상태 확인, 주문 취소 업데이트, 재고 복원, 이력 기록을 하나의 트랜잭션으로 묶어 처리합니다.

SQL
CREATE OR REPLACE PROCEDURE proc_cancel_order(
    p_order_id BIGINT,
    p_reason   VARCHAR(500)
)
LANGUAGE plpgsql
AS $$
DECLARE
    v_status VARCHAR(20);
BEGIN
    SELECT status INTO v_status
    FROM orders WHERE id = p_order_id;

    IF v_status IS NULL THEN
        RAISE EXCEPTION '주문을 찾을 수 없습니다: %', p_order_id;
    END IF;

    IF v_status = 'completed' THEN
        RAISE EXCEPTION '완료된 주문은 취소할 수 없습니다';
    END IF;

    UPDATE orders
    SET status = 'cancelled',
        cancelled_at = NOW(),
        cancel_reason = p_reason
    WHERE id = p_order_id;

    UPDATE inventory i
    SET stock = i.stock + oi.quantity
    FROM order_items oi
    WHERE oi.order_id = p_order_id
      AND i.product_id = oi.product_id;

    INSERT INTO order_logs (order_id, action, note, created_at)
    VALUES (p_order_id, 'CANCELLED', p_reason, NOW());

    COMMIT;
END;
$$;

CALL proc_cancel_order(12345, '고객 요청으로 취소');

DB Function vs Stored Procedure

Function과 Procedure는 모두 DB에 로직을 저장하지만 용도가 다릅니다. Function은 반드시 값을 반환하고 SELECT 쿼리 안에서 사용할 수 있지만 트랜잭션 제어(COMMIT/ROLLBACK)는 불가능합니다. Procedure는 반환값이 없고 SELECT 안에서 호출할 수 없지만 트랜잭션을 직접 제어할 수 있습니다.

SQL
CREATE OR REPLACE FUNCTION fn_calculate_discount(
    p_amount    NUMERIC,
    p_user_tier VARCHAR(20)
)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
BEGIN
    RETURN CASE p_user_tier
        WHEN 'gold'   THEN p_amount * 0.85
        WHEN 'silver' THEN p_amount * 0.90
        ELSE               p_amount * 0.95
    END;
END;
$$;

SELECT
    product_name,
    price,
    fn_calculate_discount(price, 'gold') AS gold_price
FROM products;
특성FUNCTIONSTORED PROCEDURE
반환값반드시 있음OUT 파라미터 또는 없음
SELECT 내 사용가능불가능
COMMIT/ROLLBACK불가능가능
호출 방법SELECT fn() 또는 쿼리 내CALL proc()
용도값 계산, 변환복잡한 트랜잭션 처리

저장 프로시저의 장단점과 현대적 관점

현대 웹 서비스 개발에서 저장 프로시저는 사용이 점점 줄고 있습니다. 애플리케이션 레이어에서 비즈니스 로직을 관리하는 것이 테스트, 버전 관리, 배포 측면에서 유리하기 때문입니다.

구분내용
장점네트워크 왕복 감소 — 여러 SQL을 하나의 CALL로 처리
장점DB 내 로직 — 어떤 언어/프레임워크에서도 일관된 동작
장점권한 분리 — EXECUTE 권한만 부여해 테이블 직접 접근 차단
장점배치 처리 — 대용량 데이터 처리 시 효율적
단점버전 관리 어려움 — SQL 파일을 Git으로 관리하기 어렵고 코드 리뷰가 불편함
단점테스트 어려움 — 단위 테스트 작성이 복잡하고 CI/CD 통합이 어려움
단점로직 분산 — 비즈니스 로직이 DB와 앱 양쪽에 나뉘어 일관성 유지 어려움
단점이식성 없음 — PostgreSQL SP는 MySQL에서 동작하지 않음

SP가 여전히 유용한 경우

SP와 트리거가 실용적인 상황이 있습니다. 감사 로그(audit log)처럼 모든 테이블 변경에 자동으로 기록이 필요한 경우, 트리거를 통한 자동화가 애플리케이션 코드에서 누락될 위험 없이 일관성을 보장합니다.

아래는 모든 테이블 변경을 자동으로 감사 로그에 기록하는 트리거 함수입니다. TG_TABLE_NAMETG_OP로 어느 테이블에서 어떤 작업이 발생했는지 자동으로 기록합니다.

SQL
CREATE OR REPLACE FUNCTION fn_audit_log()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
    INSERT INTO audit_logs (
        table_name, operation, old_data, new_data, changed_at, changed_by
    )
    VALUES (
        TG_TABLE_NAME,
        TG_OP,
        row_to_json(OLD),
        row_to_json(NEW),
        NOW(),
        current_setting('app.current_user_id', true)
    );
    RETURN NEW;
END;
$$;

CREATE TRIGGER trg_users_audit
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION fn_audit_log();

대용량 데이터 배치 처리나 반복 사용되는 복잡한 집계 계산에도 SP와 Function이 유용합니다.

SQL
CALL proc_archive_old_orders(cutoff_date := '2023-01-01');

비즈니스 대시보드에 오늘 매출이 0원으로 표시되는 버그 신고가 들어왔습니다. Materialized View가 전날 생성된 후 한 번도 REFRESH되지 않은 것이 원인이었습니다.

REFRESH 스케줄이 없거나 스케줄러 작업이 실패한 경우 이런 문제가 발생합니다. 모니터링이 없으면 얼마나 오래된 데이터인지도 알 수 없습니다.

해결 방법: Materialized View마다 마지막 갱신 시간을 Grafana나 슬랙 알림으로 모니터링합니다. REFRESH 실패 시 알림이 오도록 설정하고, 수동 복구 절차를 문서화합니다.

다음 모듈에서는 EXPLAIN 명령어로 쿼리 실행 계획을 분석하고 데이터 검색 범위를 최소화하는 최적화 기법을 다룹니다.

지식 확인

퀴즈 — 5문제

Q1

마케팅팀이 '지난 30일 카테고리별 매출 요약' 뷰를 하루 수십 번 조회하는데, 매번 수백만 건의 orders 테이블을 집계해서 응답이 3초 걸린다. 이를 개선하기 위한 가장 적절한 방법은?

Q2

저장 프로시저(SP)의 현대 개발에서 주요 단점은?

Q3

고객 서비스팀에게 users 테이블 SELECT 권한을 주되, 주민번호(ssn)와 계좌번호(bank_account)는 절대 노출해선 안 된다. View를 어떻게 활용할 수 있는가?

Q4

REFRESH MATERIALIZED VIEW CONCURRENTLY의 장점은?

Q5

주문이 INSERT될 때마다 자동으로 재고를 1 감소시키는 로직이 필요하다. 개발자가 애플리케이션 코드에서 재고 감소 코드를 실수로 빠뜨려도 항상 작동해야 한다면 어떤 방법이 적합한가?

0 / 5 답변

🧪 실습으로 확인하기

PostgreSQL 설치 및 기본 설정

초급

Ubuntu 서버에 PostgreSQL을 설치하고, 데이터베이스와 사용자를 생성한 뒤 외부 접속이 가능하도록 설정한다.

40📋 5단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요

database중급 · 55
[Database] 계층형 댓글 구조, 다대다 태그 시스템, Soft Delete 구현
Database 트랙 계속
linux입문 · 30
[Linux] 개발자가 왜 리눅스 서버와 커맨드라인을 반드시 배워야 하는가
Linux 트랙 시작점