Java 클래스·record·인터페이스·sealed 기초 (JDK 26)

OpenJDK 26에서 직접 돌린 Java 초급 강좌. 캡슐화, equals만 구현해 HashSet에 중복이 들어가는 버그, record, 인터페이스와 추상 클래스 선택 표, sealed switch 누락 에러.

시리즈: Java 강좌 2/6 (초급). main 메서드는 쓸 줄 알고, 이제 내 타입을 직접 설계해야 하는 분을 위한 글입니다. 다 읽으면 클래스·record·인터페이스·추상 클래스·sealed 계층 중 무엇을 쓸지 고를 수 있고, HashSet을 조용히 망가뜨리는 equals/hashCode 실수를 피할 수 있습니다.

국비 과정이나 입문서는 클래스 선언 방법까지는 알려 주지만, 실무에서 시간을 잡아먹는 건 그다음입니다. "Set에 같은 게 두 개 들어 있다", "하위 타입을 하나 추가했는데 아무 에러도 없다가 운영에서 분기 누락이 터졌다", "이 DTO를 record로 바꿔도 되나". 이 글은 OpenJDK 26(Temurin 26+35), Apple Silicon Mac (M1 Pro)에서 돌린 코드와 javac의 실제 메시지, 그리고 잘못된 hashCode의 비용 실측으로 이 질문들에 답합니다.

1강과 마찬가지로 예제는 모두 java 파일명.java로 실행되는 파일 하나짜리입니다.

클래스, 생성자, 캡슐화

클래스는 상태와 그 상태를 올바르게 유지하는 규칙을 한데 묶은 것입니다. 캡슐화는 그 규칙을 한 곳에 두는 것입니다. 필드는 private, 들어오는 길은 입력을 검사하는 메서드뿐입니다.

public class Account {
    private final String owner;
    private long balance;              // 원·센트 단위 정수. double 금지

    public Account(String owner, long initial) {
        if (owner == null || owner.isBlank()) throw new IllegalArgumentException("owner is required");
        if (initial < 0) throw new IllegalArgumentException("initial < 0: " + initial);
        this.owner = owner;
        this.balance = initial;
    }
    public Account(String owner) { this(owner, 0); }   // 생성자 체이닝

    public void withdraw(long amount) {
        if (amount <= 0) throw new IllegalArgumentException("amount must be > 0");
        if (amount > balance) throw new IllegalStateException("insufficient: " + balance + " < " + amount);
        balance -= amount;
    }
    public long balance() { return balance; }
    public String owner() { return owner; }

    public static void main(String[] args) {
        Account a = new Account("kim", 1000);
        a.withdraw(300);
        System.out.println(a.owner() + " " + a.balance());
        System.out.println(a);
        a.withdraw(5000);
    }
}
kim 700
Account@6f3b5d16
Exception in thread "main" java.lang.IllegalStateException: insufficient: 700 < 5000
    at Account.withdraw(Account.java:15)
    at Account.main(Account.java:26)
  • System.out.println(a)는 Account@6f3b5d16이었습니다. 클래스 이름과 16진수 해시이고 실행할 때마다 바뀝니다. Object.toString()의 결과라서, 읽을 만한 출력이 필요하면 오버라이드하거나 record를 씁니다.
  • 다른 클래스에서 a.balance = 1_000_000;이라고 쓰면 error: balance has private access in Account입니다. 이 에러가 바로 캡슐화의 목적이고, 고치는 법은 필드를 public으로 여는 게 아니라 deposit 같은 메서드를 만드는 것입니다.
  • 생성자를 하나라도 쓰면 인자 없는 기본 생성자는 자동으로 생기지 않습니다. Point(int x, int y)만 있는 클래스에서 new Point()를 하면 error: constructor Point in class Point cannot be applied to given types; 뒤에 required: int,int, found: no arguments가 나왔습니다.

equals와 hashCode 규약, HashSet 중복 버그

