Skip to content

limvik/budget-management-service

Repository files navigation

EconoMe





개인 예산 관리 REST API

개인의 예산 관리 기능을 제공하는 REST API 입니다. 경제를 의미하는 Economy 에서 개인에 특화된 서비스임을 나타내기 위해 EconoMe로 이름을 지었습니다.

  • 월 예산 추천: 사용자가 설정한 월 예산 총액을 바탕으로, 다른 사용자의 카테고리별 예산액 평균에 기반한 예산 추천
  • 오늘의 추천 지출: 개인이 설정한 월 예산을 바탕으로, 오늘의 지출 금액 추천
  • 오늘의 지출 조회: 오늘 예산 대비 사용자가 소비한 지출 금액 조회
  • 통계: 지난 달/지난 주 대비 지출 비율, 다른 사용자 대비 지출 비율 조회

참고 사항

목차

Skills

java gradle

spring boot spring security JWT

spring data jpa mysql

spring rest docs

docker testcontainer

postman intellij-idea

ERD

  • 카테고리(categories)는 예산 계획(budget_plans), 지출(expenses)과 1:M 관계가 있으므로, 관리를 위해 별도의 테이블을 추가하였습니다.
  • 사용자(users) 테이블에서 설정 항목(minimum_daily_expenses, agree_alarm)과 refresh_token은 속성 상 다른 테이블에서 관리하는게 맞다고 판단되지만, 시간 제약이 있어 개발 속도 향상을 위해 통합하였습니다.
  • 초기 수정 사항
    • 카카오 로그인 연동을 고려한 username 최대길이 변경: 12 -> 20
    • 간단하게 지출을 기록한다는 기획의도에 맞는 memo 길이 제한: TEXT -> VARCHAR(60)
    • create_time 처럼 과거분사를 사용하지 않도록 네이밍 컨벤션 통일
      • agreed_alarm -> agree_alarm
      • excluded_in_total -> exclude_in_total
  • 기타
    • 돈을 저장하려면 나중에 원화 외의 다른 통화를 사용하는 것도 고려해서 DECIMAL을 사용하는 것에 대해 고민했는데, 기능을 예상해서 개발하지 말라는 피드백을 받아서 BIGINT로 진행하였습니다.

목차로 이동

배포 구조도

deployment

목차로 이동

API Table

사용자(Users)

Operation API Endpoint HTTP Method Response Status Description
회원가입 /api/v1/users/signup POST 201(Created) 회원가입 성공
409(Conflict) 중복된 username 또는 email
422(Unprocessable Content) 유효성 검사 실패
로그인 /api/v1/users/signin POST 200(Ok) 로그인 성공
401(Unauthorized) 인증되지 않음
AccessToken 재발급 /api/v1/users/token POST 200(Ok) accessToken 재발급 성공
401(Unauthorized) 유효하지 않은 RefreshToken

예산(Budget_Plans)

Operation API Endpoint HTTP Method Response Status Description
예산 설정 카테고리 목록 조회 /api/v1/categories GET 200(Ok) 예산 설정 카테고리 목록 조회 성공
401(Unauthorized) 유효하지 않은 AccessToken
예산 설정 /api/v1/budget-plans?year=&month= POST 201(Created) 예산 설정 성공
401(Unauthorized) 유효하지 않은 AccessToken
409(conflict) 이미 설정된 예산
예산 조회 /api/v1/budget-plans?year=&month= GET 200(Ok) 예산 조회 성공
401(Unauthorized) 유효하지 않은 AccessToken
예산 수정 /api/v1/budget-plans?year=&month= PATCH 200(Ok) 예산 수정 성공
401(Unauthorized) 유효하지 않은 AccessToken
404(Not Found) 존재하지 않는 예산
예산 추천 /api/v1/budget-plans/recommendations?amount= GET 200(Ok) 추천 예산 조회 성공
401(Unauthorized) 유효하지 않은 AccessToken

