안녕하세요 ! 시안입니다😊 이번 크리스마스 즈음에 제작했던 프로젝트를 공유 해보고자 글을 작성합니다. 저는 크리스마스 때 지인들에게 편지 쓰는것을 좋아하는데요, 여기서 착안하여 이번에는 편지를 주고받을 수 있는 간단한 어플리케이션을 만들어봤습니다.
요구사항
- 이름을 입력해 등록된 메세지가 있는지 확인할 수 있다.
- 비밀번호를 입력해 본인에게 등록된 메세지를 확인할 수 있다.
- sian(나)에게 크리스마스 편지를 쓸 수 있다.
개발환경
- BackEnd : Typescript, TypeORM, Express, RDS, Docker
- FrontEnd : React, Typescript
- Test : Cypress
프로젝트는 BackEnd, FrontEnd 두개의 폴더로 나눠서 진행했습니다.
BackEnd
BackEnd의 루트경로에 src 폴더를 생성했습니다. src 내 하위 폴더 구조는 아래와 같습니다.
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 내 디렉토리 구조는 아래와 같습니다.
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.tsx
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 desktop > images 에서도 확인 가능
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년 행복한 일만 가득하시길 바랍니다 !