Go와 React로 만드는 실시간 채팅 서비스 개발기 (2) - 프론트엔드 제작 및 컨테이너화 과정

Administrator||조회수 47

alt text

React와 Tailwind CSS로 살아있는 UI 만들기

앞선 1부에서는 Go를 사용하여 실시간 통신의 핵심 로직을 담당할 백엔드 서버를 구축했다. 동시성 문제를 해결하기 위한 Hub 패턴 적용부터, JWT를 이용한 상태 비저장 인증, Rate Limiter를 통한 서비스 보호까지, 견고한 서버의 기반을 마련했다.

2부에서는 이 강력한 백엔드 엔진 위에 사용자가 실제로 보고 상호작용할 수 있는 프론트엔드 UI/UX를 구축하는 과정을 기록할 예정이다. React와 Vite를 기반으로, Tailwind CSS를 이용해 디자인 시스템을 구축하고, Zustand로 상태를 관리하며 마주쳤던 여러 문제와 해결 과정을 상세히 다룬다.

디자인 시스템의 첫걸음: Tailwind CSS 환경 구축

본격적인 UI 개발에 앞서, 일관되고 효율적인 스타일링 환경을 구축하는 것이 중요했다. 이전에 진행했던 심리상담 챗봇 프로젝트 "챗라스틱" 에서는 단일 CSS 파일을 사용했지만, 프로젝트 규모가 커짐에 따라 발생할 수 있는 클래스 이름 충돌이나 유지보수의 어려움을 해결하기 위해 Tailwind CSS를 도입하기로 결정했다.

  1. Tailwind CSS v3 설치 및 설정: pnpm을 사용하여 tailwindcss, postcss, autoprefixer를 개발 의존성으로 설치했다. pnpm tailwindcss init -p 명령어로 tailwind.config.jspostcss.config.js 설정 파일을 생성했다. tailwind.config.jscontent 배열에는 Tailwind가 스타일을 스캔할 파일 경로('./src/**/*.{js,ts,jsx,tsx}')를 명시했다.

  2. 디자인 시스템 정의: 이전 챗라스틱 프로젝트에서 사용했던 CSS 색상 변수들을 tailwind.config.jstheme.extend.colors 객체로 옮겨왔다. 이를 통해 #f8fbfd와 같은 16진수 코드 대신, bg-background, text-text-dark와 같이 의미 있는 이름의 유틸리티 클래스를 프로젝트 전반에 걸쳐 일관되게 사용할 수 있는 기반을 마련했다. 또한, Google Fonts의 'Noto Sans KR'을 기본 폰트로 설정하여 전체적인 타이포그래피의 통일성을 확보했다. alt text

UI 아키텍처 설계: 컴포넌트와 상태 관리

  1. 컴포넌트 기반 설계: UI를 재사용 가능하고 독립적인 단위로 분리하기 위해 컴포넌트 기반 아키텍처를 채택했다. Layout, ChatRoom, Header, MessageList, MessageItem, MessageInput, Avatar 등 각자의 역할이 명확한 컴포넌트들로 UI를 구조화했다. 이는 코드의 복잡도를 낮추고, 각 부분의 개발 및 테스트를 용이하게 만들었다.

  2. 중앙 상태 관리 (Zustand): React의 props drilling 문제를 피하고, 여러 컴포넌트에서 공유해야 할 전역 상태(메시지 목록, 인증 정보, 프로필 등)를 효율적으로 관리하기 위해 경량 상태 관리 라이브러리인 Zustand를 도입했다. 또한, 코드의 안정성을 높이기 위해 src/types/chat.ts 파일을 생성하여, Message, UserProfile 등 프로젝트 전반에서 사용될 핵심 데이터 타입을 중앙에서 관리하도록 했다. Zustand 스토어는 이 공유 타입을 기반으로 상태와 액션을 정의하여, 타입 불일치로 인한 오류를 사전에 방지했다. alt text

