문서 읽는 데 89분 · day47

Day 47 — 인스타그램 도메인 리팩토링 (종합 1)

목차 36
전체 48강 중 47강 · 자바 기초
난이도 · 입문

지난 시간엔 JUnit 으로 우리 코드가 정말 제대로 도는지 스스로 증명하는 법을 배웠어요. 그리고 끝에 약속을 하나 남겼죠. "다음엔 드디어 그동안 배운 걸 한자리에 모아, 흩어져 있던 인스타그램 도메인을 더 유연한 구조로 다시 다듬고 테스트로 단단히 감싼다" 고요.

오늘이 바로 그날이에요. 새 문법은 하나도 안 나와요. 대신 지금까지 47일 동안 손에 익힌 것 — Day 40~42 의 디자인 패턴, Day 26~28 의 Stream/Optional, Day 46 의 JUnit — 을 전부 꺼내서, 우리가 만들어 온 인스타그램 코드 중 "조금 답답했던 부분" 을 골라 고쳐 볼 거예요. 코드를 고쳐서 더 나은 구조로 바꾸는 작업을 리팩토링(refactoring) 이라고 불러요. 동작은 그대로 두되 안쪽 모양만 깔끔하게 다듬는 거죠.

오늘은 코드로 총정리를 하고, 다음 시간(Day 48)엔 47일 전체 여정을 돌아보며 앞으로의 학습 길까지 함께 그려 볼 거예요. 그러니 오늘은 "배운 걸 실전에서 어떻게 엮는가" 에만 집중하면 돼요.

🎯 학습 목표

  • 리팩토링이 무엇이고, "Before(고치기 전) → After(고친 뒤)" 로 코드를 다듬을 때 동작은 그대로 보존한다는 원칙을 이해할 수 있어요.
  • if-else 로 분기하던 알림 코드를 Day 41 의 전략(Strategy) 패턴으로 바꿔, 새 채널이 생겨도 기존 코드를 건드리지 않는 구조를 만들 수 있어요.
  • 좋아요 한 번에 여러 일이 엉켜 있던 코드를 Day 41 의 관찰자(Observer) 패턴으로 풀어, 반응을 하나씩 떼었다 붙였다 할 수 있어요.
  • 중첩 for 로 모으던 피드를 Day 26 의 Stream(flatMap)으로 다시 써서, 정렬 같은 확장도 한 줄로 더할 수 있어요.

Step 1: 47일의 여정과 오늘의 미션

본격적으로 코드를 고치기 전에, 우리가 지금 어디쯤 와 있는지 한 번 펼쳐 볼게요. 처음 변수 하나 선언하는 것도 낯설었던 게 엊그제 같은데, 어느새 47일을 걸어왔어요. 그동안 배운 걸 큰 덩어리로 묶으면 여섯 단계가 돼요.

텍스트
 Phase 1   문법의 기초          변수 · 조건문 · 반복문 · 배열 · 메서드
    │
 Phase 2   객체 지향(OOP)        클래스 · 상속 · 다형성 · 인터페이스 · Enum
    │
 Phase 3   핵심 API             String · 컬렉션 · 제네릭 · 예외 처리
    │
 Phase 4   모던 자바             람다 · Stream · Optional · Record
    │
 Phase 5   동시성과 입출력        Thread · 동시성 · CompletableFuture · 파일 I/O
    │
 Phase 6   설계와 깊이           디자인 패턴 · JVM 메모리 · 리플렉션 · JUnit

지금 우리는 마지막 Phase 6 의 끝자락에 서 있어요. 그런데 한 가지 아쉬운 점이 있어요. 이 모든 걸 배우긴 했는데, 각 도구를 따로따로 연습할 때가 많았어요. 람다는 람다대로, 패턴은 패턴대로요. 오늘은 그 도구들을 한 도메인 안에서 동시에 꺼내 써 보는 시간이에요.

대상은 우리가 47일 내내 키워 온 인스타그램 코드예요. 솔직하게 말하면, 코드를 빠르게 만들다 보면 "일단 돌게는 했는데 좀 답답한" 부분이 생겨요. 오늘 우리가 손볼 곳은 네 군데예요.

텍스트
 ① 알림 보내기      if-else 로 채널을 직접 따져 분기   채널 추가할 때마다 메서드를 또 연다
 ② 좋아요 처리      카운트 + 알림 + 로그를 한 메서드에   새 반응 추가할 때마다 줄을 끼운다
 ③ 피드 모으기      for 안에 for 가 도는 중첩 반복      정렬 같은 확장을 더하기 번거롭다
 ④ 값 없음 처리     null 을 그냥 흘려보내 위험 (다음 종합 시간)

이 중 ①②③ 을 오늘 차례대로 고쳐요. 고치는 방식은 늘 똑같아요. 먼저 "고치기 전(Before)" 코드를 펼쳐서 뭐가 답답한지 짚고, 이미 배운 패턴이나 Stream 으로 "고친 뒤(After)" 를 만들어요. 그리고 결과가 똑같이 나오는지 확인해요. 동작은 그대로 두고 안쪽 모양만 바꾸는 것, 이게 리팩토링의 첫 번째 약속이에요.

💡 오늘 기억할 한 줄: 리팩토링은 동작을 바꾸는 게 아니라, 같은 동작을 더 다루기 쉬운 구조로 다시 쓰는 일이에요. 그래서 늘 Before 와 After 가 같은 결과를 내는지 확인하며 나아가요.


Step 2: Before/After ① — Strategy 로 알림 채널 갈아끼우기

첫 번째로 손볼 곳은 알림을 보내는 코드예요. 인스타그램은 좋아요·댓글·팔로우가 생기면 사용자에게 알림을 보내죠. 그런데 알림이 나가는 통로(채널)는 한 가지가 아니에요. 이메일로도, 휴대폰 푸시로도, 문자(SMS)로도 보낼 수 있어요.

먼저 "고치기 전" 코드를 볼게요. 채널 종류를 문자열로 받아서 if-else 로 직접 따지는 방식이에요.

Java
// com/instagram/javabasic/capstone/NaiveNotificationService.java
public class NaiveNotificationService {

    public String send(String channelType, String message) {
        if (channelType.equals("email")) {
            return "[Email] " + message;
        } else if (channelType.equals("push")) {
            return "[Push] " + message;
        } else if (channelType.equals("sms")) {
            return "[SMS] " + message;
        } else {
            throw new IllegalArgumentException("모르는 채널이에요: " + channelType);
        }
    }
}

이 코드도 돌긴 잘 돌아요. 그런데 마케팅 팀에서 "이제 카카오톡으로도 알림을 보내요!" 라고 하면 어떻게 될까요? 우리는 이 send 메서드를 다시 열어서 else if (channelType.equals("kakao")) 한 칸을 또 끼워 넣어야 해요. 채널이 늘어날수록 이 한 메서드가 끝없이 부풀어요. 그리고 멀쩡히 잘 돌던 이메일·푸시 코드까지 같은 파일을 건드리게 되니, 실수로 다른 줄을 망가뜨릴 위험도 생겨요.

Day 14 에서 우리는 좋은 설계의 원칙 중 OCP(Open-Closed Principle) — "확장에는 열려 있고, 기존 코드 수정에는 닫혀 있어야 한다" 를 배웠어요. 위 코드는 정확히 그 반대예요. 새 채널을 추가하려면(확장) 기존 메서드를 또 수정해야 하니까요.

Day 41 에서 만든 전략 패턴을 다시 꺼내요

이 답답함을 푸는 방법을 우리는 이미 배웠어요. Day 41 의 전략(Strategy) 패턴이에요. 핵심 아이디어는 이래요 — "어떤 채널로 보낼지" 라는 갈아끼울 수 있는 동작을 통째로 객체로 만들어서, 보내는 쪽이 그 객체에게 일을 맡기는 거예요.

먼저 "알림 채널이라면 모두 send 를 할 줄 안다" 는 약속(인터페이스)을 둬요. 이건 Day 14 에서 만들어 둔 NotificationChannel 을 그대로 재활용해요.

Java
// com/instagram/javabasic/design/ocp/NotificationChannel.java
public interface NotificationChannel {

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

그리고 채널마다 이 약속을 지키는 클래스를 따로 둬요. 이메일·푸시·문자가 각자 한 파일씩이에요.

Java
// com/instagram/javabasic/design/ocp/EmailChannel.java
public class EmailChannel implements NotificationChannel {

    @Override
    public String send(String message) {
        return "[Email] " + message;
    }
}
Java
public class PushChannel implements NotificationChannel {

    @Override
    public String send(String message) {
        return "[Push] " + message;
    }
}
Java
public class SmsChannel implements NotificationChannel {

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

보내는 결과 문구([Email] ·[Push] ·[SMS] )가 고치기 전 코드와 한 글자도 다르지 않게 맞춰 둔 게 보이죠? 그래야 "고친 뒤에도 결과가 똑같은지" 를 비교할 수 있으니까요.

보내는 쪽은 전략을 품고 위임해요

이제 알림을 실제로 내보내는 쪽이에요. 이 클래스는 채널을 직접 따지지 않아요. 대신 채널 객체 하나를 품고 있다가, 보낼 일이 생기면 그 채널에게 맡겨요.

Java
// com/instagram/javabasic/design/behavioral/strategy/NotificationSender.java
public class NotificationSender {

    // 지금 사용 중인 전략(알림 채널)이에요. 런타임에 다른 채널로 바꿀 수 있어요.
    private NotificationChannel channel;

    // 처음 만들 때 어떤 전략으로 시작할지 외부에서 받아요.
    public NotificationSender(NotificationChannel channel) {
        this.channel = channel;
    }

    // 보내던 도중에도 전략(채널)을 새것으로 갈아끼워요.
    public void changeChannel(NotificationChannel channel) {
        this.channel = channel;
    }

