[초등생을 위한 경제 금융 플랫폼 BID] 2. 개발

백지윤·2024년 2월 21일

프로젝트

목록 보기
2/8
post-thumbnail

개발 기간

2024-01-22 ~ 2024-02-16

서비스 주요 기능 소개

1. 인증 / 인가

1-1. 회원가입
1-2. 로그인
1-3. 회원정보 수정 (패스워드 재설정)
1-4. 탈퇴

2. 학급 / 학생 관리

2-1. 학급 목록
2-2. 학급 추가
2-3. 학급 편집
2-4. 학생 목록
2-5. 학생 추가
2-6. 학생 패스워드 초기화

3. 경매 관리

3-1. 경매 등록
3-2. 경매 입찰
3-3. 경매 낙찰 및 유찰
3-4. 경매 댓글 관리
3-5. 경매 홀드

4. 은행 관리

4-1. 적금 목록
4-2. 적금 가입 및 해지

5. 쿠폰 관리

5-1. 쿠폰 등록
5-2. 쿠폰 목록
5-3. 쿠폰 삭제

6. 리워드 관리

6-1. 리워드 등록
6-2. 리워드 삭제
6-3. 리워드 전송

7. 통계 관리

7-1. 학급 통계
7-2. 학생 통계 (가계부)

8 대포 게임

8-1. 대포 게임

멀티모듈(아키텍처)

요구사항 정의서 작성

요구사항 정의서

기능 명세서

기능 명세서

API 명세

API명세

FE 일정

FE개발 일정


  • 프론트엔드 개발자로 팀에 합류하게 되었고, 1학기 관통 프로젝트 진행시에 배웠던 툴이 VUE 였기 때문에 VUE로 개발을 했었지만, 현업에서는 REACT를 대부분 사용한다는 것을 깨닫고, 1학기 이후 방학 기간동안 다른 반 팀원들과 함께 쇼핑몰 클론 코딩 프로젝트를 REACT로 진행하였고, 얄팍하게나마 REACT에 대한 기초 지식을 쌓아 프로젝트 진행시에 조금이나마 도움이 되었다.

  • 이번 프로젝트때 맡은 기능은

  1. 인증 / 인가
    1-1. 회원가입
    1-2. 로그인
    1-3. 회원정보 수정 (패스워드 재설정)
    1-4. 탈퇴
  1. 학급 / 학생 관리
    2-1. 학급 목록
    2-2. 학급 추가
    2-3. 학급 편집
    2-4. 학생 목록
    2-5. 학생 추가
    2-6. 학생 패스워드 초기화
  1. 통계 관리
    7-1. 학급 통계
    7-2. 학생 통계 (가계부)

+) 학생 모델링, 렌더링, 웹소켓 사용하여 실시간 이용자들 채팅 기능, 아바타 변경 기능

이었는데, 서비스에 초기에 게이미피케이션을 도입해서 학생들에게 경매에 대한 어려움을 줄여주고, 장벽을 낮춰주자는 의도로 기획한 학생 측 캐릭터 모델링과 아바타 구축을 진행 하였고 이 때, 해당 학급과 은행, 경매장 건물 모델링을 진행하였다.

  • 이 과정에서 학생들의 아바타 이용에 의미를 주기 위해 학생들끼리의 소통이 필요하다고 느껴 차후에 추가적으로 프론트엔드에서만 웹소켓을 열어서 실시간 대화를 가능하게끔 하였다.
  • 위 과정이 생각보다 시간이 많이 소요되어 맡은 기능 중 통계관리에 관한 기능을 다 구현하지 못할것라고 판별해, 타 팀원분이 해당 기능에 대한 구현을 맡아서 완료해주셨다.

해당해서 캐릭터 모델링 및 아바타 구축 부분에 대한 시간 분배를 조금 더 다른 곳에 할당해서 개발을 진행하였다면, 이후에 시간 상의 문제를 줄일 수 있지 않았을까 하는 점이 조금 아쉽다

  • 초반 기획내용을 모두 다 진행하기 위해서 과도한 일정을 짜지 않고, 추후에는 리팩토링, 코드 리뷰 할 시간을 더 가져 완성도를 높이는 방향으로 가야겠다고 생각하였다.

사용 기술

1. React

  • 리액트에서 제공하는 기본 hook들 (useEffect, useState ...)을 공식 문서에서 자세히 공부하고 추후에 정리해서 더 효율적으로 써봐야겠다는 점들에 대해서 느꼈다
  • 이번 프로젝트에서 module.css를 사용해서 모든 jsx 파일의 css를 같은 파일 하단에 두었는데, 이런 방식으로 프로젝트를 진행하다보니 이후 css 파일들과 jsx 파일들이 혼재되어 헷갈려서 힘들고 문서도 너무 많아져서 혼동스럽고 편리함을 느끼지 못했다
    • 이후 css 폴더를 따로 구축해서 모아두는 방식이나 다른 css 방식을 사용해 보고자 한다.

