Recent Posts

All Posts →

Java 문자열 더하기 연산의 최적화

String을 +로 더하는 코드가 javac과 JDK 런타임에서 버전에 따라 어떻게 최적화되는지 정리합니다. JDK 1.4의 StringBuffer 치환, JDK 5의 StringBuilder 치환, JDK 9의 invokedynamic 도입, JDK 15의 대안 전략 제거, JDK 24의 hidden class 기반 구현까지의 변화를 JDK 25에서 확인한 바이트코드와 함께 설명합니다.

String+ 연산자로 더하는 코드는 컴파일러와 JVM이 최적화합니다. JDK의 javac은 JDK 1.4까지 StringBuffer를 쓰는 코드로, JDK 5부터 JDK 8까지는 StringBuilder를 쓰는 코드로 컴파일 시점에 변환했습니다. JDK 9부터는 기본적으로 invokedynamic 명령을 만들고, 실제 결합 코드는 실행 시점에 링크합니다. 어떤 경우에 자동으로 최적화되는지 알고 있으면 문자열 결합 코드를 더 융통성 있게 쓸 수 있습니다.

Java 언어 명세는 문자열 결합의 결과와 평가 순서를 규정하지만, 구체적인 구현 방식은 컴파일러의 재량에 맡깁니다. 따라서 이 글의 버전 구분은 JDK javac의 기본 바이트코드와 OpenJDK 런타임 구현을 기준으로 합니다. [1]

컴파일 타임 상수식의 결합

문자열 타입의 컴파일 타임 상수식은 컴파일 시점에 평가되어 하나의 문자열이 되고, 이 문자열은 String.intern()을 거친 것처럼 문자열 풀에 등록되어 공유됩니다. String.intern()은 같은 내용에 대한 문자열 풀(string pool)의 canonical reference(대표 참조)를 반환합니다. 내용이 같은 String 인스턴스가 힙에 여러 개 있어도 문자열 풀에는 내용별로 대표 인스턴스가 하나만 등록되는데, canonical reference는 그 대표 인스턴스를 가리키는 참조입니다. 그래서 내용이 같은 문자열을 intern하면 동일한 인스턴스를 참조하게 됩니다. 이는 힙에 존재하는 내용이 같은 모든 String 객체가 자동으로 문자열 풀에 합쳐진다는 뜻은 아닙니다. 문자열 리터럴뿐 아니라 primitive 리터럴과 상수 변수도 이런 상수식에 포함될 수 있습니다. [2] 예를 들어 아래의 두 선언은 같은 코드로 컴파일됩니다.

String str1 = "It's" + " a string" + "....";
String str2 = "It's a string....";

