[아이든든] 프로젝트 백엔드 트러블슈팅 회고 feat. 증여 중심

예썰·2026년 6월 25일
post-thumbnail

“API 하나 만들면 끝나는 줄 알았는데,
막상 구현해보니 도메인 하나를 수정할 때마다 다른 도메인까지 같이 흔들렸다.”

아이든든 프로젝트를 진행하면서도메인 설계, 인증 구조, 예외 처리, 증여 로직, 계좌 연동, 스케줄러 처리 등 다양한 문제를 마주했다.

특히 증여세법을 적용시키면서 예외처리를 할 것이 많아 조건이 까다로웠지만 그만큼 예외처리를 하기위해 경계값을 생각하고 설계하는 과정이 나름 재밌었던 것 같다....

사실 이 내용들은 개발을 진행하면서 개인 노션에 계속 정리해두고 있었는데,
프로젝트 진행 중에는 블로그로 옮길 시간이 도저히 나지 않았다.

그래서 이제서라도 많고 많은 이슈 중 일부를 정리해보려고 한다.


들어가며

아이든든은 부모가 자녀의 미래 자금을 준비할 수 있도록 돕는 서비스다.

서비스 안에는 다음과 같은 도메인이 연결되어 있었다.

부모
 └── 자녀
      ├── 계좌
      ├── 증여 계약
      ├── 증여 이체 내역
      └── ETF 투자

처음에는 각각의 기능을 독립적으로 구현하면 된다고 생각했다.

하지만 실제로는 그렇지 않았다.

예를 들어 증여 계약 하나를 만들기 위해서도 단순히 gift_contracts 테이블에 데이터를 저장하는 것으로 끝나지 않았다.

증여 계약 생성
→ 인증된 부모 확인
→ 자녀가 해당 부모의 자녀인지 검증
→ 부모 계좌 조회
→ 자녀 계좌 조회
→ 증여 타입 확인
→ 현금 잔액 또는 ETF 보유 수량 검증
→ 증여 계약 생성
→ 이체 내역 생성

즉, 하나의 기능 안에 여러 도메인이 얽혀 있었다.

이번 글에서는 아이든든 백엔드 개발 과정에서 고민했던 트러블슈팅을 정리해보려고 한다.


1. 자녀 생년월일 검증은 프론트에서만 하면 충분할까?

문제 상황

자녀 등록 기능을 만들 때 생년월일을 입력받아야 했다.

이때 자녀의 생년월일은 현재 날짜 이후가 될 수 없다.

현재 날짜 이후의 생년월일은 선택 불가

처음에는 프론트에서 DatePicker의 선택 가능 범위를 제한하면 충분하다고 생각했다.

사용자가 미래 날짜를 선택하지 못하게 막으면 UX도 좋고, 잘못된 입력도 줄일 수 있기 때문이다.


그런데 정말 프론트 검증만으로 충분할까?

프론트 검증은 사용자가 정상적인 화면 흐름을 따라올 때는 효과적이다.

하지만 사용자가 반드시 우리가 만든 화면을 통해서만 요청을 보내는 것은 아니다.

Postman
Swagger
curl
브라우저 개발자 도구

이런 방식으로 API를 직접 호출하면 프론트 검증은 우회될 수 있다.

즉, 프론트 검증은 사용자 경험을 위한 장치이지, 데이터 무결성을 보장하는 최종 방어선은 아니었다.


해결 방향

결국 생년월일 검증은 프론트와 백엔드 양쪽에서 모두 처리하는 방향으로 정리했다.

위치역할
프론트엔드잘못된 입력을 사전에 방지해 UX 개선
백엔드우회 요청까지 고려해 최종 데이터 무결성 보장

백엔드에서는 @PastOrPresent 사용을 고려했다.

@PastOrPresent(message = "생년월일은 오늘 또는 과거 날짜여야 합니다.")
private LocalDate birthDate;

정리

프론트 검증은 UX를 위한 장치이고,
백엔드 검증은 신뢰성과 보안을 위한 장치다.

입력 검증은 프론트와 백엔드 중 하나만 선택하는 문제가 아니었다.
둘의 목적이 다르기 때문에 둘 다 필요했다.


2. 자녀 나이 계산은 DTO, Service, Entity 중 어디에 두어야 할까?

문제 상황

자녀 목록 조회 API에서는 자녀의 만나이를 내려줘야 했다.

