큰 꿈은 파편이 크다!!⚡️

시스템 디자인 인터뷰 준비 (2) - 디자인 패턴/LLD 본문

기타 CS

시스템 디자인 인터뷰 준비 (2) - 디자인 패턴/LLD

wood.forest 2025. 2. 1. 17:09

1) 시스템 디자인 인터뷰에 필요한 개념들을 확실히 함

2) 디자인 패턴/LLD <- NOW!

3) 시스템 디자인 시 각 컴포넌트의 역할 (HLD)

4) 예제 및 각 컴포넌트에 적용한 내용 뜯어보기

 

 

 

디자인 패턴이란?

디자인 패턴(Design Pattern)은 소프트웨어 설계에서 자주 발생하는 문제에 대한 일반화된 해법이다. 개발자가 유사한 문제를 해결할 때 참고할 수 있는 최적의 코드 구조를 제공한다.

..는 챗GPT가 설명한 내용이고, 내 식대로 말하자면 "코드 작성 방식 Best practice" 정도가 되겠다. 

 

디자인 패턴의 주요 특징

  • 검증된 해결책: 여러 개발자가 경험을 통해 효과를 검증한 방식
  • 재사용 가능: 다양한 프로젝트에서 활용 가능
  • 개발 효율성 증가: 코드 작성 시간이 단축되고 유지보수가 쉬워짐
  • 코드 일관성 유지: 여러 개발자가 협업할 때 동일한 설계 원칙 적용
  • 소프트웨어 품질 향상: 코드의 구조를 이해하기 쉽게 만들어 가독성, 확장성, 유지보수성 향상

 

디자인 패턴의 주요 분류

몰랐는데 gpt하다보니 이렇게나 많은 디자인 패턴이 있고, 각각을 세 가지 갈래로 분류할 수 있다. 지금은 이런 것들이 있고 이렇게 나뉘어진다 정도로만 이해하자..

 

① 생성 패턴 (Creational Patterns) → 객체 생성 관련

객체를 생성하는 방식을 효율적으로 설계하는 패턴

  • Singleton → 단 하나의 객체만 생성 (예: 데이터베이스 연결)
  • Factory Method → 객체 생성을 서브클래스에서 결정하도록 위임
  • Abstract Factory → 관련 객체 그룹을 생성하는 인터페이스 제공
  • Builder → 복잡한 객체 생성을 단계별로 수행
  • Prototype → 기존 객체를 복제하여 새 객체 생성

② 구조 패턴 (Structural Patterns) → 클래스/객체 구조 관련
객체 간의 관계를 효율적으로 설계하는 패턴

  • Adapter → 인터페이스가 다른 두 객체를 연결 (예: 충전기 변환 어댑터)
  • Bridge → 기능과 구현을 분리하여 확장성을 높임
  • Composite → 객체들을 트리 구조로 구성 (예: 파일 시스템)
  • Decorator → 기존 객체의 기능을 동적으로 확장
  • Facade → 복잡한 시스템을 단순한 인터페이스로 감싸기
  • Flyweight → 공유 가능한 객체를 활용해 메모리 절약
  • Proxy → 접근을 제어하기 위한 대리 객체 제공

③ 행동 패턴 (Behavioral Patterns) → 객체 간의 커뮤니케이션 관련
객체 간의 협력을 효과적으로 설계하는 패턴

  • Observer → 상태 변화 시 여러 객체에 알림 (예: 이벤트 리스너)
  • Strategy → 알고리즘을 런타임에 변경 가능하도록 분리
  • Command → 요청을 객체로 캡슐화하여 실행 취소 가능
  • Mediator → 객체 간의 직접적인 상호작용을 방지하고 중재자 사용
  • State → 객체 상태에 따라 동작을 변경 (예: TCP 연결 상태)
  • Template Method → 알고리즘 구조를 정의하고 세부 구현을 서브클래스에서 처리
  • Chain of Responsibility → 요청을 여러 개의 객체가 처리하도록 연결

 

주요 디자인 패턴 설명 및 예제

