Brolog
Brobean

[BroBean] BroBean 프로젝트 #13 개인·커플 모드와 가계부 API 구현 - 쿼리 성능을 다시 돌아보다

BroBean의 개인·커플 모드 전환 기능과 씨앗심기·성장일지 백엔드 및 API를 구현하고, 월별 가계부 조회 쿼리를 작성하는 과정에서 쿼리 성능을 다시 고민해본 경험을 정리합니다.

🌱 개인·커플 모드와 가계부 API 구현, 그리고 쿼리 성능을 다시 돌아보다

앞선 글에서는 BroBean을 개인 사용자도 사용할 수 있도록 개인 모드와 커플 모드를 분리하는 작업을 진행했다.

이번에는 이 구조를 실제 백엔드 기능에 연결했다.

사용자가 어떤 모드를 사용하고 있는지 UserSetting을 통해 관리하고, 마지막으로 선택했던 모드와 관련 설정값을 저장할 수 있도록 작업했다.

그리고 본격적으로 가계부 기능을 백엔드와 연결하면서 수입·지출·저축을 등록하는 '씨앗심기', 등록된 내역을 조회하는 '성장일지' API를 구현하고 프론트까지 연결했다.

기능적으로는 이전보다 꽤 많은 부분이 진행됐지만, 이번 작업에서 개인적으로 가장 기억에 남은 부분은 기능 자체보다 QueryDSL로 조회 쿼리를 작성하면서 성능에 대해 다시 생각하게 된 과정이었다.


⚙️ 개인·커플 모드를 실제 데이터와 연결하기

개인과 커플이 모두 사용할 수 있도록 방향을 변경하면서 단순히 화면에 모드 전환 버튼만 추가해서는 해결되지 않았다.

농장뿐만 아니라 실제 데이터를 사용하는 대부분의 기능이 개인과 커플을 구분해야 하기 때문이다.

농장
가계부
저축 목표
챌린지
예산
고정지출

이런 데이터들이 현재 사용자의 모드에 따라 다르게 조회되어야 한다.

그래서 UserSetting 영역에서 개인 / 커플 모드와 마지막 전환 모드, 기타 사용자 설정값을 관리하고, 실제 데이터 조회 시에도 현재 모드를 기준으로 데이터를 구분할 수 있도록 백엔드 작업을 진행했다.

프론트에서는 Pinia Store가 현재 모드를 관리하고, 백엔드에서는 사용자 설정을 기준으로 상태를 유지하는 방식이다.

결국 하나의 모드 전환 기능이 단순한 UI 상태가 아니라 서비스 전체 데이터 조회의 기준으로 확장되고 있다.


🌱 씨앗심기와 성장일지 API

가계부 기능도 본격적으로 백엔드와 연결했다.

BroBean에서는 수입·지출·저축을 추가하는 기능을 씨앗심기라고 표현하고 있다.

실제 백엔드에서는 하나의 거래 데이터로 관리하며 타입을:

EXPENSE
INCOME
SAVING

으로 구분한다.

사용자가 입력한 거래는 개인 / 커플 모드와 함께 저장되고, 이후 성장일지에서 해당 데이터를 다시 조회한다.

성장일지는 실제로는 가계부 내역 조회 기능이다.

예를 들어 사용자가 2026년 8월을 선택하면:

지출   1,250,000원
수입   3,500,000원
저축     800,000원

과 같은 월별 요약 정보와 함께 해당 월의 거래 내역을 보여준다.

이 부분까지 백엔드 API를 구현하고 프론트와 연결하면서 이제 실제로 데이터를 입력하고 다시 조회하는 흐름이 만들어졌다.


🔎 월별 조회 쿼리를 작성하면서 생긴 고민

성장일지에서 월별 요약 정보를 조회하기 위해 QueryDSL을 사용했다.

지출, 수입, 저축을 각각 별도로 조회하지 않고 하나의 쿼리에서 조건별 합계를 계산하도록 구성했다.

queryFactory
        .select(Projections.constructor(
                TransactionDto.SummaryResponse.class,

                // 지출 합계
                new CaseBuilder()
                        .when(transaction.type.eq(TransactionType.EXPENSE))
                        .then(transaction.amount)
                        .otherwise(0L)
                        .sum()
                        .coalesce(0L),

                // 수입 합계
                new CaseBuilder()
                        .when(transaction.type.eq(TransactionType.INCOME))
                        .then(transaction.amount)
                        .otherwise(0L)
                        .sum()
                        .coalesce(0L),

                // 저축 합계
                new CaseBuilder()
                        .when(transaction.type.eq(TransactionType.SAVING))
                        .then(transaction.amount)
                        .otherwise(0L)
                        .sum()
                        .coalesce(0L)
        ))
        .from(transaction)
        .where(
                eqCoupleOrEmail(recordMode, couple, email)
                        .and(transaction.date.between(startDate, endDate))
        )
        .fetchOne();

여기서 처음 작성할 때는 월을 선택한다는 화면 요구사항을 그대로 쿼리에도 반영하려고 했다.

예를 들어:

.and(transaction.date.year().eq(year))
.and(transaction.date.month().eq(month))

