자바 객체 참조 전달 void 메서드 값 변경 이유 오류 해결 예제 코드
자바를 공부하다 보면 void 메서드는 값을 반환하지 않는데, 메서드 안에서 객체 값을 바꿨더니 원본 객체까지 변경되는 상황을 자주 만나게 됩니다.
처음 보면 “자바가 객체를 참조 전달하는 건가?”라고 생각하기 쉽습니다. 하지만 정확히 말하면 자바는 객체 자체를 참조 전달하는 언어가 아니라, 객체를 가리키는 참조값을 복사해서 전달합니다.
쉽게 말하면 메서드 안의 매개변수와 메서드 밖의 변수가 같은 객체를 바라보고 있기 때문에, 그 객체의 내부 값을 바꾸면 양쪽에서 변경된 결과가 보입니다.
자바 객체 참조 전달 개념 먼저 정리하기
🔉 1. 자바는 참조 전달이 아니라 값 전달 방식입니다
결론부터 보면, 자바의 메서드 호출은 기본형이든 참조형이든 모두 값 전달 방식으로 이해해야 합니다.
여기서 많이 헷갈리는 부분은 객체 변수에 저장된 값이 객체 자체가 아니라는 점입니다. 객체 변수에는 실제 객체가 통째로 들어 있는 것이 아니라, 힙 메모리에 있는 객체를 찾아갈 수 있는 참조값이 들어 있습니다.
Java Language Specification은 메서드가 호출될 때 각 매개변수마다 새로운 변수(parameter variable)가 만들어지고, 그 변수는 호출 시 전달된 인자 값으로 초기화된다고 설명합니다. 즉, 메서드 안의 매개변수는 바깥 변수와 같은 변수가 아니라 새로 만들어진 변수입니다.
🔉 2. 객체 값이 바뀌는 이유는 같은 객체를 바라보기 때문입니다
void 메서드에서 객체 값이 바뀌는 이유는 반환값 때문이 아니라, 메서드 안에서 같은 객체의 필드나 상태를 수정했기 때문입니다.
예를 들어 User user라는 변수가 있고, 이 변수를 changeName(user)에 전달했다고 가정해보겠습니다. 이때 user 변수 자체가 넘어가는 것이 아니라, user 안에 들어 있던 참조값이 복사되어 메서드의 매개변수로 들어갑니다.
하지만 복사된 참조값도 같은 객체를 가리킵니다. 그래서 메서드 안에서 user.name = "Kim"처럼 객체 내부 값을 바꾸면, 메서드 밖에서도 같은 객체를 보고 있으므로 변경된 값이 확인됩니다.
void 메서드에서 객체 값이 변경되는 예제 코드
🔉 1. 필드 값을 변경하면 원본 객체도 바뀝니다
아래 예제의 핵심은 changeName() 메서드가 void인데도 member.name 값이 바뀐다는 점입니다.
class Member {
String name;
Member(String name) {
this.name = name;
}
}
public class Main {
public static void main(String[] args) {
Member member = new Member("Lee");
changeName(member);
System.out.println(member.name); // Kim
}
static void changeName(Member member) {
member.name = "Kim";
}
}
이 코드는 changeName()이 아무것도 반환하지 않아도 member.name이 "Kim"으로 출력됩니다.
이유는 간단합니다. main()의 member와 changeName()의 member는 서로 다른 변수지만, 둘 다 같은 Member 객체를 바라보고 있습니다.
🔉 2. 매개변수에 새 객체를 대입하면 원본 변수는 바뀌지 않습니다
반대로 메서드 안에서 매개변수 자체에 새 객체를 대입하면, 바깥의 원본 변수는 바뀌지 않습니다.
class Member {
String name;
Member(String name) {
this.name = name;
}
}
public class Main {
public static void main(String[] args) {
Member member = new Member("Lee");
changeObject(member);
System.out.println(member.name); // Lee
}
static void changeObject(Member member) {
member = new Member("Kim");
}
}
이 예제에서는 member.name이 "Lee"로 출력됩니다.
메서드 안의 member는 복사된 참조값을 담은 별도 변수입니다. 따라서 member = new Member("Kim")을 실행하면 메서드 안의 매개변수만 새 객체를 바라보게 되고, main()의 member는 여전히 기존 객체를 바라봅니다.
객체 내부 변경과 객체 재할당 차이 비교
🔉 1. 내부 상태 변경과 참조 재할당은 결과가 다릅니다
자바 객체 전달에서 가장 중요한 구분은 “객체의 내부 값을 바꾼 것인지”와 “매개변수 변수가 다른 객체를 바라보게 만든 것인지”입니다.
이 두 가지를 구분하면 void 메서드에서 객체 값이 바뀌는 현상을 훨씬 쉽게 이해할 수 있습니다.
| 구분 | 메서드 안 코드 예시 | 원본 객체 영향 | 이유 |
|---|---|---|---|
| 객체 필드 변경 | member.name = "Kim" | 변경됨 | 같은 객체의 내부 상태를 수정함 |
| setter 호출 | member.setName("Kim") | 변경됨 | 같은 객체의 메서드를 통해 상태를 수정함 |
| 매개변수 재할당 | member = new Member("Kim") | 변경 안 됨 | 매개변수 변수만 새 객체를 바라봄 |
| 기본형 값 변경 | num = 10 | 변경 안 됨 | 기본형 값이 복사되어 전달됨 |
| 배열 요소 변경 | arr[0] = 10 | 변경됨 | 배열도 객체이므로 같은 배열 객체의 요소를 수정함 |
🔉 2. 배열과 컬렉션도 같은 원리로 동작합니다
배열과 컬렉션도 참조 타입이므로 메서드 안에서 내부 요소를 바꾸면 원본에서도 변경된 값이 보입니다.
예를 들어 List<String>을 메서드에 넘긴 뒤 list.add("Java")를 실행하면, 호출한 쪽의 리스트에도 "Java"가 추가됩니다. 이 역시 리스트 객체 자체가 복사된 것이 아니라, 같은 리스트 객체를 바라보는 참조값이 복사되었기 때문입니다.
하지만 메서드 안에서 list = new ArrayList<>()처럼 새 리스트를 대입하면 호출한 쪽의 리스트 변수는 바뀌지 않습니다. 이 차이를 모르면 “어떤 때는 바뀌고 어떤 때는 안 바뀐다”는 식으로 느껴질 수 있습니다.
자바 값 전달과 참조값 복사 흐름
🔉 1. 메서드 호출 시 매개변수 변수는 새로 만들어집니다
공식 문서 기준으로 보면, 메서드나 생성자를 호출할 때 전달된 인자 값이 해당 메서드의 매개변수에 대응됩니다. Oracle Java Tutorial에서도 메서드와 생성자가 인자를 받아 처리하는 구조를 설명하고 있으며, JLS는 매개변수 변수가 호출 때마다 새로 생성된다고 설명합니다.
쉽게 말하면 다음과 같은 흐름입니다.
| 단계 | 실행 흐름 | 핵심 의미 |
|---|---|---|
| 1단계 | Member member = new Member("Lee") | 힙에 객체가 생성되고 변수는 참조값을 가짐 |
| 2단계 | changeName(member) 호출 | 참조값이 메서드 매개변수로 복사됨 |
| 3단계 | member.name = "Kim" 실행 | 같은 객체의 필드 값이 변경됨 |
| 4단계 | 메서드 종료 | 매개변수 변수는 사라짐 |
| 5단계 | main()에서 출력 | 원본 변수가 같은 객체를 보므로 변경 결과가 보임 |
여기서 핵심은 메서드가 끝나면 매개변수 변수는 사라지지만, 힙에 있는 객체는 계속 남아 있을 수 있다는 점입니다. 그래서 객체 내부 값의 변경은 메서드 밖에서도 유지됩니다.
🔉 2. void는 객체 변경 가능 여부와 직접 관련이 없습니다
void는 메서드가 호출한 곳으로 값을 반환하지 않는다는 뜻일 뿐, 메서드 내부에서 객체 상태를 바꾸지 못한다는 뜻이 아닙니다.
Java Language Specification은 void 메서드 호출 표현식이 값을 반환하지 않는 표현식으로 사용된다고 설명합니다. 다만 메서드 호출은 객체 상태 변경 같은 부수 효과를 일으킬 수 있습니다.
따라서 void 메서드에서 객체 값이 바뀌는 것은 이상한 동작이 아닙니다. 반환값은 없지만, 같은 객체에 접근해서 내부 상태를 변경했기 때문에 결과가 남는 것입니다.
자바 객체 참조 전달 오류 해결 체크리스트
🔉 1. 원본 객체를 바꾸고 싶지 않다면 복사본을 사용해야 합니다
원본 객체 변경을 막고 싶다면 메서드에 객체를 그대로 넘기기보다 복사본을 만들어 전달하는 방식을 고려해야 합니다.
예를 들어 회원 정보를 검증하거나 임시로 수정해보는 로직이라면 원본 객체를 직접 바꾸는 코드가 위험할 수 있습니다. 이럴 때는 복사 생성자, 정적 팩토리 메서드, DTO 변환, 불변 객체 설계 등을 사용할 수 있습니다.
Member copied = new Member(original.name);
changeName(copied);
이렇게 하면 copied 객체의 값만 바뀌고 original 객체의 값은 유지됩니다.
🔉 2. 객체 변경 의도를 메서드 이름에 드러내는 것이 좋습니다
객체를 변경하는 메서드는 이름만 봐도 변경이 일어난다는 사실을 알 수 있어야 합니다.
예를 들어 checkMember(member)라는 이름은 단순 확인처럼 보입니다. 그런데 내부에서 member.status = "ACTIVE"처럼 값을 바꾸면 코드를 읽는 사람이 예상하기 어렵습니다.
이런 경우에는 activateMember(member), updateMemberName(member), normalizeMemberInfo(member)처럼 변경 의도를 드러내는 이름이 더 안전합니다.
🔉 3. setter 남용은 디버깅을 어렵게 만들 수 있습니다
객체 값이 예상하지 못한 곳에서 바뀐다면, 대부분은 여러 메서드에서 같은 객체를 공유하면서 setter나 필드 변경이 흩어져 있기 때문입니다.
특히 서비스 로직이 길어질수록 어느 지점에서 값이 바뀌었는지 추적하기 어려워집니다. 이럴 때는 객체를 불변으로 만들거나, 값 변경 메서드를 한 곳으로 모으는 방식이 도움이 됩니다.
준비서류 체크리스트처럼 개발자가 확인할 항목을 정리하면 다음과 같습니다.
| 확인 항목 | 점검 내용 |
|---|---|
| 매개변수 재할당 여부 | obj = new Object()가 원본을 바꾸는 코드라고 착각하지 않았는지 확인 |
| 필드 직접 변경 여부 | obj.field = value 코드가 원본 객체 상태를 바꾸는지 확인 |
| setter 호출 여부 | setName(), setStatus() 같은 변경 메서드 호출 위치 확인 |
| 컬렉션 변경 여부 | add(), remove(), clear()가 원본 컬렉션에 영향을 주는지 확인 |
| 복사본 필요 여부 | 원본 보존이 필요한 로직인지 확인 |
| 불변 객체 가능 여부 | 변경 자체를 막는 설계가 더 적합한지 확인 |
실무에서 헷갈리는 상황별 정리
🔉 1. DTO를 넘긴 뒤 값이 바뀌는 경우
DTO를 메서드에 넘긴 뒤 필드 값이나 setter로 값을 바꾸면 원본 DTO도 변경된 상태로 남습니다.
예를 들어 요청 DTO를 검증하는 메서드에서 빈 문자열을 null로 바꾸거나, 날짜 형식을 보정하거나, 상태값을 추가하는 로직이 들어가면 호출한 쪽에서도 변경된 DTO를 보게 됩니다.
이런 변경이 의도된 것이라면 문제가 없습니다. 다만 단순 검증 메서드처럼 보이는 곳에서 값을 바꾸면 유지보수 과정에서 오류로 이어질 수 있습니다.
🔉 2. List를 메서드에 넘겼더니 원본 목록이 바뀌는 경우
List, Map, Set 같은 컬렉션은 메서드 안에서 요소를 추가하거나 삭제하면 원본 컬렉션에도 그대로 반영됩니다.
예를 들어 removeInvalidItems(list)처럼 이름에서 변경이 드러나는 메서드는 비교적 안전합니다. 반면 validateItems(list)라는 이름의 메서드가 내부에서 list.remove()를 실행하면 예상하지 못한 데이터 손실처럼 보일 수 있습니다.
원본 리스트를 유지해야 한다면 new ArrayList<>(list)처럼 새 컬렉션을 만든 뒤 작업하는 것이 좋습니다.
List<String> copiedList = new ArrayList<>(originalList);
removeInvalidItems(copiedList);
🔉 3. final 매개변수는 참조 재할당만 막습니다
final을 매개변수에 붙이면 매개변수 변수가 다른 객체를 다시 가리키는 것은 막을 수 있습니다.
하지만 final Member member라고 선언해도 member.name = "Kim"처럼 객체 내부 값을 바꾸는 것은 가능합니다. final은 변수에 다시 대입하는 것을 막는 키워드이지, 객체 자체를 불변으로 만드는 키워드가 아닙니다.
객체 내부 변경까지 막고 싶다면 필드를 final로 설계하거나 setter를 제거하고, 생성 시점에만 값을 넣는 불변 객체 구조를 고려해야 합니다.
자주 묻는 질문
🔉 질문 1
Q. 자바는 객체를 참조 전달하나요, 값 전달하나요?
A. 자바는 값 전달 방식입니다. 다만 객체 변수에 들어 있는 값이 객체 자체가 아니라 참조값이기 때문에, 참조값이 복사되어도 같은 객체를 바라볼 수 있습니다.
🔉 질문 2
Q. void 메서드인데 객체 값이 바뀌는 이유는 무엇인가요?
A. void는 반환값이 없다는 뜻일 뿐 객체 변경을 막는 의미가 아닙니다. 메서드 안에서 같은 객체의 필드나 컬렉션 요소를 수정하면 호출한 쪽에서도 변경된 결과가 보입니다.
🔉 질문 3
Q. 메서드 안에서 new로 객체를 다시 만들면 원본도 바뀌나요?
A. 바뀌지 않습니다. 매개변수에 새 객체를 대입하면 메서드 안의 지역 변수만 새 객체를 바라보고, 호출한 쪽의 원본 변수는 기존 객체를 계속 바라봅니다.
🔉 질문 4
Q. 자바 List를 메서드에 넘기면 원본 리스트도 변경되나요?
A. 메서드 안에서 add(), remove(), clear()처럼 리스트 내부를 수정하면 원본 리스트도 변경됩니다. 원본을 보존해야 한다면 new ArrayList<>(list)처럼 복사본을 만든 뒤 작업하는 것이 안전합니다.
🔉 질문 5
Q. 자바 객체 참조 전달 오류를 줄이려면 어떻게 작성해야 하나요?
A. 객체를 변경하는 메서드는 이름에 변경 의도를 드러내고, 원본 보존이 필요할 때는 복사본을 사용해야 합니다. 값 변경이 잦은 객체라면 불변 객체 설계나 변경 메서드 집중 관리도 함께 고려하는 것이 좋습니다.
댓글
댓글 쓰기