문서 읽는 데 56분 · day14

Day14: 캡슐화와 설계 원칙 — 좋은 클래스를 짜는 기준

전체 21강 중 14강 · 자바 기초
난이도 · 입문

안녕하세요! 여러분의 Java 가이드, 홍순구 튜터입니다.

Day 14에 오신 걸 환영합니다! 지난 시간 마지막에 제가 작은 고민거리를 하나 던져뒀어요. 기억나시나요? "역할(인터페이스)은 얼마나 잘게 쪼개는 게 좋을까?" 였죠. Shareable(공유 가능), Commentable(댓글 가능) 처럼 역할을 나눠두면 콘텐츠마다 필요한 것만 골라 끼울 수 있어서 좋았는데, 그럼 도대체 어디까지 나누는 게 적당한 걸까요? 오늘은 바로 그 "잘 나누기" 를 본격적으로 배워봅니다.

그리고 하나 더 떠올려볼게요. Day 9에서 우리는 캡슐화를 배웠죠. 필드를 private 으로 숨기고, getter/setter 라는 통로로만 드나들게 했어요. 특히 setFollowers 안에 "음수면 0으로" 같은 검증을 넣어서, 외부에서 함부로 이상한 값을 못 넣게 막았던 게 핵심이었어요. 그런데 여기서 한 발 더 들어가 볼게요. "그럼 모든 필드에 getter/setter 를 다 열어두면 될까?" 무조건 다 열어도 괜찮은 걸까요? 오늘은 이 질문을 깊게 파봅니다.

오늘 우리가 갈 길을 미리 그려볼게요.

  • 먼저 getter/setter 를 무분별하게 다 열면 왜 위험한지 짚어요. 캡슐화의 진짜 의미를 다시 잡는 거죠.
  • 그다음 한 클래스에 책임이 너무 많을 때 어떤 일이 벌어지는지 나쁜 예로 보고, 그걸 둘로 쪼개봐요. 이걸 SRP(단일 책임 원칙) 라고 불러요.
  • 이어서 기존 코드를 안 고치고 기능만 더하는 방법을 배워요. 이건 OCP(개방-폐쇄 원칙) 예요.
  • 한 번 만들면 절대 안 바뀌는 불변 객체 도 직접 만들어봐요.
  • private·protected·public 세 가지 접근 제어자 를 언제 어떻게 고를지 정리하고요.
  • 마지막으로 오늘 배운 걸 좋은 클래스 설계 체크리스트 로 묶어 마무리해요.

오늘은 새 문법을 배우기보다, 지금까지 배운 클래스·상속·인터페이스를 "어떻게 하면 잘 짤까" 를 고민하는 시간이에요. 코드를 짜는 건 누구나 하지만, 잘 짜는 건 또 다른 이야기거든요. 자, 그럼 첫 질문부터 풀어볼까요?

🎯 학습 목표

  • getter/setter 를 무분별하게 다 여는 게 왜 위험한지 이해하고, "꼭 필요한 통로만 연다" 는 캡슐화의 의미를 설명할 수 있다
  • 한 클래스가 너무 많은 책임을 떠안았을 때의 문제를 알고, SRP(단일 책임 원칙) 에 따라 클래스를 나눌 수 있다
  • 기존 코드를 고치지 않고 새 기능을 더하는 OCP(개방-폐쇄 원칙) 를 인터페이스로 구현할 수 있다
  • 한 번 만들면 안 바뀌는 불변 객체final 필드로 설계하고, "변경" 을 새 인스턴스 반환으로 표현할 수 있다
  • private·protected·public 세 접근 제어자의 공개 범위를 구분하고, 상황에 맞게 고를 수 있다
  • 좋은 클래스 설계의 판단 기준(체크리스트)을 세우고, 기존 도메인 코드를 그 기준으로 다시 볼 수 있다

Step 1. getter/setter, 다 열어도 될까? — 캡슐화 다시 보기

지난 시간들에서 우리는 객체를 만드는 문법을 충분히 익혔어요. 클래스, 상속, 인터페이스까지요. 그런데 막상 "그래서 좋은 클래스가 뭔데?" 라고 물으면 답이 막막하죠. 오늘 그 답을 하나씩 채워나가는데, 첫 출발은 Day 9에서 배운 캡슐화예요.

다시 떠올려요 — setter 안에 검증을 넣었던 이유

Day 9에서 우리는 필드를 private 으로 숨기고, setFollowers 같은 메서드를 통해서만 값을 바꾸게 했어요. 그때 setFollowers 안에 이런 검증을 넣었던 걸 기억하시나요? 음수가 들어오면 0으로 막는 거였죠.

// (Day 9 에서 배운 모습 — 검증 통로로서의 setter)
public void setFollowers(int followers) {
    if (followers < 0) {
        this.followers = 0;   // 음수는 막아요
        return;
    }
    this.followers = followers;
}

여기서 진짜 중요한 게 뭐였냐면, 필드가 private 이라서 외부에서 직접 member.followers = -100; 같은 짓을 못 했다는 거예요. 무조건 setFollowers 라는 통로를 거쳐야 하니까, 그 통로 안에 검증을 한 번만 넣어두면 객체가 항상 올바른 상태를 지킬 수 있었던 거죠. 이게 캡슐화의 핵심이에요. 데이터를 숨기고, 정해진 통로로만 드나들게 하는 거요.

그런데 통로를 다 열어버리면?

문제는 여기서 생겨요. 많은 분들이 클래스를 만들 때 "일단 모든 필드에 getter/setter 를 다 만들어 두자" 라고 해요. IDE 가 자동으로 만들어 주기도 하고요. 그런데 모든 필드에 setter 를 열면 어떻게 될까요?

[ 모든 setter 를 다 연 클래스 ]          [ 필요한 통로만 연 클래스 ]

   외부 ─→ setUsername()                   외부 ─→ checkPassword()
   외부 ─→ setPassword()  ⚠️                       (검증된 행동만 노출)
   외부 ─→ setFollowers()                          getUsername()
   외부 ─→ setBio()
   외부 ─→ setVerified() ⚠️                  password, verified 같은
                                            민감한 값은 통로 자체가 없음
   누구나 아무 값이나 막 넣음                 → 외부에서 손댈 방법이 없음
   → 객체가 자기 상태를 못 지킴

setPassword 가 열려 있으면, 인증 로직을 거치지 않고도 외부에서 비밀번호를 통째로 바꿀 수 있어요. setVerified(true) 가 열려 있으면, 인증 절차 없이 아무나 자기를 "인증된 계정" 으로 만들 수 있고요. 객체가 스스로 지켜야 할 규칙이 있는데, 통로를 다 열어버리면 그 규칙이 무너지는 거예요.

그래서 오늘의 큰 주제는 이거예요. "필요한 통로만 연다." 외부가 꼭 해야 하는 행동만 열고, 나머지는 숨긴다. 이 한 문장이 오늘 배울 모든 설계 원칙의 뿌리예요.

💡 튜터의 결론

캡슐화는 단순히 getter/setter 를 만드는 게 아니에요. "이 필드를 외부가 정말 바꿔도 되는가?" 를 하나하나 따져서, 꼭 필요한 통로만 여는 거예요.


Step 2. 한 클래스가 너무 많은 일을 한다면 — SRP 위반 사례

자, 첫 번째 설계 원칙을 만나러 가요. 이번엔 "한 클래스가 무슨 일을 하느냐" 를 따져볼 거예요. 나쁜 예부터 보는 게 빠르겠죠. 일부러 욕심을 잔뜩 부린 클래스를 하나 만들어봤어요.

무슨 일을 하는지 한마디로 답할 수 없는 클래스

// com/instagram/javabasic/design/srp/BigMember.java
package com.instagram.javabasic.design.srp;

public class BigMember {

    private String username;
    private String password;   // 비밀번호는 외부에서 직접 못 읽게 숨겨요
    private String bio;
    private int followerCount;

    public BigMember(String username, String password, String bio, int followerCount) {
        this.username = username;
        this.password = password;
        this.bio = bio;
        this.followerCount = followerCount;
    }

    // 책임 1: 계정 인증 — 입력한 비밀번호가 맞는지 확인해요
    public boolean checkPassword(String input) {
        return password.equals(input);
    }

    // 책임 2: 추천 점수 계산 — 팔로워 100명당 1점
    public int calculateScore() {
        return followerCount / 100;
    }

    // 책임 3: 화면 표시 문구 포맷 — 프로필 카드에 보여줄 한 줄을 만들어요
    public String formatProfileCard() {
        return "@" + username + " | " + bio + " | 팔로워 " + followerCount;
    }
}

