Java의 Call by value
실무에서 맡은 기능을 구현하는 중 위 제목과 같이 Call by value와 관련된 이슈를 만나 기록하고자 한다.
Call by value
Call by value 란 간단하게 값에 의한 호출을 의미한다.
즉 메서드 호출 시 전달되는 인자는 변수의 값만을 복사하여 전달하게 된다.
이렇게 변수의 값만을 복사한 인자는 해당 메서드에서 지역변수로서 사용되고 해당 메서드가 스택에서 소멸되면 함께 사라진다.
Java는 이러한 Call by value의 특성을 가지고 있으며 원시형 타입 8개는 변수의 값을 복사하고 Array와 인스턴스 변수와 같은 참조형 변수는 주소값을 복사한다.
기본형(원시형) 타입의 Call by value
그렇다면 간단한 예제 코드를 통해서 Call by value 대해서 알아보자.
public class callByValueTest {
public static void transferTwenty(int num) {
num = 20;
}
public static void main(String[] args) {
int num = 10;
System.out.println(num);
transferTwenty(num);
System.out.println(num);
}
}
위 코드는 main의 num 변수 값 10이 transferTwenty(num) 메서드를 통해 20으로 변환되어 출력되기를 기대하는 코드이다.
하지만 Java와 같이 Call by value 기반의 언어는 기대한 결과와 다르게 아래와 같이 출력된다.
출력 결과
10
10
즉 transferTwenty 메서드에 원본값을 넘겨줬음에도 불구하고 num 값이 변경되지 않고 10으로 출력되는 것을 확인할 수 있다.
해당 코드에서 일어나는 일을 메모리 구조를 통해서 알아보자.

위와 같이 Java에서 원시형(기본 타입) 은 힙 영역에 생성되지 않고 Stack 영역에 직접 할당된다.
(TMI : 그렇기 때문에 원시형이 참조형 보다 성능적으로 우수하다)
메서드 호출 후의 스택 영역을 살펴보면 main의 num 주소값은 0x100이고, 메서드 호출 뒤 인자로 넘어간 변수 num은 0x200으로 값만 복사된 것을 확인할 수 있다.
즉 인자로 넘어가기 전 호출자(main num)와 메서드의 인자로 받는 수신자(transferTwenty num)는 값 복사만 일어났을 뿐 전혀 다른 메모리 주소값을 가진다.
그리고 이후 메서드가 종료되어 스택 영역에서 소멸되게 되면 지역 변수로 할당 돼 있던 num(0x200) 은 메모리에서 사라지게 된다.
여기서 주목해야 할 점은 밑의 두 가지이다.
1. main의 num과 메서드 인자로 넘어간 지역변수 num의 메모리 주소값은 다르다.
2. main의 num 값을 메서드 인자로 넣어주면서 값(10)만을 복사
보통 기본형의 경우는 헷갈리지 않지만 참조형일 경우 이러한 개념을 알고 있음에도 불구하고 혼돈이 오게 된다.
특히 이번 글을 쓰게 된 계기인 실무에서 String 원본 값을 메서드의 인자로 넘겨주었을 때 원본값이 바뀌지 않는 것이 그러하다.
String 타입의 Call by value
밑의 예시 코드를 통해 String의 Call by value와 관련된 내용을 확인해 보자.
public class callByValueTest {
public static void print(String text) {
text = text + "world";
System.out.println(text);
}
public static void main(String[] args) {
String text = "Hello";
System.out.println(text);
print(text);
System.out.println(text);
}
}
참조형 타입인 String 은 객체의 주소값을 복사하여 print 메서드에 넘겨주어 원시형 타입과 다르게 원본값이 바뀔 것이라고 착각하기 쉽다.
main 메서드의 실행 결과는 아래와 같다.
출력 결과
Hello
Hello world
Hello
역시나 String 타입도 원시형과 같이 원본 값이 변하지 않은 것을 확인할 수 있다.
위의 코드도 메모리 영역을 자세히 살펴보자.

