자바에서 참조형을 이해하는 것은 매우 중요하다. 변수의 데이터 타입은 크게 보면 사용하는 값을 변수에 직접 넣을 수 있는 “기본형” 과 실제 데이터가 있는 주소를 가리키는 참조값을 넣을 수 있는 “참조형”, 이렇게 2가지로 분류할 수 있다.
int, long, double, boolean처럼 변수에 사용할 값을 직접 넣을 수 있는 데이터 타입이다. 직접 사용할 수 있는 값이 들어가 있으며, 해당 값을 바로 사용할 수 있다.Student student1, int[] students와 같이 데이터에 접근하기 위한 참조(주소)를 저장하는 데이터 타입이다. 참조형은 객체 또는 배열에 사용된다. 객체는 온점(.)을 통해서 메모리 상에 생성된 객체를 찾아가 사용하고, 배열은 []를 통해서 메모리 상에 생성된 배열을 찾아가 사용한다.
기본형은 연산이 가능하지만, 참조형은 불가능하다. 참조형 변수에는 객체의 위치를 가리키는 참조값이 들어있기 때문에 온점(.)을 통해 객체의 기본형 멤버 변수에 접근한 경우에만 연산이 가능하다.
Student student1 = new Student();
student1.grade = 100;
Student student2 = new Student();
student2.grade = 70;
int average = student1.grade + student2.grade; // 참조형은 이렇게 연산 가능
자바의 대원칙: “자바는 항상 변수의 값을 복사해서 대입한다.”
코드를 보면서 이해해보자.
int a = 10;
int b = a;
a의 값 10을 “복사” 해서 b에 넣어주는 것이다. 쉽게 말해, a와 b는 각각 다른 10을 가지고 있는 상태다. 기본형과 참조형 모두 항상 변수에 있는 값을 복사해서 대입한다.
기본형이면 변수에 들어있는 실제 사용하는 값을 복사해서 대입하고, 참조형이면 변수에 들어있는 참조값을 복사해서 대입한다. 다른 예제를 보자.
Student student1 = new Student();
Student student2 = student1;
이때는, student1의 참조값을 복사해서 student2에 넣어주는 것이다. 그 말은, student1과 student2가 같은 객체를 가리키고 있다는 것이다. 쉽게 말해 인스턴스는 하나인데, 인스턴스에 갈 수 있는 길이 2개라는 말이다.
아래 코드를 보고 어떤 결과가 나올지 생각해보자.
public class VarChange1 {
public static void main(String[] args) {
int a = 10;
int b = a;
System.out.println("a = " + a); // 10
System.out.println("b = " + b); // 10
a = 20;
System.out.println("변경된 a = 20");
System.out.println("a = " + a); // 20
System.out.println("b = " + b); // 10
b = 30;
System.out.println("변경된 b = 30");
System.out.println("a = " + a); // 20
System.out.println("b = " + b); // 30
}
}
간단하다. int b = a 라고 했을 때 변수에 들어있는 값을 복사해서 전달한다는 핵심만 명심하면 된다. 따라서 a=20, b=30 이라고 했을 때, 각각 본인의 값만 변경되는 것을 확인할 수 있다.
이제 다른 코드를 살펴보자.
package ref;
public class Data {
int value;
}
package ref;
public class VarChange2 {
public static void main(String[] args) {
Data data1 = new Data();
data1.value = 10;
Data data2 = data1;
System.out.println("data1 참조값= " + data1);
System.out.println("data2 참조값= " + data2);
System.out.println("data1.value= " + data1.value);
System.out.println("data2.value= " + data2.value);
data1.value = 20;
System.out.println("변경 data1.value = 20");
System.out.println("data1.value = " + data1.value);
System.out.println("data2.value = " + data2.value);
data2.value = 30;
System.out.println("변경 data2.value = 30");
System.out.println("data1.value = " + data1.value);
System.out.println("data2.value = " + data2.value);
}
}
/*
data1 참조값 = ref.Data@a09ee92
data2 참조값 = ref.Data@a09ee92
data1.value = 10
data1.value = 10
변경 data1.value = 20
data1.value = 20
data2.value = 20
변경 data2.value = 30
data1.value = 30
data2.value = 30
*/
보다시피 길이 2개인 것 뿐이지, 막상 찾아가면 같은 객체를 만나는 것이다. data1와 data2에 들어있는 참조값은 동일한 것이다. 그래서 data1을 사용하든, data2를 사용해서 value를 변경하면 그 녀석의 value가 바뀌는 것이다.
이번에는 기본형과 참조형이 메서드 호출에 따라서 어떻게 달라지는지 알아보자.
다시 한번, 상기하는 대원칙…
“자바는 항상 변수의 값을 복사해서 대입한다.”
기본형이면 변수에 들어있는 실제 사용하는 값을 복사해서 대입하고, 참조형이면 변수에 들어있는 참조값을 복사해서 대입하는 것이다.
메서드 호출도 다를 게 없다. 메서드를 호출할 때 사용하는 매개변수(파라미터)도 결국 변수일 뿐이기 때문이다. 매개변수에 값을 전달하는 것 역시 값을 복사해서 전달하는 것이다.
아래 코드를 보자.
package ref;
public class MethodChange1 {
public static void main(String[] args) {
int a = 10;
System.out.println("메서드 호출 전 a = " + a); // 10
changePrimitive(a);
System.out.println("메서드 호출 후 a = " + a); // 10
}
public static void changePrimitive(int x) {
x = 20;
}
}
왜 메서드를 호출하고 나서 20이 출력되지 않을까?