    // 실제로 보낼 때는 직접 하지 않고, 지금 품고 있는 전략에게 맡겨요.
    public String notify(String message) {
        return channel.send(message);
    }
}

이 부분이 왜 이렇게 되어 있냐면, NotificationSender 가 "보내라" 는 명령만 내리고 진짜 보내는 일은 품고 있는 채널에게 떠넘기기 때문이에요. 생성자에서 채널을 외부로부터 넘겨받는다는 점도 눈여겨보세요. Day 24 에서 서비스에 저장소를 넘겨주던 것처럼, "객체를 직접 만들지 않고 생성자로 넘겨받기" 그대로예요. notify("좋아요가 눌렸어요") 를 부르면, 지금 품고 있는 채널이 이메일이면 [Email] 좋아요가 눌렸어요 가, 푸시면 [Push] 좋아요가 눌렸어요 가 나와요. 같은 명령인데 품은 전략에 따라 결과가 달라지죠.

그래서 이제 카카오톡 채널을 추가하고 싶으면 어떻게 할까요? KakaoChannel implements NotificationChannel 파일 하나만 새로 만들면 끝이에요. NotificationSender 는 한 줄도 안 바뀌어요. 이게 바로 "확장에는 열려 있고, 수정에는 닫혀 있다" 가 실제로 작동하는 모습이에요.

전략은 람다로 한 줄로도 줄 수 있어요

여기서 한 가지 더요. NotificationChannel 은 메서드가 send 단 하나뿐인 인터페이스예요. Day 25 에서 배운 걸 떠올려 보세요 — 메서드가 하나뿐인 인터페이스는 람다로 짧게 표현할 수 있죠. 그래서 굳이 클래스 파일을 만들지 않고도, 쓰는 코드 안에서 바로 전략을 람다로 끼워 넣을 수 있어요.

Java
NotificationChannel kakao = msg -> "[KakaoTalk] " + msg;
NotificationSender sender = new NotificationSender(kakao);
sender.notify("새 댓글이 달렸어요");   // [KakaoTalk] 새 댓글이 달렸어요

msg -> "[KakaoTalk] " + msg 한 줄이 곧 하나의 전략이에요. 클래스를 따로 만들 만큼 무겁지 않은 동작이라면 이렇게 람다로 바로 끼워 주는 게 훨씬 가벼워요. 패턴(Strategy)과 람다, 따로 배운 두 도구가 여기서 자연스럽게 한 흐름으로 만나는 거예요.

🙋 학생 질문 — "튜터님, if-else 가 그렇게 나쁜 건가요? 채널 세 개뿐이면 그냥 if 가 더 짧지 않아요?"

아주 정확한 감각이에요. 채널이 두세 개로 끝나고 앞으로도 거의 안 늘 거라면, 솔직히 if-else 가 더 짧고 읽기도 쉬워요. 패턴은 공짜가 아니거든요 — 인터페이스 하나에 클래스 여러 개로 파일 수가 늘어나니까요.

전략 패턴이 값을 하는 건 "채널이 앞으로 계속 늘어날 게 뻔할 때" 예요. 알림 채널은 서비스가 크면 이메일·푸시·문자·카카오·웹훅처럼 계속 불어나는 쪽이라, 미리 갈아끼울 수 있게 열어두는 게 이득이에요. 반대로 "절대 안 늘 거다" 싶은 분기까지 전부 패턴으로 만들면 오히려 과해요. "변할 곳에만 패턴을 쓴다" 가 핵심이에요.

💡 오늘 기억할 한 줄: 갈아끼울 동작을 객체로 빼서 품고 위임하면(Strategy), 새 채널은 파일만 추가하면 되고 보내는 쪽은 안 바뀌어요. 메서드가 하나뿐인 전략은 람다로 한 줄로도 줄 수 있어요.


Step 3: Before/After ② — Observer 로 좋아요 사건 퍼뜨리기

두 번째로 손볼 곳은 좋아요를 처리하는 코드예요. 인스타그램에서 누가 내 게시물에 좋아요를 누르면, 한 번의 클릭으로 여러 일이 동시에 일어나야 해요. 좋아요 숫자가 1 올라가고, "OO님이 좋아합니다" 알림 문구가 만들어지고, 활동 기록에 한 줄이 남아요.

"고치기 전" 코드는 이 세 가지 일을 like 메서드 하나가 몽땅 떠안고 있어요.

Java
// com/instagram/javabasic/capstone/NaiveLikeHandler.java
public class NaiveLikeHandler {

    private int count;
    private String lastNotification;
    private final List<String> logs = new ArrayList<>();

    // 좋아요 한 번 — 세 가지 반응을 여기서 직접 다 처리해요(강하게 엉킨 모습).
    public void like(String postId, String likedBy) {
        count++;
        lastNotification = likedBy + "님이 회원님의 게시물을 좋아합니다";
        logs.add(postId + " liked by " + likedBy);
    }

    public int getCount() {
        return count;
    }

    public String getLastNotification() {
        return lastNotification;
    }

    public List<String> getLogs() {
        return logs;
    }
}

like 메서드를 보면 세 줄이 한 덩어리로 엉켜 있어요. 첫 줄은 숫자 세기, 둘째 줄은 알림 문구 만들기, 셋째 줄은 로그 쌓기예요. 셋은 사실 서로 아무 상관도 없는 일인데, 한 메서드 안에 같이 갇혀 있죠.

여기서 "좋아요가 눌리면 작성자에게 활동 점수도 올려줘" 같은 새 반응이 생기면 어떻게 될까요? 또 이 like 메서드를 열어서 줄을 추가해야 해요. 반응이 늘수록 메서드가 길어지고, 세 일이 단단히 엉켜 있어서 하나만 바꿔도 다른 게 깨질까 조심스러워져요.

Day 41 의 관찰자 패턴으로 풀어요

이 엉킴을 푸는 방법도 Day 41 에서 배웠어요. 관찰자(Observer) 패턴이에요. 발상이 재미있어요 — 좋아요가 눌린 게시물은 "방금 좋아요가 눌렸어요!" 라고 소문만 내고, 그 소문을 듣고 무슨 일을 할지는 듣는 쪽(관찰자)이 알아서 정하는 거예요.

유튜브 채널 구독을 떠올리면 쉬워요. 채널은 새 영상을 올리면서 "영상 올렸어요" 알림만 쏴요. 그 알림을 받은 구독자가 영상을 볼지, 댓글을 달지, 무시할지는 각자의 몫이죠. 채널은 구독자가 몇 명이고 뭘 하는지 일일이 신경 쓰지 않아요.

먼저 "좋아요 소문을 듣는 쪽" 이 지켜야 할 약속을 둬요.

Java
// com/instagram/javabasic/design/behavioral/observer/PostObserver.java
public interface PostObserver {

    // 게시물에 좋아요가 눌리면 호출돼요.
    // postId 는 어떤 게시물인지, likedBy 는 누가 눌렀는지예요.
    void onLiked(String postId, String likedBy);
}

그리고 좋아요를 받는 게시물이에요. 자기를 구독한 관찰자들을 명단으로 들고 있다가, 좋아요가 눌리면 명단을 한 명씩 돌면서 onLiked 를 불러 알려요.

Java
// com/instagram/javabasic/design/behavioral/observer/LikeablePost.java
public class LikeablePost {

    private final String postId;
    private final List<PostObserver> observers = new ArrayList<>();

    public LikeablePost(String postId) {
        this.postId = postId;
    }

    // 관찰자를 구독 목록에 등록해요.
    public void subscribe(PostObserver o) {
        observers.add(o);
    }

    // 관찰자를 구독 목록에서 빼요. 그 뒤로는 더 이상 통지받지 않아요.
    public void unsubscribe(PostObserver o) {
        observers.remove(o);
    }

    // 좋아요가 눌렸어요. 등록된 모든 관찰자에게 한 명씩 알려줘요.
    public void like(String likedBy) {
        for (PostObserver o : observers) {
            o.onLiked(postId, likedBy);
        }
    }
}

like 메서드가 고치기 전과 완전히 달라진 점을 보세요. 이제 좋아요 숫자도, 알림 문구도, 로그도 직접 만들지 않아요. 그냥 "좋아요 눌렸어요" 라고 관찰자들에게 알려 주기만 해요. 무슨 반응을 할지는 관찰자 각자가 정하는 거죠.

반응 하나하나를 따로 떼어 둬요

이제 엉켜 있던 세 가지 일을 각자 독립된 관찰자로 나눠요. 숫자 세기, 알림 문구 만들기, 로그 쌓기가 한 클래스씩이에요.

Java
// com/instagram/javabasic/design/behavioral/observer/LikeCountObserver.java
public class LikeCountObserver implements PostObserver {

    private int count;

    @Override
    public void onLiked(String postId, String likedBy) {
        count++;
    }

    public int getCount() {
        return count;
    }
}
Java
// com/instagram/javabasic/design/behavioral/observer/NotificationObserver.java
public class NotificationObserver implements PostObserver {

    private String lastMessage;

    @Override
    public void onLiked(String postId, String likedBy) {
        lastMessage = likedBy + "님이 회원님의 게시물을 좋아합니다";
    }

    public String getLastMessage() {
        return lastMessage;
    }
}
Java
// com/instagram/javabasic/design/behavioral/observer/ActivityLogObserver.java
public class ActivityLogObserver implements PostObserver {

    private final List<String> logs = new ArrayList<>();

    @Override
    public void onLiked(String postId, String likedBy) {
        logs.add(postId + " liked by " + likedBy);
    }

    public List<String> getLogs() {
        return logs;
    }
}

세 관찰자가 같은 onLiked 신호를 받지만, 각자 자기 방식으로만 반응하는 게 보이죠? 카운터는 숫자만 세고, 알림은 문구만 만들고, 로그는 한 줄만 쌓아요. 고치기 전엔 한 메서드 안에 엉켜 있던 세 줄이, 이제 서로 모르는 세 클래스로 깔끔하게 떨어졌어요.

쓸 때는 이렇게 좋아요 게시물에 관찰자 셋을 구독시켜요.

Java
LikeablePost post = new LikeablePost("post-1");
LikeCountObserver counter = new LikeCountObserver();
NotificationObserver notifier = new NotificationObserver();
post.subscribe(counter);
post.subscribe(notifier);

post.like("jaehoon");   // 등록된 관찰자들에게 한꺼번에 퍼져요

counter.getCount();           // 1
notifier.getLastMessage();    // jaehoon님이 회원님의 게시물을 좋아합니다

좋아요 한 번 누르는 그림을 화살표로 그려 보면 이래요. 한 사건이 구독자들에게 동시에 퍼지는 모습이에요.

텍스트
                        post.like("jaehoon")
                               │
                  "좋아요 눌렸어요!" 소문 한 번
            ┌──────────────────┼──────────────────┐
                                                
     LikeCountObserver  NotificationObserver  ActivityLogObserver
       (숫자만 센다)       (문구만 만든다)        (로그만 쌓는다)

이제 "좋아요에 활동 점수도 올려줘" 라는 새 요구가 와도, LikeablePost 와 기존 세 관찰자는 한 줄도 안 건드려요. ScoreObserver 라는 관찰자를 새로 만들어 subscribe 로 끼우기만 하면 끝이에요. 반대로 어떤 반응을 잠시 꺼두고 싶으면 unsubscribe 로 빼면 되고요. 반응을 떼었다 붙였다 할 수 있게 된 거예요.

🙋 학생 질문 — "튜터님, Step 2 의 Strategy 랑 이번 Observer 랑 비슷하게 인터페이스를 끼우는데, 뭐가 다른 거예요?"

겉모습이 닮아서 헷갈리기 딱 좋아요. 둘 다 인터페이스를 두고 갈아끼우니까요. 차이는 "몇 명이 일하느냐" 와 "왜 갈아끼우느냐" 에 있어요.

전략(Strategy)은 여러 후보 중 하나를 골라 쓰는 거예요. 알림을 이메일로 보낼지 푸시로 보낼지 — 그 순간 채널은 하나만 골라져요. "이번엔 이 방법으로 한다" 죠.

관찰자(Observer)는 한 사건을 여럿에게 동시에 퍼뜨리는 거예요. 좋아요 한 번에 카운터·알림·로그가 다 같이 반응해요. "한 사건, 여러 반응" 이죠.

그래서 전략은 "하나를 교체", 관찰자는 "여럿에게 방송" 이라고 기억하면 둘이 안 헷갈려요.

💡 오늘 기억할 한 줄: 사건을 알리는 쪽과 반응하는 쪽을 분리하면(Observer), 새 반응은 관찰자만 추가하고 기존 코드는 그대로 둬요. 한 사건이 구독자 여럿에게 동시에 퍼져요.


Step 4: Before/After ③ — Stream 으로 피드 고도화

세 번째로 손볼 곳은 피드를 만드는 코드예요. 인스타그램을 켜면 보이는 피드는, 내가 팔로우한 사람들의 게시물을 한데 모은 결과죠. 이 기능은 우리가 이미 Day 24 에서 만들었어요. 그때 만든 코드를 다시 꺼내 볼게요.

Java
// com/instagram/javabasic/service/solution/day24/FeedService.java
public class FeedService {

