정산 파일의 일부 행에서만 ArrayIndexOutOfBoundsException이 난 이유
문제 발생
PG사에서 매일 받는 정산 파일을 읽어 DB에 넣는 배치였습니다. 한 줄에 주문번호, 이름, 금액, 결제수단, 메모, 쿠폰코드가 쉼표로 들어옵니다. 열이 고정이라 split으로 충분하다고 생각했습니다.
String[] cols = line.split(",");
String orderId = cols[0];
String coupon = cols[5];몇 달 잘 돌던 배치가 어느 날 중간에 죽었습니다.
java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 4이상한 건 열 순서도 개수도 그대로였다는 점입니다. 실패한 줄을 찾아보니 A001,홍길동,15000,카드,,처럼 메모와 쿠폰코드가 둘 다 비어 있는 줄이었습니다. 쉼표는 다섯 개가 그대로 있으니 칸은 분명 여섯 개인데, split이 돌려준 배열은 길이가 4였습니다. 알고 보니 PG사가 그 주부터 빈 값을 - 대신 아예 비워서 보내기 시작했습니다. 그전까지는 끝 칸이 비어 있는 줄이 한 번도 없었던 겁니다.
원인 분석
split(regex)는 끝에 붙은 빈 문자열을 버립니다. Java String.split(String regex) 문서에 따르면 이 메서드는 limit을 0으로 준 두 인자짜리 split과 같고, 그래서 끝의 빈 문자열은 결과 배열에 포함되지 않는다고 적혀 있습니다. 두 인자 버전의 설명은 더 직접적입니다. limit이 0이면 패턴을 가능한 한 많이 적용하고, 끝의 빈 문자열은 버려진다고요. A001,홍길동,15000,카드,,를 자르면 원래 [A001, 홍길동, 15000, 카드, "", ""]이 되어야 하는데, 끝의 빈 칸 두 개가 잘려서 길이 4가 됩니다.
버리는 건 끝쪽뿐이라 더 헷갈립니다. 앞이나 중간의 빈 칸은 그대로 남습니다. Java 21에서 직접 돌려 보니 이렇게 나왔습니다.
",,a,,".split(",") // [, , a] 길이 3, 앞의 빈 칸은 살아 있음
",,,".split(",") // [] 길이 0, 전부 끝의 빈 칸이라 다 사라짐
"".split(",") // [""] 길이 1",,,"는 칸이 네 개인데 길이 0, 빈 문자열은 칸이 없어 보이는데 길이 1입니다. 마지막 건 문서의 다른 문장 때문입니다. 패턴이 입력의 어디에도 맞지 않으면 결과 배열에는 원래 문자열 하나만 들어간다고요. 빈 줄 하나에 쉼표가 없으니 [""]이 됩니다. 결국 "쉼표 개수 + 1"이라는 직관과 맞는 경우가 오히려 드뭅니다.
다른 언어에서 온 습관이면 더 당하기 쉽습니다. 같은 줄을 Python의 str.split(",")와 JavaScript의 split(",")로 잘라 보면 둘 다 빈 칸 두 개를 살려서 길이 6을 돌려줍니다. Java만 기본값이 다릅니다.
해결 방안
- 열 개수가 의미 있는 데이터라면
limit에 음수를 줍니다. 문서대로limit이 음수면 패턴을 가능한 한 많이 적용하고, 배열 길이에 제한이 없으며 빈 문자열도 버리지 않습니다. 관례적으로-1을 씁니다.
"A001,홍길동,15000,카드,,".split(",", -1) // 길이 6, 끝의 "" 두 개 유지limit에 양수를 주면 의미가 또 다릅니다. 패턴을 최대 limit - 1번만 적용하고 마지막 원소에 나머지를 통째로 넣습니다. "a,b,c,d".split(",", 2)는 [a, b,c,d]입니다. "앞의 몇 칸만 자르고 나머지는 그대로" 같은 경우에 쓰는 옵션이라, 열 개수를 맞추려고 6을 넣으면 의도와 다르게 동작할 수 있습니다.
split의 인자는 정규식이라는 걸 잊지 않습니다. 이번 일 뒤에 구분자를 쉼표에서 파이프로 바꾸자는 얘기가 나왔는데,split("|")로 바꾼 테스트가 바로 깨졌습니다.|는 정규식에서 "또는"이라 빈 패턴끼리의 선택이 되고, 결국 모든 글자 사이에서 잘립니다."A001|홍길동|15000".split("|")는 세 칸이 아니라 글자마다 하나씩, 길이 14짜리 배열이 됐습니다..도 마찬가지라서"1.5.2".split(".")는 모든 글자에서 잘린 뒤 끝의 빈 문자열이 전부 버려져 길이 0이 됩니다. 구분자는Pattern.quote()로 감쌉니다. 문서대로 주어진 문자열을 리터럴로 취급하는 패턴 문자열을 돌려줍니다.
line.split(Pattern.quote("|"), -1)"\\|"로 이스케이프해도 되지만, 구분자를 설정값으로 받는다면 Pattern.quote()가 안전합니다.
- 자른 직후에 열 개수를 검사합니다. 이번 사고가 고약했던 건 배열 길이가 틀린 게 아니라, 틀린 길이가 몇 줄 뒤
cols[5]에서야 드러났다는 점입니다. 길이가 다른 줄은 그 자리에서 몇 번째 줄인지와 함께 실패시킵니다.
String[] cols = line.split(",", -1);
if (cols.length != 6) {
throw new IllegalStateException(lineNo + "번째 줄 열 개수 " + cols.length + ": " + line);
}-1을 줬으니 이제 길이가 6이 아니라는 건 진짜로 파일이 깨졌다는 뜻이 됩니다. 검사의 의미가 분명해집니다.
-
진짜 CSV라면
split으로 자르지 않습니다. CSV 형식을 정리한 RFC 4180에 따르면 줄바꿈, 큰따옴표, 쉼표가 들어간 필드는 큰따옴표로 감싸야 합니다. 주소에"서울, 강남"이 들어오면split(",", -1)은 이걸 두 칸으로 쪼갭니다. 직접 해 보니 세 칸짜리 줄이 네 칸이 됐습니다. 따옴표, 이스케이프된 따옴표, 필드 안의 줄바꿈까지 처리해야 하니, 파일이 CSV라고 불리는 순간부터는 검증된 CSV 파서를 쓰는 게 맞습니다.split은 따옴표가 절대 안 들어온다고 보장된, 형식이 고정된 내부 데이터에만 씁니다. -
언어를 옮길 때
split의 기본값을 먼저 확인합니다. 2번에서 본 정규식 문제와 1번의 빈 문자열 문제는 둘 다 Java의split이 다른 언어와 다르게 동작하는 부분입니다. Python의str.split(sep)은 구분자를 문자열 그대로 쓰고 빈 칸도 남기니, Python 코드를 Java로 옮기면서split을 1:1로 바꾸면 두 가지가 동시에 달라집니다. 이번 일 뒤로 저희 팀은 정산 파일 파싱에 끝이 빈 줄, 앞이 빈 줄, 완전히 빈 줄, 구분자만 있는 줄을 테스트 데이터로 넣어 두었습니다. 위의 결과가 전부 다르게 나왔던 바로 그 입력들입니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.