위 그림을 보면 먼저 a는 10으로 선언했었다. changePrimitive() 메서드가 호출되면서 선언된 x가 메모리에 생성된다. 이때 x에는 a에 있는 값 10이 복사되어 전달된다. 그래서 x는 10이 된 것이다. 결국 아래의 코드처럼 해석할 수 있다.
int x = a;
따라서 변수 a, x 모두 각각 숫자 10을 가지고 있는 것이다.

x는 20이라고 하면, x 변수에 있는 값만 20으로 바뀌는 것이다. a에는 아무런 영향을 주지 않는다.

메서드 종료 후, 출력을 하면 a에는 당연히 10이 그대로 들어 있다. 바뀐 것은 x뿐이다. 참고로 메서드가 종료되면 더 이상 쓸모가 없으니 메모리에서 사용됐던 매개변수 x는 제거된다.
이번에는 약간 다르게 접근해보자. 아래 코드를 보고 결과를 예상해보자.
package ref;
public class MethodChange2 {
public static void main(String[] args) {
Data dataA = new Data();
dataA.value = 10;
System.out.println("메서드 호출 전 dataA.value = " + dataA.value); // 10
System.out.println("dataA의 참조값: " + dataA);
changeReference(dataA);
System.out.println("메서드 호출 후 dataA.value = " + dataA.value); // 20
}
public static void changeReference(Data dataX) {
System.out.println("dataX의 참조값: " + dataX);
dataX.value = 20;
}
}
이번엔 왜 20으로 바뀐걸까? Data 인스턴스를 생성하고, 참조값을 dataA 변수에 담고 value에 10을 할당한 상태는 아래와 같다.


changeReference() 메서드를 호출할 때, 매개변수 x에 dataA가 가지고 있는 참조값을 전달한다. 이 과정도 아래의 코드와 동일한 것이다.
int dataX = dataA;
dataA는 현재 참조값 x001을 가지고 있으므로 참조값을 복사해서 전달한 것이다. 따라서 현재 dataA, dataX 모두 같은 참조값 x001을 가지게 된다. 그래서 dataX를 통해서도 Data 인스턴스에 접근할 수 있는 것이다.

메서드 안에서 dataX.value = 20으로 새로운 값을 대입하고 있다. 참조값을 통해 x001 인스턴스에 접근하고 그 안에 있는 value의 값을 20으로 변경한 것이다. 방금 말했다시피 dataA와 dataX가 동일한 인스턴스를 가리키고 있기 때문에 당연히 dataA로 인스턴스의 value를 꺼내보면 20을 보게 되는 것이다.

