MariaDB / Tutorial / 중복 데이터 조회하는 방법

MariaDB에서 중복 데이터를 찾으려면 중복 기준 열로 GROUP BY한 뒤 HAVING COUNT(*) > 1로 두 행 이상인 그룹을 고릅니다. 중복된 값과 건수만 필요하면 집계 결과를 보고, ID를 포함한 원본 행이 필요하면 그 결과를 원본 테이블에 다시 JOIN합니다. 먼저 “어떤 열이 같으면 중복인가”와 NULL을 포함할지를 정해야 결과를 올바르게 해석할 수 있습니다.

중복 기준을 정하고 실습 데이터 준비하기

같은 이메일이 여러 번 있는지와 이메일·국가 조합이 같은지는 다른 질문입니다. 기본 키인 id는 모든 행에서 다르므로 중복 기준에 넣으면 이 예제의 중복을 찾을 수 없습니다. 행 전체가 같은지 확인하려면 고유 ID·작성 시각처럼 항상 달라지는 열을 제외하고 업무상 같아야 하는 열을 명시합니다.

CREATE·INSERT·SELECT 권한이 있는 실습용 learning 데이터베이스에서 아래 SQL을 순서대로 실행합니다. duplicate_member_demo가 이미 있다면 별도 이름을 사용하세요. 주소는 실습용 example.test 도메인이며, 같은 이메일을 가진 서로 다른 행을 일부러 넣었습니다. email은 대소문자 차이를 구분하도록 utf8mb4_bin을 지정했습니다.

USE learning;
CREATE TABLE duplicate_member_demo (
    id INT PRIMARY KEY,
    email VARCHAR(80) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL,
    country CHAR(2) NULL,
    display_name VARCHAR(30) NOT NULL
) ENGINE=InnoDB;
INSERT INTO duplicate_member_demo VALUES
    (1,'a@example.test','KR','A first'),
    (2,'a@example.test','KR','A second'),
    (3,'a@example.test','US','A abroad'),
    (4,'b@example.test','KR','B single'),
    (5,NULL,'KR','Unknown first'),
    (6,NULL,'KR','Unknown second'),
    (7,'c@example.test','KR','C first'),
    (8,'c@example.test','KR','C second'),
    (9,'d@example.test',NULL,'D first'),
    (10,'d@example.test',NULL,'D second');
SELECT id, email, country, display_name
FROM duplicate_member_demo ORDER BY id;

총 10행입니다. a@example.test는 세 행이지만 이메일·국가 조합으로 보면 KR 두 행과 US 한 행으로 나뉩니다. NULL은 알 수 없거나 빠진 값이며 빈 문자열과 구분합니다. 이메일이 없다는 이유만으로 같은 사람이라고 단정할 수는 없습니다.

한 열의 중복 값과 건수 조회

SELECT email, COUNT(*) AS row_count
FROM duplicate_member_demo
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY email;

SELECT email, COUNT(*) AS row_count
FROM duplicate_member_demo
WHERE email IS NOT NULL
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY email;

첫 조회는 NULL 2행, a@example.test 3행, c@example.test 2행, d@example.test 2행이라는 네 그룹을 반환합니다. 둘째는 이메일이 알려진 행만 대상으로 하므로 NULL 그룹을 제외한 세 그룹입니다. WHERE는 집계 전에 행을 고르고 HAVING은 집계 후 그룹을 고릅니다. COUNT(*) > 1 같은 집계 조건을 WHERE에 넣지 않습니다.

GROUP BY는 NULL끼리 하나의 그룹으로 묶습니다. 행 수를 세는 COUNT(*)와 NULL이 아닌 값만 세는 COUNT(email)은 다르므로, NULL까지 포함하는 중복 검사에는 COUNT(*)를 사용합니다.

SELECT email, COUNT(*) AS row_count, COUNT(email) AS known_email_count
FROM duplicate_member_demo
GROUP BY email
ORDER BY email;

NULL 그룹은 row_count가 2이지만 known_email_count는 0입니다. 나머지는 a가 3·3, b가 1·1, c와 d가 각각 2·2입니다. 이 차이는 COUNT 공식 문서와 NULL의 그룹화 규칙에서 확인할 수 있습니다.

여러 열의 조합이 같은 행 찾기

SELECT email, country, COUNT(*) AS row_count
FROM duplicate_member_demo
GROUP BY email, country
HAVING COUNT(*) > 1
ORDER BY email, country;

결과는 (NULL,KR,2), (a@example.test,KR,2), (c@example.test,KR,2), (d@example.test,NULL,2)입니다. a@example.test의 US 행은 혼자이므로 빠집니다. 중복 기준이 열 두 개라면 두 값이 모두 같은 그룹을 검사해야 합니다. 문자열을 CONCAT으로 이어 붙여 그룹화하면 구분자 충돌·NULL 처리 때문에 다른 조합이 섞일 수 있으므로 열을 각각 적는 편이 명확합니다.

SELECT * ... GROUP BY email처럼 그룹에 포함되지 않은 ID·이름을 함께 조회해서는 원본 행을 모두 얻을 수 없습니다. ONLY_FULL_GROUP_BY 모드에서는 허용되지 않으며, 그렇지 않아도 어느 행의 값을 가져오는지 정해져 있지 않습니다. GROUP BY 문서에 따라 집계 결과와 원본 행 조회를 구분합니다.

