Java 핵심 원리 심화: String 불변성, equals/hashCode 계약, 제네릭 타입 소거, Autoboxing의 함정

Java 핵심 원리 심화: String 불변성, equals/hashCode 계약, 제네릭 타입 소거, Autoboxing의 함정

Java를 “기초”라고 부르기 쉬운 주제들이 있다. String, equals, 제네릭, 오토박싱. 그러나 이 주제들의 내부 동작 원리를 정확히 아는 개발자는 의외로 드물다. “String은 불변이다”는 알지만 왜 불변으로 설계되었고, 내부적으로 어떻게 구현되는지 설명할 수 있는가? ==equals()의 차이는 알지만 hashCode()를 오버라이드하지 않으면 HashMap에서 무슨 일이 벌어지는지 구체적으로 설명할 수 있는가?

이 글은 Java의 기초 문법처럼 보이지만 실제로는 깊은 이해가 필요한 핵심 주제들을 바이트코드 수준, JVM 수준, 자료구조 수준까지 파고들어 정리한다. 면접에서 “기초인데 깊게 물어보는” 질문에 대비하기 위한 글이다.


1. String의 내부 구조와 불변성

1.1 String은 왜 불변(Immutable)인가

String 클래스의 핵심 필드:

1
2
3
4
5
6
7
8
9
10
// Java 9+ (Compact Strings)
public final class String implements Serializable, Comparable<String>, CharSequence {
    @Stable
    private final byte[] value;    // 문자열 데이터 (final!)
    private final byte coder;      // LATIN1(0) 또는 UTF16(1)
    private int hash;              // hashCode 캐시 (지연 계산)
    private boolean hashIsZero;    // hash가 0인 것이 실제 0인지 미계산인지 구분

    // Java 8 이전: private final char[] value;
}

final 키워드가 두 곳에 사용된다.

  1. final class String → 상속 불가. String을 상속하여 가변 String을 만드는 것을 방지.
  2. final byte[] value → 참조 변경 불가. 그러나 배열 내부 값은 리플렉션으로 변경 가능하므로, 이것만으로는 불변이 아니다. String 클래스가 value 배열을 외부에 노출하지 않고, 모든 메서드가 원본을 수정하지 않고 새 String을 반환하기 때문에 불변이다.
1
2
3
4
5
String s = "Hello";
s.toUpperCase();    // "HELLO"을 반환하지만
System.out.println(s);  // "Hello" — 원본은 변경되지 않음!

String upper = s.toUpperCase();  // 새 String 객체 생성

1.2 불변으로 설계한 이유

이유 1: String Pool의 전제조건

1
2
3
4
5
6
7
8
9
10
11
12
13
String Pool (Heap 내부의 특수 영역):

  String a = "Hello";
  String b = "Hello";

  a ──┐
      ├──► "Hello" (String Pool의 단 하나의 인스턴스)
  b ──┘

  만약 String이 가변이라면?
  a.setValue("World") → b도 "World"가 됨!
  → 전혀 관련 없는 코드가 예상치 못하게 영향 받음
  → String Pool 자체가 불가능해짐

이유 2: 해시코드 캐싱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// String.hashCode() — 한 번 계산하면 캐싱
public int hashCode() {
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value)
                       : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h;
        }
    }
    return h;
}

String이 불변이므로 hashCode를 한 번만 계산하고 캐싱할 수 있다. HashMap의 키로 String을 사용할 때 성능이 보장되는 이유이다.

이유 3: 스레드 안전성

불변 객체는 여러 스레드에서 동시에 접근해도 안전하다. 동기화가 필요 없다.

이유 4: 보안

데이터베이스 URL, 파일 경로, 네트워크 호스트명 등이 String으로 전달된다. 가변이면 전달 후 값이 바뀔 수 있어 보안 취약점이 된다.

1.3 String Pool과 intern()

1
2
3
4
5
6
7
8
9
String s1 = "Hello";                    // String Pool에 생성
String s2 = "Hello";                    // Pool에서 같은 참조 반환
String s3 = new String("Hello");        // Heap에 새 객체 생성
String s4 = new String("Hello").intern(); // Pool에서 참조 반환

