
Java 개발을 하다 보면 문자열을 조합해야 하는 상황이 자주 발생한다. 이때 두 가지 주요한 방법이 있는데
// 방법 1: 문자열 연결
String result = "Hello " + World + "!";
// 방법 2: String.format 사용
String result = String.format("Hello %s!", World);
이 두 가지를 사용한다.
둘 다 같은 결과를 만들어내지만, 내부적으로는 어떤 차이가 있을까? 성능상 차이도 있지 않을까?
이런 궁금증에서 시작해 실제 Spring Boot 프로젝트 개선 사항에 오픈소스 기여 PR을 생성하기
까지의 과정을 담았다.
엄청 예전에 작성된 두 글이다.
1. String.format() vs "+" operator
2. Is it better practice to use String.format over string Concatenation in Java?
다양한 사람들의 토론과 테스트가 진행되었는데 여기서 퍼포먼스 관련 테스트를 한 댓글을 보면
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
for(int i = 0; i < 1000000; i++){
String s = "Hi " + i + "; Hi to you " + i*2;
}
long end = System.currentTimeMillis();
System.out.println("Concatenation = " + ((end - start)) + " millisecond") ;
start = System.currentTimeMillis();
for(int i = 0; i < 1000000; i++){
String s = String.format("Hi %s; Hi to you %s",i, + i*2);
}
end = System.currentTimeMillis();
System.out.println("Format = " + ((end - start)) + " millisecond");
}
를 통해 다음과 같은 결과를 얻었다고 한다.
// 1백만 번 반복 테스트 결과
Concatenation = 265 millisecond
Format = 4141 millisecond
즉, Concatenation 문자열 연결이 String.format 보다 약 15배 빠른 결과를 보여준다.
(이 성능 테스트 결과는 특정 환경에서의 측정값이며, 실제 애플리케이션에서는 다양한 요인이 성능에 영향을 줄 수 있다.)
먼저 내부 동작을 비교해보자
Formatte 객체 생성StringBuilder 생성 및 변환StringBuilder 로 변환
String str = "A" + var + "B";
// 이 코드는 컴파일러에의해 아래처럼 변환됨
String str = new StringBuilder("A").append(var).append("B").toString();
String 클래스 내부 코드를 보자
public final class String implements Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
private final byte coder; // LATIN1 또는 UTF16
static final boolean COMPACT_STRINGS;
static final byte LATIN1 = 0;
static final byte UTF16 = 1;
}
Java 9부터 String은 더 이상 char[]가 아닌 byte[]로 문자를 저장합니다. 이는 메모리 효율성을 위한 것으로, Latin-1 문자들은 1바이트로, 그 외는 UTF-16으로 저장됩니다.
JDK 소스코드 주석에 이런것이 적혀있다.
/**
* @implNote The implementation of the string concatenation operator is left to
* the discretion of a Java compiler, as long as the compiler ultimately conforms
* to The Java Language Specification. For example, the javac compiler
* may implement the operator with StringBuffer, StringBuilder,
* or java.lang.invoke.StringConcatFactory depending on the JDK version.
*/
즉, + 연산자의 구현은 컴파일러에 따라 다르며, 최신 버전에서는 StringConcatFactory 를사용할 수도 있다는 것이다.
String 클래스에는 concat() 메서드도 있다.
public String concat(String str) {
if (str.isEmpty()) {
return this;
}
return StringConcatHelper.simpleConcat(this, str);
}
StringConcatHelper.simpleConcat() 을 사용하여 최적화된 연결을 수행한다.
public static String format(String format, Object... args) {
return new Formatter().format(format, args).toString();
}
매번 새로운 Formatter 객체를 생성하고, 이 객체는 내부적으로 복잡한 파싱 로직을 수행하는데, 이것이 이것이 성능 차이의 주요 원인이다.
JDK 소스코드를 보면 여러 최적화 기법들도 볼 수 있다.
@IntrinsicCandidate
public String(String original) {
this.value = original.value;
this.coder = original.coder;
this.hash = original.hash;
this.hashIsZero = original.hashIsZero;
}
JIT 컴파일러와 성능 최적화
JDK 소스코드에서 발견한
@IntrinsicCandidate어노테이션은 JIT 컴파일러가 해당 메서드를 특별히 최적화한다는 의미다. 이는 문자열 연산이 얼마나 중요하게 여겨지는지를 보여준다.
실제 오픈소스에서는 어떻게 사용되고 있는지도 살펴보고자 github를 찾아가봤다.
Spring Boot의 CorrelationIdFormatter 클래스에서 흥미로운 코드를 발견했다.
// 기존 코드
this.blank = String.format("[%s] ",
parts.stream().map(Part::blank).collect(Collectors.joining(" ")));
이 코드를 보았을 때 특징을 몇가지 적자면
&s 하나만 사용하는 간단한 케이스다Formatter 객체 생성과 정규식 파싱이라는 불필요한 오버헤드를 줄이는게 나아보인다.이를 바탕으로 개선을 제안해보았다