본문 바로가기

Side Project

React+Flask 어플리케이션: 코드부터 Jenkins 배포까지 (CI/CD) - 1

728x90
반응형

React+Flask 이미지 갤러리:  (Mac · Docker · Jenkins)

  • 이 글은 그대로 따라 해도 로컬 실행부터 Docker Compose, 그리고 Jenkins를 통한 자동 배포까지 완주할 수 있도록 만든 실전형 가이드입니다. 진행 중 만났던 실제 오류들과 해결 방법을 전부 정리했습니다.

최종 목표

  • React(프론트) + Flask(백엔드)로 구성된 Unsplash 로 부터 이미지를 가지고 오는 이미지 갤러리 앱을 만든다.
    • Unsplash API 키(Secret)를 사용해 키워드로 이미지를 검색한다.
  • Docker Compose로 frontend + backend를 함께 띄운다.
  • Jenkins 파이프라인으로 main 브랜치 push → 자동 빌드/배포까지 연결한다.

준비물 (Mac 기준)

  • Docker Desktop for Mac 설치 (Docker Compose 내장: docker compose … 명령 사용)
  • Node.js 18+, Python 3.11+
  • Jenkins
  • Unsplash API Access Key (계정 생성 후 발급)
  • GitHub 리포지토리(예: ImageGallery)

디렉토리 구조

ImageGallery/
├─ backend/
│  ├─ app
│  │   ├─ main.py
│  │   ├─ requirements.txt
│  └─ Dockerfile
├─ frontend/
│  ├─ src
│  │   ├─ App.js
│  │   ├─ Config.js
│  │   ├─ index.js
│  ├─ package.json
│  └─ Dockerfile
├─ docker-compose.yml
└─ Jenkinsfile

1. 백엔드(Flask) — Unsplash 검색 API 프록시

backend/app/main.py

from flask import Flask, request, jsonify
from flask_cors import CORS
import requests
import os
from dotenv import load_dotenv

app = Flask(__name__)
CORS(app)

load_dotenv()  # .env 파일 로드
UNSPLASH_ACCESS_KEY = os.getenv("UNSPLASH_ACCESS_KEY")
port = int(os.getenv("FLASK_PORT", 8000))

@app.route("/images", methods=["POST"])
def get_images():
    data = request.get_json()
    query = data.get("keyword", "").strip()
    if not query:
        return jsonify({"error": "검색어를 입력하세요."}), 400

    url = f"https://api.unsplash.com/search/photos?query={query}&per_page=12&client_id={UNSPLASH_ACCESS_KEY}"
    response = requests.get(url)
    data = response.json()

    if response.status_code != 200:
        return jsonify({"error": data.get("errors", "Unknown error")}), 400

    images = [photo["urls"]["regular"] for photo in data.get("results", [])]
    return jsonify({"images": images[:12]})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=port, debug=True)

backend/requirements.txt

flask
flask-cors
requests
python-dotenv

backend/Dockerfile

# Python 3.11 사용
FROM python:3.11-slim
WORKDIR /app
ENV FLASK_PORT=8000

# 소스 복사
COPY app/ .
RUN pip install --no-cache-dir -r requirements.txt

# Flask 서버 실행
EXPOSE 8000
CMD ["python", "main.py"]

2. 프런트엔드(React)

frontend/src/App.js

import { useState } from "react";
import { BACKEND_URL } from "./config";

