본문 바로가기
Front-end/React.js

프론트엔드 아키텍처 다층화구조(layered architecture) (+실제 폴더 구성)

by img 2022. 4. 12.

해당 글은 아래 원문을 번역하여 다시 작성한 글입니다. 
(https://blog.logrocket.com/optimize-react-apps-using-a-multi-layered-structure/)

리액트로 사이드프로젝트를 만들면서 리액트의 구조를 어떻게 만들어야 미래의 내가 현재의 나에게 고마워할까^^ 라는 생각을 계속 했었고, 관련 정보를 찾기 위해 이글 저글 기웃기웃 하던 중에 해당 글을 발견했는데, 딱 제가 찾던 정보의 글이라 다시 한번 정리해놓으면 좋을것 같아서 정리했습니다. 그리고 글을 읽으면서 '개발바닥'이라는 유튜브채널의 영상(https://www.youtube.com/watch?v=oqAdL8Nw5j0)에서 'layered-architecture'이라는 용어가 등장했던 것이 기억이 나서 "아, 이게 그 영상에서 말했던 그거구나!"하면서 개인적으로 더 재미있게 읽었던 것 같습니다ㅎㅎ

원문은 React를 기준으로 설명을 하고 있지만, 실제 저희 회사 프로젝트(Vue)도 비슷한 구조를 가지고 있는 것 보면 대부분의 프론트엔드에 동일하게 적용될 수 있을 것 같습니다. 미래의 저뿐만아니라 이 글을 읽으시는 모든 분들의 이해를 위해 의역하겠습니다. 


프로젝트가 진행되다보면 프로젝트의 크기는 점점 커지기 마련입니다. 그렇게 커진 프로젝트 안에서 내가 찾고 있는 파일이 어느 폴더에 들어있는지 찾는데에 한세월 걸린 경험...은 저만 있는건 아니겠죠? 이런 일이 반복될 경우 개발을 하면서 엄청난 비효율을 실감할 수 있을 것입니다. ( 이게 바로 제가 프론트엔드의 구조에 좀 더 신경을 쓰려고 했던 이유이기도 합니다 )   그럼 프로젝트 구조를 어떻게 만들어야할까요? 바로 다층구조, multi-layered 라고도 불리는 아키텍처를 사용해서 정리를 하면 됩니다. 이런 구조를 잘 따라서 개발을 할 경우 개발자는 자신이 찾는 파일을 바로바로 쉽게 찾을 수 있게 되고, 이런 변화는 개발단계에서 큰 변화를 가져오게 됩니다. 또한 개발자 개인뿐만 아니라 개발팀 전체에도 긍정적인 개발자경험을 줄 수 있는데요. 많은 사람들이 다른 레이어에서 작업을 하기 때문에, 서로 겹치거나 충돌이 날 경우를 방지할 수 있습니다. 

다층화구조가 최적화에 좋은 이유

1. 코드 재사용

내가 사용하고 있는 모든 리액트 프로젝트들이 동일한 다층화 파일구조로 만들어졌다고 가정해봅시다. 그렇게 되면 컴포넌트나 다른 파일들을 다른 프로젝트의 소스코드를 가져와서 사용하는대신, 동일한 파일구조들은 어떤 파일이나 코드든 그냥 간단하게 복-붙만 하면 사용할 수 있습니다. 이렇게 하게 될 경우 코드 재사용성이 높아질 뿐만아니라 중복 코드를 방지해 소스 크기도 작아져서 빌드를 하는데에도 시간을 아낄 수 있고 쉬워집니다. 

예를들어서, 내 프로젝트들 중 하나에 계산식을 몇개 만들어놨다고 가정해봅니다. 아마 이 유틸리티 함수를 내 코드들 전체에 퍼트려놓고 싶지 않을 것입니다. 대신에 해당 유틸리티들만 담을 수 있는 math-utils.js라는 파일을 만들어서, 관련 함수들을 다 그 안에 넣어놓습니다. 동일한 파일에는 동일한 목적만을 담당하는 요소들을 외부로 export함으로써, 코드를 좀 더 재사용성 있게 만드는 것입니다. 다시 말하면, "미래에 비슷한 이슈를 해결해야될 나"는 예전에 만들어놨던 math-utils.js파일을 utils레이어에 복붙해 넣으면 간단하게 재사용할 수 있게 되는 것입니다.

2. 코드 중복 방지

다층화구조(multi-layered architecture)로 만들게 되면 모든 파일, 함수, 소스코드들은 정해진 위치에 들어있게됩니다. 이게 무슨말이냐면 뭔가 새로운 코드를 작성하고 싶으면 먼저 해당 폴더를 살펴봐야하는데요. 그러면 이미 만들어 놓았던 소스를 다시 사용하게 되는 코드 중복을 방지할 수도 있고 빌드사이즈도 최소화할 수 있게 됩니다. 만약 번들의 사이즈가 커지게 되면 코드를 다운로드하고 렌더링해주는 브라우저 입장에서는 부담스러울 수 밖에 없습니다. 그래서 코드 중복을 피하는 일은 내 어플리케이션을 더 클린하게, 빌드하게 쉽게, 더 빠르게 만들어줄 수 있습니다!

예를들어서, 특정API에서 반환된 데이터를 검색해야할 때마다 다음과 같이 접근할 수도 있게되는데요 : 

더보기
function FooComponent1(props) {     
      // ...

      // retrieving data
      fetch(`/api/v1/foo`)
      .then((resp) => resp.json())
      .then(function(data) {
          const results = data.results;
          // handling data ...
        })
        .catch(function(error) {
          console.log(error);
        });

      // component logic ...
}

export default FooComponent1
function FooComponent2(props) {
      // ... 

      // retrieving data
      fetch(`/api/v1/foo`)
      .then((resp) => resp.json())
      .then(function(data) {
          const results = data.results;
          // handling data in a different way than FooComponent1 ...
        })
        .catch(function(error) {
          console.log(error);
        });

      // different component logic from FooComponent1 ...
}

export default FooComponent2

보시다시피 FooComponent1과 FooComponent2는 코드가 중복되는 문제가 있습니다. 이 문제를 API계층을 이용해서 고친다면:

export const FooAPI = {

    // ...

    // mapping the API of interest
    get: function() {
        return axiosInstance.request({
            method: "GET",
            url: `/api/v1/foo`
        });
    },

    // ...

}

-

import React from "react";
import {FooAPI} from "../../api/foo";
//...

function FooComponent1(props) {
      // ... 

      // retrieving data
      FooAPI
      .get()
      .then(function(response) {
          const results = response.data.results;
          // handling data ...
        })
        .catch(function(error) {
          console.log(error);
        });

      // component logic ...
}

export default FooComponent1

-

import React from "react";
import {FooAPI} from "../../api/foo";
//...

function FooComponent2(props) {
      // ... 

      // retrieving data
      FooAPI
      .get()
      .then(function(response) {
          const results = response.data.results;
          // handling data in a different way than FooComponent1 ...
        })
        .catch(function(error) {
          console.log(error);
        });

      // different component logic from FooComponent1 ...
}

export default FooComponent2

이런식으로 코드 중복을 피할 수 있습니다. 

3. 팀 시너지 극대화

잘 알려진 아키텍처에서 개발을 한다는 것은 개발 팀원들 모두가 같은 방식으로 작업한다는 것을 의미합니다. 팀원이 새로운 API를 매핑했거나, 새로운 util파일이나 컴포넌트를 만들었을때, 다른 팀원들도 바로 그것을 사용할 수 있게 됩니다. 그리고 각각의 파일들은 모두가 예상할 수 있는 경로의 특정 폴더에 위치해있을 것이기 때문에, 불필요한 의사소통은 하지 않아도 되고 개발 프로세스 또한 간단해집니다.


어쩌면 단점이 있을지도...

해당 아키텍처의 유일한 잠재적인 단점이 있다면, npm모듈을 만드는 것이 복잡해질 수 있습니다. 이런 방식의 아키텍처를 사용할 경우에파일들이 많은 폴더들에 흩어져있기 때문에 상대경로로 import를 할 때 더 많은 주의가 필요합니다. 이런 문제는 npm모듈을 만들 때의 문제로 잘 알려져 있기도 합니다. 

상대경로로 import를 하게 되면 (특히 소스코드 일부를 캡슐화해서 독립적인 모듈로 만들 때 ) 경로문제가 있을 수도 있는데요. 

하지만!

상대경로로 import하는대신 절대경로를 사용함으로써 이런 문제는 쉽게 해결할 수 있습니다. 

import UserComponent from "../../components/UserComponent";
import userDefaultImage from "../../../assets/images/user-default-image.png";
import {UserAPI} from "../../apis/user";

이렇게(상대경로) 된 코드를

import UserComponentfrom "@components/UserComponent";
import userDefaultImage from "@assets/images/user-default-image.png";
import {UserAPI} from "@apis/user";

이렇게(절대경로) 바꿔만주면 됩니다!

그렇기 때문에 이게 진짜 "단점"이라고 말할 수 있을지는 의문이 드네요.. 


전체 구조

그럼 이제, 이 구조를 사용해야하는 이유에 대해서는 열심히 설명을 했으니 어떻게 내 프로젝트에 적용을 시킬 수 있을지 살펴보겠습니다. 먼저, 최종 결과는 이렇게 생겼습니다 :

폴더로 만들어진 각 계층

아키텍처의 각 계층(layer)은 폴더로 만들어져야 합니다. 그 폴더들은 아키텍처를 구성하는 레이어와 동일하게 이름을 짓는 것이 좋습니다. 이렇게되면 파일 검색이 직관적이고 쉬워지게 됩니다. 또한 새 파일을 어느 폴더에 넣어야 할지 쉽게 알 수 있다는 것도 장점입니다.


1. API 계층

axios와 같은 Promise기반 http 클라이언트를 사용하면, 각 API에 대한 함수를 정의해서 호출에 필요한 모든 로직을 캡슐화할 수 있는데요. 다음 코드와 같이 API요청이 실제 사용되는 위치와 정의된 위치를 나눌 수 있습니다 : 

더보기

코드보기

const axiosInstance = axios.create({
   baseURL: 'https://yourdomain.org'
});

export const UserAPI = {
    getAll: function() {
        return axiosInstance.request({
            method: "GET",
            url: `/api/v1/users`
        });
    },
    getById: function(userId) {
        return axiosInstance.request({
            method: "GET",
            url: `/api/v1/users/${userId}`
        });
    },
    create: function(user) {
        return axiosInstance.request({
            method: "POST",
            url: `/api/v1/users`,
            data: user
        });
    },
    update: function(userId, user) {
        return axiosInstance.request({
            method: "PUT",
            url: `/api/v1/users/${userId}`,
            data: user,
        });
    },
}

이렇게 API계층에 정의해놓으면

import React, {useEffect, useState} from "react";
import {UserAPI} from "../../api/user";
//...

function UserComponent(props) {
    const { userId } = props;

    // ...

    const [user, setUser] = useState(undefined)

    // ...

    useEffect(() => {
        UserAPI.getById(userId).then(
            function (response) {
                // response handling
                setUser(response.data.user)
            }
        ).catch(function (error) {
            // error handling
        });
    }, []);

    // ...
}

export default UserComponent;

이런식으로 갖다 씁니다.

이런식으로, 모든 API요청을 정의해놓은 모든 파일들을 API계층의 폴더에 넣어놓습니다. 

2. Asset 계층

멀티미디어 파일과 같이 소스코드가 아닌 파일이 필요할 수도 있는데요. 이런 파일들은 소스코드들과 명확하게 구분이 되어있어야 하기 때문에 전용 폴더를 가지고 있는 것이 좋습니다. 그래서 assets 폴더를 만들어서 해당 파일들을 여기에 위치시키는데요. 이 안에서도 파일의 확장자에 따라 하위 폴더로 나누어서 저장할 수도 있습니다.

파일 확장자에 따른 구분

그리고 만약 다국어지원 어플리케이션을 만들고 있다면 모든 번역파일들을 저장할 공간이 되기도 합니다.

다국어처리를 위해 i18next 파일만을 담은 폴더를 만들 수도!

3. component 계층

모든 컴포넌트 파일들이 저장되는 곳입니다. 컴포넌트 하나당 하나의 폴더가 필요합니다. 보통 index.js와 index.css파일이 같이 들어가야 하기 때문이죠...
만약 컴포넌트들의 크기가 너무 클 경우 하위폴더를 만드는 방법도 좋습니다. 하지만, 컴포넌트를 저장하는 구조를 언제든지 바꿀 수 있다는 것도 항상 생각하고 있어야합니다. 

4. constant 계층

상수를 저장하는 곳입니다. 흔한 방법으로는 constans.js파일을 만들어서 모든 상수들을 그 안에 정의할 수도있지만, 역시 프로젝트가 커진다면 해당 파일은 더러워지게되겠죠... 그래서 상수를 여러 파일로 분할해줘야합니다. 

예를들어서, 따로 파일을 만들어서 다국어를 지원하는 어플리케이션에서 모든 i18n과 관련된 모든 상수들만 넣어준다면 관리하기가 더욱 좋습니다!

상수들을 각자 다른 파일에 나누어 담아줬습니다.

5. redux 계층

redux나 vuex와 같이 상태관리 라이브러리를 사용할 경우에만 필요한 곳인데요. 수많은 reducer와 actions 안에서 갈팡질팡해본 경험이 있는 사람만 조용히 손을 들어봅니다.. 게다가 actions는 bolierplate 코드를 포함하기도 합니다. 그래서 모든 actions를 한번에 관리하기 위해 redux-actions와 같은 라이브러리를 사용하기도 합니다. 그러면 아래처럼 정리된 파일 구조를 간단히 얻을 수 있게 됩니다.

하나의 index.js파일과 와 두개의 폴더(actions, reducers)

이처럼 모든 reducer를 "reducers"라는 폴더에 넣고 actions는 "actions"라는 폴더에 각각 담아줬습니다. 그리고 그 상위 레벨 redux 에 index.js를 만들어서 store나 반복해서 사용되는 로직들을 넣을 수 있습니다. 만약 필요하다면, 위의 폴더(redux/reducers, redux/actions)안에서 하위 폴더를 나누어서 정리해둘 수도 있습니다. 

6. route 계층

프로젝트에 이미 모든 routes를 포함하는 routes.js파일이 있을 수도 있는데요. 아마 그 파일 안에다 모든 routes들을 하나로 모아 놓고 실제 사용할 때와 구분하고 있을 것입니다. 작은 프로젝트에서는 이런 방식도 괜찮지만, 문제는 우리의 프로젝트가 얼마나 더 커질지 모른다는 것이죠^^... 이것이 바로 모든 라우팅 파일과 로직들을 포함하는 Routes레이어를 따로 만들어줘야 하는 이유입니다. route.js를 여러 파일로 나누는 쉬운 방법은 다음과 같이 프로젝트에서 사용되는 각 기능의 라우팅으로 각각의 파일을 생성해주는 것입니다 :

각 기능에 따라 4개의 파일로 나누어준 routes들

7. utility 계층

여기는 프로젝트 어디에서든 사용할 수 있도록 따로 만들어놓은 사용자정의 유틸리티 함수를 저장하는 곳입니다. 하나의 파일에 모두 몰아넣을 수도 있지만, 재차 말하지만 프로젝트의 크기가 커지면 강제로 여러개의 파일로 나눠줘야하는 일이 생기면 더 복잡해지때문에, 전용 폴더(계층)를 만들어주는 것이 좋습니다. 

8. view 계층

여기는 구성된 모든 페이지를 저장하는 최상위의 계층입니다. 여태까지 설명했던 다른 계층의 파일들을 import해서 쓸 수 있는 유일한 계층이라는 점을 잊지마세요! 프로젝트의 크기가 커져서 페이지의 양이 많아질 때를 대비해서 하위 폴더로 나누어 놓는것이 좋습니다. 나누는 기준은 라우팅 구조를 그대로 따라가면 좋습니다. 

나머지 파일들은?

지금까지 말한 폴더(계층)로는 프로젝트에 필요한 모든 소스코드들을 다 커버할 수는 없을 것입니다. 때문에 어디에 위치해야할지 길을 잃은 파일들이 있을수도 있는데요, 만약 3개 이하의 파일이 있다면 그냥 최상위 경로에 놔두는게 좋을 수도 있고, 언제든지 새로운 폴더(계층)을 만들어서 사용해도 됩니다. 

결론

이 글에서 리액트 프로젝트를 어떻게 정리하고 왜 정리해야 하는지 알아봤습니다. 다층화구조(multi-layered architecture)를 이용해 나누어준 폴더들에 각각의 파일들을 위치시키면서, 우리의 프로젝트는 좀 더 예쁘고 잘 정리된 프로젝트가 되었을 것입니다. 또한 개발팀은 잘 정립된 아키텍처 표준과, 유지보수하기 편한 코드와 간소화된 개발 프로세스를 얻게 되었습니다!


이렇게 원문에 대한 정리는 끝이 났고, 개인적인 생각을 적어보자면, 규모가 작은 프로젝트에서는 굳이? 라는 생각이 들 수도 있지만, 규모가 커질수록 더 큰 효과를 낼 수 있는 아키텍처라는 생각됩니다. 지금 회사의 프로젝트도 이런식으로 계층화는 되어있지만 좀 더 나눌 수 있는 부분이 있을 것 같습니다.

댓글