[포스코x코딩온] KDT-Web-8 6주차 회고1 - 디자인패턴

Yunes·2023년 8월 7일
0

[포스코x코딩온]

목록 보기
14/47
post-thumbnail

📚 서론

nestJS 를 하며 진득히 겪어봤던 MVC 패턴에 대해 배우게 되었다. 이에 대해 다시한번 확실히 정리해보자.

포스트를 다 작성하고나서 추가하는 내용인데 너무너무너무 괜찮은 사이트를 보게 되어 맨 위에 붙여놓으려 한다. 왠만한 디자인 패턴뿐 아니라 다른 내용도 굉장히 정리가 잘 되어있다.

Patterns.dev

📘 MVC

MVC 는 Model View Controller 로 사용자 인터페이스, 데이터 및 논리 제어를 구현하는데 널리 사용되는 소프트웨어 디자인 패턴을 말한다. MVC 는 비즈니스 로직과 화면을 구분하는데 중점을 둔다.

이게 무슨 소리일까?

  • Model : 데이터와 비즈니스 로직 관리
  • View : 레이아웃과 화면 처리
  • Controller : 명령을 모델과 뷰 부분으로 라우팅

MVC 패턴은 비즈니스 로직과 UI로직을 분리하여 유지보수를 독립적으로 수행할 수 있게 하는 장점이 있다. 이 경우 확장성, 유연성이 증가한다.

출처 : mdn - mvc

📗 Model

Model 은 앱이 포함해야할 데이터가 무엇인지 정의한다. 데이터의 상태가 바뀌면 보통 뷰에게 변경사항을 알린다. 데이터베이스, 상수, 문자열과 같은 변수들이 Model 에 해당한다. Model 에는 View 나 Controller 의 정보가 전혀 없고 정보만 반환하거나 설정한다. 변경 발생시 변경 통지에 대한 처리 방법을 구현해야 한다.

Model 은 비즈니스 로직을 구현하는 영역이다.

📕 비즈니스 로직

비즈니스 로직은 유저의 요청에 따른 결과물을 만들어내기 위한 일련의 작업들을 의미한다. 사용자가 어떤 요청을 했을 때 그 요청을 처리하기 위해 내부적으로 진행되는 절차이다.

구체적으로 데이터의 처리, 상태 변경, 알고리즘 등과 같이 애플리케이션의 핵심 기능을 구현하는 부분을 말한다. MVC 패턴에서 비즈니스 로직은 주로 Model 에 위치한다. 모델은 데이터의 처리, 비즈니스 규칙을 담당하며 데이터베이스와 상호 작용하고 데이터를 유효석 검사하며 애플리케이션의 핵심 기능을 수행한다.

📗 View

View 는 앱의 데이터를 보여주는 방식을 지정한다. 표시할 데이터를 모델로부터 받는다. 사용자가 제어하고 데이터를 확인할 수 있는 영역이다. 별도의 데이터를 보관하지 않고 입력받고 출력해주는 모든 데이터는 Model 을 활용한다.

View 는 모델이 가지고 있는 정보를 따로 저장해서는 안된다. View 는 모델이나 컨트롤러처럼 다른 구성요소들을 몰라야 한다. View 는 변경 발생시 이에 대한 처리 방법을 구현해야 한다.

View 는 UI 로직을 구현한다.

📗 Controller

앱의 사용자로부터 입력에 대한 응답으로 모델 및 뷰를 업데이트하는 로직을 포함한다. 사용자가 View 에서 어떤 행동을 했을 때 그 내부적인 처리는 Controller 에서 관리하게 된다. Controller는 모델이나 뷰에 대해 알고 있어야 하고 모델이나 뷰의 변경을 모니터링해야 한다.

Controller 는 애플리케이션의 흐름 제어나 사용자의 처리 요청을 담당한다.

📗 한계