    private final MemberRepository memberRepository;
    private final PostRepository postRepository;

    public FeedService(MemberRepository memberRepository, PostRepository postRepository) {
        this.memberRepository = memberRepository;
        this.postRepository = postRepository;
    }

    // 피드 만들기 — 내가 팔로우한 사람들의 게시물을 모두 모아 돌려줘요.
    public List<Post> buildFeed(Long memberId) {
        Member me = memberRepository.findById(memberId);
        List<Post> feed = new ArrayList<>();
        for (int i = 0; i < me.getFollowingCount(); i++) {
            Member followee = me.getFollowing(i);
            feed.addAll(postRepository.findByAuthor(followee));
        }
        return feed;
    }
}

이 코드는 잘 돌아요. 그런데 자세히 보면 "for 안에서 또 글을 모으는" 중첩 구조예요. 팔로잉을 한 명씩 돌면서(바깥 for), 그 사람의 글을 빈 리스트에 차곡차곡 부어요(addAll). 빈 리스트(feed)를 먼저 만들고, 인덱스(i)를 직접 굴리고, 마지막에 그 리스트를 돌려주는 — 손이 여러 번 가는 방식이에요.

Day 24 에 이렇게 짠 건 그때 우리가 아직 Stream 을 안 배웠기 때문이에요. 하지만 지금 우리는 Day 26~27 에서 Stream 을 배웠죠. 같은 일을 훨씬 흐름 그대로 읽히게 다시 쓸 수 있어요.

중첩 for 를 한 흐름으로 펼쳐요

Stream 으로 고친 모습이에요. "팔로잉을 하나씩 → 그 사람 글들을 펼쳐서 → 한 줄로 모은다" 가 코드 그대로 읽혀요.

Java
// com/instagram/javabasic/capstone/StreamFeedService.java
public class StreamFeedService {

    private final MemberRepository memberRepository;
    private final PostRepository postRepository;

    public StreamFeedService(MemberRepository memberRepository, PostRepository postRepository) {
        this.memberRepository = memberRepository;
        this.postRepository = postRepository;
    }

    // 피드 만들기 — 중첩 for 를 Stream 으로 바꾼 모습이에요.
    public List<Post> buildFeed(Long memberId) {
        Member me = memberRepository.findById(memberId);
        return IntStream.range(0, me.getFollowingCount())
                .mapToObj(me::getFollowing)
                .flatMap(followee -> postRepository.findByAuthor(followee).stream())
                .toList();
    }
}

한 줄씩 따라가 볼게요. IntStream.range(0, me.getFollowingCount()) 는 팔로잉 인덱스 0, 1, 2... 를 흐름으로 펼쳐요. 고치기 전의 for (int i = ...) 를 대신하는 부분이에요. 다음 mapToObj(me::getFollowing) 은 그 인덱스로 팔로잉을 한 명씩 꺼내요 — me.getFollowing(i) 를 메서드 참조(me::getFollowing)로 짧게 적은 거예요(Day 25).

핵심은 flatMap 이에요. 팔로잉 한 명에게서 글이 여러 개 나오는데, 그 여러 개짜리 묶음들을 전부 한 줄기로 풀어 펼쳐 줘요. Day 26 에서 배운 "묶음 안의 묶음을 한 흐름으로 펴는" 그 도구죠. 마지막 toList() 로 한 리스트에 담아 돌려줘요. 빈 리스트를 미리 만들 필요도, addAll 을 부를 필요도 없어졌어요.

흐름을 그림으로 보면 이래요.

텍스트
 팔로잉 명단        각자의 글들                  flatMap 으로 한 줄기에 펴기
 ┌────────┐
 │ minji  │ ──  [점심글, 노을글]  ─┐
 ├────────┤                         ├─  [점심글, 노을글, 운동글]  ──  toList()
 │seungwoo│ ──  [운동글]          ─┘
 └────────┘

결과는 Day 24 버전과 똑같아요. 동작은 그대로 두고 모양만 바꾼 것 — 리팩토링의 약속을 지킨 거죠. 다만 읽는 사람 입장에선 "팔로잉을 펼쳐서, 글을 펼쳐서, 모은다" 는 의도가 코드에 그대로 드러나요.

정렬 한 줄만 더하면 인기순 피드예요

Stream 으로 바꾸면 좋은 점이 하나 더 있어요. "피드를 좋아요 많은 순으로 보여줘" 같은 확장이 한 줄로 끝나요.

Java
    // 인기순 피드 — 위에서 모은 피드를 좋아요 많은 순으로 정렬해 돌려줘요.
    public List<Post> buildPopularFeed(Long memberId) {
        return buildFeed(memberId).stream()
                .sorted(Comparator.comparingInt(Post::getLikeCount).reversed())
                .toList();
    }

방금 만든 buildFeed 로 게시물을 모은 다음, sorted 한 줄만 끼웠어요. Comparator.comparingInt(Post::getLikeCount) 는 좋아요 수를 기준으로 줄을 세우고, .reversed() 를 붙여 많은 쪽이 위로 오게 뒤집어요(Day 27). 만약 고치기 전의 중첩 for 버전에 인기순 정렬을 더하려면, 모으고 나서 정렬 코드를 또 따로 써야 했어요. Stream 에선 흐름 중간에 sorted 한 칸만 끼우면 끝이에요.

이게 Stream 의 힘이에요. "모으기 → 거르기 → 정렬하기" 같은 단계들을 부품처럼 끼웠다 뺐다 할 수 있어서, 새 요구가 와도 흐름 한 칸만 손보면 돼요.

🙋 학생 질문 — "튜터님, 그럼 for 문은 이제 안 좋은 거고 무조건 Stream 으로 바꿔야 하나요?"

전혀요. for 문은 지금도 멀쩡한 도구예요. "무조건 Stream" 이 정답은 아니에요.

Stream 이 빛나는 건 "데이터를 거르고·변형하고·모으는" 흐름이 이어질 때예요. 이번 피드처럼 펼치고(flatMap) 모으고(toList) 정렬하는(sorted) 단계가 줄줄이 엮이면, Stream 쪽이 의도가 훨씬 또렷하게 읽혀요.

반대로 단순히 "1 부터 10 까지 출력" 하거나, 중간에 흐름을 끊고 빠져나와야 하는(break) 복잡한 제어가 필요하면 평범한 for 가 더 읽기 쉬워요. 도구는 상황에 맞게 고르는 거지, 새 도구가 옛 도구를 몰아내는 게 아니에요.

💡 오늘 기억할 한 줄: 중첩 for 로 모으던 일을 Stream 의 flatMap 으로 펼치면 의도가 그대로 읽혀요. 정렬·거르기 같은 확장도 흐름에 한 칸 끼우면 되니, 새 요구에 강해져요.


Step 5: Before/After ④ — Optional 로 "없을 수도 있는 값" 안전하게

네 번째이자 마지막으로 손볼 곳은 회원을 찾아오는 코드예요. 인스타그램에선 회원을 번호표(id)로 찾는 일이 끊임없이 일어나요. 프로필을 열 때도, 댓글 작성자를 표시할 때도요. 그런데 그 번호표가 가리키는 회원이 항상 있다는 보장은 없어요. 이미 탈퇴했거나, 잘못된 번호표일 수도 있죠.

지금 우리 저장소(MemberRepository)의 findById 는 이런 모습이에요. 회원이 없으면 곧장 MemberNotFoundException 을 던져요.

Java
// com/instagram/javabasic/repository/MemberRepository.java (메서드만 발췌)
public Member findById(Long id) {
    Member found = store.get(id);
    if (found == null) {
        throw new MemberNotFoundException("id " + id + " 에 해당하는 회원이 없어요.");
    }
    return found;
}

이렇게 예외를 던지는 방식 자체는 잘못된 게 아니에요. 다만 받는 쪽이 그 예외를 잊으면 곤란해져요. findById 를 부른 코드가 try-catch 로 감싸지 않으면, 회원이 없는 순간 예외가 위로 쭉 타고 올라가 프로그램이 멈춰버려요. "여기선 예외가 날 수도 있다" 는 걸 받는 사람이 일일이 기억해야 하는 게 부담이죠.

Day 28 의 Optional 을 다시 꺼내요

이 부담을 덜어주는 도구를 우리는 Day 28 에서 배웠어요. 바로 Optional 이에요. Optional 은 "있을 수도, 없을 수도 있는 값" 을 상자에 담아 건네줘요. 받는 쪽은 상자를 열어보기 전엔 값을 못 꺼내니까, null 인지 확인하는 걸 깜빡할 수가 없어요.

같은 저장소를 본떠 만든 MemberLookup 에 두 가지 방식을 나란히 두고 비교해 볼게요. 하나는 고치기 전처럼 없으면 예외를 던지고, 다른 하나는 없으면 빈 상자를 돌려줘요.

Java
// com/instagram/javabasic/capstone/MemberLookup.java
public class MemberLookup {

    // 번호표(id) → 회원 짝을 보관하는 사물함이에요.
    private final Map<Long, Member> store = new HashMap<>();

    // 다음에 발급할 번호표예요. 저장할 때마다 1씩 올라가요.
    private long sequence = 0L;

    // 저장 — 다음 번호표를 뽑아 회원을 보관하고, 그 번호표(id)를 돌려줘요.
    public Long save(Member member) {
        sequence++;
        Long id = sequence;
        store.put(id, member);
        return id;
    }

    // [Before] 없으면 곧장 예외를 던지는 방식이에요.
    public Member findOrThrow(Long id) {
        Member found = store.get(id);
        if (found == null) {
            throw new MemberNotFoundException("id " + id + " 에 해당하는 회원이 없어요.");
        }
        return found;
    }