상태 패턴 (State)

  • 객체의 상태에 따라 동작을 다르게 처리하는 패턴
  • ex. 전등 상태 관리, 자판기 상태 관리
class LightState {
    pressButton(light) {
        throw new Error('This method must be implemented');
    }
}

class OnState extends LightState {
    constructor() {
        super();
        console.log("Lights on");
    }

    pressButton(light) {
        console.log('Turning off the light');
        light.setState(new OffState());
    }
}

class OffState extends LightState {
    constructor() {
        super();
        console.log("Lights off");
    }

    pressButton(light) {
        console.log('Turning on the light');
        light.setState(new OnState());
    }
}

class Light {
    constructor() {
        this.state = new OffState(); 
    }

    setState(state) {
        this.state = state;
    }

    pressButton() {
        this.state.pressButton(this);
    }
}

const light = new Light();
light.pressButton();  // "Lights on" + "Turning off the light"
light.pressButton();  // "Lights off" + "Turning on the light"

 

 

싱글톤 패턴 (Singleton)

  • 특정 클래스의 인스턴스를 하나만 생성하고 이를 전역적으로 접근할 수 있도록 하여 모든 곳에서 동일한 객체를 공유
  • ex. 설정 관리, 데이터베이스 연결과 같은 시스템 전역에서 하나의 인스턴스만 필요한 경우
class Singleton {
    constructor() {
        if (Singleton.instance) {
            return Singleton.instance;
        }
        Singleton.instance = this;
        this.data = "Singleton Data";
    }

    getData() {
        return this.data;
    }
}

const instance1 = new Singleton();
const instance2 = new Singleton();
console.log(instance1 === instance2); // true
console.log(instance1.getData()); // "Singleton Data"

 

 

 

팩토리 메서드 패턴 (Factory Method)

  • 객체 생성 로직을 서브클래스에 위임하여 객체 생성을 캡슐화
  • ex. 객체 생성 과정이 복잡하거나 클래스가 구체적인 타입에 의존하지 않도록 해야 할 때
class Product {
    constructor(name) {
        this.name = name;
    }
    describe() {
        console.log(`This is a ${this.name}.`);
    }
}

class Factory {
    static createProduct(type) {
        switch (type) {
            case "Laptop":
                return new Product("Laptop");
            case "Phone":
                return new Product("Phone");
            default:
                throw new Error("Unknown product type");
        }
    }
}

const laptop = Factory.createProduct("Laptop");
laptop.describe(); // "This is a Laptop."

 

 

 

빌더 패턴 (Builder)

  • 복잡한 객체를 단계별로 생성할 수 있도록 도와주는 패턴
  • ex. 옵션이 많은 복잡한 객체(예: 컴퓨터, UI 컴포넌트 등)를 명확하게 단계별로 구성할 때 사용
class Computer {
    constructor(cpu, ram, storage, gpu) {
        this.cpu = cpu;
        this.ram = ram;
        this.storage = storage;
        this.gpu = gpu;
    }
}

class ComputerBuilder {
    constructor() {
        this.cpu = "Intel i5";
        this.ram = "8GB";
        this.storage = "256GB SSD";
        this.gpu = "Integrated";
    }

    setCPU(cpu) {
        this.cpu = cpu;
        return this;
    }

    setRAM(ram) {
        this.ram = ram;
        return this;
    }

    setStorage(storage) {
        this.storage = storage;
        return this;
    }

    setGPU(gpu) {
        this.gpu = gpu;
        return this;
    }

    build() {
        return new Computer(this.cpu, this.ram, this.storage, this.gpu);
    }
}

const gamingPC = new ComputerBuilder()
    .setCPU("Intel i9")
    .setRAM("32GB")
    .setStorage("1TB SSD")
    .setGPU("NVIDIA RTX 3080")
    .build();

console.log(gamingPC);

 

 

데코레이터 패턴 (Decorator)

  • 객체의 기능을 동적으로 확장할 수 있도록 도와주는 패턴
  • ex. 기본 기능에 동적으로 옵션(예: 커피에 첨가물 추가)을 추가하거나 변경해야 하는 경우
