[Architecture] Layerd Architecture & DI & Unit Test

Noah·2022년 2월 28일
0

Architecture

목록 보기
1/1

Project Description

오늘은 크누마켓 프로젝트 리팩토링 과정에서 Layerd Architecture를 적용하고 DI를 통해 Unit Test를 적용한 부분까지 한 번에 얘기해 보려고 한다. 프로젝트에서 사용한 주요 스택은 다음과 같다.

  • Language: Node.js, Typescript
  • Framework: Express.js
  • ORM: TypeORM

자, 먼저 리팩토링 이전에 더러웠던 한 API의 사례를 보도록 하자. 아 참고로 리팩토링 이전에 사용한 프레임워크로는 Koa.js를 사용했었다.

리팩토링 이전의 getPosts API

export const getPosts = async (ctx: Context) => {
  let body, status: number, posts;
  const { page } = ctx.request.query;
  const start = (Number(page) - 1) * 15;
  const totalNums = await getConnection().getRepository(Post).createQueryBuilder('post').select('COUNT(*) AS cnt').getRawMany();
  const canHaveMaxPage: number = Math.ceil(Number(totalNums[0].cnt) / 15);
  const withoutcomplete = ctx.header.withoutcomplete === '1' ? true : false;

  if (Number(page) > canHaveMaxPage) {
    ctx.status = 400;
    ctx.body = await errorCode(601, `글이 더이상 존재하지 않습니다. 마지막 페이지는 ${canHaveMaxPage}페이지 입니다.`);
    return;
  }

  if (!page || start < 0) {
    status = 400;
    body = await errorCode(401);
  } else {
    if (withoutcomplete) {
      posts = await getConnection()
        .getRepository(Post)
        .createQueryBuilder('post')
        .leftJoinAndSelect('post.medias', 'media')
        .orderBy('post.isArchived', 'ASC')
        .addOrderBy('post.createDate', 'DESC')
        .offset(start)
        .limit(15)
        .getMany();
    } else {
      posts = await getConnection().getRepository(Post).createQueryBuilder('post').leftJoinAndSelect('post.medias', 'media').orderBy('post.createDate', 'DESC').offset(start).limit(15).getMany();
    }

    posts.forEach((element) => (element.user = { uid: element.createUserUid }));

    status = 200;
    body = posts;
  }

  ctx.compress = true;
  ctx.status = status;
  ctx.body = body;
  ctx.set('Content-Type', 'application/json');
};

단순한 글 목록을 조회하는 API이다. 하나의 API에서 요청을 받아 응답을 하고 비즈니스 로직에 따라 response status와 body 상태를 결정하며 DB에서 데이터를 액세스하는 일까지 모두 처리하고 있음을 알 수 있다.

지금 다시 생각해 봐도 이 얼마나 더럽고 번잡하며 상대방을 배려하지 않는 코드인가... 심지어 모든 API를 이런 방식으로 작성했었다. 때문에 프로젝트 리팩토링을 진행하면서 최우선 과제는 이 더러운 코드를 각각의 역할에 맞게 분리하고 테스트하기 좋은 코드를 만드는 것이었다.

리팩토링 후의 코드를 보기 전에 먼저 적용할 Layerd Architecture에 대해서 알아보자.


Layerd Architecture

레이어드 아키텍처는 가장 일반적인 아키텍처 패턴으로 n-계층 아키텍처 패턴으로 알려진 계층화된 아키텍처 패턴이다. 이 패턴은 대부분의 Java EE 애플리케이션에 대한 사실상의 표준이므로 평소 스프링에 익숙한 개발자라면 모를 수 없을뿐더러 심지어 몰랐더라도 해당 아키텍처가 적용된 방식으로 코드를 짰으리라 생각된다.

레이어드 아키텍처에서 각 계층은 애플리케이션 내에서 특정 역할을 수행한다. 레이어드 아키텍처 패턴은 패턴에 반드시 존재해야 하는 계층의 수와 유형을 지정하지 않지만 대부분의 레이어드 아키텍처는 프레젠테이션, 비즈니스, 지속성 및 데이터베이스의 4가지 표준 계층으로 구성된다. 그러나 더 작은 애플리케이션의 경우 3개의 계층만 있을 수 있고 더 복잡한 애플리케이션의 경우 5개 이상의 계층이 포함될 수 있다. 이는 프로젝트 규모에 따라 계층이 늘어날 수도 있고 줄어들 수도 있다는 말이다. 즉 "무조건 4개의 계층으로 가야 해!"라는 것이 아니다.