System.out.println(s1 == s2);       // true  (같은 Pool 참조)
System.out.println(s1 == s3);       // false (Pool vs Heap)
System.out.println(s1 == s4);       // true  (intern()이 Pool 참조 반환)
System.out.println(s1.equals(s3));  // true  (내용 비교)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
메모리 구조:

  Stack                    Heap
  ┌─────┐        ┌───────────────────────────┐
  │ s1  │───────►│ String Pool               │
  │ s2  │───────►│  ┌─────────────────┐      │
  │ s4  │───────►│  │ "Hello" (유일)   │      │
  │     │        │  └─────────────────┘      │
  │ s3  │───┐    │                           │
  └─────┘   │    │  ┌─────────────────┐      │
            └───►│  │ new String("Hello")│    │
                 │  │ (별도 객체)         │    │
                 │  └─────────────────┘      │
                 └───────────────────────────┘

new String("Hello")는 객체를 몇 개 생성하는가?

  • “Hello” 리터럴이 Pool에 없으면: 2개 (Pool에 하나 + Heap에 new로 하나)
  • “Hello” 리터럴이 Pool에 이미 있으면: 1개 (Heap에 new로 하나만)
  • 면접 단골 문제이다.

1.4 Java 9+ Compact Strings

Java 8까지 String 내부는 char[](2바이트/문자)이었다. ASCII 문자만 담는 경우 절반이 낭비된다.

Java 9부터 Compact Strings가 도입되어, LATIN-1(ISO-8859-1) 범위의 문자열은 byte[]에 1바이트/문자로 저장한다.

1
2
3
4
Java 8: "Hello" → char[] {'H','e','l','l','o'} → 10바이트
Java 9+: "Hello" → byte[] {72,101,108,108,111} + coder=LATIN1 → 5바이트

Java 9+: "안녕" → byte[] {UTF-16 인코딩} + coder=UTF16 → 4바이트

대부분의 애플리케이션에서 String이 메모리의 25~40%를 차지하므로, Compact Strings는 상당한 메모리 절약 효과가 있다.

1.5 String, StringBuilder, StringBuffer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// String: 불변, 문자열 연결 시 새 객체 생성
String result = "";
for (int i = 0; i < 10000; i++) {
    result += i;  // 매 반복마다 새 String + StringBuilder 생성
}
// → O(n²) 시간 복잡도!

// StringBuilder: 가변, 단일 스레드 환경
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i);  // 내부 버퍼에 추가
}
String result = sb.toString();
// → O(n) 시간 복잡도

// StringBuffer: 가변, 멀티 스레드 안전 (synchronized)
StringBuffer sbuf = new StringBuffer();  // 모든 메서드가 synchronized
1
2
3
4
5
6
7
8
9
비교:

┌──────────────┬──────────┬─────────────┬──────────────┐
│              │ 가변 여부  │ 스레드 안전   │ 성능          │
├──────────────┼──────────┼─────────────┼──────────────┤
│ String       │ 불변      │ 안전 (불변)   │ 연결 시 느림   │
│ StringBuilder│ 가변      │ 안전하지 않음  │ 빠름          │
│ StringBuffer │ 가변      │ 안전 (동기화) │ StringBuilder보다 느림│
└──────────────┴──────────┴─────────────┴──────────────┘

Java 컴파일러 최적화: "a" + "b" + "c"는 컴파일 시 "abc"로 상수 폴딩된다. 변수가 포함된 s1 + s2는 Java 9+에서 invokedynamic으로 최적화된다 (이전에는 StringBuilder로 변환).


2. equals()와 hashCode() 계약

2.1 == vs equals()

1
2
3
4
5
6
7
8
// == : 참조(주소) 비교
// equals() : 내용(논리적 동등성) 비교

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

a == b        // false — 서로 다른 객체
a.equals(b)   // true  — 내용이 같음
1
2
3
4
5
6
7
8
9
10
11
12
메모리에서의 차이:

  Stack          Heap
  ┌────┐      ┌──────────────┐
  │ a  │─────►│ String@0x100 │  value: "Hello"
  └────┘      └──────────────┘
  ┌────┐      ┌──────────────┐
  │ b  │─────►│ String@0x200 │  value: "Hello"
  └────┘      └──────────────┘

  a == b: 0x100 == 0x200? → false
  a.equals(b): "Hello".equals("Hello")? → true

2.2 Object.equals()의 기본 구현

1
2
3
4
// Object.java
public boolean equals(Object obj) {
    return (this == obj);  // 기본: 참조 비교 (== 와 동일!)
}

오버라이드하지 않으면 ==와 동일하게 동작한다. 논리적 동등성이 필요하면 반드시 오버라이드해야 한다.

2.3 equals() 재정의 규약 (5가지)

1
2
3
4
5
6
7
// Effective Java Item 10의 규약

