[토이프로젝트]대출이자계산기 만들어보기 - 2편 : Next.js로 웹 서비스 개발하기
- -
지난 글에 이어 계산 기능 구현하기 바로 들어가보겠습니다.
화면에 보이는 계산하기 버튼을 눌러보겠습니다.
! 가 콘솔에 조회되고 있습니다.
다시 코드로 돌아와서 계산기 컴포넌트를 확인해볼까요?
버튼 컴포넌트에 onClick 이벤트로 console.log를 전달했고 정상적으로 실행이 된 것을 알 수 있습니다.
이제 이 버튼 컴포넌트를 클릭하면 계산이 될 수 있게 만들어 보겠습니다.
2-4. 계산 기능 구현하기
계산하기 버튼 클릭시 아래와 같이 팝업이 뜨며 계산 결과를 확인할 수 있게 할 것인데요. 우선 팝업을 추가해보겠습니다.
아이콘 라이브러리 추가
Lucide라는 아이콘 라이브러리를 추가하겠습니다.
npm i lucide-react
설치 후에는 사이트에서 원하는 아이콘 클릭 후 Copy JSX로 복사하고 붙여넣으면 바로 사용이 가능합니다.
팝업 추가
팝업을 만들기 위해 React Portal이라는 것을 이용할 것입니다. React Portal은 DOM 계층 구조 바깥에 있는 DOM 노드로 자식을 렌더링하는 최고의 방법을 제공합니다.
modal 컴포넌트를 생성해보겠습니다.
app/components/modal 디렉토리를 추가합니다. 디렉토리 안에 modal.tsx, modalPortal.tsx, modal.module.scss 파일도 추가합니다.
app/components/modal/modal.tsx
import { Dispatch, ReactNode, SetStateAction } from "react";
import { X } from "lucide-react";
import styles from "./modal.module.scss";
import ModalPortal from "./modalPortal";
type Props = {
setToggleModal: Dispatch<SetStateAction<boolean>>;
children: ReactNode;
};
export default function Modal({ setToggleModal, children }: Props) {
return (
<ModalPortal>
<div className={styles.modal_wrap}>
<div className={styles.modal_content}>
<X className={styles.close_btn} onClick={() => setToggleModal(false)} />
{children}
</div>
</div>
</ModalPortal>
);
}
app/components/modal/modalPortal.tsx
import ReactDOM from "react-dom";
interface ModalPortalProps {
children: React.ReactNode;
}
const ModalPortal = ({ children }: ModalPortalProps) => {
const modalRoot = document.getElementById("modal") as HTMLElement;
return ReactDOM.createPortal(children, modalRoot);
};
export default ModalPortal;
app/components/modal/modal.module.scss
.modal_wrap {
z-index: 99;
height: 100%;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: fixed;
left: 0;
top: 0;
text-align: center;
background-color: rgba(0, 0, 0, 0.16);
.close_btn {
position: absolute;
top: 20px;
right: 20px;
width: 35px;
height: 35px;
cursor: pointer;
color: #333d4b;
z-index: 999;
}
}
.modal_content {
position: relative;
width: max-content;
border-radius: 10px;
background-color: white;
overflow: hidden;
}
모달 팝업이 생성될 위치를 추가하기 위해 layout.tsx를 수정합니다.
app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
import "./font.css";
export const metadata: Metadata = {
title: "대출이자계산기",
description: "간편한 대출이자계산기입니다.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<body>
{children}
<div id="modal"></div>
</body>
</html>
);
}
팝업 실행하기
이제 추가한 팝업을 실행하기 위해 계산하기 버튼 이벤트를 수정해보겠습니다. 계산기 컴포넌트(form.tsx)로 돌아와 아래와 같이 코드를 수정합니다.
app/components/form/form.tsx
"use client";
import { FormProvider, useForm } from "react-hook-form";
import MyInput from "../input/input";
import MyButton from "../button/button";
import MyRadio from "../radio/radio";
import Modal from "../modal/modal";
import styles from "./form.module.scss";
import { useState } from "react";
const OPTIONS = [
{
value: "1",
name: "원리금 균등",
},
{
value: "2",
name: "원금 균등",
},
{
value: "3",
name: "만기 일시",
},
];
export default function Calcurator() {
const [toggleModal, setToggleModal] = useState(false);
const methods = useForm({
mode: "onChange",
defaultValues: {},
});
return (
<FormProvider {...methods}>
<MyRadio id={"paymentMethod"} options={OPTIONS} label={"상환방식"} />
<MyInput id={"amount"} label={"대출원금"} require={true} unit={"원"} />
<MyInput id={"period"} label={"대출기간"} require={true} unit={"년"} />
<MyInput id={"interest"} label={"연이자"} require={true} unit={"%"} />
<MyButton text={"계산하기"} onClick={() => setToggleModal(true)} />
{toggleModal && (
<Modal setToggleModal={setToggleModal}>
<article className={styles.popup}>
<div></div>
</article>
</Modal>
)}
</FormProvider>
);
}
팝업 영역에 스타일도 추가합니다.
app/components/form/form.module.scss
.popup {
margin-right: auto;
margin-left: auto;
padding: 50px 25px;
overflow-y: auto;
position: relative;
width: 460px;
max-width: 460px;
height: 80vh;
text-align: left;
}
이제 F5를 눌러 실행해보겠습니다.
팝업 안에 조회되는 영역은 <div></div> 입니다. 이 영역을 컴포넌트로 분리시켜 계산 결과가 그려지도록 하겠습니다.
props를 통해 상환방식, 대출원금, 대출기간, 연이자 값을 전달해 계산 함수를 실행하면 될 것 같습니다.
그리드 추가
계산 결과가 그려질 그리드 컴포넌트를 만들어보겠습니다.
app/components/grid 디렉토리를 추가합니다. 디렉토리 안에 grid.tsx, grid.module.scss 파일도 추가합니다.
ul,li 태그를 사용하기 위해 리스트 스타일을 초기화해줍니다.
app/globals.css
a, li, ul {
color: inherit;
text-decoration: none;
list-style: none;
}
app/components/grid/grid.tsx
import styles from "./grid.module.scss";
const MyGrid = () => {
return (
<div className={styles.grid_container}>
<div className={styles.contents}>
<ul className={styles.grid_header}>
<li style={{ width: "10%" }}>회차</li>
<li style={{ width: "22%" }}>상환원금</li>
<li style={{ width: "22%" }}>이자액</li>
<li style={{ width: "23%" }}>총납부액</li>
<li style={{ width: "23%" }}>잔여원금</li>
</ul>
<ul className={styles.grid_body}>
<li style={{ width: "10%", textAlign: "center" }}>1</li>
<li style={{ width: "22%", textAlign: "right" }}>5000000</li>
<li style={{ width: "22%", textAlign: "right" }}>500000</li>
<li style={{ width: "23%", textAlign: "right" }}>5000000</li>
<li style={{ width: "23%", textAlign: "right" }}>500000000</li>
</ul>
<ul className={styles.grid_body}>
<li style={{ width: "10%", textAlign: "center" }}>2</li>
<li style={{ width: "22%", textAlign: "right" }}>5000000</li>
<li style={{ width: "22%", textAlign: "right" }}>500000</li>
<li style={{ width: "23%", textAlign: "right" }}>5000000</li>
<li style={{ width: "23%", textAlign: "right" }}>500000000</li>
</ul>
/div>
</div>
);
};
export default MyGrid;
app/components/grid/grid.module.scss
.grid_container {
font-size: 15px;
.grid_header {
min-height: 55px;
display: flex;
padding: 0 10px;
align-items: center;
background: #f9fafb;
margin-top: 20px;
li {
text-align: center;
}
}
.grid_body {
display: flex;
align-items: center;
padding: 0 10px;
transition: background-color 0.2s ease;
min-height: 45px;
}
}
추가한 그리드 컴포넌트를 계산기 컴포넌트에 추가하겠습니다.
app/components/form/form.tsx
"use client";
import { FormProvider, useForm } from "react-hook-form";
import MyInput from "../input/input";
import MyButton from "../button/button";
import MyRadio from "../radio/radio";
import Modal from "../modal/modal";
import styles from "./form.module.scss";
import { useState } from "react";
import MyGrid from "../grid/grid";
const OPTIONS = [
{
value: "1",
name: "원리금 균등",
},
{
value: "2",
name: "원금 균등",
},
{
value: "3",
name: "만기 일시",
},
];
export default function Calcurator() {
const [toggleModal, setToggleModal] = useState(false);
const methods = useForm({
mode: "onChange",
defaultValues: {},
});
return (
<FormProvider {...methods}>
<MyRadio id={"paymentMethod"} options={OPTIONS} label={"상환방식"} />
<MyInput id={"amount"} label={"대출원금"} require={true} unit={"원"} />
<MyInput id={"period"} label={"대출기간"} require={true} unit={"년"} />
<MyInput id={"interest"} label={"연이자"} require={true} unit={"%"} />
<MyButton text={"계산하기"} onClick={() => setToggleModal(true)} />
{toggleModal && (
<Modal setToggleModal={setToggleModal}>
<article className={styles.popup}>
<MyGrid />
</article>
</Modal>
)}
</FormProvider>
);
}
F5를 눌러 실행해보면 아래와 같은 화면이 확인됩니다. 이제 그리드 컴포넌트에서 props로 계산에 필요한 데이터를 전달 받고, 상환 방식에 따라 각 회차별(화살표 표시된 부분)로 금액이 계산되면 될 것 같습니다.
입력값 전달하기
그리드 컴포넌트에 입력값을 전달받기 위한 props를 정의하겠습니다.
app/components/grid/grid.tsx
import { useEffect, useState } from "react";
import styles from "./grid.module.scss";
export type GridType = {
paymentMethod: string;
amount: string;
period: string;
interest: string;
};
type ResultType = {
round: number;
principalPayment: string;
interest: string;
monthlyPayment: string;
remainPayment: string;
};
type Props = {
params: GridType | undefined;
};
const MyGrid = ({ params }: Props) => {
const [result, setResult] = useState<ResultType[]>();
useEffect(() => {
console.log(params);
}, []);
return (
<div className={styles.grid_container}>
<ul className={styles.grid_header}>
<li style={{ width: "10%" }}>회차</li>
<li style={{ width: "22%" }}>상환원금</li>
<li style={{ width: "22%" }}>이자액</li>
<li style={{ width: "23%" }}>총납부액</li>
<li style={{ width: "23%" }}>잔여원금</li>
</ul>
{result?.map((obj, index) => {
return (
<ul className={styles.grid_body} key={index}>
<li style={{ width: "10%", textAlign: "center" }}>{obj.round}</li>
<li style={{ width: "22%", textAlign: "right" }}>{obj.principalPayment}</li>
<li style={{ width: "22%", textAlign: "right" }}>{obj.interest}</li>
<li style={{ width: "23%", textAlign: "right" }}>{obj.monthlyPayment}</li>
<li style={{ width: "23%", textAlign: "right" }}>{obj.remainPayment}</li>
</ul>
);
})}
</div>
);
};
export default MyGrid;
props로 전달받는 값의 타입을 GridType으로 정의합니다. GridType은 export해 계산기 컴포넌트에서 사용할 수 있게 합니다.
- paymentMethod : 상환방식
- amount : 대출원금
- period : 대출기간
- interest : 연 이자
ResultType은 계산 결과의 타입으로 사용됩니다.
- round : 회차
- principalPayment : 상환원금
- interest : 이자액
- monthlyPayment : 총 납부액
- remainPayment : 잔여원금
props로 전달받은 params를 이용해 계산을 실행하며, useEffect의 옵션(useEffect(() => {}, [])으로 최초 한번만 실행 되게 하겠습니다. 이제 계산기 컴포넌트에서 params를 전달해보겠습니다.
app/components/form/form.tsx
"use client";
import { FormProvider, useForm } from "react-hook-form";
import MyInput from "../input/input";
import MyButton from "../button/button";
import MyRadio from "../radio/radio";
import Modal from "../modal/modal";
import styles from "./form.module.scss";
import { useState } from "react";
import MyGrid, { GridType } from "../grid/grid";
const OPTIONS = [
{
value: "1",
name: "원리금 균등",
},
{
value: "2",
name: "원금 균등",
},
{
value: "3",
name: "만기 일시",
},
];
export default function Calcurator() {
const [toggleModal, setToggleModal] = useState(false);
const [params, setParams] = useState<GridType>();
const methods = useForm({
mode: "onChange",
defaultValues: {},
});
const onSubmit = async (form: any) => {
console.log(form);
setParams({
paymentMethod: form.paymentMethod,
amount: form.amount,
period: form.period,
interest: form.interest,
});
setToggleModal(!toggleModal);
};
const onInvalid = (errors: any) => {
console.log(errors);
if (errors && errors.amount) {
alert(errors.amount.message);
return;
} else if (errors && errors.period) {
alert(errors.period.message);
return;
} else if (errors && errors.interest) {
alert(errors.interest.message);
return;
}
};
return (
<FormProvider {...methods}>
<MyRadio id={"paymentMethod"} options={OPTIONS} label={"상환방식"} />
<MyInput id={"amount"} label={"대출원금"} require={true} unit={"원"} />
<MyInput id={"period"} label={"대출기간"} require={true} unit={"년"} />
<MyInput id={"interest"} label={"연이자"} require={true} unit={"%"} />
<MyButton text={"계산하기"} onClick={methods.handleSubmit(onSubmit, onInvalid)} />
{toggleModal && (
<Modal setToggleModal={setToggleModal}>
<article className={styles.popup}>
<MyGrid params={params} />
</article>
</Modal>
)}
</FormProvider>
);
}
params를 관리할 useState와 onSumbmit, onInvalid 함수가 추가됐습니다.
react-hook-form을 사용하는 이유가 바로 여기서 나오는데요. 버튼 컴포넌트의 이벤트로 method.handleSubmit(onSubmit, onInvalid)가 전달됩니다.
작동 원리를 간략히 살펴보면, handleSubmit 메소드를 사용해 클릭 이벤트를 처리하면 onSubmit, onInvalid 콜백 함수를 전달할 수 있습니다. (여기서 함수명은 달라도 상관 없습니다) 이 콜백 함수들은 자동으로 handleSubmit에 의해 호출되며, 이벤트에 대한 파라미터를 받을 수 있습니다.
파라미터를 받을 수 있는 것은 각각의 컴포넌트에 적용된 register 함수와 연관이 있습니다. FormProvider로 래핑된 컴포넌트 내에서 register 함수를 등록하면 입력 요소가 자동으로 관리되고 양식 제출과 유효성 검사 과정이 가능해집니다.
이제 프로젝트를 실행하고 계산하기 버튼을 눌러보겠습니다.
onSubmit을 통해 전달받은 입력 요소들이 조회되는 것을 확인할 수 있습니다. 그리고 값이 비어있다면 onInvalid를 통해 alert 창이 출력됩니다.
그리드 컴포넌트로 이동해보겠습니다. 마찬가지로 계산기 컴포넌트로부터 넘어온 params 값이 잘 조회됩니다.
계산식 적용하기
기획 단계에서 조사한 계산식을 실제로 적용해보겠습니다.
app/components/grid/grid.tsx
import { useEffect, useState } from "react";
import styles from "./grid.module.scss";
export type GridType = {
paymentMethod: string;
amount: string;
period: string;
interest: string;
};
type ResultType = {
round: number;
principalPayment: string;
interest: string;
monthlyPayment: string;
remainPayment: string;
};
type Props = {
params: GridType | undefined;
};
const MyGrid = ({ params }: Props) => {
const [result, setResult] = useState<ResultType[]>();
/**
* 원리금 균등 분할 상환
* 계산식 : M = P * r * (1 + r)^n / ((1 + r)^n - 1)
* @param amount
* @param monthlyRate
* @param month
*/
const formula1 = (amount: number, monthlyRate: number, month: number) => {
const newResults = [];
let remainPayment = amount;
let index = 1;
while (index <= month) {
// 총 납부액
const monthlyPayment =
(amount * monthlyRate * Math.pow(1 + monthlyRate, month)) / (Math.pow(1 + monthlyRate, month) - 1);
// 이자액 : 잔여원금 * 월 이자율
const interest = remainPayment * monthlyRate;
// 상환원금 : 총 납부액 - 이자액
const principalPayment = monthlyPayment - interest;
// 잔여원금 : 잔여원금 - 상환원금
remainPayment = remainPayment - principalPayment;
// console.log(`-- ${index}회차 --`);
// console.log(`총납부액 : ${monthlyPayment)}`);
// console.log(`이자액 : ${interest}`);
// console.log(`상환원금 : ${principalPayment}`);
// console.log(`잔여원금 : ${remainPayment}`);
newResults.push({
round: index,
principalPayment: Math.round(principalPayment).toLocaleString(),
interest: Math.round(interest).toLocaleString(),
monthlyPayment: Math.round(monthlyPayment).toLocaleString(),
remainPayment: Math.abs(Math.round(remainPayment)).toLocaleString(),
});
index++;
}
setResult(newResults);
};
/**
* 원금 균등 분할 상환
* 계산식 : M = P / n + (P * r)
* @param amount
* @param monthlyRate
* @param month
*/
const formula2 = (amount: number, monthlyRate: number, month: number) => {
const newResults = [];
let remainPayment = amount;
let index = 1;
let principalPayment = 0;
while (index <= month) {
// 이자액 : 잔여원금 * 월 이자율
const interest = remainPayment * monthlyRate;
// 총 납부액
const monthlyPayment = amount / month + interest;
// 상환원금 : 총 납부액 - 이자액
if (index === 1) {
principalPayment = monthlyPayment - interest;
}
// 잔여원금 : 잔여원금 - 상환원금
remainPayment = remainPayment - principalPayment;
// console.log(`-- ${index}회차 --`);
// console.log(`총납부액 : ${monthlyPayment}`);
// console.log(`이자액 : ${interest}`);
// console.log(`상환원금 : ${principalPayment}`);
// console.log(`잔여원금 : ${remainPayment}`);
newResults.push({
round: index,
principalPayment: Math.round(principalPayment).toLocaleString(),
interest: Math.round(interest).toLocaleString(),
monthlyPayment: Math.round(monthlyPayment).toLocaleString(),
remainPayment: Math.abs(Math.round(remainPayment)).toLocaleString(),
});
index++;
}
setResult(newResults);
};
/**
* 만기일시 상환
* 계산식 : M = P * r
* @param amount
* @param monthlyRate
* @param month
*/
const formula3 = (amount: number, monthlyRate: number, month: number) => {
const newResults = [];
let index = 1;
let principalPayment = 0;
while (index <= month) {
// 이자액 : 잔여원금 * 월 이자율
const interest = amount * monthlyRate;
// 상환원금 : 마지막 회차에 납부
if (index === month) {
principalPayment = amount;
}
// 총 납부액
const monthlyPayment = interest + principalPayment;
// 잔여원금
const remainPayment = amount - principalPayment;
// console.log(`-- ${index}회차 --`);
// console.log(`총납부액 : ${monthlyPayment}`);
// console.log(`이자액 : ${interest}`);
// console.log(`상환원금 : ${principalPayment}`);
// console.log(`잔여원금 : ${remainPayment}`);
newResults.push({
round: index,
principalPayment: Math.round(principalPayment).toLocaleString(),
interest: Math.round(interest).toLocaleString(),
monthlyPayment: Math.round(monthlyPayment).toLocaleString(),
remainPayment: Math.abs(Math.round(remainPayment)).toLocaleString(),
});
index++;
}
setResult(newResults);
};
useEffect(() => {
const method = params?.paymentMethod;
// 대출원금
const amount = Number(params?.amount);
// 월 이자율 : 대출 연이율 / 12 / 100
const monthlyRate = Number(params!.interest) / 12 / 100;
// 대출기간(월 변환)
const month = Number(params?.period!) * 12;
switch (method) {
case "1":
formula1(amount, monthlyRate, month);
break;
case "2":
formula2(amount, monthlyRate, month);
break;
case "3":
formula3(amount, monthlyRate, month);
break;
}
}, []);
return (
<div className={styles.grid_container}>
<ul className={styles.grid_header}>
<li style={{ width: "10%" }}>회차</li>
<li style={{ width: "22%" }}>상환원금</li>
<li style={{ width: "22%" }}>이자액</li>
<li style={{ width: "23%" }}>총납부액</li>
<li style={{ width: "23%" }}>잔여원금</li>
</ul>
{result?.map((obj, index) => {
return (
<ul className={styles.grid_body} key={index}>
<li style={{ width: "10%", textAlign: "center" }}>{obj.round}</li>
<li style={{ width: "22%", textAlign: "right" }}>{obj.principalPayment}</li>
<li style={{ width: "22%", textAlign: "right" }}>{obj.interest}</li>
<li style={{ width: "23%", textAlign: "right" }}>{obj.monthlyPayment}</li>
<li style={{ width: "23%", textAlign: "right" }}>{obj.remainPayment}</li>
</ul>
);
})}
</div>
);
};
export default MyGrid;
각각의 계산식에서 사용된 변수들의 의미는 다음과 같습니다.
- M : 매월 상환하는 금액
- P : 대출 원금
- r : 대출 연이율의 월 이자율
- n : 대출 기간(월 단위)
r 값을 구하기 위해서는 대출 연이율을 12로 나누고 다시 100으로 나누면 됩니다.(이자율을 백분율로 변환)
n 값은 대출기간을 12로 곱하여 월로 변환합니다.
각각의 계산식에서는 소수점 이하에서 반올림 처리(Math.round()) 했습니다. 소수점 계산에 의해 다른 계산기와 미미한 오차가 발생할 수 있습니다.
formula1()
원리금 균등 분할 상환 방식에 사용된 계산식입니다. n제곱은 javascript에서 Math.pow로 구현할 수 있습니다.
M = P * r * (1 + r)^n / ((1 + r)^n - 1)
formula2()
원금 균등 분할 상환 방식에 사용된 계산식은 다음과 같습니다.
원금 균등 방식은 상환 원금이 일정합니다. 이를 위해 index가 1일 때에만 총 납부액 - 이자액을 통해 상환 원금을 산출해내어 계산했습니다.
M = P / n + (P * r)
formula3()
만기일시 상환 방식에 사용된 계산식은 다음과 같습니다.
대출 기간동안 매월 이자를 상환하고 마지막 회차에 대출원금과 이자를 한번에 상환하는 방식으로 index와 month가 같아질 때 상환 원금이 계산되도록 했습니다.
M = P * r
계산된 결과는 setResult를 통해 배열로 담기고 화면에 그려지게 됩니다.
마지막으로 그리드 스타일을 적용해줍니다. 이후 모바일 환경을 고려하여 미디어쿼리를 적용했습니다.
app/components/grid/grid.module.scss
.grid_container {
font-size: 15px;
.contents {
height: 80vh;
overflow: auto;
box-shadow: rgba(33, 35, 38, 0.1) 0px 10px 10px -10px;
margin-bottom: 20px;
@media (max-width: 480px) {
height: 65vh;
}
}
@media (max-width: 480px) {
font-size: 13px;
}
.grid_header {
min-height: 55px;
display: flex;
gap: 5px;
padding: 0 10px;
align-items: center;
background: #f9fafb;
margin-top: 20px;
li {
text-align: center;
}
}
.grid_body {
font-family: "BookkMyungjo-Bd";
display: flex;
gap: 10px;
align-items: center;
padding: 0 10px;
transition: background-color 0.2s ease;
min-height: 45px;
}
}
네이버 계산기를 통해 결과값을 비교해보겠습니다.
조건1
- 상환방식 : 원리금 균등
- 대출원금 : 1억
- 대출기간 : 1년
- 연이자 : 4.5%
조건2
- 상환방식 : 원금 균등
- 대출원금 : 1억
- 대출기간 : 1년
- 연이자 : 4.5%
조건3
- 상환방식 : 만기일시
- 대출원금 : 1억
- 대출기간 : 1년
- 연이자 : 4.5%
조건4
- 상환방식 : 원리금 균등
- 대출원금 : 2억
- 대출기간 : 5년
- 연이자 : 4.5%
계산 결과가 잘 맞아떨어지는 것을 확인할 수 있습니다.
다음 편에서 이 계산 결과를 공유하는 기능을 만들어보겠습니다.
전체 코드는 GitHub에서 확인하실 수 있습니다.
참고문서
'토이프로젝트' 카테고리의 다른 글
[토이프로젝트]대출이자계산기 만들어보기 - 4편 : Flutter로 모바일 서비스 개발하기 (0) | 2024.05.01 |
---|---|
[토이프로젝트]대출이자계산기 만들어보기 - 3편 : 웹 배포하기 (1) | 2024.04.27 |
[토이프로젝트]대출이자계산기 만들어보기 - 2편 : Next.js로 웹 서비스 개발하기 (1) | 2024.04.27 |
[토이프로젝트]대출이자계산기 만들어보기 - 2편 : Next.js로 웹 서비스 개발하기 (0) | 2024.04.23 |
[토이프로젝트]대출이자계산기 만들어보기 - 1편 : 서비스 기획과 웹 프로젝트 생성 (0) | 2024.04.23 |
소중한 공감 감사합니다.