2024-12-13 13:43:46

1차 파일 병합 후 여러가지 문제를 발견할 수 있었는데 그 원인의 대표적인 것은 아래와 같음.

  • 개발 환경이 다름
    • 친구는 프론트엔드를 그리고 나는 백엔드를 개발해왔기 때문에 서로 설치되어있는 툴도 다르고 환경도 달라 서로의 로컬이 같고 코드가 같더라도 돌아가는게 다름
      • ex) 실제로 내 백엔드 코드에는 파이썬 항목에 CORS가 설정되지 않아도 flask_cors라는 패키지가 깔려있어 자동으로 돌아갔는데 친구는 파이썬 자체를 처음 접했기 때문에 코드 상에 없어도 cors패키지를 설치해야하는 문제가 있었음
  • 생각하는 구조가 다름
    • 친구는 UI / UX의 개발을 우선적으로 생각하며, 나는 API가 제대로 돌아가는 것이 먼저이니 CSS는 전부 우선순위에서 뒤로 미루자는 의견을 하게 되었음.
    • 이 부분에서 서로 맞춰가며 (작은 CSS를 뒤로 미루고 큰 CSS는 그래도 직관적으로 보이게 하게 설정하는 등) 개발을 진행함

이에 따라 1차 코드리뷰를 조금 이르게 진행하였고, 아래와 같이 진행되었음

담당자 : 프론트, 백엔드 

  • 1차 파일 병합
    • 패키지 라이브러리 통일
    • API 호출 불러오기
    • 폴더 구조 통일

이슈 해결

1. Animation.tsx에서 import.meta.glob에 대한 오류 해결 방안

1.1 tsconfig.json 수정

{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"types": ["vite/client"],
// 기타 옵션
}
}


1.2 declations.d.ts 파일 생성 후 해당 코드 선언 및 추가

/// <reference types="vite/client" />

interface ImportMeta {
glob: (pattern: string, options?: { eager?: boolean }) => Record<string, { default: string }>;
}

2. CORS 문제 해결

2.1 pip install flask_cors 설치

2.2 설치 후 lotto_service.py 파일 상단에 CORS 설정 추가

# 라이브러리 import
from flask import Flask, Response, request # flask 모듈에서 필요한 클래스와 객체를 가져옴
import pandas as pd # 데이터 처리를 위한 pandas 라이브러리
import numpy as np # 샘플링과 수학적 계산을 위한 numpy 라이브러리
import json # JSON 응답을 생성하기 위한 라이브러리
from collections import OrderedDict # 열 순서를 유지하기 위한 OrderedDict 클래스
from flask_cors import CORS
app = Flask(__name__) # flask 앱 초기화
CORS(app)
# 데이터 파일 경로 (추후 인터넷 URL로 변경 예정)
DATA_URL = "../data/lottoDB.csv"

##############################################################################################################################################
# 함수 선언부 #
##############################################################################################################################################

# 작성자 : 박건혁
# 작성일 : 2024-12-09
# 목  적 : 데이터를 로드하고 정렬하는 함수
# 메서드 : CSV 파일을 pandas 데이터프레임으로 로드 후 최신 날짜 기준으로 정렬
# 반환값 : sorted_data -> 최신 날짜로 정렬된 data 프레임
def load_and_sort_data():
    # CSV 파일을 pandas 데이터프레임으로 로드
    lotto_data = pd.read_csv(DATA_URL)

    # 최신 날짜 기준으로 정렬 (마지막 줄이 첫 번째 줄로 올라옴)
    sorted_data = lotto_data.iloc[::-1].reset_index(drop=True)
    return sorted_data