HashSet과 HashMap은 hashCode()로 버킷을 고른 다음 그 안에서 equals()로 비교하는 두 단계로 요소를 찾습니다. 그래서 a.equals(b)이면 a.hashCode() == b.hashCode()여야 합니다. 이를 어기면 예외 하나 없이 컬렉션이 이상하게 동작합니다.

static class P1 {                       // equals만 구현, hashCode는 빠뜨림
    final int x, y;
    P1(int x, int y) { this.x = x; this.y = y; }
    @Override public boolean equals(Object o) {
        return o instanceof P1 p && p.x == x && p.y == y;
    }
}
static class P2 {                       // 둘 다 제대로 구현
    final int x, y;
    P2(int x, int y) { this.x = x; this.y = y; }
    @Override public boolean equals(Object o) {
        return o instanceof P2 p && p.x == x && p.y == y;
    }
    @Override public int hashCode() { return Objects.hash(x, y); }
}
// P0 = equals도 hashCode도 없음, P3 = P2와 같지만 필드가 가변

HashBug.java(전체 코드는 영어판) 출력입니다.

no equals/hashCode : size=2
equals only        : size=2, equals? true, contains? false
equals + hashCode  : size=1, contains? true
equals only, 10,000 trials: duplicates in 10000
ArrayList.contains : true
mutated key        : contains(p)=false, size=1, remove(p)=false

둘째 줄을 천천히 보세요. equals는 같다고 하는데 Set에는 둘 다 들어 있고, 같은 점을 새로 만들어 찾아도 못 찾습니다. 가끔 나는 버그가 아니라 10,000번 중 10,000번입니다. 기본 hashCode가 객체마다 다르기 때문입니다. 게다가 equals만 쓰는 ArrayList.contains는 true라서, 리스트로 짠 단위 테스트는 통과하고 누군가 Set으로 바꾸는 날 처음 드러납니다.

마지막 줄은 규약의 나머지 절반입니다. Set에 넣은 뒤 p.x = 99로 바꾸자 크기는 1인데 contains도 remove도 닿지 않았습니다. 키는 불변이어야 합니다.

첫 번째 실수는 javac가 알려 줍니다.

$ javac -Xlint:all HashBug.java
HashBug.java:8: warning: [overrides] Class P1 overrides equals, but neither it nor any superclass overrides hashCode method

흔한 실수: equals(Object o) 대신 equals(P o)

public boolean equals(P o) { return o != null && o.x == x; }   // 오버로드가 돼 버림
p.equals(p2)        : true
o.equals(p2)        : false      // o를 Object 타입으로 선언
List.of(..).contains: false

실제로 일어난 일: 오버라이드가 아니라 새 메서드를 하나 추가했을 뿐이라, 컬렉션이 부르는 equals(Object)는 여전히 동일성 비교입니다. 해결책은 항상 @Override를 붙이는 것. 붙이면 javac가 error: equals(P) in P does not override or implement a method from a supertype로 막아 줍니다.

잘못된 hashCode의 비용

상수를 돌려주는 hashCode는 규약상 합법입니다(같은 객체는 같은 해시). 다만 모든 키가 버킷 하나에 몰립니다. 키 n개를 HashSet에 넣고 전부 contains하는 시간을 record가 만든 hashCode와 return 42로 비교했습니다(JIT 워밍업 후 3회 중 최솟값, OpenJDK 26, M1 Pro).

n= 1,000  record hashCode:    0.21 ms   constant hashCode:       2.3 ms
n= 5,000  record hashCode:    0.17 ms   constant hashCode:      61.9 ms
n=20,000  record hashCode:    1.26 ms   constant hashCode:    1246.7 ms
밀리초 막대그래프. 키 5,000개: record 0.17, 상수 hashCode 61.9. 키 20,000개: record 1.26, 상수 hashCode 1,246.7. 상수 hashCode는 키가 4배일 때 시간이 약 20배가 됐다.
HashSet에 키 n개를 넣고 모두 조회하는 시간. record가 만든 hashCode 대 규약상 합법인 상수 hashCode(return 42). OpenJDK 26, Apple Silicon Mac (M1 Pro), JIT 워밍업 후 3회 중 최솟값.

