크리스마스 토이프로젝트 제작기

크리스마스 토이프로젝트 공유

안녕하세요 ! 시안입니다😊 이번 크리스마스 즈음에 제작했던 프로젝트를 공유 해보고자 글을 작성합니다. 저는 크리스마스 때 지인들에게 편지 쓰는것을 좋아하는데요, 여기서 착안하여 이번에는 편지를 주고받을 수 있는 간단한 어플리케이션을 만들어봤습니다.

요구사항

  • 이름을 입력해 등록된 메세지가 있는지 확인할 수 있다.
  • 비밀번호를 입력해 본인에게 등록된 메세지를 확인할 수 있다.
  • sian(나)에게 크리스마스 편지를 쓸 수 있다.

개발환경

  • BackEnd : Typescript, TypeORM, Express, RDS, Docker
  • FrontEnd : React, Typescript
  • Test : Cypress

프로젝트는 BackEnd, FrontEnd 두개의 폴더로 나눠서 진행했습니다.

tree
homepage


BackEnd

BackEnd의 루트경로에 src 폴더를 생성했습니다. src 내 하위 폴더 구조는 아래와 같습니다.

backend tree

data-source.ts

require("dotenv").config();
export const AppDataSource = new DataSource({
    ~ other code ~
    host: process.env.RDS_HOSTNAME, 
    port: Number(process.env.RDS_PORT),
    username: process.env.RDS_USERNAME,
    password: process.env.RDS_PASSWORD,
    database: process.env.RDS_DB_NAME,
    .
    .
  });

DataSource 객체를 생성해 DB 연결을 관리하기 위한 환경변수들을 입력해주었습니다. DataSource는 DB 연결을 관리하기 위한 객체입니다. 먼저 [.env] 파일을 생성해 환경변수를 입력하고, dotenv 모듈을 설치해서 process.env 객체에 저장된 환경변수들을 불러옵니다.

  • .env 란 ?
    • Node.js 에서 사용하는 환경변수를 저장하기 위한 파일입니다.
    • git 과 같은 VCS 에 관리되지 않습니다.
    • 일반적으로 .env 파일은 어플리케이션을 실행할 때 읽어지고, 읽어진 환경변수들은 process.env 객체에 저장됩니다.
    • 이 객체는 Node.js 어플리케이션 전역에서 접근이 가능합니다.
  • dotenv 란 ?
    • .env 파일을 읽어서 환경변수를 설정해주는 Node.js 모듈입니다.
    • dotenv 를 통해 .env 환경변수를 process.env 객체에 저장할 수 있습니다.

index.ts

AppDataSource.initialize()
  .then(async () => {
    // create express app
    const app = express();
    
    //cors
    app.use(cors({
        origin:'http://localhost:3000'
    }));
    
    //body-parser
    app.use(express.json());
    app.use(express.urlencoded({ extended: true }));

    //routes
    app.use("/api/to", toRouter);
    app.use("/api/from", fromRouter);

    // run app
    app.listen(PORT);

    console.log("Server running on port: " + PORT);
  })
  .catch((error) => console.log(error));

index 파일에서는 initialize 메소드가 실행되고, 초기화 작업이 완료되면 콜백함수가 실행됩니다. 콜백함수는 express 앱을 생성하고, cors와 body-parser, 라우팅을 설정합니다.

  • initialize() 란 ?

    • initialize() 메소드는 앱을 시작할 때 필요한 초기화 작업을 수행하는 메소드로, 실행할때마다 데이터베이스 스키마가 동기화시켜줍니다.
  • body-parser

    • 클라이언트에서 서버에 데이터를 보낼 때 body 에 데이터가 담겨 오는데, express 를 사용할 땐 body 에서 데이터를 꺼내쓰기 위해 body-parser 를 사용합니다.
  • Cors

    • Cors 는 출처가 다른 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다. 출처가 다른 자원에 접근 시 CORS 에러가 발생하는데, 이를 허용해주기위해 미들웨어 CORS 를 추가했습니다. Mozilla 에서 Cors 에 대한 자세한 내용을 확인할 수 있습니다.

