Project

[개인 프로젝트] 예산 관리 어플리케이션

newny 2023. 11. 17. 08:35
반응형

github 주소

GitHub - newnyee/saving: 💰 예산 관리 어플리케이션

💰 예산 관리 어플리케이션. Contribute to newnyee/saving development by creating an account on GitHub.

github.com

 

❓ 프로젝트 목적

- JWT 발급/인증
- RESTful API 설계
- Spring Scheduler를 활용한 알림 서비스
- 동적 쿼리를 이용한 통계 및 통계 확인을 위한 Dummy data 생성
- 타이트한 개발 일정 분배
 

📆 작업 기간 & 인원

2023.11.09 ~ 2023.11.15 (7일)
2024.01 ~ 리팩토링 진행중
개인 프로젝트
 

🎤 프로젝트 소개

본 서비스는 사용자들이 개인 재무를 관리하고 지출을 추적하는 데 도움을 주는 애플리케이션입니다. 이 앱은 사용자들이 예산을 설정하고 지출을 모니터링하며 재무 목표를 달성하는 데 도움이 됩니다.
 

⏳ 요구사항 분석

모든 테이블에는 자동 생성되는 id가 존재합니다.
 

A. 사용자

  • 사용자는 본 사이트에 들어와 회원가입을 통해 서비스를 이용합니다.
  • 회원가입에는 계정, 비밀번호, 오늘 지출 추천 동의 유무, 오늘 지출 안내 동의 유무가 포함되어야 합니다.
  • 비밀번호는 암호화하여 저장되어야 합니다.
  • 회원 가입시 계정은 5~20자, 비밀번호는 8~16자의 제한을 두어야 합니다.
  • 사용자 로그인 시 해당하는 계정과 비밀번호 입력 후 JWT를 발급 받습니다.
  • 이후 모든 API 요청 Header에 JWT가 항시 포함되며, JWT 유효성을 검증합니다.

B. 카테고리

  • 카테고리는 식비, 교통 등 일반적인 지출 카테고리를 의미합니다.
  • 기본으로 제공되는 카테고리로는 식비, 교통비, 공과금, 생활용품, 통신비, 기타 입니다.
  • 추후 확장성을 고려하여 관리자가 입력할 기본 카테고리(가입 시 제공되는 카테고리)와 사용자들이 자유롭게 카테고리를 생성, 수정, 삭제 할 수 있는 카테고리를 분리하여 따로 관리합니다.

C. 예산

  • 예산은 한달을 기준으로 합니다. (1일~말일)
  • 예산은 카테고리별로 지정할 수 있습니다.
  • 사용자는 예산을 생성, 수정할 수 있습니다.
  • 예산 생성시 해당하는 카테고리의 아이디, 예산액, 예산할 년월 정보가 포함되어야 합니다.
  • 예산 설정에 어려움이 있는 사용자를 위해 예산 비율 추천 기능이 존재합니다.
    • 카테고리 지정 없이 한 달 예산 총 액을 입력하면, 사용자들이 설정한 카테고리 별 예산의 평균 값으로 예산 설정을 합니다. (사용자들이 설정한 카테고리 별 예산을 불러올 수 없을 경우 default_categories 테이블의 default_budget에 설정되어있는 비율로 계산됨)
    • 10% 이하의 카테고리들은 모두 묶어 기타로 제공합니다. (문화 8%, 레저 7% 라면 기타 15%로 표기)
    • 위 비율에 따라 예산이 생성됩니다.

D. 지출

  • 지출 생성 시 해당하는 카테고리의 아이디, 카테고리 이름(추후 확장성을 위해 저장), 지출 방법(카드, 현금, 계좌이체), 지출 금액, 메모, 지출 합계 제외 여부, 지출 날짜 정보가 포함되어야 합니다.
  • 사용자는 지출 생성, 수정, 읽기(상세, 목록), 삭제가 가능합니다.
  • 지출 목록 조회
    • 지출 목록은 기본적으로 기간으로 조회 가능합니다.
    • 합계 제외 처리한 지출 목록에는 포함되지만, 모든 지출 합계에서 제외됩니다.
      • 기간으로 조회 : 지출 합계, 카테고리 별 지출 합계를 같이 반환합니다.
      • 기간 + 카테고리로 조회 : 특정 카테고리만 조회 가능합니다.
      • 기간 + 금액으로 조회 : 금액의 범위를 입력하여(min~max) 조회 가능합니다.