세 번 돌린 큰 값의 차이는 약 4%(1,201~1,247ms)였습니다. 키가 4배면 시간은 약 20배, 20,000개에서는 약 1,000배 차이입니다. record 쪽은 값이 너무 작아 JIT 잡음이 더 커서 5,000이 1,000보다 빠르게 나왔습니다.

record: 규약이 처음부터 지켜지는 데이터 클래스

record는 구성 요소를 한 번만 쓰면 private final 필드, 생성자, 접근자(getX()가 아니라 x()), equals·hashCode·toString을 서로 어긋나지 않게 만들어 줍니다. Lombok의 @Value를 쓰던 자리 상당수를 대신할 수 있습니다.

record Point(int x, int y) {}

record Range(int lo, int hi) {
    Range {                                   // 컴팩트 생성자
        if (lo > hi) throw new IllegalArgumentException("lo > hi: " + lo + " > " + hi);
    }
    int length() { return hi - lo; }
}

record Tags(List<String> values) {
    Tags { values = List.copyOf(values); }    // 방어적 복사
}

Records.java 출력(main은 영어판과 같음):

Point[x=1, y=2]
equals=true, sameHash=true, == false
HashSet size=2, contains(1,2)=true
x()=1, length=7
Tags[values=[java]]
values().add -> java.lang.UnsupportedOperationException
Exception in thread "main" java.lang.IllegalArgumentException: lo > hi: 10 > 3
    at Records$Range.<init>(Records.java:8)
  • 컴팩트 생성자(매개변수 목록 없는 Range { … })는 필드에 값이 들어가기 전에 실행됩니다. 검증과 정규화를 하는 자리입니다.
  • record는 "얕은" 불변입니다. List.copyOf가 없었다면 호출한 쪽이 나중에 src.add("oops")한 내용이 record로 새어 들어왔을 겁니다. 복사해 두면 Map 키로 써도 안전합니다.
  • record에는 인스턴스 필드를 더할 수 없습니다. int z;를 넣으면 error: field declaration must be static(힌트: consider replacing field with record component), 메서드에서 구성 요소에 대입하면 error: cannot assign a value to final variable x였습니다.
  • record는 클래스를 상속할 수 없지만(암묵적으로 java.lang.Record 상속) 인터페이스는 구현할 수 있습니다. 뒤의 sealed 예제가 이 점을 이용합니다.

인터페이스, default 메서드, 추상 클래스

인터페이스는 "이 타입이 무엇을 할 수 있는가"를 나타냅니다. Java 8부터 default 메서드와 static 메서드를 가질 수 있지만 인스턴스 상태는 가질 수 없습니다. 추상 클래스는 필드, 생성자, final 메서드를 가질 수 있는 대신 하나뿐인 부모 클래스 자리를 차지합니다.

interface Notifier {
    void send(String to, String msg);                    // 추상 메서드
    default void sendAll(List<String> to, String msg) {  // default 메서드
        for (String t : to) send(t, msg);
    }
    static Notifier console() {
        return (to, m) -> System.out.println("[console] " + to + ": " + m);
    }
}

static abstract class Job {
    private int runs;                                     // 인터페이스에는 둘 수 없는 상태
    protected final String name;
    Job(String name) { this.name = name; }
    public final void run() {                             // 템플릿 메서드
        runs++;
        System.out.println("start " + name + " #" + runs);
        doWork();
    }
    protected abstract void doWork();
}
[console] alice: deploy done
[console] bob: deploy done
start cleanup #1
  deleting temp files
start cleanup #2
  deleting temp files

