계층형 아키텍처 패턴 프로젝트 - 템플릿

https://hanghae99-node-example.s3.ap-northeast-2.amazonaws.com/re/layered-architecture-pattern-template.zip

 

위 링크에서 파일을 다운로드 받아 압축을 해제한다.

 

  • .env 파일에서 DB 속성을 변경한다.

꼭, DATABASE_URL을 대여한 RDS의 속성값으로 수정한다.

사용할 데이터베이스 명은 layerd_architecture_pattern_db 로 한다.

 

// .env

# MySQL의 데이터베이스 URL입니다.
DATABASE_URL="mysql://아이디:비번@RDS_EndPoint/layered_architecture_pattern_db"

 

yarn 패키지를 설치하고, Prisma로 DB 및 테이블 정보를 동기화 한다.

 

# yarn 패키지 설치
yarn

# Prisma를 이용하여 DB 및 테이블 생성
npx prisma db push

 

계층형 아키텍처 패턴 프로젝트 ( 디렉토리 구조 )

내 프로젝트 폴더 이름
├── package.json
├── prisma
│   └── schema.prisma
├── src
│   ├── app.js
│   ├── controllers
│   │   └── posts.controller.js
│   ├── middlewares
│   │   ├── error-handling.middleware.js
│   │   └── log.middleware.js
│   ├── repositories
│   │   └── posts.repository.js
│   ├── routes
│   │   ├── index.js
│   │   └── posts.router.js
│   ├── services
│   │   └── posts.service.js
│   └── utils
│       └── prisma
│           └── index.js
└── yarn.lock

 

이 프로젝트의 디렉토리 구조는 기존 프로젝트와는 조금 다르다.

새롭게 controllers, services, repositories 라는 폴더가 보이는데, 이 폴더들은 각 계층병 역할을 담당한다.

 

새로운 디렉토리 구조에 맞춰 3계층 아키텍처 프로젝트를 구성해보자.


 

컨트롤러( Controller )

프레젠테이션 계층( Presentation Layer ) 

3계층 아키텍처 패턴에서 가장 먼저 클라이언트의 요청( Request )을 만나게 되는 계층이고, 대표적으로 컨트롤러( Controller )가 이 역할을 담당한다.

  • 하위 계층( 서비스 계층, 저장소 계층 )에서 발생하는 예외( Exception )를 처리한다.
  • 클라이언트가 전달한 데이터에 대해 유효성을 검증하는 기능을 수행한다.
  • 클라이언트의 요청을 처리한 후 서버에서 처리된 결과를 반환( Response )한다.

 

컨트롤러( Controller )

클라이언트의 요청( Request )을 처리하고, 서버에서 처리된 결과를 반환( Response )하는 역할을 담당한다.

컨트롤러( Controller )는 추가적으로 아래의 역할을 담당한다.

  • 클라이언트의 요청( Request )을 수신한다.
  • 요청( Request )에 들어온 데이터 및 내용을 검증한다.
  • 서버에서 수행된 결과를 클라이언트에게 반환( Response )한다.

 

Express로 구현하는 컨트롤러

 

Express에서는 컨트롤러라우터( Router )를 연결하기 위해서는 express.Router를 사용해, 라우터가 클라이언트의 요청( Request )에서 특정 URIHTTP Method를 전달받았을 때 컨트롤러의 특정 메서드로 요청된 내용을 전달하도록 구현해야한다.

 

그렇기 때문에 routes 폴더에서 posts.router.js 라는 파일을 만들어 PostsController와 연결하도록 구성해보자.

 

routes/posts.router.js

// src/routes/posts.router.js

import express from 'express';
import { PostsController } from '../controllers/posts.controller.js';

const router = express.Router();

// PostsController의 인스턴스를 생성합니다.
const postsController = new PostsController();

/** 게시글 조회 API **/
router.get('/', postsController.getPosts);
/** 게시글 작성 API **/
router.post('/', postsController.createPost);

export default router;

 

router.get('/', postsContoller.getPosts); 

  • router.get은 저희가 Express를 이용해 API를 만들때 매번 사용했던 코드다.
  • 이 메서드는 / URI에 대한 GET 메서드 요청이 들어올 경우, postsController.getPosts를 호출하라는 의미다.
  • 즉, 클라이언트의 요청이 들어오면, PostsController 클래스에서 정의된 getPosts메서드를 실행하도록 라우터를 설정한 것이다.

위 결과로 Post에 해당하는 컨트롤러와 라우터를 연결할 수 있게 되었다.

 

posts.router.js 파일을 통해 컨트롤러라우터연결했으니, 다음 단계로 posts.controller.js 파일을 작성해 Post컨트롤러를 구현해보자.

 

controllers/posts.controller.js

// src/controllers/posts.controller.js

import { PostsService } from '../services/posts.service.js';

// Post의 컨트롤러(Controller)역할을 하는 클래스
export class PostsController {
  postsService = new PostsService(); // Post 서비스를 클래스를 컨트롤러 클래스의 멤버 변수로 할당합니다.

  getPosts = async (req, res, next) => {
    try {
      // 서비스 계층에 구현된 findAllPosts 로직을 실행합니다.
      const posts = await this.postsService.findAllPosts();

      return res.status(200).json({ data: posts });
    } catch (err) {
      next(err);
    }
  };

  createPost = async (req, res, next) => {
    try {
      const { nickname, password, title, content } = req.body;

      // 서비스 계층에 구현된 createPost 로직을 실행합니다.
      const createdPost = await this.postsService.createPost(
        nickname,
        password,
        title,
        content,
      );

      return res.status(201).json({ data: createdPost });
    } catch (err) {
      next(err);
    }
  };
}

 

await this.postsService.findAllPosts();

  • await this.postsService.findAllPosts(); 는 PostsController 클래스의 postService 인스턴스에서 findAllPost 메서드를 호출한다.
  • 컨트롤러는 하위 계층내부 구조에 대해 신경쓰지 않는다. 대신, 외부에 공개된 메서드를 호출하기만 한다. 이것이 가능한 이유는 추상화( Absctraction )의 특성 덕분이다.
  • PostsController 클래스는 전달된 요청( Request )을 처리하기 위해 PostsService를 호출하도록 구현했다. 여기서 컨트롤러비즈니스 로직을 직접 수행하지 않고, 클라이언트요청서비스 계층으로 바로 전달 하도록 구현한 것을 확인 할 수 있다.

결국, PostsController 클래스클라이언트의 요청( Request )서비스 계층으로 전달하는 역할을 수행하며, 서비스 계층이 어떠한 내부 구조를 통해 비즈니스 로직을 수행하는지는 상위 계층컨트롤러에게는 중요하지 않다.

 


 

서비스( Service )

서비스 계층( Service Layer )

다른 이름으로는 비즈니스 로직 계층( Business logic layer )은 아키텍처의 가장 핵심적인 비즈니스 로직을 수행하고 클라이언트가 원하는 요구사항을 구현하는 계층이다.

  • 프레젠테이션 계층( Persentation Layer )데이터 엑세스 계층( Data Access Layer ) 사이에서 중간 다리 역할을 하고, 서로 다른 두 계층이 직접 통신하지 않게 만들어 준다.
  • 서비스( Service )데이터가 필요할 때, 저장소( Repository )에게 데이터를 요청한다.
  • 어플리케이션의 규모가 커질수록, 서비스 계층의 역할코드의 복잡성도 점점 더 커지게 된다.
  • 어플리케이션의 핵심적인 비즈니스 로직을 수행하고, 클라이언트들의 요구사항을 반영해 원하는 결과를 반환해주는 계층이다.

 

서비스 계층의 장단점

서비스 계층의 장점

  • 사용자의 유즈 케이스( Use Case )워크플로우( Workflow )를 명확히 정의하고 이해할 수 있도록 도와준다.
  • 비즈니스 로직이 API 뒤에 숨겨져 있으므로, 서비스 계층의 코드를 자유롭게 수정하거나 리팩터링할 수 있다.
  • 저장소 패턴( Repository Pattern )가짜 저장소( Fake Repository )와 조합하면 높은 수준테스트작성할 수 있다.

서비스 계층의 단점

  • 서비스 계층 또한 다른 추상화 계층이므로, 잘못 사용하면 코드의 복잡성을 증가시킬 수 있다.
  • 한 서비스 계층이 다른 서비스 계층에 의존하는 경우, 의존성 관리가 복잡해질 수 있다.
  • 서비스 계층에 너무 많은 기능을 넣으면 빈약한 도메인 모델( Anemic Domain Model )과 같은 안티 패턴이 생길 수 있다.

 

Express로 구현하는 서비스 계층

Service

  • 사용자의 요구사항을 처리하는 핵심 부분
  • DB 정보가 필요할 때는 Repository에게 요청한다.

PostsController 클래스가 클라이언트의 요청( Request )을 PostsServer 클래스에게 전달하는 과정을 살펴봤다.

이번에는 서비스 계층( Service Layer )에게 비즈니스 로직을 어떻게 수행하고, 필요한 데이터를 어떻게 저장소( Repository )로부터 요청하는지 확인해보도록 하자.

 

posts.service.js

// src/services/posts.service.js

import { PostsRepository } from '../repositories/posts.repository.js';

export class PostsService {
  postsRepository = new PostsRepository();

  findAllPosts = async () => {
    // 저장소(Repository)에게 데이터를 요청합니다.
    const posts = await this.postsRepository.findAllPosts();

    // 호출한 Post들을 가장 최신 게시글 부터 정렬합니다.
    posts.sort((a, b) => {
      return b.createdAt - a.createdAt;
    });

    // 비즈니스 로직을 수행한 후 사용자에게 보여줄 데이터를 가공합니다.
    return posts.map((post) => {
      return {
        postId: post.postId,
        nickname: post.nickname,
        title: post.title,
        createdAt: post.createdAt,
        updatedAt: post.updatedAt,
      };
    });
  };

  createPost = async (nickname, password, title, content) => {
    // 저장소(Repository)에게 데이터를 요청합니다.
    const createdPost = await this.postsRepository.createPost(
      nickname,
      password,
      title,
      content,
    );

    // 비즈니스 로직을 수행한 후 사용자에게 보여줄 데이터를 가공합니다.
    return {
      postId: createdPost.postId,
      nickname: createdPost.nickname,
      title: createdPost.title,
      content: createdPost.content,
      createdAt: createdPost.createdAt,
      updatedAt: createdPost.updatedAt,
    };
  };
}

 

이번 서비스 계층( Service Layer )에서 PostsService 클래스가 PostsRepository의 findAllPosts, createPost 메서드를 호출하는 것을 확인할 수 있다. 해당 코드는 서비스가 비즈니스 로직을 수행하는 데 필요한 데이터를 저장소 계층( Repository Layer )에게 요청해 가져오는 것을 확인 할 수 있다.

