django boiler plate 를 잘 활용하고 있었습니다. 템플릿에서 Static 파일에 대해서 절대경로를 사용 했습니다. 서버에서 운영을 할 때에는 Nginx 에서 Reverse Proxy 설정을 활용 했습니다.

이번에 중형 프로젝트를 진행 하면서 React Code Split 내용이 추가 되었습니다. 앞의 내용대로 진행한 결과 index.min.js 까지는 정상적으로 인식 했습니다.

하지만 연결된 파일들과 관련하여 React 또는 Vite.js 내부의 설정 오류로 인하여 실제 존재하는 경로와 다른 위치에서 파일들을 찾고 있었고, 이러한 Static 내부의 문제점을 배포파일이 여러개로 생성이 된 후, 그리고 nginx 서버에서 직접 운영을 한 뒤에서야 확인할 수 있었습니다.

이번 페이지 에서는 Django 와 Vite.js 연결에 있어서 Static 설정과 관련된 개념들과 작업 내용에 대해서 중점적으로 살펴보겠습니다. GitHub : Django-Vite-React-TS 저장소에 아래 내용이 모두 반영된 소스코드를 올려 놓았습니다.


Initialized

Tutorials

전체적인 내용은 아래의 동영상을 참고 하였습니다.

Integrate React in Django

Installation

Vite.js Getting StartedDjango 첫 번째 장고 앱 작성하기 Part1 내용을 참고 하였습니다.

$ python3.11 -m venv .venv
$ cd .venv
$ . bin/activate
$ cd ..
$ mkdir django
$ cd django
$ pip install Django
$ django-admin startproject server .
$ cd ..

$ yarn create vite react --template react-ts
$ cd react
$ yarn install
$ tree -d
.
├── django
│   └── server
└── react
    ├── node_modules
    ├── public
    └── src
        └── assets


Django & Vite.js

Django 서버에 vite.js 를 연결하여 활용하고 있습니다. 다른 내용에서는 Django 와 React 를 구동하는 서버를 별개로 구성하는 경우가 많습니다. 이러한 방법은 Django 1개의 서버로 운영할 때 보다 장점이 많은데, 대표적인 예로 백엔드 서버를 점검 및 작업을 위해 백업서버로 연결을 할 때, 서비스 중단을 최소화 하며 빠르게 작업을 진행할 수 있습니다. 이번 내용에서는 Django 서버 1개만을 사용하여 배포 서비스를 구현하는 것을 목표로 합니다.

Django Setting

Django 설치 후 기본내용은 다음과 같습니다.

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []

DEBUG = True 일 때에는 위 설정 만으로도 정상동작 합니다. DEBUG = False 로 변경 후 실행하면 다음과 같은 메세지를 출력 합니다.

CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False.

DEBUG = False 옵션은 서버에서 배포 하는 상황 입니다. 때문에 배포를 하는 서버가 사용자 설정값과 동일한지 점검하는데 위에서 표시된 ALLOWED_HOSTS 에 대한 내용을 실행환경에 맞게 내용을 입력 합니다.

ALLOWED_HOSTS = [
    '0.0.0.0',
    '127.0.0.1',
    'localhost',
    '.localhost', 
    '127.0.0.1', 
    '[::1]'
]

Django Template

리액트 프로젝트 내부에 생성된 템플릿 파일을 보면 다음과 같습니다.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="django"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

위 탬플릿은 yarn dev 로 리액트 프로젝트를 실행할 때 사용하는 파일 입니다. Backend Integration 내용을 참고하여 Django 의 Template 내용에 다음의 내용을 추가 합니다.


<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>mysite</title>
  </head>
  <body>
    <div id="root"></div>

    <!-- if development -->
    {% if debug %}
      <h1>Development Mode</h1>
      <script type="module" src="http://localhost:5173/@vite/client"></script>
      <script type="module">
        import RefreshRuntime from 'http://localhost:5173/@react-refresh'
        RefreshRuntime.injectIntoGlobalHook(window)
        window.$RefreshReg$ = () => {}
        window.$RefreshSig$ = () => (type) => type
        window.__vite_plugin_react_preamble_installed__ = true
      </script>
      <script type="module" src="http://localhost:5173/src/main.tsx"></script>

    <!-- production mode -->
    {% else %}
      <h1>Production Mode</h1>
    {% endif %}

    {% block content %}
    {% endblock content %}
  </body>
</html>


