Notification Service

LLD

Understand it step by step (한국어로 이해 → 영어로 말하기)

사용자에게 여러 채널(이메일, SMS, 푸시)로 알림을 보내는 시스템을 설계합니다. 한 알림을 사용자가 허용한 채널로만 보내고, 전송에 실패하면 정책에 따라 재시도할 수 있어야 합니다. 새로운 채널을 코드 수정 없이 쉽게 추가할 수 있게 확장성도 고려합니다.
  1. 1

    먼저 요구사항을 정리하고 핵심 데이터를 정합니다. 채널 종류는 정해져 있으니 enum으로 만들고, 알림 한 건은 변하지 않는 값이라 record로 만듭니다. 알림은 받는 사람, 채널, 제목, 본문을 가집니다.

    ⚖️ Trade-off: 채널을 enum으로 둘지, 클래스로 둘지 고민됩니다. 채널이 무한히 늘어나면 enum이 부담일 수 있습니다.

    ✅ Fix: 채널 종류 자체는 고정된 분류값이라 enum이 맞고, 채널별 '전송 행동'은 따로 분리합니다. 즉 enum은 식별자만, 동작은 다른 곳에 둡니다.

    🔧 도구:enumrecordSRP

    enum Channel { EMAIL, SMS, PUSH }
    
    record Notification(String userId, Channel channel,
                        String subject, String body) {}
    🗣 영어로 말해

    I model the channel as an enum and one notification as an immutable record.

    checking microphone…

  2. 2

    채널마다 보내는 방법이 다릅니다. 그래서 전송 동작을 인터페이스로 추상화합니다. 각 sender는 자기가 담당하는 채널을 알려주고, 알림을 실제로 보냅니다.

    ⚖️ Trade-off: if-else로 채널마다 분기할 수도 있지만, 채널이 늘 때마다 같은 함수를 계속 고쳐야 합니다.

    ✅ Fix: 전송을 인터페이스로 빼서 채널별 구현을 따로 둡니다. 이게 Strategy 패턴이고, 새 채널은 새 클래스 추가만으로 끝납니다.

    🔧 도구:StrategyinterfaceOCP

    interface NotificationSender {
        Channel channel();
        boolean send(Notification n);
    }
    🗣 영어로 말해

    Each channel is a strategy behind a common sender interface.

    checking microphone…

  3. 3

    이제 채널별 구체 sender를 만듭니다. EmailSender, SmsSender, PushSender 세 개를 만들고 각각 자기 채널을 반환합니다. 실제 발송 부분은 외부 연동이지만 여기서는 성공 여부만 반환합니다.

    🔧 도구:Strategyinterface 구현

    class EmailSender implements NotificationSender {
        public Channel channel() { return Channel.EMAIL; }
        public boolean send(Notification n) {
            System.out.println("EMAIL -> " + n.userId());
            return true;
        }
    }
    // SmsSender, PushSender 도 같은 방식
    🗣 영어로 말해

    Concrete senders implement the interface, one per channel.

    checking microphone…

  4. 4

    서비스 본체를 만듭니다. 여러 sender를 받아서 채널을 키로 하는 Map에 넣어두면, 알림이 오면 채널에 맞는 sender를 O(1)로 찾을 수 있습니다.

    ⚖️ Trade-off: 리스트를 순회하며 채널을 비교할 수도 있지만 매번 선형 탐색이 됩니다.

    ✅ Fix: 생성자에서 sender 리스트를 Channel -> Sender Map으로 미리 등록합니다. 조회가 빨라지고 분기문이 사라집니다.

    🔧 도구:Map 디스패치DI(생성자 주입)

    class NotificationService {
        private final Map<Channel, NotificationSender> senders
            = new ConcurrentHashMap<>();
        NotificationService(List<NotificationSender> list) {
            for (NotificationSender s : list)
                senders.put(s.channel(), s);
        }
    }
    🗣 영어로 말해

    The service routes by channel using a map instead of if-else.

    checking microphone…

  5. 5

    사용자 알림 설정을 추가합니다. 사용자가 허용한 채널만 저장하고, 알림을 보낼 때 그 채널이 허용 목록에 없으면 보내지 않습니다. 설정이 없으면 기본은 모두 허용으로 둡니다.

    ⚖️ Trade-off: 허용 채널을 어디에 둘지 고민입니다. 알림 안에 넣으면 알림이 사용자 설정까지 책임지게 됩니다.

    ✅ Fix: 설정은 서비스가 별도 Map으로 관리하고, EnumSet으로 채널 집합을 효율적으로 저장합니다. 책임이 분리됩니다.

    🔧 도구:EnumSetSRP

    private final Map<String, Set<Channel>> prefs
        = new ConcurrentHashMap<>();
    void setPreferences(String userId, Set<Channel> allowed) {
        prefs.put(userId, EnumSet.copyOf(allowed));
    }
    🗣 영어로 말해

    User preferences decide which channels are actually allowed.

    checking microphone…

  6. 6

    전송 실패 시 재시도 정책을 추가합니다. 재시도 횟수나 조건을 정책 인터페이스로 빼서, 고정 횟수 정책을 먼저 구현합니다. 나중에 지수 백오프 같은 정책으로 갈아끼울 수 있습니다.

    ⚖️ Trade-off: 재시도 로직을 서비스 안에 하드코딩하면 정책을 바꿀 때마다 서비스를 고쳐야 합니다.

    ✅ Fix: RetryPolicy를 인터페이스로 분리해 주입합니다. 이것도 Strategy라서 정책 교체가 자유롭습니다.

    🔧 도구:StrategyDIOCP

    interface RetryPolicy { boolean shouldRetry(int attempt); }
    
    class FixedRetryPolicy implements RetryPolicy {
        private final int max;
        FixedRetryPolicy(int max) { this.max = max; }
        public boolean shouldRetry(int attempt) {
            return attempt < max;
        }
    }
    🗣 영어로 말해

    Retry behavior is a pluggable policy, not hard-coded.

    checking microphone…

  7. 7

    핵심 동작 notify를 완성합니다. 먼저 사용자 설정으로 채널을 거르고, sender를 찾은 뒤, 성공할 때까지 또는 정책이 멈추라 할 때까지 재시도합니다. sender가 없으면 예외를 던집니다.

    ⚖️ Trade-off: 엣지 케이스 처리가 필요합니다: 허용 안 된 채널, 등록 안 된 sender, 전송 반복 실패.

    ✅ Fix: 허용 안 되면 false 반환, sender 없으면 IllegalStateException, 재시도 다 실패하면 false로 명확히 구분합니다.

    🔧 도구:엣지 케이스 처리ConcurrentHashMap(동시성)

    boolean notify(Notification n) {
        Set<Channel> ok = prefs.getOrDefault(
            n.userId(), EnumSet.allOf(Channel.class));
        if (!ok.contains(n.channel())) return false;
        var sender = senders.get(n.channel());
        if (sender == null) throw new IllegalStateException("no sender");
        int attempt = 0;
        do {
            attempt++;
            if (sender.send(n)) return true;
        } while (retryPolicy.shouldRetry(attempt));
        return false;
    }
    🗣 영어로 말해

    notify filters by preference, picks the sender, then retries on failure.

    checking microphone…

  8. 8

    마지막으로 확장 포인트를 정리합니다. 새 채널은 enum 값 추가와 새 sender 클래스만으로 끝나고, 서비스 코드는 안 바뀝니다. 재시도 정책도 새 클래스로 교체 가능합니다. ConcurrentHashMap은 senders/prefs 맵의 동시 읽기·쓰기만 안전하게 해줄 뿐, notify() 호출 자체를 원자적·멱등으로 만들지는 않습니다 — 동시 재시도에서 중복 발송을 막으려면 멱등성 키(dedup) + at-least-once를 따로 둬야 합니다.

    🔧 도구:OCP확장성동시성

    // 새 채널 추가 예:
    //  1) enum Channel 에 SLACK 추가
    //  2) class SlackSender implements NotificationSender
    //  3) 생성자 리스트에 new SlackSender() 만 추가
    // 서비스 본문(notify)은 수정 불필요
    🗣 영어로 말해

    Adding a channel needs a new sender only; the service stays untouched.

    checking microphone…

