확장자를 rstrip으로 뗐더니 sales.csv가 sale이 된 이유
문제 발생
매일 밤 exports/ 폴더에 쌓인 CSV를 Parquet로 바꿔 분석용 폴더에 넣는 배치였습니다. 확장자만 바꾸면 되는 일이라 코드도 짧았습니다.
for path in glob.glob("exports/*.csv"):
name = os.path.basename(path).rstrip(".csv")
df = pd.read_csv(path)
df.to_parquet(f"warehouse/{name}.parquet")몇 주 뒤 분석 쪽에서 연락이 왔습니다. orders 테이블이 없다고요. 폴더를 열어 보니 order.parquet, sale.parquet, metri.parquet가 있었습니다. 복수형 이름 끝의 s가 하나같이 잘려 있었고, metrics는 cs까지 날아갔습니다. 여기까진 이름만 이상한 수준이었는데, 진짜 문제는 그다음이었습니다. 회원 테이블 users.csv와 사용자 설정 테이블 user.csv가 둘 다 user.parquet가 되면서 하나가 다른 하나를 덮어쓰고 있었습니다. 어떤 날은 회원 데이터가, 어떤 날은 설정 데이터가 남아 있어서 대시보드 숫자가 날마다 들쭉날쭉했습니다. 버전 관리 테이블 vcs.csv는 아예 이름이 통째로 사라져 .parquet라는 숨김 파일이 돼 있었습니다.
원인 분석
rstrip의 인자는 접미사가 아니라 문자 집합입니다. Python 문서의 str.rstrip() 설명이 이걸 분명히 적어 둡니다. chars 인자는 제거할 문자들의 집합을 지정하는 문자열이고, 접미사가 아니라 그 값들의 모든 조합이 제거된다고요. 문서 예제가 이 함정을 그대로 보여 줍니다. 'Monty Python'.rstrip(' Python')은 'Monty'가 아니라 'M'입니다. ' Python'에 든 글자인 공백, P, y, t, h, o, n이 끝에서부터 계속 지워지다가 집합에 없는 M을 만나서야 멈춥니다.
rstrip(".csv")도 똑같습니다. 이 코드가 지우는 건 .csv라는 문자열이 아니라 ., c, s, v 네 글자 중 아무거나입니다. sales.csv를 끝에서부터 보면 v, s, c, .이 지워지고, 그 앞의 s도 집합에 있으니 같이 지워지고, e에서 멈춥니다. 그래서 sale이 됩니다. metrics.csv는 s, c까지 먹혀 metri가 되고, vcs.csv는 모든 글자가 집합에 들어 있어서 빈 문자열이 됩니다.
>>> "sales.csv".rstrip(".csv")
'sale'
>>> "users.csv".rstrip(".csv"), "user.csv".rstrip(".csv")
('user', 'user')
>>> "vcs.csv".rstrip(".csv")
''report.csv처럼 확장자 앞 글자가 집합에 없는 이름은 멀쩡히 report가 나옵니다. 테스트를 이런 이름으로만 했으니 통과할 수밖에 없었습니다.
덮어쓰는 쪽이 날마다 바뀐 건 glob 때문입니다. glob.glob() 문서에 따르면 결과가 정렬되는지는 파일 시스템에 달려 있습니다. users.csv와 user.csv 중 어느 쪽이 나중에 처리되느냐에 따라 남는 데이터가 달라졌던 겁니다.
이 혼동은 워낙 흔해서 아예 새 메서드가 생겼습니다. removeprefix()와 removesuffix()를 추가한 PEP 616의 배경 설명이 바로 이겁니다. 사용자들이 lstrip과 rstrip에 새 메서드 같은 동작을 기대했다가, 인자가 부분 문자열이 아니라 문자 집합으로 해석된다는 데 놀란다고요. lstrip("https://")로 URL 앞부분을 떼면 https://shop.example.com이 op.example.com이 되는 것도 같은 이유입니다. shop의 s와 h가 https://의 글자 집합에 들어 있어서 함께 지워집니다.
해결 방안
- 접미사를 떼려면
removesuffix()를 씁니다. Python 3.9에 추가됐고, 문서대로 문자열이 그 접미사로 끝날 때만 그 길이만큼 잘라 냅니다. 아니면 원래 문자열을 그대로 돌려줍니다. 앞쪽은removeprefix()입니다.
>>> "sales.csv".removesuffix(".csv")
'sales'
>>> "vcs.csv".removesuffix(".csv")
'vcs'
>>> "https://shop.example.com".removeprefix("https://")
'shop.example.com'딱 한 번만 떼고 대소문자를 구분한다는 점은 알아 둡니다. "a.csv.csv"는 "a.csv"가 되고, "SALES.CSV"는 아무것도 안 바뀝니다.
- 파일 이름이라면
pathlib에 맡깁니다. 확장자를 바꾸는 게 목적이었으니 처음부터 경로 도구를 쓰는 게 맞았습니다.Path.stem은 마지막 확장자를 뺀 이름이고,with_suffix()는 확장자를 바꾼 새 경로를 돌려줍니다. 문서 예제대로library.tar.gz의stem은library.tar,suffix는.gz입니다.
from pathlib import Path
for src in Path("exports").glob("*.csv"):
dst = Path("warehouse") / src.with_suffix(".parquet").name
pd.read_csv(src).to_parquet(dst)이번 일을 겪고 나서 출력 이름이 겹치는지도 쓰기 전에 검사하도록 바꿨습니다. 이름을 만드는 로직이 틀려도 덮어쓰기만은 막자는 생각입니다. 대상 이름을 먼저 전부 만들어 보고, 중복이 있으면 아무것도 쓰지 않고 멈춥니다. 조용히 덮어쓰는 것보다 배치가 멈추는 쪽이 훨씬 빨리 알아챌 수 있습니다.
-
3.8 이하라면
endswith()로 확인하고 잘라 내되, 빈 접미사를 조심합니다.if s.endswith(suffix): s = s[:-len(suffix)]가 기본 형태입니다. 그런데suffix가 빈 문자열이면endswith("")는 항상 참이고,s[:-0]은s[:0]과 같아서 빈 문자열이 나옵니다.removesuffix()문서가 "접미사가 비어 있지 않을 때"라는 조건을 굳이 적어 둔 이유가 이겁니다. 직접 구현할 때는if suffix and s.endswith(suffix):로 씁니다. -
린터에 맡기되, 다 잡아 주지는 않는다는 걸 압니다. Ruff의
B005(flake8-bugbear에서 온 규칙)가strip,lstrip,rstrip에 여러 글자짜리 문자열을 넘기는 코드를 잡아 줍니다. 다만 문서에 적힌 대로 이 규칙은 휴리스틱이라서 같은 글자가 두 번 이상 들어 있는 문자열만 표시합니다..strip("xyz")처럼 의도적으로 문자 집합을 넘기는 코드를 살리기 위해서입니다. 직접 돌려 보니lstrip("https://")와rstrip(".txt")는 잡혔는데, 이번 사고의 주인공인rstrip(".csv")는 글자가 겹치지 않아서 그냥 통과했습니다. 린터를 켜 두는 건 좋지만, 규칙은 "인자가 단어처럼 보이면strip이 아니다"로 사람이 기억해 두는 게 확실합니다. -
SQL의
TRIM도 DB마다 뜻이 다릅니다. 같은 일을 DB에서 하다가 또 걸릴 수 있습니다. PostgreSQL 문서의trim은 지정한 문자들로만 이뤄진 가장 긴 문자열을 지우는, Python의strip과 같은 문자 집합 방식입니다. PostgreSQL 16에서 직접 돌려 보니trim(trailing '.csv' from 'sales.csv')가sale을 돌려줬습니다. 반면 MySQL 문서의TRIM은remstr접두사나 접미사를 전부 지우는 문자열 방식이고, 예제도TRIM(TRAILING 'xyz' FROM 'barxxyz')가barx입니다. 같은 구문이 DB에 따라 다른 결과를 냅니다. PostgreSQL에서 접미사를 떼려면regexp_replace(name, '\.csv$', '')처럼 끝에 고정된 패턴을 씁니다.
공식 문서
- Python: str.rstrip(), str.lstrip(), str.strip()
- Python: str.removesuffix(), str.removeprefix()
- PEP 616 – String methods to remove prefixes and suffixes
- Python: pathlib — PurePath.stem, suffix, with_suffix()
- Python: glob.glob()
- Ruff: strip-with-multi-characters (B005)
- PostgreSQL: String Functions — trim
- MySQL: String Functions — TRIM()
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.