처음에는 DTO의 정적 팩토리 메서드에서 생년월일을 기준으로 나이를 계산했다.

public static ChildItem from(Children child) {
    int age = Period.between(child.getBirthDate(), LocalDate.now()).getYears();

    return new ChildItem(
        child.getId(),
        child.getName(),
        age
    );
}

동작은 문제없었다.

하지만 코드를 보면서 한 가지 의문이 생겼다.

DTO가 나이를 계산해도 될까?

DTO에 계산 로직을 두는 것이 어색했던 이유

DTO는 데이터를 전달하기 위한 객체다.

그런데 DTO 안에서 birthDate를 기준으로 만나이를 계산하면, DTO가 도메인 지식을 가지게 된다.

DTO의 역할
→ 데이터를 담아서 전달

나이 계산
→ birthDate라는 도메인 값에서 파생되는 규칙

처음에는 Service에서 계산하는 것도 고려했다.

int age = Period.between(child.getBirthDate(), LocalDate.now()).getYears();
ChildItem.from(child, age);

이 방식은 DTO에서 계산 로직을 제거할 수 있다는 장점이 있었다.

하지만 나이 계산은 여러 엔티티를 조합하는 유스케이스라기보다는, Children 엔티티 자신의 상태인 birthDate에서 파생되는 값이었다.

그래서 Entity에 두는 것이 더 자연스럽다고 판단했다.


해결 방향

Children 엔티티에 getAge() 메서드를 추가했다.

public int getAge() {
    return Period.between(this.birthDate, LocalDate.now()).getYears();
}

DTO는 엔티티의 메서드를 호출해 값을 담기만 하도록 변경했다.

public static ChildItem from(Children child) {
    return new ChildItem(
        child.getId(),
        child.getName(),
        child.getAge()
    );
}

계층별 책임 정리

계층역할
Entity자신의 데이터로 계산 가능한 도메인 로직
Service여러 엔티티와 리포지토리를 조합하는 유스케이스
DTO데이터 전달

정리

단순 계산이라도 어디에 둘지 고민해야 한다.

엔티티 자신의 상태만으로 계산할 수 있는 값이라면,
DTO나 Service보다 Entity에 두는 것이 더 자연스러울 수 있다.


3. 증여 계약 테이블에 nullable 필드가 많아진 문제

문제 상황

증여 계약에는 여러 유형이 있었다.

INSTALLMENT  적립식 현금 증여
ONE_TIME     일시금 현금 증여
ETF          ETF 직접 이전

처음에는 이 세 가지 증여 타입을 하나의 gift_contracts 테이블에 모두 담으려고 했다.

gift_contracts
- id
- child_id
- gift_type
- cash_amount
- transfer_day
- end_month
- external_etf_id
- qty

구현은 단순했다.

하지만 타입별로 필요한 데이터가 다르다 보니 사용하지 않는 컬럼이 많아졌다.

증여 타입필요한 값불필요한 값
INSTALLMENTcash_amount, transfer_day, end_monthexternal_etf_id, qty
ONE_TIMEcash_amount, start_datetransfer_day, end_month, external_etf_id, qty
ETFexternal_etf_id, qty, start_datecash_amount, transfer_day, end_month

예를 들어 ETF 증여에는 cash_amount가 필요 없다.
반대로 일시금 증여에는 transfer_day, end_month가 필요 없다.

결국 테이블에는 nullable 필드가 많아졌다.


왜 문제가 될까?

nullable 필드가 많아지면 당장 구현은 편하다.

하지만 데이터 의미가 흐려진다.

예를 들어 ETF 증여에서 cash_amount = 0으로 저장된다면, 이 값은 정말 0원을 의미하는 것일까?
아니면 해당 타입에서는 사용하지 않는 값이라는 뜻일까?

0원이라는 의미
vs
사용하지 않는 필드라서 임시로 넣은 값

이 둘은 완전히 다르다.


고민했던 설계

1안. Single Table 방식

gift_contracts
- id
- child_id
- gift_type
- cash_amount
- transfer_day
- end_month
- external_etf_id
- qty
장점단점
구현이 단순하다nullable 필드가 많아진다
조회가 쉽다타입별 의미가 흐려진다
MVP에 빠르게 적용 가능하다의미 없는 default 값이 생길 수 있다

2안. 타입별 상세 테이블 분리

gift_contracts
- id
- child_id
- gift_type
- status
- start_date

gift_contract_cash_details
- contract_id
- cash_amount
- transfer_day
- end_month