이 규칙은 이 글에서 다루는 모든 JDK 버전에서 같습니다. 뒤에서 소개할 JDK 25의 바이트코드에서도 두 변수가 상수 풀의 같은 항목(ldc #7)을 읽는 것을 확인할 수 있습니다. 두 변수는 문자열 풀의 같은 인스턴스를 참조하므로 equals() 메서드뿐 아니라 str1 == str2 비교도 true가 됩니다. 반면 비상수 결합식의 결과는 새 String으로 만들어지고 자동으로 intern되지 않으므로, 내용이 같은 리터럴이나 별도로 만들어진 문자열과 ==로 비교하면 false가 됩니다.

변수가 섞인 결합의 StringBuffer·StringBuilder 치환

상수와 String 변수를 섞어서 더하는 연산을 JDK 8까지의 javac으로 컴파일하면 StringBufferStringBuilderappend(), toString() 메서드를 호출하는 코드로 변환됩니다. JDK 1.4의 javacStringBuffer를 사용하는 방식이었습니다. 같은 시기의 Java ME 문서인 CLDC 1.1의 StringBuffer javadoc에는 당시 컴파일러의 변환 방식이 아래와 같이 설명되어 있습니다.

String buffers are used by the compiler to implement the binary string concatenation operator. For example, the code: x = "a" + 4 + "c" is compiled to the equivalent of: x = new StringBuffer().append("a").append(4).append("c").toString() which creates a new string buffer (initially empty), appends the string representation of each operand to the string buffer in turn, and then converts the contents of the string buffer to a string. Overall, this avoids creating many temporary strings.

JDK 5부터 JDK 8까지의 javacStringBuffer 대신, 동기화를 하지 않아서 단일 스레드에서 일반적으로 더 빠른 StringBuilder로 변환합니다. 이를 바이트코드나 역컴파일 결과로 확인할 수 있습니다. 원래의 소스가 아래와 같을 때

String str0 = "It's a string....";
String str1 = "It's" + " a string" + "....";
String str2 = "It's a string...." + str0 + "000";
str2 = str0 + str1 + "1111" ;
str2 = str2 + "1111";
str2 += "1111";
for (int i=0;i<10;i++){
    str2 = str2 + "1111";
    str2 += "1111";
}

JDK 5 이상 JDK 8 이하의 javac으로 컴파일한 후 역컴파일하면 다음과 같은 결과가 나옵니다.

String str0 = "It's a string....";
String str1 = "It's a string....";
String str2 = (new StringBuilder("It's a string....")).append(str0).append("000").toString();
str2 = (new StringBuilder(String.valueOf(str0))).append(str1).append("1111").toString();
str2 = (new StringBuilder(String.valueOf(str2))).append("1111").toString();
str2 = (new StringBuilder(String.valueOf(str2))).append("1111").toString();
for(int i = 0; i < 10; i++) {
    str2 = (new StringBuilder(String.valueOf(str2))).append("1111").toString();
    str2 = (new StringBuilder(String.valueOf(str2))).append("1111").toString();
}
Note

역컴파일 도구의 옵션에 따라서는 원래의 소스와 비슷하게 복원해서 + 연산이 그대로 보일 수도 있습니다. cfr이라는 디컴파일러가 그런 경우입니다. javap -c 명령으로 바이트코드를 확인하는 것이 정확합니다.

반복문 안에서 누적하는 문자열 결합

위의 역컴파일 결과에서 주목할 부분은 반복문입니다. 바이트코드에는 반복 주기마다 새로운 StringBuilder를 만들고, toString()을 호출해서 다음 반복에 넘길 String을 만드는 코드가 들어 있습니다. HotSpot JIT가 이 임시 StringBuilder의 실제 힙 할당을 제거할 수 있으므로, 바이트코드의 new 횟수가 항상 실제 객체 할당 횟수와 같지는 않습니다. 그러나 매번 더 길어진 이전 결과를 새 결과에 복사해야 한다는 문제는 남습니다.

따라서 성능이 중요한 반복문에서 문자열을 많이 누적할 때는 + 대신 하나의 StringBuilder를 직접 쓰는 편이 좋습니다. 고정된 길이의 문자열을 반복해서 붙인다면 + 방식의 누적 복사량은 반복 횟수에 대해 제곱으로 증가하지만, StringBuilder는 내부 버퍼를 재사용합니다. 반복 횟수가 작다면 가독성과 실제 측정 결과를 함께 고려할 수 있습니다. [3]

StringBuilder sb = new StringBuilder(str2);
for (int i = 0; i < 10; i++) {
    sb.append("1111");
}
str2 = sb.toString();

두 방식의 차이를 JDK 25(Temurin 25+36)에서 간단히 측정해 보았습니다. 4글자 문자열을 n회 누적하는 코드를 JIT 워밍업 후 여러 번 실행해서 가장 짧은 시간을 기록한 결과입니다. 측정에 쓴 코드는 examples/string-concat에 있습니다. [4]

반복 횟수 + 결합 StringBuilder 직접 사용 배율

100

0.008ms

0.001ms

약 10배

1,000

0.53ms

0.009ms

약 56배

10,000

10.6ms

0.023ms

약 471배

100,000

1,485ms

0.22ms

약 6,667배

반복 횟수가 늘수록 + 결합 시간은 급격히 증가합니다. 측정값은 JIT와 GC의 영향을 받지만, 누적 복사량이 반복 횟수에 대해 제곱으로 증가한다는 앞의 설명과 일치하는 경향을 보입니다. 반복 횟수가 100회 정도라면 어느 방식이든 절대 시간은 무시할 만한 수준입니다.

JDK 9부터 적용된 invokedynamic 방식

JDK 9부터는 JEP 280: Indify String Concatenation으로 javac의 기본 변환 방식이 완전히 달라졌습니다. 컴파일러가 StringBuilder 호출 코드를 만들어 넣는 대신 invokedynamic 호출 지점을 남기고, 실제 결합 메서드는 실행 시점에 연결합니다.

invokedynamic의 동작 구조

invokedynamic은 호출할 메서드를 실행 시점에 결정하는 바이트코드 명령입니다. invokevirtual이나 invokestatic 같은 다른 호출 명령은 호출 대상이 클래스 파일에 고정되어 있지만, invokedynamic은 호출 지점(call site)을 어떤 메서드에 연결할지를 실행 시점의 Java 코드에 위임합니다. JVM에서 동적 언어를 지원하기 위해 JSR 292로 JDK 7에 추가되었고, JDK 8의 람다 표현식 구현에도 쓰입니다.

이 연결을 담당하는 코드가 bootstrap 메서드입니다. 클래스 파일에는 invokedynamic 호출 지점마다 사용할 bootstrap 메서드가 기록됩니다. JVM은 호출 지점을 처음 실행하기 전에 bootstrap 메서드를 호출해 링크하고, bootstrap 메서드는 실제 실행할 메서드를 가리키는 CallSite를 만들어 반환합니다. 여러 스레드가 같은 호출 지점을 동시에 최초 실행하면 bootstrap 메서드가 병행 호출될 수 있지만, JVM은 그중 하나의 결과만 호출 지점에 설치합니다. 문자열 결합에서는 StringConcatFactory.makeConcatWithConstants()가 bootstrap 메서드이고, 이 메서드가 결합 코드를 만들어 CallSite에 담아 돌려줍니다.

MethodHandlejava.lang.invoke 패키지에 있는, 실행할 대상을 가리키는 참조입니다. 메서드뿐 아니라 생성자 호출이나 필드 접근도 가리킬 수 있고, 인수와 반환 타입을 나타내는 MethodType을 가집니다. 리플렉션의 Method와 쓰임새는 비슷하지만, 접근 권한을 MethodHandle을 만드는 시점에 한 번만 확인하고 invokeExact() 같은 메서드로 바로 호출한다는 점이 다릅니다. 또한 MethodHandles.filterArguments()`나 `MethodHandles.foldArguments() 같은 메서드로 여러 MethodHandle을 엮어 새로운 MethodHandle을 만들 수 있습니다. 뒤에서 볼 문자열 결합 전략 중 일부가 이 조합 기능으로 결합 코드를 구성합니다.

CallSite는 같은 패키지의 객체로, 실행할 메서드를 가리키는 MethodHandle 하나를 target이라는 속성으로 참조합니다. CallSite.getTarget() 메서드가 현재 target인 MethodHandle을 돌려줍니다. JVM은 bootstrap 메서드가 반환한 CallSite를 호출 지점에 연결해 두는데, 이를 링크라고 합니다. 링크된 뒤의 invokedynamic 실행은 그 시점의 target MethodHandle을 호출하는 것과 같습니다. MutableCallSite처럼 실행 중에 target을 다른 MethodHandle로 바꿀 수 있는 구현도 있지만, 문자열 결합에는 target이 바뀌지 않는 ConstantCallSite가 쓰입니다. 그래서 한 번 링크된 뒤에는 일반 메서드 호출처럼 동작하고 JIT 컴파일러의 인라인 최적화 대상도 됩니다.

JDK 25에서 확인한 invokedynamic 바이트코드

JDK 25에서 아래의 소스를 컴파일해서 확인해 보았습니다.

StringTest.java의 인스턴스 메서드 본문
String str0 = "It's a string....";
String str1 = "It's" + " a string" + "....";
String str2 = str0 + str1 + "1111";
for (int i = 0; i < 10; i++) {
    str2 = str2 + "1111";
}

javap -c StringTest 명령으로 바이트코드를 보면 StringBuilder 호출 대신 invokedynamic 명령이 나옵니다.

javap -c StringTest 출력 중 해당 메서드 (JDK 25로 컴파일)
 0: ldc           #7    // String It's a string....
 2: astore_1
 3: ldc           #7    // String It's a string....
 5: astore_2
 6: aload_1
 7: aload_2
 8: invokedynamic #9,  0  // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
13: astore_3
14: iconst_0
15: istore        4
17: iload         4
19: bipush        10
21: if_icmpge     37
24: aload_3
25: invokedynamic #13,  0 // InvokeDynamic #1:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
30: astore_3
31: iinc          4, 1
34: goto          17
37: return

같은 소스를 javac --release 8 옵션으로 컴파일하면 앞 절에서 역컴파일로 보았던 것과 같은 StringBuilder 호출 코드가 나옵니다.

이 방식의 장점은 결합 전략이 컴파일된 클래스 파일이 아니라 JDK 런타임에 있다는 점입니다. 한 번 컴파일한 클래스도 실행하는 JDK를 올리면, 같은 호출 지점과 recipe에 대해 새 런타임의 결합 전략을 재컴파일 없이 적용받을 수 있습니다. 자세한 배경은 JDK 9/JEP 280: String Concatenations Will Never Be the Same에 설명되어 있습니다.

StringConcatFactory 결합 전략의 변화

JDK 9의 StringConcatFactory에는 6가지 결합 전략이 있었고, java.lang.invoke.stringConcat 시스템 프로퍼티로 선택할 수 있었습니다. 전략 이름에서 BC는 실행 시점의 바이트코드 생성을, MHMethodHandle 조합을, SBStringBuilder 사용을, SIZED는 버퍼 초기 용량 추정을, SIZED_EXACT는 정확한 용량 계산을 뜻합니다.

전략 설명

BC_SB

StringBuilder를 호출하는 바이트코드를 가진 클래스를 실행 시점에 생성합니다. JDK 8까지의 javac이 컴파일 시점에 만들던 코드와 같은 형태입니다.

BC_SB_SIZED

BC_SB와 같지만 StringBuilder의 초기 용량을 추정값으로 지정해서 내부 버퍼의 확장을 줄입니다.

BC_SB_SIZED_EXACT

인수들의 길이를 미리 계산해서 StringBuilder의 초기 용량을 정확한 값으로 지정합니다.

MH_SB_SIZED

바이트코드 생성 대신 MethodHandle 조합으로 StringBuilder 호출을 구성합니다. 초기 용량은 추정값입니다.

MH_SB_SIZED_EXACT

MH_SB_SIZED와 같은 방식이지만 초기 용량을 정확하게 계산합니다.

MH_INLINE_SIZED_EXACT

기본 전략입니다. StringBuilder를 거치지 않고 최종 길이의 byte 배열을 직접 만들어 인수들을 그 배열에 바로 씁니다.

기본 전략인 MH_INLINE_SIZED_EXACT는 다 채운 byte 배열로 추가 복사 없이 String을 생성하므로, 임시 객체와 복사가 가장 적습니다. StringConcatFactory.javagenerateMHInlineCopy() 메서드가 그 구현입니다. 두 참조 값을 더하는 단순한 형태에는 StringConcatHelper.javasimpleConcat() 같은 특수 경로도 사용됩니다.

기본 전략 외의 나머지 5가지 선택 전략과 java.lang.invoke.stringConcat 프로퍼티는 JDK-8245455로 JDK 15에서 제거되었습니다. 이 글에서는 simpleConcat() 같은 특수한 형태 전용의 빠른 경로를 제외하고, 대부분의 결합이 기본으로 타는 경로를 일반 결합 경로라고 부르겠습니다. JDK 15부터 JDK 22까지의 일반 결합 경로는 MH_INLINE_SIZED_EXACT에서 이어진 inline-copy MethodHandle 방식입니다. 여러 MethodHandle을 조합해서, 정확한 최종 길이의 byte 배열을 만들고 각 인수를 중간 버퍼 없이 그 배열에 바로 복사해 넣는(inline copy) 결합 메서드를 구성합니다.

JDK 23에서는 JDK-8327247로 인수 개수가 기본 임계값 20을 넘는 결합에 StringBuilder 호출 바이트코드를 생성하는 경로가 추가되었습니다. 큰 MethodHandle 표현식 트리가 JIT 컴파일 자원을 과도하게 쓰는 문제를 피하기 위한 변경입니다.

JDK 24에서는 JDK-8336856으로 주된 일반 결합 경로가 인수 형태(shape)별 hidden class를 생성하는 방식으로 다시 바뀌었습니다.

hidden class는 JEP 371: Hidden Classes로 JDK 15에 추가된 특수한 클래스입니다. Lookup.defineHiddenClass() 메서드로 정의하며, 클래스 이름으로 찾을 수 없고 참조가 사라지면 개별적으로 언로드될 수 있어서, 프레임워크나 JDK가 실행 시점에 만들어 내는 코드에 적합합니다. JDK 25 구현에서 hidden class 캐시의 키가 되는 인수 형태는 동적 인수의 개수와 정규화된 타입으로 이루어진 MethodType입니다. 참조 타입은 Object로, byteshortint로 정규화됩니다. 상수의 내용과 배치는 캐시 키에 포함되지 않고 호출 지점마다 별도의 String[]에 저장됩니다. 따라서 앞의 바이트코드에서 str0 + str1 + "1111"의 호출 지점은 (String, String) → String 시그니처를 가지지만 캐시에서는 (Object, Object) → String 형태를 사용하고, 반복문 안 str2 + "1111"의 호출 지점은 (Object) → String 형태를 사용하므로 서로 다른 형태입니다. 인수 형태별 hidden class 방식은 생성한 결합 클래스를 캐시에 두고 같은 형태의 다른 호출 지점에서 재사용할 수 있습니다. 캐시는 soft reference를 사용하므로 캐시된 클래스가 회수되면 같은 형태의 클래스를 다시 만들 수도 있습니다. 깊게 중첩된 MethodHandle 트리 대신 JIT 컴파일러가 다루기 쉬운 평범한 바이트코드가 만들어지므로, 링크와 초기 구동(warmup) 비용이 줄어듭니다.

JDK 25의 StringConcatFactory는 먼저 단순 결합용 빠른 경로를 확인하고, 그 밖의 결합은 기본적으로 InlineHiddenClassStrategy로 처리합니다. 이 전략은 동적 인수가 하나인 경우 미리 구현된 전용 핸들러를 사용하고, 그 밖의 일반적인 결합에는 인수 형태별 hidden class를 사용합니다. 결합 코드는 최종 크기의 byte 배열을 채우고 그 배열로 String을 만듭니다. JDK 25의 StringConcatFactory.java에서 이를 확인할 수 있습니다.

한편 위의 바이트코드에서 보듯이 반복문 안의 invokedynamic 명령은 반복 주기마다 이미 링크된 결합 메서드를 호출하여 새로운 결과 String을 만듭니다. StringConcatFactory의 bootstrap 메서드가 매번 다시 실행되는 것은 아닙니다. 컴파일러나 런타임이 누적용 StringBuilder 하나를 반복문 밖에 만들어 주지는 않으므로, 많은 문자열을 누적하는 반복문에서 직접 StringBuilder를 쓰라는 결론은 JDK 9 이후에도 같습니다.

정리

JDK 버전 + 결합 연산의 처리 방식

JDK 1.4까지

javac이 컴파일 시점에 StringBufferappend(), toString() 호출로 변환

JDK 5 ~ 8

javac이 컴파일 시점에 동기화를 하지 않는 StringBuilder 호출로 변환

JDK 9 ~ 14

javacinvokedynamic 호출 지점을 생성. 첫 링크 시 StringConcatFactory가 결합 메서드를 연결하며 6가지 전략 중 선택 가능

JDK 15 ~ 22

대안 전략을 제거하고 inline-copy MethodHandle 방식을 일반 결합 경로로 사용

JDK 23

인수가 많은 결합에는 StringBuilder 호출 바이트코드를 생성하는 경로 사용

JDK 24 ~ 25

단순 결합용 빠른 경로, 단일 인수 전용 구현과 정규화된 인수 형태별 hidden class 기반의 일반 결합 경로 사용

문자열 타입의 컴파일 타임 상수식이 하나의 상수로 합쳐지는 동작은 모든 버전에서 같습니다. 반복문 밖에서 문자열 한두 개를 더하는 코드는 javac과 JDK 런타임이 최적화할 수 있으므로 가독성이 좋은 + 연산을 그대로 쓰면 됩니다. 반면 컴파일러나 런타임이 반복문을 누적용 StringBuilder 하나로 자동 변환하지는 않습니다. 성능이 중요한 반복문에서 많은 문자열을 누적한다면 StringBuilder를 직접 쓰는 편이 좋습니다.


1. JLS 15.18.1은 컴파일러가 StringBuffer나 그와 비슷한 기법을 사용할 수 있다고 설명합니다.
2. 컴파일 타임 상수식의 정확한 범위는 JLS 15.29를 참고합니다.
3. 성능 테스트 사례는 String concatenation with Java 8을 참고합니다.
4. JMH를 쓰지 않은 System.nanoTime() 기반의 단순 측정이라 미세한 수치는 부정확할 수 있지만, 수백 배 이상의 차이라 결론에는 영향이 없습니다.

Call by value vs Call by reference

"primitive type은 call by value, 객체는 call by reference"라는 설명은 오해입니다. Java와 JavaScript는 객체를 전달할 때도 참조값의 복사본을 전달하는 call by value 방식만 씁니다. swap 예제와 그림으로 참조 재할당과 객체 상태 변경의 차이를 정리하고, 진짜 call by reference를 지원하는 언어와 비교합니다.

"primitive type은 call by value로, 객체는 call by reference로 전달된다"는 설명을 Java 개발자들 사이에서 종종 접합니다. 예전에 회사 소모임 세미나에서도 이 주제로 논의했던 적이 있습니다. 결론부터 말하면 Java에는 call by value만 있습니다. 객체를 전달할 때 복사되는 값이 '객체에 대한 참조값’일 뿐입니다. JavaScript도 같은 방식입니다. 이 글에서는 두 용어의 정의를 정리하고, Java와 JavaScript의 동작을 예제와 그림으로 설명합니다.

두 용어의 정의

Call by value (pass by value)는 인자로 넘긴 값의 복사본을 파라미터에 대입하는 방식입니다. 함수 안에서 파라미터에 다른 값을 대입해도 호출한 쪽의 변수는 영향을 받지 않습니다.

Call by reference (pass by reference)는 변수 자체를 전달하는 방식입니다. 파라미터는 호출한 쪽 변수의 별칭(alias)이 되므로, 함수 안에서 파라미터에 새 값을 대입하면 호출한 쪽의 변수도 함께 바뀝니다.

이 글처럼 call by value와 call by reference만 비교하고 대입 가능한 변수 인자를 전달한다고 가정하면, 두 방식을 구분하는 실용적인 기준은 한 문장으로 요약할 수 있습니다. 함수 안에서 파라미터에 새 값을 대입했을 때 호출한 쪽의 변수가 바뀌는가? 바뀌면 두 방식 중 call by reference이고, 바뀌지 않으면 call by value입니다.[1] 파라미터가 가리키는 객체의 내부 상태를 바꾸는 일은 이 기준과는 다른 문제인데, 뒤에서 다루겠지만 바로 이 지점에서 오해가 생깁니다.

Java의 인자 전달 방식

swap 메서드의 실행 결과

아래 코드의 출력을 예상해 보면 Java의 전달 방식을 확인할 수 있습니다.

public class ReferenceTest {
    public static void main(String[] args) {
        String x1 = "Hello";
        String y1 = "World";
        swap(x1, y1);
        System.out.println(x1);

        int x2 = 1;
        int y2 = 2;
        swap(x2, y2);
        System.out.println(x2);
    }

    static void swap(Object x, Object y) {
        Object temp = x;
        x = y;
        y = temp;
    }
}

출력은 World, 2가 아니라 Hello1입니다.[2] swap 메서드 안에서 파라미터 xy에 서로의 값을 대입해도, 호출한 쪽의 x1, y1, x2, y2는 바뀌지 않습니다. 앞에서 정리한 기준대로라면 Java는 primitive type이든 객체든 call by value로 동작합니다.

파라미터 재할당이 호출자에게 보이지 않는 이유

객체를 전달하는 경우를 그림과 함께 들여다보겠습니다.

public class ReassignTest {
    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append("A1");

        work(sb);
        System.out.println(sb); // A1
    }

    static void work(StringBuilder sb) {
        sb = new StringBuilder();
        sb.append("B2");
    }
}