class Coffee {
    getDescription() {
        return "Basic Coffee";
    }

    cost() {
        return 5;
    }
}

class MilkDecorator {
    constructor(coffee) {
        this.coffee = coffee;
    }

    getDescription() {
        return `${this.coffee.getDescription()} + Milk`;
    }

    cost() {
        return this.coffee.cost() + 1;
    }
}

const basicCoffee = new Coffee();
const coffeeWithMilk = new MilkDecorator(basicCoffee);
console.log(coffeeWithMilk.getDescription()); // "Basic Coffee + Milk"
console.log(coffeeWithMilk.cost()); // 6

 

 

 

프록시 패턴 (Proxy)

  • 실제 객체에 대한 접근을 제어하는 대리 객체를 제공하는 패턴
  • ex. 리소스가 무거운 객체의 생성을 지연하거나 접근 제어가 필요한 경우
class RealImage {
    constructor(filename) {
        this.filename = filename;
        this.loadFromDisk();
    }

    loadFromDisk() {
        console.log(`Loading ${this.filename}`);
    }

    display() {
        console.log(`Displaying ${this.filename}`);
    }
}

class ProxyImage {
    constructor(filename) {
        this.filename = filename;
        this.realImage = null;
    }

    display() {
        if (!this.realImage) {
            this.realImage = new RealImage(this.filename);
        }
        this.realImage.display();
    }
}

const image = new ProxyImage("test_image.jpg");
image.display(); // Loading 후 Displaying 출력
image.display(); // 바로 Displaying 출력

 

 

 

옵저버 패턴 (Observer)

  • 한 객체의 상태 변화가 다른 객체들(리스너)에게 자동으로 통지되도록 하는 패턴
  • ex. 이벤트 기반 시스템에서 상태 변화를 감지하고 자동으로 반응해야 하는 경우
class Subject {
    constructor() {
        this.observers = [];
    }

    addObserver(observer) {
        this.observers.push(observer);
    }

    notifyObservers(message) {
        this.observers.forEach(observer => observer.update(message));
    }
}

class Observer {
    constructor(name) {
        this.name = name;
    }

    update(message) {
        console.log(`${this.name} received message: ${message}`);
    }
}

const subject = new Subject();
const observer1 = new Observer("Observer 1");
const observer2 = new Observer("Observer 2");

subject.addObserver(observer1);
subject.addObserver(observer2);

subject.notifyObservers("Hello Observers!");

 

 

 

전략 패턴 (Strategy)

  • 알고리즘군을 정의하고 각 알고리즘을 캡슐화하여 런타임에 동적으로 교체할 수 있도록 유연성을 제공
  • 결제 수단 변경, 정렬 알고리즘 선택 등 런타임에 다양한 동작을 교체해야 하는 경우
class CreditCardPayment {
    pay(amount) {
        console.log(`Paid $${amount} using Credit Card.`);
    }
}

class PaypalPayment {
    pay(amount) {
        console.log(`Paid $${amount} using PayPal.`);
    }
}

class ShoppingCart {
    constructor(paymentMethod) {
        this.paymentMethod = paymentMethod;
    }

    checkout(amount) {
        this.paymentMethod.pay(amount);
    }
}

const cart1 = new ShoppingCart(new CreditCardPayment());
cart1.checkout(100);

const cart2 = new ShoppingCart(new PaypalPayment());
cart2.checkout(200);

 

 

의존성 주입 패턴 (Dependency Injection)

  • 객체가 필요로 하는 의존성을 외부에서 주입받도록 하여 결합도를 낮추는 패턴
  • ex. 테스트하기 쉬운 코드 작성, 유연한 모듈 간의 연결 관리 등
class EmailService {
    sendEmail(message) {
        console.log(`Sending email: ${message}`);
    }
}

class NotificationManager {
    constructor(emailService) {
        this.emailService = emailService;
    }

    notifyUser(message) {
        this.emailService.sendEmail(message);
    }
}