# 작성자 : 박건혁
# 작성일 : 2024-12-09
# 목  적 : 특정한 확률을 적용하여 숫자 6개를 뽑는 함수 (1회 추출)
# 메서드 : 추첨 데이터에서 필요한 열만 추출 -> 모든 숫자를 1차원 배열로 변환 ->
#          숫자별 등장 횟수 계산 -> 총 추첨 횟수를 통한 각 숫자의 확률 계산(빈도 수) ->
#          확률에 기반해 6개의 숫자를 샘플링하여 추출 -> 리스트로 반환 
# 반환값 : 리스트로 반환된 1회의 추출 숫자 수
def single_draw(data):
    # 추첨 데이터에서 필요한 열만 추출
    numbers = data[["one", "two", "three", "four", "five", "six"]]
    # 모든 숫자를 1차원 배열로 변환 
    all_numbers = numbers.values.flatten()
    # 숫자별 등장 횟수 계싼
    number_counts = pd.Series(all_numbers).value_counts().sort_index()
    # 총 추첨 횟수
    total_draws = len(data)
    # 각 숫자의 확률 계산 (빈도 수 계산)
    number_probabilities = number_counts / (6 * total_draws)
    # 확률에 기반하여 6개의 숫자를 샘플링
    sampled_numbers = np.random.choice(
        number_counts.index, size=6, replace=False, p=number_probabilities.values
    )
    # 리스트로 반환
    return sampled_numbers.tolist()

# 작성자 : 박건혁
# 작성일 : 2024-12-09
# 목  적 : 특정 범위 횟수안에서 등장한 숫자를 가지고 숫자 6개를 뽑는 함수
# 메서드 : 특정 범위를 선택 (최근 n회의 데이터 선택) -> 이후 single_draw와 동일
def limited_draw(data, recent_count):
    # 최근 n회 데이터만 선택
    recent_data = data.head(recent_count)
    numbers = recent_data[["one", "two", "three", "four", "five", "six"]]
    all_numbers = numbers.values.flatten()
    
    # 숫자별 빈도 계산 및 확률 계산
    number_counts = pd.Series(all_numbers).value_counts().sort_index()
    total_draws = len(recent_data)
    number_probabilities = number_counts / (6 * total_draws)
    
    # 샘플링
    sampled_numbers = np.random.choice(
        number_counts.index, size=6, replace=False, p=number_probabilities.values
    )
    return sampled_numbers.tolist()

##############################################################################################################################################
# API 반환부 #
##############################################################################################################################################

# 작성자 : 박건혁
# 작성일 : 2024-12-09
# 목  적 : 복권 데이터를 정렬된 형식으로 반환하는 API
# 입력값 : 없음
# 메서드 : 1. 데이터를 로드하고 최신 날짜 기준으로 정렬 / 2. 열 순서를 유지하여 JSON 포맷으로 변환
# 반환값 : JSON 형식으로 정렬된 복권 데이터
@app.route("/api/data", methods=["GET"])
def get_data():
    # 최신 데이터 로드
    data = load_and_sort_data()
    if data is None:
        return Response(json.dumps({"error": "데이터를 로드할 수 없습니다."}), status=500, mimetype='application/json')
    
    # 열 순서 유지
    column_order = ["Index", "date", "one", "two", "three", "four", "five", "six", "bonus"]
    if set(column_order) == set(data.columns):
        data = data[column_order]
    
    # JSON으로 변환 (OrderedDict를 사용해 열 순서 유지)
    json_data = [
        OrderedDict((col, row[col]) for col in column_order) for _, row in data.iterrows()
    ]
    
    # JSON 문자열로 변환 후 응답
    return Response(json.dumps(json_data, ensure_ascii=False), mimetype='application/json')

# 작성자 : 박건혁
# 작성일 : 2024-12-09
# 목  적 : 확률에 기반하여 숫자 6개를 1회 추출 하는 API
# 입력값 : 없음
# 메서드 : 1. 전체 데이터를 로드 / 2. 숫자 6개를 샘플링하는 single_draw 함수 호출
# 반환값 : JSON 형식으로 반환된 숫자 6개
@app.route("/api/single-draw", methods=["GET"])
def get_single_draw():
    data = load_and_sort_data()
    draw_result = single_draw(data)
    return Response(json.dumps({"single_draw": draw_result}, ensure_ascii=False), mimetype='application/json')