work(sb)를 호출하는 순간, main 메서드의 sb에 담긴 참조값이 복사되어 work 메서드의 파라미터 sb에 대입됩니다. 두 변수는 같은 객체를 가리키지만 서로 다른 변수입니다.

참조값이 복사된 뒤 파라미터만 새 객체를 가리키게 되는 과정

work 메서드 안에서 sb에 새 객체를 대입하면 파라미터 sb만 새 객체를 가리키게 되고, main 메서드의 sb는 원래 객체를 그대로 가리킵니다. 그래서 출력은 A1입니다.

오해의 근원인 객체 상태 변경

그런데 파라미터를 재할당하지 않고 파라미터가 가리키는 객체의 내부 상태를 바꾸면, 그 변경은 호출한 쪽에서도 보입니다.

static void appendWork(StringBuilder sb) {
    sb.append("B2");
}

main 메서드에서 "A1"이 담긴 sbappendWork(sb)를 호출한 뒤 출력하면 A1B2가 나옵니다.

두 변수가 같은 객체를 가리키므로 내부 상태 변경이 호출자에게도 보이는 상황

파라미터 재할당은 호출자에게 보이지 않는데 객체 상태 변경은 보이니, "객체는 call by reference로 전달된다"는 설명이 그럴듯하게 들립니다. 그러나 이 글에서 두 방식을 가르는 실용적인 기준은 파라미터 대입이 호출자 변수에 반영되는가입니다. 객체 상태 변경이 보이는 것은 두 변수가 같은 객체를 가리키고 있기 때문일 뿐, 전달 방식은 여전히 참조값의 복사, 즉 call by value입니다.