const emailService = new EmailService();
const notificationManager = new NotificationManager(emailService);
notificationManager.notifyUser("Welcome to our service!");

 

 

 

LLD(Low-Level Design)?

이전 글에도 작성했지만, 한번 더 복습!

HLD는 시스템의 아키텍처와 모듈 간의 상호작용을 정의하는 반면, LLD는 각 모듈의 내부 설계를 의미한다. 예를 들어, HLD에서는 "사용자 관리 모듈"이라는 큰 단위로 설명되지만, LLD에서는 이 모듈이 어떻게 클래스로 구성되고 메서드들이 상호작용하는지를 구체적으로 설명해야 한다.

 

LLD의 주요 구성 요소

  • 클래스 다이어그램: 클래스 간 관계를 정의하고 각 클래스의 속성과 메서드를 명시
  • 시퀀스 다이어그램: 시스템 내 객체 간 상호작용을 시간의 흐름에 따라 시각화
  • 인터페이스 설계: 모듈 간 통신을 위한 명확한 인터페이스 정의

좋은 LLD를 위한 설계 원칙

효율적이고 유지보수하기 쉬운 설계를 목표로, 아래 원칙을 상기시키면서 코드를 작성하자. 이 원칙을 따름으로써 모듈 간의 결합도를 낮추고 응집도를 높일 수 있다.

 

SOLID 원칙

  • S: 단일 책임 원칙 (Single Responsibility Principle)
  • O: 개방-폐쇄 원칙 (Open/Closed Principle)
  • L: 리스코프 치환 원칙 (Liskov Substitution Principle)
  • I: 인터페이스 분리 원칙 (Interface Segregation Principle)
  • D: 의존성 역전 원칙 (Dependency Inversion Principle)

KISS와 DRY

  • KISS(Keep It Simple, Stupid): 설계를 간단하고 직관적으로 유지
  • DRY(Don’t Repeat Yourself): 중복된 코드를 피하고 재사용성을 극대화

 

 

LLD 인터뷰 예제: 자판기 설계하기

인터뷰 준비 시 한번씩 연습해보는 것들이 "자판기 설계하기" / "엘리베이터 설계하기" 등이 있다. 일단 클래스들을 정의하므로서 코드가 실제로 동작해야 하고, (예를 들어 Elevator.up() 메서드를 호출했을 때 console.log("올라갑니다") 와 같이 결과가 나타나야 한다) 시스템을 모듈화하고 적절한 설계 패턴을 선택하여 코드를 작성해야 한다.

 

문제: "자판기 시스템을 설계하세요. 조건은 아래와 같습니다."

요구사항:

  • 음료 선택: 사용자가 음료를 선택할 수 있다.
  • 결제: 사용자가 음료의 가격을 지불하고, 결제가 완료되면 음료가 제공된다.
  • 잔돈 반환: 잔돈이 있을 경우 반환한다.
  • 상태 관리: 자판기의 상태(충분한 음료 재고, 금액 확인 등)를 관리한다.

 

..여기까지 읽고 정말 중요한 점!! 

시나리오와 조건이 이해되지 않았다면 반드시! 인터뷰어와의 질의응답을 통해 확실하게 해야 한다.

또한 머릿속에 떠오르는 의문점을 해결하고 넘어가야 한다. 예를 들어 저 요구사항을 확인한 뒤, "현금만 받나요? 카드도 되나요?" / "새로운 음료를 추가할 수 있나요?" 와 같은 질문을 해볼 수 있다.

 

 

설계 과정

주요 클래스 다이어그램:

  • VendingMachine: 음료 선택, 금액 검증 등
  • Drink: 음료의 속성을 정의
  • PaymentProcessor: 금액 투입 및 결제를 처리하는 인터페이스

패턴 선택:

  • 상태 패턴 -> 자판기 상태를 나타냄 (금액부족, 재고없음 등)
  • 전략 패턴 -> 다양한 결제 방식

 

// 음료 클래스
class Drink {
    constructor(name, price, stock) {
        this.name = name;
        this.price = price;
        this.stock = stock;
    }