중복 그룹에 속한 원본 행 모두 조회

SELECT m.id, m.email, m.country, m.display_name, d.row_count
FROM duplicate_member_demo AS m
JOIN (
    SELECT email, country, COUNT(*) AS row_count
    FROM duplicate_member_demo
    GROUP BY email, country
    HAVING COUNT(*) > 1
) AS d
  ON m.email <=> d.email
 AND m.country <=> d.country
ORDER BY m.id;

ID 1·2·5·6·7·8·9·10의 8행을 반환하며 각 행의 row_count는 2입니다. 안쪽 쿼리는 중복 조합마다 한 행을 만들고 바깥 JOIN은 그 조합에 속하는 원본 행을 모두 가져옵니다. ID 3과 4는 중복 조합에 속하지 않습니다.

여기서는 NULL 그룹도 포함하기 위해 = 대신 NULL 안전 동등 연산자 <=>를 사용합니다. 양쪽이 모두 NULL이면 참이고 한쪽만 NULL이면 거짓입니다. =로 바꾸면 NULL = NULL이 참이 아니므로 ID 5·6과 9·10을 잃습니다. NULL을 제외하는 업무 규칙이라면 집계와 원본 조회의 필터 범위를 그 규칙에 맞게 통일하세요. 자세한 비교 규칙은 NULL 안전 동등 비교 문서를 참고합니다.

기간·상태 같은 조건으로 일부 데이터만 검사할 때도 범위를 일치시켜야 합니다. 예를 들어 특정 기간의 중복 그룹을 구한 뒤 바깥 원본 조회에 기간 조건을 빼면 같은 키를 가진 기간 밖 행까지 나올 수 있습니다. 조건을 공통 데이터 집합으로 만들거나 양쪽에 동일하게 적용합니다.

중복 그룹 수·관련 행 수·추가 행 수 구분

SELECT COUNT(*) AS duplicate_groups,
       COALESCE(SUM(row_count), 0) AS rows_in_duplicate_groups,
       COALESCE(SUM(row_count - 1), 0) AS extra_rows
FROM (
    SELECT COUNT(*) AS row_count
    FROM duplicate_member_demo
    GROUP BY email, country
    HAVING COUNT(*) > 1
) AS d;

결과는 4·8·4입니다. 중복 조합은 네 개, 그 조합들에 속한 원본 행은 여덟 개입니다. 각 조합마다 한 행을 남긴다고 가정한 추가 행은 네 개입니다. 세 수치는 서로 다르며, extra_rows가 실제 삭제해도 되는 건수를 뜻하지는 않습니다. 중복 그룹이 없으면 COUNT(*)는 0이고 SUM은 NULL이므로 COALESCE로 두 합계도 0으로 표시했습니다.

대소문자·공백과 결과 해석 시 주의점

문자열이 같은지는 콜레이션(Collation), 즉 문자 비교·정렬 규칙의 영향을 받습니다. 다음은 저장 데이터를 바꾸지 않고 대소문자 구분 규칙만 바꾸어 비교하는 예입니다.

SELECT
    (_utf8mb4'Case@example.test' COLLATE utf8mb4_general_ci =
     _utf8mb4'case@example.test' COLLATE utf8mb4_general_ci) AS case_insensitive,
    (_utf8mb4'Case@example.test' COLLATE utf8mb4_bin =
     _utf8mb4'case@example.test' COLLATE utf8mb4_bin) AS case_sensitive;

결과는 1·0입니다. 검사 쿼리만 LOWER·TRIM 등으로 정규화할지, 원본 값 그대로 비교할지는 업무 규칙으로 정합니다. 이메일의 모든 부분이 언제나 대소문자를 무시한다고 가정해 일괄 변경해서는 안 됩니다. 후행 공백의 비교도 자료형·콜레이션의 패딩 규칙에 영향을 받으므로 utf8mb4_bin이라는 이름만 보고 바이트 단위 동일성을 보장한다고 판단하지 않습니다.

DISTINCT는 선택한 열의 중복을 결과에서 한 번만 표시할 뿐, 중복 횟수나 해당 ID 목록을 알려 주지 않습니다. 테이블에 중복이 없는데 JOIN 결과만 늘어난다면 조인 키의 다대다 관계를 먼저 검사합니다. 이 글의 조회는 데이터를 삭제하지 않으며, 삭제가 필요하면 보존할 행·참조 관계·백업·트랜잭션을 별도로 설계해야 합니다.

반복되는 중복을 막을 때는 업무상 고유해야 하는 키에 UNIQUE 제약을 검토하되, MariaDB의 UNIQUE 인덱스는 여러 NULL을 허용하므로 누락값 정책을 함께 정합니다. 큰 테이블은 검사 열 조합의 인덱스와 실행 계획을 확인하고, 운영 중 데이터가 바뀌는 환경에서는 여러 조회 결과를 같은 시점의 데이터로 볼 수 있는지도 고려합니다. 위 실습에서는 데이터를 바꾸는 쿼리가 준비 단계의 INSERT뿐이며 이후 예제는 모두 SELECT입니다.