2. Redux-toolkit

  • Redux-toolkit을 사용하여 전역적인 상태관리를 위해 환경을 구축하게 되었는데, 리덕스를 사용하여 관리 한것에는 이점이 정말 많아서 이후에도 사용할 계획이다.
  • 이전 프로젝트 진행시에 zustand를 이용해 상태관리를 하였는데, 리덕스가 훨씬 어렵긴 했다...
  • 로그인, 로그아웃 시에 리덕스에 저장된 값들을 초기화 하거나, 설정해주는 단계가 필요한데 이 때, 더 촘촘이 개발을 진행하여야 이후에 차질이 생기지 않다는 점을 느꼈다
    • Redux-persist를 사용하여서 리덕스에 저장된 값들을 계속 유지할 수 있도록 라이브러리 사용

3. Redux-query

  • React 애플리케이션에서 데이터를 관리하기 위한 라이브러리

  • Axios 사용시에 리액트 쿼리를 사용해서 api 호출을 하였는데, api 관련 로직들을 한 jsx파일에 모아두고, 쿼리를 사용해서 단순하게 api를 처리할 수 있었다.

  • 다음 프로젝트에서도 사용해보고 싶다.

4. Blender

  • 과 특성상, 3D 관련 툴을 많이 다뤄봤었는데 블렌더는 대학졸업 전에 개인적으로 튜토리얼 강의들을 통해 공부를 하고 포트폴리오 용으로 미니 프로젝트를 몇개 진행해본 적 있는데, 위 경험들을 통해서 캐릭터 모델링, 건물 모델링 등을 빨리 진행할 수 있었다
  • 기본 모델링
  • 총 10개의 캐릭터 모델링 완성

  • 초반 교실 모델링

  • 최종 모델링된 학생 메인페이지

5. Socket-io

  • 학생들마다 각기의 캐릭터를 보유하고 있고, 메인 페이지에 모델링 된 학급의 UI와 아바타들을 띄우게 되다보니 자연스레 채팅이나, 캐릭터 움직임 구현에 대한 필요가 느껴졌고, 소켓에 대한 지식이 아예 없었고, 시간 또한 여유롭지 못한 상황에서 진행이 되었으므로 백엔드에서 소켓을 열기에는 무리가 있었다.

  • 이에, 프론트엔드 단독으로 소켓을 열어서, 데이터를 주고받는 통신이 아닌, 아이들이 메인페이지에 접속했을때, 경제 금융 플랫폼에 대한 딱딱한 이미지와 거부감을 줄이고 친구들과 실시간 양방향 소통을 하여서 머무르는 시간을 늘리게 하고자 하였다.

참고한 유튜브 소켓 강좌

  • 이런식으로 프론트엔드 폴더 내부에 리액트 프로젝트와 소켓 서버를 두어서, Server의 index.js에서
//Server/index.js

import { Server as SocketIOServer } from "socket.io";
import { createServer as createHttpsServer } from "https";
import fs from "fs";

const origin = "https://i10a306.p.ssafy.io";

let httpsOptions;
try {
  httpsOptions = {
    key: fs.readFileSync('/etc/letsencrypt/live/i10a306.p.ssafy.io/privkey.pem', 'utf8'),
    cert: fs.readFileSync('/etc/letsencrypt/live/i10a306.p.ssafy.io/fullchain.pem', 'utf8'),
  };
  // 인증서 로드 성공 메시지
  console.log('SSL/TLS 인증서가 성공적으로 로드되었습니다.');
} catch (error) {
  // 에러 로깅
  console.error('SSL/TLS 인증서 로드 중 에러 발생:', error);
}

const httpsServer = createHttpsServer(httpsOptions);

const io = new SocketIOServer(httpsServer, {
  cors: {
    origin, // 허용할 CORS origin
    methods: ["GET", "POST"] // 허용할 HTTP 메소드
  },
});

