[LG CNS AM Inspire Camp 1기] 미니프로젝트 회고록

정성엽·2025년 3월 1일
0

LG CNS AM Inspire 1기

목록 보기
54/70
post-thumbnail

INTRO

이번 포스팅에서는 교육 과정에서 진행했던 미니프로젝트를 회고하는 포스팅을 작성해보려고 한다 👀


1. FE 디렉토리 구조 고민

우선 프로젝트를 시작하면서 맨 처음 고민했던 부분은 디렉토리 구조이다.

필자는 총 2명이서 프론트엔드 개발을 진행했는데, 각자 개발한 페이지를 간단하게 적용하고 싶었고, 일관된 구성으로 코드를 조금 더 깔끔하게 관리하고 싶었다.

그래서 라우팅 정보를 관리하는 파일과 공통 레이아웃을 적용하여 이러한 문제를 해결해보기로 결정했다.

💡 라우팅 파일

우선 코드를 살펴보자

Sample Code

// RouterInfo.jsx
export const routerInfo = createBrowserRouter([
  {
    path: "/",
    element: <DefaultLayout />,
    children: [
      {
        path: "",
        element: <Home />,
      },
      {
        path: "/profile",
        element: <Profile />,
      },
      {
        path: "/history",
        element: <History />,
      },
    ],
  },
  {
    path: "/login",
    element: <Login />,
  },
  {
    path: "/signup",
    element: <Signup />,
  },
  {
    path: "/padmin",
    element: <AdminLayout />,
    children: [
      {
        path: "",
        element: <AdminHome />,
      },
      {
        path: "manage-league",
        element: <AdminManageLg />,
      },
    ],
  },
]);

// DefaultLayout.jsx
const DefaultLayout = () => {
  return (
    <div className="default-layout-container">
      <div className="default-layout-navigation">
        <NavigationBar />
      </div>
      <div className="default-layout-contents">
        <Outlet />
      </div>
    </div>
  );
};

export default DefaultLayout;

// App.jsx 
function App() {
  return <RouterProvider router={routerInfo} />;
}

export default App;

이처럼 공통 레이아웃 파일을 만들어서 관리하고, 라우팅 정보는 별도 파일에 저장해서 코드의 복잡도를 낮추고 싶었다.

따라서, 만약 동료가 페이지를 개발했다면 RouterInfo.jsx 에 개발한 path와 element만 추가해주면 된다.

💡 컴포넌트 추상화 정도와 하위 컴포넌트 관리는 어떻게?

이전에 React Native를 사용하여 졸업 프로젝트를 진행했을 때, 추상화 단계를 엄청 높게 가져갔던 경험이 있다.

졸업 프로젝트 주제는 알람 어플리케이션이었고, 알람 생성 모달을 여러 군데에서 사용할 수 있도록 하기 위해 컴포넌트를 잘게 쪼개서 관리했었다.

그런데 이렇게 프로젝트를 진행해보니, 에러가 발생하면 어디에서 에러가 발생했는지 타고들어가 확인하는 과정이 너무 힘들고 번거로웠다.

그래서 이번 프로젝트에서는 추상화를 너무 깊게 가져가지 않고 최대한 간단하게 구현하려고 노력했다.

하지만, 하위 컴포넌트가 생성되는 것은 어쩔 수 없었다.

그렇다면 어디서 어떻게 하위 컴포넌트를 관리해야할까?

디렉토리 구조

사실 어떤게 정답인지는 모르겠으나, 필자는 이번 프로젝트에서 위 사진과 같이 컴포넌트를 관리하려고 시도했다.

pages 하위에는 페이지를 구성하는 각각의 디렉토리가 있다.

예를들어, pages/home 디렉토리에는 해당 페이지를 나타내는 메인 컴포넌트인 Home.jsx 가 있다.

다음으로 Home.jsx 에서 사용하는 하위 컴포넌트들은 pages/home/view 에서 관리한다.

실제로 필자가 개발한 화면을 보면서 생각해보자

이처럼 내부에서만 사용되는 컴포넌트는 특정 페이지 디렉토리의 ~/view 에서 관리하고, 외부에서도 사용될 수 있는 컴포넌트는 components/ 디렉토리에서 관리하도록 구조를 잡고 개발했다.

이렇게 개발해보니 조금 더 관리하기 편하다는 느낌이 들었다.


2. 로그인 관리

우선 이번 프로젝트에서는 소셜 로그인을 사용하지 않고, 커스텀 로그인을 구현하고 적용했다.

따라서, 사용자가 로그인을 진행할 경우 어떻게 페이지 인가를 결정해야할지 고민을 해봤다.

