서론
실무에서 코드리뷰를 하다가 String 함수 관련해서 개발자 의도와 다르게 작동할 수 있는 코드를 발견했습니다.
이 부분에 대해 바로 피드백드리고 정정할 수 있도록 가이드를 제공해 드렸는데요.
어떤 부분인지 어떻게 올바른 코드를 작성할 수 있는지 알아보겠습니다.
Java 17, SpringBoot3를 기준으로 작성되었습니다.
배경
현재 회사에서 수개월의 설계를 끝낸 후 아주 활발하게 개발이 이뤄지고 있습니다.
저와 함께 협력하는 개발자는 같은 회사 동료도 있고 다른 회사에서 협력 중이신 분들도 계십니다.
현재 개발에 들어간 지 겨우 1개월 정도인데요. (3월에 시작)
모든 동료분들이 Java/Spring에 익숙한 상황이 아니기에 각 파트별로 기술적 논의와 표준을 정의하는 담당자를 배정했고 제가 그 역할을 하게 되었습니다.
우선 첫 2~3개월은 다들 어떤 방식으로 개발을 하시는지 살펴보고 제가 어떤 스탠스를 취해야 할지 자체적으로 생각해 보는 기간을 가지기로 했어요.
어라.. 그런데 1개월이 지난 현재, 생각이 바뀐 계기가 있었어요.
당장 세세한 코드리뷰를 시작해야겠다.
"이거 이거 큰 문제가 되겠는걸.." 했던 코드가 있었어요.
문제의 코드
그 계기가 되었던 코드를 볼까요?
사실 Java 생태계에 익숙하신 분들이라면 단번에 문제를 알 수 있을 거라 생각해요.
public void method(String param) {
if(param.isBlank() || param == null) { // ... 1
// ...2
}
// ...3
}
자, 위 코드는 어떻게 작동할까요?
어떤 문제가 있을까요
잠깐 코드를 보고 고민해 봅시다.
조건문의 목적
이 조건문의 목적은 명확합니다.
문자열이 비어있거나 null인 경우 조건문 안의 로직을 수행하는 것이죠.
근데 정말 이 목적을 달성할 수 있을까요?
여기서 우리는 isBlank와 같은 java.lang의 String에서 제공하는 기본 함수 역할에 대해 짚고 넘어가야 합니다.
정답 스포일러
intelliJ에서는 친절하게도 이 코드는 절대 실행되지 않는다고 알려주고 있네요.
param이 null이라면 isBlank 함수에서 무조건 NullPointerException이 발생합니다. 그래서 뒤의 null 검증은 전혀 이뤄지지 않게 되는 거죠.
이번 문제의 정답은 String의 함수는 NULL Safety 하지 않음을 알려드리려고 했어요.
동시에, 조건문 내 순서의 중요성도 짚고 넘어가면 좋겠네요. 물론, 이 글에서 조건문 순서에 대해선 다루지 않아요.
String 함수가 NULL Safety하지 않는 이유에 대해 자세히 알아볼까요?
String 함수 분석
isBlank 함수
String.java 내 isBlank 함수를 먼저 살펴봅시다.
/**
* Returns {@code true} if the string is empty or contains only
* {@linkplain Character#isWhitespace(int) white space} codepoints,
* otherwise {@code false}.
*
* @return {@code true} if the string is empty or contains only
* {@linkplain Character#isWhitespace(int) white space} codepoints,
* otherwise {@code false}
*
* @see Character#isWhitespace(int)
*
* @since 11
*/
public boolean isBlank() {
return indexOfNonWhitespace() == length();
}
주석을 보면 문자열이 비어있거나 공백만 존재하는 경우 true를 반환하고 그렇지 않으면 false를 반환한다고 되어 있습니다.
String empty = "";
String blank = " ";
위 두 가지 경우 모두 true에 해당하게 되는 것이죠.
내부적으로 indexOfNonWhitespace(), length() 함수를 호출하고 있는데 각 함수가 반환하는 값이 같다면 blank 상태이고 그렇지 않다면 blank가 아님을 나타내는데요.
이 두 함수에 대해서도 살펴보겠습니다.
indexOfNonWhitespace, length 함수
내부 구조는 크게 어렵지 않습니다.
private int indexOfNonWhitespace() {
return isLatin1() ? StringLatin1.indexOfNonWhitespace(value)
: StringUTF16.indexOfNonWhitespace(value);
}
/**
* Returns the length of this string.
* The length is equal to the number of <a href="Character.html#unicode">Unicode
* code units</a> in the string.
*
* @return the length of the sequence of characters represented by this
* object.
*/
public int length() {
return value.length >> coder();
}
byte coder() {
return COMPACT_STRINGS ? coder : UTF16;
}
indexOfNonWhitespace에서는 한글이나 영어는 UTF16으로 분류되어 해당 함수가 실행될 것이고 처음으로 공백이 아닌 문자의 인덱스를 반환하는 함수입니다.
length는 여러분들이 잘 아시는 문자열의 전체 길이를 반환하는 함수죠.
만약 " "와 같은 공백 문자가 입력되었다면 indexOfNonWhitespace 함수는 1을 반환하게 됩니다.
공백으로만 이뤄져 있는데 왜 1이 반환될까요?
그 이유는 아래 코드를 보고 간단히 설명해 볼게요.
// StringUTF16.java
public static int indexOfNonWhitespace(byte[] value) {
int length = value.length >> 1;
int left = 0;
while (left < length) {
int codepoint = codePointAt(value, left, length);
// 공백이 아니라면(유효한 문자라면) index 반환
if (codepoint != ' ' && codepoint != '\t' && !Character.isWhitespace(codepoint)) {
break;
}
left += Character.charCount(codepoint); // 공백이라면 인덱스 증가
}
return left;
}
공백이라면 left가 계속해서 증가하는 게 보이시나요?
즉, 모든 문자열이 공백으로만 이뤄져 있다면 결국 length와 동일하게 문자열 전체 길이를 반환하게 됩니다.
간단한데 재밌는 원리이죠.
String 문자열의 관리
우리가 선언하는 String 문자열은 코드 어디에 저장될까요?
내부적으로 value라는 필드 변수가 선언되어 있는데 이렇게 선언되어 있답니다.
/**
* The value is used for character storage.
*
* @implNote This field is trusted by the VM, and is a subject to
* constant folding if String instance is constant. Overwriting this
* field after construction will cause problems.
*
* Additionally, it is marked with {@link Stable} to trust the contents
* of the array. No other facility in JDK provides this functionality (yet).
* {@link Stable} is safe here, because value is never null.
*/
@Stable
private final byte[] value;
문자열이 생성될 때, JVM은 문자열의 값을 value 필드에 저장하고, 이 문자열 인스턴스를 JVM 힙 메모리에 추가합니다.
가장 하단의 주석이 보이시나요?
value는 절대 null이 아니야
하지만 우리가 선언하는 String은 언제나 null일 수 있죠.
String은 Java의 대표적인 문자열을 다루는 래퍼 클래스에요.
반대로 기본 타입엔 byte, short, int, long, float, double, boolean, char가 있죠.
기본 타입은 절대 null이 될 수 없어요.
즉, String 클래스 안에 선언된 isBlank, isEmpty 등 모든 함수들은 null이 아님을 보장할 때 작동하게 됩니다.
어떻게 해야 할까?
만약, 웹 애플리케이션이고 앞단에 Controller가 있다면 요청 DTO 필드에서 Validation을 활용해 Service Layer로 null이 들어오지 않도록 하는 방법도 있겠죠.
하지만 꼭 String 객체가 외부에서 들어오는 값이 아닐 수 있어요.
내 코드 어딘가에서 생성되고 있겠죠.
여러 대안이 존재하는데요. 상황에 맞게 선택하시면 되겠습니다.
Java8 Optional 함수 이용
String str = null;
boolean isBlank = Optional.ofNullable(str).map(String::isBlank).orElse(true);
위의 코드에서 Optional.ofNullable(str)는 주어진 문자열을 Optional 객체로 감쌉니다. 만약 문자열이 null이라면, Optional.empty()가 반환돼요.
map(String::isBlank)는 Optional이 감싸고 있는 문자열에 isBlank()를 안전하게 호출합니다. 만약 Optional이 비어있다면 (즉, 원래의 문자열이 null이었다면), 이 map 연산은 아무런 효과가 없습니다.
마지막으로, orElse(true)는 Optional이 비어있을 때 (즉, 원래의 문자열이 null이었을 때) 반환할 기본값을 지정합니다. 여기서는 문자열이 null이면 공백으로 간주하므로 true를 반환하도록 했어요.
이렇게 하면, NULL Safety 하게 함수를 호출할 수 있게 됩니다.
Java11 Objects.isNull 함수 이용
String str = null;
boolean isBlank = Objects.isNull(str) || str.isBlank();
위의 코드에서 Objects.isNull(str)는 문자열이 null인지 확인하고, str.isBlank()는 문자열이 비어 있거나 공백 문자만 포함하는지 확인합니다. 두 조건 중 하나라도 만족하면 isBlank는 true가 됩니다.
보다 간결해지고 가독성이 올라간 것을 볼 수 있어요.
Util 클래스 이용 ⭐
제가 권장했던 방법인데요.
직접 로직을 구현하기보다 이미 검증된 함수를 이용하는 방식을 안내드렸어요.
- org.apache.commons.lang3.StringUtils
- org.apache.logging.log4j.util.Strings
- io.micrometer.common.util.StringUtils
위 클래스 외에도 더 많은 검증된 오픈소스나 라이브러리가 존재할거에요.
내부 isBlank 함수를 살펴보면 구조는 비슷합니다.
공통점은 모두 null을 먼저 검증하고 있어요.
더 궁금하시다면 각 코드를 비교해 보는 것도 도움이 될 거예요.
자세히 들여다보면 비슷한 듯 다른 모습을 띄고 있거든요.
마무리
간단한 듯하면서도 실수할 수 있는 부분인 String 클래스와 Null Safety에 대해 살펴봤습니다.
여러 경험과 협업을 하다 보면 "당연히 알아야 하는 것"은 없더라구요.
결국 어떤 문제 상황이 발생하면 어떻게 그 상황을 대처할지, 앞으로 어떻게 재발을 방지할지를 먼저 고민해야 하고
어떻게 해야 효율적으로 효과적으로 가이드할 수 있을지 고민하게 되는 요즘입니다.
저는 앞으로 이런 상황을 방지하기 위해 4월부터 당분간은 세세한 코드리뷰를 진행하기로 했어요.
이런 비슷한 상황이 발생하면 어떤 문제가 있는지, 더 나은 코드를 작성하는 방법을 가이드하려고요.
개발과 운영, 리뷰까지 병행한다면 분명 리소스가 더 들어가긴 하지만 결국 장기적인 관점에선 협업하는 모두가 상향평준화 될 수 있고 같은 실수를 미연에 방지할 수 있다는 이점이 있다고 생각합니다.
혼자 개발하는 것도 아니구요.
저도 누군가의 도움을 받았던 순간이 있으니까요.
그럼 오늘도 행복코딩하세요.
감사합니다.
'Tech > Java&Spring' 카테고리의 다른 글
Java10 무분별한 var를 지양해야 하는 이유 (2) | 2023.06.18 |
---|---|
멀티스레드 분산 환경에서의 로깅(2) (0) | 2023.06.04 |
멀티스레드 분산 환경에서의 로깅(1) (0) | 2023.05.21 |
JPA saveAll이 Bulk INSERT 되지 않았던 이유 (3) | 2023.04.05 |
try-with-resources와 native 영역 (0) | 2023.03.11 |
인프런 지식공유자로 활동하고 있으며 MSA 전환이 취미입니다. 개발과 관련된 다양한 정보를 몰입감있게 전달합니다.