function App() {
  const [keyword, setKeyword] = useState("");
  const [images, setImages] = useState([]);
  const [bgGradient, setBgGradient] = useState(
    "linear-gradient(to right, #fff, #eee)"
  );
  const [tempGradient, setTempGradient] = useState(null);
  const [modalUrl, setModalUrl] = useState(null);
  const [loadedCount, setLoadedCount] = useState(0);

  // 검색어 기반 gradient 생성
  const generateGradient = (keyword) => {
    let hash = 0;
    for (let i = 0; i < keyword.length; i++) {
      hash = keyword.charCodeAt(i) + ((hash << 5) - hash);
    }
    const color1 = `hsl(${hash % 360}, 70%, 80%)`;
    const color2 = `hsl(${(hash + 180) % 360}, 70%, 70%)`;
    return `linear-gradient(to right, ${color1}, ${color2})`;
  };

  console.log("Using BACKEND_URL:", BACKEND_URL);
  const handleSearch = async () => {
    if (!keyword) return;

    // 검색어 기반 임시 gradient 저장 (로딩 완료 후 적용)
    setTempGradient(generateGradient(keyword));

    const res = await fetch(`${BACKEND_URL}/images`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ keyword }),
    });
    const data = await res.json();
    setImages(data.images || []);
    setLoadedCount(0); // 로딩 카운트 초기화
  };

  const handleImageLoad = () => {
    setLoadedCount((prev) => {
      const next = prev + 1;
      // 모든 이미지가 로드되면 배경 gradient 적용
      if (next === images.length && tempGradient) {
        setBgGradient(tempGradient);
        setTempGradient(null);
      }
      return next;
    });
  };

  const openModal = (url) => setModalUrl(url);
  const closeModal = () => setModalUrl(null);

  return (
    <div
      style={{
        minHeight: "100vh",
        padding: "50px",
        fontFamily: "Arial",
        background: bgGradient,
        transition: "background 0.8s ease",
      }}
    >
      <h1 style={{ textAlign: "center", marginBottom: "30px" }}>
        🖼️ My Gallery
      </h1>
      <div
        style={{
          display: "flex",
          justifyContent: "center",
          marginBottom: "30px",
        }}
      >
        <input
          style={{
            flex: 1,
            padding: "12px",
            fontSize: "16px",
            borderRadius: "8px",
            border: "1px solid #ccc",
          }}
          value={keyword}
          onChange={(e) => setKeyword(e.target.value)}
          placeholder="Put a keyword..."
        />
        <button
          style={{
            padding: "12px 20px",
            marginLeft: "10px",
            borderRadius: "8px",
            border: "none",
            backgroundColor: "#4CAF50",
            color: "#fff",
            fontWeight: "bold",
            cursor: "pointer",
            transition: "background 0.3s",
          }}
          onClick={handleSearch}
          onMouseOver={(e) =>
            (e.currentTarget.style.backgroundColor = "#45a049")
          }
          onMouseOut={(e) =>
            (e.currentTarget.style.backgroundColor = "#4CAF50")
          }
        >
          Search
        </button>
      </div>

      <div
        style={{
          display: "grid",
          gridTemplateColumns: "repeat(auto-fill, minmax(250px, 1fr))",
          gap: "20px",
        }}
      >
        {images.map((url, idx) => (
          <div
            key={idx}
            style={{
              overflow: "hidden",
              borderRadius: "16px",
              cursor: "pointer",
              boxShadow: "0 4px 12px rgba(0,0,0,0.1)",
              transition: "transform 0.4s, box-shadow 0.4s, opacity 0.6s",
              opacity: 0,
              animation: `fadeIn 0.6s forwards ${idx * 0.1}s`,
            }}
          >
            <img
              src={url}
              alt="search result"
              style={{
                width: "100%",
                height: "250px",
                objectFit: "cover",
                display: "block",
                transition: "transform 0.4s",
              }}
              onLoad={handleImageLoad}
              onClick={() => openModal(url)}
              onMouseOver={(e) => {
                e.currentTarget.style.transform = "scale(1.1)";
                e.currentTarget.parentElement.style.boxShadow =
                  "0 8px 20px rgba(0,0,0,0.3)";
              }}
              onMouseOut={(e) => {
                e.currentTarget.style.transform = "scale(1)";
                e.currentTarget.parentElement.style.boxShadow =
                  "0 4px 12px rgba(0,0,0,0.1)";
              }}
            />
          </div>
        ))}
      </div>

      {modalUrl && (
        <div
          onClick={closeModal}
          style={{
            position: "fixed",
            top: 0,
            left: 0,
            width: "100vw",
            height: "100vh",
            backgroundColor: "rgba(0,0,0,0.8)",
            display: "flex",
            justifyContent: "center",
            alignItems: "center",
            zIndex: 9999,
            cursor: "pointer",
          }}
        >
          <img
            src={modalUrl}
            alt="원본 이미지"
            style={{ maxHeight: "90%", maxWidth: "90%", borderRadius: "12px" }}
          />
        </div>
      )}

      <style>{`
        @keyframes fadeIn {
          to { opacity: 1; }
        }
      `}</style>
    </div>
  );
}