이 코드를 보고 누가 "이 BigMember 클래스는 무슨 일을 하는 클래스야?" 라고 물으면, 한마디로 답하기가 어려워요. 계정 인증도 하고(checkPassword), 추천 점수도 계산하고(calculateScore), 화면 표시 문구도 만들어요(formatProfileCard). 서로 전혀 다른 세 가지 일이 한 클래스에 다 들어 있는 거예요.

        ┌─────────────────────────────┐
        │         BigMember           │
        │                             │
        │  책임 1: 계정 인증           │  ← 보안팀이 신경 쓰는 일
        │  책임 2: 추천 점수 계산      │  ← 추천 알고리즘 담당이 신경 쓰는 일
        │  책임 3: 화면 표시 문구      │  ← 디자이너/프론트가 신경 쓰는 일
        └─────────────────────────────┘
              세 가지가 한 박스에 뒤엉킴

왜 이게 문제일까요?

이렇게 여러 일이 한곳에 섞여 있으면, 나중에 고칠 때 골치가 아파요. 예를 들어 "비밀번호를 더 안전하게 암호화하자" 는 요청이 들어왔다고 해봐요. 그럼 BigMember 를 열어서 고쳐야 하는데, 이 파일 안에는 점수 계산이랑 화면 표시 로직도 같이 들어 있죠. 비밀번호만 손보려 했는데 점수 계산 코드를 실수로 건드릴 위험이 생기는 거예요.

지난 시간을 떠올려보세요. ImageContentShareable(공유 가능) 과 Commentable(댓글 가능) 두 역할을 나눠 가졌잖아요? 그건 역할을 잘게 나눠서 필요한 것만 가진 거였어요. 그런데 BigMember 는 정반대예요. 나누기는커녕 온갖 책임을 혼자 다 떠안고 있죠.

여기서 첫 번째 설계 원칙이 등장해요. SRP, 풀어쓰면 Single Responsibility Principle, 우리말로 단일 책임 원칙 이에요. 어렵게 들리지만 뜻은 아주 단순해요.

하나의 클래스는 한 가지 책임만 가져야 한다.

"이 클래스는 무슨 일을 해?" 라고 물었을 때 한마디로 답할 수 있어야 좋은 클래스라는 거예요. BigMember 는 이 기준에서 보면 책임을 셋이나 떠안았으니, SRP 를 어긴 셈이죠.

🙋 학생 질문 — "튜터님, 그럼 메서드 하나마다 클래스를 따로 만들어야 하나요?"

그건 너무 극단적이에요. SRP 에서 말하는 "책임" 은 메서드 하나가 아니라, "변경의 이유" 로 생각하면 편해요. 비밀번호 정책이 바뀌는 이유와, 화면 디자인이 바뀌는 이유는 완전히 다르잖아요? 서로 다른 이유로 바뀌는 코드들이 한 클래스에 섞여 있으면 나누는 게 좋고, 같은 이유로 함께 바뀌는 코드들은 한 클래스에 모여 있는 게 좋아요. "메서드 개수" 가 아니라 "바뀌는 이유의 개수" 를 세면 됩니다.


Step 3. 책임을 나눠 갖자 — SRP 실전

문제를 봤으니 고쳐봐요. BigMember 의 세 가지 책임 중에서, "계정 인증" 과 "프로필 표시" 를 각각 다른 클래스로 떼어내 볼게요. (점수 계산은 과제로 남겨둘게요!)

인증은 Account, 프로필은 Profile

먼저 계정 인증만 담당하는 Account 클래스예요.

// com/instagram/javabasic/design/srp/Account.java
package com.instagram.javabasic.design.srp;

public class Account {

    private String username;
    private String password;   // 비밀번호는 숨겨서 외부에서 직접 못 읽어요

    public Account(String username, String password) {
        this.username = username;
        this.password = password;
    }

    // 이 클래스의 단 하나의 책임 — 입력한 비밀번호가 맞는지 확인해요
    public boolean checkPassword(String input) {
        return password.equals(input);
    }

    public String getUsername() {
        return username;
    }
}

이제 Account 한테 "무슨 일을 해?" 라고 물으면 명확하게 답할 수 있어요. "계정 인증을 해." 끝이에요. 비밀번호도 private 으로 숨겨두고, getPassword 같은 통로는 아예 만들지 않았어요. 외부에서 비밀번호를 읽을 일이 없으니까요. checkPassword 로 "맞는지 틀린지" 만 확인할 수 있죠. 이게 Step 1 에서 말한 "필요한 통로만 연다" 예요.

다음은 프로필 표시만 담당하는 Profile 클래스예요.

// com/instagram/javabasic/design/srp/Profile.java
package com.instagram.javabasic.design.srp;

public class Profile {

    private String username;
    private String bio;
    private int followerCount;

    public Profile(String username, String bio, int followerCount) {
        this.username = username;
        this.bio = bio;
        this.followerCount = followerCount;
    }

    // 이 클래스의 단 하나의 책임 — 프로필 카드에 보여줄 한 줄을 만들어요
    public String formatProfileCard() {
        return "@" + username + " | " + bio + " | 팔로워 " + followerCount;
    }

    public int getFollowerCount() {
        return followerCount;
    }
}

Profile 도 답이 명확해요. "프로필 카드 문구를 만들어." 비밀번호 같은 건 아예 안 들고 있어요. 화면에 보여줄 정보만 갖고 있죠.

   Before                          After

 ┌──────────────┐            ┌────────────┐   ┌──────────────┐
 │  BigMember   │            │  Account   │   │   Profile    │
 │ ─ 인증       │   ───→     │ ─ 인증     │   │ ─ 프로필 표시 │
 │ ─ 점수 계산  │            └────────────┘   └──────────────┘
 │ ─ 프로필 표시 │            비밀번호 정책      화면 디자인 바뀌면
 └──────────────┘            바뀌면 여기만       여기만
   다 뒤엉킴                  책임이 또렷하게 나뉨

나누면 뭐가 좋아지나요?

이제 비밀번호 암호화 방식을 바꾸고 싶으면 Account 만 열면 돼요. 화면에 보여줄 문구를 바꾸고 싶으면 Profile 만 열고요. 서로의 코드를 건드릴 일이 없으니, 한쪽을 고치다가 다른 쪽을 망가뜨릴 걱정이 사라져요. 파일은 둘로 늘었지만, 각 파일은 훨씬 단순하고 안전해진 거예요.

💡 튜터의 결론

클래스를 쪼개면 파일 수는 늘어요. 하지만 "비밀번호를 고칠 때 점수 계산 코드를 실수로 건드리는" 사고가 사라져요. SRP 는 "한 곳을 고칠 때 다른 곳이 안 다치게 하는" 안전장치예요.


Step 4. 코드를 깨지 않고 기능 추가하기 — OCP

두 번째 설계 원칙으로 가볼게요. 이번엔 "기능을 추가할 때 기존 코드를 얼마나 건드려야 하느냐" 를 따져봐요. 상황을 하나 그려볼게요.

우리 서비스가 사용자에게 알림을 보내요. 처음엔 이메일푸시 알림 두 가지로 보냈어요. 그런데 어느 날 기획팀이 "문자(SMS)로도 보낼 수 있게 해주세요" 라고 요청해요. 자, 이때 기존 알림 코드를 뜯어고쳐야 할까요?

약속(인터페이스)을 먼저 정한다

지난 시간에 배운 인터페이스를 떠올려보세요. 인터페이스는 "이런 기능이 있어야 한다" 는 약속이었죠. 알림 채널들이 공통으로 지켜야 할 약속을 먼저 인터페이스로 정해요.

// com/instagram/javabasic/design/ocp/NotificationChannel.java
package com.instagram.javabasic.design.ocp;

public interface NotificationChannel {

    // 메시지를 채널 형식으로 보내고, 보낸 결과 문구를 돌려줘요.
    // 본문이 없는 빈칸이에요 — 채우는 건 구현 클래스의 몫이에요.
    String send(String message);
}

"메시지를 보내라(send)" 는 약속 하나뿐이에요. 실제로 어떻게 보낼지는 각 채널이 알아서 채우는 거죠. 이메일 채널과 푸시 채널을 만들어볼게요.

// com/instagram/javabasic/design/ocp/EmailChannel.java
package com.instagram.javabasic.design.ocp;

public class EmailChannel implements NotificationChannel {

    @Override
    public String send(String message) {
        return "[Email] " + message;
    }
}
// com/instagram/javabasic/design/ocp/PushChannel.java
package com.instagram.javabasic.design.ocp;

public class PushChannel implements NotificationChannel {

    @Override
    public String send(String message) {
        return "[Push] " + message;
    }
}

채널들을 부려 쓰는 Notifier

이제 이 채널들에게 실제로 메시지를 뿌려주는 Notifier 를 봐요.

// com/instagram/javabasic/design/ocp/Notifier.java
package com.instagram.javabasic.design.ocp;

public class Notifier {