Entity

TypeORM 의 @Entity 데코레이터를 사용해서 필요한 엔티티를 만들었습니다. 테이블 구성은 아주 간단합니다.

//Sian 이 작성한 메세지 정보를 관리하는 FromSian
@Entity()
  export class FromSian {
    
    @PrimaryGeneratedColumn()
    id: number;
  
    @Column()
    recipient: string;

    @Column()
    pwd : string;
    
    @Column()
    message : string;

    @Column({default : ()=>'CURRENT_TIMESTAMP'})
    createdAt : Date;
  }

//Sian 에게 작성된 메세지 정보를 관리하는 ToSian
@Entity()
  export class ToSian {
    @PrimaryGeneratedColumn()
    id: number;
  
    @Column()
    sender: string;
    
    @Column()
    message : string;

    @Column({default : ()=>'CURRENT_TIMESTAMP'})
    createdAt : Date;
  }

Routes

데이터가 향하는 테이블 별로 라우터 파일을 분리했습니다. 연결된 메서드는 컨트롤러에서 자세하게 소개하겠습니다.

from.routes.ts

import express from 'express';
import * as fromController from '../controller/FromController';

const router = express.Router();

router.get('/recipient',fromController.getCountByName); //query parameter
router.get('/:id', fromController.getMessageById);
router.post('/check/pwd',fromController.confirmPassword);

export default router;

to.routes.ts

const router = express.Router();

router.post('/',toController.saveMesage);

export default router;

Controller

FromController

//FromSian 엔티티에 관련된 작업을 처리하는 Repository 를 가져옴
const fromSianRepository = AppDataSource.getRepository(FromSian);

//요청으로 받은 이름에 해당하는 데이터가 있는지 확인을 하고, 데이터가 존재할 경우에 응답을 보내준다
export async function getCountByName(req:Request, res:Response){
    const inputRecipient = req.query.recipient as string;

    try{
        const [fromSian_object,count] = await fromSianRepository
        .findAndCount({where : {recipient: inputRecipient}});

        res.send({
            success: true,
            data : {
                fromSian_object, 
                count
            }
        })
    }catch(err){
        console.log(err);
    }
}

//요청으로 받은 아이디에 해당하는 메세지 객체응답 (pwd 제외)
export async function getMessageById(req:Request, res:Response){
    const inputId = parseInt(req.params.id);

    try{
        const fromSian_object = await fromSianRepository
        .findOneBy({
            id: inputId,
        });
    
        res.send({
            data : {
                id : fromSian_object?.id,
                recipient : fromSian_object?.recipient,
                createdAt : fromSian_object?.createdAt,
                message : fromSian_object?.message,
            }
        })
    }catch(err){
        console.log(err);
    }
}

//요청 받은 name 과 pwd 가 일치할 경우 success 여부와 data 를 응답하고, 그렇지 않을 경우 false 를 응답
export async function confirmPassword(req:Request, res:Response){
    const inputRecipient = req.body.recipient;
    const  inputPwd = req.body.pwd;

    try{
        const fromSian_object = await fromSianRepository
        .findOneBy({
            recipient: inputRecipient
        });
    
        if(fromSian_object?.pwd !== inputPwd){
            res.send({
                success:false
            });
        }
    
        res.send({
            success: true,
            data : {id : fromSian_object?.id}
        });
    }catch(err){
        console.log(err);
    }
}
  • Repository : 각 엔티티는 해당 엔티티관련 작업을 처리하는 Repository 를 가지고 있습니다.

ToController

//Repository의 save() 메서드를 사용해서 요청된 값을 저장해주고 status와 data 를 리턴
export async function saveMesage(req:Request, res:Response){

    try{
        const toSian_object = await toSianRepository.save(req.body);
        
        res.send({
            status : "success",
            data : toSian_object
        })
    }catch(err){
        console.log(err);
    }
}

