SQL에서 중복을 찾으려면 고유해야 하는 컬럼으로 행을 그룹화하고, 행이 2개 이상인 그룹만 남기면 돼요. 그런 다음 윈도우 함수와 셀프 조인으로 중복된 행 자체를 확인하고 어떤 행을 남길지 결정할 수 있어요. 예제는 PostgreSQL, MySQL, SQLite, SQL Server에서 동작해요.
SQL에서 중복을 찾는 방법
GROUP BY는 같은 값을 가진 행을 하나로 묶고, COUNT(*)는 각 그룹의 행 수를 세며, HAVING은 행이 2개 이상인 그룹만 남겨요:
SELECT columns, COUNT(*)
FROM table_name
GROUP BY columns
HAVING COUNT(*) > 1;
예를 들어, 2명 이상의 고객이 사용하는 이메일 주소를 찾으려면 이렇게 해요:
-- Input: customers
-- id | name | email
-- ---+----------------+----------------------
-- 1 | Maria Lopez | maria.lopez@gmail.com
-- 2 | Sam Patel | sam@patel.dev
-- 3 | M. Lopez | maria.lopez@gmail.com
-- 4 | Kenji Watanabe | kenji@watanabe.jp
SELECT email, COUNT(*) AS copies
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
-- Output:
-- email | copies
-- ----------------------+-------
-- maria.lopez@gmail.com | 2
여러 컬럼이 모두 일치할 때만 중복으로 보려면, 해당 컬럼을 모두 SELECT와 GROUP BY 양쪽에 나열하세요.
알아 둘 규칙
-
SELECT하는 모든 컬럼은
GROUP BY에 있거나 집계 함수 안에 있어야 해요. 위 쿼리에name을 추가해도 중복된 행의 이름이 표시되지 않아요. 오류가 나는데, SQLite만 예외로 이메일당 이름 하나를 보여 줘요:
-- Input: the customers table above
SELECT name, email, COUNT(*) AS copies
FROM customers
GROUP BY email
HAVING COUNT(*) > 1;
-- ERROR: column "customers.name" must appear in the GROUP BY clause or be used in an aggregate function
중복된 행을 모두 보려면 “대체 문법”에 있는 쿼리 중 하나를 사용하세요.
-
NULL은 한 그룹으로 묶이고, 대소문자는 구분돼요. NULL 이메일은 모두 한 그룹에 들어가서 서로 중복처럼 보이지만,
Ana@Silva.dev와ana@silva.dev는 중복으로 잡히지 않아요:
-- Input: users
-- id | email
-- ---+--------------
-- 1 | ana@silva.dev
-- 2 | NULL
-- 3 | Ana@Silva.dev
-- 4 | NULL
SELECT email, COUNT(*) AS copies
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- Output:
-- email | copies
-- ------+-------
-- NULL | 2
-- MySQL and SQL Server ignore case by default, so they also return ana@silva.dev | 2
-- Skip the NULLs and ignore case:
SELECT LOWER(email) AS email, COUNT(*) AS copies
FROM users
WHERE email IS NOT NULL
GROUP BY LOWER(email)
HAVING COUNT(*) > 1;
-- Output:
-- email | copies
-- --------------+-------
-- ana@silva.dev | 2
대체 문법
GROUP BY는 중복된 값마다 행을 하나씩 반환해요. 중복된 행 자체를 얻으려면 대신 윈도우로 개수를 세세요. COUNT(*) OVER (PARTITION BY email)은 각 이메일의 개수를 해당 이메일을 가진 모든 행에 붙여 줘요:
-- Input: the customers table above
SELECT id, name, email, copies
FROM (
SELECT id, name, email, COUNT(*) OVER (PARTITION BY email) AS copies
FROM customers
) AS c
WHERE copies > 1
ORDER BY id;
-- Output:
-- id | name | email | copies
-- ---+-------------+-----------------------+-------
-- 1 | Maria Lopez | maria.lopez@gmail.com | 2
-- 3 | M. Lopez | maria.lopez@gmail.com | 2
EXISTS를 쓰면 윈도우 함수 없이 같은 결과를 얻을 수 있어요. 같은 이메일을 가진 다른 행이 존재하는 행만 남기면 돼요:
-- Input: the customers table above
SELECT id, name, email
FROM customers c
WHERE EXISTS (
SELECT 1 FROM customers other
WHERE other.email = c.email AND other.id <> c.id
);
-- Output:
-- id | name | email
-- ---+-------------+----------------------
-- 1 | Maria Lopez | maria.lopez@gmail.com
-- 3 | M. Lopez | maria.lopez@gmail.com
언제 중복을 찾아야 할까요?
중복은 가져오기, 더블 클릭, 재시도, 누락된 제약 조건 때문에 생겨요. 중복과 관련해 가장 자주 하게 될 작업 4가지를 소개할게요.
컬럼이 고유한지 확인하기
UNIQUE 제약 조건을 추가하기 전이나, 로그인 또는 조회 결과가 2개씩 나오기 시작할 때는 두 번 이상 나타나는 값을 나열해 보세요. GROUP BY와 HAVING으로 바로 확인할 수 있어요.
관련된 모든 행 보기
어떤 값이 반복되는지만 알아서는 문제를 고칠 수 없어요. 행 전체를 봐야 어떻게 다른지, 어느 쪽이 맞는지 알 수 있죠. 중복된 값을 같은 테이블에 대한 쿼리에 다시 넣어 보세요.
여분의 사본 표시하기
정리하려면 각 중복 집합에서 행 하나만 남기고 나머지는 삭제하세요. ROW_NUMBER()는 각 집합 안의 행에 번호를 매기므로, 1번 이후의 행은 모두 여분의 사본이에요.
중복 제출 잡아내기
버튼을 두 번 클릭하거나 결제를 재시도하면 ID만 다르고 나머지는 모두 같은 행이 2개 남아요. 테이블을 자기 자신과 조인하면 이 행들을 짝지을 수 있고, t1.id < t2.id를 쓰면 각 쌍이 한 번씩만 나와요.
중복 찾기 실전 예제
이중 예약된 호텔 객실 찾기
객실은 하룻밤에 한 번만 예약할 수 있으니, 같은 객실과 같은 날짜의 조합이 중복이에요. 두 컬럼으로 그룹화하세요:
-- Input: bookings
-- booking_id | room | night | guest
-- -----------+------+------------+----------
-- 1 | 204 | 2026-11-06 | R. Okafor
-- 2 | 204 | 2026-11-07 | R. Okafor
-- 3 | 310 | 2026-11-06 | L. Chen
-- 4 | 204 | 2026-11-06 | P. Novak
SELECT room, night, COUNT(*) AS bookings
FROM bookings
GROUP BY room, night
HAVING COUNT(*) > 1;
-- Output:
-- room | night | bookings
-- -----+------------+---------
-- 204 | 2026-11-06 | 2
같은 이메일 주소를 공유하는 계정 나열하기
같은 주소를 대소문자만 다르게 해서 두 번 가입하는 경우가 있어요. LOWER(email)로 파티션하면 두 계정을 나란히 볼 수 있어요:
-- Input: accounts
-- id | username | email
-- ---+----------+----------------
-- 1 | jkim | jin@kim.kr
-- 2 | olivia_r | olivia@reyes.mx
-- 3 | jin.kim | Jin@Kim.kr
-- 4 | noah_b | noah@baker.uk
SELECT id, username, email, accounts_with_email
FROM (
SELECT id, username, email,
COUNT(*) OVER (PARTITION BY LOWER(email)) AS accounts_with_email
FROM accounts
) AS a
WHERE accounts_with_email > 1
ORDER BY id;
-- Output:
-- id | username | email | accounts_with_email
-- ---+----------+------------+--------------------
-- 1 | jkim | jin@kim.kr | 2
-- 3 | jin.kim | Jin@Kim.kr | 2
정리 전에 중복 인물 표시하기
이름과 성이 같은 사용자를 한 사람으로 보고, 가장 오래된 레코드를 남긴 뒤 나머지에 라벨을 붙여요. CTE를 쓰면 번호 매기기와 라벨 붙이기를 분리할 수 있어요:
-- Input: users
-- id | first_name | last_name | email
-- ---+------------+-----------+----------------
-- 1 | Priya | Shah | priya@shah.net
-- 2 | Tom | Becker | tom@becker.de
-- 3 | Priya | Shah | pshah@work.com
-- 4 | Lena | Kowalski | lena@kowal.pl
-- 5 | Tom | Becker | tbecker@mail.de
WITH numbered AS (
SELECT id, first_name, last_name, email,
ROW_NUMBER() OVER (PARTITION BY first_name, last_name ORDER BY id) AS copy_number
FROM users
)
SELECT id, first_name, last_name, email,
CASE WHEN copy_number > 1 THEN 'Duplicate' ELSE 'Original' END AS status
FROM numbered
ORDER BY id;
-- Output:
-- id | first_name | last_name | email | status
-- ---+------------+-----------+-----------------+----------
-- 1 | Priya | Shah | priya@shah.net | Original
-- 2 | Tom | Becker | tom@becker.de | Original
-- 3 | Priya | Shah | pshah@work.com | Duplicate
-- 4 | Lena | Kowalski | lena@kowal.pl | Original
-- 5 | Tom | Becker | tbecker@mail.de | Duplicate
Duplicate로 표시된 행이 병합하거나 삭제할 대상이에요. 삭제 방법은 중복 제거 가이드에서 확인하세요.
실수로 두 번 들어간 주문 찾기
같은 고객이 같은 날 같은 상품을 두 번 주문했다면 아마 한 주문이 두 번 제출된 것일 거예요:
-- Input: orders
-- order_id | customer_id | product | order_date
-- ---------+-------------+-----------+-----------
-- 1001 | 42 | Desk Lamp | 2026-03-02
-- 1002 | 42 | Desk Lamp | 2026-03-02
-- 1003 | 42 | Desk Lamp | 2026-03-09
-- 1004 | 57 | Desk Lamp | 2026-03-02
SELECT o1.order_id AS first_order, o2.order_id AS repeat_order,
o1.customer_id, o1.product
FROM orders o1
JOIN orders o2
ON o1.customer_id = o2.customer_id
AND o1.product = o2.product
AND o1.order_date = o2.order_date
AND o1.order_id < o2.order_id;
-- Output:
-- first_order | repeat_order | customer_id | product
-- ------------+--------------+-------------+----------
-- 1001 | 1002 | 42 | Desk Lamp
중복에 관해 더 읽을거리
이 블로그의 글:
- SQL 중복 제거 가이드: 찾아낸 여분의 사본을 삭제하는 방법
- SQL Server 테이블에서 중복 행 찾기: 같은 문제를 T-SQL로 해결하기
- SQL COUNT DISTINCT 가이드: 중복을 제외하고 값 세기
-
SQL GROUP BY 가이드: 그룹화와
HAVING심층 정리 -
SQL의 Having vs Where: 개수 필터를
HAVING에 넣는 이유 - PostgreSQL에서 고유 제약 조건 만들기: 새로운 중복 막기
- SQL CTE vs 서브쿼리 가이드: 이런 쿼리를 구성하는 두 가지 방법
공식 문서:
- PostgreSQL: GROUP BY와 HAVING, 윈도우 함수
- MySQL: SELECT(GROUP BY, HAVING 포함), ROW_NUMBER()
- SQLite: SELECT(GROUP BY, HAVING 포함), 윈도우 함수
- SQL Server: HAVING, ROW_NUMBER
Beekeeper Studio는 무료 오픈 소스 데이터베이스 GUI예요
지금까지 써 본 SQL 쿼리 & 에디터 도구 중 최고예요. 데이터베이스 관리에 필요한 모든 게 다 있어요. - ⭐⭐⭐⭐⭐ Mit
Beekeeper Studio는 빠르고 직관적이며 사용하기 쉬워요. 다양한 데이터베이스를 지원하고 Windows, Mac, Linux에서 모두 잘 작동해요.
Beekeeper Studio 사용자 후기
"Beekeeper Studio가 기존 SQL 작업 방식을 완전히 대체했어요. 빠르고 직관적이라 데이터베이스 작업이 다시 즐거워졌어요."
"여러 데이터베이스 GUI를 써 봤지만, Beekeeper는 기능과 단순함 사이의 균형이 완벽해요. 그냥 잘 돌아가요."