목차로 이동

지출(Expenses)

Operation API Endpoint HTTP Method Response Status Description
지출 생성 /api/v1/expenses POST 201(Created) 지출 생성 성공
401(Unauthorized) 유효하지 않은 AccessToken
지출 수정 /api/v1/expenses/{id} PATCH 200(Ok) 지출 수정 성공
401(Unauthorized) 유효하지 않은 AccessToken
404(Not Found) 존재하지 않는 id
지출 조회 /api/v1/expenses/{id} GET 200(Ok) 지출 조회 성공
401(Unauthorized) 유효하지 않은 AccessToken
404(Not Found) 존재하지 않는 id
지출 삭제 /api/v1/expenses/{id} DELETE 204(No Content) 지출 삭제 성공
401(Unauthorized) 유효하지 않은 AccessToken
404(Not Found) 존재하지 않는 id
지출 목록 조회 /api/v1/expenses?startDate={yyyy-mm-dd}&endDate={yyyy-mm-dd}&categoryId={categoryId}&minAmount={minAmount}&maxAmount={maxAmount}} GET 200(Ok) 지출 목록 조회 성공
401(Unauthorized) 유효하지 않은 AccessToken

추천/안내/통계

Operation API Endpoint HTTP Method Response Status Description
오늘 지출 추천 /api/v1/expenses/recommendations GET 200(Ok) 오늘 지출 추천 조회 성공
401(Unauthorized) 유효하지 않은 AccessToken
오늘의 지출 안내 /api/v1/expenses/today GET 200(Ok) 오늘의 지출 조회 성공
401(Unauthorized) 유효하지 않은 AccessToken
통계 /api/v1/expenses/stat GET 200(Ok) 지난달/지난주 같은 요일/다른 사용자 대비 소비율 통계 정보 조회 성공
401(Unauthorized) 유효하지 않은 AccessToken

목차로 이동

구현 고려사항

구현 시 고려했던 내용을 일부 정리하였습니다.

Refresh Token 클라이언트에 저장

Refresh Token을 클라이언트에 저장하는 것은 항상 탈취 가능성이 있으므로 보안적으로 위험하다고 생각해서 다른 방법을 찾아보았습니다. 아래와 같이 BFF(Backend For Frontend) 패턴으로 세션과 토큰을 매핑하는 방식이 있었습니다.

image
출처: https://www.pingidentity.com/en/resources/blog/post/refresh-token-rotation-spa.html

마감일까지 구현할 수 있을 가능성이 적어 배운대로 Refresh Token과 Access Token을 로그인 시에 반환하고, Access Token 만료 시 Refresh Token으로 갱신된 Access Token을 발급받을 수 있도록 구현하였습니다.

대신 모든 엔드포인트가 사용자의 많은 정보를 필요로 하는것은 아니므로, 사용자의 식별자만 토큰에 저장하여 정보유출을 최소화했습니다.


카테고리 항목 선정

통계청 가계동향조사에 사용되는 소비지출 12대 비목을 기준으로 카테고리를 선정하였습니다. 통계청 자료에 가구 특성별, 소득별 소비지출 통계가 있어 사용자가 부족해도 데이터를 바탕으로 다양한 추천 서비스를 제공할 수 있을 것으로 기대합니다.

  • 참고 자료
    • 통계청 가구특성별 비목별 소비지출(링크)
    • 2023년 2/4분기 가계동향조사 결과(링크)

목차로 이동

이슈 및 해결

TestRestTemplate 사용하여 401 수신 시 HttpRetryException 던짐

상황

슬라이스 테스트로 각각의 Controller, Service, Repository 에 대한 테스트를 구현하기에는 시간이 부족하여, TestRestTemplate을 이용한 통합 테스트를 구현하였습니다.

그런데 서버가 401(Unauthorized) 반환 시 body 에 데이터가 있는 경우, cannot retry due to server authentication, in streaming mode 오류가 발생하였습니다.