// 1. 반사성 (Reflexivity): x.equals(x) == true
// 2. 대칭성 (Symmetry): x.equals(y) == true → y.equals(x) == true
// 3. 추이성 (Transitivity): x.equals(y) && y.equals(z) → x.equals(z)
// 4. 일관성 (Consistency): 같은 인수로 여러 번 호출해도 항상 같은 결과
// 5. null 비동등성: x.equals(null) == false

대칭성 위반 예시

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 잘못된 구현: 대칭성 위반
public class CaseInsensitiveString {
    private final String s;

    @Override
    public boolean equals(Object o) {
        if (o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(((CaseInsensitiveString) o).s);
        if (o instanceof String)  // 문제!
            return s.equalsIgnoreCase((String) o);
        return false;
    }
}

CaseInsensitiveString cis = new CaseInsensitiveString("Hello");
String s = "hello";

cis.equals(s)  // true — CaseInsensitiveString이 String과 비교
s.equals(cis)  // false — String은 CaseInsensitiveString을 모름
// → 대칭성 위반!

2.4 hashCode() 계약

1
2
3
// Object.java
public native int hashCode();
// 기본: 메모리 주소 기반 (구현체마다 다름)

hashCode() 규약:

  1. equals()가 true인 두 객체는 반드시 같은 hashCode를 반환해야 한다.
  2. equals()가 false인 두 객체가 같은 hashCode를 가질 수 있다 (해시 충돌).
  3. 같은 객체에 대해 hashCode()를 여러 번 호출하면 같은 값을 반환해야 한다.

2.5 equals()를 오버라이드하고 hashCode()를 오버라이드하지 않으면?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class User {
    private final Long id;
    private final String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User user = (User) o;
        return Objects.equals(id, user.id);
    }

    // hashCode() 오버라이드 안 함!
}

// 문제 발생:
User user1 = new User(1L, "홍길동");
User user2 = new User(1L, "홍길동");

user1.equals(user2);  // true — equals 오버라이드했으므로

// HashMap에서의 동작
Map<User, String> map = new HashMap<>();
map.put(user1, "데이터");
map.get(user2);  // null!! — user2로 꺼낼 수 없다

// 왜?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HashMap 내부 동작:

  put(user1, "데이터"):
    ① user1.hashCode() → 예: 0x1A2B (Object 기본 해시)
    ② 버킷 인덱스 = hash % 배열크기
    ③ 해당 버킷에 저장

  get(user2):
    ① user2.hashCode() → 예: 0x3C4D (다른 Object 기본 해시!)
    ② 버킷 인덱스 = hash % 배열크기 → 다른 버킷!
    ③ 해당 버킷에 없음 → null 반환

  user1.equals(user2) == true 이지만
  user1.hashCode() != user2.hashCode()
  → HashMap은 먼저 hashCode로 버킷을 찾고, 그 안에서 equals로 비교
  → hashCode가 다르면 equals를 비교할 기회조차 없다!
1
2
3
4
5
6
7
8
9
HashMap의 검색 과정:

  get(key):
    ① key.hashCode() 계산
    ② hashCode로 버킷 인덱스 결정
    ③ 해당 버킷의 엔트리들을 순회
    ④ 각 엔트리에 대해:
       entry.hash == key.hashCode() && entry.key.equals(key)
       → 둘 다 true면 해당 value 반환

2.6 올바른 equals/hashCode 구현

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class User {
    private final Long id;
    private final String name;
    private final String email;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;                    // 반사성 + 성능
        if (!(o instanceof User user)) return false;   // 타입 체크 + null 체크
        return Objects.equals(id, user.id);            // 비즈니스 키 비교
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);  // equals에서 사용한 필드와 동일!
    }
}
1
2
3
4
5
6
7
8
9
10
11
올바른 구현 후 HashMap:

  put(user1, "데이터"):
    ① user1.hashCode() → Objects.hash(1L) → 32
    ② 버킷[32 % 16] = 버킷[0]에 저장

  get(user2):
    ① user2.hashCode() → Objects.hash(1L) → 32  (같은 해시!)
    ② 버킷[32 % 16] = 버킷[0] 탐색
    ③ user1.equals(user2) → true
    ④ "데이터" 반환!

2.7 JPA 엔티티의 equals/hashCode

JPA 엔티티에서 equals/hashCode는 특별한 주의가 필요하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// 잘못된 방법: @GeneratedValue ID 사용
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Order)) return false;
        return Objects.equals(id, ((Order) o).id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

// 문제:
Order order = new Order();       // id = null (아직 persist 전)
Set<Order> set = new HashSet<>();
set.add(order);                  // hashCode = Objects.hash(null) = 0
entityManager.persist(order);    // id = 1L로 변경
set.contains(order);             // false! hashCode가 바뀜 → Set에서 찾을 수 없음

// 올바른 방법 1: 비즈니스 키 사용
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @Column(unique = true)
    private String orderNumber;  // 비즈니스 키

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof Order)) return false;
        return Objects.equals(orderNumber, ((Order) o).orderNumber);
    }

    @Override
    public int hashCode() {
        return Objects.hash(orderNumber);
    }
}