하나의 Model 은 여러 View 와 관련이 있을 수 있고 하나의 View 도 여러 Model 과 관련이 있을 수 있다. 그래서 프로그램의 규모가 커짐에 따라 컨트롤러가 불필요하게 커지는 현상이 발생한다. 복잡한 화면을 구성시 이런 현상이 발생하는데 이를 Massive View Controller 라고 한다.

📘 디자인 패턴

디자인 패턴 : 소프트웨어 개발에서 자주 발생하는 문제를 해결하는데 사용되는 효율적이고 검증된 해결책의 모음이며 다른 상황에 맞게 사용될 수 있는 문제들을 해결하는데 쓰이는 템플릿이다.

  • 생성(Creational) 패턴: 객체 생성과 관련된 문제를 해결하는 패턴으로 싱글톤, 팩토리 등이 있다.
  • 구조(Structural) 패턴 : 클래스나 객체들의 구성을 조직화하고 합성하는 패턴이다. 어댑터(Adapter), 데코레이터(Decorator) 등이 있다.
  • 행동(Behavioral) 패턴 : 객체들 간의 상호작용과 책임 분배에 관련된 문제를 해결하는 패턴이다. 오저버(Observer), 전략(Strategy) 등이 있다.

📗 싱글톤 패턴

싱글톤 패턴이란 객체의 인스턴스가 오직 1개만 생성되는 패턴을 의미한다. 이렇게 하면 여러 곳에서 동일한 인스턴스에 접근하여 데이터를 공유하거나 중복 인스턴스 생성을 방지할 수 있다. 그래서 동일 클래스에서 새로운 객체를 생성해도 처음 만들어진 객체를 얻는다.

싱글톤 패턴 은 인스턴스가 절대적으로 하나만 존재한다는 것을 보증하고 싶을 때 사용한다.

사용사례

  1. 어떤 클래스의 인스턴스가 단 한번만 생성되어야 할때
  2. 인스턴스를 여러 곳에서 공유하고 동일한 데이터를 사용해야 할 때
  3. 중복된 리소스 사용을 최소화해야 할 때

장점

  • 인스턴스가 하나만 생성되므로 메모리상 이점이 있고 두 번째 호출부터 객체 로딩시간이 줄어들어 성능 향상이 있다. 또한 다른 인스턴스들과 데이터 공유가 쉽다.

단점

  • 싱글톤 인스턴스가 너무 많은 일을 하거나 많은 데이터를 공유시 다른 클래스간의 결합도가 높아져 OCP (Open Closed Principle) 를 위배하게되어 수정과 테스트가 어려워진다.
  • 동일한 데이터에 여러 사람이 접근할 경우 데드락 문제를 초래할 수 있다.

데드락

  • 두 개 이상의 프로세스나 스레드가 자원을 점유한 상태에서 서로 다른 자원을 기다리며 무한정으로 대기하는 상태를 말한다.

싱글톤을 사용하는 사례이다. ( JS 보다 타입스크립트를 사용시 싱글톤이 더 잘 이해될 것 같아 TS 예시를 준비했다. )

// user.entity.ts

import { Injectable } from '@nestjs/common';

@Injectable()
export class User {
  private id: number;
  private username: string;
  private email: string;

  constructor(id: number, username: string, email: string) {
    this.id = id;
    this.username = username;
    this.email = email;
  }
}
// user.service.ts

import { Injectable } from '@nestjs/common';
import { User } from './user.entity';

@Injectable()
export class UserService {
  private users: User[] = [];

  createUser(id: number, username: string, email: string): User {
    const user = new User(id, username, email);
    this.users.push(user);
    return user;
  }

  getUsers(): User[] {
    return this.users;
  }
}

유저를 생성하는 경우를 생각해보자. 한번 생성된 유저는 동일한 id 를 갖는다. 이는 한번 유저 클래스를 통해 생성한 유저 인스턴스는 반드시 단 하나만 존재한다는 의미이니 싱글톤 패턴을 적용하는 것이 적절하다는 것을 의미한다.

@Injectable() : 클래스를 싱글톤으로 등록하여 다른 곳에서 동일한 인스턴스를 주입받아 사용하게 한다.