    // 채널 배열을 받아 각 채널에 send 를 부르고, 결과들을 배열로 모아 돌려줘요.
    public String[] broadcast(NotificationChannel[] channels, String message) {
        String[] results = new String[channels.length];
        for (int i = 0; i < channels.length; i++) {
            results[i] = channels[i].send(message);
        }
        return results;
    }
}

여기서 주목할 점이 있어요. NotifierEmailChannel 이나 PushChannel 이라는 구체적인 이름을 전혀 몰라요. 그냥 NotificationChannel 이라는 약속만 알고, "너 send 할 줄 알지? 그럼 해봐" 하고 시킬 뿐이에요. 지난 시간에 배운 다형성이 여기서 빛을 발하는 거죠.

문자(SMS)를 추가해봐요

자, 이제 기획팀의 요청을 처리할 차례예요. 문자 채널을 추가하는데 — Notifier 를 고칠 필요가 있을까요? 새 파일 하나만 만들면 돼요.

// com/instagram/javabasic/design/ocp/SmsChannel.java
package com.instagram.javabasic.design.ocp;

public class SmsChannel implements NotificationChannel {

    @Override
    public String send(String message) {
        return "[SMS] " + message;
    }
}

SmsChannel.java 라는 새 파일 하나를 추가했을 뿐, Notifier단 한 줄도 바뀌지 않았어요. EmailChannel 도, PushChannel 도 그대로고요. 새 채널을 끼우고 싶으면 그냥 NotificationChannel 약속을 지키는 클래스를 새로 만들어서, broadcast 에 넘기는 배열에 추가하기만 하면 돼요.

   Notifier (고정 — 절대 안 고침)
       │ "send 할 줄 아는 애면 누구든 OK"
       ↓
   NotificationChannel (약속)
       ↑          ↑          ↑
  EmailChannel  PushChannel  SmsChannel  ← 새 채널은 여기에 추가만
                             (NEW! Notifier 무수정)

이게 두 번째 설계 원칙이에요. OCP, 풀어쓰면 Open-Closed Principle, 우리말로 개방-폐쇄 원칙 이에요. 이름이 좀 알쏭달쏭하죠? 이렇게 외워두면 편해요.

확장에는 열려 있고(Open) — 새 채널을 얼마든지 추가할 수 있다. 수정에는 닫혀 있다(Closed) — 기존 코드는 안 건드린다.

새 기능을 더하고 싶을 때 기존 코드를 뜯어고치는 게 아니라, 새 파일을 더하는 것만으로 끝나게 만드는 거예요. 인터페이스라는 "약속" 이 중간에 끼어 있어서, Notifier 가 구체적인 채널을 몰라도 되니까 가능한 일이죠.

💡 튜터의 결론

OCP 의 비결은 "약속(인터페이스)에 의존하기" 예요. 구체적인 이름(EmailChannel) 대신 약속(NotificationChannel) 에 기대면, 새 기능은 더하기만 하면 되고 기존 코드는 건드릴 일이 없어져요.


Step 5. 한 번 만들면 안 바뀌는 객체 — 불변 객체 설계

세 번째 주제는 조금 다른 결의 이야기예요. 지금까지는 "객체의 값을 어떻게 안전하게 바꿀까" 를 고민했는데, 이번엔 아예 "값을 안 바꾸면 어떨까?" 를 생각해봐요.

댓글은 한 번 쓰면 안 바뀐다

댓글을 떠올려보세요. 누가 "안녕하세요!" 라는 댓글을 달았어요. 이 댓글의 작성자와 내용은 한 번 정해지면 바뀌지 않죠. 작성자가 갑자기 다른 사람으로 둔갑하거나, 내용이 슬쩍 바뀌면 이상하잖아요. 이렇게 "한 번 만들어지면 절대 안 바뀌는 객체" 를 불변 객체(immutable object) 라고 불러요. 변하지 않는다(immutable) 는 뜻이에요.

// com/instagram/javabasic/domain/comment/Comment.java
package com.instagram.javabasic.domain.comment;

public class Comment {

    // 모든 필드가 final — 생성자에서 한 번 정해지면 다시는 못 바꿔요
    private final String author;
    private final String text;
    private final int likeCount;

    // 값을 넣는 유일한 통로 — 생성자 하나뿐이에요
    public Comment(String author, String text, int likeCount) {
        this.author = author;
        this.text = text;
        this.likeCount = likeCount;
    }

    // ===== 읽기 전용: getter 만 있고 setter 는 없어요 =====
    public String getAuthor() {
        return author;
    }

    public String getText() {
        return text;
    }

    public int getLikeCount() {
        return likeCount;
    }

    // 불변 객체에서 "값을 바꾸는" 방법 — 자기 자신은 그대로 두고,
    // 좋아요가 1 늘어난 새 Comment 를 만들어 돌려줘요.
    public Comment withLike() {
        return new Comment(author, text, likeCount + 1);
    }

    @Override
    public String toString() {
        return "@" + author + ": " + text + " (좋아요 " + likeCount + ")";
    }

    // equals — 작성자와 내용이 모두 같으면 "같은 댓글" 로 봐요 (좋아요 수는 비교 제외)
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Comment other = (Comment) obj;
        return author != null && author.equals(other.author)
                && text != null && text.equals(other.text);
    }
}

불변 객체의 세 가지 특징

Comment 클래스를 보면 불변 객체의 특징이 또렷이 보여요.

첫째, 모든 필드가 final 이에요. Day 2 에서 final 을 상수 만들 때 배웠죠? "한 번 정하면 다시 대입 못 한다" 는 뜻이었어요. 필드에 final 을 붙이면, 생성자에서 딱 한 번 값을 정한 뒤로는 그 누구도 못 바꿔요.

둘째, setter 가 하나도 없어요. getAuthor·getText·getLikeCount 같은 읽기 전용 통로(getter)만 있죠. 값을 넣는 통로는 오직 생성자 하나뿐이에요. 객체가 태어나는 순간에만 값을 정할 수 있고, 그 뒤로는 읽기만 가능한 거예요.

셋째, "값을 바꾸고 싶으면 새 객체를 만들어요." 여기가 불변 객체의 핵심이에요. 좋아요를 누르면 좋아요 수가 늘어나야 하잖아요? 그런데 likeCountfinal 이라 못 바꿔요. 그럼 어떻게 할까요? withLike() 를 보세요. 자기 자신은 그대로 두고, 좋아요가 1 늘어난 Comment 를 만들어서 돌려줘요.

   원본 Comment                    withLike() 호출 후
 ┌──────────────────┐           ┌──────────────────┐   ┌──────────────────┐
 │ author: "minji"  │           │ author: "minji"  │   │ author: "minji"  │
 │ text:   "좋아요!" │   ───→    │ text:   "좋아요!" │   │ text:   "좋아요!" │
 │ likeCount: 2     │           │ likeCount: 2     │   │ likeCount: 3     │
 └──────────────────┘           └──────────────────┘   └──────────────────┘
                                  원본은 그대로!          새로 만들어 돌려준 것

왜 불변이 안전할까요?

값이 절대 안 바뀐다는 게 왜 좋을까요? 여러 곳에서 같은 객체를 함께 써도 안심할 수 있기 때문이에요. 만약 댓글 객체의 값이 바뀔 수 있다면, 한 화면에서 그 댓글을 쓰는 동안 다른 화면에서 몰래 내용을 바꿔버릴 수도 있겠죠. 그럼 버그를 찾기가 정말 어려워져요. 불변 객체는 "한 번 만들어지면 절대 안 바뀐다" 가 보장되니까, 누가 어디서 쓰든 몰래 바뀔 걱정이 없어요.

🙋 학생 질문 — "튜터님, 좋아요 누를 때마다 새 객체를 만들면 낭비 아닌가요?"

좋은 지적이에요! 맞아요, 불변 객체는 값을 바꿀 때마다 새 객체를 만드니 메모리를 더 쓰는 건 사실이에요. 그래서 여기엔 "안전함" 과 "비용" 사이의 트레이드오프가 있어요. 값이 자주 바뀌고 양이 어마어마하다면 불변이 부담스러울 수 있지만, 대부분의 도메인 객체(댓글, 좌표, 설정값 같은)는 그 정도 비용이 거의 문제가 안 돼요. 오히려 "몰래 바뀌어서 생기는 버그" 를 막아주는 이득이 훨씬 크죠. 이 트레이드오프는 오늘 생각해볼 주제에서 더 깊게 다뤄볼게요.

💡 튜터의 결론

불변 객체는 final 필드 + setter 없음 + "변경은 새 객체로" 라는 세 가지로 만들어요. 안 바뀌어도 되는 값은 불변으로 두면, 몰래 바뀌어서 생기는 버그를 통째로 막을 수 있어요.


Step 6. 어디까지 보여줄까 — private / protected / public