// 올바른 방법 2: 고정 hashCode
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Order order)) return false;
        return id != null && Objects.equals(id, order.id);
    }

    @Override
    public int hashCode() {
        return getClass().hashCode();  // 모든 인스턴스가 같은 해시 → 성능↓ 정확성↑
    }
}

3. 제네릭과 타입 소거

3.1 제네릭이 해결하는 문제

1
2
3
4
5
6
7
8
9
10
11
// 제네릭 이전 (Java 5 미만)
List list = new ArrayList();
list.add("Hello");
list.add(123);          // 컴파일 에러 없음!
String s = (String) list.get(1);  // 런타임 ClassCastException!

// 제네릭 이후
List<String> list = new ArrayList<>();
list.add("Hello");
// list.add(123);       // 컴파일 에러! 타입 안전성 보장
String s = list.get(0); // 캐스팅 불필요

제네릭은 컴파일 타임에 타입 안전성을 보장하는 기능이다.

3.2 타입 소거 (Type Erasure)

Java 제네릭의 가장 중요한 내부 메커니즘이다. 제네릭 타입 정보는 컴파일 후 바이트코드에서 제거된다.

1
2
3
4
5
6
7
8
9
// 소스 코드
List<String> strings = new ArrayList<>();
strings.add("Hello");
String s = strings.get(0);

// 컴파일 후 바이트코드 (타입 소거)
List strings = new ArrayList();     // <String> 제거됨
strings.add("Hello");
String s = (String) strings.get(0); // 컴파일러가 캐스트 삽입
1
2
3
4
5
6
7
8
9
타입 소거 과정:

  소스 코드               →  바이트코드
  ─────────────────────────────────────
  List<String>            →  List (raw type)
  List<Integer>           →  List (raw type)
  T                       →  Object (비한정)
  T extends Comparable    →  Comparable (상한 경계)
  List<? extends Number>  →  List

왜 타입 소거를 선택했는가? Java 5에서 제네릭이 도입될 때, 기존 Java 4 이하의 코드와 바이너리 호환성을 유지하기 위해서이다. ListList<String>이 런타임에 같은 클래스여야 기존 라이브러리와 호환된다.

3.3 타입 소거의 결과

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. instanceof에 제네릭 타입을 사용할 수 없다
if (list instanceof List<String>) { }  // 컴파일 에러!
if (list instanceof List<?>) { }       // OK (비한정 와일드카드만 가능)

// 2. 제네릭 타입으로 배열을 생성할 수 없다
T[] array = new T[10];                 // 컴파일 에러!
List<String>[] array = new List<String>[10]; // 컴파일 에러!

// 3. 런타임에 제네릭 타입 정보를 알 수 없다
List<String> strings = new ArrayList<>();
List<Integer> integers = new ArrayList<>();
strings.getClass() == integers.getClass();  // true! (둘 다 ArrayList.class)

// 4. 제네릭 타입의 static 필드 불가
public class Box<T> {
    private static T value;  // 컴파일 에러!
    // Box<String>과 Box<Integer>가 같은 클래스이므로
    // static T가 String인지 Integer인지 결정 불가
}

3.4 와일드카드 (Wildcard)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 비한정 와일드카드: ?
// "모든 타입"을 받을 수 있지만, 꺼낼 때 Object로만 받음
public void printList(List<?> list) {
    for (Object item : list) {
        System.out.println(item);
    }
    // list.add("Hello");  // 컴파일 에러! (null만 가능)
}

// 상한 경계 와일드카드: ? extends T
// T와 T의 하위 타입만 허용, 읽기만 가능 (Producer)
public double sumOfList(List<? extends Number> list) {
    double sum = 0;
    for (Number n : list) {  // Number로 읽기 가능
        sum += n.doubleValue();
    }
    // list.add(1);  // 컴파일 에러! 정확한 타입을 모르므로 쓰기 불가
    return sum;
}

// 하한 경계 와일드카드: ? super T
// T와 T의 상위 타입만 허용, 쓰기 가능 (Consumer)
public void addNumbers(List<? super Integer> list) {
    list.add(1);       // Integer를 넣을 수 있음
    list.add(2);
    // Integer n = list.get(0);  // 컴파일 에러! 꺼낼 때 Object로만 받음
    Object o = list.get(0);      // Object로만 가능
}