# 작성자 : 박건혁
# 작성일 : 2024-12-09
# 목  적 : 확률에 기반하여 숫자 6개를 5번 추출하는 API
# 입력값 : 없음
# 메서드 : 1. 전체 데이터를 로드 / 2. 숫자 6개를 5번 샘플링하여 리스트로 반환
# 반환값 : JSON 형식으로 반환된 숫자 6개를 5번 추출한 결과
@app.route("/api/multiple-draws", methods=["GET"])
def get_multiple_draws():
    data = load_and_sort_data()
    results = []
    for _ in range(5):
        results.append(single_draw(data))
    return Response(json.dumps({"multiple_draws": results}, ensure_ascii=False), mimetype='application/json')

# 작성자 : 박건혁
# 작성일 : 2024-12-09
# 목  적 : 특정 범위 내에서 숫자 6개를 추출하는 API
# 입력값 : n (URL 쿼리 파라미터, 기본값 100 -> 기본값 100 추후 삭제 예정)
# 메서드 : 1. 최근 n회의 데이터를 로드 / 2. 제한된 범위 내에서 숫자 6개를 샘플링하는 limited_draw 함수 호출
# 반환값 : JSON 형식으로 반환된 숫자 6개
@app.route("/api/draw-limited", methods=["GET"])
def get_limited_draw():
    # 최근 n회 값 가져오기
    n = request.args.get("n", default=100, type=int)
    data = load_and_sort_data()
    
    # n회 이상 요청 시 전체 데이터로 제한
    if n > len(data):
        n = len(data)
    
    # 숫자 추출
    draw_result = limited_draw(data, n)
    return Response(json.dumps({"limited_draw": draw_result}, ensure_ascii=False), mimetype='application/json')

# 작성자 : 박건혁
# 작성일 : 2024-12-09
# 목  적 : 특정 범위 내에서 숫자 6개를 5번 추출하는 API
# 입력값 : n (URL 쿼리 파라미터, 기본값 100 -> 기본값 100 추후 삭제 예정)
# 메서드 : 1. 최근 n회의 데이터를 로드 / 2. 제한된 범위 내에서 숫자 6개를 5번 샘플링하는 `limited_draw` 함수 호출 
# 반환값 : JSON 형식으로 반환된 숫자 6개를 5번 추출한 결과
@app.route("/api/draw-limited-multiple", methods=["GET"])
def get_limited_multiple_draws():
    # 최근 n회 값 가져오기
    n = request.args.get("n", default=100, type=int)
    data = load_and_sort_data()
    
    # n회 이상 요청 시 전체 데이터로 제한
    if n > len(data):
        n = len(data)
    
    # 5번 추출
    results = []
    for _ in range(5):
        results.append(limited_draw(data, n))
    
    return Response(json.dumps({"limited_multiple_draws": results}, ensure_ascii=False), mimetype='application/json')

# Flask 앱 실행
if __name__ == "__main__":
    app.run(debug=True)

기능 추가 

담당자 : 프론트, 백엔드

1. 버튼 컴포넌트 상태변화 (클릭 시 로딩) 추가 

import React, { useState } from "react";

interface ButtonProps {
  selected: "normal" | "special";
}