gift_contract_etf_details
- contract_id
- external_etf_id
- qty
장점단점
타입별 의미가 명확하다JOIN이 늘어난다
nullable 필드가 줄어든다구현 범위가 커진다
확장에 유리하다MVP 단계에서는 과할 수 있다

최종 선택

프로젝트 규모와 기간을 고려해 MVP에서는 Single Table 구조를 유지했다.

대신 타입별 검증을 Service에서 명확히 처리하는 방향으로 정리했다.

gift_type = INSTALLMENT
→ cash_amount, transfer_day, end_month 필수

gift_type = ONE_TIME
→ cash_amount, start_date 필수

gift_type = ETF
→ external_etf_id, qty, start_date 필수

정리

DB 정규화와 구현 단순성 사이에는 항상 trade-off가 있다.

MVP에서는 단순한 구조로 빠르게 구현하되,
확장 시점에 어떤 구조로 분리할 수 있을지 알고 있는 것이 중요했다.


4. 증여 계약 상태값은 타입마다 다르게 흘러가야 했다

문제 상황

증여 계약 상태로 다음 값들을 고려했다.

DRAFT
ACTIVE
COMPLETED
CANCELLED
FAILED

처음에는 모든 증여 타입이 같은 상태 흐름을 가진다고 생각했다.

하지만 실제로는 그렇지 않았다.

타입특징
INSTALLMENT여러 회차에 걸쳐 진행
ONE_TIME한 번에 즉시 실행
ETF한 번에 즉시 이전

일시금 증여와 ETF 증여는 생성 즉시 처리되는 구조에 가까웠다.
그런데 이런 계약을 DRAFTACTIVE 상태로 오래 두는 것은 어색했다.


타입별 상태 흐름 정리

적립식 증여

적립식 증여는 여러 회차에 걸쳐 진행된다.

DRAFT 또는 ACTIVE
→ 회차별 이체 진행
→ COMPLETED

시작일이 미래라면 DRAFT 또는 예약 상태로 볼 수 있고,
실제로 이체가 진행 중이면 ACTIVE 상태가 자연스럽다.


일시금 증여

일시금 증여는 한 번 실행되면 끝난다.

생성 즉시 COMPLETED

ETF 증여

ETF 증여도 수량 확인 후 즉시 이전되는 구조에 가깝다.

보유 수량 확인
→ 즉시 이전
→ COMPLETED

정리

상태값은 단순히 많이 만들어두는 것이 중요한 게 아니었다.

중요한 것은 각 상태가 실제 업무 흐름에서 언제 발생하는지 명확해야 한다는 점이었다.

이 상태는 언제 시작되는가?
이 상태에서 다음 상태는 무엇인가?
실패하면 어디로 이동하는가?
완료 조건은 무엇인가?

상태 전이가 불명확하면 백엔드 로직뿐만 아니라 프론트 화면에서도 혼란이 생긴다.


5. 적립식 증여 회차 생성 로직에서 mutable 상태 줄이기

문제 상황

적립식 증여 계약을 생성하면 매월 이체될 GiftTransfer 목록을 미리 만들어야 했다.

처음에는 while문으로 현재 월과 회차 번호를 계속 변경하는 방식이었다.

YearMonth current = startMonth;
int seq = 1;

while (!current.isAfter(endMonth)) {
    transfers.add(...);
    current = current.plusMonths(1);
    seq++;
}

이 방식도 동작은 했다.

하지만 반복문 안에서 변경되는 값이 많았다.

current
seq
transfers

특히 회차 번호와 월 계산이 따로 움직이다 보니, 둘 사이가 어긋날 가능성이 있었다.


개선 방향

회차 번호를 기준으로 해당 월을 계산하도록 바꿨다.

int totalMonths = (int) startMonth.until(endMonth, ChronoUnit.MONTHS) + 1;

List<GiftTransfer> transfers = IntStream.rangeClosed(1, totalMonths)
    .mapToObj(seq -> {
        YearMonth month = startMonth.plusMonths(seq - 1);
        return GiftTransfer.create(seq, month);
    })
    .toList();

이 방식에서는 seq 하나로 회차 번호와 해당 월을 모두 계산할 수 있다.

sequenceNo = seq
month = startMonth.plusMonths(seq - 1)

변경 전후 비교

변경 전변경 후
current, seq를 직접 변경seq 기준으로 월 계산
반복문 내부 상태가 많음mutable 상태 감소
회차와 월이 따로 움직임회차 기준으로 월이 결정됨
실수 가능성 있음계산 기준이 명확함