8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

Full Java (javac-verified)

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

enum Channel { EMAIL, SMS, PUSH }

record Notification(String userId, Channel channel, String subject, String body) {}

interface NotificationSender {
    Channel channel();
    boolean send(Notification n);
}

class EmailSender implements NotificationSender {
    public Channel channel() { return Channel.EMAIL; }
    public boolean send(Notification n) {
        System.out.println("EMAIL -> " + n.userId() + ": " + n.subject());
        return true;
    }
}

class SmsSender implements NotificationSender {
    public Channel channel() { return Channel.SMS; }
    public boolean send(Notification n) {
        System.out.println("SMS -> " + n.userId() + ": " + n.body());
        return true;
    }
}

class PushSender implements NotificationSender {
    public Channel channel() { return Channel.PUSH; }
    public boolean send(Notification n) {
        System.out.println("PUSH -> " + n.userId() + ": " + n.subject());
        return true;
    }
}

interface RetryPolicy { boolean shouldRetry(int attempt); }

class FixedRetryPolicy implements RetryPolicy {
    private final int maxAttempts;
    FixedRetryPolicy(int maxAttempts) { this.maxAttempts = maxAttempts; }
    public boolean shouldRetry(int attempt) { return attempt < maxAttempts; }
}

class NotificationService {
    private final Map<Channel, NotificationSender> senders = new ConcurrentHashMap<>();
    private final Map<String, Set<Channel>> prefs = new ConcurrentHashMap<>();
    private final RetryPolicy retryPolicy;

    NotificationService(List<NotificationSender> list, RetryPolicy retryPolicy) {
        for (NotificationSender s : list) senders.put(s.channel(), s);
        this.retryPolicy = retryPolicy;
    }

    void setPreferences(String userId, Set<Channel> allowed) {
        prefs.put(userId, EnumSet.copyOf(allowed));
    }

    boolean notify(Notification n) {
        Set<Channel> allowed = prefs.getOrDefault(n.userId(), EnumSet.allOf(Channel.class));
        if (!allowed.contains(n.channel())) return false;
        NotificationSender sender = senders.get(n.channel());
        if (sender == null) throw new IllegalStateException("No sender: " + n.channel());
        int attempt = 0;
        do {
            attempt++;
            if (sender.send(n)) return true;
        } while (retryPolicy.shouldRetry(attempt));
        return false;
    }
}

public class Solution {
    public static void main(String[] args) {
        NotificationService svc = new NotificationService(
            List.of(new EmailSender(), new SmsSender(), new PushSender()),
            new FixedRetryPolicy(3));
        svc.setPreferences("u1", EnumSet.of(Channel.EMAIL, Channel.PUSH));
        System.out.println(svc.notify(new Notification("u1", Channel.EMAIL, "Hi", "Welcome")));
        System.out.println(svc.notify(new Notification("u1", Channel.SMS, "Hi", "blocked")));
    }
}
🎙 이 카드 AI랑 음성 대화 · 기록 저장