또한, 서비스 계층에서는 return posts.map(post => {}); 와 같이 데이터를 가공하는 작업이 이루어진다.

만약, 저장소 계층에서 받은 데이터를 그대로 클라이언트에게 전달한다면, 사용자의 비밀번호와 같은 민감한 정보까지 노출되는 보안 문제가 발생해, 서버의 보안성이 떨어지는 결과를 낳는다.

 


 

저장소( Repository )

저장소 계층( Repository Layer )

데이터 엑세스 계층( Data Access Layer )이라고 불린다. 주로 데이터베이스와 관련된 작업을 처리하는 계층이다.

  • 데이터 접근과 관련된 세부 사항을 숨기는 동시에, 메모리상에 데이터가 존재하는 것처럼 가정해 코드를 구현한다.
  • 저장소 계층을 도입하면, 데이터 저장 방법을 더욱 쉽게 변경할 수 있고, 테스트 코드 작성시 가짜 저장소( Mock Repository )를 제공하기가 더 쉬워진다.
  • 어플리케이션의 다른 계층들은 저장소의 세부 구현 방식에 대해 알지 못하더라도 해당 기능을 사용할 수 있다. 즉, 저장소 계층의 변경 사항이 다른 계층에 영향을 주지 않는 것이다. ( 객체 지향의 개념 중 추상화( Abstraction)와 관계가 있다. )
  • 저장소 계층은 데이터 저장소를 간단히 추상화한 것으로, 이 계층을 통해 모델 계층과 데이터 계층을 명확하게 분리할 수 있다.

대표적인 저장소 계층의 메서드

  • add(), create() : 새 원소를 저장소에 추가한다.
  • get(), find() : 이전에 추가한 원소를 저장소에 가져온다.

 

저장소 계층의 장단점

 

저장소 계층의 장점

  • 데이터 모델과 데이터 처리 인프라에 대한 사항을 분리했기 때문에 단위 테스트( Unit test )를 위한 가짜 저장소( Mock Repository )를 쉽게 만들 수 있다.
  • 도메인 모델을 미리 작성해, 처리해야할 비즈니스 문제에 더 잘 집중할 수 있다.
  • 객체를 테이블에 매칭하는 과정을 원하는 대로 제어할 수 있어서 DB 스키마를 단순화할 수 있다.
  • 저장소 계층에 ORM을 사용하면 필요할 때 MySQL과 Postgre와 같은 다른 데이터베이스로 쉽게 전환할 수 있다.

저장소 계층의 단점

  • 저장소 계층이 없더라도 ORM은 모델과 저장소의 결합도를 충분히 완화시켜 줄 수 있다. ( ORM이 없을 때 대부분의 코드는 Raw Query로 작성되어 있기 때문 )
  • ORM 매핑을 수동으로 하려면 개발 코스트가 더욱 소모된다. ( 여기서 설명하는 ORM은 이전에 사용한 Prisma와 같은 라이브러리를 말한다. )

 

Express로 구현하는 저장소 계층

Repository

  • 데이터베이스 관리( 연결, 해제, 자원 관리 ) 역할을 담당한다.
  • 데이터베이스의 CRUD 작업을 처리한다.

3계층 아키텍처의 마지막 계층인 저장소 계층( Repository Layer )이다.

이전에 작성했던 코드에서 서비스 계층( Service Layer )인 PostsServices에서 PostsRepository를 호출해 데이터를 요청하는 것을 확인할 수 있었다.

이번에는 저장소 계층( Repository Layer )이 어떻게 데이터베이스의 데이터를 가져와 상위 계층에게 반환하는지 확인해보도자.

 

posts.repository.js

// src/repositories/posts.repository.js

import { prisma } from '../utils/prisma/index.js';

export class PostsRepository {
  findAllPosts = async () => {
    // ORM인 Prisma에서 Posts 모델의 findMany 메서드를 사용해 데이터를 요청합니다.
    const posts = await prisma.posts.findMany();

    return posts;
  };

  createPost = async (nickname, password, title, content) => {
    // ORM인 Prisma에서 Posts 모델의 create 메서드를 사용해 데이터를 요청합니다.
    const createdPost = await prisma.posts.create({
      data: {
        nickname,
        password,
        title,
        content,
      },
    });

    return createdPost;
  };
}

 

이번 저장소 계층( Repository Layer )에서는 PostRepository 클래스에서 Prisma의 메소드를 사용해 데이터를 조회하거나 생성하는 것이 가장 핵심적인 내용이다.

 

위 예제에서는 단일 테이블만 활용해 Prisma를 사용했기 때문에 코드가 복잡해지지 않았다. 당연히 어플리케이션의 규모가 커지거나, 데이터베이스의 구성이 복잡해지면 저장소 계층의 구조 또한 복잡해진다.

오늘의 목표

더보기

✔️ 프로그래머스 코테 문제 풀기

✔️ 챌린지반 수업 참가

✔️ Node.js 심화 강의 듣기


⏱️ 오늘의 일정

프로그래머스 코테 문제 풀기
Node.js 심화 강의 듣기

챌린지반 수업 참가 


📜 프로그래머스 코테 문제 풀기

 

햄버거 만들기

https://github.com/YamSaeng/AlgorithmCodingTest/tree/main/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4/1/133502.%E2%80%85%ED%96%84%EB%B2%84%EA%B1%B0%E2%80%85%EB%A7%8C%EB%93%A4%EA%B8%B0

 

AlgorithmCodingTest/프로그래머스/1/133502. 햄버거 만들기 at main · YamSaeng/AlgorithmCodingTest