정리

반복문이 항상 나쁜 것은 아니다.

하지만 값이 계속 변하는 상태가 많아지면 실수 가능성이 커진다.
회차처럼 범위가 명확한 경우에는 IntStream으로 기준을 단순화하는 것도 좋은 선택이었다.


6. ETF 증여는 요청값만 믿으면 안 된다

문제 상황

ETF 증여 요청은 다음과 같이 들어온다.

{
  "gift_type": "ETF",
  "external_etf_id": 1,
  "qty": 5,
  "start_date": "2026-06-19"
}

하지만 요청값만 보고 증여 계약을 생성하면 안 된다.

부모가 실제로 해당 ETF를 5주 이상 보유하고 있는지 확인해야 하기 때문이다.


왜 서버 검증이 필요할까?

현금 증여에서는 부모 계좌의 현금 잔액을 확인해야 한다.

ETF 증여에서는 부모 계좌의 ETF 보유 수량을 확인해야 한다.

현금 증여
→ 부모 계좌 잔액 확인

ETF 증여
→ 부모 ETF 보유 수량 확인

검증 없이 계약을 생성하면 실제로 보유하지 않은 ETF를 자녀에게 증여하는 잘못된 상태가 생긴다.


타입별 검증 기준

타입검증
INSTALLMENT부모 계좌 잔액 확인
ONE_TIME부모 계좌 잔액 확인
ETF부모 ETF 보유 수량 확인

잔액 부족과 ETF 수량 부족은 다른 의미의 오류이기 때문에 에러 코드도 분리했다.

GIFT_400_01 → 잔액 부족
GIFT_400_02 → ETF 수량 부족

정리

금융 거래성 기능은 요청값만으로 처리하면 안 된다.

클라이언트가 보낸 값은 요청일 뿐이고,
실제 처리 가능 여부는 서버가 계좌 상태와 보유 수량을 기준으로 검증해야 한다.


7. 적립식 증여 자동이체 실패 시 계약 상태는 어떻게 해야 할까?

문제 상황

적립식 증여는 스케줄러가 매일 0시에 오늘 이체할 건을 조회해 처리하는 구조였다.

@Scheduled(cron = "0 0 0 * * *")
public void processInstallmentTransfers() {
    ...
}

잔액이 부족하면 해당 이체를 실패 처리했다.

transfer.fail("잔액 부족");
return;

그런데 테스트 과정에서 문제가 보였다.

마지막 회차가 실패하면 남은 SCHEDULED 이체가 없는데도 계약이 ACTIVE 상태로 계속 남을 수 있었다.


원인

기존 로직에서는 성공한 경우에만 남은 이체가 있는지 확인하고 계약을 완료 처리했다.

반면 실패한 경우에는 return으로 빠져나가면서 계약 완료 여부를 확인하지 않았다.

성공
→ 남은 회차 확인
→ 없으면 계약 COMPLETED

실패
→ transfer FAILED
→ return
→ 계약 완료 여부 확인 안 함

즉, 실패도 하나의 회차 종료로 봐야 하는데, 실패 시 완료 체크가 실행되지 않는 것이 문제였다.


개선 방향

실제 자동이체 흐름을 생각하면, 한 번 실패했다고 계약 전체가 실패하는 것은 어색하다.

이번 달 잔액 부족으로 이체가 실패할 수는 있지만, 다음 달 회차는 정상적으로 진행될 수도 있다.

그래서 다음과 같은 방향으로 정리했다.

1차 시도 실패
→ 당일 재시도
→ 재시도 횟수 초과 시 해당 회차 FAILED
→ 계약은 ACTIVE 유지
→ 다음 달 회차는 계속 시도
→ 모든 회차가 SUCCESS 또는 FAILED 상태가 되면 계약 COMPLETED

이를 위해 다음 개선을 고려했다.

retryCount 컬럼 추가
실패 시 바로 FAILED 확정하지 않고 SCHEDULED 유지
09:00, 14:00 재시도
재시도 횟수 초과 시 FAILED 처리
성공/실패와 관계없이 남은 SCHEDULED가 없으면 계약 COMPLETED

정리

스케줄러는 성공 케이스보다 실패 케이스 설계가 더 중요했다.

특히 마지막 회차 실패처럼 경계 조건을 고려하지 않으면 계약 상태가 비정상적으로 남을 수 있다.