같은 방식이다.

논리적으로는 전혀 문제가 없다.

2026년 8월을 선택하면 year = 2026, month = 8을 조건으로 주면 해당 데이터를 가져올 수 있기 때문이다.

그런데 다시 DB 관점에서 생각해보니 굳이 데이터베이스에서 날짜의 연도와 월을 추출할 필요가 없었다.

화면에서 월을 선택하는 것과 DB가 데이터를 조회하는 방식은 별개의 문제이기 때문이다.


📅 월이라는 값을 날짜 범위로 바꾸기

사용자가 선택한 값이:

2026-08

이라면 결국 DB에서 필요한 조건은:

2026-08-01
~
2026-08-31

이다.

그래서 Java의 YearMonth를 사용해 애플리케이션에서 먼저 조회 범위를 만들도록 변경했다.

YearMonth ym = YearMonth.parse(month);

LocalDate startDate = ym.atDay(1);
LocalDate endDate = ym.atEndOfMonth();

그러면:

2026-08

↓

2026-08-01
2026-08-31

↓

transaction.date.between(startDate, endDate)

와 같은 흐름으로 조회할 수 있다.

결국 처음 생각했던:

transaction.date.year().eq(year)
transaction.date.month().eq(month)

대신:

transaction.date.between(startDate, endDate)

를 사용하게 됐다.

이렇게 하면 DB에는 연도와 월을 계산하라는 조건이 아니라 날짜 컬럼 자체에 대한 범위 조건을 전달하게 된다.


📈 알고 있던 내용인데 왜 처음에는 놓쳤을까?

사실 이 부분이 이번 작업에서 가장 많이 생각해본 부분이다.

YEAR()나 MONTH() 같은 함수를 컬럼에 적용하는 것보다, 인덱스를 고려한다면 컬럼 자체에 범위 조건을 사용하는 것이 유리할 수 있다는 것은 이미 알고 있던 내용이었다.

그런데 막상 실제 기능을 구현하다 보니:

화면에서 월을 선택한다.

↓

year와 month를 받는다.

↓

그대로 DB 조건으로 사용한다.

라는 식으로 생각하고 있었다.

돌이켜보면 너무 자연스럽게 화면의 요구사항을 DB 조회 조건으로 그대로 옮긴 것이었다.

하지만 실제로는:

화면

"2026년 8월을 보여주세요."

↓

애플리케이션

"2026-08-01 ~ 2026-08-31로 변환"

↓

DB

"이 날짜 범위의 데이터를 조회"

처럼 각 계층에서 필요한 형태로 변환해주는 것이 더 적절했다.

특히 날짜 컬럼에 인덱스가 존재한다면 범위 조건은 인덱스를 활용할 수 있는 형태가 된다.

반면 컬럼에 함수를 적용하는 조건은 DBMS와 실행 계획에 따라 인덱스 활용에 불리할 수 있다.

물론 이것을 단순히:

YEAR / MONTH = 나쁨
BETWEEN = 좋음

으로 이해해서는 안 된다.

실제 성능은 사용하는 DBMS, 인덱스 구성, 데이터 분포, 실행 계획 등에 따라 달라진다.

중요한 것은 쿼리가 실제 DB에서 어떤 형태로 실행되는지까지 생각하는 것이라고 봤다.


🧠 기능이 동작하는 것과 좋은 쿼리를 작성하는 것은 다르다

이번 경험을 통해 다시 느낀 부분이다.

처음 작성했던 쿼리도 기능적으로는 정상적으로 동작한다.

데이터도 원하는 결과가 나온다.

하지만:

정상적으로 동작한다.

≠

가장 좋은 방식으로 동작한다.

는 점을 다시 생각하게 됐다.

특히 개발을 하다 보면 기능을 먼저 완성하는 데 집중하면서:

이 데이터가 원하는 대로 조회되는가?

이 API가 정상적으로 응답하는가?

프론트에서 제대로 표시되는가?

까지만 확인하고 넘어가기 쉽다.

하지만 실제 서비스에서는 데이터가 계속 쌓인다.

지금은 거래 데이터가 몇 백 건에 불과하더라도 나중에는:

수천 건
수만 건
수십만 건

으로 늘어날 수 있다.

그때도 동일한 쿼리가 효율적으로 동작할지는 별개의 문제다.

그래서 기능 구현 단계에서도 최소한:

어떤 조건으로 조회하는가?

불필요한 계산을 DB에서 하고 있지는 않은가?

인덱스를 활용할 수 있는 형태인가?

조회 범위가 필요 이상으로 넓지는 않은가?

정도는 함께 생각해야 한다는 것을 다시 느꼈다.


🔬 작은 차이를 미리 고민하는 습관

이번 작업에서 특별히 복잡한 성능 최적화를 한 것은 아니다.

오히려:

transaction.date.year().eq(year)
transaction.date.month().eq(month)

에서

transaction.date.between(startDate, endDate)

로 바꾼 정도다.

하지만 이런 작은 차이를 개발 단계에서 발견하고 고민하는 과정 자체가 의미 있다고 생각한다.