Head First Java에서는 객체 참조를 리모컨에 비유해서 설명합니다. 객체를 전달하면 TV(객체)가 복사되는 것이 아니라 그 TV를 조작할 수 있는 리모컨(참조값)이 복사됩니다. 복사된 리모컨으로 채널을 바꾸면(객체 상태 변경) 원래 리모컨을 가진 사람에게도 바뀐 채널이 보이지만, 복사된 리모컨을 다른 TV에 연결해도(재할당) 원래 리모컨은 여전히 처음의 TV를 조작합니다.

Java의 reference와 용어의 혼란

Java의 reference는 객체를 식별하고 접근하는 값이며, 포인터처럼 이해할 수 있습니다. 아래 Java 선언의 객체 접근과 재할당 동작은

Dog d = new Dog();
d.setName("Fifi");

C++ 포인터를 쓴 다음 코드와 비슷합니다.

Dog *d = new Dog();
d->setName("Fifi");

Java의 d는 객체 자체가 아니라 객체를 가리키는 값을 담고 있습니다. d를 다른 메서드의 인자로 넘기면 이 참조값이 복사되어 전달됩니다. 다만 위 C++ 코드는 객체 접근과 포인터 재할당을 설명하기 위한 비유이며, 객체의 수명 관리나 참조의 실제 표현까지 같다는 뜻은 아닙니다.

