Thinking

프론트엔드의 아키텍쳐에 관한 생각

bkdragon 2023. 3. 6. 13:58

https://velog.io/@teo/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-MV-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80%EC%9A%94

 

프론트엔드에서 MV* 아키텍쳐란 무엇인가요?

MVC, MVVM, MVI 아키텍쳐가 어쩌고 저쩌고... 소프트웨어를 공부하다 보면 한번쯤은 MV__로 시작되는 아키텍쳐라는 용어를 들어본적이 있을 겁니다. 실제로 프로그래밍을 할 때에는 중요하지 않아보

velog.io

프로젝트를 진행하며 자연스럽게 코드의 구조에 대해 고민하기 시작했고, 위 글을 읽고 많은 인사이트를 얻고 그걸 토대로 내 생각을 정리해보고자 한다.

VM (View Model)

우리가 사용하는 라이브러리/프레임워크인 React, Vue, Angular는 선언적 프로그래밍을 바탕으로 구조상 ViewModel의 역할을 하게 된다. ViewModel은 model과 view의 관점을 분리하려 하지 않고 하나의 템플릿으로 관리하려는 방식으로 발전을 했는데 이를 component로 볼 수 있다.

 

아래는 간단한 component를 생성하는 코드이다. 상태를 만들고 화면에 보여주고 있다. 

export default function Main() {
	const [data, setData] = useState<string>("hello world!");
    
    return (
    	<div>{data}<div>
    )
}

 

Container-Presenter

기존의 component는 비지니스 로직(데이터를 다루는 로직)을 포함하는데, component를 재사용 가능하게 만들려면 비지니스 로직을 분리해야한다. 그래서 두가지로 분리하게 된다. Container Component는 비지니스 로직을 다루고 Presenter Component는 비지니스 로직을 가지지 않고 데이터만 화면에 보여준다. 

 

이 패턴의 문제는 최상단 혹은 1depth에 위치하는 Container Component에서 하위로 Props를 내려주는 과정에서 Props Drilling이 발생하는 것이다. 이것을 해결하기 위한 Flux 패턴과 Redux, 상태관리의(store) 필요성이 두각된다.

 

여기서 난 조금 다른 형태의 방식을 사용하려고 한다. Page 단위의 Container-Presenter이라면 Props-Drilling 문제는 발생하지 않을 것 이다.

 

Page 단위의 Container-Presenter

게시글 상세 페이지가 있다고 하자. 

 

BoardDetail.container.js

데이터 패칭의 작업(비지니스 로직)이 이뤄지는 컴포넌트이다. 이렇게 얻은 데이터를 presenter(ui, view)로 넘겨준다.

import React from "react";
import { useQuery } from "@apollo/client";
import { useRouter } from "next/router";
import { FETCH_BOARD } from "./BoardDetail.queries";
import BoardDetailUI from "./BoardDetail.presenter";

export default function BoardDetail() {
  const router = useRouter();
  const { loading, data, err } = useQuery(FETCH_BOARD, {
    variables: { boardId: router.query.boardId },
  });

  if (loading) {
    <div>Loading...</div>;
  }

  if (err) {
    <div>Error...</div>;
  }

  return <BoardDetailUI data={data} />;
}

 

BoardDetail.presenter.js

스타일트 컴포넌트는 무시하고 data를 받아와 화면에 뿌려주는것만 보면 된다.

import * as S from "./BoardDetail.styles";

export default function BoardDetailUI({ data }) {
  return (
    <S.DetailContainer>
      <S.ContentsWrapper>
        <S.UserInfoWrapper>       
          <div>
            <S.Writer>{data && data.fetchBoard.writer}</S.Writer>
            <S.CreateDate>
              Date : {data && data.fetchBoard.createdAt}
            </S.CreateDate>
          </div>
        </S.UserInfoWrapper>
        <S.MainContents>
          <S.Title>{data && data.fetchBoard.title}</S.Title>
          <S.MainImage />
          <S.Contents>{data && data.fetchBoard.contents}</S.Contents>
        </S.MainContents>
        <S.SideContents>
          <S.StatusContainer>
            <S.Like>
              <div>{data && data.fetchBoard.likeCount}</div>
            </S.Like>
            <S.DisLike>
              <div>{data && data.fetchBoard.dislikeCount}</div>
            </S.DisLike>
          </S.StatusContainer>
        </S.SideContents>
      </S.ContentsWrapper>
    </S.DetailContainer>
  );
}

 