이번엔 Step 1 의 "필요한 통로만 연다" 를 좀 더 정밀하게 다뤄봐요. 자바에는 "이걸 누구한테까지 보여줄까" 를 정하는 키워드가 있어요. 바로 접근 제어자 예요. 우리는 지금까지 private(숨김) 과 public(공개) 두 가지를 주로 써왔는데, 오늘 그 사이에 있는 protected 까지 셋을 나란히 놓고 정리해볼게요.

스토리(Story) 클래스로 세 가지를 다 써보기

인스타 스토리를 떠올려보세요. 스토리는 조회수가 일정 수를 넘으면 만료돼요. 이 동작을 클래스로 만들면서, 멤버마다 공개 범위를 다르게 줘볼게요.

// com/instagram/javabasic/design/access/Story.java
package com.instagram.javabasic.design.access;

public class Story {

    private static final int VIEW_LIMIT = 100;

    // private — 조회수는 내부에서만 관리해요. 외부에서 직접 못 바꿔요.
    private int viewCount = 0;

    // private — "만료됐는가" 판단은 내부 사정이에요. 외부에 보일 필요가 없어요.
    private boolean isExpired() {
        return viewCount >= VIEW_LIMIT;
    }

    // protected — 만료 연장은 운영용 동작이라 가족(같은 패키지·자식)에게만 열어요.
    // 조회수를 0 으로 되돌려 다시 활성 상태로 만들어요.
    protected void extend() {
        viewCount = 0;
    }

    // public — 누구나 부를 수 있는 외부 API. 스토리를 한 번 봤다고 기록해요.
    public void view() {
        if (isExpired()) {
            return;   // 이미 만료됐으면 더 올리지 않아요
        }
        viewCount++;
    }

    // public — 외부에 보여줄 상태 문구를 돌려줘요.
    public String getStatus() {
        String state = isExpired() ? "만료" : "활성";
        return "조회수 " + viewCount + " (" + state + ")";
    }
}

세 가지 공개 범위를 정리해요

이제 세 접근 제어자가 누구한테까지 보이는지 정리해봐요.

접근 제어자 누가 쓸 수 있나 비유
private 이 클래스 안에서만 내 방 (나만 들어감)
protected 같은 패키지 + 자식 클래스 우리 집 (가족만 들어옴)
public 누구나 대문 밖 길거리 (누구나 지나감)

이걸 동심원으로 그리면 안쪽으로 갈수록 공개 범위가 좁아져요.

   ┌─────────────────────────────────────┐
   │ public — 누구나 (view, getStatus)    │  ← 외부에 공개하는 행동
   │   ┌───────────────────────────────┐  │
   │   │ protected — 가족 (extend)       │  │  ← 운영용, 같은 패키지·자식만
   │   │   ┌───────────────────────┐    │  │
   │   │   │ private — 이 클래스만   │    │  │  ← 내부 사정 (viewCount, isExpired)
   │   │   └───────────────────────┘    │  │
   │   └───────────────────────────────┘  │
   └─────────────────────────────────────┘
         바깥 → 안으로 갈수록 공개 범위 좁아짐

Story 클래스를 다시 보면 선택의 이유가 보여요.

  • viewCount(조회수) 와 isExpired()(만료 판단) 는 private 이에요. 조회수를 외부에서 함부로 바꾸거나, 만료 여부를 직접 들여다볼 필요가 없거든요. 이건 순전히 스토리 내부 사정이에요.
  • extend()(만료 연장) 는 protected 예요. 일반 사용자가 부를 일은 없지만, 운영 기능이나 자식 클래스에서는 필요할 수 있어서 가족에게만 열어둔 거죠.
  • view()(보기) 와 getStatus()(상태 보기) 는 public 이에요. 외부에서 "스토리를 봤다" 고 기록하고, "지금 상태가 어때?" 를 물어보는 건 누구나 해야 하는 행동이니까요.

이렇게 꼭 필요한 것(view·getStatus)만 바깥에 열고, 내부 판단(isExpired)과 운영 동작(extend)은 숨기거나 가족에게만 여는 것 — 이걸 정보 은닉(information hiding) 이라고 불러요. 정보를 숨긴다는 뜻이에요. Step 1 에서 말한 "필요한 통로만 연다" 가 바로 이거예요.

⚠️ 주의

protected 는 "같은 패키지 + 자식" 까지 열려요. 자식만 열리는 게 아니라 같은 패키지의 다른 클래스도 쓸 수 있다는 점, 헷갈리기 쉬우니 기억해두세요.

💡 튜터의 결론

멤버를 만들 때마다 "이걸 누가 써야 하지?" 를 물어보세요. 기본은 private 으로 꽁꽁 숨기고, 정말 외부가 써야 하는 것만 public 으로 여는 거예요. 의심스러우면 좁게 시작하는 게 안전해요.


Step 7. 좋은 클래스 설계 체크리스트

오늘 정말 많은 걸 배웠어요. 캡슐화부터 SRP, OCP, 불변 객체, 접근 제어자까지요. 새 문법이 아니라 "어떻게 잘 짤까" 라는 안목을 키운 시간이었죠. 이걸 머릿속에 잘 남기도록 체크리스트로 묶어볼게요. 앞으로 클래스를 만들 때마다 이 질문들을 스스로에게 던져보세요.

좋은 클래스인지 스스로 점검하기

  1. 한 가지 책임만 가졌나? (SRP) — "이 클래스는 무슨 일을 해?" 에 한마디로 답할 수 있나요? 여러 일이 섞여 있으면 나눌 때예요.
  2. 새 기능을 더할 때 기존 코드를 안 고치고 되나? (OCP) — 새 종류를 추가하려는데 기존 코드를 자꾸 뜯어고쳐야 한다면, 약속(인터페이스)을 끼워야 할 신호인지 살펴보세요.
  3. 안 바뀌어도 되는 값은 final 인가? — 한 번 정해지면 안 바뀌는 값이라면 final 을 붙여 불변으로 두는 게 안전해요.
  4. setter 를 꼭 필요한 것만 열었나? — 습관적으로 모든 필드에 setter 를 만들지 않았나요? 외부가 정말 바꿔야 하는 것만 여세요.
  5. 내부용은 private 으로 숨겼나? — 외부가 알 필요 없는 필드와 메서드는 private 으로 숨겼나요? 기본은 좁게, 필요할 때만 넓게.
  6. 검증이 필요한 값은 통로 안에서 막고 있나? — Day 9 처럼, 이상한 값이 들어오는 걸 setter 나 생성자 안에서 검증하고 있나요?

우리 도메인을 이 기준으로 다시 보기

지난 시간들에 만든 도메인 코드를 이 기준으로 한번 돌아봐요. 오늘 만든 Comment 는 모든 필드가 final 이고 setter 가 없는 깔끔한 불변 객체였죠(체크리스트 3·4번 통과). Member 는 비밀번호를 private 으로 숨기고 검증된 setter 만 열어뒀고요(5·6번 통과). 만약 어떤 클래스가 인증도 하고 화면 표시도 하고 점수 계산도 한다면, 그건 BigMember 처럼 SRP 를 어긴 거니 나눌 신호예요(1번). 이렇게 체크리스트를 손에 들고 코드를 보면, "어디가 위험한지" 가 눈에 들어오기 시작해요.

다음 시간 예고

오늘 우리는 클래스를 "잘 짜는" 기준을 세웠어요. 그런데 코드를 짜다 보면 자꾸 마주치는 게 하나 있어요. "정해진 선택지" 예요. 게시물의 상태는 공개/비공개 둘 중 하나죠. 회원 등급은 일반/관리자/프리미엄 셋 중 하나고요. 이런 "정해진 몇 가지 중 하나" 를 그냥 문자열("공개")이나 숫자(1)로 다루면 오타도 나고 실수도 생겨요. 다음 시간엔 이런 정해진 선택지를 깔끔하고 안전하게 관리하는 문법, Enum(열거형) 을 배워요.

그리고 하나 더요. 오늘 코드에서 @Override 라는 @ 기호 붙은 걸 또 봤죠? 사실 우리는 @Override 를 계속 써오면서도 "이 @ 가 정확히 뭔지" 는 깊게 안 파봤어요. 다음 시간엔 이 @ 기호의 정체, 어노테이션(annotation) 도 함께 짚어볼게요.

오늘 여러분은 단순히 "돌아가는 코드" 를 넘어, "고치기 쉽고 안전한 코드" 를 보는 눈을 얻으셨어요. 이건 주니어와 시니어를 가르는 진짜 실력이에요. 정말 수고하셨습니다!


과제

오늘 배운 설계 원칙을 직접 손으로 적용해봐야 진짜 내 것이 돼요. 세 가지 과제를 준비했어요. 모두 오늘까지 배운 문법(클래스·상속·인터페이스·final·접근 제어자)만으로 충분히 풀 수 있어요.

[기본] 과제 1 — SRP 로 책임 나누기

