본문으로 건너뛰기
개발 머꼬
개발 노트Java
hohyeon.dev22

정산 파일의 일부 행에서만 ArrayIndexOutOfBoundsException이 난 이유

  • #Common Pitfall
  • #Engineering Note
  • #Java

문제 발생

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만 기본값이 다릅니다.

해결 방안

  1. 열 개수가 의미 있는 데이터라면 limit에 음수를 줍니다. 문서대로 limit이 음수면 패턴을 가능한 한 많이 적용하고, 배열 길이에 제한이 없으며 빈 문자열도 버리지 않습니다. 관례적으로 -1을 씁니다.
"A001,홍길동,15000,카드,,".split(",", -1)   // 길이 6, 끝의 "" 두 개 유지

limit에 양수를 주면 의미가 또 다릅니다. 패턴을 최대 limit - 1번만 적용하고 마지막 원소에 나머지를 통째로 넣습니다. "a,b,c,d".split(",", 2)는 [a, b,c,d]입니다. "앞의 몇 칸만 자르고 나머지는 그대로" 같은 경우에 쓰는 옵션이라, 열 개수를 맞추려고 6을 넣으면 의도와 다르게 동작할 수 있습니다.

  1. split의 인자는 정규식이라는 걸 잊지 않습니다. 이번 일 뒤에 구분자를 쉼표에서 파이프로 바꾸자는 얘기가 나왔는데, split("|")로 바꾼 테스트가 바로 깨졌습니다. |는 정규식에서 "또는"이라 빈 패턴끼리의 선택이 되고, 결국 모든 글자 사이에서 잘립니다. "A001|홍길동|15000".split("|")는 세 칸이 아니라 글자마다 하나씩, 길이 14짜리 배열이 됐습니다. .도 마찬가지라서 "1.5.2".split(".")는 모든 글자에서 잘린 뒤 끝의 빈 문자열이 전부 버려져 길이 0이 됩니다. 구분자는 Pattern.quote()로 감쌉니다. 문서대로 주어진 문자열을 리터럴로 취급하는 패턴 문자열을 돌려줍니다.
line.split(Pattern.quote("|"), -1)

"\\|"로 이스케이프해도 되지만, 구분자를 설정값으로 받는다면 Pattern.quote()가 안전합니다.

  1. 자른 직후에 열 개수를 검사합니다. 이번 사고가 고약했던 건 배열 길이가 틀린 게 아니라, 틀린 길이가 몇 줄 뒤 cols[5]에서야 드러났다는 점입니다. 길이가 다른 줄은 그 자리에서 몇 번째 줄인지와 함께 실패시킵니다.
String[] cols = line.split(",", -1);
if (cols.length != 6) {
    throw new IllegalStateException(lineNo + "번째 줄 열 개수 " + cols.length + ": " + line);
}

-1을 줬으니 이제 길이가 6이 아니라는 건 진짜로 파일이 깨졌다는 뜻이 됩니다. 검사의 의미가 분명해집니다.

  1. 진짜 CSV라면 split으로 자르지 않습니다. CSV 형식을 정리한 RFC 4180에 따르면 줄바꿈, 큰따옴표, 쉼표가 들어간 필드는 큰따옴표로 감싸야 합니다. 주소에 "서울, 강남"이 들어오면 split(",", -1)은 이걸 두 칸으로 쪼갭니다. 직접 해 보니 세 칸짜리 줄이 네 칸이 됐습니다. 따옴표, 이스케이프된 따옴표, 필드 안의 줄바꿈까지 처리해야 하니, 파일이 CSV라고 불리는 순간부터는 검증된 CSV 파서를 쓰는 게 맞습니다. split은 따옴표가 절대 안 들어온다고 보장된, 형식이 고정된 내부 데이터에만 씁니다.

  2. 언어를 옮길 때 split의 기본값을 먼저 확인합니다. 2번에서 본 정규식 문제와 1번의 빈 문자열 문제는 둘 다 Java의 split이 다른 언어와 다르게 동작하는 부분입니다. Python의 str.split(sep)은 구분자를 문자열 그대로 쓰고 빈 칸도 남기니, Python 코드를 Java로 옮기면서 split을 1:1로 바꾸면 두 가지가 동시에 달라집니다. 이번 일 뒤로 저희 팀은 정산 파일 파싱에 끝이 빈 줄, 앞이 빈 줄, 완전히 빈 줄, 구분자만 있는 줄을 테스트 데이터로 넣어 두었습니다. 위의 결과가 전부 다르게 나왔던 바로 그 입력들입니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.