ES2015 에서의 싱글톤 예시이다.

let instance;
let counter = 0;
 
class Counter {
  constructor() {
    if (instance) {
      throw new Error("You can only create one instance!");
    }
    instance = this;
  }
 
  getInstance() {
    return this;
  }
 
  getCount() {
    return counter;
  }
 
  increment() {
    return ++counter;
  }
 
  decrement() {
    return --counter;
  }
}
 
const singletonCounter = Object.freeze(new Counter());
export default singletonCounter;

Object.freeze 메서드는 사용하는 코드가 싱글톤을 수정할 수 없다는 것을 보장한다. 얼려진 인스턴스는 추가되거나 수정될 수 없다.

SingleTon 적용전SingleTon 적용후

출처 : patterns.dev

싱글톤 패턴은 위에 명시한 patterns 의 singleton for javascript 에서 자세히 살펴보자

📗 Decorator Pattern

이전에 nestjs 를 사용시 RoleGuard 라는 것을 매우 유용하게 사용했었다. 이게 어떤 동작을 하냐면 컨트롤러 단에 앞에 데코레이터 하나만 붙였을 뿐인데 컨트롤러의 API 를 실행하는 권한을 제한할 수 있는 동작을 했었다. 말 그대로 admin 만, user만, guest 만 사용할 수 있는 것처럼 권한을 따로 부여할 수 있었다는 의미이다. 코드로 봐보자.

// user.controller.ts

import { Controller, Get, UseGuards } from '@nestjs/common';
import { RoleGuard } from './role.guard';
import { Roles } from './roles.decorator';

@Controller('users')
export class UserController {
  @Get()
  @UseGuards(RoleGuard)
  @Roles('admin') // 이 컨트롤러 메서드는 'admin' 역할을 갖고 있는 사용자만 접근 가능
  findAllUsers() {
    return '모든 사용자 조회';
  }
}

이 경우 @UseGuards(RoleGuard)@Roles('admin') 을 통해 Controller 의 메서드를 실행할 수 있는 권한을 제한하는 사례이다.

📗 Observer Pattern

  • Model 에서 변경된 경과를 등록된 Observer 들에게 전달한다. 감시하는 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들에게 정보가 자동으로 갱신된다. 정보를 주고받는 Observer Interface 를 통해 추가, 교체, 동적 변경을 쉽게 만들어준다.
  • Observer 패턴에서 우리는 observer 라 불리는 특정 객체들을 observable 이라 불리는 다른 객체들에 subscribe 시킬수 있으며 이를 통해 언제든 이벤트가 발생하면 observable 은 다른 모든 observer들에게 알리게 된다.

observers : 언제든 특정 이벤트가 발생하면 이에 대해 notified 될 관찰자 observer 들의 배열
subscribe() : observer 리스트에 observer 를 추가하기 위한 메서드
unsubscribe() : observer 리스트에서 observer 를 제거하기 위한 메서드
notify() : 언제든 특정 이벤트가 발생하면 observers 들에게 알리기 위한 메서드

ES2015 에서의 예시를 확인해보자

class Observable {
  constructor() {
    this.observers = [];
  }
 
  subscribe(func) {
    this.observers.push(func);
  }
 
  unsubscribe(func) {
    this.observers = this.observers.filter((observer) => observer !== func);
  }
 
  notify(data) {
    this.observers.forEach((observer) => observer(data));
  }
}

위의 클래스가 있다면 subscribe 메서드로 observer 를 observer list 에 추가할 수 있고 unsubscribe 메서드로 리스트에서 제거할 수 있으며 notify 메서드로 이벤트를 다른 모든 observer 들에게 알릴 수 있다.

다음은 observer pattern for javascript and react 때마침 react 를 공부하고 있던 찰나 observer pattern 과 ES2015 가 사용된 매우 적절한 예시였다. 구체적으로는 관찰자를 등록, 해제, 공지할 수 있는 observer pattern 을 통해 사용자가 언제든 버튼을 클릭하거나 토글시 이를 로그로 남길 수 있도록 하는 기능이다.