해야 할 일

데이터 관리와 화면 표시를 한 클래스에서 다 하던 게시물 클래스를, 책임에 따라 두 클래스로 나눠보세요.

상황

아래처럼 한 클래스가 게시물 데이터도 들고 있고, 화면에 보여줄 문구도 만들고 있어요.

// 책임이 섞인 나쁜 예 (이걸 둘로 나눠보세요)
public class BigPost {
    private String writer;
    private String content;
    private int likeCount;

    // 책임 1: 데이터 보관 (getter)
    // 책임 2: 화면 표시 문구 만들기 (formatCard)
}

요구사항

  • 데이터 보관만 담당하는 PostData 클래스를 만드세요 (필드 + 생성자 + 필요한 getter).
  • 화면 표시 문구만 담당하는 PostCard 클래스를 만드세요 (formatCard() 메서드).
  • 두 클래스 각각에 "무슨 일을 하는지" 한마디로 답할 수 있어야 해요.

힌트

  • Step 3 의 Account / Profile 가 정확히 이 패턴이에요. 그대로 따라 해보세요.
  • 비밀번호처럼 외부에서 읽을 필요 없는 값은 getter 를 만들지 마세요.

[응용] 과제 2 — OCP 로 새 기능 끼우기

해야 할 일

Step 4 의 알림 시스템에 새 채널을 하나 추가하고, Notifier 가 정말 안 바뀌는지 확인해보세요.

요구사항

  • NotificationChannel 약속을 지키는 새 채널 KakaoChannel 을 만드세요 (send"[Kakao] " + message 를 돌려주도록).
  • main 메서드에서 EmailChannel·PushChannel·KakaoChannel 을 배열에 담아 Notifier.broadcast() 로 한꺼번에 보내보세요.
  • Notifier·EmailChannel·PushChannel 중 단 한 글자도 안 고쳤는지 확인하세요.

힌트

  • 새 채널은 SmsChannel 을 거의 그대로 베껴서 만들면 돼요. implements NotificationChannel@Override 를 잊지 마세요.
  • NotificationChannel[] channels = { new EmailChannel(), ... }; 처럼 배열에 여러 채널을 담을 수 있어요.

[심화] 과제 3 — 불변 객체 설계하기

해야 할 일

사진에 다는 "태그(Tag)" 를 불변 객체로 만들어보세요. 태그의 이름과 사용 횟수를 갖되, 사용 횟수가 올라가도 원본은 안 바뀌게요.

요구사항

  • Tag 클래스를 만드세요. 필드는 name(태그 이름)과 useCount(사용 횟수)이고, 둘 다 final 이어야 해요.
  • 값을 넣는 통로는 생성자 하나뿐이어야 하고, setter 는 만들지 마세요.
  • 사용 횟수를 1 늘린 새 Tag 를 돌려주는 withUse() 메서드를 만드세요. 원본은 그대로 둬야 해요.
  • main 에서 withUse() 를 호출한 뒤, 원본의 useCount 는 그대로이고 새 객체만 1 늘었는지 출력해서 확인하세요.

힌트

  • Step 5 의 Comment.withLike() 가 정확히 이 패턴이에요. return new Tag(name, useCount + 1); 식으로요.
  • final 필드는 생성자 안에서만 값을 정할 수 있다는 걸 기억하세요.

생각해볼 주제

정답이 하나로 정해진 문제가 아니에요. 혼자 고민해보거나, 동료와 이야기 나눠보면 설계를 보는 눈이 한 뼘 더 자라요.

주제 1 — getter/setter 를 다 여는 게 왜 위험할까?

많은 입문 자료에서 "클래스를 만들면 모든 필드에 getter/setter 를 만들어라" 라고 가르쳐요. IDE 도 버튼 한 번으로 다 만들어주고요. 그런데 오늘 우리는 "필요한 통로만 열어라" 라고 배웠죠. "데이터를 다 열어둔 클래스" 와 "통로만 연 클래스" 는 실제로 무엇이 어떻게 달라질까요? 모든 setter 를 다 열어두면 어떤 사고가 생길 수 있을지, 비밀번호나 인증 상태 같은 예를 떠올리며 생각해보세요.

주제 2 — 불변 객체는 매번 새 객체를 만드는데, 낭비 아닐까?

불변 객체는 값을 바꿀 때마다 새 객체를 만들어요. 좋아요를 누를 때마다 새 Comment 가 생기는 거죠. 언뜻 보면 메모리를 낭비하는 것 같아요. 그런데도 왜 많은 곳에서 불변 객체를 권할까요? "값이 몰래 안 바뀌어서 얻는 안전함" 과 "매번 새 객체를 만드는 비용" 사이에서, 어떤 상황엔 불변이 유리하고 어떤 상황엔 부담스러울지 따져보세요.

주제 3 — SRP 로 쪼개면 파일이 많아진다, 얼마나 쪼개야 적당할까?

SRP 를 따르면 클래스가 잘게 나뉘면서 파일 수가 늘어나요. 극단적으로 가면 메서드 하나마다 클래스를 만들 수도 있겠죠. 하지만 그건 오히려 코드를 따라가기 어렵게 만들어요. 지난 시간에 고민했던 "역할(인터페이스)을 얼마나 잘게 쪼갤까" 와도 이어지는 질문이에요. "너무 안 쪼갠 클래스(BigMember)" 와 "너무 잘게 쪼갠 클래스" 사이에서, 적당한 선을 어떻게 잡을 수 있을지 자기만의 기준을 세워보세요.

✅ 예시 답안정답 보기

오늘 배운 설계 원칙(SRP·OCP·캡슐화·불변)을 손으로 적용해 보는 답안이에요. 정답이 하나뿐인 건 아니에요. 아래 코드는 "이렇게 풀면 깔끔하다" 는 모범 사례 중 하나로 봐주세요. 세 과제 모두 오늘까지 배운 문법(클래스·final·접근 제어자·인터페이스·@Override)만으로 풀 수 있어요. 코드베이스의 Account/Profile·NotificationChannel 계열·Comment.withLike() 를 곁눈질로 참고하며 짜면 돼요.

과제 1 예시답안: SRP 로 게시물 클래스 책임 나누기

데이터 보관과 화면 표시를 한 클래스에서 다 하던 게시물 클래스를, 책임에 따라 PostDataPostCard 두 클래스로 나누는 과제예요.

핵심 접근

이 과제의 진짜 목표는 "한 클래스에게 '무슨 일을 해?' 라고 물었을 때 한마디로 답할 수 있게 만드는" 감각을 익히는 거예요. BigPost 는 데이터도 들고 화면 문구도 만들어서, 답이 두 가지로 갈려요. 그래서 데이터 보관만 맡는 PostData 와 화면 표시만 맡는 PostCard 로 떼어내요. Step 3 에서 본 Account/Profile 가 정확히 이 패턴이에요. PostData 는 필드와 getter 만 두고, PostCardformatCard() 라는 표시 메서드 하나에 집중하면 돼요. 둘로 나누면 화면 문구를 바꿀 때 PostData 를 건드릴 일이 없어져요.

예시 구현

먼저 데이터 보관만 담당하는 PostData 예요. 필드와 생성자, 그리고 읽기용 getter 만 둬요.

// com/instagram/javabasic/solution/day14/PostData.java
public class PostData {

    private String writer;
    private String content;
    private int likeCount;

    public PostData(String writer, String content, int likeCount) {
        this.writer = writer;
        this.content = content;
        this.likeCount = likeCount;
    }

    public String getWriter() {
        return writer;
    }

    public String getContent() {
        return content;
    }

    public int getLikeCount() {
        return likeCount;
    }
}

다음은 화면 표시 문구만 담당하는 PostCard 예요. formatCard() 라는 메서드 하나가 이 클래스의 전부예요.

// com/instagram/javabasic/solution/day14/PostCard.java
public class PostCard {

    private String writer;
    private String content;
    private int likeCount;

    public PostCard(String writer, String content, int likeCount) {
        this.writer = writer;
        this.content = content;
        this.likeCount = likeCount;
    }

    // 게시물을 한 줄 카드 문구로 꾸며서 돌려줘요. 화면 표시가 이 클래스의 유일한 일이에요.
    public String formatCard() {
        return "@" + writer + ": " + content + " (좋아요 " + likeCount + ")";
    }
}

코드 해설

