자바 언어·플랫폼 학습 노트 목차

자바의 바닥 — 변수 하나에서 객체까지

int x = 10; 한 줄을 쓰면 메모리에 무슨 일이 일어날까? 값이 비트로 어떻게 저장되는지, 객체가 "같다"는 건 무슨 뜻인지, 왜 어떤 객체는 절대 안 바뀌게 만드는지 — 자바의 가장 밑바닥을 컴퓨터과학·실무와 엮어 따라가 본다.


1. 두 종류의 변수 — 값을 담느냐, 주소를 담느냐

자바의 모든 변수는 둘 중 하나다. **기본형(primitive)**은 값 그 자체를 담고, **참조형(reference)**은 힙에 있는 객체의 주소를 담는다.

int x = 10;              // 작업판(스택)에 숫자 10이 그대로
String s = new String(); // 작업판엔 주소만, 진짜 문자열 객체는 창고(힙)에

앞 JVM 페이지에서 본 "스택은 작업대, 힙은 창고"가 여기서 갈린다. 그래서 기본형을 다른 변수에 넣으면 값이 복사되고, 참조형을 넣으면 같은 객체를 가리키는 주소가 복사된다(둘이 같은 객체를 본다).

기본형은 8가지이고 크기가 비트로 정해져 있다:

타입크기비고
byte·short·int·long8·16·32·64bit정수. 리터럴 기본은 int
float·double32·64bit실수. 리터럴 기본은 double
char16bitUTF-16 코드 단위, 부호 없음
boolean미정보통 int, 배열은 byte

여기서 흔한 함정이 비트 표현에서 나온다.

비트로 내려가 보면 (CS 연결)

정수는 2의 보수로 저장된다. -1은 32비트가 전부 1(0xFFFFFFFF)이고, 최상위 비트가 부호다.

Integer.toBinaryString(-1);   // "111...1" (32개)
-8 >> 1;    // -4   산술 시프트: 부호 비트를 확장
-8 >>> 1;   // 큰 양수   논리 시프트: 왼쪽을 0으로
1 << 31;    // -2147483648  최상위 비트로 넘어가 음수

왜 2의 보수인가? 음수를 이렇게 표현하면 덧셈·뺄셈 회로가 하나로 통일되고(뺄셈 = 보수를 더하기), 0의 표현이 하나뿐이라 비교가 단순하다. 그래서 거의 모든 CPU가 이 방식을 쓴다. 그 부작용이 오버플로Integer.MAX_VALUE + 1MIN_VALUE로 도는 건 비트가 한 바퀴 돈 모듈러 산술일 뿐(예외가 아니다).

실수는 IEEE 754다. double은 부호 1 + 지수 11 + 가수 52비트. 0.1은 이진 분수로 유한하게 표현되지 않아(1/10은 2의 거듭제곱 합이 아님) 근사로 저장된다 → 0.1 + 0.2 != 0.3.

실무에서는 — 돈은 절대 double로 계산하지 마라. 금액·세금·환율은 오차가 곧 사고다. BigDecimal문자열 생성자로 만들어 쓴다(new BigDecimal("0.1")double을 넣으면 이미 오차가 박혀 있다). 동등 비교도 equals(스케일까지 봄)가 아니라 compareTo(...) == 0으로 한다. Effective Java Item 60.

char는 UTF-16 코드 단위라, 이모지처럼 BMP 밖 문자는 char 2개(서로게이트 페어)다 → "𝄞".length() == 2. 그리고 Java 9의 Compact Strings 덕에 String은 내부적으로 char[]가 아니라 byte[] + 인코딩 플래그라, ASCII 위주면 메모리가 절반이다.


2. 박싱 — 편한데 함정이 숨어 있다

int(기본형)와 Integer(객체)를 자바가 자동으로 오간다(오토박싱). 편하지만 숨은 비용과 버그가 따라온다. Integer힙 객체(헤더 12B + int 4B)라, 기본형 4바이트와는 무게가 다르다.

Integer a = 127, b = 127;   // a == b → true   (-128~127은 캐시: 같은 객체)
Integer c = 128, d = 128;   // c == d → false  (캐시 밖: 새 객체, 참조가 다름)

Map<String,Integer> m = new HashMap<>();
int v = m.get("없는키");     // 💥 NPE — null(Integer)을 int로 언박싱

실무에서는 — ① 박싱 타입은 항상 .equals()로 비교(==는 참조라 캐시 경계에서 들쭉날쭉). ② 없을 수 있는 맵 조회는 getOrDefault("k", 0). ③ 합계 누적 같은 핫 루프는 Long이 아니라 long으로 — 래퍼로 누적하면 반복마다 객체가 생겨 GC를 압박한다(Item 61).


3. 객체가 "같다"는 건 무슨 뜻인가

참조형에선 "같다"가 두 가지다. ==같은 객체인가(주소 비교), equals같은 값인가(내용 비교).

String a = "hi", b = "hi";          // 리터럴은 풀에서 공유 → a == b : true
String c = new String("hi");        // new는 늘 새 객체 → a == c : false
a.equals(c);                         // 내용 비교 → true

그래서 값을 비교할 땐 언제나 equals다. 그런데 직접 만든 클래스를 HashMap 키나 HashSet에 넣으려면 규약이 하나 더 붙는다 — equals를 재정의하면 hashCode도 반드시 함께 재정의해야 한다.