3.5 PECS 원칙 (Producer Extends, Consumer Super)

Joshua Bloch(Effective Java 저자)가 제시한 와일드카드 사용 가이드라인이다.

1
2
3
4
5
PECS (Producer Extends, Consumer Super):

  데이터를 꺼내서 읽기만 한다 (Producer) → ? extends T
  데이터를 넣어서 쓰기만 한다 (Consumer) → ? super T
  읽기와 쓰기 모두 한다              → 와일드카드 사용 안 함 (T)
1
2
3
4
5
6
7
8
9
10
11
12
// Collections.copy() — PECS의 교과서적 예시
public static <T> void copy(List<? super T> dest,    // Consumer: 데이터를 넣음
                              List<? extends T> src) { // Producer: 데이터를 꺼냄
    for (int i = 0; i < src.size(); i++) {
        dest.set(i, src.get(i));  // src에서 꺼내서(extends) dest에 넣음(super)
    }
}

// 사용 예시
List<Integer> ints = List.of(1, 2, 3);
List<Number> nums = new ArrayList<>(List.of(0, 0, 0));
Collections.copy(nums, ints);  // Integer는 Number의 하위 타입 → OK
1
2
3
4
5
6
7
8
9
10
11
12
PECS 시각화:

               Object
                 │
               Number      ← ? super Integer의 범위 (상위)
              ╱      ╲
          Integer   Double  ← T (기준)
            │
           ...              ← ? extends Integer의 범위 (하위, 없지만 예시)

  List<? extends Number>: Number, Integer, Double 등에서 읽기 가능
  List<? super Integer>: Integer, Number, Object에 쓰기 가능

3.6 제네릭 메서드 vs 와일드카드

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 이 둘은 동작이 같지만, 어떤 것을 써야 하나?

// 제네릭 메서드
public <T> void swap(List<T> list, int i, int j) {
    T temp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, temp);
}

// 와일드카드
public void swap(List<?> list, int i, int j) {
    // list.set(i, list.get(j));  // 컴파일 에러! ?는 쓰기 불가
    swapHelper(list, i, j);       // 헬퍼 메서드 필요
}
private <T> void swapHelper(List<T> list, int i, int j) {
    T temp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, temp);
}

규칙: 타입 매개변수가 메서드 시그니처에서 한 번만 사용되면 와일드카드로 대체할 수 있다. 하지만 읽고 쓰기를 모두 해야 하면 제네릭 메서드가 필요하다.


4. Autoboxing과 Unboxing의 함정

4.1 Autoboxing이란

Java 5에서 도입된 기본 타입(primitive)과 래퍼 클래스(Wrapper) 간의 자동 변환이다.

1
2
3
4
5
6
7
8
9
10
// Autoboxing: int → Integer
Integer a = 42;         // 컴파일러: Integer a = Integer.valueOf(42);

// Unboxing: Integer → int
int b = a;              // 컴파일러: int b = a.intValue();

// 연산 시
Integer x = 10;
Integer y = 20;
int sum = x + y;        // 컴파일러: int sum = x.intValue() + y.intValue();

4.2 == 비교의 함정 — Integer Cache

1
2
3
4
5
6
7
8
9
Integer a = 127;
Integer b = 127;
System.out.println(a == b);   // true (!!)

Integer c = 128;
Integer d = 128;
System.out.println(c == d);   // false

// 왜?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Integer Cache (IntegerCache):

  Integer.valueOf(int i) 내부:
    if (i >= -128 && i <= 127) {
        return IntegerCache.cache[i + 128];  // 캐시된 객체 반환
    }
    return new Integer(i);  // 새 객체 생성

  ┌─────────────────────────────────────────────┐
  │           Integer Cache                      │
  │  [-128] [-127] ... [0] [1] ... [126] [127]  │
  │    ↑                                    ↑    │
  │  cache[0]                          cache[255]│
  └─────────────────────────────────────────────┘

  -128 ~ 127 범위: Integer.valueOf()가 캐시된 객체 반환 → == true
  범위 밖: 새 Integer 객체 생성 → == false
1
2
3
4
5
6
7
8
9
10
11
12
// 따라서 Integer 비교는 항상 equals()를 사용해야 한다
Integer a = 128;
Integer b = 128;
a == b           // false (서로 다른 객체)
a.equals(b)      // true  (내용 비교)
Objects.equals(a, b)  // true (null-safe)