아래 코드에서 가장 아래있는 body에 데이터를 쓰는 코드(response.getWriter().write(jsonResponse);)만 제거하면 이상 없이 동작합니다.

public class JwtEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer");
        response.setStatus(ErrorCode.INVALID_TOKEN.getHttpStatus().value());
        String jsonResponse = new ObjectMapper().writeValueAsString(
                new ErrorResponse(ErrorCode.INVALID_TOKEN.name(), ErrorCode.INVALID_TOKEN.getMessage()));
        response.getWriter().write(jsonResponse);

    }

}

원인

TestRestTemplate은 HTTP 연결 시 Native Java API인 HttpURLConnection을 사용합니다. 기본적으로 Streaming Mode를 사용하여 HTTP 요청에 대한 응답을 버퍼에 저장하지 않고 즉시 처리합니다.

API 문서를 참조해보면, 아래와 같이 authentication 이나 redirection이 필요한 경우, 응답을 읽으려고 할 때 HttpRetryException이 던져짐을 언급하고 있습니다.

A HttpRetryException will be thrown when reading the response if authentication or redirection are required.

401(Unauthorized)를 반환하는 경우 인증이 필요한 상황이고 response body에 접근하는 경우 HttpRetryException이 던져집니다.

해결

HttpURLConnection을 직접 조작하는 방법도 있겠지만, HttpClient 라이브러리를 의존성에 추가만 하면 문제가 해결되었습니다.

// For Spring Boot 3
testImplementation 'org.apache.httpcomponents.client5:httpclient5:5.2.1'

// For Spring Boot 2
testImplementation 'org.apache.httpcomponents:httpclient:4.5.14'

시간이 부족하고 Test에만 사용할 예정이므로 크게 고민할 필요가 없다고 판단했습니다. 그리고 TestRestTemplate으로 HTTP PATCH method를 지정하기 위해서는 필요했던 라이브러리기 때문에, 라이브러리를 추가하여 문제를 해결하였습니다.


목차로 이동

Instant 와 LocalDate 및 LocalDateTime 혼용으로 인한 불일치

상황

시간이 부족한 상황에서 평소 시간까지 표현하기 위해서 주로 사용하던 Instant 자료형을 사용하였습니다. DTO의 자료형이나 과거 테스트 데이터 생성 시 시간 조작 편의를 위해 LocalDateLocalDateTime 을 사용하였습니다.

데이터의 날짜를 기준으로 데이터베이스에서 불러올 때 예상했던 데이터를 가져오지 못하거나 더 가져오는 문제가 발생하였습니다.

원인

Instant는 UTC+0인 기준시가 반환되고, LocalDateLocalDateTime은 시스템 TimeZone을 기반으로 시간을 반환합니다.

image

해결

Instant는 MySQL의 Timestamp 와 동일하게 Unix Timestamp이고, 글로벌 서비스를 마음 속에 품고(?) 개발을 하면서 Instant를 사용했습니다.

하지만 이번 프로젝트에서 데이터베이스를 설계하면서도 외화를 고려한 DECIMAL 타입을 고려할 때 너무 나갔다는 피드백을 받았고, 현재 요구사항에 충실하게 개발을 했습니다. 이에 맞춰서 LocalDateLocalDateTime을 사용하기로 결정하였습니다.


목차로 이동

테스트 시간이 700ms 가 넘는 단일 테스트 항목

상황

통계 기능의 경우 지난 달/지난 주 대비 지출 비율, 다른 사용자 대비 지출 비율 조회를 하므로 테스트를 위해 과거 데이터를 생성해야 합니다.

모든 테스트 데이터를 생성한 후 테스트를 수행하면 단일 테스트 임에도 700ms가 넘는 굉장히 긴 시간을 소모하였습니다.