회차 실패와 계약 실패는 다르다.
마지막 회차 실패도 계약 종료 조건에 포함되어야 한다.
스케줄러는 중간 return 때문에 상태 변경이 누락되지 않도록 설계해야 한다.

8. MSA 구조에서 서비스 간 DB 참조를 어떻게 할까?

문제 상황

아이든든은 Core Service, ETF Service, MTS Service처럼 역할을 나누는 구조를 고려했다.

Core Service
→ 부모, 자녀, 목표, 증여

ETF Service
→ ETF 정보, 시세, 포트폴리오

MTS Service
→ 계좌, 주문, 잔고

증여 계약을 만들 때는 부모 계좌와 자녀 계좌 정보가 필요했다.
그런데 계좌 정보는 MTS 쪽 도메인에 가까웠다.

이때 고민이 생겼다.

Core Service에서 Account 테이블을 직접 읽어도 될까?
아니면 MTS Service API를 호출해야 할까?

원칙과 현실 사이

원칙적인 MSA에서는 각 서비스가 자기 DB만 소유한다.

따라서 Core Service가 MTS Service의 Account 테이블을 직접 읽는 것은 MSA 원칙에 맞지 않는다.

하지만 우리 프로젝트는 실제로는 단일 DB를 공유하고 있었고, 코드만 서비스별로 분리된 구조에 가까웠다.

그래서 완전한 MSA라기보다는, 모듈을 서비스 단위로 나눈 구조에 가까웠다.


고민했던 선택지

방식장점단점
MTS API 호출MSA 원칙에 가깝다서비스 간 결합, 장애 전파
Core에서 Account 직접 조회구현이 단순하다MSA 원칙과는 거리가 있다

MVP 단계에서는 단일 DB 공유 구조였기 때문에 Core Service에서 읽기 전용 AccountRepository를 두는 방식을 고려했다.

다만 장기적으로 서비스 경계가 명확해진다면, 계좌 상세 정보는 MTS API를 호출하는 구조가 더 바람직하다고 판단했다.


정리

MSA는 이름만 서비스가 나뉘었다고 완성되는 것이 아니다.

DB 소유권, 서비스 간 통신, 장애 격리까지 함께 고려해야 진짜 MSA라고 할 수 있다.

이번 프로젝트에서는 현실적인 단순함을 선택했지만,
장기적으로는 서비스별 데이터 소유권을 분리하는 방향이 더 적절하다고 느꼈다.


전체 회고

이번 백엔드 트러블슈팅을 정리하면서 가장 크게 느낀 점은,
백엔드 개발은 단순히 API를 만드는 일이 아니라는 것이다.

하나의 API를 만들기 위해서도 다음 요소를 함께 고민해야 했다.

도메인 의미
DB 정규화
API 경로 설계
인증 구조
예외 처리
보안
세무 기준
프론트 협업
스케줄러 실패 처리
서비스 확장 가능성

특히 증여 기능은 단순 입금 기능처럼 보였지만,
실제로는 계좌, 세무, 자녀, 부모, ETF, 스케줄러가 모두 연결된 복합 도메인이었다.

이번 프로젝트를 통해 기능 구현 전에 도메인 기준을 먼저 정리하는 것이 얼마나 중요한지 배웠다.

앞으로 백엔드 기능을 설계할 때는 다음 질문을 먼저 던져야겠다고 생각했다.

이 값은 정말 DB에 저장해야 하는가?
이 로직은 어느 계층의 책임인가?
클라이언트가 이 값을 보내야 하는가, 서버가 알 수 있는가?
상태값은 실제 흐름과 맞는가?
실패했을 때 다음 상태는 무엇인가?

이 질문들을 기준으로 설계하면,
단순히 동작하는 코드가 아니라 유지보수 가능한 코드를 만들 수 있다고 느꼈다.


마무리

아이든든 프로젝트를 하면서 기능을 하나씩 구현하는 것보다 어려웠던 것은
각 기능이 어떤 도메인 책임을 가지는지 정리하는 일이었다.

처음부터 완벽한 설계를 한 것은 아니었지만,
문제를 마주할 때마다 기준을 세우고 구조를 다듬어가면서 백엔드 설계에 대해 많이 배울 수 있었다.

다음 프로젝트에서는 기능 구현 전에 도메인 흐름, 상태 전이, 실패 케이스를 더 먼저 정리해보고 싶다.

0개의 댓글