메서드가 종료되고 값을 실제로 확인해보면 20이 찍힌다.
지금까지의 내용을 정리해보자. 자바에서 메서드의 파라미터는 항상 값에 의해 전달되는 것이다. 그러나 해당 값이 실제 값이냐, 참조값이냐에 따라 동작이 달라진다.
package class1;
public class ClassStart2 {
public static void main(String[] args) {
Student student1 = new Student();
student1.name = "학생1";
student1.age = 15;
student1.grade = 80;
Student student2 = new Student();
student2.name = "학생2";
student2.age = 20;
student2.grade = 100;
System.out.println("이름:" + student1.name + " 나이:" + student1.age + " 점수:" + student1.grade);
System.out.println("이름:" + student2.name + " 나이:" + student2.age + " 점수:" + student2.grade);
}
}
해당 코드를 보면, 불편한 부분이 있다. 각 객체에 name, age, grade에 값을 각각 대입하는 부분, 출력하는 부분이 그렇다. 코드가 거의 똑같다. 저 중복되는 부분을 쉽게 해결할 수 있는 방법이 없을까?
package ref;
public class Student {
String name;
int age;
int grade;
}
package ref;
public class Method1 {
public static void main(String[] args) {
Student student1 = new Student();
Student student2 = new Student();
initStudent(student1, "학생1", 15, 70);
initStudent(student2, "학생2", 20, 90);
printStudent(student1);
printStudent(student2);
}
// 전달한 학생 객체의 필드에 값을 설정
static void initStudent(Student student, String name, int age, int grade) {
student.name = name;
student.age = age;
student.grade = grade;
}
// 전달한 학생 객체의 필드 값을 읽어서 출력
static void printStudent(Student student) {
System.out.println("이름: " + student.name + " 나이:" + student.age + " 성적:" + student.grade);
}
}
/*
이름: 학생1 나이:15 성적:70
이름: 학생2 나이:20 성적:90
*/
이처럼 참조형은 메서드를 호출할 때 참조값을 전달한다고 했다. 따라서 메서드 내부에서 전달된 참조값을 통해 객체의 값을 변경하거나, 읽어서 사용할 수 있는 것이다.

initStudent() 메서드의 student 매개 변수에 student1의 값, 즉 참조값이 전달되는 것이다. 그리고 각각의 멤버 변수의 값을 매개변수 name, age, grade로 받은 값으로 설정하는 것이다. 결국 해당 참조값을 통해 initStudent() 메서드 안에서 student1이 참조하는 것과 동일한 x001, 같은 Student 인스턴스에 접근하고 값을 변경할 수 있다.
근데 위 코드에서 아쉬운 부분이 좀 있다. 객체를 생성하고, 그 다음에 값을 대입하는 느낌? 별로 좋지 않다… 그냥 생성하면서 초기화하는 기가 막힌 방법 없을까? 아래 코드를 보자.
package ref;
public class Method2 {
public static void main(String[] args) {
Student student1 = createStudent("학생1", 15, 90);
Student student2 = createStudent("학생2", 20, 100);
printStudent(student1);
printStudent(student2);
}
static Student createStudent(String name, int age, int grade) {
Student student = new Student();
student.name = name;
student.age = age;
student.grade = grade;
return student; // 참조값 반환
}
static void printStudent(Student student) {
System.out.println("이름: " + student.name + " 나이:" + student.age + " 성적:" + student.grade);
}
}
/*
이름: 학생1 나이:15 성적:90
이름: 학생2 나이:20 성적:100
*/
createStudent() 메서드에서 Student 객체를 아예 생성하고, 매개 변수를 통해 넘어온 값을 모두 대입하도록 한다. 이제 이 메서드 하나로 객체의 생성과 초기값 설정을 모두 처리하는 것이다. 그리고 값이 잘 대입된 객체의 참조값, student를 바깥으로 던져주는 것이다.
그림을 보면서 자세히 분석해보자.