기능만 들어도 대강 실시간 매칭 서비스나 통계 같은 서비스를 제공할때, subscribe 와 notify 등이 필요한 경우에 자주 사용될법한 패턴으로 보인다.

📗 Strategy Pattern

  • 객체들이 할 수 있는 행위 각각에 전략 클래스를 생성하고 유사한 행위들을 캡슐화하는 인터페이스를 정의하며 객체의 행위를 동적으로 바꾸고 싶은 경우 직접 행위를 수정하지 않고 전략을 바꿔주기만 함으로써 행위를 유연하게 확장하는 방법이다. 즉, 수차례 같은 로직이 돌아가는 것을 방지하기 위해 특정 입력값에 따라 로직을 구현하여 캡슐화를 시켜놓은 것을 말한다.
// auth.controller.ts

import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller('auth')
export class AuthController {
  @Get('google')
  @UseGuards(AuthGuard('google'))
  async googleLogin() {
    ...
  }

  @Get('google/callback')
  @UseGuards(AuthGuard('google'))
  async googleLoginCallback(@Req() req) {
    // 구글 로그인 후 콜백 URL로 요청되는 부분
    // 로그인에 성공하면, GoogleStrategy의 validate() 메서드에서 반환한 사용자 정보가 req.user에 담겨 있다.
    return req.user;
  }
}

예를 들어 sns 로그인을 Strategy Pattern 으로 구현한 사례이다. 위에서 Decorator Pattern 으로 UseGuard 에 RoleGuard 를 사용했던 것과 달리 AuthGuard 를 사용했다. 패턴을 보여주기 위한 예시코드라 일부만 작성했는데 이처럼 매우 유용하게 사용되는 패턴중 하나이다.

📘 SOLID 원칙

앞에서 찾아봤던 디자인 패턴들은 SOLID 설계 원칙에 입각하여 만들어진 것이기에 표준화부터 아키텍쳐 설계에 이르기까지 다양하게 적용되는 SOLID 원칙에 대해 자세히 알아야 할 필요가 있다.

SOLID 객체 지향 원칙 적용시 코드를 확장하고 유지보수가 용이해지며 복잡성을 제거해 리펙토링 시간을 줄여 개발의 생산성을 높여준다.

단, 변화가 없는 코드의 경우 굳이 SOLID 원칙을 적용해야하는 의문이 남는다.

📗 SRP (Single Responsibility Principle)

단일 책임 원칙

클래스는 단 하나의 책임만 가져야 한다.

책임 : 하나의 기능 담당

책임이라는 말이 애매할 수 있다. 다만 개개인에 따라 적용될 수 있는 책임이 달라질 수 있는데 컨트롤러의 경우엔 전체 내용의 흐름제어라는 큰 책임을 가질 수 있고 객체의 경우 작은 책임을 갖는다고 볼수 있을 것 같다.

프로그램의 유지보수성을 높이기 위한 설계기법이다. SRP 를 잘 따르면 한 책임의 변경으로부터 다른 책임의 변경으로의 연쇄작용에서 자유로워진다.

각 책임(기능 담당)에 맞게 클래스를 분리하여 구성한다.

주의점

  • 클래스명은 책임의 소재를 알수있게
  • 책임을 분리할때 항상 결합도(낮게)와 응집도(높게)를 따진다.
// SRP를 위반하는 클래스
class Order {
  constructor(orderId, customerName, items) {
    this.orderId = orderId;
    this.customerName = customerName;
    this.items = items;
  }

  calculateTotalPrice() {
    let totalPrice = 0;
    for (const item of this.items) {
      totalPrice += item.price;
    }
    return totalPrice;
  }

  saveOrderToDatabase() {
    // 주문 데이터를 데이터베이스에 저장하는 로직
  }

  sendOrderConfirmationEmail() {
    // 주문 확인 이메일을 보내는 로직
  }
}