function Button({ selected }: ButtonProps) {
  const [result, setResult] = useState<string | null>(null);
  const [loading, setLoading] = useState<"none" | "singular" | "plural">("none");

  const handleClick = async (drawCount: number) => {
    const buttonType = drawCount === 1 ? "singular" : "plural";
    setLoading(buttonType); // 로딩 상태 활성화
    setResult(null); // 기존 결과 초기화

    try {
      let endpoint = "";
      if (selected === "normal") {
        endpoint =
          drawCount === 1
            ? "http://localhost:5000/api/single-draw"
            : "http://localhost:5000/api/multiple-draws";
      } else if (selected === "special") {
        endpoint =
          drawCount === 1
            ? "http://localhost:5000/api/draw-limited"
            : "http://localhost:5000/api/draw-limited-multiple";
      }

      console.log("Requesting API endpoint:", endpoint);

      // 3초 지연 후 API 요청 처리
      await new Promise((resolve) => setTimeout(resolve, 2000));

      const response = await fetch(endpoint);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const data = await response.json();
      setResult(JSON.stringify(data, null, 2));
    } catch (error) {
      console.error("API 호출 중 오류 발생:", error);
      setResult("오류 발생: 데이터를 가져올 수 없습니다.");
    } finally {
      setLoading("none"); // 로딩 상태 비활성화
    }
  };

  return (
    <div className="w-[800px] flex flex-col items-center space-y-6">
      <div className="w-[600px] flex justify-between">
        <button
          onClick={() => handleClick(1)}
          className="w-[270px] h-[50px] bg-red-600 text-white py-2 rounded hover:bg-red-700 transition text-[20px] font-bold flex justify-center items-center"
          disabled={loading !== "none"} // 다른 버튼 클릭 방지
        >
          {loading === "singular" ? (
            <svg
              className="animate-spin h-6 w-6 text-white"
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
            >
              <circle
                className="opacity-25"
                cx="12"
                cy="12"
                r="10"
                stroke="currentColor"
                strokeWidth="4"
              ></circle>
              <path
                className="opacity-75"
                fill="currentColor"
                d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
              ></path>
            </svg>
          ) : (
            "1회 추첨"
          )}
        </button>
        <button
          onClick={() => handleClick(5)}
          className="w-[270px] h-[50px] bg-red-600 text-white py-2 rounded hover:bg-red-700 transition text-[20px] font-bold flex justify-center items-center"
          disabled={loading !== "none"} // 다른 버튼 클릭 방지
        >
          {loading === "plural" ? (
            <svg
              className="animate-spin h-6 w-6 text-white"
              xmlns="http://www.w3.org/2000/svg"
              fill="none"
              viewBox="0 0 24 24"
            >
              <circle
                className="opacity-25"
                cx="12"
                cy="12"
                r="10"
                stroke="currentColor"
                strokeWidth="4"
              ></circle>
              <path
                className="opacity-75"
                fill="currentColor"
                d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
              ></path>
            </svg>
          ) : (
            "5회 추첨"
          )}
        </button>
      </div>
      {/* <pre className="mt-6 bg-gray-200 rounded w-full max-w-[800px] overflow-auto">
        {result || "결과가 여기에 표시됩니다."}
      </pre> */}
    </div>
  );
}

export default Button;

 

2. SVG 파일 수정

2.1 파일 수정 스크립트 구현 (SVG 내 불 필요한 여백 및 공간 제거) -> 실행 후 파이썬 스크립트가 삭제 되도록

import os  # 운영 체제와 상호작용하기 위한 모듈
from xml.etree import ElementTree as ET  # XML 파일을 읽고, 수정하고, 저장하기 위한 모듈

# assets 폴더 내의 모든 SVG 파일을 수정하는 함수
def modify_svg_files():
    assets_folder = './assets'  # 현재 디렉토리 내의 'assets' 폴더 경로
    for file_name in os.listdir(assets_folder):  # assets 폴더 내의 모든 파일 이름을 반복
        if file_name.endswith('.svg'):  # 파일 이름이 '.svg'로 끝나는지 확인
            file_path = os.path.join(assets_folder, file_name)  # 파일의 전체 경로 생성
            try:
                modify_svg_file(file_path)  # 개별 SVG 파일 수정
                print(f"Modified: {file_name}")  # 수정된 파일 이름 출력
            except Exception as e:  # 수정 중 오류 발생 시
                print(f"Error modifying {file_name}: {e}")  # 오류 메시지 출력

# 개별 SVG 파일을 수정하는 함수
def modify_svg_file(file_path):
    tree = ET.parse(file_path)  # SVG 파일을 XML 트리로 파싱
    root = tree.getroot()  # XML 트리의 루트 요소 가져오기

    # viewBox 속성을 수정
    root.set('viewBox', '86.31 86.31 43.38 43.38')

    # 불필요한 <rect> 태그 제거
    for rect in root.findall('.//{http://www.w3.org/2000/svg}rect'):  # <rect> 태그 탐색
        if rect.get('width') == '100%' and rect.get('height') == '100%':  # 너비와 높이가 100%인 경우
            root.remove(rect)  # 해당 <rect> 태그 삭제

    # 불필요한 <defs> 및 <clipPath> 태그 제거
    for defs in root.findall('.//{http://www.w3.org/2000/svg}defs'):  # <defs> 태그 탐색
        root.remove(defs)  # 해당 <defs> 태그 삭제

    # 수정된 파일을 저장
    tree.write(file_path, encoding='utf-8', xml_declaration=True)  # 파일 저장 시 UTF-8 인코딩 사용