여기서 만나는 에러는 세 가지입니다.

Diamond.java:4: error: types A and B are incompatible;
  class C inherits unrelated defaults for hello() from types A and B
AbsNew.java:3: error: Half is not abstract and does not override abstract method doWork() in Job
AbsNew.java:4: error: Job is abstract; cannot be instantiated

첫째는 두 인터페이스가 같은 이름의 default 메서드를 가질 때입니다. 오버라이드한 뒤 A.super.hello()처럼 직접 고릅니다.

판단 도식. 출발점은 새 타입. 질문 1: 바뀌지 않는 순수한 데이터인가? 예면 record. 아니오면 질문 2: 종류가 정해진 유한한 집합인가? 예면 각 경우를 record로 만든 sealed interface. 아니오면 질문 3: 구현끼리 상태나 생성자 로직을 공유하는가? 예면 추상 클래스. 아니오면 interface(공통 동작은 default 메서드).
새 타입을 record, sealed interface, 추상 클래스, interface 중 무엇으로 만들지. 이 순서로 묻는다.
상황 선택 이유
값으로 동일성이 정해지는 불변 데이터(DTO, Map 키, 계산 결과) record equals/hashCode/toString이 자동으로 일관됨
종류가 정해진 집합(결제 수단, 도형, API 결과) sealed interface + record switch 분기 누락을 컴파일러가 잡음
관계없는 클래스들이 같은 능력을 가짐 interface(+ default) 여러 개 구현 가능
하위 클래스가 필드·처리 순서를 공유 추상 클래스 인터페이스는 상태를 못 가짐
불변식이 있는 가변 객체(계좌, 장바구니) 일반 클래스 + private 필드 캡슐화. record가 아님

sealed 인터페이스와 패턴 매칭 switch

sealed 인터페이스는 구현 타입을 모두 나열합니다. 그러면 컴파일러가 switch가 빠짐없는지 검사할 수 있어서, 새 종류를 추가하는 순간 그걸 처리하지 않은 switch가 전부 컴파일 에러가 됩니다. 운영 중 분기 누락이 아니라요.

public class Shapes {
    sealed interface Shape permits Circle, Rect, Triangle {}
    record Circle(double r) implements Shape {}
    record Rect(double w, double h) implements Shape {}
    record Triangle(double b, double h) implements Shape {}

    static double area(Shape s) {
        return switch (s) {
            case Circle c -> Math.PI * c.r() * c.r();
            case Rect(double w, double h) -> w * h;           // record 패턴
            case Triangle t when t.b() == 0 -> 0;              // 가드
            case Triangle t -> 0.5 * t.b() * t.h();
        };
    }

    public static void main(String[] args) {
        Shape[] all = { new Circle(1), new Rect(2, 3), new Triangle(4, 5) };
        for (Shape s : all) System.out.printf("%-30s area=%.2f%n", s, area(s));
        area(null);
    }
}
Circle[r=1.0]                  area=3.14
Rect[w=2.0, h=3.0]             area=6.00
Triangle[b=4.0, h=5.0]         area=10.00
Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:220)
    at Shapes.area(Shapes.java:8)

default가 없고 필요하지도 않습니다. Triangle 두 줄을 지우면 javac가 멈춥니다.

