[업무일지] 빈 문자열이 통과시킨 계좌 검증
예금주명 검증이 특정 사업자에서만 계속 틀려서 원인을 찾다가, 옆에 있던 걸 봤습니다. 접두어와 공백을 지운 결과가 빈 문자열이 되면
contains가 항상 참이라 통과하면 안 되는 건이 통과하고 있었습니다. 실패가 아니라 통과라서 로그에도 안 남았고요.
계좌를 등록할 때 사용자가 입력한 이름과 은행에서 조회한 예금주명이 같은지 확인합니다. 같은 회사인데도 표기가 제각각이라 그대로 비교할 수가 없거든요. 주식회사 가상상사, ㈜가상상사, 가상상사 가 다 들어옵니다.
그래서 접두어와 공백을 지우고 비교합니다. Java 21 환경입니다.
1
2
3
String cleanedUser = removePrefix(removeSpace(userName));
String cleanedOwner = removePrefix(removeSpace(bankOwnerName));
return cleanedUser.contains(cleanedOwner) || cleanedOwner.contains(cleanedUser);
전각 공백을 찾다가 로직을 다시 봤다
원래 잡으려던 건 다른 문제였습니다. 특정 사업자만 계속 불일치가 났는데 로그를 찍어보면 두 값이 똑같이 나왔습니다. 주식회사 가상상사 하고 주식회사 가상상사. 내가 생각한 거랑 좀 다른데.
코드포인트로 찍어보니 공백이 달랐습니다. 한쪽은 0x20, 다른 쪽은 U+3000 전각 공백이었습니다. 그리고 자바 정규식의 \s 는 [ \t\n\x0B\f\r] 만 잡습니다. U+3000 은 여기에 없습니다. 저는 \s 가 그냥 모든 공백인 줄 알고 있었고요.
1
2
// 전각 공백(U+3000) 포함
private static final Pattern REMOVE_EMPTY_SPACE_PATTERN = Pattern.compile("[\\s\\u3000]+");
이건 금방 끝났습니다. 문제는 테스트 케이스를 채우면서 위 비교 로직을 다시 읽었을 때였습니다.
빈 문자열은 모든 문자열에 포함된다
접두어와 공백을 다 지우고 나서 아무것도 안 남으면 어떻게 되나.
1
"가상상사".contains("") // true
자바에서 빈 문자열은 모든 문자열에 포함됩니다. String.contains 는 내부적으로 indexOf 를 쓰고 빈 문자열의 첫 등장 위치는 0 이니까 당연한 결과입니다. 특별한 예외 처리가 있는 게 아니라 정의상 그런 겁니다. startsWith("")·endsWith("") 도 같은 이유로 참이고요.
그러니까 은행에서 내려온 예금주명이 주식회사 처럼 접두어만 들어오면 정규화 후에 빈 문자열이 되고, 그 다음 줄에서 검증이 무조건 통과합니다. 계좌 검증인데요. 통과하면 안 되는 게 통과합니다.
통과하는 버그라 로그에도 알림에도 안 남는다
이게 이 버그의 성격입니다. 검증 로직이 터지면 에러 로그가 남고 알림이 옵니다. 그런데 이건 통과합니다. 예외도 안 나고 응답도 정상이고 로그에 아무것도 안 남습니다. 지표로도 안 잡히죠. 검증 실패율이 올라가는 게 아니라 오히려 내려가니까요.
원래 잡으려던 전각 공백 버그는 반대였습니다. 검증이 실패하니까 사용자가 막히고, 막히니까 문의가 들어오고, 그래서 제가 보게 된 겁니다. 증상이 있는 쪽이 먼저 발견되고 조용한 쪽은 그대로 남아 있었던 거죠.
가드를 넣고 테스트로 고정했습니다.
1
2
3
if (cleanedUser.isEmpty() || cleanedOwner.isEmpty()) {
return false;
}
false 로 막는 게 맞는지 잠깐 고민했습니다. 예금주명이 접두어만 온 건 은행 응답이 이상한 상황이라 예외를 던져서 드러내는 쪽도 됩니다. 그런데 이 경로는 검증 실패를 사용자에게 안내하는 흐름이 이미 있어서 새 예외를 끼우면 그 처리를 다 손봐야 했습니다. 그래서 실패로 떨어뜨리는 쪽을 골랐습니다.
가드는 비교 유틸 안에 넣었다
정규화 함수는 빈 문자열을 그대로 돌려주고, 그걸 참으로 볼지는 비교하는 쪽이 정하게 한 거죠. 예금주 비교가 전부 이 유틸을 거쳐서, 한 곳을 고치니 그 경로가 전부 같이 막혔습니다.
양방향 contains 는 레거시 서버에서 옮겨 오면서 그대로 가져온 형태입니다. 부분 일치를 어디까지 허용할지는 이유가 기록에 남아 있지 않아서 이번 범위에서 건드리지 않았습니다.