DEBUG=True 일 때 동작하는 미들웨어가 “django.template.context_processors.debug” 입니다. 이때 추가로 INTERNAL_IPS 설정 내용에 현재 동작하는 환경설정 값을 입력해야만 debug 값이 템플릿에서 정상작동 됩니다. 보다 자세한 내용은 How to check the TEMPLATE_DEBUG flag in a django template? 를 참고하시면 됩니다.

# settings.py
INTERNAL_IPS = [
    "127.0.0.1",
    'localhost',
]

위 내용들이 정상적으로 입력 되었다면, DEBUG=True 일 때에는 리액트 내용을 화면에 출력하고, DEBUG=False 일 때에는 문자만 출력 합니다.

Vite.config.ts

이번 단계부터는 서버에서 운영에 필요한 내용을 살펴보겠습니다. vite.config.js 파일을 열고 다음의 내용을 추가 합니다. 참고로 vscode 에서 작업을 하면 es-lint 의 점검 결과 (file) 부분에 Disable warning - Defined but never used 오류표시를 출력합니다. 이 메세지를 비활성화 하려면 eslint-disable 내용을 추가 하면 됩니다.

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react({
    exclude: /\.stories\.(t|j)sx?$/,
    include: '**/*.tsx',
  })],
  publicDir: './public',
  /* eslint-disable */
  build: {
    rollupOptions: {
      output: {
        assetFileNames: (file) => {
          return `assets/css/index.min.css`
        },
        entryFileNames: (file) => {
          return `assets/js/[name].min.js`
        }
      }
    }
  }
  /* eslint-enable */
})

favicon 등의 기본 이미지 파일으 추가와 함께 Hash 값을 추가하는 설정 방법을 찾았는데 내용은 다음과 같습니다. 보다 자세한 설명 및 내용은 How do I add an images/css/js folder to the build/dist folder? 을 참고 하였습니다.

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react({
    exclude: /\.stories\.(t|j)sx?$/,
    include: '**/*.(ts|tsx)',
  })],
  publicDir: './public',
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: 'assets/js/[name]-[hash].js',
        entryFileNames: 'assets/js/[name]-[hash].js',
        assetFileNames: ({name}) => {
          if (/\.(gif|jpe?g|png|svg)$/.test(name ?? '')){
              return 'assets/images/[name]-[hash][extname]';
          }
          if (/\.css$/.test(name ?? '')) {
              return 'assets/css/[name]-[hash][extname]';   
          }
          return 'assets/[name]-[hash][extname]';
        },
      },
    }
  }
})

이렇게 작업을 하면 vite.js 에서 빌드된 파일들은 BASE_URL/assets/index-hash값.js 에서 경로를 확인 합니다. 하지만 django 설정상 STATIC 주소를 앞에 추가하게 되는데 이처럼 배포시 기본이 되는 URL 주소가 달라지는 경우 해당 주소값을 보정해 주는 base : 설정값을 추가 하면 됩니다.

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react({
    exclude: /\.stories\.(t|j)sx?$/,
    include: '**/*.(ts|tsx)',
  })],

 (+) base: '/static',

  publicDir: './public',
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: 'assets/js/[name]-[hash].js',
        entryFileNames: 'assets/js/[name]-[hash].js',
        assetFileNames: ({name}) => {
          if (/\.(gif|jpe?g|png|svg)$/.test(name ?? '')){
              return 'assets/images/[name]-[hash][extname]';
          }
          if (/\.css$/.test(name ?? '')) {
              return 'assets/css/[name]-[hash][extname]';   
          }
          return 'assets/[name]-[hash][extname]';
        },
      },
    }
  }
})

WhiteNoise

Using WhiteNoise with Django 은, Dev 모드와 Posting 모드일 때 Static 파일들을 자동으로 연결 및 설정을 돕는 파이썬 모듈 입니다. 배포파일의 압축 및 캐시활용 그리고 경로 자동완성 까지 모든 내용을 통합 관리합니다.

Static Files in Development Mode

지금까지 Nginx 에서 담당했던 역활을 Django 에서 담당하는데 있어서 문제가 있지 않을까 생각이 들었지만, Reddit : Is using Whitenoise instead of Nginx good enough for production? 내용을 확인해본 결과 운영에서도 큰 차이가 없는 것으로 이야기가 되고 있었습니다.

설정방법은 공식문서에 자세히 나와 있어서 이를 참고하여 작성 하였습니다.

MIDDLEWARE = [
  'django.middleware.security.SecurityMiddleware',
  "whitenoise.middleware.WhiteNoiseMiddleware",
  ...
]
# https://whitenoise.readthedocs.io/en/latest/django.html
STORAGES = {
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}
CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
        "LOCATION": "/var/tmp/django_cache",
    }
}

# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
# for development
# staticfiles 작업기준 폴더
if os.path.exists('../react/dist'):
    STATICFILES_DIRS = [(BASE_DIR.joinpath("../react/dist/")),]
else:
    STATICFILES_DIRS = [(BASE_DIR.joinpath("../react/public/")),]
# for publishment
STATIC_ROOT = BASE_DIR.joinpath("staticfiles")

WhiteNoise 설정 내용을 추가한 뒤 배포환경에서 작동하는지 확인해 보았습니다. 아래의 내용들을 보면 리액트에서 빌드된 파일을 운영에 활용할 수 있도록 다양한 형태로 생성하는 것을 확인할 수 있습니다.

$ tree -l
.
├── assets
│   ├── css
│   │   └── index-d526a0c5.css
│   ├── images
│   │   └── react-35ef61ed.svg
│   └── js
│       └── index-4bbacb4a.js
├── index.html
└── vite.svg


$ ./manage.py collectstatic

You have requested to collect static files at the destination
location as specified in your settings:

  /django/staticfiles


$ ../staticfiles ➭ tree -l
.
├── assets
│   ├── css
│   │   ├── index-d526a0c5.6c5c661e3e16.css
│   │   ├── index-d526a0c5.6c5c661e3e16.css.gz
│   │   ├── index-d526a0c5.css
│   │   └── index-d526a0c5.css.gz
│   ├── images
│   │   ├── react-35ef61ed.f0402b67b6ce.svg
│   │   ├── react-35ef61ed.f0402b67b6ce.svg.gz
│   │   ├── react-35ef61ed.svg
│   │   └── react-35ef61ed.svg.gz
│   └── js
│       ├── index-4bbacb4a.618d971eb1fe.js
│       ├── index-4bbacb4a.618d971eb1fe.js.gz
│       ├── index-4bbacb4a.js
│       └── index-4bbacb4a.js.gz
├── index.19b724a8a298.html
├── index.19b724a8a298.html.gz
├── index.html
├── index.html.gz
├── staticfiles.json
├── vite.8e3a10e157f7.svg
├── vite.8e3a10e157f7.svg.gz
├── vite.svg
└── vite.svg.gz

Django Template

빌드된 파일값에 Hash 값들이 추가되어 있습니다. 어떤 파일이 중요한지 확인해 보기 위해서 staticfiles/index.html 파일을 한 번 열어보겠습니다.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
    <script type="module" crossorigin src="/assets/js/index-20bd6020.js"></script>
    <link rel="stylesheet" href="/assets/css/index-d526a0c5.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

assets/js/index-20bd6020.js 파일과 assets/css/index-d526a0c5.css 파일이 중심이 되는것을 확인할 수 있습니다. 이 내용을 Django 의 Template 파일에 추가 합니다.


<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>mysite</title>
  </head>
  <body>
    <div id="root"></div>

    <!-- if development -->
    {% if debug %}
      <h1>Development Mode</h1>
      <script type="module" src="http://localhost:5173/@vite/client"></script>
      <script type="module">
        import RefreshRuntime from 'http://localhost:5173/@react-refresh'
        RefreshRuntime.injectIntoGlobalHook(window)
        window.$RefreshReg$ = () => {}
        window.$RefreshSig$ = () => (type) => type
        window.__vite_plugin_react_preamble_installed__ = true
      </script>
      <script type="module" src="http://localhost:5173/src/main.tsx"></script>

    <!-- production mode -->
    {% else %}
      <script type="module" crossorigin src="{% static 'assets/js/index-20bd6020.js' %}"></script>
      <link rel="stylesheet" href="{% static 'assets/css/index-d526a0c5.css' %}">
    {% endif %}

    {% block content %}
    {% endblock content %}
  </body>
</html>


작업 후 서버에서 500 오류를 출력하는 경우는 JS 파일 이름이 다르게 입력된 경우 였습니다.

그리고 정상적으로 보이거나 일부분만 랜더링 되는 상태에서 Console 창에서 다음과 같은 오류를 출력하는 경우 있었습니다.

DOMException: Node.removeChild: The node to be removed 
is not a child of this node 
index-20bd6020.87dd29b3accb.js:40:161

Uncaught DOMException: Node.removeChild: The node to be 
removed is not a child of this node 
index-20bd6020.87dd29b3accb.js:40

React DOMException: Failed to execute ‘removeChild’ on ‘Node’: The node to be removed is not a child of this node 내용이 도움이 되었는데, 이유는 <React.Fragment>...</React.Fragment> 또는 <>...</> 태그를 <div></div> 로 변경함으로써 해결 되었습니다.

