Java 참조형(Reference Types) 완전 정리

Java 참조형의 개념, 힙·스택 메모리 구조, null 처리, 동일성 vs 동등성, 그리고 클래스·인터페이스·배열·열거형 4가지 종류를 예제 중심으로 완전히 정리한다

· 10 min read · PALDYN Team

지난 글에서 Java 기본형 여덟 가지의 크기, 범위, 형 변환 규칙을 살펴봤다. 값을 변수에 직접 저장하는 기본형과 달리, 참조형(reference type) 은 힙에 생성된 객체의 주소를 변수에 저장한다. Java에서 기본형 8개를 제외한 모든 타입이 참조형이며, 개발자가 직접 만드는 클래스부터 배열, 인터페이스, 열거형까지 모두 포함된다. 이 차이가 null 처리, 동등성 비교, 메모리 관리 등 Java 프로그래밍 전반에 걸쳐 큰 영향을 미친다.

참조형이란

참조형 변수는 객체 자체를 담지 않고 객체가 있는 힙 메모리 주소(참조)를 담는다. 비유하자면 기본형은 집 안에 물건을 직접 보관하는 것이고, 참조형은 물건이 보관된 창고의 주소를 적어 두는 방식이다.

이 구조 덕분에 참조형은:

  • 크기에 관계없이 어떤 객체도 가리킬 수 있고
  • 여러 변수가 같은 객체를 공유할 수 있으며
  • 객체가 없음을 나타내는 null 값을 가질 수 있다

반면 값이 스택이 아닌 힙에 있으므로 GC 대상이 되고, 참조가 끊기면 메모리에서 회수된다.

메모리 구조: 스택과 힙

JVM은 변수를 스택(Stack)에, 객체를 힙(Heap)에 저장한다. 기본형은 스택의 변수 슬롯에 값이 직접 들어가지만, 참조형은 스택에 주소(참조)만 들어가고 실제 데이터는 힙에 있다.

기본형 vs 참조형 — 메모리 저장 방식

int   age    = 30;              // 스택: 값 30을 직접 저장
String name  = "Alice";         // 스택: 힙 주소 저장, 힙에 String 객체
int[]  scores = {90, 85, 92};   // 스택: 힙 주소 저장, 힙에 int[] 객체

이 그림에서 핵심은 namescores가 스택에 주소 값을 가진다는 점이다. 만약 다른 변수가 같은 주소를 복사해 가지면, 두 변수는 동일한 힙 객체를 가리키게 된다. 이 공유 특성이 참조형을 이해하는 가장 중요한 개념이다.

String a = "Hello";
String b = a;        // b도 같은 String 객체를 가리킴
b = "World";         // b가 새 객체를 가리키도록 변경 (a는 영향 없음)
System.out.println(a); // "Hello" — 문자열은 불변(immutable)이라 안전

String은 불변 객체라 위 예시가 문제없지만, ArrayList 같은 가변 객체를 두 변수가 공유하면 한쪽에서의 수정이 다른 쪽에도 보인다.

참조형의 4가지 종류

Java 명세는 참조형을 네 가지로 분류한다.

Java 참조형 4가지

클래스 (class)

가장 일반적인 참조형이다. new 키워드로 힙에 인스턴스를 생성하고, 변수에는 그 주소가 저장된다.

String msg = new String("hello");  // 명시적 객체 생성
ArrayList<Integer> list = new ArrayList<>();

인터페이스 (interface)

인터페이스 자체로는 인스턴스를 만들 수 없지만, 인터페이스 타입으로 변수를 선언해 그 구현 클래스의 객체를 담을 수 있다.

List<String> names = new ArrayList<>();   // 타입은 List(인터페이스)
Runnable task = () -> System.out.println("run"); // 람다도 참조형

배열 (array)

배열은 같은 타입의 원소를 고정 크기로 연속 배치한 객체다. int[]처럼 기본형 배열도 힙에 생성되며 참조형으로 취급된다.

int[]    primes = {2, 3, 5, 7, 11};
String[] days   = new String[7];
Object[] mixed  = new Object[3];   // 모든 참조형 담기 가능

열거형 (enum)

enum은 미리 정의된 상수 집합으로, 각 상수가 타입 안전한 싱글턴 객체다.

enum Direction { NORTH, SOUTH, EAST, WEST }

Direction dir = Direction.NORTH;
System.out.println(dir.name());    // "NORTH"
System.out.println(dir.ordinal()); // 0

null: 참조형만의 특권과 위험

