Published on

Pandas merge KeyError·중복 컬럼 오류 완전 정복

Authors

데이터 분석·ETL 파이프라인에서 pandas.merge 는 사실상 SQL의 JOIN 역할을 합니다. 문제는 실무에서 조인 한 번이 “그냥 붙이면 되겠지”로 끝나지 않는다는 점입니다. 특히 다음 두 가지가 빈번하게 터집니다.

  • KeyError: 조인 키가 한쪽 DataFrame에 없거나, 생각한 이름과 실제 컬럼명이 다름
  • 중복 컬럼 충돌: 조인 키 외에 같은 이름의 컬럼이 양쪽에 존재해 _x, _y 가 생기거나, 이후 처리에서 컬럼 선택이 꼬임

이 글은 merge 를 “안전하게” 쓰기 위한 재현 가능한 진단 절차와, 중복 컬럼을 설계적으로 정리하는 패턴을 제공합니다.

1) merge 가 실패하는 대표 증상

1-1. KeyError 가 나는 전형적인 상황

  • on 으로 지정한 키가 한쪽에 없음
  • left_onright_on 중 하나가 오타
  • CSV 로딩 과정에서 공백이 섞여 실제 컬럼명이 "id " 처럼 됨
  • 인덱스에 키가 있는데 컬럼으로 조인하려고 함

재현 예시:

import pandas as pd

left = pd.DataFrame({
    "user_id": [1, 2, 3],
    "name": ["a", "b", "c"],
})
right = pd.DataFrame({
    "id": [1, 2, 4],
    "age": [10, 20, 40],
})

# left에는 'id'가 없으므로 KeyError
left.merge(right, on="id", how="left")

1-2. 중복 컬럼이 생기는 전형적인 상황

  • 양쪽에 status, created_at, updated_at 같은 공통 컬럼이 있음
  • 조인 키가 아닌데도 이름이 같아 _x, _y 가 자동 생성됨
  • 이후 df["status"] 를 기대했는데 실제로는 status_x, status_y 만 남아 KeyError 또는 로직 오류

재현 예시:

left = pd.DataFrame({
    "user_id": [1, 2, 3],
    "status": ["active", "inactive", "active"],
})
right = pd.DataFrame({
    "user_id": [1, 2, 4],
    "status": ["A", "I", "A"],
    "grade": ["gold", "silver", "gold"],
})

m = left.merge(right, on="user_id", how="left")
print(m.columns.tolist())
# ['user_id', 'status_x', 'status_y', 'grade']

2) KeyError 진단: 조인 전에 반드시 확인할 것

2-1. 컬럼 존재 여부와 “눈에 안 보이는 공백”

컬럼이 있는 것처럼 보여도 공백, 탭, BOM 때문에 다른 이름일 수 있습니다. 가장 먼저 columns 를 그대로 출력하고, 공백 제거를 표준화하세요.

def normalize_columns(df: pd.DataFrame) -> pd.DataFrame:
    df = df.copy()
    df.columns = (
        df.columns
          .astype(str)
          .str.replace("\ufeff", "", regex=False)  # BOM
          .str.strip()                               # 앞뒤 공백
    )
    return df

left = normalize_columns(left)
right = normalize_columns(right)

print(left.columns.tolist())
print(right.columns.tolist())

실무 팁: 파일 인코딩 문제로 컬럼이 깨지면 조인 자체가 실패하거나, 더 위험하게는 “다른 컬럼명”으로 인식됩니다. CSV/TSV 입력에서 예외가 나면 인코딩을 먼저 의심하세요. 관련해서는 Python UnicodeDecodeError 재현·해결 - 인코딩 자동감지 도 같이 참고하면 좋습니다.

2-2. 키가 컬럼이 아니라 인덱스에 있는 경우

merge 는 기본적으로 컬럼 기준입니다. 키가 인덱스라면 left_index=True 또는 reset_index() 를 고려하세요.

left_idx = left.set_index("user_id")

# 인덱스 기준 조인
m = left_idx.merge(right, left_index=True, right_on="user_id", how="left")

# 또는 인덱스를 컬럼으로 되돌린 뒤 조인
m2 = left_idx.reset_index().merge(right, on="user_id", how="left")