// 사용 예시
const order = new Order(1, '홍길동', [{ name: '상품1', price: 100 }, { name: '상품2', price: 200 }]);
order.saveOrderToDatabase();
order.sendOrderConfirmationEmail();

위의 예시코드의 경우 Order 클래스가 주문을 생성, 주문을 저장, 이메일 전송등 주문 클래스가 여러 기능을 담당하고 있다. 이는 SRP 를 위반하는 코드이다. 클래스는 단 하나의 책임만 가져아 하나 너무 많은 책임을 갖고 있다.

// SRP를 지키는 클래스들
class Order {
  constructor(orderId, customerName, items) {
    this.orderId = orderId;
    this.customerName = customerName;
    this.items = items;
  }

  calculateTotalPrice() {
    let totalPrice = 0;
    for (const item of this.items) {
      totalPrice += item.price;
    }
    return totalPrice;
  }
}

class OrderRepository {
  saveOrder(order) {
    // 주문 데이터를 데이터베이스에 저장하는 로직
  }
}

class EmailService {
  sendEmail(emailContent) {
    // 이메일을 보내는 로직
  }
}

// 사용 예시
const order = new Order(1, '홍길동', [{ name: '상품1', price: 100 }, { name: '상품2', price: 200 }]);

const orderRepository = new OrderRepository();
orderRepository.saveOrder(order);

const emailService = new EmailService();
const emailContent = `주문 번호: ${order.orderId}, 총 가격: ${order.calculateTotalPrice()}`;
emailService.sendEmail(emailContent);

SRP 를 지키기 위해서 이처럼 하나의 클래스는 단일 책임을 갖게 설계해야 한다. 이를 통해 코드의 재사용성과 관리성이 증가한다.

📗 OCP (Open Closed Principle)

개방 폐쇄 원칙

확장에 열려있어야 하고 수정에는 닫혀 있어야 한다.

  • 확장에 열려 있다 : 새 변경사항 발생시 유연하게 코드를 추가해 큰 리소스 소모 없이 애플리케이션 기능을 확장할 수 있다.
  • 변경에 닫혀 있다. : 새 변경사항 발생시 객체의 직접적인 수정을 제한한다.

클래스를 확장을 통해 손쉽게 구현하면서 확장에 따른 클래스 수정은 최소화 해야 한다.

// OCP를 위반하는 클래스
class PaymentProcessor {
  processPayment(payment) {
    if (payment.method === 'credit_card') {
      // 신용카드 결제 처리 로직
    } else if (payment.method === 'bank_transfer') {
      // 은행 이체 결제 처리 로직
    } else {
      throw new Error('지원되지 않는 결제 방법입니다.');
    }
  }
}

// 사용 예시
const paymentProcessor = new PaymentProcessor();
const payment1 = { method: 'credit_card', amount: 100 };
paymentProcessor.processPayment(payment1);

const payment2 = { method: 'bank_transfer', amount: 200 };
paymentProcessor.processPayment(payment2);

위의 코드를 보자. 결제수단으로 현금이 추가된다면? 애플페이가 추가된다면? 문화상품권이 추가된다면?

코드를 확장하려고 할때 클래스 자체를 수정해야 한다. 이는 확장에 열려있지 않다.

// OCP를 준수하는 클래스
class PaymentProcessor {
  constructor(paymentHandler) {
    this.paymentHandler = paymentHandler;
  }

  processPayment(payment) {
    this.paymentHandler.process(payment);
  }
}

// 결제 방법에 대한 처리기 클래스
class CreditCardPaymentHandler {
  process(payment) {
    // 신용카드 결제 처리 로직
  }
}

class BankTransferPaymentHandler {
  process(payment) {
    // 은행 이체 결제 처리 로직
  }
}

// 사용 예시
const creditCardPaymentHandler = new CreditCardPaymentHandler();
const bankTransferPaymentHandler = new BankTransferPaymentHandler();

const payment1 = { method: 'credit_card', amount: 100 };
const payment2 = { method: 'bank_transfer', amount: 200 };