이렇게 TypeORM 을 이용해 작성한 아주 간단한 백엔드 코드 소개가 끝났습니다. 다음은 FrontEnd 코드에 대해 소개해보겠습니다.


FrontEnd

FrontEnd 폴더의 src 내 디렉토리 구조는 아래와 같습니다.

frontend tree

routes.tsx

const routes: Route[] = [
  {
    path: "/",
    component: HomePage,
  },
  {
    path: "/detail",
    component: DetailMessage,
  },
  {
    path: "/register",
    component: CreateMessage,
  },
  {
    path: "*",
    component: NotFoundPage,
  },
];

export default routes;

먼저 Route 배열에 path 와 알맞은 컴포넌트를 넣어주었습니다. 상위 경로 중 어떤 것도 해당되지 않을 때 404 페이지로 이동됩니다.

App.tsx

const App: React.FC = () => {
  return (
    <Router>
      <NavBar />
      <div className="container mt-3">
        <Switch>
          {routes.map((route,index) => {
            return (
              <Route
                key={route.path}
                exact
                path={route.path}
                component={route.component}
              />
            );
          })}
        </Switch>
      </div>
    </Router>
  );
};

App.tsx 에서 map 함수의 인자로 import 된 routes 배열을 넣고 routes 값을 Route 컴포넌트 속성의 값으로 각각 할당했습니다. => 여러개의 Route 컴포넌트를 리턴하는 구조

  • map 함수 ?

    • 인자로 주어진 요소에 어떤 함수를 적용한 결과를 새로운 배열로 만들어 리턴해줍니다.
  • Route 컴포넌트 ?

    • Route 컴포넌트는 주소가 지정된 경로와 일치할 때 컴포넌트를 렌더링합니다.
  • Switch 컴포넌트 ?

    • Route 컴포넌트 중 첫번째로 매칭되는 path를 가진 컴포넌트를 렌더링시킵니다. 주소가 일치하는 Route 컴포넌트가 찾아지면 그 이후의 Route 컴포넌트는 처리되지 않습니다.
  • Switch를 사용하지 않는다면 ?

    • 컴포넌트가 중복되어 렌더링 될 수 있습니다.

NavBar는 페이지 최상단에 고정되어있는 네비게이션바를 나타내는 컴포넌트입니다.

const NavBar = () => {
 return (
   ~ other codes ~
         <Link
           className="btn btn-success me-2"
           type="button"
           id="from-Btn"
           onClick={() => {
           //HomePage에서 버튼을 누르면 새로고침이 되도록.
             if (window.location.pathname === "/") {
               window.location.reload();
             }
           }}
           to="/"
         >
       ~ other codes ~
 );
};
export default NavBar;

HomePage.tsx