핵심은 "한 클래스 = 한 가지 일" 이에요. PostData 에게 "무슨 일을 해?" 라고 물으면 "게시물 데이터를 보관해" 한마디로 끝나요. PostCard 는 "화면에 보여줄 카드 문구를 만들어" 죠. BigPost 처럼 둘을 한곳에 섞으면 답이 두 갈래로 갈리는데, 그게 바로 SRP 를 어긴 신호예요.

        ┌──────────────────────────┐
        │         BigPost          │   "무슨 일을 해?" → 데이터도, 화면 문구도…
        │  데이터 + 화면 문구 (둘 다)  │   답이 두 갈래 = SRP 위반
        └──────────────────────────┘
                     │  나누면
            ┌────────┴────────┐
            ▼                 ▼
     ┌─────────────┐   ┌──────────────┐
     │  PostData   │   │   PostCard   │
     │ 데이터 보관   │   │ 화면 문구 생성 │   각자 답이 한마디
     └─────────────┘   └──────────────┘

이렇게 나누면 화면 문구 모양을 바꾸고 싶을 때 PostCard 만 열면 돼요. 데이터 구조를 바꾸고 싶으면 PostData 만 열고요. 서로의 코드를 건드릴 일이 없으니, 한쪽을 고치다 다른 쪽을 망가뜨릴 걱정이 사라져요. 이 동작은 코드베이스 Day14SolutionTest 에서 검증되어 있어요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
책임 분리 데이터 보관(PostData)과 화면 표시(PostCard)를 두 클래스로 떼어냈는가
한마디 답 각 클래스에 "무슨 일을 해?" 라고 물으면 한 가지로 답할 수 있는가
PostData 구성 필드 + 생성자 + 필요한 getter 만 두고 표시 로직을 안 섞었는가
formatCard PostCardformatCard() 가 카드 문구를 조립해 돌려주는가
불필요한 통로 차단 외부에서 읽을 필요 없는 값에 굳이 getter/setter 를 안 열었는가

흔한 실수

  • PostCard 에도 setter 를 다 열어둠 → IDE 가 버튼 하나로 getter/setter 를 다 만들어주니 습관처럼 다 여는 경우가 많아요. 그런데 PostCard 는 화면 문구만 만드는 역할이라, 외부에서 값을 바꿀 일이 없어요. 통로를 다 열면 "이 값이 어디서 바뀌는지" 를 추적하기 어려워져요. 필요한 통로만 여는 게 좋아요.
  • 나누긴 했는데 PostDataformatCard() 도 가짐 → 데이터 클래스인데 화면 문구까지 만들면, 결국 BigPost 와 똑같아요. 떼어낸 의미가 없어지죠. 표시 로직은 PostCard 에만 두는 게 좋아요.

실무 개선 포인트 (심화)

지금은 PostDataPostCardwriter·content·likeCount 같은 필드를 따로따로 들고 있어요. 실무에서는 보통 PostCard 가 데이터를 또 복사해 갖는 대신, PostData 를 생성자로 전달받아 그걸 보고 문구만 만드는 식으로 짜요. 그러면 데이터가 한 곳에만 있어서 값이 어긋날 일이 없어지죠. 그래도 핵심 원칙은 같아요 — "데이터를 들고 있는 책임" 과 "그 데이터를 화면용으로 꾸미는 책임" 을 나눠 두면, 표시 방식이 바뀌어도 데이터 쪽은 흔들리지 않는다는 거예요. 이 사고방식은 나중에 화면이 여러 개(목록 카드·상세 카드)로 늘어날 때 진가를 발휘해요.


과제 2 예시답안: OCP 로 새 알림 채널 끼우기

알림 시스템에 새 채널 KakaoChannel 을 추가하면서, Notifier 가 정말 한 글자도 안 바뀌는지 확인하는 과제예요.

핵심 접근

이 과제의 진짜 목표는 "새 기능을 더할 때 기존 코드를 안 고치고 새 파일만 추가하면 되는" OCP(개방-폐쇄 원칙)를 눈으로 확인하는 거예요. 비결은 NotifierEmailChannel·PushChannel 같은 구체적인 이름을 전혀 모르고, NotificationChannel 이라는 약속만 안다는 거예요. 그래서 새 채널은 그 약속을 지키는 클래스 한 개만 새로 만들면 돼요. KakaoChannel 은 Step 4 의 SmsChannel 을 거의 그대로 베껴서 send"[Kakao] " + message 를 돌려주게 만들면 충분해요.

예시 구현

새 채널 KakaoChannelNotificationChannel 약속을 지키고, send 하나만 채우면 끝이에요.

// com/instagram/javabasic/solution/day14/KakaoChannel.java
import com.instagram.javabasic.design.ocp.NotificationChannel;

public class KakaoChannel implements NotificationChannel {

    @Override
    public String send(String message) {
        return "[Kakao] " + message;
    }
}

main 에서는 세 채널을 배열에 담아 Notifier.broadcast() 로 한꺼번에 보내요.

// com/instagram/javabasic/solution/day14/Day14SolutionMain.java
Notifier notifier = new Notifier();
NotificationChannel[] channels = {
        new EmailChannel(),
        new PushChannel(),
        new KakaoChannel()
};

String[] results = notifier.broadcast(channels, "새 댓글이 달렸어요");
for (String result : results) {
    System.out.println(result); // [Email] ... / [Push] ... / [Kakao] ...
}

코드 해설

이 과제의 핵심은 "무엇을 안 고쳤는가" 예요. KakaoChannel.java 파일 하나만 새로 만들었고, Notifier·EmailChannel·PushChannel 은 단 한 글자도 안 고쳤어요. 이게 바로 OCP — "확장에는 열려 있고(새 채널 추가 OK), 수정에는 닫혀 있다(기존 코드 그대로)" 예요.

            ┌──────────────────────────────┐
            │          Notifier            │  EmailChannel·KakaoChannel 이름을 전혀 모름
            │  broadcast(NotificationChannel[]) │  "너 send 할 줄 알지? 해봐" 만 시킴
            └──────────────┬───────────────┘
                           │ 약속(NotificationChannel)만 보고 부름
        ┌──────────┬───────┴───────┬──────────────┐
        ▼          ▼               ▼              ▼
   EmailChannel PushChannel    SmsChannel    KakaoChannel ← 새 파일만 추가!
                                            (Notifier 무수정)

NotifierNotificationChannel 이라는 약속만 알고 구체적인 채널 이름은 모르기 때문에, 새 채널이 그 약속만 지키면 자동으로 끼워져요. 지난 시간에 배운 다형성이 여기서 빛을 발하는 거예요. 이 동작은 코드베이스 Day14SolutionTest 에서 검증되어 있어요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
약속 구현 KakaoChannelimplements NotificationChannel 로 약속을 지키고 @Overridesend 를 채웠는가
기존 코드 무수정 Notifier·EmailChannel·PushChannel 을 한 글자도 안 고쳤는가
send 결과 send"[Kakao] " + message 를 정확히 돌려주는가
배열 + broadcast 세 채널을 NotificationChannel[] 배열에 담아 broadcast 로 한꺼번에 보냈는가
결과 출력 [Email]·[Push]·[Kakao] 세 줄이 모두 나오는가

흔한 실수

  • @Overrideimplements 를 빠뜨림implements NotificationChannel 을 안 적으면 Notifier 의 배열에 담을 수가 없어요(타입이 안 맞아요). @Override 는 안 적어도 돌아가긴 하지만, 약속의 메서드 이름을 오타 내면(sned) 컴파일러가 못 잡아줘요. @Override 를 붙이면 "이건 약속을 채우는 메서드" 라고 컴파일러가 검사해줘서 안전해요.
  • 새 채널 때문에 Notifierbroadcast 를 고침if (channel instanceof KakaoChannel) 같은 분기를 Notifier 안에 넣으면, 새 채널이 생길 때마다 Notifier 를 또 고쳐야 해요. 그건 OCP 를 어긴 거예요. Notifier 는 약속(NotificationChannel)만 보고 부르면 되니, 새 채널이 와도 손댈 필요가 없어요.

실무 개선 포인트 (심화)

지금은 main 에서 채널 배열을 직접 손으로 만들어 넣었어요. 실무에서는 보통 "쓸 채널 목록" 을 한 곳에 모아 관리하고, Notifier 에게 그 목록을 통째로 전달해요. 그러면 카카오 채널을 켜고 끄는 일도 그 목록만 바꾸면 끝나죠. 그래도 핵심은 그대로예요 — Notifier 가 구체적인 채널 이름이 아니라 NotificationChannel 약속에만 기대고 있으면, 채널이 3개든 10개든, 켜고 끄는 일이든, Notifier 코드는 흔들리지 않아요. "구체적인 것 말고 약속에 기댄다" 는 이 사고방식이 OCP 의 진짜 가치예요.


과제 3 예시답안: 불변 객체 Tag 설계하기

사진에 다는 "태그(Tag)" 를 불변 객체로 만드는 과제예요. 사용 횟수가 올라가도 원본은 안 바뀌게 하는 게 핵심이에요.

핵심 접근