const paymentProcessor1 = new PaymentProcessor(creditCardPaymentHandler);
paymentProcessor1.processPayment(payment1);

const paymentProcessor2 = new PaymentProcessor(bankTransferPaymentHandler);
paymentProcessor2.processPayment(payment2);

OCP 를 준수하도록 짠 코드다. 이 코드의 경우 확장시 PaymentProcessor 자체는 건드리지 않는다. 변경에 닫혀있다. 그런데 확장에는 자유롭다.

📗 LSP (Listov Substitution Principle)

리스코프 치환 원칙

자식 클래스는 언제나 자신의 부모 클래스를 대체할 수 있다.

// LSP를 위반하는 클래스
class Rectangle {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Rectangle {
  constructor(side) {
    super(side, side);
  }

  setWidth(width) {
    this.width = width;
    this.height = width; // 정사각형의 너비와 높이를 동시에 설정
  }

  setHeight(height) {
    this.width = height; // 정사각형의 너비와 높이를 동시에 설정
    this.height = height;
  }
}

// 사용 예시
function printArea(rectangle) {
  rectangle.setWidth(5);
  rectangle.setHeight(4);
  console.log('너비: 5, 높이: 4 일 때, 면적:', rectangle.getArea());
}

const rectangle = new Rectangle(0, 0);
const square = new Square(0);

printArea(rectangle); // 너비: 5, 높이: 4 일 때, 면적: 20
printArea(square); // 너비: 5, 높이: 5 일 때, 면적: 25

위반사례이다. 정사각형은 직사각형의 특징을 가지는 것 같으니 직사각형을 상속받았다. 그런데 넓이를 계산하려 하니 예상과 다르게 나타난다. 부모 자식 클래스라 생각했으나 완전히 치환되지 않는 부분이 있다는 것이다.

// LSP를 준수하는 클래스
class Shape {
  getArea() {
    throw new Error('이 메서드는 하위 클래스에서 구현되어야 합니다.');
  }
}

class Rectangle extends Shape {
  constructor(width, height) {
    super();
    this.width = width;
    this.height = height;
  }

  getArea() {
    return this.width * this.height;
  }
}

class Square extends Shape {
  constructor(side) {
    super();
    this.side = side;
  }

  getArea() {
    return this.side * this.side;
  }
}

// 사용 예시
function printArea(shape) {
  console.log('면적:', shape.getArea());
}

const rectangle = new Rectangle(5, 4);
const square = new Square(5);

printArea(rectangle); // 면적: 20
printArea(square); // 면적: 25

직사각형과 정사각형은 사각형이라는 공통점은 분명히 갖는다. 이들은 넓이를 갖고 있으니 Shape 이라는 부모 클래스가 넓이를 구하는 getArea 메서드를 직사각형과 정사각형이 각각 오버라이딩하여 각자의 특성에 맞게 재구성하는 코드이다.

LSP 는 다음 블로그를 통해 다시 보자. 바로 이해가 되지 않아서 몇번 다시 봐야 할 것 같다. 짜비 - LSP

📗 ISP (Interface Segregation Principle)

인터페이스 분리 원칙

클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다.

인터페이스를 각각 사용에 맞게끔 잘 분리해야 한다. 인터페이스를 클라이언트를 기준으로 분리하여 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공한다. 한번 인터페이스를 구성해두고 나중에 수정사항이 생긴다고 하더라도 인터페이스를 다시 분리해서는 안된다.

// ISP를 위반하는 예제
class Animal {
  swim() {
    // 모든 동물이 수영할 수 있는 기능
  }