httpsServer.listen(3001, () => {
  console.log('HTTPS 서버가 포트 3001에서 시작되었습니다.');
});

  console.log("Server started on port 3000, allowed cors origin: " + origin);

  const roomId = []
  const characters = []
  const generateRandomPosition = () => {
    const minX = 1.4;
    const maxX = 2.224 + 3;

    const minZ = -1.781 - 1.5 ;
    const maxZ = -1.781 + 1.5;

    const x = minX + Math.random() * (maxX - minX);
    const z = minZ + Math.random() * (maxZ - minZ);

    return [x, 0, z];
  };

  const generateRandomHexColor = () => {
    return "#" + Math.floor(Math.random() * 16777215).toString(16);
  };

  
  const createCharacter = (model) => {
    const [baseX, baseY, baseZ] = generateRandomPosition(); // 랜덤한 기준 포지션 생성
    const deltaX = (Math.random() * 0.3) - 0.6; // x 값에 더해줄 랜덤한 값 생성 (0.3 ~ 0.65)
    const deltaZ = (Math.random() * 0.3) + 0.6; // z 값에 더해줄 랜덤한 값 생성 (0.3 ~ 0.65)
  
    // 기준 포지션에 더해진 값을 사용하여 캐릭터의 위치를 설정
    const position = [baseX + deltaX, baseY, baseZ + deltaZ];
  
    const selectedCharacter = model.profileImgUrl.split('/').pop().replace('.png', '');
    const id = model.no;
    const gradeNo = model.gradeNo;
    const name = model.name;
    
    return {
      id,
      name,
      gradeNo,
      position,
      selectedCharacter
    };
  };

  io.on("connection", (socket) => {
    console.log("user connected:", socket.id);

    socket.on("joinRoom", (gradeNo) => {
      socket.join(gradeNo);
      console.log(`User ${socket.id} joined room ${gradeNo}`);
    });

    socket.on("characters", (data) => {
      // 여기서 models 이벤트를 수신하여 원하는 작업을 수행합니다.
      console.log("Received models data:", data);
      data.forEach(model => {
        // 중복 추가 방지
        const gradeNo = model.gradeNo;
        if (!roomId[gradeNo]) {
          roomId[gradeNo] = [];
        }
        if (!roomId[gradeNo].some(character => character.id === model.no)) {
          const character = createCharacter(model);
          roomId[gradeNo].push(character);
        }
        // Object.keys(roomId).forEach(gradeNo => {
        //   io.emit(`characters-${gradeNo}`, roomId[gradeNo]);
        // });

        io.to(gradeNo).emit(`characters-${gradeNo}`, roomId[gradeNo]);
      });
    });

    socket.on("leaveRoom", (gradeNo) => {
      roomId[gradeNo] = []
    }) 
    socket.on("move", (position, characters, userId, gradeNo) => {
      characters.forEach((character) => {

        if (character.id === userId) {
          character.position = position
        }
      })
      io.to(gradeNo).emit(`characters-${gradeNo}`, characters);
    });

    socket.on("chatMessage", (message, userId, gradeNo) => {
      console.log(userId)
      console.log(message)
      // Assuming the message object has a sender and content property
      io.to(gradeNo).emit("playerChatMessage", {
        id: userId,
        message,
      });
    });
  
    socket.on("disconnect", () => {
      console.log("user disconnected");
      const characters = []
      console.log
      // Broadcast the updated characters array to all connected clients
      io.emit("characters", characters);
    });
  });


//BID/src/Component/Models/SocketManager.jsx


import { useEffect } from "react"
import {io} from "socket.io-client"
import {useAtom, atom} from 'jotai'
import { useSelector } from "react-redux";
import { modelSelector } from "../../Store/modelSlice";

export const socket = io("https://i10a306.p.ssafy.io:3001");
export const charactersAtom = atom([])
export const userAtom = atom(null);

export const SocketManager = () => {
    
    const myInfo = useSelector(modelSelector);
    const gradeNo = myInfo.model.gradeNo
    
    const [, setCharacters] = useAtom(charactersAtom)
    const [, setUser] = useAtom(userAtom);
    useEffect(() => {
  const eventKey = `characters-${gradeNo}`;
  const onCharacters = (newCharacters) => {
    setCharacters((currentCharacters) => {
      // 현재 상태와 새로운 데이터가 동일한지 검사
      if (JSON.stringify(currentCharacters) === JSON.stringify(newCharacters)) {
        // 데이터가 변경되지 않았다면 상태 업데이트 없이 현재 상태를 유지
        return currentCharacters;
      }
      // 데이터가 변경되었다면 새로운 상태로 업데이트
      return newCharacters;
    });
  };

  socket.on(eventKey, onCharacters);
  
  // 컴포넌트 언마운트 시 이벤트 리스너 제거
  return () => {
    socket.off(eventKey, onCharacters);
  };
}, [gradeNo]); // 의존성 배열에 gradeNo 추가

}
  • 3000포트를 사용하는 리액트 프로젝트와 연결을 해서 실시간 양방향 통신이 가능하게끔 구현하였다.