export default App;

frontend/src/config.js

export const BACKEND_URL =
  process.env.REACT_APP_BACKEND_URL || "http://localhost:8000";

frontend/src/index.js

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

frontend/Dockerfile

FROM node:20-alpine
WORKDIR /app

COPY package*.json ./
RUN npm install
COPY . .

# docker-compose.yml에서 넘어온 ARG를 ENV로 세팅
ARG REACT_APP_BACKEND_URL
ENV REACT_APP_BACKEND_URL=$REACT_APP_BACKEND_URL

# React 앱 빌드
RUN npm run build
RUN npm install -g serve

EXPOSE 3000
CMD ["serve", "-s", "build", "-l", "3000"]

3. Docker Compose로 로컬 실행

docker-compose.yml

version: "3.8"
services:
  backend:
    build: ./backend
    ports:
      - "8000:8000"
    environment:
      - UNSPLASH_ACCESS_KEY=${UNSPLASH_ACCESS_KEY}
      - FLASK_PORT=8000

  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    environment:
      - REACT_APP_BACKEND_URL=http://localhost:8000 # 브라우저에서 접근할 백엔드 주서
    depends_on:
      - backend
  • ❗ REACT_APP_BACKEND_URL -> 프런트 컨테이너에서 백엔드를 http://backend:10080 으로 호출하면 브라우저가 backend라는 컨테이너 DNS를 모릅니다. 반드시 브라우저가 접근 가능한 http://localhost:10080을 사용하세요. (docker compose 세팅 시 주의) 얘 때문에 서로 통신이 안되어서 고생했어요 ㅠㅠ

.env 파일 (루트)

UNSPLASH_ACCESS_KEY=여기에_발급_키_입력
  • .env절대 커밋하지 마세요. (민감정보)

실행

docker compose build
docker compose up -d
# 확인
open http://localhost:3000

 

위 파일들을 Github repo 에 추가 합니다. 저는 https://github.com/<GithubID>/ImageGallery.git 에 추가했습니다.


4. Jenkins 파이프라인 설정 (Mac 전용 팁 포함)

4-1. Jenkins 설치/실행 (요약)