원인

  1. 테스트 편의를 위해 매월 현재의 날짜를 기준으로 지난 달 1일까지 매일 테스트 데이터를 생성(비교 대상인 다른 유저 데이터는 지난 달 1일부터 말일까지), 매월 말일에 가까워 질수록 테스트 데이터도 증가
  2. Hibernate 설정에 의해 테스트 데이터 INSERT 구문 Batch 처리 불가

해결

  1. 테스트 데이터를 오늘, 지난주 같을 날, 다음 달 같은 날만 생성하도록 변경하여 테스트 데이터 갯수를 줄이고, 동일한 로직을 테스트 하도록 수정하였습니다.
  2. Hibernate는 JPA에서 Generation Type을 IDENTITY로 설정하는 경우 INSERT 구문에 대한 Batch 처리가 불가능합니다. 그래서 Generation Type을 SEQUENCE로 변경하여 INSERT 구문을 Batch 처리하도록 하였습니다.

그 결과 700ms 가 넘던 통계 기능 테스트 시간이 200ms 초중반 수준으로 개선되었습니다.

목차로 이동

N+1 문제

상황

소비 지출(Expense) 및 예산(Budget) 객체와 @ManyToOne 관계에 있는 카테고리(Category) 객체의 카테고리 이름을 불러올 때 불필요한 Query가 추가로 요청되는 문제가 있었습니다.

원인

JPA 사용 시 연관 객체를 불러오기 위해 추가적인 쿼리를 실행시키는 N+1 문제입니다.

소비 지출 및 예산 객체에 포함되어 있는 카테고리 객체에서 카테고리 이름을 불러올 때 추가적인 쿼리가 수행됩니다.

해결

잘 알려진 해결책으로, Join Query, Batch Size 설정, Fetch Join 등이 있습니다.

하지만 추가적인 부하가 발생하는 것은 마찬가지 이므로, Enum에 유지하고 있는 카테고리 목록을 사용하기로 하였습니다.

카테고리의 종류가 12가지 밖에 없고, 카테고리가 확장된다고 해도 사용자 편의를 위해 많이 늘어나지는 않을 것이기 때문에 Enum을 이용해도 향후에도 변경 없이 사용 가능합니다.

그래서 연관 관계에 있는 객체의 카테고리 id만 참조하면 추가적인 쿼리를 발생 시키지 않으므로, 카테고리 Enum 에서 카테고리 id를 기준으로 카테고리 이름을 불러오도록 수정하였습니다.

예를 들면, 아래와 같습니다.

카테고리 정보가 담긴 Enum 클래스인 BudgetCategory에서 values() 메서드로 카테고리 목록을 배열로 받아온 후 id를 배열 index로 만들어 카테고리 이름을 받아옵니다.

private BudgetPlanListResponse mapBudgetPlanListToResponseList(List<BudgetPlan> budgetPlanList) {
    List<BudgetPlanResponse> budgetPlanResponseList = new ArrayList<>();
    BudgetCategory[] budgetCategories = BudgetCategory.values();

    for (BudgetPlan budgetPlan : budgetPlanList) {
        BudgetPlanResponse budgetPlanResponse = new BudgetPlanResponse(
                budgetPlan.getCategory().getId(),
                budgetCategories[(int) (budgetPlan.getCategory().getId() - 1)].getCategory(),
                budgetPlan.getAmount());
        budgetPlanResponseList.add(budgetPlanResponse);
    }
    return new BudgetPlanListResponse(budgetPlanResponseList);
}

Repository에서 카테고리 이름도 같이 불러오던 Query는 카테고리 id만 불러오도록 수정하였습니다.

목차로 이동

학습

Spring Security 흐름 다이어그램으로 정리

  • Spring Security의 전체적인 그림을 이해하지 못해서 인증 절차에 시간이 많이 허비됐다고 생각하여, 전체적인 흐름을 살펴보고 다이어그램으로 그려보았습니다.

spring-security-custom-authentication-filter-diagram

목차로 이동

블로그 학습 기록

About

개인 예산 관리 REST API

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Languages