우리는 JWT의 Payload에 프론트에서 사용할 사용자의 정보를 넣어서 넘겨주기로 결정했다.

프론트에서는 JWT를 sessionStorage에 저장하고 이를 decoding하여 사용자 정보를 추출하여 사용하기로 결정했다.

따라서, 실제로 로그인을 진행하면 다음 사진처럼 저장되는 모습을 볼 수 있다.

Result View


해당 토큰 정보를 JWT.io 사이트에 들어가서 PayLoad를 살펴보면 다음과 같이 claim을 확인할 수 있다.

이 부분에서 role을 "ROLE_ADMIN"으로 바꿔서 위조한다면 어떻게될까?

Result View



이처럼 실제로 권한은 User이지만, sessionStorage에 저장된 토큰의 Role은 Admin이므로 접근이 가능한 모습을 볼 수 있다.

물론, API 호출을 수행하면 백엔드에서 실제 권한을 확인하기 때문에 버튼을 클릭해도 오류가 발생한다.

하지만, 이 자체만으로도 문제의 요인이 될 수 있다고 생각한다.

어떻게하면 이러한 문제를 해결할 수 있을까?

💡 ROLE은 ResponseBody로 받자

우선 지금은 프로젝트가 마무리된 상황이라 백엔드에서 코드를 즉각적으로 수정하고 반영하기는 어려운 상황이다.

(현재 테스트가 Eureka 서버와 백엔드 서버가 Docker-Compose로 묶여있다.
백엔드 코드를 pull로 당겨와서 로그인 부분을 수정하고 허브에 올린 이후, 해당 이미지로 빌드하도록 Docker-Compose도 수정했으나 오류가 발생한다..😂)

그래서 결론은 토큰의 Payload를 디코딩해서 사용하는 것이 아니라 ResponseBody에 데이터를 받아와서 전역으로 저장한 이후, 사용하는 방법이 더 적절해보인다는 것이다.

그래서 예시를 찾아보다가 Velog에서 어떻게하는지 살펴봤다.

Velog는 어떻게 처리할까?

  • 우선 Velog는 토큰 정보를 쿠키에 저장하고 사용한다.

  • 다음으로 마이페이지에 접근하면 graphql로 요청을 날릴때, 쿠키에 저장된 access-token을 헤더에 추가하여 요청을 보낸다.

  • response를 살펴보면 이처럼 프로필 정보를 ResponseBody에 담아와서 사용하게 된다.

  • 토큰 정보를 디코딩해보면 PayLoad에는 이처럼 암호화된 아이디만 제공되는 모습을 볼 수 있다.

위처럼 Velog의 로그인 처리를 살펴보니, 코드를 어떻게 리팩토링하면 좋을지 방향이 잡히는 것 같다.

만약, 리팩토링을 한다면 우리도 토큰의 PayLoad에는 암호화된 사용자의 아이디를 제공하여 토큰의 변조를 더욱 어렵게 만들 수 있을 것이다.

또한, 사용자의 정보는 ResponseBody로 받아서 관리하도록 수정할 수 있을 것이다.

마지막으로 관리자 페이지를 하나의 클라이언트 서버에서 관리하는 것이 아니라, 별도의 클라이언트 서버를 만들어서 관리한다면 접근을 더욱 엄격하게 통제할 수 있을 것이다.


3. API 호출

필자는 Nginx 위에 리액트의 빌드 파일들을 띄워서 이미지 경량화를 시도했다.

그 과정에서 여러가지 문제를 겪었는데 이 부분을 정리해보려고 한다.

💡 동적 라우팅 오류

우선 Docker 파일을 살펴보자

Sample Code

FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN npm install && \
    npm run build
   
FROM nginx:alpine
WORKDIR /app
COPY --from=builder /app/dist /usr/share/nginx/html
COPY default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

이처럼 builder로 부터 생성된 dist 디렉토리를 /usr/share/nginx/html 에 Copy하여 빌드된 결과물만 올리고 있다.

이렇게 Dockerfile을 생성한 이후, 실제로 네비게이션바에서 다른 페이지로 접근하는 테스트를 수행해보면 오류가 발생한다.
(당시에 사진을 저장안해서.. 참고 사진은 없다!)

찾아보니 Nginx가 해당 파일을 찾지 못해서 발생하는 문제였다.

이 부분은 위 도커 파일 기준으로 default.conf 파일을 생성하여 설정을 진행해주니 해결할 수 있었다.

그렇다면 default.conf 파일을 간단하게 살펴보자

Sample Code