E. 지출 컨설팅

  • 지출 컨설팅은 오늘 지출 추천과 오늘 지출 안내 두 가지로 나뉩니다.

오늘 지출 추천

  • 설정한 월 별 예산을 만족하기 위해 오늘 지출 가능한 금액을 총액과 카테고리 별 금액으로 제공합니다.
    • 앞선 일자에서 과다 소비하였을 경우 소비를 그에 맞게 극히 줄이는 것이 아닌, 이후 일자에 부담을 분배합니다.
    • 지속적인 소비 습관을 생성하기 위한 서비스 이므로 기간 전체 예산을 초과하더라도 0원 또는 음수의 예산을 추천 받지 않아야 합니다. 정책 상 설정된 최소 금액으로 추천해야합니다.
    • 유저의 상황에 맞는 적절한 문장의 멘트를 노출합니다.
    • 일원, 십 원 단위의 값이라면 백 원 단위까지 반올림하여 사용자 친화적인 금액을 변환합니다.
  • 매일 8:00시에 알림을 발송합니다. (디스코드 웹훅)

오늘 지출 안내

  • 오늘 지출한 내용을 총액과 카테고리 별 금액으로 안내합니다.
  • 월별 설정한 예산 기준 카테고리 별 통계를 제공합니다.
    • 일자기준 오늘 적정 지출 금액: 오늘 기준 사용했으면 적절했을 금액
    • 일자기준 오늘 지출 금액: 오늘 기준 사용한 금액
    • 위험도: 카테고리 별 적적 금액, 지출 금액의 차이를 위험도로 나타내며 유저의 상황에 맞는 적절한 문장의 멘트를 노출합니다.
  • 매일 20:00시에 알림을 발송합니다. (디스코드 웹훅)

F. 지출 통계

  • 통계를 위해 dummy data 생성
  • 지난달 대비 지출총액, 카테고리별 소비율을 보여줍니다.
  • 지난 요일 대비 소비율을 보여줍니다.
  • 다른 유저 대비 소비율을 보여줍니다.
    • 다른 유저의 이번달 예산 기준 해당 일자 까지의 지출액 비율 평균과 나의 이번달 예산 대비 해당 일자 까지의 지출액 비율을 비교하여 %(퍼센트)로 제공합니다. (ex. 오늘 지준 다른 유저가 소비한 지출이 평균 50% (다른 유저의 평균 예산대비 지출 비율이 50%) 이고 나는 60% 라면 다른 사용자 대비 120%로 제공)

 

🧪 요구사항 분석에 따른 개체 추출

개체속성
사용자사용자 아이디(pk), 유저 계정(uk), 비밀번호, 오늘 지출 추천 알림 여부, 오늘 지출 안내 알림 여부, 생성일, 수정일
카테고리카테고리 아이디(pk), 카테고리 이름, 생성일, 수정일
예산예산 아이디(pk), 예산액, 예산년월, 생성일
지출지출 아이디(pk), 지출 방법, 지출액, 메모, 지출 총액 합산 여부, 지출일
기본 카테고리기본 카테고리 아이디(pk), 카테고리 이름, 카테고리별 예산율

 

🔨 구현 과정

개체 추출에 따른 ERD 작성

 

API 설계

prefix : /api/v1

DescriptionMethodURL
회원가입POST/users
로그인 POST /users/login
카테고리 목록GET/categories
예산 설정POST/categories/{categoryId}/budgets
예산 설정 추천 (자동) POST /categories/budgets/auto
예산 설정 수정PUT/categories/{categoryId}/budgets/{budgetId}
지출 기록POST/expenses
지출 목록 조회GET/expenses?search
지출 조회 상세GET/expenses/{expenseId}
지출 수정PUT/expenses/{expenseId}
지출 삭제DELETE/expenses/{expenseId}
지출 통계GET/expenses/stats

 