'포인터’는 Java Language Specification도 사용하는 표현입니다. 4.3.1. Objects 절은 참조값을 객체에 대한 포인터라고 정의합니다.

The reference values (often just references) are pointers to these objects, and a special null reference, which refers to no object.

다만 이는 언어 수준의 의미를 설명하는 표현이지, 참조값이 원시 메모리 주소와 같은 형식이라고 보장하는 문장은 아닙니다. Java Virtual Machine Specification 2.7은 객체의 내부 구조를 특정하지 않으며, 일부 구현에서는 참조가 객체 데이터가 아니라 핸들을 가리킬 수도 있다고 설명합니다.

메서드 호출 시의 전달 방식은 8.4.1. Formal Parameters 절에 있습니다. 인자 표현식의 '값’이 새로 만들어진 파라미터 변수를 초기화한다고 설명할 뿐, 파라미터가 호출한 쪽 변수의 별칭이 된다는 언급은 없습니다.

When the method or constructor is invoked (§15.12), the values of the actual argument expressions initialize newly created parameter variables, each of the declared type, before execution of the body of the method or constructor.

Java를 만든 James Gosling도 The Java Programming Language 책에서 이 오해를 직접 짚었습니다.

Some people will say incorrectly that objects are passed "by reference." …​ The Java programming language does not pass objects by reference; it passes object references by value.