// 다른 래퍼 타입의 캐시 범위
// Byte, Short, Integer, Long: -128 ~ 127
// Character: 0 ~ 127
// Boolean: TRUE, FALSE (항상 캐시)
// Float, Double: 캐시 없음

4.3 Null Unboxing — NullPointerException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Integer value = null;
int result = value;   // NullPointerException!
// 컴파일러: int result = value.intValue(); → NPE

// 실무에서 흔한 실수
public int getAge(User user) {
    return user.getAge();  // getAge()가 Integer를 반환하면?
    // user.age가 null이면 NPE!
}

// 안전한 방법
public int getAge(User user) {
    Integer age = user.getAge();
    return age != null ? age : 0;
    // 또는
    return Optional.ofNullable(user.getAge()).orElse(0);
}

4.4 성능 함정 — 불필요한 박싱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 끔찍한 성능의 코드
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
    sum += i;  // 매 반복마다 Autoboxing + Unboxing!
    // 컴파일러: sum = Long.valueOf(sum.longValue() + i);
    // → 약 2^31개의 Long 객체 생성!
}

// 올바른 코드
long sum = 0L;  // primitive 사용
for (long i = 0; i < Integer.MAX_VALUE; i++) {
    sum += i;  // 박싱 없음
}
// → 약 6배 빠름

4.5 제네릭과 Autoboxing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 제네릭은 기본 타입을 사용할 수 없다
List<int> list;      // 컴파일 에러!
List<Integer> list;  // OK — 래퍼 타입만 가능

// 이것이 Stream에 IntStream, LongStream이 존재하는 이유
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);

// 느림: 매번 unboxing
int sum1 = list.stream()
    .reduce(0, Integer::sum);  // Integer → int 반복

// 빠름: primitive 전용 스트림
int sum2 = list.stream()
    .mapToInt(Integer::intValue)  // IntStream으로 변환
    .sum();                        // primitive 연산

5. final 키워드의 깊은 의미

5.1 final의 세 가지 용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. final 변수: 재할당 불가
final int x = 10;
// x = 20;  // 컴파일 에러

// 2. final 메서드: 오버라이드 불가
public class Parent {
    public final void method() { }
}
public class Child extends Parent {
    // public void method() { }  // 컴파일 에러
}

// 3. final 클래스: 상속 불가
public final class String { }
// public class MyString extends String { }  // 컴파일 에러

5.2 final 참조 vs final 객체

1
2
3
4
5
6
final List<String> list = new ArrayList<>();
list.add("Hello");    // OK! — 리스트 내용 수정 가능
list.add("World");    // OK!
// list = new ArrayList<>();  // 컴파일 에러! — 참조 변경 불가

// final은 "참조"를 고정하는 것이지, "객체"를 불변으로 만드는 것이 아니다!
1
2
3
4
5
6
7
8
9
10
11
12
final 참조의 의미:

  Stack              Heap
  ┌──────────┐     ┌──────────────────┐
  │ list     │────►│ ArrayList@0x100  │
  │ (final)  │  ✗  │  ["Hello"]       │
  └──────────┘  │  │  → add("World") OK│
                │  └──────────────────┘
                │
                │  ┌──────────────────┐
                └─►│ ArrayList@0x200  │  ← 재할당 불가!
                   └──────────────────┘

5.3 effectively final과 Lambda

1
2
3
4
5
// Java 8+: 변수가 사실상(effectively) final이면 lambda에서 사용 가능
String name = "Hello";  // final 선언 안 했지만 재할당 안 함
// name = "World";      // 이 줄이 있으면 아래 lambda 컴파일 에러

Runnable r = () -> System.out.println(name);  // OK (effectively final)

5.4 final과 JMM (Java Memory Model)

final 필드는 JMM에서 특별한 보장을 받는다. 생성자에서 final 필드에 값을 할당하면, 생성자가 완료된 후 다른 스레드에서 해당 final 필드의 올바른 값을 볼 수 있음이 보장된다. volatile이나 synchronized 없이도!

1
2
3
4
5
6
7
8
9
10
11
public class SafePublication {
    private final int value;
    private final List<String> list;

    public SafePublication(int value) {
        this.value = value;
        this.list = List.of("a", "b", "c");
        // 생성자 완료 후, 다른 스레드에서 value와 list가
        // 올바른 값으로 보인다 (JMM final 필드 의미론)
    }
}

이것이 불변 객체가 스레드 안전한 근본적인 이유이다. 불변 객체의 모든 필드가 final이면, 추가 동기화 없이도 안전하게 공유할 수 있다.