📌 역할 및 구현 사항

Redis와 Access Token, Refresh Token을 이용한 인증 구현

1. 해당 프로세스 구현을 위해 Redis와 Access Token, Refresh Token에 대한 선행 학습 후 블로그 글 작성


2. 인증 프로세스 리팩토링

토큰 인증 프로세스 순서도

 
 
 

자동 배포를 위한 CI/CD 파이프라인 구축

1. 자동 배포 구축을 위해 선행 학습 후 블로그 글 작성


2. Github Actions를 이용하여 CI/CD 파이프라인을 구축

CI/CD 파이프라인 아키텍처

 
 

Notion과 JIRA를 활용한 프로젝트 일정 분배

  • Notion
    • 미리 만들어 놓은 프로젝트 일정관리 format을 이용하여 계획 순서에 맞게 프로젝트 계획을 진행하였다.
    • 이번 프로젝트는 일정이 굉장히 타이트 하여 첫날 설계까지 마친 후 API를 중요도 순으로 나누어 개발을 시작했다.
  • JIRA
    • 팀 프로젝트를 진행할 때 처럼 JIRA 를 이용하여 프로젝트를 체계적으로 관리하였다.
    • IntelliJ와 연동하여 티켓을 만들어 티켓에 해당하는 브랜치를 생성하여 Pull Request 하여 체계적으로 관리하였다.

 
 
API 중요도에 따라 순차적으로 개발

중요도 순으로 나눈 API
1. 회원가입 API, 로그인  API , 카테고리 목록  API , 예산 설정  API , 예산설정 수정  API
2. 지출 기록  API, 지출 목록 조회  API, 지출 조회 상세  API, 지출 수정  API, 지출 삭제  API
3. 지출 통계  API, 오늘 지출 안내(Discode webhook 이용한 알림서비스)
4. 추후 작업 → 예산 설정 추천 (자동) API, 오늘 지출 추천 알림, 테스트 코드 작성
 
중요도를 어떤 기준으로 할까 고민을 한 결과 가장 기본적인 부분(사용자 관련 API, 지출관련API, 예산관련 API) 없이는 서비스가 시작 조차 될 수없다고 판단하여 기본적인 API부터 빠르게 구현하는 것을 기준으로 하였다. 또한 Spring Scheduler를 이용한 Discode webhook 알림을 구현하는 부분이 두개(오늘 지출 안내, 오늘 지출 추천)로 나뉘는데 이 부분은 기술을 사용해보는것에 초점을 두어서 '오늘 지출 안내'만 구현해보는것으로 결정하였다.
기간이 굉장히 짧았기 때문에 요구사항 분석을 꼼꼼히 하여 한개의 API당 개발하는데 걸리는 시간을 예상하여 전체 API를 개발 기간에 맞게 분배하였다. 개발 해야할 섹션이 크게는 13개 정도 되었는데 두개의 섹션은 추후 작업으로 생각하고 나머지 11개의 섹션을 완료하는것으로 목표했다. (전체 개발 중 85% 개발 계획)
실제로 계획한 기간 안에 전체의 85%개발을 완료하였고 추후 작업을 진행중이다.
 
 

JWT를 이용한 인증 구현

JWT 구현을 위해 JWT util Class를 생성하였다.
예전 프로젝트를 진행했을 때 서비스 부분과 필터 부분에서 비밀키(Seceret Key) 와 만료시간(Expired Ms)이 무분별하게 사용 됐었는데 이번 프로젝트에서는 configClass를 생성하여 JwtUtil에 필요한 비밀키와 만료시간을 한 클래스 안에서 관리할 수 있게 하였다.
그리고 이번 프로젝트에서는 Filter를 사용하지 않고 Interceptor를 이용하여 JWT 인증을 구현하였다.(이전 프로젝트에서는 Filter를 이용하여 JWT인증을 구현하였다)
Interceptor를 이용한 이유는 개발 기간이 타이트한 관계로 Filter 사용시에 구현해야할 부분(예외처리)에 대한 시간을 줄이기 위해 사용하였다.
결과적으로 ExceptionHandler가 예외를 처리할 수 있게하여 코드도 간결해지고 개발 시간도 줄이게되었다.

