2026년 10월 02일 | DBMS Error 가이드
이 글에서 다루는 내용
P0003 에러의 원인 분석, 해결 SQL, 예방 방법을 실무 관점에서 정리합니다.
P0003 too many rows 는?
PostgreSQL 에러 코드 P0003은 too many rows라는 메시지와 함께 발생하며, PL/pgSQL 함수 또는 블록 내에서 SELECT INTO 구문이 단 하나의 행만 반환해야 하는데 실제로 두 개 이상의 행이 반환될 때 발생합니다. 이 에러는 주로 PL/pgSQL 코드 내부에서 변수에 쿼리 결과를 담으려 할 때, 예상과 달리 복수의 결과가 반환되는 상황에서 트리거됩니다. 개발 초기보다는 데이터가 누적된 운영 환경에서 갑작스럽게 나타나는 경우가 많아 장애 대응 시 각별한 주의가 필요합니다.
주요 발생 원인
1. PL/pgSQL의 SELECT INTO에서 복수 행 반환
가장 흔한 원인은 PL/pgSQL 함수나 익명 블록 내에서 SELECT INTO를 사용할 때 WHERE 조건이 너무 느슨하거나, 조인 조건이 잘못되어 복수의 행이 반환되는 경우입니다. PostgreSQL은 SELECT INTO를 통해 단 하나의 행만 변수에 대입할 수 있도록 설계되어 있으며, 이를 위반하면 즉시 P0003 에러를 발생시킵니다. 특히 초기 개발 단계에서는 테스트 데이터가 적어 문제가 없었지만, 운영 데이터가 쌓이면서 조건에 부합하는 행이 여러 개가 되는 시점에 에러가 폭발적으로 발생합니다.
2. UNIQUE 제약 조건 미적용 컬럼을 기준으로 조회
개발자가 논리적으로 유일할 것이라고 가정한 컬럼(예: 이메일, 주문번호, 사용자명 등)에 실제 데이터베이스 레벨의 UNIQUE 제약 조건이 없는 경우, 중복 데이터가 삽입될 수 있습니다. 이후 해당 컬럼을 WHERE 조건으로 사용하는 SELECT INTO 구문에서 복수 행이 반환되어 P0003이 발생합니다. 이는 단순한 쿼리 문제가 아닌 데이터 무결성 설계 문제이므로, 근본적인 스키마 검토가 필요합니다.
3. 서브쿼리 또는 뷰의 결과가 예상보다 많은 행 반환
함수 내부에서 뷰(View)나 서브쿼리를 SELECT INTO의 소스로 사용할 때, 뷰의 정의나 서브쿼리 로직이 변경되어 반환 행 수가 늘어나는 경우가 있습니다. 뷰는 독립적으로 수정될 수 있기 때문에, 함수 개발 당시에는 단일 행을 반환하던 뷰가 나중에 복수 행을 반환하도록 변경되면 함수 호출 시 P0003이 발생합니다. 이런 경우 에러의 근본 원인을 파악하기 어려울 수 있으므로 뷰의 변경 이력을 함께 검토해야 합니다.
해결 방법
원인 1 해결: LIMIT 1 또는 STRICT 키워드 활용
의도적으로 단 하나의 행만 필요하다면 LIMIT 1을 사용하거나, 반대로 반드시 정확히 하나의 행이어야 함을 명시하고 싶다면 STRICT 옵션을 활용해 에러를 명시적으로 핸들링하세요.
-- 문제가 발생하는 코드 예시
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: STRICT 옵션 + 예외 처리
DO $$
DECLARE
v_user_name TEXT;
BEGIN
SELECT name INTO STRICT v_user_name
FROM users
WHERE status = 'active'
AND user_id = 12345; -- 고유한 조건 추가
EXCEPTION
WHEN TOO_MANY_ROWS THEN
RAISE WARNING 'Multiple users found for the given condition.';
WHEN NO_DATA_FOUND THEN
RAISE WARNING 'No user found.';
END;
$$;
원인 2 해결: UNIQUE 제약 조건 추가 및 중복 데이터 정리
-- 중복 데이터 확인
SELECT email, COUNT(*) AS cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- 중복 제거 (가장 최근 데이터만 남기기)
DELETE FROM users
WHERE user_id NOT IN (
SELECT DISTINCT ON (email) user_id
FROM users
ORDER BY email, created_at DESC
);
-- UNIQUE 제약 조건 추가 (중복 제거 후)
ALTER TABLE users
ADD CONSTRAINT uq_users_email UNIQUE (email);
-- 이후 안전하게 사용 가능
CREATE OR REPLACE FUNCTION get_user_by_email(p_email TEXT)
RETURNS TEXT AS $$
DECLARE
v_user_name TEXT;
BEGIN
SELECT name INTO STRICT v_user_name
FROM users
WHERE email = p_email;
RETURN v_user_name;
EXCEPTION
WHEN NO_DATA_FOUND THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
원인 3 해결: 뷰 반환 결과 검증 및 함수 방어 코드 추가
-- 뷰가 반환하는 행 수 먼저 확인
SELECT COUNT(*) FROM active_orders_view WHERE customer_id = 101;
-- 함수 내에서 방어적으로 처리 (복수 행 가능성 고려)
CREATE OR REPLACE FUNCTION get_latest_order(p_customer_id INT)
RETURNS TABLE(order_id INT, order_total NUMERIC) AS $$
BEGIN
-- SELECT INTO 대신 RETURN QUERY 사용하여 복수 행 안전 처리
RETURN QUERY
SELECT o.order_id, o.order_total
FROM active_orders_view o
WHERE o.customer_id = p_customer_id
ORDER BY o.created_at DESC
LIMIT 1;
END;
$$ LANGUAGE plpgsql;
-- 또는 집계 함수로 단일 값 보장
DO $$
DECLARE
v_max_order_id INT;
BEGIN
SELECT MAX(order_id) INTO v_max_order_id
FROM active_orders_view
WHERE customer_id = 101;
RAISE NOTICE 'Latest Order ID: %', v_max_order_id;
END;
$$;
예방 방법
1. PL/pgSQL 함수 작성 시 STRICT + 예외 처리를 표준으로 삼기
모든 SELECT INTO 구문에 STRICT 키워드를 붙이고, TOO_MANY_ROWS와 NO_DATA_FOUND 예외를 항상 명시적으로 처리하는 것을 팀 코딩 컨벤션으로 정하세요. 이렇게 하면 P0003이 발생하는 즉시 핸들링 로직이 동작하여 예상치 못한 서비스 장애를 예방할 수 있습니다. 또한 코드 리뷰 시 SELECT INTO 뒤에 STRICT가 없는 경우를 반드시 지적하도록 리뷰 체크리스트에 포함시키는 것을 권장합니다.
-- 팀 표준 템플릿 예시
CREATE OR REPLACE FUNCTION safe_select_template(p_id INT)
RETURNS TEXT AS $$
DECLARE
v_result TEXT;
BEGIN
SELECT col INTO STRICT v_result
FROM some_table
WHERE id = p_id;
RETURN v_result;
EXCEPTION
WHEN TOO_MANY_ROWS THEN
RAISE EXCEPTION 'P0003: Multiple rows returned for id=%', p_id;
WHEN NO_DATA_FOUND THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
2. 비즈니스 키 컬럼에 반드시 UNIQUE 제약 조건 적용하기
논리적으로 유일해야 하는 컬럼(이메일, 주문번호, 사원번호 등)에는 반드시 데이터베이스 레벨의 UNIQUE 제약 조건 또는 UNIQUE 인덱스를 적용하세요. 애플리케이션 레벨에서만 중복을 통제하는 것은 Race Condition 등으로 인해 실패할 수 있으며, 결국 P0003의 씨앗이 됩니다. 정기적으로 중복 데이터 여부를 모니터링하는 쿼리를 스케줄링하여 잠재적인 문제를 사전에 차단하는 것이 좋습니다.
관련 에러
- P0001 – raise_exception: PL/pgSQL에서
RAISE EXCEPTION으로 명시적으로 발생시키는 에러로, P0003을EXCEPTION블록에서 잡아 재발생시킬 때 함께 사용됩니다. - P0002 – no_data_found:
SELECT INTO STRICT에서 반환 행이 0개일 때 발생하며, P0003과 쌍을 이루는 에러입니다. 두 에러를 함께 처리하는 것이 일반적입니다. - 42P01 – undefined_table: 잘못된 뷰나 테이블 참조로 인해 P0003과 연계되어 나타날 수 있으며, 함수 내 소스 객체 유효성 검토 시 함께 확인해야 합니다.
- 23505 – unique_violation: UNIQUE 제약 조건이 없어 중복 데이터가 쌓이고, 이것이 P0003을 유발하는 경우 근본 원인과 밀접하게 연관됩니다.
주요 DBMS error code를 정리하는 시리즈입니다.
블로그 홈에서 다른 에러도 확인하세요.
본 포스트는 AI가 생성한 기술 가이드입니다. 운영 환경 적용 전 충분한 검토를 권장합니다.