const HomePage: React.FC = () => {
  const [recipient, setRecipient] = useState<string>("");
  const [count, setCount] = useState<number>();

  const handleMessageCount = async () => {//
    try {
      const res = await getCountByName(recipient);
      setCount(res.count);

      if (!res.count) {
        alert(`${recipient}에게 등록된 메세지가 없습니다`);
      }
    } catch (err) {
      alert(err);
    }
  };

이름을 입력하고 버튼을 누르면 handleMessageCount() 메서드가 실행됩니다. handleMessageCount() 메서드는 입력된 이름을 서버에 보내서 해당 이름으로 등록된 메세지가 있는지 확인해줍니다.

//~other codes~
<button
    className="btn btn-outline-secondary"
    name='check-Btn'
    type="button"
    disabled={!recipient}
    onClick={handleMessageCount}>
    확인하기
  </button>
</div>
{count ? <AlertCard recipient={recipient} />: null}
  </div>
//~other codes~

응답받은 데이터에 count 가 있을 경우 AlertCard 컴포넌트를 보여주고, 이 컴포넌트에 (입력된 이름) recipient 를 props 로 전달합니다 .

AlertCard.tsx

const AlertCard: React.FC<Props> = ({ recipient }) => {
  const history = useHistory();
  const [pwd, setPwd] = useState<string>("");
  let isConfirmed = false;

  const handleMoveDetail = (id: number, isConfirmed: boolean) => {
    history.push({
      pathname: "/detail",
      state: {
        id: id,
        isConfirmed: isConfirmed,
      },
    });
  };

  const handleCheckPwd = async () => {
    try {
      const res = await checkPwd(recipient, pwd);

      if(!res){
        alert("비밀번호가 올바르지 않습니다 !");
      }else{
        isConfirmed = true;
        handleMoveDetail(res.id, isConfirmed);
      }
    } catch (err) {
      alert(err);
      history.push(`/`);
    }
  };
~other codes~

props 를 전달받았습니다. 비밀번호를 입력하고 확인 버튼을 누르면 handleCheckPwd 메서드가 실행됩니다. handleCheckPwd 메서드는 입력된 비밀번호를 서버에 보내서 일치 여부를 응답받고 비밀번호가 일치할 경우 handleMoveDetail 메서드를 실행하고, 일치하지 않을 경우에는 alert 을 발생시켜 사용자에게 알립니다. handleMoveDetail 메서드는 상세페이지로 이동을 시켜줍니다.

DetailMessage.tsx

const DetailMessage: React.FC = () => {
  const location = useLocation();
  const history = useHistory();

  const [message_object, setMessage_object] = useState<Message | null>(null);
  const [loading, setLoading] = useState(true);

  const state = location.state as detailInfo;

  //메세지 객체를 가져와서 페이지에 정보를 뿌려줌
  const handleShowMessage = async (id: number, isConfirmed: boolean) => {
    if (!isConfirmed) {
      alert("올바른 접근이 아닙니다 !");
      history.push(`/`);
    }
    try {
      const message = await getMessageById(id);
      setMessage_object(message);
    } catch (err) {
      alert(err);
    }
    setLoading(false);
  };

  useEffect(() => {
    handleShowMessage(state.id, state.isConfirmed);
    console.log(location);
  }, [state.id]); //eslint-disable-line

컴포넌트가 마운트 될 때 handleShowMessage() 메서드가 실행됩니다. handleShowMessage 메서드는 서버로부터 id 에 해당하는 메세지 객체를 받아옵니다. 만약 응답받은 메세지 객체가 있다면, setMessage_object useState 에 할당되어 화면에 정보가 뿌려지고, loading useState 가 false 로 변경됩니다. 따라서 서버로부터 응답을 받기 전까지 사용자는 빙글빙글 돌아가는 로딩 컴포넌트 loadingSpinner 를 보게됩니다.

  • 마운트 ?
    • 리액트에서는 컴포넌트가 처음 렌더링될 때 ‘마운트’ 된다고 합니다.

CreateMessage.tsx

const CreateMessage: React.FC = () => {
  const history = useHistory();
  const [sender, setSender] = useState<string>("");
  const [message, setMessage] = useState<string>("");

  const handleMessageSubmit = () => {
    try {
      postToSian(sender, message);
      alert(`${sender}님의 메세지가 등록되었습니다. HappyChristmas!`);
      history.push(`/`);
    } catch (err) {
      alert(err + "에러");
      history.push(`/`);
    }
  };
~other codes~

등록 페이지에서는 내용 입력하고 작성하기 버튼을 클릭하면 handleMessageSubmit 메서드가 실행됩니다.handleMessageSubmit 메서드는 입력된 작성자와 내용을 등록시켜줍니다.

Test

총 8개 기능에 대하여 Cypress 프레임워크를 사용해 자동화 테스트를 진행했습니다. 간단하게 두가지 기능만 설명해보겠습니다.

const baseURL = "http://localhost:3000";

const testData = {
  id: 1,
  name: "홍길동",
  message: "안녕",
  pwd: "1",
};

const _testData = {
  name: "등록되지않은이름",
};

먼저 test 할 더미데이터를 만들었습니다.

context("메인페이지", () => {
  beforeEach(() => {
    cy.visit(`${baseURL}`);
  });

  function showAlertCard() {
    cy.get("[name=recipient]")
      .type(testData.name)
      .get("[name=check-Btn]")
      .click()
      .get(".alert-card")
      .should("be.visible");
  }

  describe("등록된 이름이고,", () => {
    it("비밀번호가 틀리면 Alert을 보여준다", () => {
      showAlertCard();

      cy.get("[name=pwd-input]")
        .type("잘못된 비밀번호")
        .get("[name=checkPwd-Btn]")
        .click();

      cy.on("window:alert", (str) => {
        expect(str).to.equal("비밀번호가 올바르지 않습니다 !");
      });
    });
  });
});

각 테스트 코드의 역할은 코드에 이미 설명되어있기 때문에 부연적인 설명을 하지 않겠습니다.


describe("등록페이지", () => {
     beforeEach(() => {
    cy.visit(`${baseURL}/register`);
  });
     it("내용을 입력하고 작성하기 버튼을 누르면 Alert이 발생하고 홈페이지로 이동한다. .", () => {
    cy.get("[name=sender]").type(`${testData.name}`);
    cy.get("[name=message]").type(`${testData.message}`);
    cy.get("[name=create-Btn]").click();
    cy.on("window:alert", (str) => {
      expect(str).to.equal(
        `${testData.name}님의 메세지가 등록되었습니다. HappyChristmas!`
      );
    });
    cy.location().should((location) => {
      expect(location.pathname).to.eq("/");
    });
  });
});
  • describe(): 테스트 케이스의 집합을 설명할 때 사용합니다. describe 함수 안에는 it 함수와 같은 테스트 케이스가 위치합니다.
  • context(): describe 함수와 유사하게 테스트 케이스의 집합을 설명할 때 사용합니다. 그러나 context 함수는 같은 설명을 가진 테스트 케이스를 그룹핑할 때 주로 사용합니다.
  • it(): 개별 테스트 케이스를 작성할 때 사용합니다. it 함수 안에는 테스트 케이스가 실제로 수행해야 할 작업이 위치합니다.


Docker

번외로 BackEnd 폴더를 Docker 에 올린 히스토리를 공유해보겠습니다. 도커에 대한 자세한 설명은 공식문서를 참고해주시길 바랍니다.

앞서 설명한 것과 같이, 프로젝트는 BackEnd / FrontEnd 두개의 폴더로 구성되어있고, 그 중 BackEnd 폴더만 Docker 에 올려보겠습니다. 단계는 아래와 같습니다.

1. 루트 경로에 Dockerfile 생성

FROM node:16 //기반 이미지
WORKDIR /usr/src/app //작업을 수행할 디렉토리 정의

COPY package*.json ./  //package.json 파일과 package-lock.json 파일을 복사
RUN npm install //위에서 복사한 파일을 이용해 이미지에 필요한 npm 패키지 설치

COPY . . //현재 작업 디렉토리의 모든 파일을 이미지에 복사

EXPOSE 4000 //이미지가 실행될 때 외부로 열어줄 포트를 지정

CMD ["npm", "run", "dev"] //이미지가 실행될 때 실행할 명령을 지정. 여기서는 npm run dev 명령을 실행함

2. 루트 경로에 .dockerignore 파일 생성

node_modules
npm-debug.log

3. 이미지 빌드

docker build . -t {username}/{이미지이름}

4. 빌드된 이미지 확인

docker images

docker images


또는 Docker desktop > images 에서도 확인 가능

docker desktop


5. 컨테이너 생성/실행

아래 명령어를 통해 컨테이너가 실행되면서 실행되는 컨테이너 안의 server 는 localhost:4000으로 접근할 수 있다.

docker run -d -p 4000:4000 --name proj seohyunhan/happy-christmas
  • docker run : Docker 컨테이너 실행 명령어입니다.

  • -d : 컨테이너를 백그라운드에서 ‘detached’ 모드로 실행하는 것으로, 컨테이너가 실행된 이후에도 콘솔을 유지할 수 있습니다.

  • -p : 컨테이너에서 사용하는 포트를 호스트머신의 포트로 연결하는 것 입니다. 컨테이너의 4000 포트를 호스트머신의 4000번 포트로 연결하고있습니다. 이렇게 하면 localhost:4000 으로 접근할 수 있게 됩니다.

컨테이너를 생성하고 실행하는 단계에서 아래와 같은 오류가 발생할 수 있습니다.

denied: requested access to the resource is denied

이미지의 username 과 Docker hub 에 가입된 ID 가 일치하지 않아서 생긴 오류로, 등록된 이미지를 삭제하고, 양식에 맞춰 이미지를 재생성해주어 해결할 수 있습니다.

docker build . -t {이미지이름} //이미지 생성시 이미지 이름만 적음 => 에러
docker build . -t {username}/{이미지이름} //양식에 맞춰 새로운 이미지 생성 => 해결

위 단계를 마치면 Docker Desktop 에서 정상적으로 작동하는 것을 확인할 수 있습니다.

Docker 볼륨

도커에 BackEnd를 올려서 실행시켜보았습니다. 아주 간단하죠 ? 그렇다면 호스트머신의 BackEnd 폴더에서 수정사항이 생겼을 때는 Docker 컨테이너에 어떻게 반영할 수 있을까요 ? 현재로서는 image 와 컨테이너를 새로 생성해서 해결하는 방법이 있습니다. 그렇지만 수정할때마다 이 작업을 반복하기엔 소모적입니다. 도커 볼륨 을 이용해서 호스트머신과 컨테이너가 디렉토리를 공유할 수 있는 환경을 만들어보겠습니다. 도커볼륨에 관한 자세한 설명은 공식문서를 참고해주세요.

먼저 가동중인 컨테이너를 중지하고 삭제하겠습니다.

docker stop {컨테이너 이름}
docker rm {컨테이너 이름}

컨테이너를 생성하고 실행하며 동시에 컨테이너의 데이터를 호스트머신의 특정디렉토리와 맵핑해주는 마법같은 명령어를 입력해봅시다.

 docker run -d -p 4000:4000 -v $("pwd"):/usr/src/app --name {지정할 컨테이너 이름} {image 이름}

컨테이너를 생성하고 실행하는 명령어는 이미 위에서 설명했으니 생략하겠습니다.

  • -v : Docker 컨테이너에 볼륨을 마운트 할때 사용합니다.
  • -v 뒤에는 : 로 구분된 두개의 인수가 있고, 이 인수들은 볼륨의 호스트 경로와 컨테이너 경로를 지정합니다.
    • $(“pwd”) : 호스트머신의 현재 작업 디렉토리를 의미합니다.
    • /usr/src/app : 컨테이너 안에서 볼륨이 마운트 될 디렉토리 경로입니다.

즉 위 명령어는 호스트 머신의 현재 작업디렉토리가 컨테이너 안의 /usr/src/app 경로에 매핑되도록 지시하고있습니다.

호스트머신에서 파일을 수정하고 컨테이너를 확인해보시면 수정내용이 정상적으로 반영된 것을 확인하실 수 있습니다. 반대로 도커에 접속해서 수정했을 시 호스트머신에서도 바로 확인이 가능합니다. 도커 볼륨을 사용해서 image 와 컨테이너를 새로 생성하지 않아도 수정사항이 도커에 반영되는 것을 확인해보았습니다.

글을 마치며

시간 내어 긴글을 읽어주셔서 감사합니다. 크리스마스를 기다리며 즐거운 마음으로 만든 토이 프로젝트라서 함께 공유하고 싶은 마음에 글이 많이 길어진 것 같습니다😅 많은 피드백과 도움주신 멤버들께 감사합니다. 글 읽으시는 모든분들 새해복많이 받으시고 2023년 행복한 일만 가득하시길 바랍니다 !

디지엠유닛원 주식회사

  • 대표이사 권혁태
  • 개인정보보호책임자 정경영
  • 사업자등록번호 252-86-01619
  • 주소
    서울특별시 금천구 가산디지털1로 83, 6층 601호(가산동, 파트너스타워)
  • 이메일 unit1@dgmit.com