핵심 기능 구현 및 디버깅 과정

백엔드와 실제 데이터를 주고받으며 실시간 통신 기능을 구현하는 과정에서 몇 가지 중요한 문제들을 마주쳤고, 이를 해결하며 시스템의 안정성을 높일 수 있었다.

  1. CORS (Cross-Origin Resource Sharing) 문제 해결: localhost:5173에서 실행되는 프론트엔드가 localhost:8080의 백엔드 API로 요청을 보낼 때, 브라우저의 동일 출처 정책(Same-Origin Policy)으로 인해 404 Not Found (OPTIONS 요청) 오류가 발생했다. 이는 gin-contrib/cors 미들웨어를 백엔드에 추가하고, 개발 환경에서는 모든 출처를 허용(AllowAllOrigins: true)하도록 설정하여 해결했다. 이 과정을 통해 웹 브라우저의 보안 모델과 Preflight Request의 동작 원리를 명확히 이해할 수 있었다.

  2. 실시간 메시지 처리 로직 안정화: 초기 구현에서 가장 까다로웠던 부분은 메시지 중복과 데이터 불일치 문제였다.

    • 메시지 중복 현상: 내가 보낸 메시지가 내 화면에 두 번(내가 보낸 것, 서버가 되돌려준 것) 표시되는 문제가 발생했다. 이는 '낙관적 업데이트'와 '서버 에코'가 충돌했기 때문이었다.
    • 빈 말풍선 현상: 메시지 말풍선은 나타나지만 내용이 비어있는 문제가 있었다. 체계적인 디버깅을 통해, 백엔드가 보내는 JSON 데이터의 timestamp 필드 누락과, 프론트엔드 컴포넌트(MessageItem.tsx)에서 message.content 대신 message.text를 참조하고 있던 불일치를 발견했다.

    이 문제들은 '서버가 항상 진실' 이라는 원칙을 적용하여 해결했다. 프론트엔드의 낙관적 업데이트 로직을 제거하고, 모든 메시지(내가 보낸 것 포함)를 서버로부터 수신된 onmessage 이벤트를 통해서만 스토어에 추가하도록 데이터 흐름을 단순화했다. ZustandaddMessage 액션에서는 수신된 메시지의 senderId와 현재 사용자의 anonymousId를 비교하여 isMe 속성을 동적으로 부여함으로써, UI가 발신자에 따라 올바른 스타일(좌/우 정렬, 색상)을 적용하도록 했다.

UI/UX 폴리싱: 디테일의 완성

핵심 기능이 안정화된 후, 사용자 경험의 완성도를 높이기 위한 몇 가지 디테일을 추가했다.

  • 통합 프로필 시스템: 백엔드가 anonymousId를 기반으로 생성해주는 랜덤 닉네임과 그에 연결된 아바타 정보를 JWT에 담아 전달하면, 프론트엔드는 이 정보를 Zustand 스토어에 저장하고 Avatar 컴포넌트를 통해 헤더와 각 메시지 말풍선에 일관되게 표시하도록 했다.
  • 인터랙션 개선: date-fns를 사용하여 타임스탬프를 "오후 3:15"와 같은 친숙한 형식으로 포맷팅하고, react-icons로 전송 버튼을 아이콘으로 교체했다. 또한, textarea의 자동 높이 조절, 'Enter' 키 전송, Shift+Enter 줄바꿈 기능을 구현하여 사용 편의성을 높였다.
  • 시각적 효과: Tailwind CSS의 커스텀 애니메이션 기능을 사용하여, 새로운 메시지가 나타날 때 부드럽게 '슬라이드-인' 되는 효과를 추가했다. 이 과정에서 애니메이션으로 인해 가로 스크롤바가 깜빡이는 문제를 overflow-x-hidden 속성으로 해결하며 CSS 레이아웃에 대한 이해를 높였다. 마지막으로, 대화 도중 날짜가 바뀔 때마다 날짜 구분선을 표시하는 기능을 추가하여 가독성을 향상시켰다.