6. Enum의 내부 동작

6.1 Enum은 클래스다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 이 enum은...
public enum Color {
    RED, GREEN, BLUE;
}

// 사실 이것과 같다 (컴파일러가 생성)
public final class Color extends Enum<Color> {
    public static final Color RED = new Color("RED", 0);
    public static final Color GREEN = new Color("GREEN", 1);
    public static final Color BLUE = new Color("BLUE", 2);

    private static final Color[] $VALUES = { RED, GREEN, BLUE };

    private Color(String name, int ordinal) {
        super(name, ordinal);
    }

    public static Color[] values() {
        return $VALUES.clone();
    }

    public static Color valueOf(String name) {
        return Enum.valueOf(Color.class, name);
    }
}

6.2 Enum이 싱글톤 구현에 가장 좋은 이유

1
2
3
4
5
6
7
8
9
10
11
12
13
public enum Singleton {
    INSTANCE;

    private final Connection connection;

    Singleton() {
        connection = createConnection();
    }

    public Connection getConnection() {
        return connection;
    }
}

Effective Java Item 3에서 Joshua Bloch가 Enum 싱글톤을 권장하는 이유:

  1. 직렬화 안전: 일반 싱글톤은 readResolve()를 구현해야 하지만, Enum은 JVM이 보장
  2. 리플렉션 공격 방지: Constructor.newInstance() 자체가 Enum에 대해 예외 발생
  3. 스레드 안전: JVM이 클래스 로딩 시 단 한 번만 인스턴스 생성 보장
  4. 간결: 코드가 가장 짧다

6.3 Enum과 == 비교

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Color c1 = Color.RED;
Color c2 = Color.RED;

c1 == c2       // true (항상! 같은 상수 인스턴스)
c1.equals(c2)  // true

// Enum은 ==와 equals() 결과가 항상 같다
// Enum.equals()가 ==로 구현되어 있기 때문
public final boolean equals(Object other) {
    return this == other;
}

// 따라서 Enum 비교에는 ==을 사용하는 것이 더 좋다
// → null-safe: null == Color.RED → false (NPE 없음)
// → null.equals(Color.RED) → NPE!

7. 면접에서 자주 나오는 Java 핵심 질문

Q1: String이 불변인 이유를 설명하세요.

네 가지 이유가 있습니다. 첫째, String Pool의 전제조건입니다. 같은 리터럴이 하나의 인스턴스를 공유하는데, 가변이면 한 곳에서의 변경이 모든 참조에 영향을 미칩니다. 둘째, hashCode를 캐싱할 수 있어 HashMap의 키로 사용할 때 성능이 보장됩니다. 셋째, 멀티스레드 환경에서 동기화 없이 안전하게 공유할 수 있습니다. 넷째, 보안상 DB URL, 파일 경로 등이 변조되는 것을 방지합니다.

Q2: new String(“Hello”)는 객체를 몇 개 생성하나요?

String Pool에 “Hello”가 없는 경우 2개입니다. 하나는 리터럴 “Hello”가 Pool에 생성되고, 하나는 new 연산자에 의해 Heap에 별도로 생성됩니다. Pool에 이미 있는 경우 1개(Heap의 new 객체만)입니다. 따라서 new String()은 불필요한 객체 생성이므로, 리터럴을 직접 사용하는 것이 좋습니다.

Q3: equals()를 오버라이드하면 왜 hashCode()도 오버라이드해야 하나요?

HashMap, HashSet 등 해시 기반 컬렉션이 올바르게 동작하려면 “equals()가 true인 두 객체는 같은 hashCode를 반환해야 한다”는 계약이 지켜져야 합니다. HashMap은 먼저 hashCode로 버킷을 찾고 그 안에서 equals로 비교합니다. hashCode가 다르면 다른 버킷을 탐색하므로, equals가 true여도 찾지 못합니다. 따라서 hashCode를 오버라이드하지 않으면 HashMap에서 put한 값을 get으로 꺼낼 수 없는 버그가 발생합니다.

Q4: 제네릭의 타입 소거(Type Erasure)란?

Java 제네릭의 타입 정보는 컴파일 타임에만 존재하고, 바이트코드에서는 제거됩니다. List<String>List<Integer>는 런타임에 모두 List입니다. 이는 Java 5에서 제네릭 도입 시 기존 코드와의 바이너리 호환성을 유지하기 위한 설계 결정입니다. 결과적으로 런타임에 제네릭 타입을 알 수 없어 instanceof 검사 불가, 제네릭 배열 생성 불가 등의 제한이 생깁니다.