    // [After] 결과를 Optional 상자에 담아 돌려주는 방식이에요.
    // 회원이 있으면 그 회원이 담긴 상자를, 없으면 빈 상자를 줘요 — null 을 직접 건네지 않아요.
    public Optional<Member> find(Long id) {
        return Optional.ofNullable(store.get(id));
    }
}

find 메서드의 반환 타입이 Member 가 아니라 Optional<Member> 인 게 핵심이에요. Optional.ofNullable(...) 은 안에 든 값이 있으면 값이 담긴 상자를, null 이면 빈 상자를 만들어 줘요. 받는 쪽은 이제 "이 값이 null 일 수도 있나?" 를 고민할 필요가 없어요. 타입(Optional<Member>)만 봐도 "아, 없을 수도 있는 거구나" 가 바로 보이니까요.

Optional 을 이어 쓰면 null 검사가 사라져요

Optional 의 진짜 매력은 이어 쓰기(체이닝)에서 나와요. "회원이 있으면 그 이름을, 없으면 '(없는 회원)' 을 돌려줘" 를 if 문 없이 한 흐름으로 적을 수 있어요.

Java
    // Optional 을 이어 쓰는(체이닝) 모습이에요 —
    // 회원이 있으면 그 이름을, 없으면 "(없는 회원)" 을 돌려줘요. null 검사를 직접 안 해도 안전해요.
    public String usernameOrGuest(Long id) {
        return find(id)
                .map(Member::getUsername)
                .orElse("(없는 회원)");
    }

한 줄씩 따라가 볼게요. find(id) 가 회원 상자를 돌려줘요. .map(Member::getUsername) 은 상자 안에 회원이 있으면 그 회원의 이름을 꺼내 새 상자에 담고, 빈 상자면 그냥 빈 상자 그대로 흘려보내요(Day 28 에서 배운 그 map). 마지막 .orElse("(없는 회원)") 은 상자를 열어서, 값이 있으면 그 이름을, 없으면 준비해 둔 기본값 "(없는 회원)" 을 줘요.

여기서 단 한 번도 if (found == null) 같은 null 검사를 직접 쓰지 않은 점을 보세요. 만약 Optional 없이 findById 로 짰다면, 회원을 받아서 null 인지 일일이 따지거나 예외를 잡아야 했을 거예요. Optional 은 그 null 검사를 상자 안으로 가둬서, 우리가 깜빡할 수 없게 만들어 줘요.

그럼 예외 던지기는 틀린 걸까요?

여기서 오해하면 안 되는 게 있어요. "이제부터 예외 던지지 말고 무조건 Optional 써야지!" 가 아니에요. 둘은 상황에 따라 둘 다 옳아요.

텍스트
 정말 있어야 하는데 없으면      예외가 맞아요   (findOrThrow 방식)
   예: 로그인한 내 정보가 DB 에 없다? 이건 비정상 — 예외로 멈추는 게 안전

 없는 게 정상 범위 안이면       Optional 이 맞아요   (find 방식)
   예: 검색했는데 결과가 없다? 흔한 일 — 빈 상자로 받아 자연스럽게 처리

"반드시 있어야 하는데 없다" 면 그건 비정상 상황이니 예외를 던져 딱 멈추는 게 안전해요. 반대로 "없을 수도 있는 게 당연한" 상황이라면, 예외로 흐름을 끊기보다 Optional 빈 상자로 받아 부드럽게 이어가는 게 자연스러워요. 도구를 고르는 기준은 "없는 게 정상인가, 비정상인가" 예요.

🙋 학생 질문 — "튜터님, 그냥 null 을 돌려주고 받는 쪽에서 if (x != null) 로 확인하면 안 되나요?"

물론 그렇게도 돌아가긴 해요. 실제로 옛날 코드는 대부분 그렇게 짜여 있어요. 문제는 "깜빡한다" 는 데 있어요. findById 가 그냥 Member 를 돌려주면, 받는 사람은 그게 null 일 수 있다는 걸 코드만 봐선 알 수가 없어요. 그래서 null 검사를 빠뜨리고, 한참 뒤 엉뚱한 곳에서 NullPointerException 으로 터지죠.

Optional<Member> 는 타입 자체가 "이거 없을 수도 있어요" 라고 외쳐요. 받는 사람은 상자를 열어야만 값을 꺼낼 수 있으니, null 검사를 까먹을 수가 없어요. 같은 안전장치인데, 사람의 기억력에 기대는 대신 타입이 강제해 주는 거예요.

💡 오늘 기억할 한 줄: Optional 은 "없을 수도 있는 값" 을 상자에 담아 건네서, 받는 쪽이 null 검사를 깜빡 못 하게 해요. map·orElse 로 이어 쓰면 if 없이 안전하게 처리돼요. 단, "반드시 있어야 하는" 값엔 예외 던지기가 더 맞아요.


Step 6: 리팩토링이 동작을 보존했는지 테스트로 못박기

지금까지 우리는 알림·좋아요·피드·회원 조회 네 곳을 Before → After 로 다듬었어요. 그런데 한 가지 찜찜한 게 남았죠. "구조는 분명 깔끔해졌는데, 정말 고치기 전과 똑같이 동작하는 거 맞아?" 이 질문에 자신 있게 "네!" 라고 답하게 해 주는 게 바로 테스트예요.

리팩토링의 안전벨트는 테스트예요. Before 와 After 가 같은 결과를 낸다는 걸 테스트가 증명해 주면, 우리는 마음 놓고 구조를 바꿀 수 있어요. Day 46 에서 배운 JUnit 을 드디어 실전에서 써먹을 차례예요.

Before == After 를 직접 못박아요

먼저 Step 2 의 알림 리팩토링을 검증해요. 고치기 전(if-else 방식)과 고친 뒤(전략 방식)가 정말 똑같은 문구를 내는지 확인하는 거예요. 핵심은 둘의 결과를 assertEquals 로 나란히 비교하는 것이에요.

Java
// (테스트) com/instagram/javabasic/capstone/NaiveVsStrategyTest.java
@Test
@DisplayName("email 채널: if-else 방식과 전략 방식의 결과가 같다")
void email_행동보존() {
    String before = naive.send("email", "새 좋아요!");
    String after = new NotificationSender(new EmailChannel()).notify("새 좋아요!");
    assertEquals(before, after);
    assertEquals("[Email] 새 좋아요!", after);
}

before 는 고치기 전 if-else 방식으로 만든 결과, after 는 고친 뒤 전략 방식으로 만든 결과예요. assertEquals(before, after) 가 "이 둘은 같아야 한다" 를 못박아요. 한 줄이 더 있죠 — assertEquals("[Email] 새 좋아요!", after) 는 그 결과가 우리가 기대한 정확한 문구인지까지 확인해요. 푸시·문자 채널에 대해서도 똑같은 짝(push_행동보존·sms_행동보존)이 코드베이스에 들어 있어요.

이게 행동 보존(behavior preservation) 테스트예요. "구조는 바뀌었지만 동작은 그대로다" 를 컴퓨터가 증명해 주죠. 이런 테스트가 있으면, 나중에 또 구조를 손봐도 "어? 결과가 달라졌네" 를 곧바로 잡아낼 수 있어요.

여러 부품이 엮인 서비스도 통째로 검증해요

다음은 Step 4 의 피드 서비스예요. 회원 저장소·게시물 저장소·팔로우 관계가 한꺼번에 엮이는, 더 큰 검증이에요. Day 46 에서 배운 @BeforeEach 로 작은 세상을 먼저 차려요.

Java
// (테스트) com/instagram/javabasic/capstone/StreamFeedServiceTest.java
// (전체 코드: src/test/java/com/instagram/javabasic/capstone/StreamFeedServiceTest.java)
@BeforeEach
void setUp() {
    memberRepository = new MemberRepository();
    postRepository = new PostRepository();

    Member me = new Member("jaehoon", "jaehoon@insta.com");
    Member minji = new Member("minji", "minji@insta.com");
    Member seungwoo = new Member("seungwoo", "seungwoo@insta.com");
    // ... stranger 까지 저장 ...

    // 나는 민지와 승우만 팔로우 — stranger 는 팔로우하지 않아요.
    me.follow(minji);
    me.follow(seungwoo);

    postRepository.save(new Post("민지의 점심", minji, 5));
    // ... 노을·운동·모르는 사람 글까지 저장 ...

    streamFeedService = new StreamFeedService(memberRepository, postRepository);
}

@BeforeEach 가 매 테스트 직전에 이 세상을 새로 차려 줘요(Day 46). 재훈이가 민지·승우를 팔로우하고, stranger 는 팔로우하지 않는 상황이에요. 이제 두 가지를 확인해요. 먼저 피드에 팔로우한 사람 글만 모이는지 봐요.

Java
@Test
@DisplayName("Stream 피드는 팔로우한 사람 글만 모은다(모르는 사람 글은 빠진다)")
void buildFeed_팔로우한사람만() {
    List<Post> feed = streamFeedService.buildFeed(myId);

    assertAll(
            () -> assertEquals(3, feed.size()),
            () -> assertTrue(feed.stream().anyMatch(p -> p.getAuthorName().equals("minji"))),
            () -> assertTrue(feed.stream().anyMatch(p -> p.getAuthorName().equals("seungwoo"))),
            () -> assertFalse(feed.stream().anyMatch(p -> p.getAuthorName().equals("stranger")))
    );
}

assertAll 로 여러 조건을 한꺼번에 묶었어요(Day 46). 민지 글·승우 글은 들어 있고 stranger 글은 빠졌는지, 총 3 개인지를 한 번에 확인해요. 그런데 여기서 더 중요한 테스트가 하나 더 있어요 — Step 4 에서 우리가 Stream 으로 새로 쓴 피드가, Day 24 의 옛날 for 버전과 정말 같은 집합을 내는지 비교하는 거예요.

Java
@Test
@DisplayName("Stream 피드 결과는 Day 24 for 버전 FeedService 와 같은 집합이다")
void buildFeed_Day24와동일집합() {
    FeedService forVersion = new FeedService(memberRepository, postRepository);

    List<Post> streamFeed = streamFeedService.buildFeed(myId);
    List<Post> forFeed = forVersion.buildFeed(myId);

    // 같은 게시물들이 같은 개수로 모여요(Post.equals 는 작성자+내용 기준).
    assertEquals(forFeed.size(), streamFeed.size());
    assertTrue(streamFeed.containsAll(forFeed));
    assertTrue(forFeed.containsAll(streamFeed));
}

바로 이게 리팩토링의 안전벨트예요. 옛날 FeedService(for 버전)와 새 StreamFeedService(Stream 버전)를 같은 세상에 넣고 돌려서, 나온 결과가 서로 같은 게시물들을 같은 개수로 담고 있는지 비교해요. 이 테스트가 초록 불이면, "구조만 바꿨지 동작은 그대로다" 가 증명된 거예요. 이제 우리는 옛 버전을 마음 놓고 새 버전으로 갈아탈 수 있어요.

💡 오늘 기억할 한 줄: 리팩토링의 안전벨트는 테스트예요. Before 와 After 가 같은 결과를 낸다는 걸 assertEquals·containsAll 로 못박으면, 마음 놓고 구조를 바꿀 수 있어요.


Step 7: 경계와 예외 케이스 다지기

Step 6 에선 "잘 도는 경우(행복한 경로)" 를 주로 확인했어요. 그런데 진짜 안전한 코드는 잘 도는 경우만 봐선 안 돼요. "없을 때·빌 때·터질 때" 까지 확인해야 비로소 든든해져요. 사용자는 우리가 예상한 대로만 입력하지 않거든요.

Step 5 에서 만든 회원 조회를 다시 데려와요. 두 방식(없으면 예외 / 없으면 빈 상자)이 경계 상황에서 제대로 동작하는지 못박을 거예요. Day 46 에서 배운 assertThrows@ParameterizedTest 가 여기서 빛나요.

없으면 예외가 정말 터지는지

먼저 findOrThrow 예요. 없는 회원을 찾으면 정말 예외가 터지는지 확인해요.

Java
// (테스트) com/instagram/javabasic/capstone/MemberLookupTest.java
@Test
@DisplayName("findOrThrow: 없는 회원이면 MemberNotFoundException 을 던진다")
void findOrThrow_없으면_예외() {
    assertThrows(MemberNotFoundException.class, () -> lookup.findOrThrow(999L));
}

assertThrows(예외타입.class, () -> 코드) 는 그 코드가 정말 그 예외를 던지는지 봐요(Day 46). 터질 수 있는 코드를 () -> lookup.findOrThrow(999L) 람다로 감싸서 넘긴 게 보이죠? 그냥 호출하면 예외가 그 자리에서 터져 테스트가 멈추니까, 람다로 싸서 JUnit 이 대신 실행하며 지켜보게 하는 거예요.

없으면 빈 상자가 나오는지 (여러 입력으로 한 번에)

이번엔 Optional 방식이에요. 없는 id 를 여러 개 넣어도 모두 "(없는 회원)" 이 나오는지 한 표로 확인해요.

Java
@ParameterizedTest
@ValueSource(longs = {0L, 2L, 99L, 1000L})
@DisplayName("usernameOrGuest: 없는 여러 id 는 모두 '(없는 회원)' 을 돌려준다")
void usernameOrGuest_없으면_게스트(long missingId) {
    assertEquals("(없는 회원)", lookup.usernameOrGuest(missingId));
    assertFalse(lookup.find(missingId).isPresent());
}

@ParameterizedTest@ValueSource 로 입력만 바꿔 같은 검사를 네 번 돌려요(Day 46). 0, 2, 99, 1000 — 모두 없는 번호표인데, 어느 것을 넣어도 예외로 멈추지 않고 얌전히 "(없는 회원)" 이 나와요. 이게 Step 5 에서 말한 "없는 게 정상 범위" 인 상황의 안전함이에요.

빈 경계도 확인해요

마지막으로 빈 경계예요. 아무도 팔로우하지 않은 사람의 피드는 빈 리스트가 나와야 해요.

Java
// (테스트) com/instagram/javabasic/capstone/StreamFeedServiceTest.java
@Test
@DisplayName("아무도 팔로우하지 않으면 피드는 빈 리스트다")
void buildFeed_팔로잉0명() {
    Long loneId = memberRepository.save(new Member("lonely", "lonely@insta.com"));
    assertTrue(streamFeedService.buildFeed(loneId).isEmpty());
}

아무도 팔로우하지 않은 외톨이 회원의 피드를 만들면, Stream 흐름에 모을 글이 하나도 없으니 빈 리스트가 나와요. 이런 "빈 경우" 를 확인해 두지 않으면, 나중에 빈 피드에서 엉뚱한 오류가 나도 모르고 지나칠 수 있어요. 행복한 경로뿐 아니라 이런 경계까지 못박아 두는 게 진짜 안전망이에요.

💡 오늘 기억할 한 줄: 잘 도는 경우만 확인하면 절반만 한 거예요. assertThrows 로 터질 때를, @ParameterizedTest 로 여러 입력을, isEmpty 로 빈 경계를 함께 못박아야 든든해져요.


Step 8: 코드 리뷰 세션 — 흩어진 Before 넷을 통합 After 로

드디어 오늘의 마지막이에요. 지금까지 우리는 알림·좋아요·피드·회원 조회를 따로따로 고쳤어요. 그런데 실제 인스타그램에선 이것들이 따로 노는 게 아니라 한 흐름에서 맞물려 돌아가요. 좋아요 한 번을 누르면, 그 한 번이 여러 부품을 동시에 깨우거든요.

오늘 고친 네 조각이 실제로 어떻게 한 흐름으로 엮이는지, 코드 리뷰하듯 함께 읽어 볼게요. 시나리오는 이래요.

텍스트
 좋아요 한 번
    │
    ├─ 게시물이 구독자들에게 사건을 퍼뜨림    Observer (Step 3)
    │       └ 카운트·알림 문구·로그가 각자 반응
    │
    ├─ 만들어진 알림 문구를 채널 전략으로 발송  Strategy (Step 2)
    │
 회원 조회는 Optional 로 안전하게             Optional (Step 5)
 피드는 Stream 으로 모아서 인기순 정렬         Stream (Step 4)

부품들을 생성자로 넘겨받아 조립해요

이 흐름을 한곳에 모은 게 LikeFanoutFacade 예요. 눈여겨볼 점은, 이 클래스가 부품을 직접 만들지 않는다는 거예요. 다섯 개 부품을 모두 생성자로 넘겨받아요.

Java
// com/instagram/javabasic/capstone/LikeFanoutFacade.java
public class LikeFanoutFacade {