이렇게 page 단위로 Container-Presenter 패턴을 사용하면 props drilling 문제는 발생하지 않는다. 전역에서 사용해야할 데이터나 상태는 상태관리 라이브러리를 통해 관리해주면 된다.

 

상태관리 Flux 패턴

상태관리의 왕도는 아직까진 redux로 보인다. npm trends로 비교해본 결과 다른 라이브러리들의 차이가 많이 난다.

 

Flux 패턴을 기반으로 하는 Redux는 보일러 플레이트가 복잡하다는 큰 단점이 존재한다. Redux toolkit으로 그 양이 많이 줄었다곤 하나 여전히 복잡하다.

 

counter 값을 관리하는 보일러 플레이트

import { createSlice, createAction } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
    value: number;
}

const initialState: CounterState = {
    value: 0,
};

// 액션과 리듀서를 동시에 생성
export const counterSlice = createSlice({
    name: 'counter',
    initialState,
    reducers: {
        increment: (state) => {
            state.value += 1;
        },
        decrement: (state) => {
            state.value -= 1;
        },
        // PayloadAction : 액션의 payload에 타입을 지정해주는 제네릭
        incrementBy: (state, action: PayloadAction<number>) => {
            state.value += action.payload;
        },
    },
});

// action을 다른 컴포넌트에서 사용하기 위해 export
export const { increment, decrement, incrementBy } = counterSlice.actions;

// store 에서 import
export default counterSlice.reducer;

 

user 데이터를 관리하는 보일러 플레이트(비동기 처리)

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axiosInstance from '../../api/axios';
import request from '../../api/request';

interface UserState {
    email: string;
    password: string;
    name: string;
}

const initialState: UserState[] = [];

// 비동기 처리되는 action 생성 함수 만들기 
export const signupUser = createAsyncThunk(
    'user/signupUser', // reducer의 action.type
    async (userData: UserState) => {
        try {
            const response = await axiosInstance.post(
                request.register,
                JSON.stringify(userData)
            );

            if (response.status === 201) {               
                return { ...response.data, ...userData }; // reducer의 action.payload
            } else {
                throw new Error();
            }
        } catch (e: any) {
            console.log('Error', e.response.data);
            throw new Error();
        }
    }
);

// 비동기 처리되는 action 생성 함수 만들기 
export const auth = createAsyncThunk('user/auth', async () => {
    const responce = await axiosInstance
        .get(request.auth)
        .then((res) => res.data);

    return responce;
});

export const userSlice = createSlice({
    name: 'user',
    initialState,
    reducers: {
        // standard reducer logic, with auto-generated action types per reducer
    },
		// 따로 만든 액션을 추가해서 reducer 만드는 패턴
    extraReducers: (builder) => {

				// 성공
        builder.addCase(signupUser.fulfilled, (state, action) => {
						state.loading = 'succeeded';
            state.push(action.payload);
        });
				// 대기
				builder.addCase(signupUser.pending, (state, action) => {
						state.loading = 'pending';
        });
				// 실패
				builder.addCase(signupUser.rejected, (state, action) => {
						state.loading = 'failed';       
        });

        builder.addCase(auth.fulfilled, (state, action) => {
            state.push(action.payload);
        });
    },
});

export const { registerUser } = userSlice.actions;

export default userSlice.reducer;

 

상태관리 Atomic 패턴

복잡한 Flux 패턴에 관한 회의적인 시작으로 만들어진 패턴이다. 

 

카운터 보일러 플레이트, 엄청 간단해졌다.

import {atom } from 'recoil';

const counter = atom({
  key: 'myCounter',
  default: 0,
});

 

 

MVW 

react, vue의 component를 ViewModel로써 바라보는 것도 좋고, 상태관리를 위한 거대한 store를  model로 보고 react와 vue를 거대한 view로 바라보는 것도 좋고 서버 데이터를 model로 보는것도 좋다. 

 

Model-view-whatever, 중요한 것은 view와 model이다. 화면에 무엇을 보여줄 것인지와 어떤 데이터를 다뤄야하는지에 집중한다면 내 코드에 필요한 것이 구조를 깔끔하게 해줄 패턴인지, 상태관리 라이브러리인지, 데이터 패칭을 편하게 해주는 라이브러리인지 보일 것이다. 이것들 조합하여 충돌하지 않게 쓰면 된다.

 

whatever는 발전해왔고 앞으로도 발전할 것이다.