6. React three fiber

  • 일반적으로 웹에서 3D 모델링을 구현하려면 3d 모델을 gltf 파일로 변환해서 해당 3D 모델의 매쉬, 포지션, 스케일을 변수값으로 받아서 구현을 해야하는데,

3D 모델 gltf로 변환해주는 사이트

  • 일단 Public 폴더 내부에 gltf로 변환된 3d 모델을 넣어준 후
npx gltfjsx [파일명].gltf

해당 명령어를 치면

/*
Auto-generated by: https://github.com/pmndrs/gltfjsx
*/

import React from "react";
import { useGLTF } from "@react-three/drei";

export default function BlackBoard(props) {
  const { nodes, materials } = useGLTF("/models/blackboard.glb");
  return (
    <group {...props} dispose={null}>
      <group
        position={[1.198, 1.229, -3.111]}
        rotation={[Math.PI / 2, 0, -Math.PI / 2]}
        scale={[0.753, 0.408, 0.408]}
      >
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Plane008.geometry}
          material={materials["Material.026"]}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={nodes.Plane008_1.geometry}
          material={materials["Material.118"]}
        />
      </group>
    </group>
  );
}

useGLTF.preload("/blackboard.glb");

해당 모델에 대한 script 가 작성이 된다.

위 모델을 불러오기 위해선 react three fiber에서는 Canvas 태그로 감싸져 있어야 한다.

Camera와 Light와 같은 주요 객체에 대한 설정을 추가하여야 하고, OrbitControls를 추가하여 카메라를 돌리며 다양한 각도에서 확인할 수 있다.

공식 문서


  return (
    <>
      <SocketManager />
      <Canvas
        style={{ width: "100%", height: "70vh" }}
        camera={{ position: [12, 10, 20], fov: 30 }}
      >
        <CameraControls minPolarAngle={5} maxPolarAngle={Math.PI / 1} />
        <directionalLight
          position={[1, 1, 1]}
          castShadow
          intensity={3}
        ></directionalLight>
      <OrbitControls
          enableZoom={false} // 확대/축소 비활성화
          enablePan={false} // 패닝 비활성화
        />
        <ambientLight intensity={1.7} />
        <OrbitControls />
        <group scale={20} position={[0, 0, 0]}>
          <mesh
              onPointerDown={handlePointerDown}
              onPointerMove={handlePointerMove}
              onPointerUp={handlePointerUp}
          >
            <Classroom/>
          </mesh>
          {characters.map((character) => (
              <CharacterModel
                  key={character.id}
                  id={character.id}
                  name={character.name}
                  position={
                new THREE.Vector3(
                  character.position[0],
                  character.position[1],
                  character.position[2]
                )
              }
              selectedCharacter={character.selectedCharacter}
              myModelNo={myInfo.myInfo.model.no} // 내 캐릭터의 번호 전달
            />
          ))}
          <BlackBoard />
          <Cactus />
          <Alarm onClick={handleAlarmClick} />
          <Bank />
          <BiddingPlace />
          <SnowBean />
        </group>
        <OrbitControls
          makeDefault
          minAzimuthAngle={2}
          maxAzimuthAngle={2}
          minPolarAngle={Math.PI / 2.55}
          maxPolarAngle={Math.PI / 2.55}
          enableZoom={false}
          enablePan={false}
          enableRotate={false} // 회전 비활성화
          zoomSpeed={1}
        />
        <PerspectiveCamera makeDefault position={[0, 10, 190]} />
      </Canvas>
      {isAlarm && <RealTimeModal handleClick={handleAlarmClick} />}
    </>
  );
}

해당 해서 Canvas로 해당 모델들을 싼후, directionalight, ambientLight 를 추가해서 밝기에 대한 조정을 해주었고, OrbitControls 함수와 PerspectiveCamera를 통해서 카메라 각도를 조절해주었다.

  • React Three Fiber는 Three.js를 보다 편리하고 쉽게 사용할 수 있도록, 도움을 주는 라이브러리이다.

  • 일반적으로 Three.js는 WebGL을 기반으로 하며, 직접적으로 JavaScript로 3D 그래픽을 작성하고 조작하는 데 사용되는데, Three.js는 DOM과 직접 상호 작용하지 않으며, 따라서 React와 함께 사용할 때 몇 가지 문제가 발생할 수 있어 초기 세팅이 React Three Fiber에 비해 훨씬 복잡하다.

  • 해당 이유로, 짧은 시간 내에 3D 모델들과 캐릭터의 움직임만 간단하게 구현 하면 됐던 프로젝트여서 react three fiber를 이용하여서 구현하게 되었다.

profile
새싹 BJY

0개의 댓글