    private final LikeablePost post;
    private final NotificationObserver notificationObserver;
    private final NotificationSender notificationSender;
    private final MemberLookup memberLookup;
    private final StreamFeedService feedService;

    public LikeFanoutFacade(LikeablePost post,
                            NotificationObserver notificationObserver,
                            NotificationSender notificationSender,
                            MemberLookup memberLookup,
                            StreamFeedService feedService) {
        this.post = post;
        this.notificationObserver = notificationObserver;
        this.notificationSender = notificationSender;
        this.memberLookup = memberLookup;
        this.feedService = feedService;
    }

부품 다섯 개를 생성자로 받는 이 방식, 낯익죠? Day 24 에서 서비스에 저장소를 넘겨주던 것처럼, "객체를 직접 만들지 않고 생성자로 넘겨받기" 그대로예요. 무엇을 끼울지는 바깥에서 정해 주니까, 이메일 채널 대신 푸시 채널을 끼우고 싶으면 만들 때 바꿔 넣기만 하면 돼요.

좋아요 한 번에 부품들이 한꺼번에 깨어나요

이제 핵심 메서드 handleLike 를 봐요. 좋아요 한 번을 처리하는 통합 시나리오예요.

Java
    // ① 게시물에 좋아요를 알리면 구독한 관찰자들이 각자 반응해요(카운트·문구·로그).
    // ② 관찰자가 방금 만들어 둔 알림 문구를 꺼내, 전략이 정한 채널로 실제 발송 결과 문구를 만들어 돌려줘요.
    public String handleLike(String likedBy) {
        post.like(likedBy);
        String message = notificationObserver.getLastMessage();
        return notificationSender.notify(message);
    }

단 세 줄인데 오늘 고친 부품들이 다 들어 있어요. 첫 줄 post.like(likedBy) 는 관찰자 패턴(Step 3)이 일해요 — 좋아요 사건이 카운터·알림·로그 관찰자에게 한꺼번에 퍼져요. 둘째 줄은 알림 관찰자가 방금 만들어 둔 문구를 꺼내요. 셋째 줄 notificationSender.notify(message) 는 전략 패턴(Step 2)이 일해요 — 품고 있는 채널이 그 문구를 자기 형식으로 발송해요.

회원 조회와 피드도 한 메서드에 엮였어요.

Java
    // 어떤 회원의 인기순 피드를 돌려줘요 — Optional 로 회원 존재를 먼저 확인하고, 있으면 그 회원의 피드를 만들어요.
    // 없는 회원이면 빈 피드를 줘요(예외로 멈추지 않아요).
    public List<Post> popularFeedFor(Long memberId) {
        return memberLookup.find(memberId)
                .map(member -> feedService.buildPopularFeed(memberId))
                .orElse(List.of());
    }

memberLookup.find(memberId)Optional(Step 5)로 회원이 있는지 안전하게 확인하고, 있으면 Stream(Step 4)으로 만든 인기순 피드를 돌려줘요. 없으면 빈 피드를 줘서 예외로 멈추지 않아요. 네 패턴이 두 메서드 안에서 자연스럽게 한 몸이 된 거죠.

부품을 따로 테스트해 뒀으니 통합도 안심이에요

이 통합이 정말 도는지도 테스트로 확인해요. 좋아요 한 번에 관찰자 셋이 다 반응하고, 알림이 이메일 형식으로 나가는지 봐요.

Java
// (테스트) com/instagram/javabasic/capstone/LikeFanoutFacadeTest.java
@Test
@DisplayName("handleLike: 좋아요가 눌리면 관찰자들이 반응하고 이메일 채널로 알림이 발송된다")
void handleLike_이메일채널() {
    NotificationSender sender = new NotificationSender(new EmailChannel());
    LikeFanoutFacade facade =
            new LikeFanoutFacade(post, notifier, sender, memberLookup, feedService);

    String result = facade.handleLike("minji");

    // 관찰자 3종이 모두 반응했어요.
    assertEquals(1, counter.getCount());
    assertEquals("minji님이 회원님의 게시물을 좋아합니다", notifier.getLastMessage());
    assertEquals(1, logger.getLogs().size());
    // 알림은 전략(이메일 채널)이 정한 형식으로 발송됐어요.
    assertEquals("[Email] minji님이 회원님의 게시물을 좋아합니다", result);
}

좋아요 한 번(handleLike("minji"))에 카운터는 1 이 되고, 알림 관찰자는 문구를 만들고, 로그는 한 줄 쌓이고, 최종 발송 결과는 [Email] minji님이 회원님의 게시물을 좋아합니다 가 나와요. 네 부품이 한 흐름에서 정확히 맞물린 거예요. 그리고 채널 전략만 PushChannel 로 바꾸면(handleLike_푸시채널 테스트) 같은 사건이 [Push] ... 형식으로 나가요. 통합 안에서도 전략 패턴이 살아 있다는 증거죠.

지금까지 따로 다듬은 네 조각을 한 표로 정리하면 이래요.

텍스트
       Before (흩어진 안티패턴)              After (조립된 부품)
 ─────────────────────────────────────────────────────────────────
 알림    if-else 로 채널 분기              채널 전략 객체 (Strategy)
 좋아요  한 메서드에 세 반응 엉킴            관찰자 셋 (Observer)
 피드    중첩 for 로 모으기                flatMap 으로 펼치기 (Stream)
 조회    null/예외를 받는 쪽이 책임          Optional 상자로 안전하게
                          │
                          
              LikeFanoutFacade 가 부품을 끼워 조립
              (새 로직은 거의 없음 — 이미 만든 부품을 연결만)

여기서 가장 중요한 깨달음이 있어요. LikeFanoutFacade 에는 새 로직이 거의 없어요. 그냥 이미 만든 부품들을 생성자로 받아 끼워 맞췄을 뿐이에요. 어떻게 이게 가능했을까요? 각 부품(전략·관찰자·Stream 피드·Optional 조회)을 따로따로 단단히 테스트해 뒀기 때문이에요. 부품 하나하나가 제대로 도는 걸 알고 있으니, 그걸 조립한 통합도 안심하고 믿을 수 있는 거예요. 작은 부품을 잘 만들어 두면 큰 흐름은 조립만으로 완성된다 — 이게 오늘 우리가 47일치 도구로 보여준 마지막 장면이에요.

💡 오늘 기억할 한 줄: 따로 다듬은 부품(Strategy·Observer·Stream·Optional)을 생성자로 받아 끼우기만 하면 통합 흐름이 완성돼요. 부품을 각각 테스트해 뒀으니 조립한 전체도 안심할 수 있어요.


마무리

오늘은 새 문법 하나 없이, 47일 동안 쌓아 온 도구를 한자리에 모아 쓴 하루였어요. 흩어져서 조금 답답했던 코드 네 곳을 Before → After 로 다듬었죠.