이 과제의 진짜 목표는 "한번 만들어진 값은 절대 안 바뀐다(불변)" 가 어떻게 원본을 안전하게 지키는지 체감하는 거예요. 비결은 두 가지예요. 첫째, 필드를 final 로 잠가서 생성자 안에서 딱 한 번만 값을 정하게 해요. 둘째, 값을 바꾸고 싶을 땐 원본을 고치는 대신 "바뀐 값을 가진 새 Tag" 를 만들어 돌려줘요. Step 5 의 Comment.withLike() 가 정확히 이 패턴이에요. withUse()return new Tag(name, useCount + 1); 처럼 새 객체를 만들어 돌려주고, 원본(this)은 전혀 안 건드려요.

예시 구현

Tag 는 두 필드를 모두 final 로 잠그고, 값을 넣는 통로는 생성자 하나뿐이에요.

// com/instagram/javabasic/solution/day14/Tag.java
public final class Tag {

    private final String name;
    private final int useCount;

    public Tag(String name, int useCount) {
        this.name = name;
        this.useCount = useCount;
    }

    public String getName() {
        return name;
    }

    public int getUseCount() {
        return useCount;
    }

    // 사용 횟수를 1 늘린 '새 Tag' 를 돌려줘요. 원본(this)은 전혀 건드리지 않아요.
    public Tag withUse() {
        return new Tag(name, useCount + 1);
    }

    @Override
    public String toString() {
        return "#" + name + " (" + useCount + "회 사용)";
    }
}

main 에서 withUse() 를 불러보면, 원본은 그대로이고 새 객체만 1 늘어요.

// com/instagram/javabasic/solution/day14/Day14SolutionMain.java
Tag travel = new Tag("여행", 5);
Tag usedOnce = travel.withUse();

System.out.println(travel);   // #여행 (5회 사용)  ← 원본 그대로!
System.out.println(usedOnce); // #여행 (6회 사용)  ← 새 객체만 늘었음

코드 해설

여기서 눈여겨볼 건 세 가지예요.

첫째, nameuseCount 가 둘 다 final 이에요. final 필드는 생성자 안에서 단 한 번만 값을 정할 수 있어요. 그 뒤로는 누구도 못 바꿔요. 그래서 setUseCount(...) 같은 setter 를 만들려고 하면 아예 컴파일이 안 돼요 — 불변이 문법으로 강제되는 거예요.

둘째, withUse() 가 원본을 안 건드린다는 게 핵심이에요. this.useCount++ 처럼 자기 값을 올리는 게 아니라, new Tag(name, useCount + 1) 로 "6회짜리 새 태그" 를 만들어 돌려줘요. 그래서 travel 은 영원히 5회로 남고, usedOnce 만 6회예요.

   travel = new Tag("여행", 5)
        │
        │  travel.withUse()  ← 원본은 안 건드림
        ▼
   ┌─────────────────┐        ┌─────────────────┐
   │  travel         │        │  usedOnce       │  ← 새로 만들어진 객체
   │  여행 / 5회       │        │  여행 / 6회       │
   │  (그대로 보존)    │        │  (1 늘어남)       │
   └─────────────────┘        └─────────────────┘

셋째, class 앞에도 final 을 붙였어요. 이건 "이 클래스는 자식이 물려받아 살짝 비틀 수 없다" 는 뜻이에요. 자식이 함부로 동작을 바꾸지 못하게 해서 불변을 한 겹 더 단단히 지키는 거예요. 이 동작은 코드베이스 Day14SolutionTest 에서 검증되어 있어요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
final 필드 name·useCount 가 둘 다 final 이고 생성자에서만 값이 정해지는가
원본 보존 withUse()this 를 안 고치고 new Tag(...) 로 새 객체를 돌려주는가
setter 없음 값을 넣는 통로가 생성자 하나뿐이고 setter 를 안 만들었는가
결과 확인 원본 useCount 는 5 그대로, 새 객체는 6 인 걸 출력으로 확인했는가
getter 읽기용 getName()·getUseCount() 를 뒀는가
toString #여행 (5회 사용) 형태로 보기 좋게 찍히는가

흔한 실수

  • final 필드인데 setter 를 만들려다 컴파일 에러final 로 잠근 필드는 생성자 뒤로는 못 바꿔요. 그래서 setUseCount(int useCount) 안에서 this.useCount = useCount 를 적으면 "cannot assign a value to final variable" 컴파일 에러가 나요. 이건 실수가 아니라 불변이 제대로 지켜지고 있다는 신호예요. 값을 바꾸고 싶으면 setter 가 아니라 withUse() 처럼 새 객체를 만들어야 해요.
  • withUse() 가 원본을 고침this.useCount++ 를 하고 return this; 를 하면, 애초에 final 이라 컴파일도 안 되지만, 설령 final 을 빼더라도 원본 travel 이 6회로 바뀌어버려요. 그러면 불변의 의미가 사라지죠. 반드시 new Tag(name, useCount + 1) 로 새 객체를 만들어 돌려줘야 원본이 지켜져요.

실무 개선 포인트 (심화)

불변 객체는 "값이 몰래 안 바뀐다" 는 점 때문에 실무에서 정말 자주 쓰여요. 특히 여러 작업이 같은 객체를 동시에 들여다볼 때, 그 값이 안 바뀐다는 게 보장되면 마음 놓고 공유할 수 있거든요. 누가 몰래 5회를 0회로 바꿔놓을 걱정이 없으니까요. 나중에 배울 record 라는 문법을 쓰면, 오늘 손으로 짠 final 필드 + 생성자 + getter 묶음을 단 한 줄로 만들 수 있어요. 그래도 그 바탕에 깔린 생각은 똑같아요 — "한번 만든 값은 안 바꾸고, 바꾸고 싶으면 새 걸 만든다" 는 거죠. 오늘 그 원리를 손으로 직접 만들어봤으니, 나중에 record 를 만나도 "아, 이건 불변을 짧게 쓰는 문법이구나" 하고 바로 알아볼 수 있을 거예요.


생각해볼 주제 예시답안

생각해볼 주제 1 예시답안: getter/setter 를 다 여는 게 왜 위험할까?

[문제 상황 요약]

많은 입문 자료가 "필드를 만들면 getter/setter 를 다 만들어라" 라고 가르쳐요. IDE 도 버튼 한 번이면 다 만들어주죠. 그런데 오늘 우리는 "필요한 통로만 열어라" 라고 배웠어요. 데이터를 다 열어둔 클래스와 통로만 연 클래스는 실제로 무엇이 어떻게 다를까요? 비밀번호나 인증 상태 같은 값까지 setter 로 다 열면 어떤 사고가 생길 수 있을까요?

[튜터의 가이드 및 해설]

캡슐화의 본질은 "데이터를 숨기는 것" 자체가 아니라, "이 값이 어디서 어떻게 바뀌는지를 클래스가 통제하는 것" 이에요. setter 를 다 열어두면 그 통제권을 외부에 통째로 넘겨주는 셈이에요.

가장 무서운 예가 인증 상태예요. MembersetVerified(boolean verified) 를 열어뒀다고 해봐요. 그러면 코드 어디에서든 member.setVerified(true) 를 부를 수 있어요. 원래는 "이메일 인증을 통과해야만" 켜져야 하는 값인데, 그 검증을 건너뛰고 그냥 켜버릴 수 있게 되는 거죠. 비밀번호도 마찬가지예요. setPassword(...) 가 열려 있으면, 길이 검사나 암호화 같은 규칙을 거치지 않고 아무 값이나 들어갈 수 있어요.

그래서 오늘 AccountgetPassword 같은 통로를 아예 안 만들고 checkPassword 만 연 거예요. "비밀번호를 꺼내 읽는 일" 은 외부에 필요 없고, "맞는지 틀린지 확인하는 일" 만 필요하니까요. 이렇게 클래스가 "지켜야 할 규칙(불변식)" 을 가진 값이라면, 그 규칙을 거치는 통로만 열고 나머지는 닫아두는 게 안전해요.

  • Option A — 모든 필드에 getter/setter 다 열기: 빠르게 만들 수 있고 IDE 가 자동 생성해줘요. 단점은 값이 어디서 바뀌는지 추적이 안 되고, 검증을 건너뛴 위험한 값이 들어올 수 있어요.
  • Option B — 필요한 통로만 열기: 값이 바뀌는 길목이 좁아져서 추적과 검증이 쉬워요. 단점은 통로를 하나하나 의도적으로 설계해야 해서 손이 조금 더 가요.

현업에서는 보통 "읽기는 필요한 만큼, 쓰기는 최대한 좁게" 가 기본이에요. 특히 비밀번호·인증 상태·잔액처럼 규칙이 걸린 값은 setter 를 아예 안 만들고, 대신 checkPassword·verify·withdraw 같은 "규칙을 거치는 메서드" 만 열어요. "통로만 연다" 는 건 게을러서가 아니라, 위험한 값일수록 길목을 좁혀 지킨다는 뜻이에요.

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