This is an auto push repository for Baekjoon Online Judge created with [BaekjoonHub](https://github.com/BaekjoonHub/BaekjoonHub). - YamSaeng/AlgorithmCodingTest

github.com

 

function solution(ingredient) {
    var answer = 0;

    let ingredients = [];

    for (let i = 0; i < ingredient.length; i++) {
        ingredients.push(ingredient[i]);

        if (ingredients.length >= 4
            && ingredients[ingredients.length - 1] == 1
            && ingredients[ingredients.length - 2] == 3
            && ingredients[ingredients.length - 3] == 2
            && ingredients[ingredients.length - 4] == 1) {

            answer++;
            ingredients.pop(); // 1
            ingredients.pop(); // 3
            ingredients.pop(); // 2
            ingredients.pop(); // 1
        }
    }

    return answer;
}

 

 

📜 Node.js 심화 강의 듣기

 

캠프에서 제공받은 Node.js 심화 강의를 수강하고 있다.

앞서 언급한 대로 Node.js의 심화 내용이라기보다는

객체지향 프로그래밍과 아키텍처 패턴을 자세히 알아보는 수업이다.

 

2024.09.27 - [IT] - [IT] 객체 지향 설계 5 원칙 ( SOLID )

 

[IT] 객체 지향 설계 5 원칙 ( SOLID )

객체 지향 프로그래밍 및 설계의 다섯 가지 핵심 원칙을 SOLID라고 부른다.SOLID는 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙의 맨 앞단어를 하나씩 가져와 만든 것이다.SOLID 원칙을 따

program-yam.tistory.com

 

2024.09.27 - [IT] - [IT] 아키텍처 패턴 ( Architecture Pattern )

 

[IT] 아키텍처 패턴 ( Architecture Pattern )

아키텍처 패턴은 소프트웨어의 구조를 구성하기위한 가장 기본적인 토대를 제시한다.아키텍처 패턴은 각각의 시스템들과 그 역할이 정의되어 있고, 여러 시스템 사이의 관계와 규칙 등이 포함

program-yam.tistory.com

 

2024.09.27 - [IT] - [IT] 계층형 아키텍처 패턴 ( Layered Architecture Pattern )

 

[IT] 계층형 아키텍처 패턴 ( Layered Architecture Pattern )

계층형 아키텍처 패턴 ( Layered Architecture Pattern )은 시스템을 여러 계층으로 분리하여 관리하는 아키텍처 패턴이다.현재 가장 널리 채택되고 있는 아키텍처 패턴 중 하나다. ( Spring, Nest.js을 사용

program-yam.tistory.com

 

객체 지향 설계 원칙 ( SOLID )와 아키텍처 패턴, 계층형 아키텍처 패턴에 대해서 배웠다.

 

📜 챌린지반 수업 듣기

 

이번 수업도 DB에 대한 내용을 배웠다.

트랜잭션의 대한 개념과, ACID 특성, 데이터베이스 보안 ( 인증, 인가 )에 대한 내용을 배웠다.

계층형 아키텍처 패턴 ( Layered Architecture Pattern )은 시스템을 여러 계층으로 분리하여 관리하는 아키텍처 패턴이다.

현재 가장 널리 채택되고 있는 아키텍처 패턴 중 하나다. ( Spring, Nest.js을 사용할 때, 필수적으로 도입되는 아키텍터 패턴 )

 

단순하고 대중적이면서 비용도 적게 들어 사실상 모든 어플리케이션의 표준 아키텍처다. 어떤 아키텍처 패턴을 도입할지 확신이 없을 때 계층형 아키텍처 패턴은 좋은 선택지가 될 수 있다.

계층형 아키텍처 패턴은 각 계층을 명확하게 분리해서 유지하고, 각 계층이 자신의 바로 아래 계층에만 의존하게 만드는 것이 목표다.

 

 


계층화의 핵심은 각 계층이 높은 응집도( Cohesion )를 가지면서, 다른 계층과는 결합도( Coupling )를 최소화 하는 것이다. 여기서 상위 계층은 하위 계층을 사용할 수 있지만, 하위 계층은 자신이 어떤 상위 계층에 속하는지 알 필요없이, 독립적으로 동작할 수 있어야한다.

예를 들어, 데이터 액세스 계층( Data Access Layer )은 비즈니스 로직 계층( Business Logic Layer )에 어떤 코드들이 있는지 알 수 조차 없고, 사용하면 안된다라는 점을 기억하자.

 

일반적으로 계층형 아키텍처 패턴의 경우 규모가 작은 어플리케이션의 경우 3개의 계층, 크고 복잡한 경우는 그 이상의 계층으로 구성된다.

 

이번에 알아볼 아키텍처 패턴은 3계층 아키텍처( 3-Layered Architecture )다.

 

3계층 아키텍처에서 구성되는 각각의 계층( Layer )은 아래와 같다.

  • 프레젠테이션 계층 ( Presentation Layer )
  • 비즈니스 로직 계층 ( Business Logic Layer )
  • 데이터 액세스 계층 ( Data Access Layer ) | 영속 계층 ( Persistence Layer )

 

계층형 아키텍처 패턴의 장점

  • 관심사를 분리해 현재 구현하려하는 코드를 명확하게 인지할 수 있다.
  • 각 계층은 서로 독립적이며, 의존성이 낮아 모듈을 교체하더라도 코드 수정이 용이하다.
  • 각 계층별로 단위 테스트를 작성할 수 있어 테스트 코드를 조금 더 용이하게 구성할 수 있다.

 

3 계층 아키텍처 ( 3-Layered Architecture )

3-Layerd Architecture는 주로 아래의 3가지 계층으로 구성된다.

 1. 컨트롤러( Controller ) : 어플리케이션의 가장 바깥 부분, 요청 / 응답을 처리

  • 클라이언트의 요청( Request )을 수신 한 후 서버에서 처리된 결과를 반환( Response )해주는 역할을 담당한다.

 2. 서비스( Service ) : 어플리케이션의 중간 부분, API의 핵심적인 동작이 많이 일어나는 부분

  • 아키텍처의 가장 핵심적인 비즈니스 로직이 수행되는 부분이다.

 3. 저장소( Repository ) : 어플리케이션의 가장 안쪽 부분, 데이터베이스와 맞닿아 있다.

  • 실제 데이터베이스와 통신하는 계층이다.

 

3-Layered Architecture에서는 아래의 플로우를 기반으로 로직이 수행된다.

  1. 클라이언트( Client )가 어플리케이션에 요청( Request )을 보낸다.
  2. 요청( Request )을 URL에 알맞은 컨트롤러( Controller )가 수신 받는다.
  3. 컨트롤러( Controller )는 요청을 처리하기 위해 서비스( Service )를 호출한다.
  4. 서비스( Service )는 필요한 데이터를 가져오기 위해 저장소( Repository )에게 데이터를 요청한다.
  5. 서비스( Service )는 저장소( Repository )에서 가져온 데이터를 가공해 컨트롤러( Controller )에게 데이터를 전달한다.
  6. 컨트롤러( Controller )는 서비스( Service )의 결과물( Response )을 클라이언트( Client )에게 전달해준다.

서버 개발자들은 서버에서의 처리과정이 대부분 유사하다는 점을 인식하고, Controller, Service, Repository 라는 3개의 계층으로 분리했다. 각 계층 별로 하는 일을 정리해 보자.

 

컨트롤러( Controller )

 

  • 클라이언트의 요청( Request )을 받는다.
  • 요청에 대한 처리는 서비스에게 위임한다.
  • 클라이언트에게 응답( Response )을 반환한다.

 

서비스( Service )

 

  • 사용자의 요구사항을 처리하는 핵심
  • DB 정보가 필요할때 Repository 에게 요청한다.

 

저장소( Repository )

 

  • 데이터베이스 관리( 연결, 해제, 자원 관리 ) 역할을 담당한다.
  • 데이터베이스의 CRUD 작업을 처리한다.

 

전체적인 구조

 

아키텍처 패턴은 소프트웨어의 구조를 구성하기위한 가장 기본적인 토대를 제시한다.

  • 아키텍처 패턴은 각각의 시스템들과 그 역할이 정의되어 있고, 여러 시스템 사이의 관계규칙 등이 포함되어 있다.
  • 검증된 구조로 개발을 진행하기 때문에 안정적인 개발이 가능하다.
  • 복잡한 도메인 문제를 해결할 때, 아키텍처 패턴을 사용하면 모델이나 코드를 더 쉽게 변경 할 수 있다는 측면에서 큰 이익을 얻을 수 있다.

 

대표적인 아키텍처 패턴

MVC 패턴 ( Model View Controller Pattern )

  • 사용자 인터페이스 ( UI )가 필요한 어플리케이션에서 많이 사용되는 패턴
  • 모델 ( Model ) : 데이터와 비즈니스 로직을 담당
  • 뷰 ( View ) : 사용자 인터페이스 ( UI )를 담당
  • 컨트롤러 ( Controller ) : 클라이언트의 요청을 모델과 뷰로 전달해주는 역할을 담당

MVC 패턴은 현재는 많이 사용되지 않는다.

 

 

계층형 아키텍쳐 패턴 ( Layered Architecture Pattern )

 

  • 시스템의 서로 다른 기능을 여러 계층( Layer )으로 분할하는 패턴
  • 일반적으로 컨트롤러( Controller ), 서비스( Service ), 저장소( Repository ) 계층으로 분리된다.

 

클린 아키텍쳐 패턴 ( Clean Architecture )

  • 소프트웨어를 내부 도메인으로 향하는 의존성을 가지는 여러 계층으로 분리하는 패턴
  • 클라이언트의 요청 처리, 데이터베이스 조작, 외부 시스템과의 통신은 외부 계층에서 처리
  • 소프트웨어의 유지보수성과 확장성을 향상시키는 것이 주요 목표

 

마이크로 서비스 아키텍처 패턴 ( Microservices Architecture Pattern )

  • 시스템을 작고, 독립적으로 배포 가능한 서비스로 분할하는 패턴
  • 하나의 시스템에서 다양한 언어와 프레임워크를 도입할 수 있는 패턴
  • 서비스 간의 통신은 API 또는 이벤트 기반 아키텍처 ( EDA, Event Driven Architecture )를 통해 통신한다.

 

아키텍처 패턴을 도입하기 전에 고민해야할 부분

  • 아키텍처 패턴이 주는 이점비용에 대한 확실한 이유가 있어야한다.
  • 해당하는 아키텍처 패턴을 채택했을 때 어떤 장단점이 존재하는지 명확하게 인지해야 한다.
  • 여러 계층을 추가하기 위해 들이는 노력시간을 투자할 만한 가치가 있을 정도로 어플리케이션도메인복잡한 경우에만 아키텍처 패턴을 도입해야 한다.

 

 

객체 지향 프로그래밍 및 설계의 다섯 가지 핵심 원칙을 SOLID라고 부른다.

  • SOLID는 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙의 맨 앞단어를 하나씩 가져와 만든 것이다.
  • SOLID 원칙을 따르면 프로그래머는 시간이 지나도 유지 보수와 확장이 쉬운 시스템 구축할 수 있다.

 

SOLID의 종류

  • 단일 책임의 원칙 ( Single Responsibility Principle, SRP )
  • 개방-폐쇄 원칙 ( Open - Closed Principle, OCP )
  • 리스코프 치환 원칙 ( Liskov Substitution Principle, LSP )
  • 인터페이스 분리 원칙 ( Interface Segregation Principle, ISP )
  • 의존성 역전 원칙 ( Dependency Inversion Principle, DIP )

 

단일 책임의 원칙 ( Single Responsibility Principle, SRP )

하나의 객체는 단 하나의 책임을 가져야 한다.

즉, 클래스나 모듈을 변경할 이유가 단 하나 뿐이어야 한다는 원칙이다.

  • SRP는 책임이라는 개념을 정의하며 적절한 클래스의 크기를 제시한다.
  • SRP는 객체 지향설계에서 중요한 개념이고 이해하고 따르기 쉽지만, 프로그래머가 가장 무시하는 규칙 중 하나다.
  • 일반적인 프로그래머는 "깨끗하고 우아하게 작성된 소프트웨어" 보다 "동작하기만 하는 소프트웨어"에 초점을 맞추기 때문이다.

SRP를 이용해 코드를 한번 개선해보자.

아래의 UserSesstings 클래스는 하나의 클래스가 가지는 책임이 여러개가 존재한다.

 1. changeSettings : Settings를 변경한다.

 2. verifyCredentials : 인증을 검증한다.

/** SRP Before **/
class UserSettings {
  constructor(user) { // UserSettings 클래스 생성자
    this.user = user;
  }

  changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
    if (this.verifyCredentials()) {
      //...
    }
  }

  verifyCredentials() { // 사용자의 인증을 검증하는 메소드
    //...
  }
}

 

 

그렇다면 2가지의 책임을 가지고 있는 UserSettings 클래스를 어떻게 분리할 수 있을까?

 1. 사용자의 설정을 변경하는 책임을 가진 UserSettings 클래스

 2. 사용자의 인증을 검증하는 책임을 가진 UserAuth 클래스

/** SRP After **/
class UserAuth {
  constructor(user) { // UserAuth 클래스 생성자
    this.user = user;
  }

  verifyCredentials() { // 사용자의 인증을 검증하는 메소드
    //...
  }
}

class UserSettings {
  constructor(user, userAuth) { // UserSettings 클래스 생성자
    this.user = user;
    this.userAuth = userAuth; // UserAuth를 생성자를 통해 주입받는다.
  }

  changeSettings(userSettings) { // 사용자의 설정을 변경하는 메소드
    if (this.userAuth.verifyCredentials()) { // 생성자에서 주입 받은 userAuth 객체의 메소드를 사용한다.
      //...
    }
  }
}

 

위 처럼 2개의 클래스로 분리해 클래스마다 단 1개의 책임을 가지게 만들수 있다.

 

개방 - 폐쇄 원칙 ( Open - Closed Principle, OCP )

소프트웨어 엔티티 또는 개체 ( 클래스, 모듈, 함수 등 )는 확장에는 열려 있으나 변경에는 닫혀 있어야한다.

  • 즉, 소프트웨어 개체의 행위는 확장될 수 있어야 하지만, 개체를 변경해서는 안된다.
  • 조금 더 쉽게 설명하면, 기존 코드에 영향을 주지않고 소프트웨어에 새로운 기능이나 구성 요소를 추가할 수 있어야 한다는 것이다.

만약 요구사항을 조금 반영하는 데 소프트웨어를 엄청나게 수정해야 한다면, 소모되는 개발 코스트 또한 엄청나게 증가한다. 이러한 문제를 개선하기 위해 개방 - 폐쇄 원칙을 따라야하는 것이다.

 

개방 - 폐쇄 원칙 ( OCP )를 이용해 코드를 개선해보자.

아래의 calculator 라는 계산기 함수가 있다. 이 함수는 덧셈, 뺄셈 기능만 지원한다.

/** OCP Before **/
function calculator(nums, option) {
  let result = 0;
  for (const num of nums) {
    if (option === "add") result += num; // option이 add일 경우 덧셈 연산을 합니다.
    else if (option === "sub") result -= num; // option이 sub일 경우 뺄셈 연산을 합니다.
    // 새로운 연산(기능)을 추가 하기 위해서는 함수 내부에서 코드 수정이 필요합니다.
  }
  return result;
}

console.log(calculator([2, 3, 5], "add")); // 10
console.log(calculator([5, 2, 1], "sub")); // -8

 

만약 곱셈, 나눗셈, 제곱 연산 등 다양한 계산기의 기능을 추가하려면 calculator 함수 자체를 수정해야 한다.

이런 접근 방식은 개방 - 폐쇄 원칙 ( OCP )인 "확장에는 열려 있지만 변경에는 닫혀 있어야 한다."를 위반하게 된다.

 

그렇다면 calculator 함수를 어떻게 수정해야 개방 - 폐쇄 원칙에 위배되지 않고 새로운 기능을 추가할 수 있을까?

calculator 함수에서 전달받은 option 매개변수를 콜백 함수로 변경해 새로운 계산 조건이 추가되더라도 실제 calculator 함수에서는 어떠한 변화가 발생하지 않도록 만들 수 있다. 여기에서 콜백 함수란 함수의 매개변수로 다른 함수를 전달하고, 그 함수를 나중에 호출하는 것을 의미한다.

/** OCP After **/
function calculator(nums, callBackFunc) { // option을 CallbackFunc로 변경
  let result = 0;
  for (const num of nums) {
    result = callBackFunc(result, num); // option으로 분기하지 않고, Callback함수를 실행하도록 변경
  }
  return result;
}

const add = (a, b) => a + b; // 함수 표현식을 정의합니다.
const sub = (a, b) => a - b;
const mul = (a, b) => a * b;
const div = (a, b) => a / b;
console.log(calculator([2, 3, 5], add)); // add 함수 표현식을 Callback 함수로 전달합니다.
console.log(calculator([5, 2, 1], sub)); // sub 함수 표현식을 Callback 함수로 전달합니다.

 

위 처럼 계산기에 어떠한 기능을 추가 하더라도 더이상 calculator 함수 내부의 코드를 수정하지 않을 수 있게 만들 수 있다.

 

리스코프 치환 원칙 ( Liskov Substitution Principle, LSP )

어플리케이션에서 객체는 프로그램의 동작에 영향을 주지 않으면서, 하위 타입의 객체로 바꿀 수 있어야 한다.

  • 즉, S가 T의 하위 유형이라면, 프로그램의 기능에 변화를 주지 않고서도 T 타입의 객체를 S 객체로 대체할 수 있어야한다.

부모 클래스 ( Parents )와 자식 클래스 ( Child )를 가지고 있다면, 이 두가지의 클래스의 객체를 서로를 바꾸더라도 해당 프로그램에서 잘못된 결과를 도출하지 않아야하는 원칙이다.

 

아래의 정사각형과 직사각형 예쩨를 이용해 LSP를 어떻게 적용하는지 확인해 보자.

정사각형의 특징은 높이와 너비가 동일하고, 직사각형은 높이와 너비가 독립적으로 변경될 수 있다는 특징을 가지고 있다.

각각의 특징을 바탕으로 클새스를 구현해보자.

/** LSP Before **/
class Rectangle {
  constructor(width = 0, height = 0) { // 직사각형의 생성자
    this.width = width;
    this.height = height;
  }

  setWidth(width) { // 직사각형은 높이와 너비를 독립적으로 정의한다.
    this.width = width;
    return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
  }

  setHeight(height) { // 직사각형은 높이와 너비를 독립적으로 정의한다.
    this.height = height;
    return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
  }

  getArea() { // 사각형의 높이와 너비의 결과값을 조회하는 메소드
    return this.width * this.height;
  }
}

class Square extends Rectangle { // 정사각형은 직사각형을 상속받습니다.
  setWidth(width) { // 정사각형은 높이와 너비가 동일하게 정의된다.
    this.width = width;
    this.height = width;
    return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
  }

  setHeight(height) { // 정사각형은 높이와 너비가 동일하게 정의된다.
    this.width = height;
    this.height = height;
    return this; // 자신의 객체를 반환하여 메서드 체이닝이 가능하게 합니다.
  }
}

const rectangleArea = new Rectangle() // 35
  .setWidth(5) // 너비 5
  .setHeight(7) // 높이 7
  .getArea(); // 5 * 7 = 35
const squareArea = new Square() // 49
  .setWidth(5) // 너비 5
  .setHeight(7) // 높이를 7로 정의하였지만, 정사각형은 높이와 너비를 동일하게 정의합니다.
  .getArea(); // 7 * 7 = 49

 

위에 구현한 Rectangle과 Square 클래스에서는 어떠한 문제가 있을까?

Square와 Rectangle 클래스에서 같은 메서드를 호출하더라도 다른 결과값이 반환되는 것을 확인할 수 있다.

예제에서 높이를 7로 설정하려고 했지만, Square 클래스에서는 너비와 높이가 동일해야 하므로 결과적으로 너비가 7로 설정되었다.

만약 두 클래스를 서로 교체했을 때에도 동일한 결과 값이 도출되지 않는 것을 확인 할 수 있다.

위에서 확인한 결과로 LSP의 원칙 중에서 "부모 클래스와 자식클래스가 있는 경우 서로를 바꾸더라도 해당 프로그램에서 잘못된 결과를 도출하지 않는 것"에 해당하는 원칙이 깨지게 된 것을 확인 할 수 있다.

 

그렇다면, 어떻게 Square과 Rectangle 클래스를 수정해야 LSP원칙을 위반하지 않게 구현할 수 있을까?

언뜻 보면 Rectangle이 Square를 포함하고 있는 것처럼 보이지만 setWidth, setHeight 메서드처럼 다르게 동작해야하는 경우가 존재하기 때문에 Square 클래스는 Rectangle을 상속받는 것은 옳은 방법이 아니다.

이럴 경우 두 클래스를 모두 포함하는 인터페이스를 구현해야한다. 여기서는, Shape 라는 인터페이스 ( Interface ) 역할을 수행하는 새로운 부모 클래스를 생성하고, Rectangle과 Square가 이를 상속받도록 코드를 수정해보자.

/** LSP After **/
class Shape { // Rectangle과 Square의 부모 클래스를 정의합니다.
  getArea() { // 각 도형마다 계산 방법이 다를 수 있으므로 빈 메소드로 정의합니다.
  }
}

class Rectangle extends Shape { // Rectangle은 Shape를 상속받습니다.
  constructor(width = 0, height = 0) { // 직사각형의 생성자
    super();
    this.width = width;
    this.height = height;
  }

  getArea() { // 직사각형의 높이와 너비의 결과값을 조회하는 메소드
    return this.width * this.height;
  }
}

class Square extends Shape { // Square는 Shape를 상속받습니다.
  constructor(length = 0) { // 정사각형의 생성자
    super();
    this.length = length; // 정사각형은 너비와 높이가 같이 때문에 width와 height 대신 length를 사용합니다.
  }

  getArea() { // 정사각형의 높이와 너비의 결과값을 조회하는 메소드
    return this.length * this.length;
  }
}

const rectangleArea = new Rectangle(7, 7) // 49
  .getArea(); // 7 * 7 = 49
const squareArea = new Square(7) // 49
  .getArea(); // 7 * 7 = 49

 

수정된 코드에서는 Rectangle과 Square 객체를 생성하고, 각각의 getArea 메서드를 호출하면, 둘 다 49라는 동일한 넓이가 반환되는 것을 확인할 수 있다. 따라서, 이 코드는 리스코프 치환 원칙( LSP )을 만족한다는 것을 확인할 수 있다.

 

Rectangle 클래스와 Square 클래스에서 상위 타입의 getArea 메소드를 호출하더라도 문제없이 원하는 결과값을 도출할 수 있게 되었다.

 

인터페이스 분리 원칙 ( Interface Segregation Principle, ISP )

특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.

  • 클라이언트가 필요하지 않는 기능을 가진 인터페이스에 의존해서는 안 되고, 최대한 인터페이스를 작게 유지해야한다.
  • 즉, 사용자가 필요하지 않은 것들에 의존하지 않도록, 인터페이스는 작고 구체적으로 유지해야 한다는 것이다.

여기서 설명하는 인터페이스 ( Interface )는 대표적으로 Java, C++ 그리고 Typescript에서 사용하는 문법이다.

인터페이스는 특정 클래스가 반드시 구현해야 할 메서드와 속성을 정의하는 일종의 템플릿이다.

이를 통해 서로 다른 클래스가 동일한 동작을 하는것을 유추할 수 있게 된다.

 

Javascript에서는 interface 기능을 제공하지 않으므로 이번 예제는 Typescript로 살펴보자.

/** ISP Before **/
interface SmartPrinter { // SmartPrinter가 사용할 수 있는 기능들을 정의한 인터페이스 
  print();

  fax();

  scan();
}

// SmartPrinter 인터페이스를 상속받은 AllInOnePrinter 클래스
class AllInOnePrinter implements SmartPrinter {
  print() { // AllInOnePrinter 클래스는 print, fax, scan 기능을 지원한다.
    // ...
  }

  fax() { // AllInOnePrinter 클래스는 print, fax, scan 기능을 지원한다.
    // ...
  }

  scan() { // AllInOnePrinter 클래스는 print, fax, scan 기능을 지원한다.
    // ...
  }
}

// SmartPrinter 인터페이스를 상속받은 EconomicPrinter 클래스
class EconomicPrinter implements SmartPrinter {
  print() { // EconomicPrinter 클래스는 print 기능만 지원한다.
    // ...
  }

  fax() { // EconomicPrinter 클래스는 fax 기능을 지원하지 않는다.
    throw new Error('팩스 기능을 지원하지 않습니다.');
  }

  scan() { // EconomicPrinter 클래스는 scan 기능을 지원하지 않는다.
    throw new Error('Scan 기능을 지원하지 않습니다.');
  }
}

 

가장 처음 선언된 SmartPrint 인터페이스는 print(), fax(), scan() 세 가지의 기능을 정의하고 있다.

AllInOnePrinter 클래스는 print, fax, scan 3가지의 기능이 모두 필요하지만,

EconomicPrinter 클래스의 경우 print 기능만 지원하는 클래스다.

만약 EconomicPrinter 클래스가 SmartPrinter 인터페이스를 상속받는다면, 필요하지 않은 fax, scan 2가지의 기능을 예외 처리해야 하는 문제가 발생하게 되는 것이다.

 

그렇다면, 어떻게 SmartPrinter 인터페이스를 분리해야 ISP 원칙에 위배되지 않고 코드를 구현할 수 있을까?

SmartPrinter 인터페이스에 정의된 기능을 Printer, Fax, Scanner 인터페이스로 분리하면 ISP 원칙에서 "클라이언트가 필요하지 않는 기능을 가진 인터페이스에 의존해서는 안 되고, 최대한 인터페이스를 작게 유지해야한다."에 해당하는 원칙을 수행하는 코드로 개선할 수 있다.

 

/** ISP After **/
interface Printer { // print 기능을 하는 Printer 인터페이스
  print();
}

interface Fax { // fax 기능을 하는 Fax 인터페이스
  fax();
}

interface Scanner { // scan 기능을 하는 Scanner 인터페이스
  scan();
}


// AllInOnePrinter클래스는 print, fax, scan 기능을 지원하는 Printer, Fax, Scanner 인터페이스를 상속받았다.
class AllInOnePrinter implements Printer, Fax, Scanner {
  print() { // Printer 인터페이스를 상속받아 print 기능을 지원한다.
    // ...
  }

  fax() { // Fax 인터페이스를 상속받아 fax 기능을 지원한다.
    // ...
  }

  scan() { // Scanner 인터페이스를 상속받아 scan 기능을 지원한다.
    // ...
  }
}

// EconomicPrinter클래스는 print 기능을 지원하는 Printer 인터페이스를 상속받았다.
class EconomicPrinter implements Printer {
  print() { // EconomicPrinter 클래스는 print 기능만 지원한다.
    // ...
  }
}

// FacsimilePrinter클래스는 print, fax 기능을 지원하는 Printer, Fax 인터페이스를 상속받았다.
class FacsimilePrinter implements Printer, Fax {
  print() { // FacsimilePrinter 클래스는 print, fax 기능을 지원한다.
    // ...
  }

  fax() { // FacsimilePrinter 클래스는 print, fax 기능을 지원한다.
    // ...
  }
}

 

이제 EconomicPrinter는 Printer 인터페이스만 상속 받아, 필요한 print 기능만을 구현하면 된다.

이렇게 인터페이스 분리 원칙 ( ISP )을 적용하면 어플리케이션의 복잡성을 줄이고, 각 클래스가 필요한 기능에만 집중할 수 있게 된다.

 

불필요한 인터페이스를 분리해 ISP원칙을 수행하는 코드를 구현할 수 있게 되었다.

결국 불필요한 기능을 포함한 인터페이스에 의존하게 되면 예상치 못한 문제에 빠질 수 있다는 것을 확인했다.

 

의존성 역전 원칙 ( Dependency Inversion Principle, DIP )

프로그래머는 추상화에 의존해야하며, 구체화에 의존하면 안된다.

  • 높은 계층의 모듈( 도메인 )이 저수준의 모듈 ( 하부구조 )에 직접 의존해서는 안된다.

조금 더 자세하게 정리하면,

  • 프로그래머는 구체적인 것에 의존하기보다는 추상적인 것에 의존해야 한다.
  • 고수준 계층의 모듈( 도메인 )은 저수준 계층의 모듈 ( 하부구조 )에 의존해서는 안된다. 둘 다 추상화에 의존해야한다.
  • 추상화는 세부 사항에 의존하지 않아야 하고, 세부 사항이 추상화에 의존해야 한다.

 

만약 이런 추상화 없이 고수준 계층의 모듈이 저수준 계층의 모듈을 의존하게 되면 어떤 상황이 발생할까?

사소한 변경 사항에도 고수준 계층의 코드를 변경해야하고, 소모되는 개발 코스트또한 증가한다.

 

ReportReader라는 클래스에서 파일을 입력받아 확장자별로 파싱해 String 형식으로 반환하는 예시를 통해 DIP를 이용한 코드 개선을 확인해보자.

/** DIP Before **/
import { readFile } from 'node:fs/promises';

class XmlFormatter {
  parseXml(content) {
    // Xml 파일을 String 형식으로 변환합니다.
  }
}

class JsonFormatter {
  parseJson(content) {
    // JSON 파일을 String 형식으로 변환합니다.
  }
}

class ReportReader {

  async read(path) {
    const fileExtension = path.split('.').pop(); // 파일 확장자

    if (fileExtension === 'xml') {
      const formatter = new XmlFormatter(); // xml 파일 확장자일 경우 XmlFormatter를 사용한다.

      const text = await readFile(path, (err, data) => data);
      return formatter.parseXml(text); // xmlFormatter클래스로 파싱을 할 때 parseXml 메소드를 사용한다.

    } else if (fileExtension === 'json') {
      const formatter = new JsonFormatter(); // json 파일 확장자일 경우 JsonFormatter를 사용한다.

      const text = await readFile(path, (err, data) => data);
      return formatter.parseJson(text); // JsonFormatter클래스로 파싱을 할 때 parseJson 메소드를 사용한다.
    }
  }
}

const reader = new ReportReader();
const report = await reader.read('report.xml');
// or
// const report = await reader.read('report.json');

 

Xml 파일을 파싱하기 위해 XmlFormatter 클래스를 불러와 parseXml 메소드를 호출하고,

Json 파일을 파싱하기 위해 JsonFormatter 클래스를 불러와 parseJson 메소드를 호출한다.

이렇게, 각 파일 확장자에 따라 다른 클래스와 다른 메서드를 사용하면, 이는 구체적인 구현에 의존하고 있는 상황이다.

이러한 상황을 어떻게 해결해야 DIP 원칙에 맞게 코드를 개선할 수 있을까?

 

이 문제를 해결하려면 XmlFormatterJsonFormatter 클래스가 동일한 인터페이스인 Formatter를 상속받도록 수정해야한다. 이렇게 하면 ReportReader 클래스는 Formatter 인터페이스의 parse 메서드만 의존하게 된다.

또한, ReportReader 클래스가 Formatter를 직접 생성하는 것이 아니라, 생성자를 통해 Formatter 인스턴스를 주입받도록 수정해야한다. 이는 의존성 주입 ( Dependency Injection, DI ) 패턴을 사용한 것으로, DIP 원칙을 구현하는 방법 중 하나다. 

 

이렇게 구성하면, DIP원칙인 "높은 계층의 모듈 ( 도메인 )이 저수준의 모듈 ( 하부구조 )에 의존해서는 안된다."에 해당하는 원칙을 지킬 수 있게 된다.

/** DIP After **/
import { readFile } from 'node:fs/promises';

class Formatter { // 인터페이스지만, Javascript로 구현하기 위해 클래스로 선언합니다.
  parse() {  }
}

class XmlFormatter extends Formatter {
  parse(content) {
    // Xml 파일을 String 형식으로 변환합니다.
  }
}

class JsonFormatter extends Formatter {
  parse(content) {
    // JSON 파일을 String 형식으로 변환합니다.
  }
}

class ReportReader {
  constructor(formatter) { // DI 패턴을 적용하여, Formatter를 생성자를 통해 주입받습니다.
    this.formatter = formatter;
  }

  async read(path) {
    const text = await readFile(path, (err, data) => data);
    return this.formatter.parse(text); // 추상화된 formatter로 데이터를 파싱합니다.
  }
}

const reader = new ReportReader(new XmlFormatter());
const report = await reader.read('report.xml');
// or
// const reader = new ReportReader(new JsonFormatter());
// const report = await reader.read('report.json');

 

DIP 원칙을 이용해 저수준의 모듈을 수정하더라도 고수준의 모듈 코드를 더이상 수정하지 않도록 코드가 개선되었다.

오늘의 목표

더보기

✔️ 새로운 팀 편성

✔️ 개인과제 발제

✔️ Node.js 심화 주차 수업 듣기


⏱️ 오늘의 일정

  • 개인 과제 발제
  • 새로운 팀 노션 작성
  • Node.js 심화 주차 수업 듣기

📜 개인 과제 발제

 

개인 과제 발제 시간이 있어서 참가했다.

이번 주차는 심화 숙련 주차로, 드디어 웹 소켓을 활용해 TCP로 통신하는 게임을 만드는 개인과제를 만들고,

그다음에 팀 프로젝트를 수행하는 주차라고 공지를 받았다.

 

10월 7일까지 캠프에서 제시한 과제를 수행해야하는데,

 

클라는 위 게임을 강의를 보면서 직접 구현해보고, 이에 대응하는 서버는 직접 짜는 방식이라고 설명을 받았다.

드디어 TCP로 작동하는 게임 주차가 시작되긴 했는데, Node.js 에서는 소켓 프로그래밍을 어떻게 구현할지 기대가 된다.

 

팀 프로젝트는 간단히 소개만 받았는데, 타워 디펜스라는 게임을 만들거라고 들었다.

 

📜 새로운 팀 편성

 

새로운 주차에 진입하면서 앞서 언급한대로 새로운 팀에 편성되었다.

총 6명이 배정되었는데, 한분이 입문숙련으로 빠져서, 5명이 되었다. 

아마도.. 중간에 한명이 들어올수도...?

 

 

📜 Node.js 심화 수업 듣기

새로운 주차에 진입하면서 강의도 새로 제공 받았다.

강의는 총 3가지로,

 Node.js 심화, 게임서버 개발, 컴퓨터 CS 수업으로 구성된다.

 

Node.js 심화부터 들을 생각인데, 내용을 살펴보니 주로 객체 프로그래밍에 대한 내용이였다.

지겹게 들은 객체 프로그래밍이지만, 이번에 한번더 강의를 들으면서 정리를 또 해야겠다.

 

2024.09.26 - [IT] - [IT] 객체 지향 프로그래밍 ( Object-Oriented Programming, OOP )

 

[IT] 객체 지향 프로그래밍 ( Object-Oriented Programming, OOP )

객체 지향 ( Object - Oriented )객체 ( Object )는 현실 세계의 물체나 개념을 소프트웨어 세계로 옮긴 것이다.예를 들면, '자동차'나 '사람'처럼 생각하면 된다. 여기서, 객체는 여러 속성과 행동(메서드

program-yam.tistory.com

 

 

 

객체 지향 ( Object - Oriented )

객체 ( Object )는 현실 세계의 물체나 개념을 소프트웨어 세계로 옮긴 것이다.

예를 들면, '자동차'나 '사람'처럼 생각하면 된다. 여기서, 객체는 여러 속성과 행동(메서드)으로 구성된다.

  • 객체는 정보(데이터)와 그 정보를 처리하는 행동(함수 또는 메서드)을 가지고 있다.
  • 객체들은 서로 메서드 호출을 통해 메세지를 주고 받아 협력한다.

 

객체 지향은 소프트웨어 개발에서 주요 구성 요소를 기능(Function)이 아닌 객체(Object)로 삼으며, "어떤 객체가 어떤일을 할 것인가"에 초점을 맞춘다. 즉, 객체를 도출하고 각각의 역할을 명확하게 정의하는 것에 초점을 맞추는 방법론이다.

  • 객체 지향은 책임과 권한을 가진 객체들이 서로 메시지를 주고받아 협력하여 필요한 기능을 수행하는 방법론이다.
  • 이 방법은 크고 복잡한 시스템도 효과적으로 분해하고 구성하며, 효율적으로 관리할 수 있게 도와준다.

 

객체 지향 프로그래밍 ( Object - Oriented Programming, OOP )

프로그래밍 패러다임

  • 프로그래밍 패러다임( Programming Paradigm )은 프로그래밍의 방식이나 관점을 바탕으로 효율적이고 명확한 코드를 작성하는 방법을 나타낸다.

프로그래밍에서는 가장 대표적인 세 가지의 프로그래밍 패러다임이 존재한다.

  1. 구조적 프로그래밍 ( Structured Programming )
  2. 객체 지향 프로그래밍 ( Object-Oriented Programming, OOP )
  3. 함수형 프로그래밍 ( Functional Programming )

구조적 프로그래밍은 기능 중심적인 개발을 진행한다.

  • 구조적 프로그래밍은 프로그래밍이라는 기술이 시작되면서 가장 처음으로 적용된 패러다임이다.

객체 지향 프로그래밍은 프로그램의 처리단위가 객체인 프로그래밍 방법이다.

  • 객체 지향 프로그래밍은 '현실 세계를 프로그램으로 모델링'하는 가장 대표적인 프로그래밍 패러다임이다.

함수 프로그래밍은 함수를 중심적으로 개발을 진행한다.

  • 함수형 프로그래밍은 세가지의 패러다임 중 가장 초기에 만들어졌으나, 최근들어 주목받기 시작한 패러다임이다.

객체 지향 프로그래밍

 

객체 지향 프로그래밍 ( Object - Oriented Programming )이란 상태 ( 데이터 )와 그 데이터를 조작하는 프로세스 ( 메서드 )가 같은 모듈 내부에 배치되는 프로그래밍 방식을 의미한다.

  • 객체 지향 프로그래밍은 코드를 추상화해 개발자가 더욱 직관적으로 사고할 수 있게 하는 대표적인 프로그래밍 방법론이다.
  • 객제 치향 프로그래밍에서는 자동차, 동물, 사람 등과 같은 현실 세계의 객체를 유연하게 표현할 수 있다.
  • 객체는 고유한 특성을 가지고 있고, 특정 기능을 수행할 수 있다.

객체 지향 프로그래밍을 사용해야하는 이유

객체 지향 프로그래밍( OOP )은 프로그램을 객체들의 집합으로 볼 수 있는 설계 원칙을 제공한다.

이 원칙에 따라, 각 객체는 특정 데이터와 그 데이터를 처리하는 함수 ( 메서드 )를 함께 갖게 된다.

 

객체 지향 프로그래밍의 방식은 데이터와 기능이 밀접하게 연결되어 있기 때문에, 코드의 구조와 동작을 직관적으로 파악할 수 있다. 예를 들어, '자동차'라는 객체가 있다고 가정할 때, 이 객체는 '색상', '속도'와 같은 데이터와 '출발', '정지'와 같은 기능( 메서드 )을 가지게 된다. 따라서, 만약 문제가 발생한다면 '자동차'라는 객체의 내부만 살펴보면 된다.

또한, 객체 지향의 특성으로 하나의 객체에 정의된 기능이나 데이터 구조는 다른 객체에서도 쉽게 재사용할 수 있다.

이로 인해 코드의 재사용성과 확장성이 향상되고, 결과적으로 개발 시간을 효율적으로 관리할 수 있게 된다.

 

객체 지향 프로그래밍의 핵심 원칙

1. 캡슐화 ( Encapsulation )

 객체 내부의 세부적인 사항을 감추는 것, 즉 중요한 정보를 외부로 노출시키지 않도록 만드는 것을 캡슐화 ( Encapsulation )라고 한다.

 

Javascript는 완벽한 캡슐화를 지원하지 않는다. 개발자들은 변수 앞에 언더바 ( _ )를 붙여 내부의 변수를 숨긴것 처럼 나타내는 규칙을 따른다.

완벽한 캡슐화를 위해, TypeScript로 확인해 보자.

 

/** Encapsulation **/
class User {
  private name: string; // name 변수를 외부에서 접근을 할 수 없게 만듭니다.
  private age: number; // age 변수를 외부에서 접근을 할 수 없게 만듭니다.

  setName(name: string) { // Private 속성을 가진 name 변수의 값을 변경합니다.
    this.name = name;
  }
  getName() { // Private 속성을 가진 name 변수의 값을 조회합니다.
    return this.name;
  }
  setAge(age: number) { // Private 속성을 가진 age 변수의 값을 변경합니다.
    this.age = age;
  }
  getAge() { // Private 속성을 가진 age 변수의 값을 조회합니다.
    return this.age;
  }
}

const user = new User(); // user 인스턴스 생성
user.setName('이용우');
user.setAge(30);
console.log(user.getName()); // 이용우
console.log(user.getAge()); // 30
console.log(user.name); // Error: User 클래스의 name 변수는 private로 설정되어 있어 바로 접근할 수 없습니다.

 

User 클래스를 선언하고 내부에  name, age 멤버 변수를 초기화한다.

여기서는 특별하게 private라는 접근 제한자( Access modifier )를 사용하고 있는데, 인스턴스 내부에서만 해당 변수에 접근이 가능하도록 제한하는 문법이다.

Javascript에서는 존재하지 않지만 Typescript에서는 제공하는 문법이다.

 

따라서, User 클래스의 name, age 멤버 변수는 클래스 외부에서 어떠한 방법으로도 직접 접근을 할 수 없다. 오로지 setter만 변수를 변경할 수 있고, getter만 변수를 조회할 수 있게 되었다.

 getter는 변수의 값을 가져오는 ( getName, getAge )를 나타내고, setter는 변수의 값을 설정하는 ( setName, sestAge )를 나타낸다. 이를 통해 User 클래스의 중요한 정보를 외부로 노출시키지 않도록 만드는 캡슐화( Encapsulation )를 지키는 코드를 작성했다.

 

2. 상속 ( Inheritance )

상속 ( Inheritance )은 하나의 클래스가 가진 특징 ( 함수, 변수 및 데이터 )을 다른 클래스가 그대로 물려 받는 것을 말한다.

이미 정의된 상위 클래스의 특징을 하위 클래스에서 물려받아 코도의 중복을 제거하고 코드 재사용성을 증대시킨다.

  • 개별 클래스를 상속 관계로 묶음으로써 클래스 간의 체계화된 구조를 쉽게 파악할 수 있게 된다.
  • 상위 클래스의 데이터와 메서드를 변경함으로써 전체 코드에 대한 일관성을 유지할 수 있다.

상속은 기존에 작성된 클래스를 재활용해 사용할 수 있다.

상속을 구현하기 위한 예제를 확인해보자.

/** Inheritance **/
class Mother { // Mother 부모 클래스
  constructor(name, age, tech) { // 부모 클래스 생성자
    this.name = name;
    this.age = age;
    this.tech = tech;
  }
  getTech(){ return this.tech; } // 부모 클래스 getTech 메서드
}

class Child extends Mother{ // Mother 클래스를 상속받은 Child 자식 클래스
  constructor(name, age, tech) { // 자식 클래스 생성자
    super(name, age, tech); // 부모 클래스의 생성자를 호출
  }
}

const child = new Child("이용우", "28", "Node.js");
console.log(child.name); // 이용우
console.log(child.age); // 28
console.log(child.getTech()); // 부모 클래스의 getTech 메서드 호출: Node.js

 

Mother 부모 클래스를 상속받은 Child 자식 클래스에서 name, age 멤버 변수를 직접 접근해 호출하고, Mother 부모 클래스에서 정의된 getTech() 메소드를 호출할 수 있게 되었다.

이처럼, 상속의 이러한 특성 덕분에 코드를 재사용하기 수월해지고, 중복을 줄일 수 있게되는 장점이 있다.

 

상속을 활용해 부모 클래스의 코드를 수정하면 자식 클래스도 해당 변경을 반영할 수 있게 되었다. 이를 통해 클래스 전체의 코드 일관성을 유지할 수 있게 된다.

 

 

3. 추상화 ( Abstraction )

객체에서 공통된 부분을 모아 상위 개념으로 새롭게 정의하는 것을 추상화 ( Abstraction )라고 한다. 즉, 불필요한 세부 사항을 생략하고, 중요한 특징만을 강조함으로써 코드를 더욱 간결하고 관리하기 쉽게 만드는 원칙이다.

  • 추상화를 통해 객체들의 불필요한 특성을 제거함으로써, 공통적인 특성을 더욱 명확하게 파악할 수 있게 된다.
  • 이를 통해 전체 시스템의 구조를 명확하게 이해하게 되고, 테스트를 더욱 쉽게 작성할 수 있게 된다.

클래스를 설계할 때, 공통적으로 묶일 수 있는 기능을 추상화 ( Abstraction ) ▶ 추상 클래스 ( Abstract Class ) ▶ 인터페이스 ( Interface ) 순으로 정리한다면, 여러 클래스 간의 일관성을 유지하면서, 다양한 형태로 확장될 수 있는 콛, 즉 다형성 ( Polymorphism )이 가능해진다.

 ▶ 여기서 인터페이스 ( Interface )란, 클래스 정의할 때 메소드와 속성만 정의해 인터페이스에 선언된 프로퍼티 또는 메소드의 구현을 강제해 코드의 일관성을 유지하게 한다.

 

/** Abstraction **/
interface Human {
  name: string;
  setName(name);
  getName();
}

// 인터페이스에서 상속받은 프로퍼티와 메소드는 구현하지 않을 경우 에러가 발생합니다.
class Employee implements Human {
  constructor(public name: string) {  }
  
  // Human 인터페이스에서 상속받은 메소드
  setName(name) { this.name = name; }
  
  // Human 인터페이스에서 상속받은 메소드
  getName() { return this.name; }
}

const employee = new Employee("");
employee.setName("이용우"); // Employee 클래스의 name을 변경하는 setter
console.log(employee.getName()); // Employee 클래스의 name을 조회하는 getter

 

Employee 클래스는 Human 인터페이스에 정의한 name 프로퍼티와 setName, getName 메서드를 강제로 구현하게 되었다. 따라서, 동일한 인터페이스인 Human 인터페이스를 구현하는 모든 클래스는 해당 인터페이스에 선언된 프로퍼티와 메서드를 구현해야 함을 보장하게 되었다. 이로 인해 코드의 일관성을 유지할 수 있게 된 것이다.

 

4. 다형성 ( Polymorphism )

다형성 ( Polymorphism )은 하나의 객체( 클래스 )가 다양한 형태로 동작하는 것을 의미한다.

이는 객체가 가진 특성에 따라 같은 기능이 다르게 재구성되는 것을 의미한다.

즉, 동일한 메서드나 함수 명을 사용하더라도, 클래스마다 그 메서드가 다르게 동작하는 것이 다형성의 핵심이다.

 

다형성은 역할 ( 인터페이스 )과 구현을 분리하게 해준다. 따라서, 오버라이딩을 통해 특정 서비스의 기능을 유연하게

변경하거나 확장할 수 있게 한다.

 ▶ 오버로딩, 오버라이딩 자세한 정보

http://www.tcpschool.com/java/java_inheritance_overriding

 

코딩교육 티씨피스쿨

4차산업혁명, 코딩교육, 소프트웨어교육, 코딩기초, SW코딩, 기초코딩부터 자바 파이썬 등

tcpschool.com

 

/** Polymorphism **/
class Person {
  constructor(name) { this.name = name; }

  buy() {}
}

class Employee extends Person {
  buy() { console.log(`${this.constructor.name} 클래스의 ${this.name}님이 물건을 구매하였습니다.`); }
}

class User extends Person {
  buy() { console.log(`${this.constructor.name} 클래스의 ${this.name}님이 물건을 구매하였습니다.`); }
}

const employee1 = new Employee("이용우");
const employee2 = new Employee("김창환");
const user1 = new User("이태강");
const user2 = new User("김민수");

const personsArray = [employee1, employee2, user1, user2];
// personsArray에 저장되어 있는 Employee, User 인스턴스들의 buy 메소드를 호출합니다.
personsArray.forEach((person) => person.buy());

// Employee 클래스의 이용우님이 물건을 구매하였습니다.
// Employee 클래스의 김창환님이 물건을 구매하였습니다.
// User 클래스의 이태강님이 물건을 구매하였습니다.
// User 클래스의 김민수님이 물건을 구매하였습니다.

 

위의 personsArray.forEach() 예제에서 person 변수는 Person 클래스를 상속받은 Employee 또는 User 클래스의 인스턴스를 참조한다.

여기서, 각 인스턴스의 buy 메서드를 호출하는 것인 동일하나, Employee와 User 클래스의 buy 메서드는 서로 다른 행위를 수행하고 있는 것을 확인할 수 있다. 이러한 부분을 다형성( Polymorphism )의 특징이다.

'IT' 카테고리의 다른 글

[IT] 아키텍처 패턴 ( Architecture Pattern )  (1) 2024.09.27
[IT] 객체 지향 설계 5 원칙 ( SOLID )  (1) 2024.09.27
[IT] Visual Studio Code - 디버그  (0) 2024.09.19
[IT] 정규표현식  (1) 2024.09.10
[IT] Access Token, Refresh Token  (1) 2024.09.09

오늘의 목표

더보기

✔️ 프로그래머스 코테 문제 풀이

✔️ 팀 프로젝트 최종 점검

✔️ 챌린지반 수업


⏱️ 오늘의 일정

9:00 ~ 10:00 - 프로그래머스 코테 문제 풀이
10:00 ~ 17:00 - 팀 프로젝트 최종 점검

17:00 ~ 19:00 - SQL 문제 풀기

19:00 ~ 20:00 - 챌린지반 수업


📜 프로그래머스 코테 문제 풀이

9:00 ~ 10:00 - 프로그래머스 코테 문제 풀이

 

둘만의 암호

https://github.com/YamSaeng/AlgorithmCodingTest/tree/main/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4/1/155652.%E2%80%85%EB%91%98%EB%A7%8C%EC%9D%98%E2%80%85%EC%95%94%ED%98%B8

 

AlgorithmCodingTest/프로그래머스/1/155652. 둘만의 암호 at main · YamSaeng/AlgorithmCodingTest

This is an auto push repository for Baekjoon Online Judge created with [BaekjoonHub](https://github.com/BaekjoonHub/BaekjoonHub). - YamSaeng/AlgorithmCodingTest

github.com

 

function solution(s, skip, index) {
    let answer = [];

    for (let i = 0; i < s.length; i++) {
        let c = s[i];
        for (let j = 0; j < index;) {
            c = String.fromCharCode(c.charCodeAt() + 1);

            // 다시 a로 바꿈
            if (c === String.fromCharCode('z'.charCodeAt() + 1)) {
                c = 'a';
            }

            let isSkip = false;

            for (let k = 0; k < skip.length; k++) {
                if (c === skip[k]) {
                    isSkip = true;
                    break;
                }
            }

            if (isSkip === false) {
                j++;
            }            
        }

        answer.push(c);
    }

    return answer.join('');
}

 

📜 팀 프로젝트 최종 점검

10:00 ~ 17:00 - 팀 프로젝트 최종 점검

 

팀 프로젝트를 최종으로 점검하고, ppt를 작성했다.

ppt를 작성하면서 트러블 슈팅에 대해 작성하게 되었는데, 

우리 팀의 트러블 슈팅은 총 2가지였다.

 

첫번째는 다음과 같다.

 

 

api/Rating/Play URL과 api/:target/Play URL을 동시에 사용할 경우,

api/:target/Play  가 상위에 선언이 되어 있고,

api/Rating/Play  가 아래에 선언 되어 있을 때, api/Rating/Play로 접근하려고 하면

 

api/:target/Play로만 접근하게 된다. 이유는 :target에 Rating이라는 값이 들어가기 때문..

따라서 :target을 맨 뒤로 보내 문제를 해결했다.

 

두번째는 node-schedule의 사용 문제였다.

node-schdule로 일정 시간마다 호출해주려고 했으나,

예를 들어 5초마다 반복하려고 구성했는데, 실제로 5초가 될경우에만 호출이 되는 방식으로 스케줄링을 하는 패키지였다.

만약, 2초에 호출하면 7초에 호출이 되어야하는데, 5초, 10초, 5초 에 실행이 되는 방식..

setInterval을 통해 호출하는 방식으로 변경했다.

 

시연영상 링크

https://www.youtube.com/watch?v=GysPHl-4oYc

 

 

 

📜  SQL 문제 풀기

17:00 ~ 19:00 - SQL 문제 풀기

 

챌린지반 숙제인 SQL문제를 풀었다.

마땅한 문제집이 없어서 프로그래머스에 있는 문제중에서 풀었다.

 

-- 루시와 엘라 찾기
SELECT ANIMAL_ID, NAME, SEX_UPON_INTAKE
FROM ANIMAL_INS
WHERE NAME IN ('Lucy','Ella','Pickle','Rogan','Sabrina','Mitty')
ORDER BY ANIMAL_ID;

-- 이름이 없는 동물의 아이디
SELECT ANIMAL_ID
FROM ANIMAL_INS
WHERE NAME IS NULL
ORDER BY ANIMAL_ID

-- 조건에 맞는 회원수 구하기
SELECT COUNT(USER_ID) USERS
FROM USER_INFO
WHERE JOINED LIKE '2021%' AND AGE >= 20 AND AGE <= 29

-- 카테고리 별 상품 개수 구하기
SELECT ANIMAL_TYPE, COUNT(*) AS count
FROM ANIMAL_INS
GROUP BY ANIMAL_TYPE
ORDER BY ANIMAL_TYPE

 

오늘의 목표

더보기

✔️ 팀프로젝트 진행


⏱️ 오늘의 일정

9:00 ~ 21:00 팀프로젝트 진행


📜 팀프로젝트 진행

9:00 ~ 21:00 팀프로젝트 진행

 

팀프로젝트를 진행했다.

저번주 주말에 랭킹 조회를 구현했다.

 

랭킹 전체 조회

rankingRouter.get('/Ranking/AllCheck', async (req, res, next) => {
    // rankingScore 컬럼을 기준으로 내림차순한 전체 데이터 값들을 가져옴
    const currentRankings = await prisma.ranking.findMany({
        orderBy: {
            rankingScore: 'desc'
        }
    });
    
    return res.status(200).json({ ... currentRankings });
});

 

ranking Table에 들어있는 값을 전부 읽어온다.

rankingScore 값을 기준으로 내림차순해서 클라이언트에게 전달한다.

 

 

특정 유저 랭킹 조회

rankingRouter.get('/Ranking/SingleCheck/:userId', async (req, res, next) => {
    const { userId } = req.params;

    const userRanking = await prisma.ranking.findFirst({
        where: {
            userId: +userId
        }
    })

    return res.status(200).json({ userRanking });
});

 

조회하고자 하는 유저의 id값을 받아서 ranking table에 읽어온 후 클라이언트에게 전달한다.

 

DBRankingChangeScore ( 순위 변동 계산 )

export async function DBRankingChangeScore() {    
    const currentRankings = await prisma.ranking.findMany({
        orderBy: {
            rankingScore: 'desc'
        }
    });

    // 순서대로 순회하며 랭킹 설정
    let rank = 1;
    currentRankings.map((currentRanking) => {
        currentRanking.rank = rank;
        rank++;
    });


    // 순위 변동 계산
    for (let pRankKey in previousRankings) {
        const pRankData = previousRankings[pRankKey];
        for (let cRankKey in currentRankings) {
            const cRankData = currentRankings[cRankKey];
            if (pRankData.userId == cRankData.userId) {
                const rankChangeScore = pRankData.rank - cRankData.rank;

                cRankData.rankChangeScore = rankChangeScore;
                break;
            }
        }
    }

    const rankingTableUpdateFinish = async (tx) => {
        // ranking Table에 순위와 순위 변동을 기록
        await Promise.all(
            currentRankings.map(async (currentRanking) => {
                await tx.ranking.update({
                    where: {
                        userId: currentRanking.userId
                    },
                    data: {
                        rank: currentRanking.rank,
                        rankChangeScore: currentRanking.rankChangeScore
                    }
                })
            })
        );
    };
    await executeTransaction(rankingTableUpdateFinish);

    // 현재 시점 순위 변동 저장
    previousRankings = currentRankings;
}

 

이전 ranking table의 값과 DBRankingChangeScore을 호출했을 때의 ranking table의 값을 비교해서 순위변동을 계산하고,

DB에 저장한다.

 

app.js

DBRankingChangeScore();

setInterval(() => {
  DBRankingChangeScore();
}, process.env.RANKING_RANK_CHANGE_TIME);

 

서버가 켜질때, 우선 DBRankingChangeScore()를 호출해서 랭킹 데이터를 최신화 시켜준다.

서버에서 정한 일정 시간마다 DBRankingChangeScore()를 호출해서 순위를 계산해준다.

 

 

프론트엔드는 아래와 같이 구성했다.

로그인 화면

 

회원가입 화면

 

게임 화면

오늘의 목표

더보기

✔️ 팀 프로젝트

 


⏱️ 오늘의 일정

9:00 ~ 21:00 - 팀 프로젝트 


📜 팀 프로젝트

9:00 ~ 21:00 - 팀 프로젝트 

 

팀 프로젝트를 진행했다. 

테스트용으로 만든 회원가입과 로그인 부분을 내가 생각하는 로직으로 바꿧다.

 

우선 로그인 전용으로 인증을 검사하는 미들웨어를 만들었다.

export default async function (req, res, next) {
    const { authorization } = req.headers;
    const { id } = req.body;

    do {
        // AccessToken이 없음
        if (authorization === undefined || authorization.length == 0) {
            req.authLoginState = process.env.LOGIN_AUTH_FAIL_AUTHORIZATION_NOT_FOUND;
        }
        else {
            // AccessToken이 있음
            const [c2sTokenType, c2sAccessToken] = authorization.split(' ');
            if (c2sTokenType !== process.env.TOKEN_TYPE_CHECK) {
                req.authLoginState = process.env.LOGIN_AUTH_FAIL_TOKEN_TYPE_NOT_MATCH;
            }        

            const decodedToken = jwt.decode(c2sAccessToken);            
            if(decodedToken.id !== id)
            {
                req.authLoginState = process.env.LOGIN_AUTH_FAIL_DIFFERENT_ACCESS_TOKEN;
                break;
            }

            // 토큰 검증
            const verifyToken = ValidateToken(c2sAccessToken, process.env.ACCESS_TOKEN_SECRET_KEY);                        
            if (!verifyToken) {
                req.authLoginState = process.env.LOGIN_AUTH_FAIL_TOKEN_EXPIRED;
                break;
            }            

            req.authLoginState = process.env.LOGIN_AUTH_SUCCESS;
        }
    } while (0);

    next();
}

 

위 코드에서는 로그인 시도한 유저가 AccessToken을 가지고 있는지 확인한다.

AccessToken이 없거나 빈 내용을 입력하면 req.authLoginState에 LOGIN_AUTH_FAIL_AUTHORIZATION_NOT_FOUND를 저장한다.

 

AccessToken이 있는데 TokenType이 다를 경우에 LOGIN_AUTH_FAIL_TOKEN_TYPE_NOT_MATCH를 저장한다.

AccessToken이 있는데 다른 유저의 AccessToken을 가지고 왔을 경우에는 LOGIN_AUTH_FAIL_DIFFERENT_ACCESS_TOEKN을 저장한다.

 

AccessToken이 있는데 만료됐거나, 값이 다를 경우네는 LOGIN_AUTH_FAIL_TOKEN_EXPIRED를 저장한다.

 

그 외 경우에는 인증 성공으로 판단해서 LOGIN_AUTH_SUCCESS를 저장한다.

 

이후 메인 Login Router로 진행한다.

 

// 로그인

usersRouter.post('/Sign-In', loginAuthMiddleware, async (req, res, next) => {
    // 아이디, 비밀번호 가져오기
    const { id, password } = req.body;

    // userDB에 아이디가 있는지 확인
    const user = await prisma.users.findFirst({
        where: {
            id: id
        }
    });

    // 아이디 검사
    if (!user) {
        return res.status(404).json({ message: `${id}은 존재하지 않는 아이디 입니다.` });
    }

    // 비밀번호 검사
    if (!(await bcrypt.compare(password, user.password))) {
        return res.status(404).json({ message: `비밀번호가 일치하지 않습니다.` });
    }

    // RefreshTokens 테이블에 RefreshToken이 있는지 확인
    const refreshTokenExist = await prisma.refreshTokens.findFirst({
        where: {
            id: id
        }
    });

    switch (req.authLoginState) {
        case process.env.LOGIN_AUTH_FAIL_AUTHORIZATION_NOT_FOUND:
            // AccessToken이 없음
            FirstLogin(id, refreshTokenExist, res);
            break;
        case process.env.LOGIN_AUTH_FAIL_TOKEN_TYPE_NOT_MATCH:
            // AccessToken을 가지고 있지만 토큰 타입이 일치하지 않음
            return res.status(404).json({ message: '토큰 타입이 일치하지 않습니다.' });
        case process.env.LOGIN_AUTH_FAIL_TOKEN_EXPIRED:
            // AccessToken이 만료됨
            AccessTokenExpired(id, refreshTokenExist, res);
            break;
        case process.env.LOGIN_AUTH_FAIL_DIFFERENT_ACCESS_TOKEN:
            DifferentAccessToken(refreshTokenExist, res);
            break;
        case process.env.LOGIN_AUTH_SUCCESS:
            // 로그인 성공
            return res.status(200).json({ message: `${id}로 로그인에 성공했습니다.` });
    }
})

 

로그인에서는 기본적으로 아이디와 비밀번호를 검사하고,

RefreshToken Table에 RefreshToken이 있는지 찾은 후,

로그인 인증에서 저장한 authLoginState값에 따라 다른 로직으로 처리한다.

 

//------------------------------------------------------
// 처음 로그인
//------------------------------------------------------
async function FirstLogin(id, refreshTokenDBData, res) {
    if (refreshTokenDBData === null) {
        // RefreshToken이 Table에 없음
        // 처음 로그인 대상으로 해석
        // JWT로 AccessToken 생성
        const s2cAccessToken = CreateAccessToken(id);
        // JWT로 RefreshToken 생성
        const s2cRefreshToken = CreateRefreshToken(id);

        // 응답 헤더에 accessToken 기록
        res.header("authorization", s2cAccessToken);

        const nowTime = MakeTime(false);
        const expiredAtTokenTime = MakeTime(false, parseInt(process.env.REFRESH_TOKEN_EXPIRE_TIME_DB));

        // RefreshToken DB에 저장
        const refreshToken = await prisma.refreshTokens.create({
            data: {
                id: id,
                token: s2cRefreshToken,
                createdAt: nowTime,
                expiredAt: expiredAtTokenTime
            }
        });

        return res.status(200).json({ message: `${id}로 로그인에 성공했습니다.` });
    }
    else {        
        const s2cAccessToken = CreateAccessToken(id);
        res.header("authorization", s2cAccessToken);

        return res.status(200).json({ message: `${id}로 로그인에 성공했습니다.` });
    }
}

 

FirstLogin 에서는 로그인 시도한 대상이 처음으로 로그인한 대상인지를 판단한다.

만약 RefreshToken Table에서 RefreshToken을 찾지 못하면, 해당 유저를 처음 로그인한 대상으로 해석하고,

AccessToken과 RefreshToken을 발행하고, Refresh Token을 Table에 저장한다.

 

RefreshToken을 이미 가지고 있는 대상이면 AccessToken을 재발행해준다.

 

async function AccessTokenExpired(id, refreshTokenDBData, res) {
    if (refreshTokenDBData !== null) {
        // AccessToken을 가지고 있고, AccessToken이 만료됨
        // refreshToken을 DB에 가지고 있음

        // AccessToken을 재발급
        // RefreshToken이 만료되었는지 확인
        const refreshTokenCheck = ValidateToken(refreshTokenDBData, process.env.REFRESH_TOKEN_SECRET_KEY);
        if (!refreshTokenCheck) {
            // RefreshDB createdAt, expiredAt 컬럼 사용 용도 : 
            // JWT 없이 Refresh Token의 Expire 시간을 제어할 수 있는 용도로 사용
            // 예) JWT로 3시간 후 만료로 발급했으나 모종의 이유로 1시간 후 만료로 바꿔야하는 경우,
            //     JWT는 수정을 할 수 없기 때문에 DB에 있는 시간값으로 판정한다.

            // 현재 시간과 db에 저장되어 있는 시간 차를 구함
            const dbTimeDifferenceSecond = TimeDifference(refreshTokenDBData.expiredAt, "second");

            // 상수값으로 설정해둔 Refresh Token 만료 시간을 지나면 
            if (dbTimeDifferenceSecond >= parseInt(process.env.REFRESH_TOKEN_EXPIRE_DB_CHECK_TIME)) {
                // RefreshToken을 재발급
                const s2cRefreshToken = CreateRefreshToken(id);

                // 현재 시간을 구함
                const nowTime = MakeTime(false);
                // Refresh Token 만료 시간을 구함
                const expiredTime = MakeTime(false, parseInt(process.env.REFRESH_TOKEN_EXPIRE_TIME_DB));

                // Refresh Token을 업데이트
                await prisma.refreshTokens.update({
                    where: { id: id },
                    data: {
                        token: s2cRefreshToken,
                        createdAt: nowTime,
                        expiredAt: expiredTime
                    }
                });
            }
        }

        const s2cAccessToken = CreateAccessToken(id);
        res.header("authorization", s2cAccessToken);

        return res.status(200).json({ message: `AccessToken이 재발급 되었습니다. ${id}로 로그인에 성공했습니다. ` });
    }
    else {
        // AccessToken을 가지고 있고, AccessToken이 만료됨
        // refreshToken을 DB에 가지고 있지 않음
        // 불량 유저로 판정 ( 서버에 문제가 생겨 refreshToken을 기록 못할경우가 있어서 불량 유저로 판정하기엔 무리)      
        // return res.status(404).json({ message: `비정상 로그인 시도` });

        return res.status(404).json({ message: `refreshToken DB Empty` });
    }
}

 

AccessTokenExpired에서는 AccessToken을 재발행 해준다.

만약 RefreshToken을 DB에 가지고 있지 않을 경우에는 불량유저로 판단해 로그인 시켜주지 않는다.

( 이 부분은 실제 이런경우가 있는지 보고 싶어서 따로 처리한 것이고, 원래는 서버에 문제가 생겨 RefreshToken을 DB에 기록하지 못할 경우도 생길 수 있기 때문에 AccessToken을 재발행 해줘야한다고 튜터님에게 답을 얻었다. )

 

RefreshToken을 DB에 가지고 있으면, RefreshToken이 만료됐는지 JWT로 먼저 확인해주고,

추가로 DB에 있는 시간값을 기준으로 만료됐는지 확인 한 후,

( DB에 있는 시간값을 기준으로 만료됐는지 추가로 확인하는 이유는 다음과 같다. JWT로 발행을하고 나서 모종의 이유로 만료시간을 수정을 해야할 경우, JWT의 만료시간을 수정할 수 없어서 DB값을 기준으로 최종적으로 판단해준다. )

만료됐으면 RefreshToken을 재발행한다.

 

🌙 하루를 마치며

오늘 생각했던 회원가입과 로그인 부분을 구현할 수 있어서 기분이 좋았다.

로그인 부분에서 불량유저를 판단하는 부분이 애매해 튜터님에게 가서 물어보니,

다른 관점으로 대답을 해주셔서 판단에 많은 도움이 되었다.

 

주말에는 추가로 맡은 랭킹 부분을 완료해 월요일에 테스트 할 수 있도록 해야겠다.

+ Recent posts