alt text

다음 단계

이것으로 백엔드와 통신하며 사용자에게 동적인 경험을 제공하는 프론트엔드 UI/UX 개발을 마쳤다. 이제 우리는 로컬 개발 환경(pnpm dev)에서 완벽하게 동작하는, 기능적으로나 시각적으로 완성도 높은 실시간 채팅 애플리케이션의 "소스 코드"를 갖게 되었다.

하지만 이 상태는 아직 "내 컴퓨터에서만 되는" 반쪽짜리 완성에 불과하다. 다른 개발자의 컴퓨터나 앞으로 배포하게 될 클라우드 서버는 OS, 설치된 런타임(Go, Node.js) 버전이 모두 다를 수 있기 때문이다.

다음 단계에서는 이러한 '환경 의존성' 문제를 해결하고, 우리 프로젝트를 어떤 환경에서든 동일하게 실행되는 이식성 높은 애플리케이션으로 만드는 '컨테이너화(Containerization)' 과정을 기록한다. Docker를 사용하여 백엔드와 프론트엔드를 각각의 '컨테이너 이미지'로 패키징하고, Docker Compose로 전체 스택을 통합하여 실행하는 과정을 다룰 예정이다.

컨테이너화의 필요성: 왜 Docker인가?

소프트웨어를 개발할 때 마주치는 가장 고전적인 문제는 "제 컴퓨터에서는 잘 되는데요?"라는 말로 대표되는 환경 불일치 문제다. Docker는 이 문제를 해결하기 위해 애플리케이션과 그 실행에 필요한 모든 것(코드, 런타임, 라이브러리, 설정 등)을 '컨테이너' 라는 격리된 표준 단위로 패키징한다.

따라서, 컨테이너는 Docker가 설치된 곳이라면 어디서든 (내 PC, 동료의 Mac, 클라우드 서버 등) 항상 동일하게 동작하는 것을 보장한다.

백엔드 컨테이너화 (Go)

백엔드 서버를 컨테이너 이미지로 만들기 위해, apps/backend 디렉토리에 Dockerfile을 작성했다. 이때 이미지 크기를 최소화하고 보안을 강화하기 위해 '멀티스테이지 빌드(Multi-stage Build)' 기법을 적용했다.

  1. 빌더(Builder) 스테이지: golang:1.25-alpine과 같이 Go 컴파일러가 포함된 무거운 이미지를 기반으로, 소스 코드를 컴파일하여 server라는 이름의 단일 실행 파일을 생성했다.
  2. 최종(Final) 스테이지: alpine:latest와 같은 초경량 Linux 이미지 위에, 빌더 스테이지에서 생성된 server 실행 파일 딱 하나만 복사해 담았다.

이 과정을 통해, 수백 MB에 달하는 Go 개발 도구들이 모두 제거되고, 최종적으로 15MB 내외의 매우 작고 효율적인 이미지를 얻을 수 있었다.

alt text

컨테이너 실행 시, 로컬의 .env 파일을 직접 사용하는 대신 --env-file 옵션을 통해 파일의 내용을 '환경 변수'로 컨테이너에 주입하는 방식을 사용했다. Go 코드에서는 godotenv.Load()가 실패하더라도 프로그램을 중단하지 않고 os.Getenv()를 통해 환경 변수를 읽도록 수정하여, 로컬 개발 환경과 컨테이너 환경 모두에서 유연하게 동작하도록 개선했다.

프론트엔드 컨테이너화 (React + Nginx)

