Understand it step by step (한국어로 이해 → 영어로 말하기)
- 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
채널마다 보내는 방법이 다릅니다. 그래서 전송 동작을 인터페이스로 추상화합니다. 각 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
이제 채널별 구체 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
서비스 본체를 만듭니다. 여러 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
사용자 알림 설정을 추가합니다. 사용자가 허용한 채널만 저장하고, 알림을 보낼 때 그 채널이 허용 목록에 없으면 보내지 않습니다. 설정이 없으면 기본은 모두 허용으로 둡니다.
⚖️ 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
전송 실패 시 재시도 정책을 추가합니다. 재시도 횟수나 조건을 정책 인터페이스로 빼서, 고정 횟수 정책을 먼저 구현합니다. 나중에 지수 백오프 같은 정책으로 갈아끼울 수 있습니다.
⚖️ 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
핵심 동작 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
마지막으로 확장 포인트를 정리합니다. 새 채널은 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")));
}
}