왼 - JwtConfig / 중간 - JwtUtil / 오 - Interceptor

 
 
사용자의 통계 데이터 생성을 위한 Dummy data 생성

지출 통계를 확인하기 위한 Dummy data가 필요했는데 Procedure를 이용하여 생성하였다. 구글링을 해보았을 때 다른 방법들이 많았지만 Procedure 사용법을 익히고 싶어서 사용하였다. SQL문을 이용하다보니 이해하는시간이 오래걸리지않아서 해당 프로젝트에 알맞겠다 생각하였다.

DELIMITER $$
CREATE PROCEDURE insertUsersDummyData()
BEGIN
    DECLARE i INT DEFAULT 1;
    while i <= 50 do
        if i <= 25 then
            insert into users(username, password, is_today_budget_notice, is_today_expense_notice, created_at, updated_at)
                value (concat('user', i), '$argon2id$v=19$m=16384,t=2,p=1$r4Y+8E4QJAnNiwzUUrg2kQ$07ttBI0+zxjHFIMrjdiTBclVts+KrnBL7Uv4IQm+EBk', 1, 1, '2023-01-01', '2023-01-01');
        else
            insert into users(username, password, is_today_budget_notice, is_today_expense_notice, created_at, updated_at)
                value (concat('user', i), '$argon2id$v=19$m=16384,t=2,p=1$r4Y+8E4QJAnNiwzUUrg2kQ$07ttBI0+zxjHFIMrjdiTBclVts+KrnBL7Uv4IQm+EBk', 0, 0, '2023-01-01', '2023-01-01');
        end if;
            insert into categories(user_id, category_name, created_at, updated_at)
                value (i, '식비','2023-01-01', '2023-01-01');
            insert into categories(user_id, category_name, created_at, updated_at)
                value (i, '교통비','2023-01-01', '2023-01-01');
            insert into categories(user_id, category_name, created_at, updated_at)
                value (i, '공과금','2023-01-01', '2023-01-01');
            insert into categories(user_id, category_name, created_at, updated_at)
                value (i, '생활용품','2023-01-01', '2023-01-01');
            insert into categories(user_id, category_name, created_at, updated_at)
                value (i, '통신비','2023-01-01', '2023-01-01');
            insert into categories(user_id, category_name, created_at, updated_at)
                value (i, '기타','2023-01-01', '2023-01-01');
            set i = i + 1;
        end while;
END $$
DELIMITER $$
call insertUsersDummyData();
drop procedure insertUsersDummyData;


DELIMITER $$
CREATE PROCEDURE insertBudgetsDummyData()
BEGIN
    DECLARE i INT DEFAULT 1;
    DECLARE j INT DEFAULT 10;
    while j <= 11 do
        while i <= 300 do
            if i%6 = 1 then
                insert into budgets(category_id, amount, budget_year_month, created_at)
                    value (i, 400000, concat('2023-', j, '-01'), concat('2023-', j, '-01'));

            elseif (i%6 = 2) then
                insert into budgets(category_id, amount, budget_year_month, created_at)
                    value (i, 100000, concat('2023-', j, '-01'), concat('2023-', j, '-01'));

            elseif (i%6 = 3) then
                insert into budgets(category_id, amount, budget_year_month, created_at)
                    value (i, 200000, concat('2023-', j, '-01'), concat('2023-', j, '-01'));

            elseif (i%6 = 4) then
                insert into budgets(category_id, amount, budget_year_month, created_at)
                    value (i, 300000, concat('2023-', j, '-01'), concat('2023-', j, '-01'));

            elseif (i%6 = 5) then
                insert into budgets(category_id, amount, budget_year_month, created_at)
                    value (i, 100000, concat('2023-', j, '-01'), concat('2023-', j, '-01'));

            else
                insert into budgets(category_id, amount, budget_year_month, created_at)
                    value (i, 300000, concat('2023-', j, '-01'), concat('2023-', j, '-01'));

            end if;
            set i = i + 1;
        end while;
        set i = 1;
        set j = j + 1;
    end while;