이제 변수의 종류에 대해 알아보자. 변수는 크게 멤버 변수(필드), 지역 변수로 구분할 수 있다.
앞서 작성했던 코드들을 가지고 구분해보자.
package ref;
public class Student {
String name;
int age;
int grade;
}
여기서 name, age, grade는 클래스의 멤버 변수고,
package class1;
public class ClassStart2 {
public static void main(String[] args) {
Student student1 = new Student();
student1.name = "학생1";
student1.age = 15;
student1.grade = 80;
Student student2 = new Student();
student2.name = "학생2";
student2.age = 20;
student2.grade = 100;
System.out.println("이름:" + student1.name + " 나이:" + student1.age + " 점수:" + student1.grade);
System.out.println("이름:" + student2.name + " 나이:" + student2.age + " 점수:" + student2.grade);
}
}
위의 코드에서 student1 과 student2는 지역 변수이다.
package ref;
public class MethodChange2 {
public static void main(String[] args) {
Data dataA = new Data();
dataA.value = 10;
System.out.println("메서드 호출 전 dataA.value = " + dataA.value); // 10
System.out.println("dataA의 참조값: " + dataA);
changeReference(dataA);
System.out.println("메서드 호출 후 dataA.value = " + dataA.value); // 20
}
public static void changeReference(Data dataX) {
System.out.println("dataX의 참조값: " + dataX);
dataX.value = 20;
}
}
여기서 dataX 와 dataA는 지역 변수이다. 지역 변수는 "특정 범위에서만 사용되는 변수" 라는 뜻이다. 예를 들어, 변수 dataX는 changeReference() 메서드의 블록에서만 사용된다. 해당 메서드가 종료되면 dataX는 제거되는 것이다. dataA 지역 변수도 마찬가지로, main() 메서드가 종료되면 제거된다.
그리고 멤버 변수와 지역 변수는 초기화도 다르다.
멤버 변수 : 자동으로 초기화된다.
0, boolean은 false, 참조형은 null로 초기화된다. (null은 참조할 대상이 없다는 의미)지역 변수 : 수동으로 초기화해야 한다.
개발할 때, 우리를 상당히 괴롭힐 null에 대해 알아보자. 택배를 보낼 때 제품은 준비되었지만, 보낼 주소지가 아직 결정되지 않아서 결정될 때까지 주소지를 비워둬야 할 경우가 생길 수 있다.
이와 비슷한 맥락으로, 참조형 변수에는 항상 객체가 있는 위치를 가리키는 참조값이 들어간다. 근데 아직 가리키는 대상이 없거나, 가리키는 대상을 나중에 입력하고 싶다면 어떻게 해아 할까? 이때 참조형 변수에 null이라는 특수한 값을 넣어둘 수 있다. null은 값이 존재하지 않는, 없다는 뜻이다.
코드를 통해 알아보자.
package ref;
public class NullMain1 {
public static void main(String[] args) {
Data data = null;
System.out.println("1. data = " + data); // null
data = new Data();
System.out.println("2. data = " + data); // ref.Data@23fc625e
data = null;
System.out.println("3. data = " + data); // null
}
}
처음에 Data 타입을 받을 수 있는 참조형 변수인 data를 만들어서 null 값을 할당했다. 따라서 data는 현재 가리키는 객체가 없다는 뜻으로 해석할 수 있다. 이후에 new Data()를 통해 새로운 객체를 생성해서 그 참조값을 data에 할당했고 찍어보면 참조값이 존재하는 것을 볼 수 있다. 마지막으로 data에 다시 null 값을 할당했기 때문에 앞서 만든 Data 인스턴스를 더 이상 참조하지 않는다.
위의 코드에서 Data 인스턴스는 이제 아무도 참조하지 않게 되었다. 이렇게 아무도 참조하지 않게 되면 x001이라는 참조값을 다시 알아낼 방법이 없다. 다른 말로, 해당 인스턴스에 다시 접근할 방법이 없다는 뜻이다. 하지만, 없어진 것이 아니라 메모리 바다 어딘가에 표류하고 있는 것이다. 자바는 이렇게 방황하고 있는 인스턴스가 있으면 "JVM의 GC(Garbage Collection)" 가 해당 인스턴스를 자동으로 메모리에서 제거해준다.
만약 참조값 없이 객체를 찾아가면 어떤 문제가 발생할까? 이때 NullPointerException이 터진다. 이름 그대로 null을 가리킨다는 예외다. 주소가 없는 곳으로 찾아간다? 어불성설이다. 참조값이 null인 객체에 온점(.)을 찍어서 찾아가려고 할 때, 문제가 발생한다.

보다시피, 인스턴스의 value를 바꾸려고 해도 참조값이 없어서 찾아갈 방법이 없기 때문에 NullPointerException이 터지고 프로그램이 종료된 것을 볼 수 있다. 이와 같이 지역 변수에서 null 문제를 파악하는 것은 그리 어렵지 않다. 문제는 멤버 변수가 null인 경우에 상당한 주의가 필요하다.
package ref;
public class BigData {
Data data;
int count;
}

bigData.count, bigData.data 각각의 초기값은 0, null이 들어간다. 그러니까 bigData.data.value는 null.value와 같은 것이다. 그래서 당연히 NullPointerException이 터지는 것이다.
이 문제를 해결하려면 Data 인스턴스를 만들고 BigData.data 멤버 변수에 참조값을 할당하면 된다.


