Design Pattern 정리하기
Design Pattern 정리하기 — #DesignPattern #Singleton #FactoryMethod #Builder #Strategy #Observer #TemplateMethod Des...
#DesignPattern #Singleton #FactoryMethod #Builder #Strategy #Observer #TemplateMethod
Design pattern
소프트웨어 설계에서 자주 발생하는 문제에 대한 재사용 가능한 해결책입니다. 객체지향 프로그래밍에서 사용됩니다.
어떤 문제가 발생했고 문제를 해결하기 위해 어떤 사고를 했으며, 어떻게 문제를 해결했는지를 위주로 정리하였습니다.
Singleton
문제:
애플리케이션 코드 내에서 데이터 베이스 연결을 여러 곳에서 사용하고 있습니다. DB 연결은 비용이 크고, 하나의 연결로 충분합니다.
사고:
여러 개를 만들지 못하게 시스템 자체에서 강제하고, 이미 만든 객체를 재활용할 수 없을까?
해결:
class DatabaseConnection {
private static DatabaseConnection instance;
private DatabaseConnection() {} // 외부에서 new 불가
public static DatabaseConnection getInstance() {
if (instance == null) {
instance = new DatabaseConnection();
}
return instance;
}
}
// 모든 곳에서 같은 인스턴스 사용
DatabaseConnection db1 = DatabaseConnection.getInstance();
DatabaseConnection db2 = DatabaseConnection.getInstance();
Factory Method
문제:
다음 예시 코드에서 문제가 발생합니다.
class NotificationService {
public void send(String type, String message) {
if (type.equals("email")) {
EmailSender sender = new EmailSender();
sender.send(message)
} else if (type.equals("sms")) {
SMSSender sender = new SMSSender();
sender.send(message);
} else if (type.equals("push") {
PushSender sender = new PushSender();
sender.send(message);
}
}
}
Notification의 알림이 증가할 때마다 새로운 코드를 수정해야합니다. 객체지향 원칙인 OCP(Open-Closed Principle)에 따르면 확장에는 최대한 열리도록, 수정에는 최대한 닫히도록 구성하는게 좋습니다.
사고:
객체 생성 로직을 분리하면 어떨까 ?
해결:
interface Notification {
void send(String message);
}
interface NotificationFactory {
Notification create();
}
class EmailNotification implements Notification {
public void send(String message) {
System.out.println("이메일: " + message);
}
}
class EmailNotificationFactory implements NotificationFactory {
public Notification create() {
return new EmailNotification();
}
}
class SMSNotification implements Notificaion {
public void send(String message) {
System.out.println("SMS: " + message);
}
}
class SMSNotificationFactory implements NotificationFactory {
public Notification create() {
return new SMSNotification();
}
}
새로운 알림 기능이 추가될 때, 기존 코드의 수정 없이 새로운 객체를 만들어서 사용하면 됩니다.
Builder
문제:
생성자에 파라미터가 너무 많습니다. 파라미터를 하나 하나 기억하는건 불가능에 가깝습니다.\\
인스턴스를 생성하기 위해 파라미터를 계속 보고 하나씩 만들어야 하므로 작업량이 너무 많습니다. 보지 않고 만들기에는 실수하기가 쉽습니다.
사고:
생성 할때 email("example.com")처럼 명시적으로 보여주면 좋겠다고 생각합니다.
해결
class User {
private String name;
private int age;
private String email;
private User(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.email = builder.email;
}
public static class Builder {
private String name;
private int age;
private String email;
public Builder name(String name) {
this.name = name;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public User Build() {
return new User(this);
}
}
}
이제 인스턴스를 생성할 때마다 파라미터 값을 명시적으로 확인할 수 있습니다.
User user = new User.Builder()
.name("myname")
.age(12)
.email("example.com")
.build()
Strategy
문제:
문제는 다음 코드에서 발생합니다.
class ShoppingCart {
public void checkout(String paymentType) {
if(paymentType.equals("card") {
// 카드 로직 50줄 ...
}
else if (paymentType.equals("kakao")) {
// 카카오페이 로직 100줄 ...
}
else if (paymentType.equals("naver")) {
// naver pay 30 줄 ...
}
}
}
이와 같은 코드 작성 방식은 크게 두가지 문제를 야기합니다.
- 결제 수단 추가시 이 클래스를 매번 수정해야한다.
- 하나의 클래스에 로직이 너무 많아 비대해진다.
- 이는 가독성에 매우 불리
사고:
- 결제 방식은 다르지만 "결제한다"는 추상적인 개념은 동일하다.
- 각 결제 방식을 독립적인 객체로 분리하면 어떨까?
- 런타임에 결제 방식을 교체할 수 있다면?
해결
interface PaymentStrategy {
void pay(int amount);
}
class Cardpayment implements PaymentStrategy {
public void pay(int amount) {
System.out.println("카드 결제: " + amount);
}
}
class KakaoPayment implements PaymentStrategy {
public void pay(int amount) {
System.out.println("카카오페이: " + amount);
}
}
// usage
class ShoppingCart {
private PaymentStrategy stragegy;
public void setPatmentStrategy(PaymentStrategy strategy) {
this.strategy = strategy;
}
public void checkout(int amoumt) {
strategy.pay(amount);
}
}
cart.setPaymentStrategy(new CardPayment());
cart.checkout(10000);
cart.setPaymentStrategy(new KakaoPayment());
cart.checkout(5000);
Observer
문제:
특정 이벤트가 발생시 영향을 받는 객체가 많습니다. 이 경우 해당 이벤트를 발생하면 다음과 같이 코드로 알릴 수 있습니다.
class InventorySystem {
public void updateStock(Product product, int quantity) {
product.setStock(quantity);
// 재고 변경 시 알려야 할 곳이 많음
emailService.sendLowStockAlert(product);
slackService.notifyTeam(product);
dashboardService.updateChart(product);
// 알림 대상 추가할 때마다 이 코드 수정 필요
}
}
- 알림 대상이 추가될 때마다 코드 수정이 필요합니다.
사고:
발행-구독 구조를 사용해서 느슨한 연결을 사용해보는건 어떨까?
해결:
// 옵저버 인터페이스
interface StockObserver {
void onStockChange(Product product, int quantity);
}
// 구독자들
class EmailAlert implements StockObserver {
public void onStockChange(Product product, int quantity) {
System.out.println("이메일 발송: " + product.getName());
}
}
class SlackNotifier implements StockObserver {
public void onStockChange(Product product, int quantity) {
System.out.println("슬랙 알림: " + product.getName());
}
}
// 발행자
class InventorySystem {
private List<StockObserver> observers = new ArrayList<>();
public void addObserver(StockObserver observer) {
observers.add(observer);
}
public void removeObserver(StockObserver observer) {
observers.remove(observer);
}
public void updateStock(Product product, int quantity) {
product.setStock(quantity);
// 등록된 모든 옵저버에게 알림
for (StockObserver observer : observers) {
observer.onStockChange(product, quantity);
}
}
}
// 옵저버 등록/해제 — 기존 코드 수정 없음
inventory.addObserver(new EmailAlert());
inventory.addObserver(new SlackNotifier());
Template Method
문제:
두개의 class에서 대부분의 동일 메서드를 사용하는 구조이나, 특정 부분만 다릅니다.
class CSVDataProcessor {
public void process() {
openFile();
readCSV(); // CSV 전용
parseData(); // CSV 전용
saveToDatabase();
closeFile();
}
}
class JSONDataProcessor {
public void process() {
openFile();
readJSON(); // JSON 전용
parseJSON(); // JSON 전용
saveToDatabase();
closeFile();
}
}
// 문제: 전체 흐름은 같은데 코드 중복
사고:
동일한 부분은 상위구조에 놓고, 다른 부분만 하위부분에서 구현하면 어떨까?
해결:
abstract class DataProcessor {
public final void process() {
openFile();
readData();
parseData();
saveToDatabase();
closeFile();
}
private void openFile() {
System.out.printlin("파일 열기");
}
protected abstract void readData(); // 하위 클래스가 구현
protected abstract void parseData(); // 하위 클래스가 구현
private void saveToDatabase() {
System.out.println("DB 저장");
}
private void closeFile() {
System.out.println("파일 닫기");
}
}
class CSVProcessor extends DataProcessor {
protected void readData() { System.out.println("CSV 읽기"); }
protected void parseData() { System.out.println("CSV 파싱"); }
}
class JSONProcessor extends DataProcessor {
protected void readData() { System.out.println("JSON 읽기"); }
protected void parseData() { System.out.println("JSON 파싱"); }
}
Proxy
문제:
다음 상황에서 문제가 발생합니다.
class HighResolutionImage {
private String filename;
private byte[] imageData;
public HighResolutionImage(String filename) {
this.filename = filename;
loadFromDisk(); // 10MB 이미지 로딩 — 5초 소요
}
private void loadFromDisk() {
System.out.println("로딩 중... " + filename);
// 무거운 작업
this.imageData = readFile(filename);
}
public void display() {
System.out.println("표시: " + filename);
}
}
// 문제 상황
class Gallery {
public void showGallery() {
// 이미지 100개 생성 — 실제로 볼 건 몇 개뿐인데 전부 로딩
List<HighResolutionImage> images = new ArrayList<>();
for (int i = 0; i < 100; i++) {
images.add(new HighResolutionImage("image" + i + ".jpg"));
// 100개 × 5초 = 500초 대기...
}
}
}
- 이미지 전체를 불러오는 메서드를 사용하면 객체 생성 비용이 큽니다.
- 메모리 , 시간, 네트워크 등
사고
- 실제로 생성할 때가지 생성을 미루는 것은 어떨까?
- 접근 전에 권한 체크를 하고 싶다면?
- 실제 객체 대신 대리자를 둬보자!
해결
// 공통 인터페이스
interface Image {
void display();
}
// 실제 객체 — 무거움
class HighResolutionImage implements Image {
private String filename;
public HighResolutionImage(String filename) {
this.filename = filename;
loadFromDisk();
}
private void loadFromDisk() {
System.out.println("로딩 중... " + filename);
// 무거운 작업
}
public void display() {
System.out.println("표시: " + filename);
}
}
// 프록시 — 가벼움, 실제 객체를 대신함
class ImageProxy implements Image {
private String filename;
private HighResolutionImage realImage; // 처음엔 null
public ImageProxy(String filename) {
this.filename = filename;
// 여기서 로딩 안 함!
}
public void display() {
// 실제 필요한 순간에만 로딩
if (realImage == null) {
realImage = new HighResolutionImage(filename);
}
realImage.display();
}
}
// 사용
class Gallery {
public void showGallery() {
List<Image> images = new ArrayList<>();
// 프록시만 생성 — 즉시 완료
for (int i = 0; i < 100; i++) {
images.add(new ImageProxy("image" + i + ".jpg"));
}
// 사용자가 클릭한 이미지만 로딩
images.get(0).display(); // 이때 image0.jpg만 로딩
}
}
- 이렇게 프록시 객체만 생성하고, 실제로 클릭시에만 disk에서 실제 이미지를 가져오도록 구현할 수 있습니다.
Proxy의 종류
- 무거운 객체 생성 지연
위의 예시
- Protection Proxy (보호 프록시)
접근 권한을 제어
interface Document {
void read();
void write(String content);
}
class RealDocument implements Document {
public void read() { System.out.println("문서 읽기"); }
public void write(String content) { System.out.println("문서 쓰기: " + content); }
}
class ProtectionProxy implements Document {
private RealDocument realDocument;
private User user;
public ProtectionProxy(RealDocument doc, User user) {
this.realDocument = doc;
this.user = user;
}
public void read() {
realDocument.read(); // 누구나 읽기 가능
}
public void write(String content) {
if (!user.hasRole("ADMIN")) {
throw new AccessDeniedException("관리자만 수정 가능");
}
realDocument.write(content);
}
}
- Remote Proxy
원격 객체를 로컬처럼 사용
interface PaymentService {
void pay(int amount);
}
// 실제로는 원격 서버에 있음
class RemotePaymentProxy implements PaymentService {
private String serverUrl;
public RemotePaymentProxy(String serverUrl) {
this.serverUrl = serverUrl;
}
public void pay(int amount) {
// HTTP 호출로 원격 서버에 요청
HttpClient.post(serverUrl + "/pay", amount);
}
}
// 사용하는 쪽은 로컬 객체처럼 사용
PaymentService payment = new RemotePaymentProxy("https://api.payment.com");
payment.pay(10000);
유저가 사용하는 객체를 실제로는 원격 서버에 있지만, 내 서버에 있다고 생각하게 만들어줍니다.
4. Caching Proxy (캐싱 프록시)
결과를 캐싱해서 재사용
interface UserRepository {
User findById(Long id);
}
class RealUserRepository implements UserRepository {
public User findById(Long id) {
System.out.println("DB 조회...");
// 느린 DB 쿼리
return queryFromDatabase(id);
}
}
class CachingProxy implements UserRepository {
private RealUserRepository realRepo;
private Map<Long, User> cache = new HashMap<>();
public CachingProxy(RealUserRepository realRepo) {
this.realRepo = realRepo;
}
public User findById(Long id) {
if (cache.containsKey(id)) {
System.out.println("캐시 히트!");
return cache.get(id);
}
User user = realRepo.findById(id);
cache.put(id, user);
return user;
}
}
// 사용
UserRepository repo = new CachingProxy(new RealUserRepository());
repo.findById(1L); // DB 조회...
repo.findById(1L); // 캐시 히트!
repo.findById(1L); // 캐시 히트!
캐시 구현 방법중 일부로 Proxy가 사용됩니다.