Shapes.java:8: error: the switch expression does not cover all possible input values
        return switch (s) {
               ^

이것이 sealed로 만드는 이유입니다. default -> 0을 써 뒀다면 삼각형 누락은 컴파일을 통과해 면적 0을 조용히 돌려줬을 겁니다. permits에 없는 타입이 구현하려 하면 error: class is not allowed to extend sealed class: Shape (as it is not listed in its 'permits' clause)입니다.

돌려 보고 알게 된 점 두 가지: 패턴 switch는 null이 오면 NullPointerException을 던집니다(case null -> …를 넣으니 정상 종료). 그리고 가드가 붙은 Triangle t when …은 가드 없는 Triangle t보다 먼저 와야 합니다.

"Java N부터"를 직접 확인하기

javac 26에서 --release를 낮춰 컴파일했습니다. Shapes17.java는 같은 sealed 계층을 switch 대신 if (s instanceof Circle c)로 쓴 파일입니다.

$ javac --release 15 Shapes17.java
error: sealed classes are not supported in -source 15
error: records are not supported in -source 15
  (use -source 16 or higher to enable records)
$ javac --release 16 Shapes17.java
error: sealed classes are not supported in -source 16
  (use -source 17 or higher to enable sealed classes)
$ javac --release 17 Shapes17.java && java -cp out Shapes17
Rect[w=2.0, h=3.0] 6.0
$ javac --release 17 Shapes.java
Shapes.java:9: error: patterns in switch statements are not supported in -source 17
  (use -source 21 or higher to enable patterns in switch statements)
Shapes.java:10: error: deconstruction patterns are not supported in -source 17

record는 16, sealed는 17, 패턴 switch와 record 패턴은 21부터입니다. 회사 프로젝트가 아직 Java 17(Spring Boot 3의 최소 버전)이라면 sealed 계층은 만들 수 있지만 instanceof 연쇄로 써야 하고, 빠짐없는지 검사는 받지 못합니다.

솔직한 한계와 버전 메모

  • 모두 OpenJDK 26(Temurin 26+35), Apple Silicon Mac (M1 Pro)에서 컴파일·실행했습니다. --release는 javac 26으로 옛 언어 수준을 흉내 낸 것이고 JDK 16·17·21 자체를 설치하지는 않았습니다.
  • 해시 측정은 워밍업 후 best-of-3이고 JMH가 아닙니다. 비율(20,000개에서 약 1,000배)로 읽어 주세요.
  • HashMap은 긴 버킷을 트리로 바꾸지만 키가 Comparable일 때만 효과가 있습니다. 이번 Bad 키는 아니어서 최악의 경우를 본 것입니다.

이전 강좌: Java 강좌 1/6: 타입·문자열·동등성. 다음 강좌: Java 강좌 3/6: 컬렉션과 성능. 관련 글: Python 강좌 2, VB.NET 강좌 3: 클래스·상속·인터페이스, C#의 인터페이스.

자주 묻는 질문

equals만 오버라이드하고 hashCode를 빼먹으면 어떻게 되나요?

같다고 판정되는 두 객체가 서로 다른 해시값을 가져서 HashSet에 둘 다 들어가고 contains도 false가 됩니다. 같은 점을 두 번 add하는 시도를 10,000번 했더니 10,000번 모두 크기가 2였습니다. 반면 ArrayList.contains는 true라서 리스트로 짠 테스트는 통과합니다. javac -Xlint:all을 주면 [overrides] 경고가 나옵니다.

record와 일반 클래스는 어떻게 골라야 하나요?

값으로 동일성이 정해지는 불변 데이터면 record입니다. 생성자, 접근자, equals, hashCode, toString이 자동으로 생기고 컴팩트 생성자로 검증도 할 수 있습니다. 상태가 바뀌거나, 숨길 필드가 있거나, 다른 클래스를 상속해야 하면 일반 클래스입니다. record는 Java 16에서 정식이 됐고 --release 15로는 컴파일되지 않았습니다.

sealed와 패턴 매칭 switch는 Java 몇부터 쓸 수 있나요?

sealed 클래스·인터페이스는 Java 17, switch 패턴 매칭과 record 분해 패턴은 Java 21부터입니다. --release 17로 컴파일하면 patterns in switch statements are not supported in -source 17 에러가 났습니다. 둘 다 javac 26의 --release로 확인했습니다.

  • #Java
  • #초급
  • #OpenJDK 26
  • #record
  • #sealed
  • #인터페이스
  • #equals
  • #hashCode

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