이 레이어드 아키텍처에서 중요한 것은 각 계층이 특정 역할과 책임만을 수행해야 한다는 것이다. 예를 들어 프레젠테이션 계층은 모든 사용자 인터페이스와 브라우저 통신 논리를 처리하는 반면 비즈니스 계층은 요청과 관련된 특정 비즈니스 규칙을 실행하는 역할을 해야 한다. 즉 각 계층은 특정 행위를 충족하기 위해 수행해야 하는 작업을 중심으로 그 이외의 행위는 추상화를 적용한다. 예를 들어 프레젠테이션 계층은 고객 데이터를 얻는 방법을 알거나 걱정할 필요가 없다. 아니 몰라야 한다. 특정 형식의 화면에 해당 정보를 표시하기만 하면 되는 것이 프레젠테이션 계층의 역할이며 책임인 것이다.

레이어드 아키텍처 패턴의 장점은 계층 간의 관심이 분리된다는 것에 있다. 관심이 분리된다는 것은 여러 의미를 담고 있지만 쉽게 말하면 하나의 계층의 코드 변경이 다른 계층의 코드에 영향을 끼치지 않는다. 때문에 레이어드 아키텍처 패턴을 사용하면 애플리케이션을 쉽게 확장, 테스트, 유지 보수할 수 있다.

더 자세하게 알고싶다면 해당 을 참고하자!


Layerd Architecture 적용 후

자 그러면 이제 리팩토링 후의 코드를 보도록 하자. 예시는 마찬가지로 getPosts API이다.

Controller Layer

컨트롤러 계층에서는 클라이언트로부터 Http Request를 받아 적절한 Http Response를 응답하는 역할만 한다. 여기서 생성자를 통해 주입받은 postService를 통해 비즈니스 로직에 따라 적절한 데이터를 갖고 온다. 하지만 컨트롤러 계층에선 해당 서비스 계층에서 일어나는 일에 관심도 없고 모른다. 나머지 계층 모두 마찬가지이다.

async showPosts(req: Request, res: Response, next: NextFunction) {
    const { last_id } = req.query;
    const [posts, nextLastId] = await this.postService.getPosts(String(last_id));

    return {
        statusCode: 200,
        response: {
            posts,
            nextLastId
        }
    };
}

Service Layer

서비스 계층에서는 비즈니스 로직에 따라 데이터를 조회 및 가공하는 역할을 한다. 마찬가지로 생성자를 통해 주입받은 postRepository를 통해 필요한 데이터를 가져온다.

async getPosts(lastId: string) {
    let posts;

    if (lastId !== 'null') {
      posts = await this.postRepository.getPosts(Number(lastId));    
    } else {
      posts = await this.postRepository.getPostsForFirstPage();
    }
    
    const convertedPosts = this.convertPostHaveOneImage(posts);

    if (convertedPosts.length < 20) {
      return [convertedPosts, null];
    } else {
      return [convertedPosts, convertedPosts[convertedPosts.length - 1].id];
    }
  }

Repository Layer

레포지토리 계층에서는 DB에 접근해 데이터를 액세스하는 것에만 책임과 역할이 있는 계층이다. 가장 마지막 계층이기 때문에 따로 주입받은 객체는 없다.

getPosts(lastId: number) {
    return this.createQueryBuilder('p')
      .select(['p.id', 'p.title', 'p.created_at', 'i.url'])
      .leftJoin('p.images', 'i')
      .where('p.id < :lastId', { lastId })
      .orderBy('p.id', 'DESC')
      .limit(20)
      .getMany();
  }

  getPostsForFirstPage() {
    return this.createQueryBuilder('p')
      .select(['p.id', 'p.title', 'p.created_at', 'i.url'])
      .leftJoin('p.images', 'i')
      .orderBy('p.id', 'DESC')
      .limit(20)
      .getMany();
  }

리팩토링 이전의 코드와 비교했을 때보다 많이 깔끔해지지 않았는가? 단순 코드의 깔끔함을 넘어서 진짜 이점은 테스트에 있다.


Unit Test

단위 테스트에 대해선 길게 얘기하지 않겠다. 단위 테스트는 응용 프로그램에서 테스트 가능한 가장 작은 소프트웨어를 실행하여 예상대로 동작하는지 확인하는 테스트이다. 단위 테스트에서 테스트 대상 단위의 크기는 엄격하게 정해져 있지 않지만 일반적으로 클래스 또는 메서드 수준이다.