    isInStock() {
        return this.stock > 0;
    }

    dispense() {
        if (this.isInStock()) {
            this.stock--;
        } else {
            throw new Error('Out of stock');
        }
    }
}

// 결제 처리 인터페이스 (전략 패턴)
class PaymentProcessor {
    processPayment(money, price) {
        throw new Error('processPayment method must be implemented');
    }
}

// 현금 결제 처리
class CashPaymentProcessor extends PaymentProcessor {
    processPayment(money, price) {
        return money >= price;
    }
}

// 카드 결제 처리
class CardPaymentProcessor extends PaymentProcessor {
    processPayment(money, price) {
        return money >= price;  // 카드 결제 시, 실제 로직은 복잡할 수 있음
    }
}

// 자판기 상태 인터페이스 (상태 패턴)
class VendingMachineState {
    selectDrink(drinkName, money, vendingMachine) {
        throw new Error('This method must be implemented');
    }
}

class HasSufficientMoneyState extends VendingMachineState {
    selectDrink(drinkName, money, vendingMachine) {
        const drink = vendingMachine.drinks[drinkName];
        if (!drink) {
            console.log('Invalid selection');
            return;
        }

        if (drink.isInStock()) {
            drink.dispense();
            console.log(`Dispensing ${drink.name}`);
            console.log(`Returning change: $${money - drink.price}`);
        } else {
            vendingMachine.setState(new OutOfStockState());
            console.log('Out of stock');
        }
    }
}

class InsufficientMoneyState extends VendingMachineState {
    selectDrink(drinkName, money, vendingMachine) {
        console.log('Insufficient funds, please insert more money.');
    }
}

class OutOfStockState extends VendingMachineState {
    selectDrink(drinkName, money, vendingMachine) {
        console.log('Sorry, the drink is out of stock.');
    }
}

// 자판기 클래스
class VendingMachine {
    constructor() {
        this.drinks = {
            cola: new Drink('Cola', 2, 10),
            water: new Drink('Water', 1, 20),
            juice: new Drink('Juice', 3, 15),
        };
        this.paymentProcessor = new CashPaymentProcessor();  // 기본 결제 방식은 현금
        this.state = new InsufficientMoneyState();  // 초기 상태는 금액 부족
    }

    setState(state) {
        this.state = state;
    }

    setPaymentProcessor(paymentProcessor) {
        this.paymentProcessor = paymentProcessor; // 결제 방식 변경
    }

    selectDrink(drinkName, money) {
        if (this.paymentProcessor.processPayment(money, this.drinks[drinkName].price)) {
            this.setState(new HasSufficientMoneyState());
        } else {
            this.setState(new InsufficientMoneyState());
        }
        this.state.selectDrink(drinkName, money, this); // 상태에 맞는 동작
    }
}

const vendingMachine = new VendingMachine();
vendingMachine.selectDrink('cola', 3);  // 충분한 금액으로 음료 선택
vendingMachine.selectDrink('juice', 1);  // 금액 부족 시 음료 제공 불가
vendingMachine.setPaymentProcessor(new CardPaymentProcessor());  // 카드 결제 방식으로 변경
vendingMachine.selectDrink('water', 2);  // 카드 결제 방식으로 음료 선택

 

 

 

마지막으로.. 인터뷰 연습하기!!

마지막으로 강조해야 할 점은, LLD 디자인을 이해하고 작성하는 것도 중요하지만 인터뷰 에서는 이를 잘 설명해야 한다는 것이다.

나는 인터뷰 연습을 이렇게 해보려 한다. (아직 안했다는뜻)

 

시간 30분 정해놓고,

* 5분: 문제 읽기, 불확실한 부분 확실히하기(인터뷰어와의 질의응답)

* 20분: 내가 어떻게 코드를 작성하고 어떤 패턴을 적용할 것인지 Introduction -> 코드 작성, 이때도 코드를 마냥 쓰기만 하면 안되고 블록 단위로 설명하며 작성

* 5분: 테스트 코드 작성 및 검증

 

 

 

반응형