END $$
DELIMITER $$
call insertBudgetsDummyData();
drop procedure insertBudgetsDummyData;


DELIMITER $$
CREATE PROCEDURE insertExpensesDummyData()
BEGIN
    DECLARE i INT DEFAULT 10;
    DECLARE j INT DEFAULT 1;
    DECLARE x INT DEFAULT 1;

    while i <= 11 do
        while x <= 300 do
            if x % 6 = 1 then
                while j <= 30 do
                    insert into expenses(category_id, category_name, expense_method, amount, content, is_total_expense_apply, expense_at)
                        value (x, '식비', 'C', j*800 + 3000, '식비', i-10, concat('2023-', i, '-', j));
                set j = j + 1;
                end while;
            elseif (i % 6 = 2) then
                while j <= 30 do
                    insert into expenses(category_id, category_name, expense_method, amount, content, is_total_expense_apply, expense_at)
                        value (x, '교통비', 'C', 1200, '식비', i-10, concat('2023-', i, '-', j));
                set j = j + 1;
                end while;
            elseif (i % 6 = 3 and j = 14) then
                while j <= 30 do
                    insert into expenses(category_id, category_name, expense_method, amount, content, is_total_expense_apply, expense_at)
                        value (x, '공과금', 'C', 150000, '식비', i-10, concat('2023-', i, '-', j));
                    set j = j + 1;
                end while;
            elseif (i % 6 = 4) then
                while j <= 30 do
                    insert into expenses(category_id, category_name, expense_method, amount, content, is_total_expense_apply, expense_at)
                        value (x, '생활용품', 'C', j*1000 + 3000, '식비', i-10, concat('2023-', i, '-', j));
                    set j = j + 1;
                end while;
            elseif (i % 6 = 5 and j = 14) then
                while j <= 30 do
                    insert into expenses(category_id, category_name, expense_method, amount, content, is_total_expense_apply, expense_at)
                        value (x, '통신비', 'C', 80000, '식비', i-10, concat('2023-', i, '-', j));
                    set j = j + 1;
                end while;
            else
                while j <= 30 do
                    insert into expenses(category_id, category_name, expense_method, amount, content, is_total_expense_apply, expense_at)
                        value (x, '기타', 'C', j*2000 + 3000, '식비', i-10, concat('2023-', i, '-', j));
                    set j = j + 1;
                end while;
            end if;
            set j = 1;
            set x = x + 1;
        end while;
        set x = 1;
        set i = i + 1;
    end while;
END $$
DELIMITER $$
call insertExpensesDummyData();
drop procedure insertExpensesDummyData;

 
 
WebClient, Scheduler, Discord Webhook을 이용한 알림 구현

WebClient 라이브러리를 사용한 이유는 비동기적인 방식으로 간편하게 HTTP 요청을 생성하고 응답을 처리할 수 있기 때문에, 이러한 비동기적인 작업을 효과적으로 수행하기 위해 사용하였다.
로직 구성은 알림에 동의한 사용자의 리스트를 불러온 후 해당 list를 stream을 통해 병렬처리하여 알림을 보내는것으로 구현하였다.

 
처음에는 비동기화 하지 않은 상태로 for문을 이용하는것으로 실행하였으나, 실제 데이터가 커질 경우를 생각하여 비동기와 동기가 어느 정도의 데이터 처리 속도 차이가 있는 지를 대략적으로 알아볼 수 있도록 로그를 남겨보았다.

 

Dummy Data 갯수동기 상태비동기 상태 
25개 0.199초0.153초0.046 감소
50개0.312초0.171초0.141 감소
 0.113초 증가0.018초 증가 

