2026년 07월 29일 | DBMS Error 가이드
이 글에서 다루는 내용
P0003 에러의 원인 분석, 해결 SQL, 예방 방법을 실무 관점에서 정리합니다.
P0003 too many rows 는?
PostgreSQL 에러 코드 P0003 (too_many_rows)는 PL/pgSQL 함수 또는 DO 블록 내에서 SELECT INTO 구문을 사용했을 때, 해당 쿼리가 단 하나의 행만 반환해야 하는 상황에서 두 개 이상의 행을 반환할 경우 발생합니다. 이 에러는 주로 PL/pgSQL 코드 내에서 단일 변수에 쿼리 결과를 할당하려 할 때 나타나며, 런타임 시점에 감지됩니다. 개발자가 데이터 구조나 쿼리 결과를 충분히 검토하지 않고 SELECT INTO를 사용했을 때 실무에서 자주 마주치는 에러입니다.
주요 발생 원인
1. PL/pgSQL의 SELECT INTO에서 다중 행 반환
가장 흔한 원인은 PL/pgSQL 블록 내에서 SELECT INTO를 사용할 때 WHERE 조건이 충분히 좁지 않아 여러 행이 반환되는 경우입니다. SELECT INTO는 결과가 반드시 0개 또는 1개의 행이어야 하며, 2개 이상의 행이 반환되는 순간 PostgreSQL은 즉시 P0003 에러를 발생시킵니다. 특히 고유(UNIQUE) 제약이 없는 컬럼을 기준으로 조회할 때 이 문제가 자주 발생합니다.
2. STRICT 옵션 사용 시 기대 이상의 결과 반환
SELECT INTO ... STRICT 구문을 사용하면 결과가 정확히 1개의 행이어야 하며, 0개이면 P0002 (no_data_found), 2개 이상이면 P0003 (too_many_rows) 에러가 발생합니다. STRICT 옵션 없이는 다중 행이 반환되더라도 첫 번째 행만 사용하고 나머지는 무시하지만, STRICT를 명시하면 이러한 상황이 에러로 처리됩니다. 데이터 정합성을 높이기 위해 STRICT를 사용하는 것은 좋은 습관이지만, 쿼리 결과에 대한 명확한 이해가 전제되어야 합니다.
3. 함수 내부에서 서브쿼리 또는 조인 결과의 예상치 못한 증가
조인(JOIN) 이나 서브쿼리를 사용하는 복잡한 쿼리에서 데이터가 시간이 지남에 따라 늘어나면서 처음에는 단일 행을 반환하던 쿼리가 나중에 복수 행을 반환하게 되는 경우가 있습니다. 초기 개발 시에는 테스트 데이터가 적어 문제가 드러나지 않다가, 운영 환경에서 데이터가 쌓이면서 P0003 에러가 터지는 전형적인 패턴입니다. 이는 특히 1:N 관계의 테이블을 조인할 때 자주 발생하며, 실무에서 상당히 위험한 상황을 초래할 수 있습니다.
해결 방법
원인 1 해결: LIMIT 1 또는 집계 함수 사용
쿼리가 단일 행만 반환하도록 명시적으로 제어합니다.
-- 문제가 되는 코드
DO $$
DECLARE
v_user_name TEXT;
BEGIN
-- 이 쿼리가 여러 행을 반환하면 P0003 발생
SELECT name INTO v_user_name
FROM users
WHERE status = 'active';
RAISE NOTICE 'User: %', v_user_name;
END;
$$;
-- 해결 방법 1: LIMIT 1 추가 (가장 최근 사용자 한 명만 선택)
DO $$
DECLARE
v_user_name TEXT;
BEGIN
SELECT name INTO v_user_name
FROM users
WHERE status = 'active'
ORDER BY created_at DESC
LIMIT 1;
RAISE NOTICE 'User: %', v_user_name;
END;
$$;
-- 해결 방법 2: 고유한 조건으로 쿼리 수정
DO $$
DECLARE
v_user_name TEXT;
BEGIN
SELECT name INTO v_user_name
FROM users
WHERE user_id = 12345; -- 고유 식별자 사용
RAISE NOTICE 'User: %', v_user_name;
END;
$$;
원인 2 해결: STRICT 사용 시 예외 처리
STRICT를 사용하되, 예외를 적절히 처리하여 안전하게 운영합니다.
-- STRICT와 예외 처리를 함께 사용하는 패턴
CREATE OR REPLACE FUNCTION get_active_user(p_email TEXT)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_user_name TEXT;
BEGIN
-- STRICT를 사용하여 정확히 1개의 행만 허용
SELECT name INTO STRICT v_user_name
FROM users
WHERE email = p_email
AND status = 'active';
RETURN v_user_name;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RAISE WARNING 'P0002: 해당 이메일의 활성 사용자가 없습니다: %', p_email;
RETURN NULL;
WHEN TOO_MANY_ROWS THEN
-- P0003 에러를 잡아서 적절히 처리
RAISE WARNING 'P0003: 동일 이메일에 활성 사용자가 여러 명입니다: %', p_email;
-- 가장 최근에 생성된 사용자를 반환
SELECT name INTO v_user_name
FROM users
WHERE email = p_email
AND status = 'active'
ORDER BY created_at DESC
LIMIT 1;
RETURN v_user_name;
END;
$$;
-- 함수 테스트
SELECT get_active_user('test@example.com');
원인 3 해결: 조인 결과의 중복 제거
조인으로 인한 다중 행 문제는 DISTINCT, GROUP BY, 또는 서브쿼리로 해결합니다.
-- 문제 상황: orders와 order_items 조인 시 다중 행 발생
CREATE OR REPLACE FUNCTION get_order_total(p_order_id INT)
RETURNS NUMERIC
LANGUAGE plpgsql
AS $$
DECLARE
v_total NUMERIC;
BEGIN
-- 잘못된 방법: 조인으로 인해 여러 행 반환 가능
-- SELECT o.total INTO STRICT v_total
-- FROM orders o
-- JOIN order_items oi ON o.order_id = oi.order_id
-- WHERE o.order_id = p_order_id;
-- 올바른 방법 1: 집계 함수 사용
SELECT SUM(oi.price * oi.quantity) INTO v_total
FROM order_items oi
WHERE oi.order_id = p_order_id;
-- 올바른 방법 2: 서브쿼리로 집계 후 단일 행 보장
SELECT o.total INTO STRICT v_total
FROM orders o
WHERE o.order_id = p_order_id;
RETURN COALESCE(v_total, 0);
EXCEPTION
WHEN TOO_MANY_ROWS THEN
RAISE EXCEPTION 'P0003: 주문 ID %에 대한 중복 데이터가 존재합니다.', p_order_id;
WHEN NO_DATA_FOUND THEN
RETURN 0;
END;
$$;
-- 실제 데이터 확인 쿼리 (중복 데이터 탐지)
SELECT email, COUNT(*) AS cnt
FROM users
WHERE status = 'active'
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY cnt DESC;
예방 방법
1. PL/pgSQL 함수 작성 시 항상 STRICT와 예외 처리를 함께 사용하라
SELECT INTO STRICT는 데이터 정합성 측면에서 매우 유익하지만, 반드시 EXCEPTION 블록에서 TOO_MANY_ROWS와 NO_DATA_FOUND를 모두 처리해야 합니다. 이 패턴을 팀 내 코딩 컨벤션으로 정해두면, P0003 에러가 운영 환경에서 터지는 것을 사전에 방지할 수 있습니다. 또한, 함수를 배포하기 전에 실제 운영 데이터와 유사한 볼륨으로 테스트하는 것이 필수적입니다.
-- 권장 패턴: 항상 STRICT + EXCEPTION 세트로 사용
CREATE OR REPLACE FUNCTION safe_select_example(p_id INT)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_result TEXT;
BEGIN
SELECT some_column INTO STRICT v_result
FROM some_table
WHERE id = p_id;
RETURN v_result;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RETURN NULL;
WHEN TOO_MANY_ROWS THEN
RAISE EXCEPTION '[P0003] id=% 에 대한 결과가 여러 건입니다. 데이터를 확인하세요.', p_id;
END;
$$;
2. 고유 제약 조건(UNIQUE Constraint)과 데이터 모델 검토를 주기적으로 수행하라
P0003 에러는 대부분 데이터 모델 설계 단계에서 고유성이 보장되지 않은 컬럼을 기준으로 단일 행을 기대하는 쿼리를 작성했을 때 발생합니다. 주기적으로 테이블의 UNIQUE 제약 조건을 검토하고, 단일 행을 기대하는 쿼리에서 사용하는 컬럼에는 반드시 UNIQUE 인덱스를 적용하는 습관을 들여야 합니다.
-- 고유 제약 조건 추가 예시
ALTER TABLE users
ADD CONSTRAINT uq_users_email UNIQUE (email);
-- 또는 부분 고유 인덱스 (활성 사용자만 이메일 고유 보장)
CREATE UNIQUE INDEX uix_users_active_email
ON users (email)
WHERE status = 'active';
-- 기존 중복 데이터 확인 후 정리
WITH duplicates AS (
SELECT id,
email,
ROW_NUMBER() OVER (PARTITION BY email ORDER BY created_at DESC) AS rn
FROM users
WHERE status = 'active'
)
SELECT * FROM duplicates WHERE rn > 1;
-- 중복 확인 후 삭제 또는 업데이트 처리
관련 에러
- P0001 (
raise_exception): PL/pgSQL에서RAISE EXCEPTION으로 명시적으로 발생시키는 에러로, P0003과 함께 예외 처리 블록에서 자주 다루어집니다. - P0002 (
no_data_found):SELECT INTO STRICT사용 시 결과가 0개일 때 발생하는 에러로, P0003과 쌍으로 반드시 함께 처리해야 합니다. - 21000 (
cardinality_violation): 스칼라 서브쿼리가 두 개 이상의 행을 반환할 때 발생하는 SQL 표준 에러로, P0003과 유사한 상황에서 발생합니다. - 42601 (
syntax_error): PL/pgSQL 블록 작성 시 문법 오류로 발생하며, P0003과 함께 PL/pgSQL 디버깅 시 자주 마주칩니다.
주요 DBMS error code를 정리하는 시리즈입니다.
블로그 홈에서 다른 에러도 확인하세요.
본 포스트는 AI가 생성한 기술 가이드입니다. 운영 환경 적용 전 충분한 검토를 권장합니다.