server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
    ...

여기서 server 블록은 우리가 Nginx위에 올린 서버 관련 설정을 수행하는 블록이다.

보다시피, listen은 포트 번호 설정, server_name 설정, 그리고 기본 디렉토리를 /usr/share/nginx/html 로 설정해주고 index 파일도 지정해준다.

라우팅 문제를 해결하기 위해서는 어떻게 라우팅을 수행할지 결정해줘야 한다.

그 부분이 바로 location / { ... } 블록이다.

이 부분을 간단하게 정리해보자

location /

  • 앞으로 / 이 추가된 URI에 대해서는 다음 블록의 내용을 규칙으로 적용하겠다는 의미이다.

try_files $uri $uri/ /index.html;

  • 먼저 $uri 파일을 찾아본다.
  • 만약 매칭되는 파일이 없다면 $uri 이름을 갖는 디렉토리를 찾아본다.
  • 만약 매칭되는 파일이 없다면 server 블록에서 설정한 index.html 을 반환한다.

따라서, Nginx는 실제 파일을 먼저 찾고, 없으면 index.html을 제공한다.

index.html은 우리가 빌드한 파일이므로 SPA 어플리케이션의 클라이언트 측 라우터가 처리되도록 한다. (빌드된 정적 파일 제공)

Nginx 자체는 SPA의 라우팅 로직을 직접 처리하지 않고, 정적 파일 제공과 API 요청 프록시 역할만 수행한다.

이처럼 nginx 설정 파일을 생성하고 라우팅 설정을 추가하여 문제를 해결할 수 있었다!

💡 API 호출 오류

아마 이번 프로젝트에서 필자가 가장 많은 시간을 할애했던 에러 해결(?) 부분일 것이다.

우선 어떤 상황이었는지부터 살펴보자

Sample Code

export const jsonAPI = axios.create({
  baseURL: `http://${import.meta.env.VITE_REST_API_HOST}:${import.meta.env.VITE_REST_API_PORT}`,
  headers: {
    "Content-Type": "application/json",
  },
});

필자는 백엔드로 API 호출을 다음과 같이 환경변수를 통해 진행하고 있었다.

개발이 한창 진행중일 때는 백엔드 코드를 로컬에 받아와서 프론트와 백엔드 서버를 동시에 띄우고 API를 호출하는 식으로 테스트를 진행했다.

하지만, 어느정도 개발이 진행되고 나서 Dockerfile로 이미지를 빌드하고 Docker Compose 파일을 생성하면서 문제가 발생한다.

🧐 하드 코딩을 하기 싫다

위처럼 로컬에서 테스트를 진행할 때 사용한 baseURL은 Dockerfile로 이미지를 빌드하는 시점에 정의된다.

위처럼 사용하기 위해서는 Docker Compose 파일을 생성하는 시점에 백엔드 주소를 환경변수로 세팅해서 처리해야 한다.

그런데 필자는 여기서 2가지 고집(?)이 생긴다.

  1. Docker Compose 파일을 이용하여 빌드할 때, Docker Hub에서 이미지를 불러와서 사용한다. (nginx에 빌드된 파일만 올라간 경량화버전)
  2. 하드코딩을 하지 않는다.

우선 여러가지 처리할 수 있는 방법을 살펴보자


1. Github 주소를 이용하여 빌드

Docker Compose 파일을 작성할 때, Github에 올라간 파일을 사용하여 빌드하는 방법이다.

Docker Compose 파일을 작성해보면 다음과 같다.

backend:
	...
frontend:
    build:
      context: 깃허브 주소
      args:
        VITE_REST_API_HOST: backend
        VITE_REST_API_PORT: 8090

이처럼 Docker Compose 파일을 빌드하는 시점에 깃허브 주소를 사용해서 빌드를 할 수 있다.

하지만, 빌드된 결과물만 올리고싶고 실제로 깃허브 주소를 사용하는 방법은 좋은 방법이 아니라고 한다.

예를들어서 깃허브 레포지토리가 private로 설정되면 가져올 수 없는 문제가 발생하기도 한다.

또한, 직관적으로 생각하더라도 버전별로 관리되는 이미지를 사용하여 빌드하는 것이 개발 버전을 관리하기에는 더욱 용이할 것이다.


2. 네트워크 설정 & baseURL 수정

Docker Compose 파일을 작성할 때, 백엔드와 프론트에 동일한 네트워크를 설정해준다.

(당연히 컨테이너 사이에서 통신을 수행하기 위해서는 필수적으로 networks를 설정해줘야하므로 위 예시에서도 동일하게 네트워크를 설정해줬다!)