레포지토리 계층의 단위 테스트를 위해 SQLite라는 인 메모리 DBMS를 이용해 간단하게 테스트했다. 리눅스나 맥 같은 유닉스 계열의 OS는 아마 기본으로 설치가 되어있을 것이다. 참고하길 바란다.

테스트 라이브러리로는 Jest를 활용했다. 자세한 코드는 생략하겠다! Jest 라이브러리에 대한 글이 아니기 때문이다.

Repository Layer

getPostsForFirstPage 쿼리 테스트

it('getPostsForFirstPage - 첫 페이지 글 목록 조회', async () => {
    // given
    const title = '치피 파티 할 사람??';
    const description = '치킨나라 피자공주 같이 시켜먹어요!! 너무 심심해요..ㅜㅜ';
    const location = 2;
    const max_head_count = 4;
    
    for (let i = 0; i < 15; i++) {
        await postRepository.insertPost(title, description, location, max_head_count);
    }

    // when
    const result1 = await postRepository.getPostsForFirstPage();
    
    // then
    expect(result1.length).toBe(15);
  });

getPosts 쿼리 테스트

it('getPosts - 페이징을 통한 글 목록 조회', async () => {
    // given
    const title = '치피 파티 할 사람??';
    const description = '치킨나라 피자공주 같이 시켜먹어요!! 너무 심심해요..ㅜㅜ';
    const location = 2;
    const max_head_count = 4;
    
    for (let i = 0; i < 43; i++) {
        await postRepository.insertPost(title, description, location, max_head_count);
    }

    // when
    const result1 = await postRepository.getPosts(24);
    const result2 = await postRepository.getPosts(4);
    
    // then
    expect(result1.length).toBe(20);
    expect(result1[0].id).toBe(23);
    expect(result1[19].id).toBe(4);
    
    expect(result2.length).toBe(3);
    expect(result2[0].id).toBe(3);
    expect(result2[result2.length - 1].id).toBe(1);
  });

기본적으로 둘 모두 조회하는 쿼리이기 때문에 미리 데이터를 넣어두고 원하는 결과를 얻을 수 있었다.

Service Layer

getPosts 메서드 테스트

describe('getPosts - 공동구매 글 조회', () => {
    it('성공 - lastId가 null인 경우 첫 번째 페이지의 글 조회', async () => {
      // given
      const lastId = 'null';

      // when
      postRepository.getPostsForFirstPage.mockResolvedValue([]);
      await postService.getPosts(lastId);

      // then
      expect(postRepository.getPostsForFirstPage).toHaveBeenCalled();
    });

    it('성공 - lastId가 number인 경우 lastId보다 작은 수부터 20개 조회', async () => {
        const lastId = '22';

        // when
        postRepository.getPosts.mockResolvedValue([]);
        await postService.getPosts(lastId);
  
        // then
        expect(postRepository.getPosts).toHaveBeenCalled();
    });
  });

서비스 계층에선 주입받은 레포지토리 계층을 통해 데이터를 액세스하는 부분이 있으므로 그 부분을 가짜 행위로 모킹해야 한다. 단위 테스트란 테스트 대상의 역할과 책임만을 테스트하는 것이므로 비즈니스 로직에 따라 데이터 가공을 담당하는 서비스 계층에선 비즈니스 로직에 따라 논리에 오류가 있는지 만을 테스트하면 된다. 레포지토리 계층의 역할인 데이터 액세스 부분에는 책임이 없기 때문이다.


정리

어쨌든 얘기하고자 하는 결론, 내가 느낀 바는 다음과 같다. 깔끔한 코드와 동료를 배려하는 코드를 위해서도 물론이고 애플리케이션의 확장과 테스트 및 유지 보수를 위해서라도 적절한 아키텍처는 필수이며 나의 경우 레이어드 아키텍처를 적용해 리팩토링함으로써 위와 같이 각 계층은 단일 책임을 통해 각 계층에서의 역할에만 신경을 쓸 수 있었고 테스트하기 훨씬 쉬운 환경이 되었다. 또한 이는 각 계층에서 문제가 생길 경우 해당 계층의 코드만 수정하면 되므로 더욱 유지 보수하기 쉬운 시스템이 된 거 같다. 쉬운 작업은 아니었지만 시스템을 갈아엎고 나니 매우 뿌듯했다 :)

profile
개발 공부는 🌳 구조다…

0개의 댓글