  • 알림if-else 분기를 전략(Strategy) 패턴으로 바꿔, 새 채널은 파일만 추가하면 돼요(Day 41).
  • 좋아요 — 한 메서드에 엉킨 세 반응을 관찰자(Observer) 패턴으로 풀어, 반응을 떼었다 붙였다 할 수 있게 됐어요(Day 41).
  • 피드 — 중첩 for 를 Stream 의 flatMap 으로 펼쳐, 인기순 정렬 같은 확장이 한 줄로 끝나요(Day 26).
  • 회원 조회 — null 위험을 Optional 상자에 가둬, 받는 쪽이 안전하게 다루도록 했어요(Day 28).

그리고 이 모든 변화가 동작을 바꾸지 않았다는 걸 JUnit 으로 못박았어요(Day 46). Before 와 After 가 같은 결과를 내는지(행동 보존), 경계와 예외까지 제대로 도는지 테스트로 증명했죠. 마지막엔 흩어진 네 조각이 LikeFanoutFacade 한곳에서 어떻게 한 흐름으로 맞물리는지도 함께 읽었어요.

지난 시간 우리가 했던 약속 기억나시죠? "디자인 패턴·Stream/Optional·JUnit 을 총동원해서, 흩어진 인스타그램 도메인을 더 유연한 구조로 다시 다듬고 테스트로 단단히 감싸자" 고요. 오늘 그 약속을 지킨 거예요.

다음 시간엔 이 여정을 마무리하며 네 가지를 함께 해요. ① 오늘 우리가 쓴 테스트가 정말 좋은 테스트인지 F.I.R.S.T 다섯 성질로 점검하고, ② 47일 전체를 한 장의 지도로 정리하고, ③ 우리가 순수 자바로 만든 도메인이 앞으로 더 큰 도구를 만나면 어떻게 커지는지 살짝 맛보고, ④ 앞으로의 학습 길과 커리어까지 함께 그려 봐요.

변수 하나 선언하는 것도 낯설었던 첫날부터, 47일 동안 정말 먼 길을 걸어오셨어요. 오늘 여러분은 단순히 도구를 배운 사람이 아니라, 그 도구들을 실전에서 엮어 쓰고 테스트로 지켜낼 줄 아는 개발자가 됐어요. 수고 많으셨어요!


과제

오늘 배운 리팩토링 흐름을 직접 손에 익혀보는 과제예요. 모두 코드베이스에 이미 있는 부품을 활용하면 돼요. 한 단계씩 쌓아 올리도록 준비했어요.

과제 1: 카카오톡 채널을 람다 전략으로 추가하기 [기초]

Step 2 에서 만든 전략 패턴에 새 채널을 하나 더 끼워 보세요. 카카오톡(또는 웹훅) 채널을 만들어서, 기존 이메일과 함께 NotificationSender 로 알림을 보내보는 거예요. 핵심은 NotificationChannel 인터페이스가 메서드 하나뿐이니 클래스를 따로 만들 필요 없이 람다 한 줄로 전략을 줄 수 있다는 점이에요. NotificationChannel kakao = msg -> "[KakaoTalk] " + msg; 처럼 전략을 만들고, new NotificationSender(kakao).notify("...") 의 결과가 [KakaoTalk] ... 으로 나오는지 확인해 보세요. (힌트: NotificationSender 는 한 줄도 안 고쳐도 돼요.)

과제 2: 좋아요에 활동 점수 관찰자 추가하기 [응용]

Step 3 에서 만든 관찰자 패턴의 확장성을 직접 느껴볼 차례예요. 좋아요가 눌리면 "작성자 활동 점수 +1" 같은 새 반응을 추가하되, 조건이 하나 있어요 — LikeablePost 와 기존 관찰자 세 개(LikeCountObserver·NotificationObserver·ActivityLogObserver)는 한 줄도 고치지 마세요. PostObserver 를 구현하는 ScoreObserver 를 새로 만들어 subscribe 로 끼우기만 하면 돼요. 좋아요를 세 번 눌렀을 때 점수가 3 이 되는지 테스트로 확인해 보세요. (힌트: 새 관찰자도 onLiked 안에서 자기 일만 하면 돼요.)

과제 3: 흩어진 안티패턴 하나를 골라 Before → After + 행동 보존 테스트 [심화]

오늘의 흐름을 처음부터 끝까지 직접 밟아보는 과제예요. 우리 코드베이스에서 조금 답답한 조회·집계 로직 하나를 골라(예: if-else 로 등급을 따지거나, for 로 무언가를 세는 코드), 이미 배운 패턴이나 Stream/Optional 로 Before → After 리팩토링을 해 보세요. 가장 중요한 건 마무리예요 — Before 버전과 After 버전을 같은 입력으로 돌려 결과가 똑같은지 assertEquals 로 증명하는 행동 보존 테스트를 꼭 함께 작성하세요. 이 테스트가 초록 불이어야 "동작은 그대로, 구조만 더 좋게" 를 자신 있게 말할 수 있어요. (힌트: Step 6 의 email_행동보존·buildFeed_Day24와동일집합 테스트가 좋은 본보기예요.)


생각해볼 주제

1. 패턴은 공짜가 아니다 — 채널이 두세 개뿐일 때도 전략 패턴을 써야 할까?

Step 2 에서 우리는 if-else 알림 코드를 전략 패턴으로 바꿨어요. 그런데 만약 채널이 이메일·푸시 단 두 개뿐이고, 앞으로도 늘어날 일이 거의 없다면 어떨까요? 전략 패턴은 인터페이스 하나에 클래스 여러 개로 파일 수가 늘어나는 비용이 있어요. "확장에 강한 구조" 와 "지금 당장 단순한 코드" 사이에서 무엇을 기준으로 패턴 도입 여부를 정해야 할지, 과한 추상화의 경계는 어디인지 생각해 보세요.

2. 리팩토링하면서 "동작이 그대로다" 를 어떻게 확신할까?

오늘 우리는 구조를 네 군데나 뜯어고쳤어요. 그러면서도 "동작은 안 변했다" 고 말할 수 있었던 건 행동 보존 테스트 덕분이었죠. 만약 그 테스트가 없었다면 어땠을까요? 눈으로 코드를 읽어 확인하는 것과, 테스트로 자동 검증하는 것 사이엔 어떤 차이가 있을까요? 테스트가 리팩토링에서 어떤 안전벨트 역할을 하는지, 그리고 테스트 없이 구조를 바꾸는 게 왜 위험한지 생각해 보세요.

3. 좋은 코드는 누구를 위한 걸까?

오늘 고친 코드들은 사실 고치기 전에도 멀쩡히 잘 돌아갔어요. 컴퓨터 입장에선 if-else 든 전략 패턴이든 결과가 같으니까요. 그렇다면 우리는 누구를 위해 구조를 다듬은 걸까요? 잘 도는 코드와, 다음에 그 코드를 읽고 고칠 사람이 이해하기 쉬운 코드 사이에서 무엇이 더 중요할까요? 가독성과 유지보수가 왜 "지금의 나" 보다 "미래의 누군가" 를 위한 투자인지 생각해 보세요.

✅ 예시 답안정답 보기

아래는 과제와 생각해볼 주제의 예시답안이에요. 정답이 하나만 있는 건 아니니, 내가 고친 코드·내가 떠올린 답과 비교하며 "왜 이렇게 했는지" 를 짚어보세요.

과제 예시답안

과제 1 예시답안 — 카카오톡 채널을 람다 전략으로 추가하기

핵심 접근

이 과제의 핵심은 "새 채널을 끼우는데 NotificationSender 는 한 줄도 안 고친다" 예요. NotificationChannel 은 메서드가 send 단 하나뿐인 인터페이스라, 클래스 파일을 새로 만들 필요 없이 msg -> "[KakaoTalk] " + msg 람다 한 줄로 전략을 줄 수 있어요. 이메일로 시작한 SenderchangeChannel 로 카카오톡 전략을 갈아끼우면, 같은 객체 하나가 발송 형식만 바꿔서 내보내요.

예시 구현

Java
// (테스트) com/instagram/javabasic/capstone/solution/day47/LambdaChannelAdditionTest.java
class LambdaChannelAdditionTest {

    @Test
    @DisplayName("이메일 전략으로 시작하면 [Email] 형식으로 보낸다")
    void sends_with_email_channel_by_default() {
        NotificationSender sender = new NotificationSender(new EmailChannel());

        String result = sender.notify("새 팔로워!");

        assertEquals("[Email] 새 팔로워!", result);
    }

    @Test
    @DisplayName("카카오톡 람다 전략으로 갈아끼우면 [KakaoTalk] 형식으로 보낸다")
    void sends_with_kakao_lambda_after_change() {
        NotificationSender sender = new NotificationSender(new EmailChannel());

        // 새 채널을 클래스 하나 안 만들고 람다 한 줄로 정의해요.
        NotificationChannel kakao = msg -> "[KakaoTalk] " + msg;
        sender.changeChannel(kakao);

        String result = sender.notify("새 팔로워!");

        assertEquals("[KakaoTalk] 새 팔로워!", result);
    }
}

미션별 답

  1. 람다 전략 정의: NotificationChannel kakao = msg -> "[KakaoTalk] " + msg; 한 줄이 곧 하나의 전략이에요. send 메서드 하나만 채우면 되니 클래스를 따로 만들 이유가 없어요.
  2. 결과 확인: 이메일로 시작한 SenderchangeChannel(kakao) 로 갈아끼우면 notify("새 팔로워!") 결과가 [KakaoTalk] 새 팔로워! 로 나와요. 같은 Sender 객체 하나가 품은 전략만 바꿔 다른 형식으로 보낸다는 게 핵심이에요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
람다 전략 클래스 없이 msg -> ... 람다로 채널을 정의했는가
Sender 무수정 NotificationSender 를 한 줄도 안 고치고 채널을 끼웠는가
결과 형식 notify 결과가 [KakaoTalk] ... 으로 정확히 나오는가
읽기 좋은 이름 @DisplayName 으로 무엇을 확인하는지 드러냈는가

흔한 실수