그렇게 설정하면 다음과 같이 코드를 작성해도 문제가 없다.

Sample Code

export const jsonAPI = axios.create({
  baseURL: "http://localhost:8090",
  headers: {
    "Content-Type": "application/json",
  },
});

  backend:
    image: kdh19/springimg:0.0.8
    container_name: backend_container
    ...
    ports:
      - "8090:8090"
    networks:
      - my_network

  front:
    image: jjabc3758/sports-news:0.0.15
    container_name: front_container
    restart: always
    depends_on:
      - backend
    ports:
      - "1000:80"
    networks:
      - my_network

포트번호는 사전에 백엔드 파트와 협의하면 지정할 수 있으며, 동일한 네트워크로 묶여있으므로 localhost로 backend host 주소를 설정하면 정상적으로 API 호출이 가능하다!


3. Nginx 프록시 설정

이전에 default.conf 파일을 생성하고 nginx 설정을 추가하여 라우팅 문제를 해결해봤다.

마찬가지로 Docker Compose를 실행해서 컨테이너를 띄운다면 실행되는 환경은 nginx이므로 nginx의 환경 설정을 추가해주면 된다.

이 때, API 호출이 특정 네트워크를 거쳐서 나가도록 conf 파일을 설정해주고 Dockerfile로 이미지를 빌드하여 허브에 올려두면 conf 파일에 의해 정의된 내용대로 프록시가 동작하여 API 호출이 진행된다.

설정 파일은 다음과 같이 작성할 수 있다.

Sample Code

server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;

	...

    location ~* ^/(openai|football|admin|user|login) {
        proxy_pass http://backend:8090;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

이렇게 설정하면 프론트엔드에서 /openai , /football , /admin , /user , /login 경로로 들어오는 모든 요청을 자동으로 백엔드 서비스로 프록시해준다.

여기서 중요한 부분은 proxy_pass http://backend:8090 인데, 이것은 Docker 네트워크 내에서 백엔드 컨테이너의 이름을 사용하여 요청을 전달한다는 의미다.

이처럼 실제로 러닝타임에 실행되는 것은 Nginx이므로 백엔드 컨테이너의 이름을 사용하여 프록시 패스를 설정해주면 특정 API 호출이 프록시를 통해서 백엔드로 전송될 수 있도록 할 수 있다!

이 방식을 사용하면 프론트엔드 코드에서는 단순히 /user/login과 같은 상대 경로로 API를 호출하면 되고, Nginx가 알아서 올바른 백엔드 서비스로 요청을 전달해준다.

이 방법의 장점은 프론트엔드 코드에서 백엔드의 URL을 하드코딩할 필요가 없고, 환경이 변경되더라도 Nginx 설정만 수정하면 된다는 점이다.

또한 모든 요청이 동일 출처에서 발생하므로 CORS 문제도 자연스럽게 해결된다.

필자는 최종적으로 이러한 방식으로 하드코딩을 하지 않고 허브의 이미지를 불러와서 컨테이너를 띄울 수 있도록 Dockerfile과 conf 파일을 설정하여 문제를 해결해봤다.


OUTRO

이번에 진행했던 미니 프로젝트를 수행하면서 겪은 문제를 정리해봤다.

처음에는 단순하게 생각했던 Docker 환경 구성이 생각보다 여러 고려사항과, 그에 따른 해결책을 찾는 과정이 필요했다.

특히 프론트엔드와 백엔드 간의 통신 문제는 몇 가지 다른 접근법들을 시도해볼 수 있는 좋은 경험이었다.

사실 개발 과정에서 자잘한 오류들은 훨씬 많았지만, 개발하면서 했던 여러 고민들을 크게 세 가지 주제로 정리해봤다.

Docker 이미지와 빌드 설정, 컨테이너 간 네트워크 통신, 그리고 Nginx 프록시 설정은 실제 프로덕션 환경에서도 자주 마주치게 될 문제들이라 이번 경험이 값진 경험이라고 생각한다.

예상했던 것보다 설정이 복잡했지만, 덕분에 Docker와 컨테이너 네트워킹에 대한 이해도가 높아졌고, 프론트엔드와 백엔드를 연결하는 다양한 방법에 대해 깊이 생각해볼 수 있었다.

미니 프로젝트 결과물도 괜찮고, 나름 개발하면서 여러 고민을 하며 성장할 수 있는 계기가 되었던 것 같다.

재밌었다 👊

SPONEWS Github


📖 참고
Nginx 새로고침 시 동적 라우팅 안되는 문제

profile
코린이

0개의 댓글