null은 “어떤 객체도 가리키지 않는다”는 의미의 특수 리터럴이다. 기본형은 null을 가질 수 없다. 참조형 변수의 기본값이 null이므로 초기화하지 않은 인스턴스 필드는 자동으로 null이 된다.

String s = null;
System.out.println(s.length()); // NullPointerException!

null인 참조에 메서드를 호출하면 NullPointerException(NPE)이 발생한다. Java 14부터 NPE 메시지가 더 상세해져 어떤 변수가 null이었는지 알려준다.

null 안전 패턴

// 1. 고전적 null 검사
if (s != null) {
    System.out.println(s.length());
}

// 2. Objects.requireNonNull (Java 7+) — null 이면 즉시 NPE
String name = Objects.requireNonNull(s, "name must not be null");

// 3. Optional (Java 8+) — null 가능성을 타입으로 표현
Optional<String> opt = Optional.ofNullable(s);
int len = opt.map(String::length).orElse(0);

// 4. 방어적 기본값 패턴
String result = (s != null) ? s : "default";

실무에서는 메서드 파라미터와 반환값에서 null 의미를 명확히 하고, 가능하면 Optional로 null 가능성을 타입 시스템에 드러내는 것이 좋다.

동일성(==)과 동등성(equals)

참조형에서 ==두 변수가 같은 힙 주소를 가리키는지(동일성, identity)를 비교한다. 값이 같은지(동등성, equality)를 비교하려면 equals()를 사용해야 한다.

String a = new String("hello");
String b = new String("hello");

System.out.println(a == b);        // false — 힙의 다른 두 객체
System.out.println(a.equals(b));   // true  — 값은 같음

// 배열도 동일
int[] arr1 = {1, 2, 3};
int[] arr2 = {1, 2, 3};
System.out.println(arr1 == arr2);          // false
System.out.println(Arrays.equals(arr1, arr2)); // true

==로 문자열을 비교하는 실수는 매우 흔하다. 항상 equals()로 값을 비교하는 습관을 들여야 한다.

String Pool — 문자열의 특별 취급

Java는 성능을 위해 문자열 풀(String Pool) 이라는 특별한 힙 영역을 운용한다. 문자열 리터럴로 생성된 String은 풀에 저장되고, 같은 리터럴을 쓰면 동일 객체를 재사용한다.

String x = "hello";      // String Pool에 "hello" 저장
String y = "hello";      // Pool에서 동일 객체 재사용
String z = new String("hello"); // new → 풀 밖에 새 객체 생성

System.out.println(x == y); // true  — 풀의 같은 객체
System.out.println(x == z); // false — z는 풀 밖 새 객체
System.out.println(x.equals(z)); // true — 값은 동일

// intern()으로 풀에 등록
String w = z.intern();
System.out.println(x == w); // true — 풀에서 기존 객체 반환

실무에서는 new String(...) 대신 리터럴을 사용하는 것이 성능에 유리하다. Java 21 기준으로 풀은 힙의 일부(기존 PermGen이 아닌 Heap 영역)에 존재한다.

참조형 사용 시 주의사항

얕은 복사 vs 깊은 복사: 참조형을 단순 대입하면 주소만 복사되어 두 변수가 같은 객체를 공유한다. 독립적인 사본이 필요하면 복사 생성자, clone(), 또는 직접 깊은 복사를 구현해야 한다.

List<String> original = new ArrayList<>(List.of("a", "b"));
List<String> shallow  = original;          // 같은 객체 공유
List<String> deep     = new ArrayList<>(original); // 새 리스트, 원소는 공유

shallow.add("c"); // original에도 영향
deep.add("d");    // original에 영향 없음

메모리 누수: 참조형 변수가 불필요한 객체를 계속 가리키면 GC가 회수하지 못해 메모리 누수가 생긴다. 컬렉션에서 불필요한 원소를 명시적으로 제거하거나, 수명이 긴 캐시에는 WeakReference를 검토한다.

정리

참조형은 Java에서 기본형을 제외한 모든 타입을 아우른다. 핵심은 변수가 객체 자체가 아닌 힙 주소를 저장한다는 점이며, 이에서 null 처리의 필요성, == vs equals() 구분, 얕은/깊은 복사의 차이가 모두 파생된다. 다음 글에서는 기본형과 참조형 사이, 그리고 상속 계층 안에서 이루어지는 타입 변환(type conversion) 규칙을 자세히 살펴본다.


지난 글: Java 기본형(Primitive Types) 완전 정리

다음 글: Java 타입 변환(Type Conversion) 완전 정리


읽어주셔서 감사합니다. 😊