  • 굳이 KakaoChannel implements NotificationChannel 클래스를 새로 만드는 경우 → 틀린 건 아니지만, 메서드 하나뿐인 인터페이스는 람다가 더 가벼워요. 이 과제의 의도는 "클래스 없이도 전략을 줄 수 있다" 를 손에 익히는 거예요.
  • NotificationSender 안에 if (channel == kakao) 같은 분기를 넣으려는 경우 → 그건 전략 패턴을 도로 if-else 로 되돌리는 거예요. Sender 는 어떤 채널인지 몰라도 돼요. 그저 품은 전략에게 send 를 맡기기만 하면 끝이에요.

실무 개선 포인트 (심화)

람다가 빛나는 건 동작이 짧을 때예요. 그런데 카카오톡 채널에 "토큰 발급 → 재시도 3회 → 실패 로그 남기기" 같은 설정이 줄줄이 붙기 시작하면, 람다 한 줄에 다 욱여넣기 답답해져요. 그럴 땐 다시 KakaoChannel 클래스로 꺼내서 그 안에 설정을 정리하는 게 읽기 좋아요. "동작이 단순하면 람다, 설정과 상태가 붙으면 클래스" 라는 기준만 기억해두면, 둘 사이를 자연스럽게 오갈 수 있어요. 같은 전략 패턴이라도 람다와 클래스는 상황에 맞게 고르는 두 카드예요.


과제 2 예시답안 — 좋아요에 활동 점수 관찰자 추가하기

핵심 접근

이 과제의 마침표는 "기존 코드를 한 줄도 안 고치고 새 반응만 끼운다" 예요. LikeablePost 도, 기존 관찰자 세 개도 그대로 두고, PostObserver 를 구현하는 ScoreObserver 클래스 하나만 새로 만들어 subscribe 로 끼워요. 새 관찰자는 onLiked 안에서 점수를 1 올리는 자기 일만 해요. 좋아요 세 번이면 점수가 정확히 3 이 되는지로 확장이 제대로 됐는지 확인해요.

예시 구현

Java
// com/instagram/javabasic/capstone/solution/day47/ScoreObserver.java
public class ScoreObserver implements PostObserver {

    // 지금까지 쌓인 활동 점수예요. 좋아요 한 번에 1씩 올라가요.
    private int score;

    @Override
    public void onLiked(String postId, String likedBy) {
        score++;
    }

    // 지금까지 쌓인 활동 점수를 돌려줘요.
    public int getScore() {
        return score;
    }
}
Java
// (테스트) com/instagram/javabasic/capstone/solution/day47/ScoreObserverTest.java
@Test
@DisplayName("좋아요 3번이면 점수 관찰자와 카운트 관찰자가 둘 다 3을 본다")
void score_and_count_both_reach_three_after_three_likes() {
    LikeablePost post = new LikeablePost("post-1");
    ScoreObserver scorer = new ScoreObserver();
    LikeCountObserver counter = new LikeCountObserver();

    // 새 관찰자(점수)와 기존 관찰자(카운트)를 같은 게시물에 둘 다 구독해요.
    post.subscribe(scorer);
    post.subscribe(counter);

    post.like("minji");
    post.like("jaehoon");
    post.like("seungwoo");

    assertEquals(3, scorer.getScore());
    assertEquals(3, counter.getCount());
}

미션별 답

  1. 새 관찰자 추가: ScoreObserver implements PostObserveronLikedscore++ 만 적었어요. 이 한 클래스가 곧 "좋아요에 점수도 올린다" 는 새 반응이에요.
  2. 기존 코드 무수정: LikeablePost·LikeCountObserver·NotificationObserver·ActivityLogObserver 는 한 줄도 안 건드렸어요. subscribe(scorer) 한 줄로 끼우기만 했죠.
  3. 좋아요 3번 → 점수 3: 같은 게시물에 점수 관찰자와 카운트 관찰자를 둘 다 구독시키고 좋아요를 세 번 누르면, 둘 다 똑같이 3 을 봐요. 한 사건이 여러 관찰자에게 동시에 퍼진다는 증거예요. 구독을 unsubscribe 로 빼면 그 뒤 좋아요부터는 점수가 안 오르는 것도 함께 확인해 두면 좋아요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
인터페이스 구현 PostObserver 를 구현해 onLiked 안에서 점수를 올렸는가
기존 코드 무수정 LikeablePost·기존 관찰자 셋을 한 줄도 안 고쳤는가
구독 동작 subscribe 로 끼우고 좋아요 3번에 점수 3을 확인했는가
구독 해제 unsubscribe 뒤로는 점수가 안 오르는 걸 확인했는가

흔한 실수

  • LikeablePost.like 안에 점수 올리는 줄을 직접 끼워 넣는 경우 → 이건 고치기 전의 "한 메서드에 세 반응이 엉킨" 강결합으로 되돌아가는 거예요. 관찰자 패턴의 핵심은 사건을 알리는 쪽(LikeablePost)이 무슨 반응이 붙는지 몰라도 되는 거예요. 점수 로직은 새 관찰자 안에만 있어야 해요.
  • 다 쓴 관찰자를 unsubscribe 안 하는 경우 → 이 과제 자체는 테스트가 짧게 끝나 문제가 없지만, Day 43 에서 배운 것처럼 관찰자를 구독만 하고 안 빼면 그 참조가 계속 살아남아 메모리 누수로 이어질 수 있어요. "더 안 들을 거면 구독을 뺀다" 는 습관을 같이 들이면 좋아요.

실무 개선 포인트 (심화)

지금 LikeablePost.like 는 관찰자 명단을 for 로 돌며 한 명씩 onLiked 를 불러요. 그런데 관찰자 중 하나가 onLiked 안에서 예외를 던지면 어떻게 될까요? 그 자리에서 for 가 멈춰, 뒤에 있던 관찰자들은 통지를 못 받아요 — 점수는 올랐는데 로그는 안 쌓이는 어긋남이 생기죠. 실무에선 통지 하나가 터져도 나머지는 계속 돌도록, 각 관찰자 호출을 try-catch 로 감싸 예외를 격리하는 경우가 많아요. 통지 순서가 결과에 영향을 주면 안 된다는 점, 그리고 한 관찰자의 실패가 다른 관찰자로 번지지 않게 한다는 점 — 이 두 가지가 관찰자 패턴을 실전에서 단단하게 만드는 포인트예요.


과제 3 예시답안 — Before → After + 행동 보존 테스트

핵심 접근

이 과제의 안전벨트는 행동 보존 테스트예요. "좋아요 N개 이상인 게시물 수 세기" 를 평범한 for(Before)와 Stream(After) 두 방식으로 만든 뒤, 같은 입력을 두 방식에 똑같이 넣어 결과가 같은지 assertEquals 로 못박아요. 여러 기준값을 한 번에 돌리는 건 @ParameterizedTest 가 딱이고요. 한 가지 조심할 건 count() 의 결과 타입이 long 이라, for 결과(int)를 long 으로 맞춰 비교해야 한다는 점이에요.

예시 구현

Java
// com/instagram/javabasic/capstone/solution/day47/PopularPostCounter.java
public class PopularPostCounter {

    // Before — 평범한 for 루프로 하나씩 보며 직접 세는 방식이에요.
    public int countWithFor(List<Post> posts, int minLikes) {
        int count = 0;
        for (Post post : posts) {
            if (post.getLikeCount() >= minLikes) {
                count++;
            }
        }
        return count;
    }

    // After — Stream 으로 조건에 맞는 것만 거른 뒤 개수를 세는 방식이에요.
    // count() 의 결과 타입은 long 이라 반환 타입도 long 이에요.
    public long countWithStream(List<Post> posts, int minLikes) {
        return posts.stream()
                .filter(post -> post.getLikeCount() >= minLikes)
                .count();
    }
}
Java
// (테스트) com/instagram/javabasic/capstone/solution/day47/PopularPostCounterTest.java
@ParameterizedTest
@ValueSource(ints = {0, 5, 50, 100})
@DisplayName("여러 기준값에서 for 방식과 Stream 방식의 결과가 항상 같다")
void for_and_stream_agree_on_various_thresholds(int minLikes) {
    List<Post> posts = mixedPosts();

    int fromFor = counter.countWithFor(posts, minLikes);
    long fromStream = counter.countWithStream(posts, minLikes);

    // for 결과(int)를 long 으로 맞춰 비교해요. 두 방식의 행동이 보존됐는지 확인해요.
    assertEquals((long) fromFor, fromStream);
}

미션별 답

  1. Before → After: for 로 변수 만들고 조건 맞으면 1 올리던 방식을, filter(...).count() 흐름으로 다시 썼어요. 동작은 "기준 이상인 게시물 수 세기" 그대로 두고 모양만 바꾼 거예요.
  2. 행동 보존 테스트: 기준값 0, 5, 50, 100 을 @ParameterizedTest 로 한 번에 돌려, 어느 기준에서도 for 결과와 Stream 결과가 같은지 assertEquals((long) fromFor, fromStream) 로 못박았어요. 이 테스트가 초록 불이면 "구조만 바꿨지 동작은 그대로다" 를 자신 있게 말할 수 있어요.
  3. 경계까지: 빈 리스트(둘 다 0), 기준값 0(전부 통과해 게시물 수만큼), 아무도 못 넘는 높은 기준값(둘 다 0) 세 경계도 따로 확인했어요. 행복한 경로뿐 아니라 빈 경우·전부 통과·전부 탈락까지 봐야 든든해져요.

채점 포인트

포인트 무엇을 봐야 하는가 배점 가중
동일 결과 Before(for)와 After(Stream)가 같은 입력에 같은 결과를 내는가
행동 보존 테스트 두 방식 결과를 assertEquals 로 나란히 비교했는가
int/long 처리 count()long 임을 알고 (long) 캐스팅으로 맞췄는가
경계 케이스 빈 리스트·기준값 0·높은 기준값 등 경계를 확인했는가

흔한 실수