# Python 스크립트 실행 후 삭제
if __name__ == "__main__":  # 이 파일이 직접 실행될 때만 실행
    modify_svg_files()  # 모든 SVG 파일 수정 함수 호출
    print("All SVG files modified.")  # 작업 완료 메시지 출력
    os.remove(__file__)  # 현재 실행 중인 Python 스크립트 파일 삭제
    print("Python script deleted.")  # 스크립트 삭제 완료 메시지 출력

- 수정 된 assets.zip 파일

assets.zip
0.02MB

 


코드리뷰

코드를 수정하고 셋팅을 수정하면서 이에 따라 코드리뷰를 전체적으로 진행하였으며, 두 명의 개발자가 같이 진행하였음.

이에 따라 바뀐 코드를 공유

1. 애니메이션 수정 (SVG 파일 여백제거 변경 이슈)

/*Animation.tsx*/
import React from "react";

// SVG 파일 타입 정의
type SvgMap = {
  [key: string]: {
    default: string;
  };
};

const svgs: SvgMap = import.meta.glob("../assets/ball_*.svg", { eager: true });

interface BallPosition {
  x: number;
  y: number;
  dx: number;
  dy: number;
}

interface AnimationState {
  ballPositions: BallPosition[];
}

class Animation extends React.Component<{}, AnimationState> {
  private animationFrameId: number | null = null;

  constructor(props: {}) {
    super(props);
    this.state = {
      ballPositions: Array.from({ length: Object.keys(svgs).length }, () => ({
        x: Math.random() * 600, // 가로축 시작 지점 랜덤
        y: Math.random() * 300, // 세로축 시작 지점 랜덤
        dx: (Math.random() * 2 - 1) * 2, // 임의의 x 방향 속도
        dy: (Math.random() * 2 - 1) * 2, // 임의의 y 방향 속도
      })),
    };
  }

  componentDidMount() {
    this.animate();
  }

  componentWillUnmount() {
    if (this.animationFrameId) {
      cancelAnimationFrame(this.animationFrameId);
    }
  }

  animate = () => {
    this.setState((prevState) => {
      const newPositions = prevState.ballPositions.map((ball) => {
        let { x, y, dx, dy } = ball;

        // 위치 업데이트
        x += dx;
        y += dy;

        // 테두리에 닿았을 때 반사
        if (x < 0 || x > 600 - 50) {
          // 50은 공의 너비
          dx *= -1;
        }
        if (y < 0 || y > 300 - 50) {
          // 50은 공의 높이
          dy *= -1;
        }

        return { x, y, dx, dy };
      });

      return { ballPositions: newPositions };
    });

    this.animationFrameId = requestAnimationFrame(this.animate);
  };

  render() {
    return (
      // 컴포넌트 전체 배경
      <div className="w-[800px] h-screen flex justify-center items-center bg-white">
        {/* 추첨 애니메이션 범위 */}
        <div className="w-[600px] h-[300px] relative border-2 rounded-xl bg-gray-200 overflow-hidden">
          {this.state.ballPositions.map((ball, index) => (
            <img
              key={index}
              src={svgs[`../assets/ball_${index + 1}.svg`].default}
              alt={`Ball ${index + 1}`}
              className="absolute"
              style={{
                left: `${ball.x}px`,
                top: `${ball.y}px`,
                width: "50px", // 공의 가로 크기
                height: "50px", // 공의 세로 크기
              }}
            />
          ))}
        </div>
      </div>
    );
  }
}

export default Animation;

2. 당첨 기록 구현

2.1 BackEnd 코드 수정 (당첨 기록 불러오는 부분 한번에 10개씩 불러올 수 있도록 수정)

