-
[Java] 문자열 결합과 컴파일러 최적화Java 2025. 5. 7. 22:37
자바를 사용하다보면 문자열을 정말 많이 다루는데 이때 결합 연산자 '+'도 자주 다루게 된다. 처음 자바를 배웠을 때는 결합 연산자로 문자열을 다루면 성능이 안 좋을 수 있다고 들었다. 그런데 조금 더 공부해보니 결합 연산자를 사용해도 컴파일러가 컴파일 시점에 최적화를 해준다고 알게 되었다. 딱 여기까지만 알고 있었는데, 오늘은 어떻게 최적화가 이루어지는지 궁금해서 직접 알아보기로 했다.
javap -c 명령어
이 명령어는 Java 클래스 파일(.class)의 바이트코드를 디컴파일해서 보여주는 명령어이다. 즉, java 파일이 class 파일로 컴파일된 이후 실제 JVM이 실행하는 저수준 명령어(바이트코드)를 확인할 수 있는 도구이다. 예제 코드를 만들어 컴파일하고 이 명령어를 통해 코드가 어떻게 동작하는지 알아보자.
예제 코드 1
public class CompilerMainV1 { public static void main(String[] args) { System.out.println("Hello, " + "world"); } }
이 코드를 컴파일하고, javap -c 명령어를 통해 확인해보면 다음과 같은 결과가 나온다. (Java 8, 21 모두 동일한 결과)
$ javap -c CompilerMainV1 Compiled from "CompilerMainV1.java" public class CompilerMainV1 { public CompilerMainV1(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String Hello, world 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return }
주의 깊게 볼 부분은 ldc이다. ldc는 'load constant'의 약자로, 상수 풀에 저장된 값을 스택 영역으로 가져오는 JVM 명령어이다. 이처럼 문자열 리터럴끼리의 결합은 컴파일 시점에 하나의 상수로 만들어져 사용되기 때문에 결합 연산자를 사용함에 따른 오버헤드 또는 StringBuilder 사용 등은 확인할 수 없었다.
예제 코드 2
이 코드를 컴파일하고, javap -c 명령어를 통해 확인해보니 자바 버전에 따른 차이가 있었다.
public class CompilerMainV2 { public static void main(String[] args) { String data = "world"; System.out.println("Hello, " + data); } }
(1) Java 8
문자열 결합 시 내부적으로 StringBuilder가 고정적으로 사용되는 것을 알 수 있다.
$ javap -c CompilerMainV2 Compiled from "CompilerMainV2.java" public class CompilerMainV2 { public CompilerMainV2(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: ldc #2 // String world 2: astore_1 3: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream; 6: new #4 // class java/lang/StringBuilder 9: dup 10: invokespecial #5 // Method java/lang/StringBuilder."<init>":()V 13: ldc #6 // String Hello, 15: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 18: aload_1 19: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder; 22: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String; 25: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 28: return }
(2) Java 21
Java 21로 컴파일된 코드를 보면 위와 차이가 있었는데, 알아보니 Java 9 이상부터 이와 같은 방식으로 바뀌었다고 한다. 이 방식은 위처럼 StringBuilder만 고정적으로 사용하는 방식과 달리, JVM이 상황에 따라 최적의 방식을 선택하여 최적화가 이루어지는 방식이다.
$ javap -c CompilerMainV2 Compiled from "CompilerMainV2.java" public class CompilerMainV2 { public CompilerMainV2(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: ldc #7 // String world 2: astore_1 3: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream; 6: aload_1 7: invokedynamic #15, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String; 12: invokevirtual #19 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 15: return }
invokedynamic makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
이 방식은 StringConcatFactory라는 내부 API를 통해 실제 문자열 결합 코드를 런타임에 동적으로 생성되는 방식이다.
StringBuilder로 최적화되는 것까지는 이미 알고 있었지만 실제로 확인해보니 자바 버전이 올라감에 따라 더 유연하게 최적화가 되어있었다는 사실을 알게 되었다..!
Slf4j와 문자열 결합
이번에는 인텔리제이에서 아래와 같이 로그를 찍으면 경고 표시가 나타나는데 왜 그런지 알아보자.
@Slf4j public class LogMainV1 { public static void main(String[] args) { long startTimeMillis = System.currentTimeMillis(); for (int i = 0; i < 10000000; i++) { log.info("Hello, {}", i); } long endTimeMillis = System.currentTimeMillis(); log.error("소요 시간: {}ms", endTimeMillis - startTimeMillis); } }
@Slf4j public class LogMainV2 { public static void main(String[] args) { long startTimeMillis = System.currentTimeMillis(); for (int i = 0; i < 10000000; i++) { log.info("Hello, " + i); } long endTimeMillis = System.currentTimeMillis(); log.error("소요 시간: {}ms", endTimeMillis - startTimeMillis); } }
이 두 코드를 실행하면 소요시간이 어떻게 나올까?
많이 테스트 해보지는 못했지만 거의 비슷한 결과가 나왔다. 유의미한 성능 차이는 없었던 것 같다.
그런데 여기에 핵심은 로그 레벨에 있었다. 위 테스트는 로그 레벨을 INFO로 한 결과였지만, ERROR로 변경한 뒤 테스트해보니 10배 이상의 성능 차이가 발생했다..!
이와 같은 차이가 발생하는 이유는 log() 메서드 사용 방식에 있다. 왼쪽처럼 중괄호 {}를 사용하는 경우, 로그 레벨이 INFO보다 낮으면 "Hello, {}"와 i는 포맷팅 되지 않고 생략된다.
하지만 log() 내부에서 결합 연산자를 사용하면 로그 레벨과 상관없이 StringBuilder가 동작하게 되고, 결국에는 문자열은 생성하지만 사용하지 않는 쓸데없는 연산이 이루어지는 것이다.
개발 및 실무 환경에서 로그 레벨을 바꾸는 상황은 종종 있을 것으로 생각한다. 그러므로 로그를 남길 땐 결합 연산자 대신 반드시 중괄호 {}를 사용하는 포맷팅 방식을 사용하자!!
'Java' 카테고리의 다른 글
[Java] 생산자-소비자 문제 (0) 2025.04.08 [Java] volatile 예약어 (1) 2025.02.18 [Java] record 예약어 (1) 2025.02.13 [Java] 리플렉션(Reflection)에 대해서 (1) 2025.01.17 [Java] I/O 정리 (1) 2024.11.09