Dummy Data가 많지 않아서 아쉽지만 그래도 내부에서 수행해야할 로직들이 많아서인지 수치에서 차이를 보였다.
처리할 데이터가 늘어날 경우 동기 상태에 늘어난 시간이 비동기 상태에 늘어난 시간보다 더 컸다.
또한 처리할 데이터가 늘어남에따라 동기와 비동기의 데이터 처리의 시간차이가 커졌다. 
이로 인해 데이터 처리의 속도가 확실히 비동기가 더 빠르다는것을 알 수 있었다.
그리하여 비동기 처리를하는 것으로 로직을 변경하였다.
 
하지만 병렬처리의 경우 그 데이터 수 만큼 thread의 갯수가 증가함으로 성능저하와 메모리 사용량의 증가를 초래할 수 있다. 그러므로 thread의 크기를 제어해줘야하는데 Scheduler Config를 이용하여 제어하였다.
config에서 pool 사이즈를 조정할 수 있는데 이때에 사이즈 조정을 하지 않는 경우 ThreadPoolTaskScheduler 자체에서 Runtime.getRuntime().availableProcessors() 메서드를 활용해 자동적으로 최적의 사이즈로 조정한다고 한다.
하지만 Runtime.getRuntime().availableProcessors() 메소드를 로그로 남겨서 실제 나의 컴퓨터의 코어 갯수를 어떻게 출력하는지 확인해 보니 물리적 코어 갯수 6개가 아닌 논리 프로세서 12개로 출력되는것을 확인할 수 있었다.

구글링을 해보니 I/O 작업이 많은 경우에는 물리적인 코어 수 * 2 개의 스레드를 사용하는것을 권장한다고 하여 명시적으로 pool size를 12개로 조정해 주었다.

@EnableScheduling
@Configuration
class SchedulerConfig {

    private final static int SCHEDULER_POOL_SIZE = 12; // 서버의 물리적인 코어 수 * 2 (I/O-bound 작업)
    private final static String SCHEDULER_THREAD_NAME_PREFIX = "sc-thread-";

    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(SCHEDULER_POOL_SIZE);
        scheduler.setThreadNamePrefix(SCHEDULER_THREAD_NAME_PREFIX);
        return scheduler;
    }
}

 
 

 
Swagger를 이용한 API 문서화

이번 프로젝트는 특히나 자주 사용되지 않는 용어들이 많기때문에 API 사용시 단어에 대한 혼동이 올 수 있음을 생각하여 Swagger를 이용한 API 명세서를 만들었다. Contorller와 Controller에서 사용되는 모든 DTO를 기준으로 2번 이상 확인하여 꼼꼼히 작성하였다.

 
 

❗ 프로젝트를 마친 후

이번 프로젝트는 일부러 일정을 타이트하게 잡아보았다. 나의 개발 속도와 실제 구현 정도가 얼마인지 파악하기 위해서였다. 시간은 타이트했지만 그 안에서 개발 중간에 로직 변경이나 테이블 변경이 일어나지 않게 요구사항 분석을 정말 꼼꼼히 하였다. 또한 실제 서비스중인 예산관리 어플리케이션을 여러개 다운로드하여 사용해 본 후 가장 많이 사용되는 로직과 속성들을 추출하여 테이블을 만들었다. 그 결과 큰 로직 변경없이 작업을 마무리 할 수 있었다. DB 테이블도 마찬가지로 몇개의 컬럼 수정 외에는 변경없이 진행하였다. 
또한 이후 버전을 위해 서비스 확장성을 염두해두고 테이블을 설계하였다. 현재의 요구사항에는 없는 부분(사용자의 카테고리 삭제와 수정, 관리자의 기본 카테고리 테이블 관리 API 등)을 추후에 조금씩 개발해 나갈 예정이다.
알림 서비스의 경우 처음 사용해보는 메서드들이 많았으나 구글링을 통해 차근차근 구현해보니 크게 어려운 부분은 없었다. 
개발 일정을 꼼꼼하게 정하여 개발을 마치고나니 그렇지 않았을 때와 비교하였을 때 더 체계적인 개발을 할 수 있었고, 개발을 마친 후 더 큰 성취감을 얻을 수 있었다.
반응형