참고로 이 오류는 Vite.js DEV 서버에서는 발생하지 않았고, 빌드된 이후에 발생하였습니다. 이점에서 생각해 볼때 WhiteNoise 에서 압축을 하면서 발생한 문제로 예상되었고, 다음의 설정을 비활성화 한뒤 실행해본 결과 오류없이 정상 작동하는 것을 볼 수 있었습니다.

# settings.py
STORAGES = {
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
    },
}

Custom Management Commands

Vite.js 에서 빌드된 index.html 파일을 열어서 index.js, index.css 정보를 찾아서 Django 의 템플릿에 내용을 직접 입력하는 과정을 앞에서 살펴보았습니다. 수정을 할 때마다 이와같은 작업을 반복하게 되는데 Django Commands 를 활용해 보겠습니다.

.
├── core
│   ├── management
│   │   └── commands
│   │       ├── build.py
│   │       └── __init__.py
│   └── templates
└── staticfiles
    └── assets

위에 표시된 내용처럼 core 앱 내부에 폴더들을 추가한뒤 build.py 파일에 다음의 내용을 추가합니다. file_djangoDjango Template 의 경로를 입력하고 file_buildVite.js 빌드된 템플릿 경로를 사용자 환경에 맞춰 입력하면 됩니다.


import os
import re
from django.core.management.base import (
    BaseCommand, CommandParser
)


class Command(BaseCommand):

    r"""React Build & Django Template Auto Edit"""
    message     = 'building js & css copy to html\n'
    file_django = './core/templates/base.html'
    file_build  = './staticfiles/index.html'

    def add_arguments(self, parser: CommandParser) -> None:
        return super().add_arguments(parser)

    def handle(self, *args, **options):

        # Pre Processing
        build_data    = {}
        build_targets = {'js':"/assets/",'css':"/assets/"}
        if os.path.exists(self.file_build) == False:
            self.stdout.write(f"{self.file_build} is not existed ...")
            return None

        # Process 1 : 빌드된 파일에서 필요한 정보찾기
        with open(self.file_build, 'r') as f:
            texts = f.readlines()

        tokenizer = re.compile(r'assets/[tjscx]+/[A-z0-9\-\.]+.[tjscx]+')
        for text in texts:
            for file_type, hint in build_targets.items():
                if (text.find(hint) != -1) & (text.find(f".{file_type}") != -1):
                    text = "".join(tokenizer.findall(text))
                    build_data[file_type] = text

        # Process 2 : Django Template 에 추출한 내용 입력하기
        with open(self.file_django, 'r') as f:
            texts = f.readlines()

        result_list  = []
        result_text  = self.message
        for text in texts:
            for _type in ['css', 'js']:
                if (text.find("{% static") != -1) & (text.find(f".{_type}") != -1):
                    check = "".join(re.findall(r'assets/[jstcx]+/[.A-z0-9\-\.]+', text))
                    text  = re.sub(check, build_data[_type], text)
                    result_text += f"{check} => {build_data[_type]}\n"
            result_list.append(text)

        # 결과값 저장하기 & 결과 메세지 출력
        with open(self.file_django, 'w') as f:
            f.write("".join(result_list))
        self.stdout.write(result_text)

이처럼 작업을 완료하고 나면, 아래의 내용처럼 실행 명령어를 확인해 볼 수 있습니다.

$ ./manage.py help     
Type 'manage.py help <subcommand>' for help on a specific subcommand.
Available subcommands:

[core]
  build


Appendix

(2023-08-29) vite’s HMR does not work when I use React.lazy() API for lazyload

vite dev server 환경에서 작업을 하던 중, 어떤 내용이 추가되면 다음과 같은 메세지를 출력 하면서 HMR 모드가 작동되지 않았습니다.

00:00:00 PM [vite] hmr invalidate /src/Content/chart.tsx Could not Fast Refresh. Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports

이유는 다음의 내용에서 찾을 수 있었는데 vite’s HMR does not work when I use React.lazy() API for lazyload 내용은 다음과 같습니다.

메세지로 안내하는 컴포넌트 내부에서 function() 의 오동작으로 인하여 react-refresh 모듈의 lazy() 함수에서 정상적으로 인식을 하지 못하여 발생한 문제였습니다. 위 작업에서는 @fake 모듈에서 메서드가 변경되어 발생한 경고메세지에 따라, 새로운 메서드를 적용하고 난 뒤에 이같은 문제가 해결되었습니다.


참고사이트