최신 공식 Java 학습 자료인 Dev.java에서도 같은 내용을 설명합니다.

Reference data type parameters, such as objects, are also passed into methods by value. …​ when the method returns, the passed-in reference still references the same object as before.

결국 혼란의 뿌리는 용어에 있습니다. Java에서 객체를 가리키는 값을 'reference’라고 부르는데, 이 단어가 'call by reference’의 reference와 겹치면서 오해가 생겼습니다. 정확한 문장은 이렇습니다. Java는 객체에 대한 참조값을 call by value로 전달합니다.

JavaScript의 인자 전달 방식

JavaScript도 Java와 같은 방식으로 동작합니다. 먼저 swap 예제입니다.

function swap(x, y) {
  const temp = x;
  x = y;
  y = temp;
}

let x1 = "Hello";
let y1 = "World";
swap(x1, y1);
console.log(x1); // Hello

객체의 경우도 재할당과 상태 변경이 Java와 똑같이 갈립니다.

function reassign(dog) {
  dog = { name: "Rex" };
}

function rename(dog) {
  dog.name = "Rex";
}

const dog = { name: "Fifi" };

reassign(dog);
console.log(dog.name); // Fifi

rename(dog);
console.log(dog.name); // Rex

reassign 함수 안에서 파라미터 dog에 새 객체를 대입해도 호출한 쪽의 dog는 바뀌지 않습니다. 반면 rename 함수처럼 파라미터가 가리키는 객체의 프로퍼티를 바꾸면 호출한 쪽에서도 보입니다. 앞의 두 그림에서 StringBuilder 객체를 객체 리터럴로 바꾸면 JavaScript의 동작을 설명하는 그림이 됩니다.

ECMAScript 사양도 이 동작을 호출자 변수의 별칭이 아니라 값과 별도의 파라미터 바인딩으로 기술합니다. ArgumentListEvaluation은 인자 표현식에서 GetValue로 값을 얻어 ECMAScript 언어 값 목록을 만들고, FunctionDeclarationInstantiation은 그 목록으로 새 함수 실행 환경의 파라미터 바인딩을 초기화합니다.

ECMAScript 사양에서는 Java와 달리 객체에 접근하는 값을 'reference value’라고 부르지 않고 Object 자체를 ECMAScript 언어 값으로 다룹니다. 따라서 이 글의 '참조값 복사’는 관찰되는 동작을 설명하기 위한 모델로 이해하는 편이 정확합니다.

JavaScript: The Definitive Guide의 call by reference 서술

JavaScript를 call by reference로 설명하는 자료도 있습니다. 오랫동안 JavaScript의 표준적인 책으로 꼽혀온 JavaScript: The Definitive Guide, 4th Edition은 'By Value Versus by Reference' 장의 요약 표에서 객체가 by reference로 복사·전달·비교된다고 정리합니다. 이 요약 표만 보면 JavaScript는 call by reference라고 설명해도 될 것처럼 보입니다.