"getter/setter 를 기계적으로 다 여는 건 캡슐화가 아니라 캡슐화를 가장한 공개 필드라고 봅니다. 캡슐화의 본질은 '값을 숨기는 것' 이 아니라 '값이 바뀌는 길목을 클래스가 통제하는 것' 이거든요. 비밀번호나 인증 상태처럼 검증 규칙이 걸린 값에 setter 를 열면, 그 규칙을 건너뛴 잘못된 상태가 들어올 수 있습니다. 그래서 저는 읽기는 필요한 만큼만 열고, 쓰기는 setVerified 같은 무방비 통로 대신 verify() 처럼 규칙을 거치는 메서드로만 여는 걸 원칙으로 합니다."


생각해볼 주제 2 예시답안: 불변 객체는 매번 새 객체를 만드는데, 낭비 아닐까?

[문제 상황 요약]

불변 객체는 값을 바꿀 때마다 새 객체를 만들어요. 좋아요를 누를 때마다 새 Comment 가, 태그를 쓸 때마다 새 Tag 가 생기는 거죠. 언뜻 보면 메모리를 낭비하는 것 같아요. 그런데도 왜 많은 곳에서 불변을 권할까요? "값이 몰래 안 바뀌어 얻는 안전함" 과 "매번 새 객체를 만드는 비용" 사이에서, 어떤 상황엔 불변이 유리하고 어떤 상황엔 부담스러울까요?

[튜터의 가이드 및 해설]

먼저 불변이 주는 안전함이 어디서 오는지 짚어볼게요. 핵심은 "한번 만든 값은 절대 안 바뀐다는 보장" 이에요. 이 보장이 있으면 여러 곳에서 같은 객체를 마음 놓고 같이 봐도 돼요. 누가 몰래 값을 바꿔놓을 일이 없으니까요.

이게 특히 빛나는 곳이 여러 작업이 동시에 도는 상황이에요. 보통 객체는 "A 가 읽는 도중에 B 가 값을 바꾸면" 엉킨 값이 나올 수 있어요. 그런데 불변 객체는 애초에 안 바뀌니, 동시에 봐도 절대 안 엉켜요. 그래서 "여러 작업이 함께 쓰는 값" 일수록 불변이 안전해요.

또 하나 숨은 이점이 있어요. 보통은 객체를 남에게 넘길 때 "혹시 저쪽에서 내 값을 바꿔버리면 어쩌지?" 싶어서 복사본을 떠서 넘기곤 해요(방어적 복사). 그런데 불변 객체는 어차피 못 바꾸니, 원본을 그냥 넘겨도 안전해요. 복사 비용을 오히려 아끼는 거예요.

  • Option A — 불변 객체: 값이 안 바뀌어 안전하고, 여러 곳에서 같이 봐도 안 엉켜요. 단점은 값을 바꿀 때마다 새 객체가 생겨서, 변경이 아주 잦고 객체가 무거우면 부담이 될 수 있어요.
  • Option B — 가변 객체(setter 로 값 변경): 같은 객체의 값만 바꾸니 새 객체를 안 만들어요. 단점은 값이 언제 어디서 바뀌었는지 추적이 어렵고, 여러 곳이 같이 보면 엉킬 위험이 있어요.

현업에서는 보통 "기본은 불변, 부담될 때만 가변" 으로 가요. Tag·Comment 처럼 작고 값이 자주 안 바뀌는 객체는 불변이 압도적으로 편하고 안전해요. 반대로 아주 큰 데이터를 초당 수만 번 바꿔야 하는 특수한 경우라면, 매번 새 객체를 만드는 비용이 커지니 그때만 가변을 고민해요. 입문 단계에서는 "일단 불변으로 만들고, 진짜 무거워질 때 다시 생각한다" 가 안전한 기본기예요.

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

"불변 객체가 새 객체를 만드는 비용은 대부분 무시할 수 있는 수준인데, 그 대가로 얻는 안전함은 큽니다. 값이 절대 안 바뀐다는 보장이 있으면 여러 작업이 동시에 같은 객체를 봐도 안 엉키고, 남에게 넘길 때 방어적 복사조차 필요 없죠. 그래서 저는 '기본은 불변, 정말 큰 객체를 아주 빈번하게 변경해야 하는 특수한 경우에만 가변' 을 원칙으로 둡니다. 작은 도메인 객체에서 새 객체 생성 비용을 아끼려고 불변을 포기하는 건, 얻는 것보다 잃는 안전함이 더 크다고 봅니다."


생각해볼 주제 3 예시답안: SRP 로 쪼개면 파일이 많아진다, 얼마나 쪼개야 적당할까?

[문제 상황 요약]

SRP 를 따르면 클래스가 잘게 나뉘면서 파일 수가 늘어나요. 극단으로 가면 메서드 하나마다 클래스를 만들 수도 있겠죠. 하지만 그러면 오히려 코드를 따라가기 어려워져요. 지난 시간에 고민한 "역할(인터페이스)을 얼마나 잘게 쪼갤까" 와도 이어지는 질문이에요. 너무 안 쪼갠 클래스(BigMember)와 너무 잘게 쪼갠 클래스 사이에서, 적당한 선을 어떻게 잡을까요?

[튜터의 가이드 및 해설]

쪼갬의 기준을 한 문장으로 잡으면 이래요 — "함께 바뀌는 건 함께 두고, 따로 바뀌는 건 따로 둔다."

BigMember 가 문제였던 건 파일이 하나여서가 아니라, 전혀 다른 이유로 바뀌는 일 세 가지(인증·점수 계산·화면 표시)가 한곳에 엉켜 있었기 때문이에요. "비밀번호 암호화를 바꾸자" 와 "화면 문구를 바꾸자" 는 서로 아무 상관 없는 변경인데, 같은 파일에 있으니 한쪽을 고치다 다른 쪽을 건드릴 위험이 생겼죠. 이렇게 "따로 바뀌는 것" 이 섞여 있으면 나누는 게 맞아요.

반대로 너무 잘게 쪼개는 것도 문제예요. 메서드 하나마다 클래스를 만들면, 작은 일 하나를 이해하려고 파일 열 개를 왔다 갔다 해야 해요. 늘 같이 바뀌고 늘 같이 쓰이는 것까지 억지로 떼어내면, 흩어진 조각을 다시 맞추느라 더 힘들어지죠. "함께 바뀌는 것" 까지 나누면 오히려 손해예요.

이건 지난 시간 인터페이스 쪼개기와 똑같은 결이에요. 그때 ShareableCommentable 을 나눈 건 "텍스트는 댓글만 쓰고 공유는 안 쓰는" 식으로 따로 쓰이는 일이 실제로 있었기 때문이죠. 클래스 쪼개기도 마찬가지예요. "이 부분이 다른 부분과 따로 바뀌거나 따로 쓰이는 일이 실제로 있는가" 를 물어보면 돼요.

  • Option A — 적게 쪼개기(큰 클래스): 관련된 게 한곳에 모여 찾기 쉬울 수 있어요. 단점은 따로 바뀌어야 할 것들이 엉켜서, 한쪽 수정이 다른 쪽을 망가뜨릴 위험이 커져요.
  • Option B — 잘게 쪼개기(작은 클래스 여러 개): 각 클래스가 한 가지 일만 해서 안전하고 이해하기 쉬워요. 단점은 너무 잘게 나누면 파일이 넘쳐나고, 흩어진 조각을 따라다니느라 오히려 복잡해져요.

현업에서는 "응집도는 높게, 결합도는 낮게" 라는 말로 이 균형을 표현해요. 어렵게 들리지만 뜻은 단순해요. 한 클래스 안의 것들은 서로 끈끈하게 관련돼 있어야 하고(응집도), 클래스끼리는 서로 너무 얽히지 않아야 한다(결합도)는 거죠. 그 실전 신호가 바로 "이 클래스는 무슨 일을 해?" 에 한마디로 답할 수 있느냐예요. 답이 두 갈래로 갈리면 쪼갤 때고, 쪼갠 조각들이 늘 붙어다닌다면 너무 나눈 거예요.

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

"SRP 의 기준은 '파일 개수' 가 아니라 '바뀌는 이유' 라고 봅니다. 한 클래스가 서로 다른 이유로 바뀌는 책임을 여럿 안고 있으면 — 인증 규칙 변경과 화면 문구 변경처럼 — 그건 쪼갤 신호죠. 반대로 늘 함께 바뀌고 함께 쓰이는 걸 억지로 나누면 응집도가 깨져서 오히려 따라가기 어려워집니다. 그래서 저는 '함께 바뀌는 건 함께 두고, 따로 바뀌는 건 따로 둔다' 를 기준으로 삼고, 실전에서는 '이 클래스가 무슨 일을 하는지 한 문장으로 답되는가' 로 적정선을 가늠합니다."

더 배우려면

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

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