  • 행동 보존 테스트 없이 for 를 곧장 Stream 으로 갈아치우는 경우 → 동작이 같다는 보장이 사라져요. 리팩토링의 핵심은 "구조를 바꿨지만 동작은 그대로" 인데, 그걸 증명하는 테스트가 없으면 "그대로일 거야" 라는 짐작에 기대게 돼요. 이 과제는 After 보다 테스트가 더 중요해요.
  • intlong 을 그냥 비교하려다 헷갈리는 경우 → count()long 을 돌려줘요. assertEquals(fromFor, fromStream) 처럼 intlong 을 섞으면 의도와 다른 메서드가 골라질 수 있으니, (long) fromFor 로 타입을 맞춰 비교하는 게 안전해요.
  • 행복한 경로만 보고 경계를 빼먹는 경우 → 빈 리스트나 "아무도 못 넘는 기준값" 같은 경계에서 엉뚱한 값이 나오는 버그는 평범한 입력만으론 안 잡혀요. 경계를 한두 줄 더해두면 안전망이 촘촘해져요.

실무 개선 포인트 (심화)

여기서 한 가지 짚어둘 게 있어요 — "그럼 for 는 이제 나쁜 거고 무조건 Stream 이 정답인가?" 는 아니에요. 단순히 개수를 세는 정도면 for 도 충분히 읽기 좋고, 오히려 흐름이 짧을 땐 for 가 더 직관적일 때도 있어요. Stream 이 값을 하는 건 "거르고·변형하고·모으고·정렬하는" 단계가 줄줄이 엮일 때예요 — 그땐 의도가 코드에 그대로 드러나죠. 또 데이터가 아주 많아지면 StreamparallelStream 으로 병렬 처리로 넓힐 여지도 있고요(단, 단순 카운트엔 병렬화가 오히려 손해예요). 도구는 상황에 맞게 고르는 거지, 새 도구가 옛 도구를 몰아내는 게 아니라는 걸 기억해두면 좋아요.


생각해볼 주제 예시답안

생각해볼 주제 1 예시답안 — 채널이 두세 개뿐일 때도 전략 패턴을 써야 할까

[문제 상황 요약]

Step 2 에서 우리는 if-else 알림 코드를 전략 패턴으로 바꿨어요. 그런데 채널이 이메일·푸시 단 두 개뿐이고 앞으로도 늘 일이 거의 없다면, 인터페이스 하나에 클래스 여러 개로 파일만 늘리는 비용이 아까워 보여요. "확장에 강한 구조" 와 "지금 당장 단순한 코드" 사이에서 무엇을 기준으로 패턴 도입 여부를 정할지에 대한 질문이에요.

[튜터의 가이드 및 해설]

먼저 분명히 할 게 있어요. 패턴은 공짜가 아니에요. 전략 패턴을 도입하면 인터페이스 하나에 채널마다 클래스 하나씩, 파일 수가 늘어나고 코드가 여러 군데로 흩어져요. 읽는 사람은 "이 채널이 실제로 뭘 하는지" 보려고 파일을 몇 개 더 열어봐야 하죠. 이 비용을 무시하고 모든 분기를 패턴으로 만들면, 오히려 단순한 코드를 복잡하게 만드는 과한 추상화가 돼요.

그래서 판단 기준은 "변할 곳인가" 예요. 분기가 앞으로 계속 늘어날 게 뻔하면 미리 갈아끼울 수 있게 열어두는 게 이득이에요. 알림 채널은 서비스가 크면 이메일·푸시·문자·카카오·웹훅처럼 계속 불어나는 쪽이라, 전략 패턴이 값을 해요. 새 채널이 파일 하나 추가로 끝나고 기존 코드는 안 건드리니까요.

반대로 "절대 안 늘 거다" 싶은 두세 개짜리 분기는 if-else 가 더 정직해요. 한곳에서 다 보이고, 읽으려고 파일을 더 열 필요도 없죠. 여기엔 YAGNI("필요해질 때까지 만들지 마라") 라는 감각이 깔려 있어요. "언젠가 늘지도 몰라" 라는 막연한 가정만으로 패턴을 미리 깔면, 오지 않을 미래를 위해 지금의 단순함을 버리는 셈이에요.

그러니 결론은 "패턴이 좋다 / 나쁘다" 가 아니라, "변할 곳에만 패턴을 쓴다" 예요. 분기 개수와 "앞으로 더 늘어난다" 는 신호를 함께 보고 정하면 돼요. 신호가 안 보이면 if-else 로 두고, 늘어나는 게 보이기 시작할 때 그때 전략으로 빼도 늦지 않아요. 처음부터 완벽한 구조를 깔기보다, 변화의 조짐을 보고 그 자리에만 유연함을 더하는 게 현실적인 선택이에요.

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

"저는 분기가 두세 개로 끝나고 더 늘어날 신호가 없으면 if-else 가 더 정직하다고 봅니다. 전략 패턴은 파일이 늘고 코드가 흩어지는 비용이 분명히 있으니까요. 패턴을 도입하는 기준은 '변할 곳인가' 입니다. 분기가 계속 늘어나는 게 보이고, 새 분기를 추가할 때마다 기존 코드를 또 열어야 한다면 — 그때 전략 패턴으로 빼서 확장에는 열고 수정에는 닫습니다. YAGNI 관점에서, 오지 않을 미래를 위해 지금의 단순함을 미리 버리지는 않습니다."


생각해볼 주제 2 예시답안 — 리팩토링하면서 "동작이 그대로다" 를 어떻게 확신할까

[문제 상황 요약]

오늘 우리는 구조를 네 군데나 뜯어고치고도 "동작은 안 변했다" 고 말할 수 있었어요. 행동 보존 테스트 덕분이었죠. 만약 그 테스트가 없었다면 어땠을까요? 눈으로 코드를 읽어 확인하는 것과 테스트로 자동 검증하는 것 사이엔 어떤 차이가 있고, 테스트 없이 구조를 바꾸는 게 왜 위험한지에 대한 질문이에요.

[튜터의 가이드 및 해설]

먼저 "눈으로 읽어 확인" 의 한계를 짚어볼게요. 사람의 눈은 생각보다 쉽게 속아요. forStream 으로 바꾸면서 >=> 로 잘못 적어도, 코드를 쭉 읽을 땐 "어차피 같은 일 하는 거지" 하고 넘어가기 쉬워요. 더구나 리팩토링은 보통 여러 곳을 동시에 건드리는데, 한 곳을 고치다 멀리 떨어진 다른 곳이 깨지는 걸 눈으로 다 따라가는 건 거의 불가능해요. 결국 "동작 그대로일 거야" 라는 짐작에 기대게 되죠.

그래서 행동 보존 테스트가 안전벨트가 돼요. 핵심은 단순해요 — 고치기 전 결과를 먼저 테스트로 고정해 초록 불을 만들어 두고, 그 초록 불이 유지되는지 보면서 구조만 바꾸는 거예요. 오늘 과제 3 에서 for 결과와 Stream 결과를 assertEquals 로 나란히 비교한 게 바로 이거예요. 구조를 바꿨는데 결과가 달라지면, 그 순간 빨간 불이 정확히 어디가 어긋났는지 알려줘요. 짐작이 아니라 증명으로 바뀌는 거죠.

차이를 한 줄로 정리하면 이래요.

  • 눈으로 읽기: 빠르지만 사람의 주의력에 기대요. 놓치는 순간 버그가 그대로 흘러가고, 한참 뒤 엉뚱한 곳에서 터져요.
  • 자동 테스트: 처음 짜는 손이 좀 들지만, 한 번 박아두면 구조를 몇 번을 바꿔도 매번 같은 잣대로 검증해줘요. 회귀(고쳤다가 옛 버그가 되살아남)를 그 자리에서 잡아요.

테스트 없이 구조를 바꾸는 건 눈 감고 운전하는 것과 비슷해요. 잘 도착할 수도 있지만, 어디서 벗어났는지 알 길이 없죠. 그래서 리팩토링의 전제 조건은 "먼저 테스트로 현재 동작을 고정하는 것" 이에요. 고칠 코드에 테스트가 없다면, 리팩토링을 시작하기 전에 테스트부터 깔아두는 게 순서예요.

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

"리팩토링의 전제 조건은 '먼저 테스트로 현재 동작을 고정하는 것' 입니다. 눈으로 읽어 확인하는 건 빠르지만 사람의 주의력에 기대서, 여러 곳을 동시에 건드리는 리팩토링에선 놓치는 변화를 따라가기 어렵습니다. 저는 고치기 전 결과를 테스트로 박아 초록 불을 만들고, 그 초록 불을 유지하며 구조만 바꿉니다. 결과가 달라지면 빨간 불이 어디가 어긋났는지 정확히 알려주죠. 테스트 없이 구조를 바꾸는 건 회귀를 짐작에 맡기는 일이라, 고칠 코드에 테스트가 없으면 리팩토링 전에 테스트부터 깝니다."


생각해볼 주제 3 예시답안 — 좋은 코드는 누구를 위한 걸까

[문제 상황 요약]

오늘 고친 코드들은 사실 고치기 전에도 멀쩡히 잘 돌아갔어요. 컴퓨터 입장에선 if-else 든 전략 패턴이든 결과가 같으니까요. 그렇다면 우리는 누구를 위해 구조를 다듬은 걸까요? 잘 도는 코드와, 다음에 그 코드를 읽고 고칠 사람이 이해하기 쉬운 코드 중 무엇이 더 중요한지에 대한 질문이에요.

[튜터의 가이드 및 해설]

먼저 분명히 할 게 있어요. 컴퓨터에게는 if-else 든 전략 패턴이든 forStream 이든 똑같아요. 둘 다 같은 결과를 내니까요. 그러니 오늘 우리가 구조를 다듬은 건 컴퓨터를 위해서가 아니에요. 차이가 생기는 곳은 딱 하나, 그 코드를 다음에 읽고 고칠 사람이에요.

그런데 그 "다음 사람" 이 누구냐가 핵심이에요. 흔히 동료나 후임을 떠올리지만, 사실 가장 먼저 그 코드를 다시 읽는 사람은 6개월 뒤의 나 자신인 경우가 많아요. 지금은 머릿속에 다 들어 있어서 if-else 가 아무리 길어도 술술 읽히지만, 반년 뒤엔 내가 짠 코드인데도 "이게 왜 이렇게 되어 있지?" 하고 한참 헤매게 돼요. 가독성은 그래서 남을 위한 배려이기 전에, 미래의 나를 위한 투자예요.

조금 더 넓게 보면, 이건 비용의 문제이기도 해요. 코드는 한 번 쓰고 끝나는 게 아니라 계속 읽히고 고쳐져요. 소프트웨어의 생애에서 코드를 처음 쓰는 시간보다 그 뒤로 읽고 고치는 시간이 훨씬 길어요. 그러니 "지금 빨리 돌게만 하면 된다" 로 짠 코드는, 당장은 시간을 아낀 것 같아도 앞으로 그걸 읽고 고칠 사람들의 시간을 두고두고 갉아먹어요. 반대로 읽기 좋게 다듬는 데 지금 조금 더 쓰면, 그 비용을 미래에 여러 번 돌려받죠.

그러니 결론은 "잘 도는 코드 vs 읽기 좋은 코드" 의 양자택일이 아니에요. 잘 도는 건 기본이고, 그 위에 읽기 좋음을 더하는 거예요. 오늘 우리가 한 리팩토링이 정확히 그거였어요 — 동작은 그대로 두고(잘 돈다), 다음 사람이 이해하기 쉽게 구조만 다듬었죠(읽기 좋다). 좋은 코드는 컴퓨터가 아니라 사람을 위한 거고, 그 첫 번째 사람은 미래의 나라는 걸 기억하면, 왜 가독성에 시간을 쓰는지가 분명해져요.

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

"코드는 컴퓨터가 실행하지만, 결국 사람이 읽고 고칩니다. 컴퓨터에게는 if-else 든 전략 패턴이든 결과가 같으니, 가독성은 오롯이 다음에 그 코드를 읽을 사람을 위한 겁니다. 그리고 그 첫 번째 사람은 보통 6개월 뒤의 저 자신이고요. 소프트웨어 생애에서 코드를 쓰는 시간보다 읽고 고치는 시간이 훨씬 길기 때문에, 읽기 좋게 다듬는 비용은 유지보수에서 여러 번 돌려받는 투자라고 봅니다. 그래서 '잘 도는 코드' 를 기본으로 깔고, 그 위에 '다음 사람이 이해하기 쉬운 구조' 를 더하는 걸 리팩토링의 목적으로 삼습니다."

전체 목록 자바 기초