  fly() {
    // 모든 동물이 날 수 있는 기능
  }
}

class Bird extends Animal {
  swim() {
    throw new Error('새는 수영을 할 수 없습니다.');
  }
}

class Fish extends Animal {
  fly() {
    throw new Error('물고기는 날 수 없습니다.');
  }
}

동물이라고해서 필요한 기능들을 모두 프로토타입 메서드로 집어넣었는데 정작 동물을 상속받는 클래스중 지원되지 않는 기능이 있던 것이다.

내가 안쓰는 기능은 넣지 말아야 한다. 이들을 분리해보자.

// ISP를 준수하는 인터페이스들
// ISP를 준수하는 예제
class SwimmingAbility {
  swim() {
    ...
  }
}

class FlyingAbility {
  fly() {
    ...
  }
}

class Bird extends FlyingAbility {
  // 새는 날 수 있는 기능만 구현
}

class Fish extends SwimmingAbility {
  // 물고기는 수영할 수 있는 기능만 구현
}

이제 이해가 된다.

📗 DIP (Dependency Inverion Principle)

의존 역전 원칙

의존시 변화하기 어려운 것을 의존하라는 원칙이다.

어떤 class 를 참조해서 사용해야 하는 상황이 생길시, 그 class 를 직접 참조하는 것이 아니라 그 대상의 사위 요소로 참조하라는 원칙이다. 즉, 구현 클래스에 의존하지 말고 인터페이스에 의존하거나 만약 의존시 변화하기 쉬운 것보다 변화가 거의 없는 것에 의존하라는 원칙이다.

// DIP 위반 예제

class LowLevelModule {
  // 저수준 모듈: 데이터베이스와 상호작용하는 클래스
  // 데이터베이스에 직접 의존하고 있음
  constructor() {
    this.db = new Database();
  }

  saveData(data) {
    this.db.save(data);
  }
}

class HighLevelModule {
  // 고수준 모듈: 비즈니스 로직을 수행하는 클래스
  // 저수준 모듈에 의존하고 있음
  constructor() {
    this.lowLevelModule = new LowLevelModule();
  }

  processData(data) {
    // 비즈니스 로직 처리
    this.lowLevelModule.saveData(data);
  }
}

고수준에서 저수준에 직접 의존하고 있으니 DIP 를 위배한다. 이는 코드의 유연성을 떨어뜨린다.

// DIP 준수 예제

// 인터페이스 정의
class IDbHandler {
  save(data) {}
}

class Database extends IDbHandler {
  save(data) {
    // 데이터베이스에 데이터 저장하는 코드
  }
}

class FileHandler extends IDbHandler {
  save(data) {
    // 파일에 데이터 저장하는 코드
  }
}

class HighLevelModule {
  constructor(dbHandler) {
    // 인터페이스를 통해 의존성 주입(Dependency Injection) 받음
    this.dbHandler = dbHandler;
  }

  processData(data) {
    // 비즈니스 로직 처리
    this.dbHandler.save(data);
  }
}

// 실제 사용 사례

// 데이터베이스 핸들러를 이용한 HighLevelModule 인스턴스 생성
const databaseHandler = new Database();
const highLevelModuleWithDatabase = new HighLevelModule(databaseHandler);

// 파일 핸들러를 이용한 HighLevelModule 인스턴스 생성
const fileHandler = new FileHandler();
const highLevelModuleWithFile = new HighLevelModule(fileHandler);

// 데이터를 처리하는 사용 예시
const data1 = { id: 1, name: "사용자1" };
const data2 = { id: 2, name: "사용자2" };

highLevelModuleWithDatabase.processData(data1);
// 출력: 데이터베이스에 저장되었습니다: { id: 1, name: '사용자1' }

highLevelModuleWithFile.processData(data2);
// 출력: 파일에 저장되었습니다: { id: 2, name: '사용자2' }

저수준 클래스에 직접 의존하는 것이 아닌 중간에 인터페이스를 통해 코드를 구현하는 모습을 볼 수 있다.

그런데 사례를 찾다보니 java 였다면 이해가 쉬웠을 것이 JS 로 바꾸다보니 애매해지는 경우가 많은 것 같다. 아무래도 동적 타입 언어라 타입 체크가 엄격하게 되지 않는 부분도 코드를 작성하는 부분에서의 유연성을 높이나 안정성을 낮추는 것처럼 이런 부분에서 좀 애매한 것 같다.

📔 레퍼런스

docs
mdn - MVC
Patterns.dev ⭐️
blog
뜐뜐뜐- mvc
yoonjongpark - mvc
보스독 테크코스 - 싱글톤 패턴
Inpa Dev - SRP
Inpa Dev - SOLID
Inpa Dev - OCP
Inpa Dev - DIP
te-ing - Observer Pattern
victolee - Strategy Pattern
yukina1418 - strategy & decorator pattern
turtle61 - mvc 패턴 & 싱글톤

📓 결론

