문서 읽는 데 63분 · day29

Day 29 — Record와 Sealed Class: 불변 데이터 클래스를 짧고 안전하게

목차 20
전체 33강 중 29강 · 자바 기초
난이도 · 입문

지난 시간, 우리는 Member 같은 객체에서 값을 꺼내 Optional 로 안전하게 다뤘어요. 그러면서 마지막에 살짝 흘려둔 이야기가 있었죠. "값을 담기만 하는 객체"를 만들 때마다 생성자·getter·equals·hashCode·toString 을 잔뜩 적어야 한다는 불편 말이에요.

Day 16 에서 Member 클래스를 만들 때를 떠올려보세요. 한 사람을 표현하려고 필드 적고, 생성자 적고, getter 줄줄이 적고, equalshashCode 까지 손으로 맞췄잖아요. 그 파일이 200줄을 넘겼어요. 그런데 그중 상당수는 "값을 받아서 담고, 다시 꺼내주는" 거의 똑같은 코드였죠.

오늘은 그 길고 반복되는 코드를 단 한 줄로 줄여주는 새 문법, Record(레코드) 를 배워요. "값을 담는 불변 객체"를 아주 간결하게 만드는 도구예요. 그리고 후반에는 Record 와 단짝처럼 쓰이는 sealed(봉인) 클래스까지 익혀서, "정해진 종류만 허용하는" 안전한 설계를 만들어볼 거예요. 시작해볼게요!

🎯 학습 목표

  • "값을 담는 객체"가 왜 매번 길어지는지, 어떤 코드가 반복되는지 직접 보고 이해해요.
  • record 한 줄이 생성자·접근자·equals·hashCode·toString 을 어떻게 자동으로 만들어주는지 알아요.
  • 컴팩트 생성자로 값 검증과 정리를 한 곳에 모을 수 있어요.
  • record 의 두 가지 제약(불변·상속 불가)을 이해하고, 왜 그게 오히려 장점인지 알아요.
  • sealedpermits 로 "정해진 종류만" 구현하도록 봉인할 수 있어요.
  • sealed + record + switch 패턴 매칭을 조합해 타입별 분기를 안전하게 처리해요.

오늘의 로드맵

  • Step 1 — 값만 담는 객체를 옛 방식으로 직접 짜보기 (반복 코드의 불편).
  • Step 2record 등장: 50줄을 한 줄로.
  • Step 3record 가 자동으로 만들어주는 것 뜯어보기.
  • Step 4 — 컴팩트 생성자: 검증과 정리를 한 곳에.
  • Step 5record 의 제약: 불변과 상속 불가.
  • Step 6sealed: 상속을 골라서 허용하기.
  • Step 7sealed + record + switch 패턴 매칭의 조합.
  • Step 8 — 종합: 화면에 뿌릴 데이터를 record 로.

Step 1: 값만 담는 객체를 옛 방식으로 직접 짜보기

record 를 배우기 전에, 먼저 "왜 이게 필요한가"부터 느껴봐야 해요. 그러려면 옛 방식으로 직접 한 번 만들어봐야겠죠.

사진 한 장에는 "크기(가로·세로)"라는 정보가 있어요. 가로 1080, 세로 1080 이면 정사각형 사진이고요. 이 "크기"라는 값을 담는 작은 객체를 만들어볼게요. 필드는 딱 두 개(width, height) 예요. 그런데 이 객체가 "제대로" 동작하려면 적어야 할 게 생각보다 많아요.

Java
// com/instagram/javabasic/modern/OldImageSize.java
public final class OldImageSize {

    // 값 두 개. 한 번 정하면 안 바뀌도록 final 로 둬요(불변).
    private final int width;
    private final int height;

    // (1) 생성자 — 값을 받아 채워요.
    public OldImageSize(int width, int height) {
        this.width = width;
        this.height = height;
    }

    // (2) 접근자 — 숨긴 값을 읽는 통로. 값 두 개니까 두 개.
    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }
    // ... 아래로 equals, hashCode, toString 이 이어져요
}

여기까지는 Day 9 에서 배운 그대로예요. 그런데 이게 끝이 아니에요. "가로·세로가 같으면 같은 크기"로 취급하려면 equalshashCode 를 직접 정의해야 해요. Day 19 에서 배운 그 약속 기억나시죠? equals 만 바꾸고 hashCode 를 안 맞추면 HashSet 이 중복을 제대로 못 걸러요.

Java
    // (3) equals — "가로·세로가 같으면 같은 크기" 로 보려고 직접 정의해요.
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        OldImageSize other = (OldImageSize) obj;
        return width == other.width && height == other.height;
    }

    // (4) hashCode — equals 와 짝꿍. 같다고 본 두 값은 같은 칸으로 보내야 HashSet 이 중복을 걸러요.
    @Override
    public int hashCode() {
        return Objects.hash(width, height);
    }

    // (5) toString — 그냥 출력하면 주소가 찍히니, 사람이 읽을 글로 바꿔요.
    @Override
    public String toString() {
        return "OldImageSize[width=" + width + ", height=" + height + "]";
    }

자, 다 합쳐보면 값은 단 두 개인데 코드는 50줄 가까이 돼요. 한눈에 정리하면 이래요.

텍스트
 값만 담는 객체 (필드 2개) 에 손으로 적어야 하는 다섯 가지
 ┌────────────────────────────────────────────────┐
 │ (1) 생성자        OldImageSize(w, h)           │
 │ (2) 접근자 × 2    getWidth() / getHeight()     │
 │ (3) equals        값으로 같은지 직접 비교      │
 │ (4) hashCode      equals 와 짝 맞추기          │
 │ (5) toString      읽기 좋은 글로 바꾸기        │
 └────────────────────────────────────────────────┘
        값은 2개  →  코드는 약 50줄

이렇게 만들어두면 동작은 잘해요. 실제로 써볼게요.

Java
// com/instagram/javabasic/modern/BoilerplateProblemDemo.java
OldImageSize square = new OldImageSize(1080, 1080);
OldImageSize sameSquare = new OldImageSize(1080, 1080);
OldImageSize portrait = new OldImageSize(1080, 1350);

System.out.println("두 1080 정사각은 같은 크기? " + square.equals(sameSquare)); // true
System.out.println("정사각 vs 세로형은 같은 크기? " + square.equals(portrait));   // false

Set<OldImageSize> sizes = new HashSet<>();
sizes.add(square);
sizes.add(sameSquare);  // 값이 같아 중복 — 안 들어가요
sizes.add(portrait);
System.out.println("서로 다른 크기 종류 수: " + sizes.size()); // 2

실행하면 equals 도 잘 동작하고, HashSet 이 똑같은 1080 × 1080 을 중복으로 걸러서 종류는 2개로 나와요. 다 좋아요. 딱 하나, "이걸 위해 손으로 50줄을 적었다"는 게 마음에 걸릴 뿐이에요.

이런 객체를 인스타그램 도메인 곳곳에서 만든다고 생각해보세요. 사진 크기, 좌표, 화면에 뿌릴 작은 데이터 묶음… 매번 50줄씩 적는 건 너무 번거롭죠. 게다가 equalshashCode 를 깜빡하거나 잘못 적으면 조용히 버그가 생겨요. 바로 이 불편을 없애려고 나온 게 다음 단계의 record 예요.


Step 2: record 등장: 50줄을 한 줄로

방금 그 OldImageSize똑같은 일을 하는 객체를, 이번엔 단 한 줄로 만들어볼게요.

Java
// com/instagram/javabasic/modern/ImageSize.java
public record ImageSize(int width, int height) {
}