// equals만 고치고 hashCode를 빼면 → 같은 값인데 HashMap에서 못 찾는다
map.put(new Point(1, 2), "A");
map.get(new Point(1, 2));   // hashCode가 달라 다른 버킷을 봄 → null

왜 그런지는 앞 컬렉션·JVM 페이지에서 본 해시 버킷 구조 때문이다. HashMap은 먼저 hashCode로 버킷을 찾고 그 안에서 equals로 비교한다. hashCode가 어긋나면 아예 다른 버킷을 뒤지니 영영 못 만난다. 규약은 "equals가 true면 hashCode도 같아야 한다"(역은 아님 — 충돌 허용).

실무에서는 — 값을 담는 클래스는 record로. Java 16+의 record는 모든 필드 기반 equals/hashCode/toString/접근자를 자동 생성해 이 버그를 원천 차단한다. DTO·API 응답·다중 반환·맵 키 같은 값 객체는 record가 사실상 표준이다.

record Point(int x, int y) {
    Point { if (x < 0 || y < 0) throw new IllegalArgumentException(); }  // 컴팩트 생성자로 검증
}

4. 안 바뀌는 객체의 힘 — 불변(immutability)

record가 좋은 또 하나의 이유는 불변이라는 점이다. 불변 객체는 한 번 만들면 상태가 안 바뀐다. 만드는 법은 셋이다 — 클래스·필드를 final로, setter 없이, 가변 내부는 방어적 복사.

public final class Money {
    private final long amount;
    private final List<String> tags;
    public Money(long amount, List<String> tags) {
        this.amount = amount;
        this.tags = List.copyOf(tags);   // 외부 리스트 변경이 새어들지 않게 복사
    }
    public List<String> tags() { return tags; }   // 불변 뷰만 노출
}

왜·실무에서는 — 불변 객체는 ① 여러 스레드가 동시에 읽어도 안전하다(아무도 못 바꾸니 락이 필요 없다 — 동시성 페이지의 "안전한 발행"과 직결). ② HashMap 키로 안전하다(키로 넣은 뒤 값이 바뀌면 영영 못 찾는 버그가 없다). ③ 캐시·공유에 안심하고 재사용할 수 있다. 그래서 도메인 값 객체, DTO, 설정 객체는 불변으로 만드는 게 정석이다(Item 17).


5. 설계의 갈림길 — 상속보다 합성, 그리고 sealed·enum

마지막으로 "타입을 어떻게 엮을까". 초보가 가장 먼저 배우는 상속은 사실 조심해서 써야 한다. 상위 클래스가 바뀌면 하위가 깨지는 취약한 기반 클래스 문제가 있어서다.

// 상속: 상위 구현 변화에 취약 (addAll이 내부에서 add를 부르면 카운트가 두 번 증가)
class InstrumentedSet<E> extends HashSet<E> { ... }

// 합성(권장): 필드로 위임 → 상위 변화와 격리
class CountingSet<E> {
    private final Set<E> delegate;            // 가지고 있고(has-a)
    int add(E e) { count++; return delegate.add(e) ? 1 : 0; }   // 위임
}

실무에서는 — "is-a"가 확실할 때만 상속, 아니면 합성. 왜 깨지나? 상위 클래스가 내부에서 *자기 메서드를 호출(self-use)*하면, 내가 오버라이드한 메서드가 의도치 않게 끼어들어 상위 구현의 가정을 깬다(위 addAll이 내부에서 add를 부르면 카운트 이중 증가). 이 self-use 패턴은 문서화도 잘 안 되고 버전마다 바뀌어, 프레임워크 클래스를 상속하면 업그레이드할 때 갑자기 깨지는 사고가 흔하다(Item 18).

대신 닫힌 계층이 필요하면 **sealed**가 깔끔하다. 허용 하위 타입을 permits로 못 박으면, switch모든 경우를 다뤘는지 컴파일러가 검사해 준다.

sealed interface Shape permits Circle, Rectangle {}
double area(Shape s) {
    return switch (s) {                       // 망라 검사 → default 불필요
        case Circle c    -> Math.PI * c.r() * c.r();
        case Rectangle r -> r.w() * r.h();
    };
}

enum은 단순 상수를 넘어 상수마다 다른 동작까지 담을 수 있어(상수별 메서드 본문), switch 분기 대신 타입 안전하게 전략을 묶는다.

enum Op { PLUS { int apply(int a,int b){return a+b;} },
          TIMES{ int apply(int a,int b){return a*b;} };
          abstract int apply(int a, int b); }

한눈에 정리

  • 기본형은 값, 참조형은 주소 — 복사·비교의 의미가 다르다.
  • 비트: 정수=2의 보수(오버플로=모듈러), 실수=IEEE 754(0.1 부정확 → 돈은 BigDecimal), char=UTF-16.
  • 박싱: Integer는 힙 객체. == 캐시 함정·언박싱 NPE → equals·getOrDefault·기본형 누적.
  • 같음: ==(주소) vs equals(값). equals 고치면 hashCode도 — 값 객체는 record.
  • 불변: final + 방어적 복사 → 스레드 안전·안전한 키. 도메인 값·DTO는 불변으로.
  • 설계: 상속보다 합성, 닫힌 계층은 sealed(망라 switch), 동작 있는 상수는 enum.
제네릭