  • MVC 패턴, 싱글톤 패턴, observer 패턴, strategy 패턴, decorator 패턴, SOLID 원칙 등에 대해 알아보았다.

용어만 듣다보면 이해한 것 싶다가도 실제로 어떻게 적용하는지 묻는다면 모르는 경우가 많기에 실제 코드로 어떻게 쓰이는지 되도록이면 정리하고자 했다.

MVC 라는 말을 많이 들어봤다. 그런데 정작 설명하고자 하니 말이 잘 안나오고 구현할때도 제대로 적용하기 힘들었다. 그런데 정작 개념을 찾아보면 포스트는 많은데 깊이있게 알기는 어려웠다. 대체로 view 와 model 의 분리를 통해 유지보수와 확장성을 높일 수 있는 디자인 패턴이라는 내용이었다.

이전에 nestjs 에서 MVC 패턴에 따라 Model, View, Controller 를 나누고 추가로 Service, Repository 를 나눠 5계층으로 엄격히 분리해서 관리했던 적이 있다. 그 당시에도 비즈니스 로직이 많이 헷갈리고 Repository 와 Model 이 분리되니 DB 는 그래서 어떻게 처리해야 하는지, @Injection 으로 DI 를 해줄 수 있는데 그렇게 Controller 에서 Repository 를 불러오는 것과 같이 DIP 를 위배하는 코드를 짜서 cyclic dependency 가 발생하기도 했다.

부트캠프에서는 express 와 함께 간단하게 controller, model, view + router 등으로 나누어 mvc 패턴을 적용해보는 시간을 가졌다. 이것 자체는 크게 문제로 다가오진 않았다.

다만 개인적으로 view 는 UI 만 담당하고 model 이 비즈니스 로직을 담당하며 그 사이를 controller 로 연결지어 로직을 처리하는데 동적 form 의 경우 view 에 해당하는 ejs 에서 script 로 관리하는 것이 UI 만 담당한다고 했던 view 에서 로직이 처리되는 것 같아 mvc 패턴에 위배되는 것이 아닌가하는 생각에 좀 혼란스러웠다.

생각하기로는 model 을 통해 얻은 정보를 controller 를 통해 view 에 정보를 전달하거나 view 에서 유저의 입력에 따라 controller 로 전달된 입력을 model 에 넣어 생긴 update 를 controller 가 다시 view 에 전달하는 과정이 mvc 에 맞지 않는가 라는 생각이 들었다.

찾다 보니 동적 form 같은 경우 UI 로직에 해당하는 UI 변경에 관한 부분은 view ejs 안에서 script 를 통해 ajax 같은 기능을 사용할 수 있었다.

사실 디자인 패턴은 정말 엄격히 적용하자면 끝없이 엄격해질 수 있다. 많은 사람들이 이에 대해 피로감을 느끼는 것 같다. 대체로 적당한 수준에서의 디자인 패턴 적용을 원했고 나 역시 마찬가지였다. 이정도의 로직정도는 UI 로직에서 처리하게 view 에서 충분히 처리할 수 있다는 생각이 들었다.

워낙 중요한 내용이였던 터라 더 관심을 갖고 회고를 작성해봤다. 심지어 우테코 담당하시는 분이 회고의 중요성에 대해 말하는 글을 보았던 터라 앞으로 더 열심히 회고를 작성해야 겠다는 생각이 들었다.

profile
미래의 나를 만들어나가는 한 개발자의 블로그입니다.

0개의 댓글