그런데 같은 장의 동작 설명을 읽어보면, 이 책이 말하는 by reference는 이 글의 앞부분에서 정의한 call by reference와는 다른 의미입니다. '객체 값 전체가 복사되는 대신 객체에 대한 참조가 전달된다’는 의미로 쓴 표현이고, 파라미터를 재할당해도 호출한 쪽에 보이지 않는다는 점은 이 책도 똑같이 설명합니다.

A function can use the reference to modify properties of the object or elements of the array. But if the function overwrites the reference with a reference to a new object or array, that modification is not visible outside of the function.

참조 자체는 값으로 전달된다("References themselves are passed by value")는 설명도 함께 나옵니다. 결국 관찰되는 동작에 대한 설명은 이 글과 같고, 무엇이 전달된다고 부를지에 대한 용어 선택이 다를 뿐입니다.

Call by sharing이라는 용어

이처럼 호출자 변수 자체가 아니라 객체를 공유하는 인자 전달 동작을 call by sharing이라고 부르기도 합니다. Barbara Liskov를 비롯한 설계팀이 1970년대 중반에 만든 언어인 CLU의 설계 문서에서 쓴 용어입니다. Java, JavaScript, Python, Ruby의 객체 인자 동작을 이 개념으로 설명할 수 있습니다.

Call by value라고 하면 '객체가 통째로 복사된다’는 오해를, call by reference라고 하면 '재할당도 호출한 쪽에 보인다’는 오해를 각각 부를 수 있습니다. 그래서 저는 JavaScript도 두 용어 중 하나를 고르기보다 call by sharing이라는 개념으로 설명하는 편이 더 명확하다고 생각합니다.

Call by reference를 지원하는 언어

진짜 call by reference가 어떤 것인지는 C++의 참조 파라미터와 비교하면 분명해집니다.

void swap(int& x, int& y) {
    int temp = x;
    x = y;
    y = temp;
}

int a = 1;
int b = 2;
swap(a, b); // a == 2, b == 1

파라미터를 int& 타입으로 선언하면 x는 호출한 쪽 변수 a의 별칭이 됩니다. 함수 안의 대입이 호출한 쪽 변수를 실제로 바꿉니다. C#의 ref, out 키워드도 같은 성격의 기능입니다. Java와 JavaScript에는 이런 문법 자체가 없습니다.

Java 이후에 등장한 Go와 Rust도 일반 함수 호출에서 호출자 변수 자체를 파라미터의 암묵적인 별칭으로 만들지 않습니다. Go 사양은 호출 전에 인자를 평가하고 새로 할당한 파라미터 저장 공간에 그 값을 대입한다고 설명합니다. Rust Book은 함수 인자 전달이 대입과 마찬가지로 타입에 따라 값을 이동하거나 복사한다고 설명합니다.

호출한 쪽의 값을 바꾸려면 swap(&a, &b), swap(&mut a, &mut b)처럼 포인터나 참조를 호출 지점에 명시할 수 있습니다. Go에서는 포인터 값이 파라미터에 대입됩니다. Rust에서는 값이 타입과 문맥에 따라 이동하거나 복사되며, 특히 `&mut T`는 `Copy`가 아닙니다. 기존 가변 참조를 인자로 넘기는 문맥에서는 재대여(reborrow)가 일어날 수도 있습니다. 핵심은 어느 경우에도 파라미터 자체를 재할당해서 호출자의 변수 바인딩을 바꾸는 전통적인 call by reference가 아니라는 점입니다.

정리

  • Java와 JavaScript는 인자 값으로 별도의 파라미터 변수 또는 바인딩을 초기화합니다. 호출자 변수 자체가 파라미터의 별칭이 되는 call by reference를 지원하지 않습니다.

  • 객체를 전달하면 객체가 복제되는 것도, 호출자의 변수 자체가 전달되는 것도 아닙니다. Java에서는 객체에 대한 참조값이, JavaScript에서는 Object 값이 별도의 파라미터에 전달되며 호출자와 피호출자가 같은 객체를 관찰할 수 있습니다.

  • 함수 안에서 파라미터를 재할당해도 호출한 쪽의 변수는 바뀌지 않습니다. 반면 파라미터가 가리키는 객체의 내부 상태를 바꾸면 호출한 쪽에서도 보입니다. 이 차이가 "객체는 call by reference"라는 오해의 근원입니다.

  • 객체를 공유하는 이 동작을 call by sharing이라고 부르기도 합니다. 오래된 일부 자료는 객체를 공유하는 동작을 by reference라고 표현하지만, 호출자 변수의 별칭을 전달한다는 의미의 call by reference와는 구분해야 합니다.


1. Call by copy-restore나 call by name 등 다른 평가 전략까지 포함하면 이 질문만으로 모든 인자 전달 방식을 분류할 수는 없습니다.
2. swap(x2, y2)에서는 파라미터가 Object 타입이라 int 값이 Integer 객체로 오토박싱되어 전달됩니다. 그래도 호출한 쪽의 변수가 바뀌지 않는다는 결과는 같습니다.

Claude Code에서 작업 중에 보낸 프롬프트가 전달되는 시점

Claude Code가 작업 중일 때 /btw 없이 그냥 입력한 프롬프트도 잘 반영된다고 느껴서, 언제부터 그랬는지 릴리즈 노트에서 찾아봤습니다. /btw와 큐 메시지의 차이, 큐 메시지가 모델에 전달되는 시점, CHANGELOG에서 확인한 변경 이력과 확인하지 못한 부분을 정리합니다.