정리하자면, NullPointerException이 발생하면 null 값에 온점(.)을 찍었다고 생각하면 문제점을 생각보다 쉽게 찾을 수 있다.
앞서 만들었던, ProductOrder, ProductOrderMain을 리팩토링해보자.
먼저, ProductOrder 클래스는 상품명, 가격, 주문 수량을 가지고 있어야 한다. 그리고 리팩토링할 클래스 안에 main() 메서드를 포함하여, 여러 상품의 주문 정보를 배열로 관리하고, 그 정보들을 출력, 최종 결제 금액을 계산한 것도 출력해야 한다. 아래 메서드를 포함해서 말이다.
static ProductOrder createOrder(String productName, int price, int quantity)static void printOrders(ProductOrder[] orders)static int getTotalAmount(ProductOrder[] orders)
package class1;
public class ProductOrder {
String productName;
int price;
int quantity;
}
// 내가 푼 풀이
package class1;
public class ProductOrderMain2 {
public static void main(String[] args) {
ProductOrder[] orders = new ProductOrder[] {
createOrder("마라탕", 20000, 1),
createOrder("떡볶이", 10000, 2),
createOrder("요거트 아이스크림", 15000, 3)
};
printOrders(orders);
}
static ProductOrder createOrder(String productName, int price, int quantity) {
ProductOrder order = new ProductOrder();
order.productName = productName;
order.price = price;
order.quantity = quantity;
return order;
}
static void printOrders(ProductOrder[] orders) {
for (ProductOrder o : orders) {
System.out.println("상품명: " + o.productName + ", 가격:" + o.price + ", 수량:" + o.quantity);
}
System.out.println("총 결제 금액: " + getTotalAmount(orders));
}
static int getTotalAmount(ProductOrder[] orders) {
int totalPrice = 0;
for (ProductOrder o : orders) {
totalPrice += o.price * o.quantity;
}
return totalPrice;
}
}
/*
상품명: 마라탕, 가격:20000, 수량:1
상품명: 떡볶이, 가격:10000, 수량:2
상품명: 요거트 아이스크림, 가격:15000, 수량:3
총 결제 금액: 85000
*/
사용자의 입력을 받아서 처리한다고 한다면…
package class1;
import java.util.Scanner;
public class ProductOrderMain3 {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.print("입력할 주문의 개수를 입력하세요: ");
int n = sc.nextInt();
sc.nextLine();
ProductOrder[] orders = new ProductOrder[n];
for (int i = 0; i < n; i++) {
System.out.print((i+1) + "번째 주문 정보를 입력하세요.");
System.out.print("상품명: ");
String productName = sc.nextLine();
System.out.print("가격: ");
int price = sc.nextInt();
System.out.print("수량: ");
int quantity = sc.nextInt();
sc.nextLine();
orders[i] = createOrder(productName, price, quantity);
}
printOrders(orders);
}
static ProductOrder createOrder(String productName, int price, int quantity) {
ProductOrder order = new ProductOrder();
order.productName = productName;
order.price = price;
order.quantity = quantity;
return order;
}
static void printOrders(ProductOrder[] orders) {
for (ProductOrder o : orders) {
System.out.println("상품명: " + o.productName + ", 가격:" + o.price + ", 수량:" + o.quantity);
}
System.out.println("총 결제 금액: " + getTotalAmount(orders));
}
static int getTotalAmount(ProductOrder[] orders) {
int totalPrice = 0;
for (ProductOrder o : orders) {
totalPrice += o.price * o.quantity;
}
return totalPrice;
}
}
/*
입력할 주문의 개수를 입력하세요: 3
1번째 주문 정보를 입력하세요.상품명: 마라탕
가격: 20000
수량: 1
2번째 주문 정보를 입력하세요.상품명: 떡볶이
가격: 10000
수량: 2
3번째 주문 정보를 입력하세요.상품명: 요거트 아이스크림
가격: 15000
수량: 3
상품명: 마라탕, 가격:20000, 수량:1
상품명: 떡볶이, 가격:10000, 수량:2
상품명: 요거트 아이스크림, 가격:15000, 수량:3
총 결제 금액: 85000
*/
대원칙: “자바는 항상 변수의 값을 복사해서 대입한다!”
기본형, 참조형 모두 항상 변수에 있는 값을 복사해서 대입한다. 기본형이면 변수에 들어있는 실제 사용하는 값을 복사해서 대입하고, 참조형이면 변수에 들어 있는 참조값을 복사해서 대입한다. 기본형이든 참조형이든 변수의 값을 대입하는 방식은 같다. 하지만 기본형과 참조형에 따라 동작하는 방식이 달라진다.
기본형 vs 참조형 - 기본
null을 할당할 수 없지만, 참조형 변수는 null을 할당할 수 있다.기본형 vs 참조형 - 대입
기본형 vs 참조형 - 메서드 호출