에너지절약법 보고서를 생성하는 코드에 이런 요건이 있었다. "데이터가 없는 월도 0으로 채워야 한다." 얼핏 단순해 보이는 요건이었다.


발단: 코드를 열었더니

6,573줄짜리 파일에서 동일한 패턴의 함수 3개를 발견했다. 모두 "빈 셀을 0으로 채운다"는 같은 일을 하는데, 구현 방식이 달랐다.

하나는 깔끔했다. pd.merge()를 쓴다. 나머지 둘은 구형 루프 방식이었다.

왜 이렇게 됐을까? 추측하건대, appendix 시트가 나중에 추가되면서 더 나은 방식으로 작성됐고, 먼저 만들어진 1_21_2_b는 리팩토링되지 않은 채 방치된 것이다. 코드베이스에서 흔히 볼 수 있는 장면 — 진화의 흔적이 지층처럼 쌓여 있다.


문제: 600번 비교

구형 루프 코드가 무엇을 하는지 들여다봤다.

for month in all_months:                          # 12회
    for combo in unique_combinations.itertuples():  # 50회
        if not (df["month"] == month & ...).any():  # 매번 1,000행 검색
            results.append(zero_record)

12개월 × 50개 법정 분류 조합 × 1,000행 데이터.

매 루프마다 "이 조합에 해당하는 행이 있나?"를 전체 데이터에서 하나씩 뒤진다. 조합이 50개, 데이터가 1,000행이면 600,000번 비교가 일어난다.

엑셀로 비유하면 이렇다. 600개 빈 셀 후보가 있을 때, 각 셀마다 1,000행짜리 시트를 위에서 아래로 훑으며 "여기 값이 있나?" 확인하는 것이다. 셀 하나하나를 사람이 직접 확인하는 방식.


pandas가 뭘 하는 건지 잠깐

pandas는 표(DataFrame)를 다루는 Python 라이브러리다. 엑셀처럼 생겼지만 코드로 조작한다.

import pandas as pd

# 학생 성적 데이터
df_students = pd.DataFrame({
    "이름": ["철수", "영희", "민준"],
    "과목": ["수학", "수학", "영어"],
    "점수": [90, 85, 78]
})

pd.merge()는 두 표를 특정 기준 컬럼으로 결합하는 연산이다. 엑셀의 VLOOKUP과 비슷하지만, 훨씬 빠르고 여러 컬럼을 동시에 키로 쓸 수 있다.

# 전체 학생 × 과목 조합 표
df_all = pd.DataFrame({
    "이름": ["철수", "철수", "영희", "영희"],
    "과목": ["수학", "영어", "수학", "영어"]
})

# 실제 데이터와 한 번에 결합
df_result = df_all.merge(df_students, on=["이름", "과목"], how="left")
# how="left": 왼쪽 표(전체 조합)를 기준으로 — 오른쪽에 없으면 NaN

결과:

이름  과목  점수
철수  수학  90.0
철수  영어   NaN   ← 철수의 영어 점수 없음
영희  수학  85.0
영희  영어   NaN   ← 영희의 영어 점수 없음

NaN(값 없음)을 0으로 채우면 끝이다. df_result.fillna(0)

루프를 돌며 하나씩 확인할 필요가 없다. 전체 조합표를 미리 만들고, 실제 데이터와 한 번에 대조하면 된다.


개선: 1회 merge

보고서 코드에 같은 방식을 적용하면 이렇게 된다.

# 개선 전: 600,000번 비교
for month in all_months:
    for combo in unique_combinations.itertuples():
        if not (df["month"] == month & ...).any():
            results.append(zero_record)

# 개선 후: 1회 merge
all_keys = pd.MultiIndex.from_product(
    [all_months, unique_combinations["category"].unique()]
)
df_full = pd.DataFrame(index=all_keys).reset_index()
df_full = df_full.merge(df, on=["month", "current_designated_category", ...], how="left")
df_full.fillna(0)

appendix 함수에 이미 이 방식이 구현돼 있다. 새로 발명할 것도 없이, 같은 파일 안에 답이 있었다.

유사 케이스로 이전 포스트에서 33배 성능 차이를 측정한 적이 있다. 알고리즘 개선이 인프라 확장보다 먼저라는 원칙이 여기서도 동일하게 적용된다.


발견한 작은 이상점

코드를 보다가 흥미로운 걸 하나 더 발견했다.

1_21_2_b 함수의 호출부에 # noqa: F841 주석이 붙어 있었다. "지역 변수 미사용" 경고를 억제하는 주석이다.

그런데 바로 다음 줄에서 반환값이 df.groupby()에 쓰이고 있었다. 변수가 실제로 사용되고 있는데 억제 주석이 붙어 있는 것이다. 처음 작성할 때 임시로 붙였다가 코드가 수정된 후에도 주석이 그대로 남은 것으로 보인다.

코드 냄새(code smell)라기보다는 코드 노이즈에 가깝다. 큰 문제는 아니지만, 오해를 유발할 수 있다.


왜 아직 못 고쳤나

적용이 간단하지 않다. 6,573줄 파일이다. 비즈니스 로직이 복잡하게 얽혀 있고, 현재 테스트 커버리지가 충분하지 않다.

성능 개선 코드가 잘못 적용되면, 보고서 숫자가 조용히 틀려질 수 있다. "빈 셀을 채운다"는 단순한 요건 뒤에는 어느 컬럼을 키로 쓸지, 어느 범위까지 0으로 채울지 같은 도메인 지식이 숨어 있다.

지금은 appendix 패턴을 레퍼런스로 삼아, 테스트를 먼저 작성하고 적용하는 순서를 밟을 계획이다. 코드는 이미 어떻게 바꿔야 할지 알고 있다. 다음 단계는 안전하게 바꾸는 것이다.


코드베이스를 읽다 보면 이런 장면이 자주 나온다. 같은 파일 안에 좋은 답과 나쁜 답이 공존한다. 더 나은 방식이 이미 구현돼 있는데, 오래된 코드가 그걸 모른 채 낡은 방식을 고수하고 있다.

고치는 것보다 발견하는 게 먼저다. 오늘은 발견했다.