
Product Manager : 이새미(Front-End)
Project Manager : 홍영기(Back-End)
Front-End Teammate : 김성호, 황수현
Back-End Teammate : 최현수, 정원규
Front-End Stack
React, Javascript, HTML, SCSS, Git & Github,Visual Studio Code
Back-End Stack
Javascript, Node.js, Express, MySQL, Git & Github, Visual Studio Code
Communication
Slack, Notion, Trello
Design
Figma
(1) OneBook User Flow

(2) OneBook ERD

이새미(Product Manager)
- User Flow 제작
- 카카오 로그인 & 회원가입
- 가계부 조회 페이지 - 수입/지출 테이블
김성호(Teammate)
- 메인 페이지 - 대시보드(1년 수입/지출과 월별-카테고리별 현황 그래프)
- 메인 페이지 - 메뉴바
- 메인 페이지 - 가계부 참여 & 생성 모달창
황수현(Teammate)
- OneBook 레이아웃 디자인
- 가계부 설정 페이지 - 예산 등록하기
- 가계부 설정 페이지 - 용돈 등록하기
상세역할 : 프로젝트 개발 담당
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import API from '../../config';
import './MenuBar.scss';
const MenuBar = () => {
// 페이지 이동
const navigate = useNavigate();
// 사용자 이름, 권한 상태 지정
const [userName, setUserName] = useState('');
const [userRole, setUserRole] = useState('');
// 로고 클릭시 메인페이지로 이동
const goToMain = () => {
navigate('/main');
};
const TOKEN = localStorage.getItem('TOKEN') || '';
// 로그아웃
const logout = () => {
localStorage.removeItem('TOKEN');
localStorage.removeItem('userName');
localStorage.removeItem('userRole');
alert('로그아웃 되었습니다.');
navigate('/login');
};
// 버튼 데이터
const BUTTONS = [
{ text: 'Home', onClick: () => navigate('/main') },
{ text: '가계부 조회', onClick: () => navigate('/table') },
{ text: '가계부 설정', onClick: () => navigate('/setting') },
{ text: '금융상품 안내', onClick: () => {} },
{ text: '개인정보 수정', onClick: () => {} },
{ text: '로그아웃', onClick: logout, isRed: true },
];
// 사용자 정보(이름, 권한)
useEffect(() => {
fetch(API.UserInfo, {
method: 'GET',
headers: {
'Content-Type': 'application/json;charset=utf-8',
authorization: `Bearer ${TOKEN}`,
},
})
.then((response) => response.json())
.then((data) => {
setUserName(data.userName);
setUserRole(data.userRole === 1 ? '관리자' : '참여자');
})
.catch((error) => {
console.error('로그인 정보를 불러오는 중 에러:', error);
});
}, []);
return (
<div className="menuBarFrame">
<div className="logoFrame">
<img
className="wonBookLogo"
src="/../images/OneBook_Logo_Small.png"
alt="WonBook 로고"
onClick={goToMain}
/>
</div>
<div className="userInfoFrame">
<p className="nameText">{userName}</p>
<p className="adminText">{userRole}</p>
</div>
<div className="menuBarButtonFrame">
<ul>
{BUTTONS.map(
(button, index) =>
!(setUserRole === 0 && button.text === '가계부 설정') && ( //userRole이 0이면서, button.text가 가계부 설정이면 렌더링하지않음
<li key={index} className="buttonList">
<button
className={`menuBarButton${button.isRed ? ' red' : ''}`}
onClick={button.onClick}
>
{button.text}
</button>
</li>
),
)}
</ul>
</div>
</div>
);
};
export default MenuBar;
(2-1) 대시보드(1년 수입/지출 그래프 & 월별 카테고리별 현황 원형차트)
...생략...
<div className="dashboardContainer">
<div className="graphMain">
<div className="graphBarChart">
<p className="yearText">1년 수입/지출 비교</p>
{yearlyData && <GraphBarChart data={yearlyData} />}
</div>
<div className="graphCirculChart" onClick={goToTable}>
<p className="monthText">월별-카테고리별 현황(%)</p>
{monthlyData && <GraphCircularChart data={monthlyData} />}
</div>
</div>
...생략...
(2-2) 1년 수입/지출 그래프 - 막대그래프
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from 'recharts';
const GraphBarChart = ({ data }) => {
const transformedData = [];
for (let month = 1; month <= 12; month++) {
const monthName = month + '월';
const income = data.INCOME[monthName];
const spending = data.SPENDING[monthName];
const newData = {
name: monthName,
수입: income,
지출: spending,
};
transformedData.push(newData);
}
const tickFormatY = (tickItem) => tickItem.toLocaleString();
return (
<BarChart
width={580}
height={280}
data={transformedData}
margin={{
top: 25,
right: 10,
left: 40,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis tickFormatter={tickFormatY} />
<Tooltip />
<Legend />
<Bar
dataKey="수입"
fill="#8884d8"
position="top"
formatter={(value) => new Intl.NumberFormat('ko-KR').format(value)}
/>
<Bar
dataKey="지출"
fill="#82ca9d"
position="top"
formatter={(value) => new Intl.NumberFormat('ko-KR').format(value)}
/>
</BarChart>
);
};
export default GraphBarChart;
(2-3) 월별 카테고리별 현황 - 원형차트
import React, { useState, useEffect } from 'react';
import { PieChart, Pie, Cell, Tooltip, LabelList } from 'recharts';
const GraphCircularChart = ({ data }) => {
const transformedData = [];
// 월 데이터 변환 로직
const [currentMonth, setCurrentMonth] = useState(getCurrentMonth());
// 현재
useEffect(() => {
const interval = setInterval(() => {
const newMonth = getCurrentMonth();
if (newMonth !== currentMonth) {
setCurrentMonth(newMonth);
}
}, 1000 * 60);
return () => clearInterval(interval);
}, [currentMonth]);
function getCurrentMonth() {
const currentDate = new Date();
const month = currentDate.toLocaleString('default', { month: 'long' });
return month;
}
data.forEach((item) => {
let value = 0;
if (item.spending !== '0%') {
const percentage = parseInt(item.spending);
value = Math.round((percentage / 100) * 100);
}
transformedData.push({
name: item.category,
value: value,
});
});
// const total = data.reduce((acc, entry) => acc + entry.value, 0);
return (
<PieChart width={900} height={500}>
<Pie
data={transformedData}
dataKey="value"
isAnimationActive={true}
cx={250}
cy={140}
innerRadius={45}
outerRadius={110}
fill="#82ca9d"
paddingAngle={5}
label
>
{transformedData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
<LabelList
dataKey="name"
position="outside"
content={(props) => {
const { value, index } = props;
const percent = data[index].spending;
return (
<g>
<rect
x={465}
y={196 + index * 28}
width={20}
height={15}
fill={COLORS[index % COLORS.length]}
/>
<text
x={500}
y={210 + index * 28}
fill={COLORS[index % COLORS.length]}
fontWeight="bold"
fontSize={18}
>
{value} ({percent})
</text>
<text
x={255}
y={155}
textAnchor="middle"
fill="#333"
fontSize={24}
fontWeight="bold"
>
{currentMonth}
</text>
</g>
);
}}
/>
</Pie>
<Tooltip />
</PieChart>
);
};
export default GraphCircularChart;
const COLORS = ['#0088FE', '#00C49F', '#FFBB28'];
import Modal from 'react-modal';
...생략...
// 모달창 닫기
const closeModal = () => {
setCurrentModal('');
resetInputStates();
};
// 입력 값 초기화(모달창 닫았을때)
const resetInputStates = () => {
setCheckedMenu('');
setInputValues(INITIAL_INPUT_VALUES);
setIsCompleteEnabled(false);
};
...생략...
...생략...
// input, selectBox 값 변경 여부
const handleInputChange = (fieldName, value) => {
if (fieldName === 'verifyInput') {
// 인증번호 입력값 업데이트
setVerifycationCode(value);
// 참여하기 체크박스가 활성화되면서 인증번호를 입력되면 완료 버튼 활성화
const isVerifycationCodeValid =
checkedMenu === 'partic' && value.length === 8;
setIsCompleteEnabled(isVerifycationCodeValid);
} else {
// 다른 필드의 입력값 업데이트
const updatedInputValues = { ...inputValues, [fieldName]: value };
setInputValues(updatedInputValues);
// 완료 버튼 활성화 여부 업데이트
const { divide, category, day, price, memo } = updatedInputValues;
setIsCompleteEnabled(divide && category && day && price && memo);
// 체크박스 클릭시 비활성화
if (
(fieldName === 'partic' && checkedMenu === 'partic') ||
(fieldName === 'creating' && checkedMenu === 'creating')
) {
resetInputStates();
}
}
};
...생략...
...생략...
<button
className="actionButton"
onClick={() => setCurrentModal('참여')}
>
참여 & 생성하기
</button>
<Modal
isOpen={currentModal === '참여'}
overlayClassName="overlay"
className="modal"
ariaHideApp={false}
>
<button className="closeBtn" onClick={closeModal}>
<img src="/../images/closeMark.png" alt="닫기버튼" />
</button>
<div className="mainFrame">
<div
className={`partic${checkedMenu === 'partic' ? ' selected' : ''}`}
onClick={() => {
if (checkedMenu === 'partic') {
setCheckedMenu('');
} else {
setCheckedMenu('partic');
}
}}
>
<input
className="clickBox"
type="checkbox"
checked={checkedMenu === 'partic'}
readOnly
/>
<p className="clickText">참여하기</p>
<span className="womanEmoji" role="img" aria-label="Emoji">
💁🏻♀️
</span>
<input
className="verifyInput"
type="text"
placeholder="인증번호를 입력해주세요"
maxLength={8}
disabled={checkedMenu !== 'partic'}
onClick={(event) => event.stopPropagation()}
onChange={(event) =>
handleInputChange('verifyInput', event.target.value)
}
/>
</div>
<div
className={`creating${
checkedMenu === 'creating' ? ' selected' : ''
}`}
onClick={() => {
if (checkedMenu === 'creating') {
setCheckedMenu('');
} else {
setCheckedMenu('creating');
}
}}
>
<input
className="clickBox"
type="checkbox"
checked={checkedMenu === 'creating'}
/>
<p className="creatingText">생성하기</p>
<span className="manEmoji" role="img" aria-label="Emoji">
🙋🏻♂️
</span>
<p className="settingText">설정페이지로 이동합니다.</p>
</div>
</div>
<div className="buttonFrame">
<button
className="completeButton"
disabled={!isCompleteEnabled}
onClick={handleComplete}
>
완료
</button>
</div>
</Modal>
...생략...
import Modal from 'react-modal';
...생략...
// 모달창 닫기
const closeModal = () => {
setCurrentModal('');
resetInputStates();
};
// 입력 값 초기화(모달창 닫았을때)
const resetInputStates = () => {
setCheckedMenu('');
setInputValues(INITIAL_INPUT_VALUES);
setIsCompleteEnabled(false);
};
...생략...
...생략...
<button
className="actionButton"
onClick={() => setCurrentModal('수입')}
>
수입/지출 등록하기
</button>
<Modal
isOpen={currentModal === '수입'}
overlayClassName="overlay"
className="modal"
ariaHideApp={false}
>
<button className="closeBtn" onClick={closeModal}>
<img src="/../images/closeMark.png" alt="닫기버튼" />
</button>
<div className="requiredTextMain">
<p className="divideText">구분</p>
<p className="categoryText">카테고리</p>
</div>
<div className="divideFrame">
<select
className="selectBox"
value={inputValues.divide}
onChange={(event) =>
handleInputChange('divide', event.target.value)
}
>
{DIVIDE_LIST.map((divide, index) => (
<option key={index}>{divide}</option>
))}
</select>
<select
className="selectBox"
value={inputValues.category}
onChange={(event) =>
handleInputChange('category', event.target.value)
}
>
{CATEGORY_LIST.map((category, index) => (
<option key={index}>{category}</option>
))}
</select>
</div>
<div className="requiredTextMain">
<p className="dayText">일자</p>
<p className="priceText">금액</p>
</div>
<div className="divideFrame">
<DatePicker
className="selectBox"
selected={inputValues.day}
onChange={(date) => handleInputChange('day', date)}
selectsEnd
dateFormat="yyyy년MM월dd일"
locale={ko}
/>
<input
type="text"
className="priceInput"
placeholder="금액을 입력해주세요"
value={inputValues.price}
onChange={(event) => {
const onlyNumbers = event.target.value.replace(/[^0-9]/g, '');
handleInputChange('price', onlyNumbers);
}}
/>
</div>
<div className="memoTextMain">
<p className="memoText">메모</p>
</div>
<div className="divideFrame">
<input
className="memoInput"
type="text"
maxLength={25}
placeholder="25자 내로 작성해주세요"
value={inputValues.memo}
onChange={(event) =>
handleInputChange('memo', event.target.value)
}
/>
</div>
<div className="yearTextMain">
<p className="yearText">반복 종료 년/월</p>
<p className="optionalText">선택 입력</p>
</div>
<div className="divideFrame">
<select className="selectBox">
{YEAR_LIST.map((year, index) => (
<option key={index}>{year}</option>
))}
</select>
<select className="selectBox">
{MONTH_LIST.map((month, index) => (
<option key={index}>{month}</option>
))}
</select>
</div>
<div className="buttonFrame">
<button
className="completeButton"
disabled={!isCompleteEnabled}
onClick={goToIncomeExpend}
>
완료
</button>
</div>
</Modal>
...생략...
OneBook 팀원들과 소통하는데 사용했던 Slack 채널 방


Front-End & Back-End 간의 티켓 분배 상태들을 관리하는 Trello 협업 툴

Daily Standup Meeting, Planing Meeting, Sprint Meeting 시 기록한 회의록 및 프로젝트 기능 요구 정의서를 정리해둔 Notion 툴



Github 원격에서 Git Convention을 활용한 팀 코드리뷰 수행

Meeting 때마다 매일 아침 10시~11시로 시간을 정해서, 프로젝트 티켓 분배 및 그외 이슈사항들을 팀원과 멘토님에게 공유하며 팀원간의 커뮤니케이션을 증대시키기 위해 노력하였습니다.
[기술 문제]
- 실제로 사용되고 있는 대시보드 내용들은 사용자들에게 보여주는 데이터들이 복잡하고 쉴새없이 많아서, 이 내용들을 그대로 가계부에 적용하게 될시에는 사용자가 수입/지출 내역 및 예산을 등록하고 파악하는데 불편함을 느껴 사용자 불만으로 이어지고 결국엔 사용자 이탈로 이어질수 있는 문제 가능성을 파악했습니다.
- 개선 하기 전
[고민]
- 사용자들이 가계부라는 하나의 제품 안에서 가계부 정보를 파악하기 쉽게끔 수입/지출 내역에 대한 데이터를 간략하게 시각화해서 쉽게 접근할 수 있도록 개선해야할 필요성을 느끼게 되었습니다.
[시도 방법]
- 대시보드 한 페이지 안에 1년 수입/지출과 월별-카테고리별 현황으로 나누어 간결하게 그래프 데이터로 보여주는 방법을 도입했습니다. 이를 통해 사용자들이 필요한 정보를 보다 쉽게 파악하고 활용할 수 있도록 하는 방법으로 보완하였습니다.
- 문제 개선 후
[개선 성과]
- 가계부 데이터들을 하나로 간략하게 시각화함으로써, 사용자들이 수입/지출 내역을 하나로 확인하고 예산을 관리할때 더욱 편리해졌습니다. 이를 통해, 사용자들이 가족 예산과 수입/지출의 내역 정보의 접근성과 편의성을 높여 가계부를 더욱 효율적으로 활용할 수 있도록 개선했습니다.
1) 가장 어려웠던 코드
1-2) 막대그래프 차트
import React from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
} from 'recharts';
const GraphBarChart = ({ data }) => {
const transformedData = [];
for (let month = 1; month <= 12; month++) {
const monthName = month + '월';
const income = data.INCOME[monthName];
const spending = data.SPENDING[monthName];
const newData = {
name: monthName,
수입: income,
지출: spending,
};
transformedData.push(newData);
}
const tickFormatY = (tickItem) => tickItem.toLocaleString();
return (
<BarChart
width={580}
height={280}
data={transformedData}
margin={{
top: 25,
right: 10,
left: 40,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis tickFormatter={tickFormatY} />
<Tooltip />
<Legend />
<Bar
dataKey="수입"
fill="#8884d8"
position="top"
formatter={(value) => new Intl.NumberFormat('ko-KR').format(value)}
/>
<Bar
dataKey="지출"
fill="#82ca9d"
position="top"
formatter={(value) => new Intl.NumberFormat('ko-KR').format(value)}
/>
</BarChart>
);
};
export default GraphBarChart;
1-2) 원형차트
import React, { useState, useEffect } from 'react';
import { PieChart, Pie, Cell, Tooltip, LabelList } from 'recharts';
const GraphCircularChart = ({ data }) => {
const transformedData = [];
// 월 데이터 변환 로직
const [currentMonth, setCurrentMonth] = useState(getCurrentMonth());
// 현재
useEffect(() => {
const interval = setInterval(() => {
const newMonth = getCurrentMonth();
if (newMonth !== currentMonth) {
setCurrentMonth(newMonth);
}
}, 1000 * 60);
return () => clearInterval(interval);
}, [currentMonth]);
function getCurrentMonth() {
const currentDate = new Date();
const month = currentDate.toLocaleString('default', { month: 'long' });
return month;
}
data.forEach((item) => {
let value = 0;
if (item.spending !== '0%') {
const percentage = parseInt(item.spending);
value = Math.round((percentage / 100) * 100);
}
transformedData.push({
name: item.category,
value: value,
});
});
// const total = data.reduce((acc, entry) => acc + entry.value, 0);
return (
<PieChart width={900} height={500}>
<Pie
data={transformedData}
dataKey="value"
isAnimationActive={true}
cx={250}
cy={140}
innerRadius={45}
outerRadius={110}
fill="#82ca9d"
paddingAngle={5}
label
>
{transformedData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
<LabelList
dataKey="name"
position="outside"
content={(props) => {
const { value, index } = props;
const percent = data[index].spending;
return (
<g>
<rect
x={465}
y={196 + index * 28}
width={20}
height={15}
fill={COLORS[index % COLORS.length]}
/>
<text
x={500}
y={210 + index * 28}
fill={COLORS[index % COLORS.length]}
fontWeight="bold"
fontSize={18}
>
{value} ({percent})
</text>
<text
x={255}
y={155}
textAnchor="middle"
fill="#333"
fontSize={24}
fontWeight="bold"
>
{currentMonth}
</text>
</g>
);
}}
/>
</Pie>
<Tooltip />
</PieChart>
);
};
export default GraphCircularChart;
const COLORS = ['#0088FE', '#00C49F', '#FFBB28'];
2차 프로젝트가 끝난뒤, 바로 3차 프로젝트 기획과 개발에 착수했습니다. 이번 3차 프로젝트는 가족의 예산 관리와 자녀들의 경제 교육을 위한 가계부를 개발하는것을 목표로 잡았습니다.
이를 위해 4일 동안 늦은 시간까지 Planing Meeting을 진행하고, 매일 아침 10~11시 마다 Daily Standup Meeting으로 각자 맡은 개발 티켓 상황과 이슈사항을 공유하면서 현업과 똑같은 프로젝트를 수행했습니다. 프로젝트를 진행하면서 실제 회사에서 진행하는 업무를 경험하며 프로젝트의 중요성을 몸소 느낄 수 있었습니다.
제가 맡았던 기능들은 대시보드, 메뉴바, 막대/원형 그래프 구현을 담당했습니다. 개발 수행시 멘토님의 코드리뷰와 해결해야 할 과제가 많았는데, 특히 막대그래프와 원형차트의 구현이 어려웠지만, 이러한 어려움과 에러를 극복하며 적용하기 위해 노력했습니다. 만약에 저희 팀에서 개발한 가계부 프로젝트가 실제 사용자들에게 배포해서 사용에 대한 피드백을 받는다면, 그것을 어떻게 적용해야 하는지 문제 해결의 의미를 생각하며 스스로 반성해야 겠다고 느꼈습니다.
3주 라는 기간동안 고생많으셨고 저는 별도로 개인 프로젝트를 통해 부족한 내용에 대해 스스로 학습하며 회고하는 습관을 가지기 위해, 기업협업을 나가지 않지만 이것도 하나의 큰 도움이 될꺼라고 믿어 의심치 않습니다.다음주 부터 시작되는 기업협업 잘 다녀오시길 바랄께요!
Fighting!!