# Backend 코드 수정 
# 작성자 : 박건혁
# 작성일 : 2024-12-09
# 수정일 : 2024-12-12
# 목  적 : 복권 데이터를 정렬된 형식으로 반환하는 API 
# 입력값 : 없음
# 수정 전 메서드 : 1. 데이터를 로드하고 최신 날짜 기준으로 정렬 / 2. 열 순서를 유지하여 JSON 포맷으로 변환
# 수정 후 메서드 : 1. 데이터를 로드 후 페이지 번호와 한 페이지당 항목 수를 쿼리파라미터로 받음 
#                 2. 데이터의 시작과 끝 인덱스 계산 / 3. 데이터가 없을 경우 에러처리 / 4. JSON 포맷으로 변환하여 전송
# 반환값 : JSON 형식으로 정렬된 복권 데이터
@app.route("/api/data", methods=["GET"])
def get_paginated_data():
    # 최신 데이터 로드
    data = load_and_sort_data()
    if data is None:
        return Response(json.dumps({"error": "데이터를 로드할 수 없습니다."}), status=500, mimetype='application/json')
    # 페이지 번호와 한 페이지당 항목 수를 쿼리 파라미터로 받기 (기본값: page=1, limit=10)
    page = request.args.get("page", default=1, type=int)
    limit = request.args.get("limit", default=10, type=int)
    # 데이터의 시작 및 끝 인덱스 계산
    start_index = (page - 1) * limit
    end_index = start_index + limit
    # 해당 페이지에 해당하는 데이터 추출
    paginated_data = data.iloc[start_index:end_index]
    # 데이터가 없을 경우 에러 처리
    if paginated_data.empty:
        return Response(json.dumps({"error": "더 이상 데이터가 없습니다."}), status=404, mimetype='application/json')
    # 열 순서 유지
    column_order = ["Index", "date", "one", "two", "three", "four", "five", "six", "bonus"]
    if set(column_order) == set(data.columns):
        paginated_data = paginated_data[column_order]
    # JSON으로 변환 (OrderedDict를 사용해 열 순서 유지)
    json_data = [
        OrderedDict((col, row[col]) for col in column_order) for _, row in paginated_data.iterrows()
    ]
    # JSON 문자열로 변환 후 응답
    return Response(json.dumps(json_data, ensure_ascii=False), mimetype='application/json')

2.2 프론트엔트 구현 (백엔드 개발자 + 프론트엔드 개발자 담당)

import React, { useEffect, useState, useRef } from "react";

// LottoRecord 인터페이스: 로또 데이터가 어떤 형식인지 정의
interface LottoRecord {
  Index: number; // 로또 회차
  date: string; // 추첨 날짜
  one: number; // 당첨 숫자 1
  two: number; // 당첨 숫자 2
  three: number; // 당첨 숫자 3
  four: number; // 당첨 숫자 4
  five: number; // 당첨 숫자 5
  six: number; // 당첨 숫자 6
  bonus: number; // 보너스 숫자
}

// SVG 파일들을 동적으로 가져오기
const svgs = import.meta.glob("../assets/ball_*.svg", {
  eager: true,
}) as Record<string, { default: string }>;

const LottoCard = ({ record }: { record: LottoRecord }) => {
  return (
    <div className="flex items-center justify-between w-[700px] p-3 mb-4 bg-white rounded-lg shadow-md">
      <div className="flex flex-col items-start text-sm mr-1">
        <div className="font-bold text-gray-500">{record.Index}회</div>
        <div className="text-gray-400">{record.date}</div>
      </div>
      <div className="flex items-center">
        {[
          record.one,
          record.two,
          record.three,
          record.four,
          record.five,
          record.six,
        ].map((num, idx) => (
          <img
            key={idx}
            src={svgs[`../assets/ball_${num}.svg`]?.default} // 올바르게 타입 강제 변환
            alt={`ball_${num}`}
            className="w-12 h-12 mr-1"
          />
        ))}
        <span className="text-lg font-bold text-gray-500 mx-1">+</span>
        <img
          src={svgs[`../assets/ball_${record.bonus}.svg`]?.default} // 올바르게 타입 강제 변환
          alt={`ball_${record.bonus}`}
          className="w-12 h-12 ml-1"
        />
      </div>
    </div>
  );
};