2-3. dtype 불일치: KeyError 는 아니지만 “조인 결과가 전부 NaN”

키 컬럼이 한쪽은 int64, 다른 쪽은 object 문자열이면 에러가 아니라 “매칭이 안 되는” 결과가 나옵니다. 이건 더 위험합니다.

left = pd.DataFrame({"user_id": [1, 2, 3]})
right = pd.DataFrame({"user_id": ["1", "2"], "age": [10, 20]})

m = left.merge(right, on="user_id", how="left")
print(m)
# age가 전부 NaN

# 해결: 조인 전에 dtype을 명시적으로 통일
right["user_id"] = right["user_id"].astype("int64")

m2 = left.merge(right, on="user_id", how="left")

진단용으로는 아래처럼 타입과 결측을 한 번에 확인해두면 좋습니다.

def inspect_key(df: pd.DataFrame, key: str) -> None:
    print("dtype:", df[key].dtype)
    print("nulls:", int(df[key].isna().sum()))
    print("nunique:", int(df[key].nunique(dropna=True)))

inspect_key(left, "user_id")
inspect_key(right, "user_id")

3) 중복 컬럼 문제를 ‘설계’로 해결하는 4가지 패턴

중복 컬럼은 단순히 보기 싫은 문제가 아니라, 이후 로직에서 “어느 쪽 값이 맞는지”를 불명확하게 만들어 버그를 유발합니다. 아래 패턴 중 하나를 팀 규칙으로 고정하는 것이 좋습니다.

3-1. suffixes 를 명시해 의미를 부여

기본 _x, _y 는 의미가 약합니다. 데이터 출처를 나타내는 접미사를 쓰면 후속 처리도 명확해집니다.

m = left.merge(
    right,
    on="user_id",
    how="left",
    suffixes=("_profile", "_crm")
)

3-2. 조인 전에 우측 컬럼을 선택적으로 가져오기

가장 깔끔한 방식은 “필요한 컬럼만” 가져오는 것입니다. 특히 우측 테이블에 공통 컬럼이 많을 때 효과가 큽니다.

cols = ["user_id", "grade"]  # 필요한 것만
m = left.merge(right[cols], on="user_id", how="left")

3-3. 조인 전에 컬럼명을 네임스페이스로 리네이밍

우측의 비키 컬럼에 접두사를 붙이면 충돌이 원천 차단됩니다.

key = "user_id"
right_ns = right.rename(columns={
    c: f"crm_{c}" for c in right.columns if c != key
})

m = left.merge(right_ns, on=key, how="left")

이 패턴은 “조인 후 컬럼 정리”보다 안전합니다. 조인 후 정리는 이미 _x, _y 가 생성된 뒤라 실수 여지가 큽니다.

3-4. 중복 컬럼을 병합(coalesce)해 단일 컬럼으로 만들기

예를 들어 status 가 양쪽에 있고, 우측을 신뢰하되 없으면 좌측을 쓰고 싶다면 combine_first 로 합칠 수 있습니다.

m = left.merge(right, on="user_id", how="left", suffixes=("_l", "_r"))

m["status"] = m["status_r"].combine_first(m["status_l"])

m = m.drop(columns=["status_l", "status_r"])

4) validate 로 조인 카디널리티를 강제해 버그를 조기에 잡기

실무에서 더 치명적인 문제는 “에러 없이 돌아가는데 결과가 틀린” 경우입니다. 예를 들어 우측에 키가 중복되어 있으면 left 가 행 폭발(row explosion)합니다.

mergevalidate 파라미터는 조인 관계를 강제합니다.

  • "one_to_one"
  • "one_to_many"
  • "many_to_one"
  • "many_to_many"
# left의 user_id는 유니크, right의 user_id도 유니크하다고 가정
m = left.merge(right, on="user_id", how="left", validate="one_to_one")

만약 right에 중복 키가 있으면 즉시 예외가 나서, 데이터 품질 이슈를 조기에 발견할 수 있습니다.

중복 키를 사전에 확인하려면:

dup = right[right.duplicated("user_id", keep=False)].sort_values("user_id")
print(dup)

5) indicator=True 로 매칭 실패 원인을 추적