이게 전부예요. class 대신 record 라고 적고, 이름 뒤 괄호 안에 "담을 값"만 나열했어요. 그러면 자바가 Step 1 에서 우리가 손으로 적었던 다섯 가지(생성자·접근자·equals·hashCode·toString) 를 전부 알아서 만들어줘요. 우리는 한 줄도 더 안 적었는데 말이죠.

정말 똑같이 동작하는지 확인해볼게요.

Java
// com/instagram/javabasic/modern/RecordIntroDemo.java
ImageSize square = new ImageSize(1080, 1080);
ImageSize sameSquare = new ImageSize(1080, 1080);
ImageSize portrait = new ImageSize(1080, 1350);

System.out.println("정사각 사진 크기: " + square);   // ImageSize[width=1080, height=1080]
System.out.println("두 1080 정사각은 같은 크기? " + square.equals(sameSquare)); // true

Set<ImageSize> sizes = new HashSet<>();
sizes.add(square);
sizes.add(sameSquare);
sizes.add(portrait);
System.out.println("서로 다른 크기 종류 수: " + sizes.size()); // 2

// 접근자(읽는 통로)도 자동 — 다만 이름이 getWidth() 가 아니라 width() 예요.
System.out.println("가로: " + square.width() + ", 세로: " + square.height()); // 1080, 1080

실행 결과를 보면 Step 1 의 50줄짜리와 완전히 똑같아요.

  • toString 이 자동으로 만들어져서 ImageSize[width=1080, height=1080] 처럼 값이 한눈에 보여요.
  • equals 가 자동이라 값이 같으면 true.
  • hashCode 도 자동이라 HashSet 중복 제거가 그대로 동작해서 종류는 2개.
텍스트
 같은 일을 하는 두 객체
 ┌──────────────────────────┬──────────────────────────────┐
 │ OldImageSize (옛 방식)   │ ImageSize (record)           │
 ├──────────────────────────┼──────────────────────────────┤
 │ 약 50줄 직접 작성        │ 한 줄                        │
 │ equals/hashCode 손으로   │ 자동 생성                    │
 │ 깜빡하면 조용히 버그     │ 깜빡할 코드가 없음           │
 └──────────────────────────┴──────────────────────────────┘

처음 보면 "이렇게 짧아도 되나?" 싶을 만큼 간결하죠. record 는 "이 객체는 값을 담는 게 전부야"라는 뜻을 코드로 솔직하게 드러내는 도구예요. 그래서 자바가 "값을 담는 객체라면 으레 필요한 것들"을 알아서 채워주는 거고요.

마지막 줄을 보면 값을 읽을 때 getWidth() 가 아니라 width() 를 썼어요. record 가 만들어주는 접근자는 이름이 좀 다르거든요. 이게 왜 그런지, 자동으로 만들어진 것들을 다음 단계에서 하나씩 뜯어볼게요.


Step 3: record 가 자동으로 만들어주는 것 뜯어보기

record 한 줄이 만들어준 것들을 천천히 들여다볼게요. 무엇이 생겼는지 알아야 마음 놓고 쓸 수 있으니까요.

Java
// com/instagram/javabasic/modern/RecordAccessorDemo.java
// (1) 표준 생성자 — 괄호 안 순서대로 값을 받아요. ImageSize(int width, int height)
ImageSize size = new ImageSize(1080, 1350);

// (2) 접근자 — 필드 이름 그대로예요. getWidth() 가 아니라 width().
int w = size.width();
int h = size.height();
System.out.println("가로 " + w + " / 세로 " + h); // 가로 1080 / 세로 1350

// (3) toString — 디버깅할 때 값이 한눈에 보여요.
System.out.println(size); // ImageSize[width=1080, height=1350]

// equals & hashCode — 값이 같으면 같고, 같으면 해시도 같아요.
ImageSize copy = new ImageSize(1080, 1350);
System.out.println("값이 같으면 equals? " + size.equals(copy));          // true
System.out.println("그러면 hashCode 도 같아야죠? " + (size.hashCode() == copy.hashCode())); // true

자동으로 생긴 것들을 정리하면 이래요.

  • 표준 생성자 — 괄호에 적은 순서(width, height) 그대로 값을 받는 생성자가 생겨요. "이 record 를 만드는 가장 기본 통로"라서 표준(canonical) 생성자라고 불러요.
  • 접근자 — 값을 읽는 통로가 생겨요. 그런데 이름이 getWidth() 가 아니라 필드명 그대로 width() 예요.
  • toStringrecord이름[필드=값, ...] 형식으로 자동.
  • equals / hashCode — 괄호 안 값들이 모두 같으면 같은 객체로 봐요. 둘은 짝이라 같으면 해시도 같고요.

Day 16 에서 우리가 Member 에 손으로 적었던 코드와 나란히 비교하면, record 가 무엇을 대신해주는지 또렷이 보여요.

우리가 만들 것 Day 16 Member (손으로) record (자동)
생성자 public Member(String username, ...) { this.username = ...; } 괄호만 적으면 끝
값 읽기 public String getUsername() { return username; } username() 자동
같은지 비교 equals 안에서 username 직접 비교 자동
해시 return Objects.hash(username); 자동
글로 출력 toString 직접 작성 자동
🙋 학생 질문 — "튜터님, 왜 getWidth() 가 아니라 width() 인가요?"

좋은 관찰이에요! Day 9 에서 배운 일반 클래스에서는 보통 getWidth() 처럼 get 을 붙이는 게 관례였죠.

그런데 record 는 "값을 담는 게 본업인 객체"예요. 그래서 자바가 접근자 이름을 더 간결하게, 필드 이름 그대로 width() 로 만들어요. get 을 떼서 짧고 깔끔하게요. 외울 건 하나예요. record 의 값을 읽을 땐 필드 이름에 괄호만 붙인다size.width(), size.height() 처럼요.

처음엔 getWidth() 가 손에 익어서 헷갈릴 수 있는데, 몇 번 쓰다 보면 오히려 더 편해요.

이제 record 가 무엇을 자동으로 만들어주는지 분명해졌죠. 그런데 한 가지 걱정이 생길 수 있어요. "생성자가 자동이면, 값을 검사하고 싶을 땐 어떻게 하지? 좋아요 수에 음수가 들어오면 막고 싶은데?" 바로 다음 단계에서 풀어볼게요.


Step 4: 컴팩트 생성자: 검증과 정리를 한 곳에

record 의 생성자가 자동이라 편하긴 한데, 가끔은 값을 받을 때 검사하거나 다듬고 싶어요. 예를 들어 게시물 캡션을 담는 record 라면, "빈 캡션은 안 된다", "앞뒤 공백은 정리한다" 같은 규칙을 넣고 싶죠.

이럴 때 쓰는 게 컴팩트 생성자예요. 이름 그대로 "짧게 쓰는 생성자"인데, 매개변수 목록도, this.text = text 같은 대입도 적지 않아요. 검사하거나 다듬는 코드만 적으면 돼요.

Java
// com/instagram/javabasic/modern/PostCaption.java
public record PostCaption(String text) {

    // 컴팩트 생성자 — 매개변수 목록도, this 할당도 안 써요.
    // 마지막에 자바가 text 를 알아서 필드에 넣어줘요. 그 전에 검사·정리만 해요.
    public PostCaption {
        if (text == null || text.isBlank()) {
            throw new InvalidCaptionException("캡션은 비어 있을 수 없어요.");
        }
        text = text.trim(); // 정규화 — 앞뒤 공백을 정리해서 저장해요.
    }
}