Claude Code가 작업하는 중에 무언가를 덧붙이고 싶을 때 한동안 저는 /btw를 썼습니다. 그냥 프롬프트를 입력하면 그 작업에 반영되지 않는다고 느꼈기 때문입니다. 그런데 어느 시점부터인가 /btw 없이 그냥 입력해도 작업 중간에 잘 반영되었습니다. 현시점에서의 /btw를 언제써야할지와 릴리즈 노트에서 관련 이력을 찾아봤습니다

/btw와 큐 메시지의 차이

Interactive mode 문서에 따르면 두 기능은 이렇게 다릅니다.

항목 /btw (side question) 작업 중 입력한 프롬프트 (queued message)

대화 이력 반영

반영되지 않음. 질문과 답변 모두 conversation history에 들어가지 않음

반영됨. 일반 메시지와 같은 자격으로 대화에 들어감

도구 사용

불가. 이미 컨텍스트에 있는 내용만으로 답함

가능. 파일 읽기, 명령 실행 모두 가능

진행 중인 작업에 미치는 영향

없음. 본 작업을 중단시키지도, 방향을 바꾸지도 않음

있음. 이후 작업의 방향이 바뀜

용도

이번 세션에서 이미 알고 있는 내용을 묻기

작업 방향을 바꾸거나 지시를 덧붙이기

문서는 /btw를 "subagent의 반대"라고 설명합니다. subagent는 대화 이력 없이 도구만 갖고 새로 알아내는 쪽이고, /btw는 대화 이력을 다 보지만 도구가 없는 쪽입니다.

그래서 /btw로 지시를 내리면 그 지시는 진행 중인 작업에 반영되지 않습니다. 제 프롬프트 이력에도 /btw로 지시를 내린 뒤 "아까 btw로 내린 명령을 수행해 줘"라고 다시 요청한 기록이 남아 있었습니다. 애초에 지시를 전달하는 용도로는 맞지 않는 명령을 썼던 것입니다.

/btw 자체가 언제 도입되었는지는 CHANGELOG에서 확인되지 않습니다. 도입을 알리는 항목이 없고, 가장 이른 언급은 v2.1.79(2026-03-18)의 버그 수정입니다.

Fixed `/btw` returning the main agent's output instead of answering
the side question when triggered during streaming

큐에 넣은 메시지가 모델에 전달되는 시점

작업 중에 프롬프트를 입력하고 Enter를 누르면 Claude Code는 턴을 중단하지 않고 큐에 넣습니다. 큐에 있는 항목이 모델에 언제 전달되는지는 문서에 이렇게 적혀 있습니다.

Messages: if you queue a message while Claude is running tool calls, Claude Code passes it to Claude as soon as those tool calls finish, within the same turn. When the turn ends, Claude Code sends the messages that are still queued as the next turn, each as a separate message

즉 도구 호출이 진행 중일 때 넣은 메시지는 그 도구 호출이 끝나는 시점에 같은 턴 안에서 전달됩니다. 턴이 이미 끝나 가고 있었다면 다음 턴의 첫 메시지가 됩니다. 슬래시 명령과 ! 셸 명령은 다르게 처리되어 턴이 끝난 뒤에 하나씩 실행됩니다.

바로 전달하고 싶으면 Esc로 턴을 중단하면 됩니다. 이때 큐에 있던 내용은 버려지지 않고 그대로 전달됩니다.

이 동작이 언제부터 이랬는지는 문서에 나와 있지 않습니다. 이 문서는 동작이 바뀐 항목에 "Before v2.1.186", "Before v2.1.212"처럼 이전 버전 주석을 붙이는데, 큐 관련 절에는 그런 주석이 없습니다.

CHANGELOG에서 확인한 steering 관련 변경 이력

작업 중 메시지 전달과 직접 관련된 항목은 세 개입니다.

버전 배포일 릴리즈 노트 항목

0.2.108

2025-05-13

You can now send messages to Claude while it works to steer Claude in real-time

1.0.84

2025-08-18

Fix Claude sometimes ignoring real-time steering when wrapping up a task

2.1.20

2026-01-27

Fixed agents sometimes ignoring user messages sent while actively working on a task

작업 중 메시지를 실시간으로 보내는 기능 자체는 2025년 5월에 도입되었습니다. "보낸 메시지를 무시한다"는 문제를 정면으로 고친 항목은 v2.1.20이 마지막입니다. 한 버전을 꼽으라면 이쪽입니다.

그 뒤로는 기능 변경이 아니라 유실이나 표시 관련 수정이 이어졌습니다.

  • v2.1.97(2026-04-08): 작업 중 입력한 메시지가 transcript에 기록되지 않던 문제

  • v2.1.105(2026-04-13): 큐에 넣은 메시지에 첨부한 이미지가 버려지던 문제

  • v2.1.178(2026-06-15): subagent가 턴을 마무리하는 중에 보낸 메시지가 유실되던 문제

  • v2.1.205(2026-07-08): --max-turns 한도에서 턴이 끝날 때 작업 중 보낸 메시지가 조용히 사라지던 문제

  • v2.1.234(2026-08-17): 큐에 남아 있는 메시지가 프롬프트 이력에 다시 나타나던 문제

  • v2.1.239(2026-08-21): 큐에 프롬프트가 있는 상태에서 Esc를 눌렀을 때 턴이 일찍 끝나던 경합 문제

참고 자료


이 포스트는 Claude Code와 정상혁이 함께 작성했습니다.