function Record() {
  const [records, setRecords] = useState<LottoRecord[]>([]); // 로또 데이터 저장
  const [loading, setLoading] = useState(false); // 로딩 상태
  const [error, setError] = useState<string | null>(null); // 에러 메시지
  const [hasMore, setHasMore] = useState(true); // 더 많은 데이터 여부
  const [currentPage, setCurrentPage] = useState(1); // 현재 페이지 번호
  const ITEMS_PER_PAGE = 10; // 한 번에 불러올 데이터 개수
  const scrollContainerRef = useRef<HTMLDivElement>(null); // 스크롤 컨테이너 참조

  const fetchData = async (page: number) => {
    try {
      setLoading(true); // 로딩 시작
      const response = await fetch(
        `http://localhost:5000/api/data?page=${page}&limit=${ITEMS_PER_PAGE}`
      );
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const data = await response.json(); // JSON 데이터 파싱
      setRecords((prevRecords) => [...prevRecords, ...data]); // 기존 데이터에 새 데이터 추가
      setHasMore(data.length === ITEMS_PER_PAGE); // 데이터가 더 있는지 확인
    } catch (err) {
      console.error("Error fetching data:", err);
      setError("데이터를 가져오는 데 실패했습니다.");
    } finally {
      setLoading(false); // 로딩 종료
    }
  };

  useEffect(() => {
    fetchData(currentPage);
  }, [currentPage]);

  const handleScroll = () => {
    if (loading || !hasMore || !scrollContainerRef.current) return;

    const { scrollTop, scrollHeight, clientHeight } =
      scrollContainerRef.current;

    if (scrollHeight - scrollTop <= clientHeight + 50) {
      setCurrentPage((prevPage) => prevPage + 1);
    }
  };

  useEffect(() => {
    const container = scrollContainerRef.current;
    if (container) {
      container.addEventListener("scroll", handleScroll);
      return () => container.removeEventListener("scroll", handleScroll);
    }
  }, [loading, hasMore]);

  if (error) {
    return (
      <div className="flex justify-center items-center min-h-screen text-red-500">
        {error}
      </div>
    );
  }

  return (
    <div
      ref={scrollContainerRef}
      className="flex flex-col items-center bg-gray-100 min-h-screen p-4 overflow-y-auto"
      style={{ maxHeight: "100vh" }}
    >
      {records.map((record) => (
        <LottoCard key={record.Index} record={record} />
      ))}
      {loading && (
        <div className="flex justify-center items-center mt-4">로딩 중...</div>
      )}
      {!hasMore && (
        <div className="text-gray-500 mt-4">더 이상 데이터가 없습니다.</div>
      )}
    </div>
  );
}

export default Record;

3. App.tsx 수정 (Header 삽입 하고 DrawPage에 Header 삭제)

/*App.tsx*/
import React, { useState } from "react";
import { Route, Routes, useNavigate } from "react-router-dom";
import DrawPage from "./pages/DrawPage";
import Record from "./pages/Record";
import Header from "./components/Header";

function App() {
  const [activeTab, setActiveTab] = useState("lottery");
  const navigate = useNavigate();

  const handleTabChange = (tab: string) => {
    setActiveTab(tab);
    if (tab === "lottery") {
      navigate("/");
    } else if (tab === "record") {
      navigate("/record");
    }
  };

  return (
    <div>
      <Header activeTab={activeTab} onTabChange={handleTabChange} />
      <Routes>
        <Route path="/" element={<DrawPage />} />
        <Route path="/record" element={<Record />} />
      </Routes>
    </div>
  );
}

export default App;

4. main.tsx 수정 -> navigation 기능 삭제

/*main.tsx*/
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";

const rootElement = document.getElementById("root");

if (rootElement) {
  ReactDOM.createRoot(rootElement).render(
    <React.StrictMode>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </React.StrictMode>
  );
} else {
  console.error(
    "Root element not found. Make sure 'root' exists in your HTML."
  );
}

이상으로 금일 까지 진행된 상황을 완료하며 진행된 상황을 확인하면 아래와 같음

폴더 구조


추첨기 페이지 현황 (1)


추첨기 페이지 (2) 당첨기록