프론트엔드 역시 멀티스테이지 빌드를 적용하여 최적화했다.

  1. 빌더(Builder) 스테이지: node:22-alpine 이미지 위에서 pnpm installpnpm build를 실행하여, React 애플리케이션을 순수 정적 파일(HTML, CSS, JS) 묶음인 dist 디렉토리로 빌드했다.
  2. 최종(Final) 스테이지: Node.js가 전혀 필요 없는 가벼운 nginx:alpine 웹 서버 이미지 위에, 빌더 스테이지에서 생성된 dist 폴더의 내용물만 복사했다.

또한, 클라이언트 사이드 라우팅 시 발생할 수 있는 404 오류를 방지하기 위해, 모든 요청을 index.html로 되돌려주는 nginx.conf 설정 파일을 작성하여 이미지에 함께 포함시켰다.

alt text

전체 스택 통합: Docker Compose

이제 각각 독립적으로 실행 가능한 backendfrontend 이미지를 함께 오케스트레이션하기 위해, 프로젝트 루트에 docker-compose.yml 파일을 작성했다.

이 파일에는 다음과 같은 내용을 선언적으로 정의했다.

  • services: backendfrontend라는 두 개의 컨테이너 서비스를 정의했다.
  • build: 각 서비스가 어떤 Dockerfile을 사용하여 이미지를 빌드해야 하는지 경로를 지정했다.
  • ports: 컨테이너 내부의 포트와 내 컴퓨터(호스트)의 포트를 연결했다. (예: frontend80 포트 -> localhost:5173)
  • depends_on: frontend 서비스가 backend 서비스에 의존함을 명시하여, 항상 백엔드가 먼저 실행되도록 보장했다. alt text

마지막으로, 컨테이너 환경에서의 네트워크 통신 문제를 해결해야 했다. docker-compose로 실행된 프론트엔드 컨테이너는 localhost가 아닌, 서비스 이름인 backend를 통해 백엔드 컨테이너와 통신해야 한다. 이 문제를 해결하기 위해 Vite의 환경 변수 시스템과 Docker의 빌드 인자(args)를 연동했다.

  • docker-compose.ymlfrontend 서비스 빌드 시, VITE_API_BASE_URL 인자로 http://backend:8080을 전달했다.
  • 로컬 개발 시에는 .env.development 파일의 VITE_API_BASE_URL=http://localhost:8080을 사용하도록 설정했다.
  • 프론트엔드 코드는 import.meta.env.VITE_API_BASE_URL 변수를 참조하도록 수정하여, 어떤 환경에서든 코드를 변경할 필요 없이 올바른 API 주소를 바라보도록 만들었다.

이 모든 설정이 끝난 후, 프로젝트 루트에서 docker-compose up --build 단일 명령어를 실행하자, 두 개의 컨테이너가 성공적으로 빌드되고 실행되었다. 브라우저에서 localhost:5173으로 접속하여, 로컬 개발 환경과 완벽하게 동일하게 동작하는 실시간 채팅 애플리케이션을 최종적으로 확인할 수 있었다. alt text

마치며

이것으로 로컬 개발 환경에서의 모든 개발 과정을 마쳤다. 우리는 이제 단순히 소스 코드 묶음이 아닌, Docker라는 표준 기술로 패키징되어 어디든 배포할 수 있는 '완성된 애플리케이션'을 갖게 되었다. 수많은 디버깅 과정을 거치며 컨테이너의 동작 원리와 네트워크, 환경 변수 설정에 대한 깊은 이해를 얻을 수 있었다.

다음 여정에서는 이 컨테이너화된 애플리케이션을 실제 클라우드 환경에 배포하는 과정을 다룰 예정이다. Terraform을 사용하여 GCP에 Kubernetes 인프라를 구축하고, GitHub Actions와 Argo CD를 이용해 GitOps 기반의 CI/CD 파이프라인을 구축하여, git push만으로 모든 배포가 자동으로 이루어지는 시스템을 완성해 나갈 것이다.

Administrator
Written by

Administrator

안녕하세요! Deep Dive! 블로그 제작자 입니다.

댓글을 불러오는 중...
대화방 참여하기 👋