Java의 문자열은 그림과 같이 Heep 영역의 특수한 공간인 String pool(상수풀)이라는 공간에 저장된다.
String Pool의 역할을 간략하게 설명하면 문자열 리터럴이 생성될 때마다 JVM은 해당 문자열이 상수풀에 존재하는지 찾아보고 없으면 상수풀에 추가 후 주소값을 리턴하고, 있으면 기존 리터럴의 주소값을 리턴하는 역할을 한다.
즉 문자열은 너무도 자주 쓰이기 때문에 이러한 특수한 저장 공간을 만들어 메모리 절약 효과와 성능적인 우위를 점하기 위한 Heep 영역의 문자열 전용 저장 공간이라고 생각하면 된다.
다시 돌아와 text값 변경 전의 메모리 구조를 보면 main의 text 값(String 참조값)이 print 메서드의 파라미터 text에 복사되어 상수풀의 "Hello"를 가리키고 있다.
그러나 text 값을 변경하면 상수풀에는 "Hello world" 문자열이 새로 생성되고, 기존에 print 메서드의 text 참조값은 0x200으로 변경된 것을 볼 수 있다.
이러한 이유는 String 은 immutable(불변) 한 특징을 갖고 있기 때문이다.
String 타입의 값은 '절대 변경할 수 없다'는 특징을 가지고 있기 때문에 새로운 값을 할당하려고 하면 상수풀에 새로운 리터럴을 생성하고 이전에 참조하던 참조값(0x100)은 끊어버리고 새로 생성된 리터럴의 참조값을 가리키게 되는 것이다.
그러므로 main의 text 변수 주소값과 print 메서드의 text 변수 주소값이 다르고 String 타입은 불변하다는 특징으로 main의 text 원본값을 바꿀 수 없기 때문에
클래스의 인스턴스나 Array와 같은 참조형은 객체의 주소값을 복사해 해당 객체의 속성값을 변경하므로 마치 Call by reference 인 것처럼 동작하지만 String은 참조형이지만 마치 기본형인 것처럼 동작한다는 것을 알 수 있다.
이슈
실무에서 기능을 구현하면서 있었던 이슈도 위와 같은 경우였다.
아래의 코드를 통해 간단하게 이슈를 확인해 보자.
@Controller
public class Controller{
@GetMapping("/")
public String callByValueTest(){
String message= "";
testService.add(message);
log.info(message);
return "home"
}
}
testService의 add 메서드를 통해 message 원본 값을 변경하려 하였지만 String의 immutable 한 특성 때문에 계속해서 message 값이 빈문자열로 출력되었던 것이다.
해결 방안
StringBuilder를 이용하는 것이다.
StringBuilder는 문자열을 변경 가능하도록 해주며 문자열을 추가하거나 삭제하는 작업에 용이하게 사용된다.
그래서 위의 코드를 개선하면 아래와 같다.
@Controller
public class Controller{
@GetMapping("/")
public String callByValueTest(){
StringBuilder message= new StringBuilder("");
testService.add(message);
log.info(message);
return "/home"
}
}
물론 StringBuilder 대신 Map과 같은 참조형 타입을 써도 되지만 가독성 및 문자열을 사용한다는 의도를 분명하게 하기 위해 StringBuilder를 쓰는 것이 더 좋은 코드라는 개인적인 사견이다.
요약
✔️ Java는 Call by value를 따라 값만을 복사한다.
✔️ 참조형 타입은 참조하고 있는 참조값(Heap 영역의 리터럴 주소값)을 복사해서 넘겨주어 마치 Call by reference처럼 동작한다.
✔️ String은 참조형이지만 immutable 한 특성으로 인해 참조하고 있는 문자열을 변경하면 상수풀에 새로운 리터럴이 생성되고 이전에 참조하고 있던 값은 GC에 의해 제거된다.
✔️ 문자열의 원본값을 바꾸고 싶거나 문자열이 자주 변경되는 상황일 경우 StringBuilder를 활용한다