public PostCaption { 처럼 괄호 안 매개변수를 생략한 게 컴팩트 생성자예요. 동작 순서를 보면요.

텍스트
 new PostCaption("  안녕  ")
        │
        ▼
 컴팩트 생성자 안으로 text = "  안녕  " 가 들어옴
        │  ① 비었는지 검사 → 비었으면 예외 던지고 중단
        │  ② text = text.trim() → "안녕" 으로 다듬음
        ▼
 자바가 다듬어진 text 를 필드에 자동으로 넣어줌
        ▼
 결과: PostCaption[text=안녕]

검증에서 던지는 InvalidCaptionException 은 Day 23 에서 만들었던 우리 서비스 전용 예외예요. "캡션 검증에서 막혔구나"를 이름만 봐도 알 수 있죠. 실제로 써보면 이렇게 동작해요.

Java
// com/instagram/javabasic/modern/CompactConstructorDemo.java
// 정상 — 앞뒤 공백이 정리돼서 저장돼요.
PostCaption caption = new PostCaption("  오늘 날씨 좋네요  ");
System.out.println("정리된 캡션: [" + caption.text() + "]"); // [오늘 날씨 좋네요]

// 빈 캡션 — 만드는 순간 막혀요(예외).
try {
    new PostCaption("   ");
} catch (InvalidCaptionException e) {
    System.out.println("막힘: " + e.getMessage()); // 막힘: 캡션은 비어 있을 수 없어요.
}

공백이 섞인 " 오늘 날씨 좋네요 " 를 넣으면 앞뒤가 정리된 오늘 날씨 좋네요 가 저장돼요. 공백만 있는 " " 를 넣으면 객체를 만드는 그 순간 예외가 터지고요.

여기서 중요한 점 하나. 검증을 객체를 만드는 순간에 하니까, 한 번 만들어진 PostCaption 은 "항상 올바른 캡션"이라는 게 보장돼요. 받는 쪽에서 다시 "이 캡션 비어 있나?" 확인할 필요가 없어요. 만들 때 이미 걸렀으니까요.

🙋 학생 질문 — "튜터님, 컴팩트 생성자에서 왜 this.text = text 를 안 써도 되나요?"

일반 생성자에서는 this.text = text; 로 값을 직접 필드에 넣어야 했죠 (Day 8 에서 배운 그대로요).

그런데 컴팩트 생성자는 자바가 마지막에 알아서 그 대입을 해줘요. 우리가 할 일은 그 전에 "검사하고 다듬는 것"뿐이에요. 그래서 text = text.trim() 처럼 매개변수 값을 다듬어두면, 자바가 그 다듬어진 값을 필드에 넣어줘요.

정리하면 컴팩트 생성자의 흐름은 이래요. (내가) 검사·정리 → (자바가) 필드에 자동 대입. 대입을 자바에게 맡기니 코드가 훨씬 짧아지는 거예요.

값을 받을 때 검증까지 깔끔하게 넣을 수 있게 됐어요. 그런데 record 에는 일반 클래스와 다른 두 가지 규칙이 있어요. 이걸 알아야 "언제 record 를 쓰고, 언제 일반 클래스를 쓸지" 판단할 수 있어요. 다음 단계에서 봐요.


Step 5: record 의 제약: 불변과 상속 불가

record 는 편하지만 아무 데나 쓰는 도구는 아니에요. 두 가지 분명한 규칙이 있거든요. 이 규칙을 "제약"이라고 부르지만, 알고 보면 오히려 안전을 지켜주는 장치예요.

규칙 1 — 값이 안 바뀌어요(불변). record 의 값들은 한 번 정해지면 바꿀 수 없어요. 값을 바꾸는 setter 가 아예 없어요. size.width = 150 같은 직접 변경도 문법 오류라 쓸 수조차 없고요. 그래서 "썸네일 크기로 바꾸기" 같은 건, 기존 걸 고치는 게 아니라 새로 만드는 걸로 해요.

Java
// com/instagram/javabasic/modern/RecordConstraintDemo.java
ImageSize original = new ImageSize(1080, 1080);

// 값을 바꾸는 setter 가 없어요. "썸네일 크기로 바꾸기" 는 새 객체를 만드는 걸로 해요.
ImageSize thumbnail = new ImageSize(150, 150);

// 원본은 그대로예요 — 누가 가져가도 내 값이 바뀔 걱정이 없어요(불변의 장점).
System.out.println("원본: " + original);     // ImageSize[width=1080, height=1080]
System.out.println("썸네일: " + thumbnail);   // ImageSize[width=150, height=150]
System.out.println("원본은 그대로? " + original.equals(new ImageSize(1080, 1080))); // true

"바꿀 수 없다니 불편한 거 아니야?"라고 느낄 수 있는데, 사실 이게 큰 장점이에요. 값이 안 바뀌니까 이 객체를 누구에게 건네줘도 안심이에요. 받은 쪽이 몰래 값을 바꿔서 내 데이터가 망가질 걱정이 없거든요.

Day 14 에서 이야기한 "불변 객체가 안전하다"는 그 원리예요. 또 Day 28 에서 "Optional 을 필드로 두지 말고 값은 평범하게 두라"고 했던 그 값 객체를, 이제 record 로 안전하게 만들 수 있게 된 거고요.

규칙 2 — 다른 클래스를 상속할 수 없어요. Day 10~11 에서 배운 extends 로 부모 클래스를 물려받는 것, record 는 못 해요. record 는 항상 그 자체로 완결된 최종 형태예요.

텍스트
 record 가 할 수 있는 것 / 없는 것
 ┌───────────────────────────────────────────────┐
 │ ❌ class 를 extends (상속)    → 불가          │
 │ ✅ interface 를 implements (구현) → 가능!     │
 └───────────────────────────────────────────────┘

대신 인터페이스를 구현(implements) 하는 건 얼마든지 가능해요. Day 13 에서 배운 그 "역할로서의 인터페이스"는 record 도 맡을 수 있어요. 이게 다음 단계로 가는 다리예요. record 여러 개가 같은 인터페이스를 구현하면, "같은 역할의 여러 종류"를 만들 수 있거든요.

💡 한 줄 정리: record값을 담고, 안 바뀌고, 인터페이스는 구현하되 클래스 상속은 안 하는 객체예요. "이 데이터는 한번 정해지면 그대로다" 싶은 자리에 딱 맞아요.

그럼 "여러 종류"를 만들 때, 아무나 그 인터페이스를 구현하게 두면 될까요? 때로는 "딱 이 몇 가지 종류만 허용하고 싶다"는 상황이 있어요. 그걸 가능하게 해주는 게 다음 단계의 sealed 예요.


Step 6: sealed: 상속을 골라서 허용하기

새로운 문법을 배우기 전에, 지금까지의 흐름을 짚어볼게요. Day 10~13 에서 상속과 인터페이스를 배울 때는 "누구나 자유롭게" 물려받거나 구현할 수 있었어요. 부모 클래스 하나를 만들면 자식이 얼마든지 늘어날 수 있었죠.

그런데 가끔은 반대가 필요해요. "이 역할을 구현할 수 있는 건 딱 이 몇 가지뿐"이라고 못 박고 싶을 때가 있어요. 예를 들어 인스타그램 "알림"을 생각해봐요. 알림 종류는 좋아요·댓글·팔로우, 이렇게 정해져 있죠. 아무나 새 알림 종류를 끼워 넣으면 곤란해요.

이럴 때 쓰는 게 sealed(봉인) 예요. 인터페이스를 sealed 로 만들고 permits(허용) 뒤에 "허락된 종류"만 적으면, 그 셋만 구현할 수 있어요.

Java
// com/instagram/javabasic/modern/notification/Notification.java
public sealed interface Notification
        permits LikeNotification, CommentNotification, FollowNotification {
}
텍스트
        sealed interface Notification
                  │ permits (이 셋만 허용)
        ┌───────────────────────┼──────────────┬───────────────┐
        ▼                       ▼              ▼                 
   LikeNotification  CommentNotification  FollowNotification
        │              │                  │
        └──────────────┴──── 다른 클래스는 Notification 을 ────┘
                              구현할 수 없어요 (봉인됨)

이제 허락된 세 종류를 record 로 만들어요. Day 12 에서 추상 클래스로 만들었던 그 Notification·Like·Comment·Follow 계층을, 오늘은 sealed + record 로 훨씬 간결하게 다시 설계하는 거예요.

Java
// com/instagram/javabasic/modern/notification/LikeNotification.java
public record LikeNotification(String actor, String postTitle) implements Notification {
}
Java
// com/instagram/javabasic/modern/notification/CommentNotification.java
public record CommentNotification(String actor, String postTitle, String text) implements Notification {
}
Java
// com/instagram/javabasic/modern/notification/FollowNotification.java
public record FollowNotification(String actor) implements Notification {
}

각 알림은 담는 정보가 조금씩 달라요. 좋아요는 "누가·어떤 글을", 댓글은 "누가·어떤 글에·무슨 내용을", 팔로우는 "누가" 만 담죠. 그래도 셋 다 implements Notification 으로 "나는 Notification 의 한 종류"라고 밝혀서, 같은 Notification 타입 변수에 담을 수 있어요(Day 11 에서 배운 다형성이에요).

Java
// com/instagram/javabasic/modern/SealedNotificationDemo.java
Notification n1 = new LikeNotification("jaehoon", "노을 사진");
Notification n2 = new CommentNotification("minji", "노을 사진", "색감 예뻐요!");
Notification n3 = new FollowNotification("dana");

// record 의 자동 toString 덕에 어떤 알림인지 한눈에 보여요.
System.out.println(n1); // LikeNotification[actor=jaehoon, postTitle=노을 사진]
System.out.println(n2); // CommentNotification[actor=minji, postTitle=노을 사진, text=색감 예뻐요!]
System.out.println(n3); // FollowNotification[actor=dana]
🙋 학생 질문 — "튜터님, 추상 클래스로도 됐는데 왜 굳이 sealed 를 쓰나요?"

날카로운 질문이에요! Day 12 의 추상 클래스도 "공통 역할"을 묶는 데는 충분했죠.

차이는 "종류가 닫혀 있느냐"예요. 추상 클래스는 자식이 얼마든 늘어날 수 있어요. 반면 sealedpermits 에 적은 종류만 허용하고, 그 밖의 종류는 아예 막아요. 그래서 자바가 "이 인터페이스의 종류는 정확히 이 셋"이라고 확신할 수 있어요.

이 확신이 다음 단계에서 큰 힘을 발휘해요. switch 로 종류별 처리를 할 때, 자바가 "세 종류 다 처리했네"를 검사해줄 수 있거든요. 빠뜨린 종류가 있으면 컴파일 단계에서 미리 알려줘요. 추상 클래스로는 종류가 열려 있어서 이런 검사를 못 해요. 바로 다음 단계에서 확인해봐요!

"정해진 종류만" 봉인했으니, 이제 그 종류별로 다르게 처리하는 일이 남았어요. recordswitch 가 만나는 가장 멋진 단계로 가볼게요.


Step 7: sealed + record + switch 패턴 매칭의 조합

이번 단계가 오늘의 하이라이트예요. sealed 로 봉인한 알림들을 switch 로 종류별로 갈라서, 사람이 읽을 알림 문장으로 바꿔볼 거예요.

Day 15 에서 switch 를 배웠고, Day 11 에서 instanceof 로 "이 객체가 어떤 타입인지" 확인하는 패턴 변수를 배웠죠. 이 둘이 합쳐진 게 switch 패턴 매칭이에요. switch 가 값뿐 아니라 타입으로도 갈래를 나눌 수 있어요.

Java
// com/instagram/javabasic/modern/SealedRecordSwitchDemo.java
public static String toMessage(Notification n) {
    return switch (n) {
        // record 분해 — 괄호로 안의 값을 바로 꺼내 변수로 받아요.
        case LikeNotification(String actor, String title) ->
                actor + "님이 '" + title + "' 글을 좋아해요.";
        case CommentNotification(String actor, String title, String text) ->
                actor + "님이 '" + title + "' 에 댓글: " + text;
        case FollowNotification(String actor) ->
                actor + "님이 회원님을 팔로우하기 시작했어요.";
    };
}

두 가지 새로운 게 한꺼번에 나왔어요.

  • 타입으로 갈래 나누기case LikeNotification ... 처럼, 값이 어떤 종류인지에 따라 갈래가 정해져요.
  • record 분해LikeNotification(String actor, String title) 처럼 괄호를 쓰면, 그 record 안의 값을 바로 꺼내서 actor·title 변수로 받아요. 따로 n.actor() 를 부를 필요 없이 한 번에 풀어내는 거예요.

여기서 가장 멋진 점. default: 가 없어요. 보통 switch 에는 "나머지 경우"를 위한 default 를 넣잖아요. 그런데 Notificationsealed 라서 종류가 딱 셋으로 닫혀 있으니, 자바가 "세 종류를 전부 처리했다"는 걸 확인할 수 있어요. 그래서 default 가 필요 없어요.

텍스트
 switch (n)  —  Notification 은 sealed (종류가 딱 셋)
   ├─ case LikeNotification    → 처리 ✅
   ├─ case CommentNotification → 처리 ✅
   └─ case FollowNotification  → 처리 ✅
        ↑ 세 종류 전부 처리됨  →  default 불필요
          만약 새 종류가 늘면? 컴파일러가 "이거 빠졌어!" 라고 미리 알려줘요

이게 왜 강력하냐면, 미래의 안전장치가 되거든요. 나중에 알림 종류가 하나 더 늘어서 permits 에 추가됐다고 해봐요. 그러면 이 switch 는 "새 종류를 처리 안 했네!"라며 컴파일 단계에서 빨간 줄로 알려줘요. 프로그램을 실행하기도 전에, 빠뜨린 처리를 잡아주는 거예요. 실행하다가 엉뚱한 결과가 나오는 사고를 미리 막아주는 셈이죠.

실제로 알림함을 처리해볼게요.

Java
List<Notification> inbox = List.of(
        new LikeNotification("jaehoon", "노을 사진"),
        new CommentNotification("minji", "노을 사진", "색감 예뻐요!"),
        new FollowNotification("dana"));

for (Notification n : inbox) {
    System.out.println(toMessage(n));
}
// jaehoon님이 '노을 사진' 글을 좋아해요.
// minji님이 '노을 사진' 에 댓글: 색감 예뻐요!
// dana님이 회원님을 팔로우하기 시작했어요.

알림 객체 하나하나가 종류에 맞는 사람 친화적인 문장으로 바뀌어 나와요. sealed 로 종류를 닫고, record 로 데이터를 담고, switch 패턴 매칭으로 종류별 처리를 하는 — 이 셋의 조합이 모던 자바에서 "여러 종류를 안전하게 다루는" 대표적인 방법이에요.

💡 세 도구가 어떻게 맞물리는지 기억해두세요. sealed종류를 닫고, record데이터를 담고, switch 패턴 매칭은 종류별로 빠짐없이 처리해요. 닫혀 있으니 빠짐없이 처리할 수 있고, 빠뜨리면 컴파일러가 잡아줘요.

이제 오늘 배운 record 를 인스타그램 도메인에 실제로 적용하는 종합 단계로 마무리해볼게요.


Step 8: 종합: 화면에 뿌릴 데이터를 record

마지막은 종합이에요. 지금까지 배운 record 를 인스타그램 도메인에 실제로 써볼게요. 그동안 배운 흐름(Stream) 과 묶어서요.

상황은 이래요. 우리에겐 Member 객체가 있어요. 그런데 Member 는 Day 16 에서 봤듯이 글 목록·팔로잉 목록까지 잔뜩 안고 있는 무거운 객체예요. 화면에 "추천 회원 카드"를 뿌릴 땐 그 모든 게 필요하지 않아요. 이름·등급·팔로워 수 정도면 충분하죠.

그래서 화면용으로 딱 필요한 값만 담는 가벼운 record 를 따로 만들어요.

Java
// com/instagram/javabasic/modern/ProfileCard.java
public record ProfileCard(String username, String grade, int followers) {
}

이제 무거운 Member 목록을, 가벼운 ProfileCard 목록으로 바꿔볼게요. Day 26~27 에서 배운 흐름(streammaptoList) 을 그대로 써요.

Java
// com/instagram/javabasic/modern/RecordDtoComprehensive.java
public static List<ProfileCard> toCards(List<Member> members) {
    return members.stream()
            .map(m -> new ProfileCard(m.getUsername(), m.grade(), m.getFollowers()))
            .toList();
}

흐름을 따라가 보면, 회원 한 명(m) 을 받아서 이름(m.getUsername())·등급(m.grade())·팔로워 수(m.getFollowers()) 만 뽑아 ProfileCard 로 만들고, 그걸 리스트로 모아요. m.grade() 는 Day 16 에서 만든 그 등급 계산 메서드고요.

Java
List<Member> members = List.of(
        new Member("jaehoon", 30000, 500, 10, 730),
        new Member("minji", 8500, 150, 5, 365),
        new Member("dana", 50, 0, 0, 10));

List<ProfileCard> cards = toCards(members);
cards.forEach(System.out::println);
// ProfileCard[username=jaehoon, grade=강력 추천, followers=30000]
// ProfileCard[username=minji, grade=추천, followers=8500]
// ProfileCard[username=dana, grade=관심 낮음, followers=50]

무거운 도메인 객체에서 화면에 필요한 값만 추려, 가볍고 안 바뀌는 record 카드로 옮겨 담았어요. record 의 자동 toString 덕에 어떤 카드인지도 한눈에 보이고요.

💡 이렇게 "화면에 주고받을 데이터만 담는 가벼운 객체"는 실무에서 정말 자주 써요. 다음 과목에서 화면(앱·웹) 과 서버가 데이터를 주고받을 때, 바로 이 record 가 그 데이터를 담는 그릇이 돼요. 오늘 배운 게 그대로 이어진다는 것만 기억해두세요.


마무리

오늘은 "값을 담는 객체"를 짧고 안전하게 만드는 record 와, "정해진 종류만 허용하는" sealed 를 배웠어요.

  • boilerplate 의 불편 — 값만 담는 객체도 생성자·접근자·equals·hashCode·toString 을 손으로 다 적으면 50줄. 깜빡하면 조용히 버그.
  • recordrecord ImageSize(int width, int height) {} 한 줄이면 그 다섯 가지를 자바가 자동 생성. 접근자 이름은 getWidth() 가 아니라 width().
  • 컴팩트 생성자 — 매개변수·대입 없이 검사·정리만. 만드는 순간 걸러서, 한번 만들어지면 항상 올바른 값.
  • record 의 제약 — 값이 안 바뀌고(불변), 클래스 상속은 못 하지만 인터페이스 구현은 가능. 불변이라 누구에게 건네도 안전.
  • sealedpermits 에 적은 종류만 구현 허용. "종류를 닫는" 봉인.
  • sealed + record + switch — 종류를 닫고, 데이터를 담고, switch 패턴 매칭으로 빠짐없이 처리. default 없이도 컴파일러가 빠진 종류를 잡아줘요.

Day 16 에서 Member 를 만들 때 길게 적었던 생성자·equals·hashCode·toString 을, 이제 record 한 줄로 줄일 수 있게 됐어요. "값을 담는 객체"를 만들 때 손이 훨씬 가벼워진 거예요.

다음 시간엔 — 모던 자바 문법을 한자리에

오늘 switch 패턴 매칭을 처음 맛봤죠. 종류별로 갈래를 나누는 그 강력함을 봤어요. 다음 시간엔 거기에 조건을 더 얹는 가드 패턴(when) 을 배워서, "좋아요가 100개 넘는 알림만 따로 처리" 같은 세밀한 갈래까지 나눠볼 거예요.

거기에 더해, 긴 글을 따옴표 걱정 없이 적는 텍스트 블록("""), 안 쓰는 변수를 _ 한 글자로 비우는 문법까지 — 그동안 배운 모던 자바의 마지막 조각들을 한자리에 모아 정리해요. 오늘 배운 recordswitch 패턴 매칭이 다음 시간 주인공들의 단짝이 되어줄 거예요. 정말 수고 많으셨어요!


과제

오늘 배운 recordsealed 를 직접 써보며 익히는 과제예요. 모두 우리 인스타그램 도메인 위에서 풀어봐요. modern 패키지의 오늘 예제들을 옆에 두고 비교하면서 작성하면 편해요.

과제 1: 해시태그 중복 없이 모으기 — record 자동 equals 활용

상황 배경: 게시물에서 뽑아낸 해시태그 이름이 목록(List<String>) 으로 들어와요. 그런데 같은 태그가 여러 번 들어 있을 수 있어요("여행" 이 세 번 나오는 식으로요). 이걸 중복 없이 모으려 해요. Day 19 에서 HashSet 이 중복을 걸러주려면 equalshashCode 가 필요하다고 배웠죠. 오늘은 그걸 record 가 공짜로 만들어준다는 걸 활용해봐요.

🎯 해결 미션:

  1. 해시태그 이름 하나를 담는 Hashtag 라는 record 를 만드세요. 담을 값은 name(String) 하나예요.
  2. distinctTags(List<String> names) 메서드를 만들어, 이름 목록을 Hashtag 로 감싸 중복 없이 모은 Set<Hashtag> 을 돌려주세요. (HashSet 또는 LinkedHashSet 에 하나씩 담아보세요. record 의 자동 equals·hashCode 덕에, 같은 이름은 한 번만 들어갈 거예요.)

["여행", "맛집", "여행", "일상", "맛집"] 을 넣으면 종류는 3개(여행·맛집·일상) 가 나와야 해요.

과제 2: 좋아요 수 검증하기 — 컴팩트 생성자

상황 배경: 좋아요 수를 담는 값 객체를 만들려 해요. 그런데 좋아요 수는 음수가 될 수 없죠. 잘못된 값(-1 같은) 이 들어오면 객체를 만드는 순간 막고 싶어요. Step 4 의 컴팩트 생성자를 떠올려보세요.

🎯 해결 미션: 좋아요 수를 담는 LikeCount 라는 record 를 만드세요. 담을 값은 value(int) 하나예요.

  • 컴팩트 생성자에서 value 가 음수면 IllegalArgumentException 을 던지세요. (Day 21 에서 배운 그 기본 예외예요. "잘못된 인자가 들어왔다"는 뜻이죠.)
  • 0 이상은 정상으로 통과시키세요.

new LikeCount(150) 은 잘 만들어지고, new LikeCount(-1) 은 그 자리에서 예외가 터져야 해요.

과제 3: 활동 알림 문장 만들기 — sealed + record + switch 종합

상황 배경: 인스타그램 피드에서 일어나는 사건(이벤트) 종류는 정해져 있어요. "스토리 조회", "언급(멘션)", "사진 태그", 이렇게 셋이라고 해봐요. 이 사건들을 sealed 로 봉인하고, 종류별로 사람이 읽을 알림 문장을 만들어봐요. Step 6~7 의 Notification 예제를 그대로 따라가면 돼요.

🎯 해결 미션:

  1. FeedEvent 라는 sealed interface 를 만들고, 세 종류만 permits 로 허용하세요.
    • StoryViewed(String viewer) — 누가 내 스토리를 봤는지
    • Mentioned(String actor, String postTitle) — 누가 어떤 글에서 나를 언급했는지
    • Tagged(String actor, String photoTitle) — 누가 어떤 사진에 나를 태그했는지
    • 세 종류 모두 record 로, implements FeedEvent 로 만드세요.
  2. describe(FeedEvent event) 메서드를 만들어, switch 패턴 매칭으로 종류별 알림 문장을 돌려주세요. record 분해를 써서 안의 값을 바로 꺼내보세요. sealeddefault 는 필요 없어요.

예를 들어 new StoryViewed("dana")"dana님이 회원님의 스토리를 봤어요." 처럼 나오면 돼요.


생각해볼 주제

혼자 고민해도 좋고, 동료와 토론해도 좋아요. 정답을 외우기보다, "나라면 어떻게 설명할까"를 떠올리며 읽어보세요.

1. record 는 언제 쓰고, 일반 클래스는 언제 쓸까?

오늘 record 가 정말 편하다는 걸 봤어요. 그러면 앞으로 모든 클래스를 record 로 만들면 될까요? 그런데 record 는 값이 안 바뀌고(불변), 다른 클래스를 상속할 수 없다는 제약이 있었죠. 예를 들어 Day 16 의 Member 는 좋아요를 누르면 팔로워 수가 바뀌고, 글을 추가하면 목록이 늘어나요.

이렇게 "상태가 변하는" 객체를 record 로 만들면 어떤 점이 곤란할지 떠올려보세요. "값을 담기만 하는가" vs "상태가 변하고 행동을 하는가"를 기준으로, 둘을 어떻게 나눌지 정리해보는 주제예요.

2. sealed 가 있으면 그냥 enum 이나 추상 클래스로 충분하지 않을까?

Day 15 에서 enum 으로 "정해진 몇 가지"를 표현했고, Day 12 에서 추상 클래스로 "공통 역할"을 묶었어요. 오늘 배운 sealed 도 "정해진 종류"를 다루죠. 셋이 비슷해 보이는데, 무엇이 다를까요?

enum 은 "고정된 상수 몇 개"를 다루고, sealed 의 종류들은 저마다 다른 데이터(좋아요는 글 제목, 댓글은 글 제목 + 내용)를 담을 수 있다는 점을 떠올려보세요. "종류마다 담는 정보가 다른가, 같은가"를 기준으로 셋의 쓰임을 구분해보는 주제예요.

3. record 를 무조건 쓰면 다 좋을까?

record 가 짧고 안전하다는 걸 배웠어요. 그렇다면 데이터를 담는 모든 곳에 record 를 쓰면 항상 이득일까요? 그런데 record 는 값이 한번 정해지면 못 바꿔요. 만약 "회원이 좋아요를 누를 때마다 팔로워 수가 1씩 늘어야" 한다면, 불변인 record 로는 그 값을 직접 못 고치니 매번 새 객체를 만들어야 해요.

이게 어떤 상황에서 오히려 번거로움이 될지, 반대로 "안 바뀌는 게 더 안전한" 상황은 어떤 자리일지 — record 가 어울리는 곳과 안 어울리는 곳의 경계를 한번 그어보세요.

✅ 예시 답안정답 보기

오늘 과제는 세 가지 결이 이어져요. 과제 1·2 는 record 한 줄과 컴팩트 생성자로 "값 객체"를 짧고 안전하게 만드는 연습이고, 과제 3 은 sealed + record + switch 패턴 매칭을 한 번에 묶는 종합이에요. modern 패키지의 오늘 예제(RecordIntroDemo·CompactConstructorDemo·SealedRecordSwitchDemo)를 옆에 두고 비교하면서 보면 편해요.

과제 예시답안

과제 1 예시답안 — 해시태그 중복 없이 모으기

핵심 접근

"중복 없이 모으기"는 Day 19 의 HashSet 이 딱이에요. 그런데 HashSet 이 중복을 걸러주려면 equalshashCode 가 필요했죠. 직접 짜면 번거롭지만, 오늘은 record 가 그 둘을 공짜로 만들어줘요.

그래서 태그 이름을 Hashtag record 로 감싸기만 하면, "이름이 같으면 같은 태그"가 저절로 성립해서 중복이 걸러져요. 우리가 할 일은 이름 하나하나를 Hashtag 로 감싸 Set 에 담는 것뿐이에요.

예시 구현

Java
// com/instagram/javabasic/modern/solution/day29/Hashtag.java
public record Hashtag(String name) {
}
Java
// com/instagram/javabasic/modern/solution/day29/HashtagCollector.java
public static Set<Hashtag> distinctTags(List<String> names) {
    Set<Hashtag> tags = new LinkedHashSet<>();
    for (String name : names) {
        tags.add(new Hashtag(name));
    }
    return tags;
}

Hashtagrecord 한 줄이라, 이름이 같으면 같은 객체로 보는 equals·hashCode 가 자동으로 생겨요. distinctTags 는 이름 목록을 돌며 하나씩 Hashtag 로 감싸 Set 에 넣어요.

["여행", "맛집", "여행", "일상", "맛집"] 을 넣으면, 여행맛집 은 두 번째부터 "이미 있는 값"이라 안 들어가서 종류는 3개가 돼요. LinkedHashSet 을 쓰면 처음 등장한 순서(여행맛집일상)까지 그대로 지켜져요.

채점 포인트

확인할 점 왜 중요한가
Hashtagrecord 로 만들었는가 equals·hashCode 를 자동으로 얻어, 중복 제거가 공짜로 동작해요
이름을 new Hashtag(name) 으로 감쌌는가 String 그대로면 굳이 record 를 만든 의미가 없어요. 감싸야 자동 equals 가 일해요
Set 에 담아 중복을 걸렀는가 중복 제거는 Set 의 본업이에요. List 면 같은 태그가 그대로 쌓여요
결과 종류가 3개인가 여행·맛집 의 중복이 걸러졌는지 확인하는 핵심 결과예요

흔한 실수

  • Hashtagrecord 가 아니라 일반 클래스로 만들면서 equals·hashCode 를 빠뜨려요. 그러면 같은 이름도 다른 객체로 취급돼서 중복이 안 걸러져요. record 면 이 걱정이 사라져요.
  • 이름(String) 을 감싸지 않고 그대로 Set<String> 에 담으면 과제의 의도(record 활용) 를 비켜가요. 같은 결과처럼 보여도, 오늘 배운 자동 equals 를 쓰는 게 핵심이에요.
  • record 접근자를 getName() 으로 부르려다 막혀요. record 의 접근자는 name() 이에요(이번 과제에선 직접 안 써도 되지만 기억해두세요).

과제 2 예시답안 — 좋아요 수 검증하기

핵심 접근

"음수는 안 된다"는 규칙은 값을 받는 그 순간에 거르는 게 가장 깔끔해요. 컴팩트 생성자가 바로 그 자리예요. record 를 만들 때 자바가 값을 필드에 넣어주기 직전에, 우리가 "이 값이 올바른가"를 검사할 수 있거든요. 음수면 예외를 던져 만들기를 중단하고, 0 이상이면 그대로 통과시켜요. 이렇게 하면 한 번 만들어진 LikeCount 는 "항상 0 이상"이 보장돼요.

예시 구현

Java
// com/instagram/javabasic/modern/solution/day29/LikeCount.java
public record LikeCount(int value) {

    public LikeCount {
        if (value < 0) {
            throw new IllegalArgumentException("좋아요 수는 음수가 될 수 없어요: " + value);
        }
    }
}

컴팩트 생성자(public LikeCount {) 안에서 value 가 음수인지 검사해요. 음수면 IllegalArgumentException 을 던져서 객체가 아예 만들어지지 않아요. 0 이상이면 검사를 통과하고, 자바가 value 를 알아서 필드에 넣어줘요. 그래서 new LikeCount(150) 은 잘 만들어지고, new LikeCount(-1) 은 만드는 그 순간 예외가 터져요.

채점 포인트

확인할 점 왜 중요한가
컴팩트 생성자(public LikeCount {) 를 썼는가 매개변수·대입 없이 검사만 적는 게 컴팩트 생성자의 핵심이에요
음수일 때 예외를 던졌는가 잘못된 값이 객체로 만들어지는 걸 만드는 순간 막아야 해요
IllegalArgumentException 을 던졌는가 "잘못된 인자가 들어왔다"는 뜻에 딱 맞는 기본 예외예요 (Day 21)
0 이상을 정상 통과시키는가 0 도 올바른 좋아요 수라, 막으면 안 돼요. 조건이 < 0 이라야 정확해요

흔한 실수

  • 컴팩트 생성자 안에서 this.value = value; 를 적으려다 오류가 나요. 컴팩트 생성자는 대입을 자바가 알아서 해주니, 검사만 적으면 돼요.
  • 조건을 value <= 0 으로 적으면 0 까지 막혀버려요. 좋아요가 하나도 없는 0 은 정상이라, 조건은 value < 0 이라야 해요.
  • 검증을 record 밖(쓰는 쪽) 에서 하려다 곳곳에 if 가 번져요. 검증을 컴팩트 생성자 한 곳에 모으면, 한 번 만든 값은 어디서든 믿고 쓸 수 있어요.

과제 3 예시답안 — 활동 알림 문장 만들기

핵심 접근

이 과제는 오늘 배운 세 도구를 한 번에 묶어요. 먼저 피드 사건을 sealed 로 봉인해서 "딱 세 종류"로 닫고, 각 종류를 record 로 만들어 담는 데이터를 표현해요. 그다음 switch 패턴 매칭으로 종류별 문장을 만드는데, sealed 라 종류가 닫혀 있으니 default 없이도 빠짐없이 처리돼요. Step 6~7 의 Notification 예제와 구조가 똑같아서, 그 흐름을 그대로 따라가면 돼요.

예시 구현

먼저 사건을 sealed 로 봉인하고, 세 종류만 허용해요.

Java
// com/instagram/javabasic/modern/solution/day29/FeedEvent.java
public sealed interface FeedEvent
        permits StoryViewed, Mentioned, Tagged {
}

세 종류는 각각 담는 정보가 다르니, record 로 만들어요.

Java
// com/instagram/javabasic/modern/solution/day29/StoryViewed.java
public record StoryViewed(String viewer) implements FeedEvent {
}
Java
// com/instagram/javabasic/modern/solution/day29/Mentioned.java
public record Mentioned(String actor, String postTitle) implements FeedEvent {
}
Java
// com/instagram/javabasic/modern/solution/day29/Tagged.java
public record Tagged(String actor, String photoTitle) implements FeedEvent {
}

이제 switch 패턴 매칭으로 종류별 문장을 만들어요.

Java
// com/instagram/javabasic/modern/solution/day29/FeedEventMessages.java
public static String describe(FeedEvent event) {
    return switch (event) {
        case StoryViewed(String viewer) ->
                viewer + "님이 회원님의 스토리를 봤어요.";
        case Mentioned(String actor, String title) ->
                actor + "님이 '" + title + "' 에서 회원님을 언급했어요.";
        case Tagged(String actor, String photo) ->
                actor + "님이 '" + photo + "' 사진에 회원님을 태그했어요.";
    };
}

switch 가 사건의 종류에 따라 갈래를 나누고, record 분해(StoryViewed(String viewer)) 로 안의 값을 바로 꺼내 문장에 넣어요. FeedEventsealed 라 세 종류가 전부라서 default 가 필요 없고요. new StoryViewed("dana") 를 넣으면 "dana님이 회원님의 스토리를 봤어요." 가 나와요.

채점 포인트

확인할 점 왜 중요한가
FeedEventsealed interface 로 만들고 permits 로 셋만 허용했는가 "종류를 닫는" 게 sealed 의 핵심이에요. 닫혀야 switch 가 빠짐없음을 보장해요
세 종류를 record 로, implements FeedEvent 로 만들었는가 종류마다 담는 데이터가 달라서 record 가 딱이고, 같은 역할이라 인터페이스를 구현해요
switch 에서 record 분해를 썼는가 Mentioned(String actor, String title) 처럼 괄호로 값을 바로 꺼내는 게 패턴 매칭의 묘미예요
default 없이 세 종류를 모두 처리했는가 sealed 라 컴파일러가 빠짐없음을 검사해줘서, default 가 오히려 불필요해요

흔한 실수

  • FeedEventsealed 를 빼면, switch 에서 default 를 요구받아요. 종류를 닫지 않으면 자바가 "다른 종류가 있을지도 모른다"고 보거든요. sealed 가 있어야 default 를 뺄 수 있어요.
  • permits 에 적은 이름과 실제 record 이름이 안 맞으면 컴파일이 안 돼요. permits StoryViewed, Mentioned, Tagged 와 세 record 이름이 정확히 같아야 해요.
  • switch 에서 n.viewer() 처럼 일일이 접근자를 부르려다 길어져요. record 분해를 쓰면 case StoryViewed(String viewer) 한 번에 꺼내져서 훨씬 깔끔해요.

생각해볼 주제 예시답안

생각해볼 주제 1 예시답안 — record 는 언제 쓰고, 일반 클래스는 언제 쓸까?

[문제 상황 요약]

record 가 짧고 편하니 "모든 클래스를 record 로" 만들고 싶은 마음이 들어요. 그런데 record 는 값이 안 바뀌고(불변), 다른 클래스를 상속할 수 없죠. Day 16 의 Member 는 좋아요를 받으면 팔로워 수가 바뀌고 글이 늘어나요. 이런 객체를 record 로 만들면 곤란해져요. 둘을 어떤 기준으로 나눠야 할까요?

[튜터의 가이드 및 해설]

기준은 한 문장으로 정리돼요. "값을 담기만 하는가" vs "상태가 변하고 행동을 하는가."

record 가 어울리는 곳은 "한번 정해지면 그대로인 데이터 묶음"이에요. 오늘 만든 ImageSize(사진 크기)·ProfileCard(화면 카드)·Hashtag(태그) 처럼, 값을 담아 전달하는 게 본업인 객체죠. 이런 건 안 바뀌는 게 오히려 안전하고, equals·hashCode·toString 이 자동이라 손도 덜 가요.

반대로 일반 클래스가 어울리는 곳은 "상태가 변하거나 행동이 있는" 객체예요. Memberfollow() 로 다른 회원을 팔로우하고, 좋아요를 받으면 숫자가 바뀌고, calculateRecommendScore() 처럼 자기 데이터로 계산까지 해요. 이렇게 "살아 움직이는" 객체는 값이 바뀔 수 있어야 하니, 불변인 record 와는 안 맞아요.

쉽게 떠올리는 법은 이래요. "이 객체가 만들어진 뒤에 내용이 바뀔 일이 있나?"를 물어보세요. 안 바뀐다면 record, 바뀐다면 일반 클래스예요. 화면에 주고받는 데이터·설정값·좌표 같은 건 record, 도메인의 중심에서 상태를 들고 변하는 Member·Post 같은 건 일반 클래스로요.

🎯 면접관을 홀리는 핵심 멘트

"저는 record 와 일반 클래스를 '값이냐 상태냐'로 나눕니다. 만들어진 뒤 내용이 바뀌지 않고 데이터를 담아 전달하는 게 본업이면 record 를 써서 불변성과 자동 equals·hashCode 를 얻고, 좋아요 수가 변하거나 행동을 가진 도메인 객체는 일반 클래스로 둡니다. record 의 불변 제약은 단점이 아니라, '이건 안 바뀌는 데이터'라는 의도를 타입으로 드러내는 장치라고 봅니다."


생각해볼 주제 2 예시답안 — sealed 가 있으면 enum 이나 추상 클래스로 충분하지 않을까?

[문제 상황 요약]

Day 15 의 enum 도 "정해진 몇 가지"를 표현했고, Day 12 의 추상 클래스도 "공통 역할"을 묶었어요. 오늘 배운 sealed 도 "정해진 종류"를 다루죠. 셋이 비슷해 보이는데, 무엇이 다르고 언제 어느 걸 써야 할까요?

[튜터의 가이드 및 해설]

핵심 차이는 "종류마다 담는 정보가 같은가, 다른가" 그리고 "종류가 닫혀 있는가" 두 가지예요.

enum 은 "고정된 상수 몇 개"예요. PostStatus 의 공개·비공개·보관됨처럼, 종류가 정해져 있고 저마다 따로 데이터를 담지 않아요(있어도 똑같은 모양의 필드죠). "이 중 하나"를 고르는 자리에 딱이에요.

sealed 의 종류들은 저마다 다른 데이터를 담을 수 있어요. 오늘 Notification 을 보면 좋아요는 "글 제목", 댓글은 "글 제목 + 내용", 팔로우는 "행위자"만 담았죠. 종류마다 모양이 다른 거예요. 이건 enum 으로는 표현이 어색해요. 그래서 "종류는 닫혀 있되, 종류마다 담는 정보가 다른" 경우에 sealed + record 가 빛나요.

추상 클래스는 "공통 역할"을 묶지만 종류가 열려 있어요. 자식이 얼마든 늘 수 있죠. 그래서 switch 로 처리할 때 자바가 "다른 종류가 더 있을지도 모른다"고 봐서 빠짐없음을 보장 못 해요. sealed 는 종류를 닫아서, 컴파일러가 "이게 전부"라고 확신하고 빠진 처리를 잡아줘요.

정리하면, "하나를 고르는 고정 상수"는 enum, "종류마다 데이터가 다르고 닫아두고 싶은 계층"은 sealed + record, "종류가 계속 열려 늘어나는 공통 역할"은 추상 클래스나 인터페이스예요.

🎯 면접관을 홀리는 핵심 멘트

"enum·sealed·추상 클래스는 '종류마다 데이터가 다른가'와 '종류가 닫혔는가'로 구분합니다. 단순히 하나를 고르는 고정 상수면 enum, 종류마다 담는 데이터가 다르고 집합을 닫아두고 싶으면 sealed + record, 종류가 계속 확장되는 열린 계층이면 추상 클래스를 씁니다. sealed 의 진짜 가치는 switch 패턴 매칭과 만났을 때인데, 종류가 닫혀 있어 컴파일러가 누락된 처리를 잡아주는 안전성이 핵심이라고 봅니다."


생각해볼 주제 3 예시답안 — record 를 무조건 쓰면 다 좋을까?

[문제 상황 요약]

record 가 짧고 안전하니 데이터를 담는 모든 곳에 쓰고 싶어요. 그런데 record 는 값이 한번 정해지면 못 바꿔요. "회원이 좋아요를 누를 때마다 팔로워 수가 1씩 늘어야" 한다면, 불변인 record 로는 그 값을 직접 못 고치니 매번 새 객체를 만들어야 해요. 이게 언제 번거롭고, 반대로 언제 불변이 더 안전할까요?

[튜터의 가이드 및 해설]

이건 불변의 트레이드오프를 묻는 주제예요. 불변은 안전하지만, 자주 바뀌는 값에는 번거로움이 될 수 있어요.

먼저 불변이 번거로운 경우. 팔로워 수처럼 시시각각 바뀌는 값을 record 에 담으면, 1을 올릴 때마다 "기존 걸 버리고 새 record 를 만드는" 일을 반복해야 해요. 좋아요가 초당 수백 번 눌리는 인기 게시물이라면, 그때마다 새 객체를 만드는 게 낭비처럼 느껴질 수 있죠. 이렇게 "자주, 조금씩 바뀌는 상태"는 값을 직접 고칠 수 있는 일반 클래스가 더 자연스러워요.

반대로 불변이 더 안전한 경우. 여러 곳에서 같은 객체를 나눠 쓸 때예요. 불변이면 누가 가져가도 내 값이 바뀔 걱정이 없어요. 예를 들어 화면에 뿌릴 ProfileCard 를 여러 군데서 참조해도, 한 곳에서 몰래 값을 바꿔 다른 곳이 망가지는 사고가 안 생겨요. "한번 만든 사진 크기·좌표·설정값"처럼 바뀔 일이 없는 데이터일수록 불변이 마음 편하죠.

그래서 결론은 "무조건"이 아니라 "성격에 맞게"예요. 바뀌지 않고 여러 곳에 안전하게 건네야 하는 데이터는 record, 자주 변하는 상태를 들고 있는 객체는 일반 클래스. record 는 강력한 도구지만, 모든 자리에 맞는 만능은 아니라는 걸 아는 게 진짜 실력이에요.

🎯 면접관을 홀리는 핵심 멘트

"record 의 불변성은 양날의 검입니다. 여러 곳에서 공유하는 데이터라면 누구도 값을 바꿀 수 없어 안전하지만, 팔로워 수처럼 자주 변하는 상태를 담으면 매번 새 객체를 만들어야 해서 비효율적입니다. 그래서 저는 '공유되고 안 바뀌는 데이터'에는 record 를, '자주 변하는 상태'에는 가변 일반 클래스를 씁니다. 도구의 장점이 어떤 맥락에서는 단점이 된다는 걸 이해하고 상황에 맞게 고르는 게 핵심이라고 생각합니다."

전체 목록 자바 기초

더 배우려면

실무 프로젝트까지 가고 싶다면

팀스파르타 백엔드 부트캠프에서 인스타그램 클론을 풀스택으로 완성합니다.