조인 결과에서 특정 레코드가 왜 붙지 않았는지 확인하려면 indicator=True 가 매우 유용합니다.

m = left.merge(right, on="user_id", how="left", indicator=True)

# 매칭 실패(좌측만 존재)
unmatched = m[m["_merge"] == "left_only"]
print(unmatched)

이 방식은 단순히 결측을 보는 것보다 훨씬 명확합니다. 특히 키 dtype 불일치, 트리밍 미적용 같은 문제를 빠르게 좁힐 수 있습니다.

6) 실전 체크리스트: merge 전에 이것만은 자동화

아래는 “조인 직전”에 넣어두면 좋은 최소 방어 코드입니다.

import pandas as pd

def safe_merge(
    left: pd.DataFrame,
    right: pd.DataFrame,
    *,
    on: str,
    how: str = "left",
    validate: str | None = None,
    suffixes: tuple[str, str] = ("_l", "_r"),
    normalize_cols: bool = True,
) -> pd.DataFrame:
    if normalize_cols:
        left = left.copy()
        right = right.copy()
        left.columns = left.columns.astype(str).str.replace("\ufeff", "", regex=False).str.strip()
        right.columns = right.columns.astype(str).str.replace("\ufeff", "", regex=False).str.strip()

    if on not in left.columns:
        raise KeyError(f"left missing key: {on}")
    if on not in right.columns:
        raise KeyError(f"right missing key: {on}")

    # dtype 통일(필요 시 정책화)
    if left[on].dtype != right[on].dtype:
        # 무작정 캐스팅이 위험할 수 있어, 여기서는 문자열로 통일하는 예시
        left[on] = left[on].astype("string")
        right[on] = right[on].astype("string")

    return left.merge(
        right,
        on=on,
        how=how,
        validate=validate,
        suffixes=suffixes,
    )

m = safe_merge(left, right, on="user_id", validate="one_to_one")

팀/프로젝트에 따라 dtype 통일 정책은 달라질 수 있습니다. 중요한 건 “정책을 코드로 고정”하는 것입니다.

7) 흔한 함정 Q&A

7-1. KeyError 인데 분명 컬럼이 존재합니다

대부분 다음 중 하나입니다.

  • 컬럼명 앞뒤 공백: str.strip()
  • 보이지 않는 BOM: "\ufeff" 제거
  • 대소문자 불일치: 입력 소스가 제각각이면 str.lower() 로 표준화
  • MultiIndex 컬럼: df.columns 가 단일 문자열이 아닐 수 있음

7-2. 조인 후 행 수가 늘어났습니다

우측 키 중복으로 one_to_many 가 발생했을 가능성이 큽니다. validate="many_to_one" 또는 "one_to_one" 로 강제하고, 중복을 제거하거나 집계한 뒤 조인하세요.

# 예: right를 user_id 기준으로 최신 1건만 남기기
right_sorted = right.sort_values("updated_at")
right_dedup = right_sorted.drop_duplicates("user_id", keep="last")

m = left.merge(right_dedup, on="user_id", how="left", validate="many_to_one")

8) 마무리: merge는 ‘조인’이 아니라 ‘계약’이다

pandas.merge 는 문법만 알면 쉬운데, 결과의 신뢰성은 전적으로 “키의 품질”과 “컬럼 충돌 관리”에 달려 있습니다. 실무에서는 조인을 단발성 작업이 아니라 계약(contract)처럼 다루는 것이 안전합니다.

  • 조인 키 존재 여부를 자동 검증
  • dtype, 공백, BOM 같은 입력 변칙을 표준화
  • validate 로 카디널리티를 고정
  • 중복 컬럼은 suffixes 또는 네임스페이스 리네이밍으로 설계적으로 제거
  • indicator=True 로 매칭 실패를 추적

디버깅 관점에서 보면, 이런 문제들은 원인이 한 번에 드러나지 않아 “증상은 단순한데 해결은 오래 걸리는” 유형입니다. 비슷한 결의 트러블슈팅 글로는 Git rebase 후 PR에 커밋이 중복될 때 원인·복구 도 참고할 만합니다. 겉보기 증상보다 근본 원인을 구조적으로 고정하는 접근이 결국 시간을 절약합니다.