jenkins 설치에 대한 자세한 정보는 공식 홈페이지를 참조했습니다. (https://www.jenkins.io/download/lts/macos/)

  • Homebrew로 설치했다면: brew services start jenkins-lts
  • 웹 접속: http://localhost:8080 (포트 번호는 바꿀 수 있습니다.)
  • 초기 비번: /Users/<계정>/.jenkins/secrets/initialAdminPassword

4-2. Credentials 등록

  • UNSPLASH_KEY: Kind = Secret text, ID = UNSPLASH_KEY, Secret = 발급 키
  • GITHUB_CREDENTIAL: GitHub Personal Access Token (private repo면 필수)

4-3. Jenkinsfile

이제 Jenkins에 접속하여 ( http://localhost:8080 ) 새로운 item 을 등록합니다.

그러면 아래와 같은 화면이 나오는데, item name 은 원하는 문자열을 넣고, 저희는 Pipeline을 선택합니다.

 

그리고 아래로 쭉 내려와 Script에 아래 Jenkinsfile을 작성 후, 빌드를 트리거 하면 됩니다.

Jenkinsfile

pipeline {
    agent any
    environment {
        // Mac 에서 docker 명령어를 찾지 못해서 해당 문제를 대응하기 위한 PATH 추가
        PATH = "/usr/local/bin:/opt/homebrew/bin:$PATH"
        // Secret Text → 환경변수로 주입
        UNSPLASH_ACCESS_KEY = credentials('UNSPLASH_KEY')
    }

    stages {
       stage('Checkout') {
            steps {
               // 해당 repo 로부터 코드 fetch 후 빌드할 예정
                git branch: 'main', url: 'https://github.com/<GithubID>/ImageGallery.git'
            }
        }
        stage('Build & Deploy') {
            steps {
                sh '''
                docker compose build --no-cache
                docker compose up -d
                '''
            }
        }
    }
    post {
        success {
            echo "✅ Deployment Success!"
        }
        failure {
            echo "❌ Deployment Failed!"
        }
    }
}
  • ✅ Declarative Pipeline에서 모든 stage는 반드시 stages {}에 있어야 합니다. Undefined section "stage" 오류가 났었는데 그게 원인이었습니다.

4-4. 빌드 트리거

  • 생성된 pipeline에 들어가서 수동으로 Jenkins를 빌드합니다.  (지금 빌드 button)

  • 추후에 자동으로 CD 가 가능한 Jenkins Job을 설정할 예정입니다. 방법은 크게 아래처럼 2가지 인데 저는 Github Webhook 연결을 하여 자동화할 예정입니다. ( 이 부분은 따로 blog posting 할 예정입니다.)
    • 간단: Poll SCMH/1 * * * * (1분마다)
    • 권장: GitHub Webhook 연결 (push 시 즉시 빌드)

5. 실제로 겪었던 오류들 & 해결법 총정리

아래는 제가 CI/CD 를 구축하면서 마주하였던 에러들을 정리한 것입니다. 자세한 내용은 https://silver-programmer.tistory.com/entry/Jenkins-배포-중-마주한-에러들과-해결방법 에 정리되어 있습니다! 혹시나 도움이 될까...ㅎㅎ

증상/로그 원인 해결
WorkflowScript: Undefined section "stage" stage{}stages{} 밖에 있음 모든 stage를 stages{} 내부로 이동
ERROR: UNSPLASH_KEY Jenkins Credential 미등록 또는 타입 오류 Kind=Secret text, ID=UNSPLASH_KEY로 등록 후 credentials('UNSPLASH_KEY') 사용
docker-compose: command not found (구버전 명령) 미설치 또는 PATH 문제 Mac은 docker compose 사용. PATH에 /usr/local/bin:/opt/homebrew/bin 추가
docker: command not found Jenkins 환경 PATH에 docker 없음 Jenkinsfile environment { PATH=… } 추가 또는 Jenkins 실행 환경 PATH 설정
main으로 push했는데 반영 안 됨 workspace 캐시, 인증 문제 docker compose build --no-cache 사용
프런트에서 http://backend:10080 호출 실패 브라우저는 컨테이너 DNS 미인지 프런트 env를 http://localhost:10080로 설정

7. 체크리스트

Unsplash 키 발급 완료, .envUNSPLASH_ACCESS_KEY 설정

backend/requirements.txtbackend/Dockerfile과 같은 폴더

docker compose up -dhttp://localhost:3000 접속 가능

브라우저 Network에서 /images 200 응답 확인

Jenkins Credentials: UNSPLASH_KEY(Secret text), GITHUB_CREDENTIAL

Jenkinsfile의 PATH에 /usr/local/bin:/opt/homebrew/bin 포함


8. 마무리

이 글의 목표는 **“복붙만 해도 끝까지 간다”**였습니다. ㅎㅎ 실제로 겪었던 문제(Declarative 문법, Credential, Docker/Docker Compose 차이, PATH, COPY 경로, 브라우저-컨테이너 네트워킹 차이)를 모두 체크리스트화 했습니다. 실습이 막히면 6장 오류표를 먼저 확인해 보세요.

필요하시면 이 프로젝트를 Helm 배포나 클라우드(EC2, Lightsail, GCP, Render 등)로 확장하는 글도 이어서 준비하겠습니다. 즐거운 배포 되세요! 🚀

728x90
반응형