Q5: PECS 원칙을 설명하세요.

Producer Extends, Consumer Super의 약자입니다. 데이터를 꺼내서 읽기만 하면(Producer) ? extends T를, 데이터를 넣어서 쓰기만 하면(Consumer) ? super T를 사용합니다. Collections.copy(List<? super T> dest, List<? extends T> src)가 대표적 예시로, src에서 꺼내고(extends) dest에 넣습니다(super). 이 원칙을 따르면 API의 유연성을 극대화할 수 있습니다.

Q6: Integer a = 127; Integer b = 127; 에서 a == b가 true인 이유는?

Integer.valueOf()가 -128~127 범위의 값에 대해 캐시된 객체를 반환하기 때문입니다. 이 범위 내의 값은 같은 Integer 객체를 참조하므로 ==가 true입니다. 128 이상은 새 객체를 생성하므로 ==가 false입니다. 따라서 Integer 비교는 항상 equals()를 사용해야 하며, 이것은 면접에서 매우 자주 출제되는 문제입니다.

Q7: Autoboxing에서 NullPointerException이 발생하는 경우는?

Integer 같은 래퍼 타입이 null인 상태에서 기본 타입으로 unboxing될 때 발생합니다. Integer value = null; int result = value;는 컴파일러가 value.intValue()로 변환하므로 NPE가 발생합니다. 실무에서는 DB에서 nullable 컬럼을 Integer로 매핑한 뒤 int로 사용할 때 자주 발생합니다. Optional이나 null 검사로 방어해야 합니다.

Q8: final 변수는 왜 lambda에서 사용할 수 있나요?

Lambda는 별도의 스택 프레임에서 실행되므로, 외부 지역 변수를 직접 참조할 수 없습니다. 대신 변수의 값을 복사하여 사용합니다(captured variable). 만약 변수가 변경 가능하면 복사된 값과 원본 값이 달라질 수 있어 혼란이 생깁니다. 따라서 Java는 lambda에서 사용하는 외부 변수가 final 또는 effectively final이어야 한다고 강제합니다.

Q9: Enum 비교에서 ==과 equals() 중 무엇을 쓰나요?

Enum은 ==을 사용하는 것이 더 좋습니다. 세 가지 이유가 있습니다. 첫째, Enum의 equals()가 내부적으로 ==으로 구현되어 있으므로 결과가 동일합니다. 둘째, ==은 null-safe입니다(null == Color.RED는 false, null.equals(Color.RED)는 NPE). 셋째, 컴파일 타임에 타입 체크가 되어 실수를 방지합니다.

Q10: String 연결 시 +와 StringBuilder의 성능 차이는?

반복문에서 String +를 사용하면 매 반복마다 새 String 객체와 StringBuilder가 생성되어 O(n²)의 시간 복잡도가 됩니다. StringBuilder를 직접 사용하면 O(n)입니다. 단, 단일 표현식의 문자열 연결("a" + "b" + "c")은 컴파일러가 최적화하므로 성능 차이가 없습니다. Java 9+에서는 invokedynamic 기반의 StringConcatFactory로 더 효율적으로 처리됩니다.


정리

Java 기초 문법의 심층 원리를 정리하면 다음과 같다.

String: 불변 객체이며, String Pool을 통해 리터럴이 공유된다. 불변성은 Pool, hashCode 캐싱, 스레드 안전성, 보안의 전제조건이다. Java 9+에서 Compact Strings가 도입되어 LATIN-1 문자열은 1바이트/문자로 저장된다.

equals/hashCode: Object.equals()는 기본적으로 ==와 동일하며, 논리적 동등성을 위해 오버라이드해야 한다. hashCode()를 함께 오버라이드하지 않으면 HashMap에서 올바르게 동작하지 않는다. “equals가 true이면 hashCode가 같아야 한다”는 계약이 핵심이다. JPA 엔티티에서는 @GeneratedValue ID 대신 비즈니스 키를 사용하거나, 고정 hashCode 전략을 써야 한다.

제네릭: 컴파일 타임 타입 안전성을 제공하지만, 타입 소거에 의해 런타임에는 타입 정보가 없다. 와일드카드 사용 시 PECS(Producer Extends, Consumer Super) 원칙을 따르면 API 유연성을 극대화할 수 있다.

Autoboxing: -128~127 범위의 Integer 캐시, null unboxing NPE, 불필요한 박싱에 의한 성능 저하가 주요 함정이다. Integer 비교는 반드시 equals()를 사용하고, 성능이 중요한 코드에서는 primitive 타입을 직접 사용해야 한다.