서비스 규모가 작을 때는 두 방식의 차이가 눈에 띄지 않을 수 있다.

하지만 데이터가 커지고 조회가 반복되기 시작하면 작은 쿼리 하나의 차이가 누적될 수 있다.

그래서 성능 최적화라는 것을 반드시:

Redis
캐싱
분산 처리
DB 샤딩
비동기 처리

같은 큰 작업으로만 생각할 필요는 없을 것 같다.

처음부터 DB가 효율적으로 데이터를 찾을 수 있는 형태로 쿼리를 작성하는 것 역시 성능을 고려한 개발이라고 생각하게 됐다.


⚠️ 날짜 타입에 따른 조회 범위도 확인

이번 구현에서는 거래 날짜를 LocalDate로 관리하고 있기 때문에:

YearMonth ym = YearMonth.parse(month);

LocalDate startDate = ym.atDay(1);
LocalDate endDate = ym.atEndOfMonth();

형태로 간단하게 처리할 수 있었다.

하지만 만약 거래 시간이 LocalDateTime으로 관리된다면 월의 마지막 날짜를 처리할 때 조금 더 주의해야 한다.

예를 들어:

2026-08-31 15:30:00

같은 데이터까지 포함해야 하기 때문이다.

이런 경우에는:

LocalDateTime start =
        ym.atDay(1).atStartOfDay();

LocalDateTime end =
        ym.plusMonths(1).atDay(1).atStartOfDay();

처럼 다음 달의 시작을 종료 기준으로 잡고:

>= start
< end

형태로 조회하는 방법도 고려할 수 있다.

날짜 조회는 단순해 보이지만 실제로는:

LocalDate인가?
LocalDateTime인가?

시작과 종료를 어떻게 정의할 것인가?

마지막 날을 포함해야 하는가?

시간대가 필요한가?

등을 데이터 모델에 맞게 함께 생각해야 한다.


🚜 지금까지 진행된 BroBean

이번 작업까지 진행하면서 주요 기능들이 실제 데이터와 연결되기 시작했다.

UserSetting

개인 / 커플 모드
마지막 선택 모드
사용자 설정값

을 관리할 수 있도록 백엔드 작업을 진행했다.

🌱 씨앗심기

수입
지출
저축

을 실제 Transaction 데이터로 저장하는 API를 구현했다.

📖 성장일지

거래 내역 조회
월별 조회
지출 합계
수입 합계
저축 합계

를 제공하는 API를 구현하고 프론트와 연결했다.

그리고 개인 / 커플 모드를 기준으로 실제 데이터 조회 범위까지 연결하면서 이제 단순한 화면 구현을 넘어 서비스의 데이터 구조와 조회 방식을 함께 고민하는 단계로 넘어가고 있다.


🎯 마무리

이번 작업에서 기능적으로 가장 큰 변화는 개인 / 커플 모드가 실제 백엔드 데이터와 연결되고, 씨앗심기와 성장일지까지 실제 API를 통해 동작하기 시작했다는 점이다.

하지만 개인적으로 더 기억에 남은 것은 월별 조회 쿼리를 작성하면서 내가 알고 있던 기본적인 원칙을 실제 코드에서는 놓칠 수도 있다는 것을 다시 확인한 것이다.

처음에는:

transaction.date.year().eq(year)
transaction.date.month().eq(month)

가 당연하게 느껴졌다.

하지만 월이라는 화면상의 개념을 실제 날짜 범위로 변환해 생각해보니:

YearMonth ym = YearMonth.parse(month);

LocalDate startDate = ym.atDay(1);
LocalDate endDate = ym.atEndOfMonth();

로 애플리케이션에서 범위를 만들고,

transaction.date.between(startDate, endDate)

로 조회하는 것이 더 자연스러운 구조였다.

무엇보다 이번 일을 계기로 쿼리를 작성할 때 단순히 **"원하는 결과가 나오는가"**만 확인하지 않고,

DB 입장에서는 이 조건을 어떻게 처리할까?

인덱스를 활용할 수 있는 형태인가?

불필요한 계산을 하고 있지는 않은가?

데이터가 많아져도 괜찮은 구조인가?

를 한 번 더 생각해보게 됐다.

아직 BroBean의 데이터 규모가 크지 않기 때문에 지금 당장 큰 성능 차이를 체감할 수 있는 단계는 아니다.

그래도 이런 부분을 나중에 문제가 발생한 뒤 고치는 것보다, 기능을 구현하는 순간부터 조금씩 신경 쓰는 것이 더 좋은 개발 습관이라고 생각한다.

결국 좋은 코드는 단순히 동작하는 코드에서 끝나는 것이 아니라, 데이터가 늘어나고 서비스가 커졌을 때도 어떤 방식으로 동작할지를 생각한 코드라고 생각한다.

BroBean도 이제 화면을 하나씩 완성하는 단계를 넘어 실제 데이터가 쌓이고 여러 기능이 서로 연결되는 단계로 넘어가고 있다.

앞으로 기능이 많아질수록 단순한 구현보다 데이터 구조와 조회 성능까지 함께 고민하는 개발을 계속해보려고 한다.

Copyright © 2026 Brolog. All rights reserved.