Skip to content

Latest commit

 

History

History
570 lines (423 loc) · 15.4 KB

File metadata and controls

570 lines (423 loc) · 15.4 KB

SettleFlow Backend Architecture Overview

1. 프로젝트를 만든 이유

SettleFlow는 매장 주문, 결제 승인, 정산 반영 흐름을 직접 설계하고 구현하기 위한 Spring Boot 기반 백엔드 프로젝트입니다.

이 프로젝트의 목적은 단순 CRUD API를 만드는 것이 아닙니다. 실제 백엔드 실무에서 자주 마주치는 다음 문제를 학습하는 데 목적이 있습니다.

  • JPA 기반 도메인 모델링
  • Store/Menu/Order/Payment 도메인의 연관관계 설계
  • 결제 상태 전이와 상태 이력 저장
  • 중복 결제 요청 방지
  • Redis 캐싱 대상과 비대상 분리
  • Kafka 이벤트 중복 수신 대응
  • 정산 데이터 정합성 보장
  • 운영자가 자연어로 매출/정산 데이터를 조회할 수 있는 AI 기능 확장
  • LLM과 백엔드의 역할 분리
  • AI 기능의 안전장치, 감사 로그, 테스트 전략 설계

즉, SettleFlow는 “주문, 결제, 정산 데이터를 정확하게 만들고, 이후 AI가 그 데이터를 안전하게 조회할 수 있도록 확장하는 백엔드 프로젝트”입니다.


2. 프로젝트 한 문장 소개

SettleFlow는 결제, 정산 데이터의 정확성을 백엔드에서 보장하면서, 사용자가 자연어로 매출과 정산 정보를 조회할 수 있도록 AI 질의 기능까지 확장하는 Spring Boot 기반 운영 백엔드 프로젝트입니다.


3. 기술 스택

  • Java 21
  • Spring Boot 3.5.14
  • Gradle Groovy
  • MySQL
  • Spring Data JPA
  • Spring Web
  • Validation
  • Spring Boot Actuator
  • Spring Data Redis
  • Spring for Apache Kafka
  • Docker Compose
  • Spock Framework
  • Swagger/OpenAPI

4. 전체 아키텍처 방향

flowchart TB
    Client[Client / Swagger UI]
    Controller[Controller Layer]
    Service[Service Layer]
    Domain[Domain Entity]
    Repository[Repository Layer]
    MySQL[(MySQL)]

    Redis[(Redis - Later)]
    Kafka[(Kafka - Later)]
    SettlementConsumer[Settlement Consumer - Later]
    AI[AI Sales Query - Future]
    Tool[Backend Tool Layer - Future]

    Client --> Controller
    Controller --> Service
    Service --> Domain
    Service --> Repository
    Repository --> MySQL

    Service -. Store/Menu Cache Later .-> Redis
    Service -. Idempotency Key Later .-> Redis
    Service -. Payment Approved Event Later .-> Kafka
    Kafka -. consume .-> SettlementConsumer
    SettlementConsumer -. update settlement .-> MySQL

    AI -. natural language query .-> Tool
    Tool -. read-only service call .-> Service
Loading

5. 계층 설계

flowchart LR
    Request[HTTP Request]
    Controller[Controller]
    RequestDTO[Request DTO]
    Service[Service]
    Entity[Domain Entity]
    Repository[Repository]
    DB[(MySQL)]
    ResponseDTO[Response DTO]
    Response[HTTP Response]

    Request --> Controller
    Controller --> RequestDTO
    Controller --> Service
    Service --> Entity
    Service --> Repository
    Repository --> DB
    Service --> ResponseDTO
    ResponseDTO --> Response
Loading

Controller

Controller는 HTTP 요청과 응답을 담당합니다.

Controller의 책임은 다음과 같습니다.

  • Request DTO 수신
  • Bean Validation 실행
  • Service 호출
  • Response DTO 반환

Controller에서는 Entity를 직접 반환하지 않습니다. Entity는 내부 도메인 모델이고, Response DTO는 외부 API 계약이기 때문입니다.

Service

Service는 하나의 비즈니스 유스케이스를 담당합니다.

Service의 책임은 다음과 같습니다.

  • 트랜잭션 경계 설정
  • Repository 호출
  • Entity 생성 및 도메인 메서드 호출
  • 조회 실패 등 유스케이스 예외 처리
  • Entity를 Response DTO로 변환

트랜잭션은 Repository 단위가 아니라 Service 유스케이스 단위로 둡니다.

Domain Entity

Entity는 단순 테이블 매핑 객체가 아니라 도메인 규칙을 가진 객체입니다.

Entity 설계 원칙은 다음과 같습니다.

  • Entity는 class로 작성
  • JPA 기본 생성자는 protected
  • setter는 만들지 않음
  • 생성은 정적 팩토리 메서드 사용
  • 상태 변경은 의미 있는 도메인 메서드로 처리
  • Request/Response DTO와 분리

Repository

Repository는 DB 접근을 담당합니다.

Spring Data JPA의 JpaRepository를 사용하여 기본 CRUD 기능을 제공받습니다. 필요하지 않은 query method는 미리 만들지 않고, 요구사항이 생겼을 때 추가합니다.


6. 현재 도메인 흐름

flowchart TD
    Store[Store]
    Menu[Menu]
    Order[Order]
    OrderItem[OrderItem]
    Payment[Payment]
    PaymentHistory[PaymentHistory]
    Settlement[Settlement]

    Store --> Menu
    Store --> Order
    Order --> OrderItem
    Order --> Payment
    Payment --> PaymentHistory
    Payment -. approved event later .-> Settlement
Loading

현재 구현 순서는 다음과 같습니다.

  1. Store API 구현
  2. Store 테스트 작성
  3. Menu Entity 구현
  4. Menu API 구현
  5. Menu 테스트 작성
  6. Order / OrderItem 구현
  7. Payment / PaymentHistory 구현
  8. Settlement 구현
  9. Redis 캐싱 구현
  10. Kafka 이벤트 처리 구현
  11. AI Sales Query 확장

7. Store 설계

Store는 매장을 나타내는 도메인입니다.

Store 설계 원칙

  • StoreStatus는 ACTIVE, INACTIVE를 가진다.
  • Store 생성 시 name은 null 또는 blank일 수 없다.
  • Store 생성 시 기본 상태는 ACTIVE이다.
  • Store는 BaseTimeEntity를 상속해 createdAt, updatedAt을 관리한다.
  • Store Entity를 API 응답으로 직접 반환하지 않는다.
  • StoreResponse record를 통해 응답한다.

Store 생성 흐름

sequenceDiagram
    participant Client
    participant StoreController
    participant StoreService
    participant Store
    participant StoreRepository
    participant MySQL

    Client->>StoreController: POST /api/v1/stores
    StoreController->>StoreService: createStore(request)
    StoreService->>Store: Store.create(name)
    Store-->>StoreService: Store(status=ACTIVE)
    StoreService->>StoreRepository: save(store)
    StoreRepository->>MySQL: insert stores
    MySQL-->>StoreRepository: saved row
    StoreRepository-->>StoreService: savedStore
    StoreService-->>StoreController: StoreResponse
    StoreController-->>Client: JSON Response
Loading

Store 생성 규칙을 Store.create(name)에 모은 이유는 Store가 생성되는 순간부터 유효한 상태를 갖도록 하기 위해서입니다.


8. Menu 설계 방향

Menu는 Store에 속하는 메뉴 도메인입니다.

Menu 설계 원칙

  • Menu는 Store에 속한다.
  • Store 1개는 여러 Menu를 가진다.
  • Menu 1개는 Store 1개에 속한다.
  • Menu는 Store와 ManyToOne 관계를 가진다.
  • Menu에서 Store 연관관계는 LAZY로 설정한다.
  • Menu 가격은 0보다 커야 한다.
  • Menu 이름은 null 또는 blank일 수 없다.
  • Menu 생성 시 기본 상태는 ACTIVE이다.
  • Menu 삭제는 실제 delete가 아니라 INACTIVE 상태 변경으로 처리한다.
  • Menu 조회 API는 나중에 Redis 캐싱 대상이 된다.

Menu와 Store 관계

erDiagram
    STORES ||--o{ MENUS : has

    STORES {
        bigint id PK
        varchar name
        varchar status
        datetime created_at
        datetime updated_at
    }

    MENUS {
        bigint id PK
        bigint store_id FK
        varchar name
        bigint price
        varchar status
        datetime created_at
        datetime updated_at
    }
Loading

Menu는 Store를 참조하지만, 처음에는 Store에서 List

를 가지는 양방향 관계를 만들지 않습니다. 현재 요구사항에서는 Menu가 Store를 알면 충분하므로 단방향 관계로 시작합니다.

Menu 생성 흐름

sequenceDiagram
    participant Client
    participant MenuController
    participant MenuService
    participant StoreRepository
    participant Menu
    participant MenuRepository
    participant MySQL

    Client->>MenuController: POST /stores/{storeId}/menus
    MenuController->>MenuService: createMenu(storeId, request)
    MenuService->>StoreRepository: findById(storeId)
    StoreRepository->>MySQL: select store
    MySQL-->>StoreRepository: store
    StoreRepository-->>MenuService: Store
    MenuService->>Menu: Menu.create(store, name, price)
    Menu-->>MenuService: Menu(status=ACTIVE)
    MenuService->>MenuRepository: save(menu)
    MenuRepository->>MySQL: insert menus
    MenuRepository-->>MenuService: savedMenu
    MenuService-->>MenuController: MenuResponse
    MenuController-->>Client: JSON Response
Loading

Menu 생성 시 Store를 먼저 조회하는 이유는 존재하지 않는 매장에 메뉴가 생성되면 안 되기 때문입니다.


9. 왜 Menu 연관관계를 LAZY로 두는가?

Menu는 Store와 ManyToOne 관계를 가집니다.

기본적으로 ManyToOne은 즉시 로딩이기 때문에 명시적으로 LAZY를 설정합니다.

LAZY를 사용하는 이유는 다음과 같습니다.

  • Menu를 조회할 때 항상 Store 전체 정보가 필요한 것은 아니다.
  • 불필요한 조인을 줄일 수 있다.
  • 필요한 경우 Service 계층에서 fetch join이나 별도 조회로 명시적으로 로딩할 수 있다.
  • open-in-view=false 설정과 함께 Service 계층에서 필요한 데이터를 명확히 조회하도록 유도한다.

10. 왜 물리 삭제 대신 INACTIVE 상태 변경을 사용하는가?

Menu는 운영 데이터와 주문 데이터에 연결될 수 있습니다.

메뉴를 실제로 삭제하면 다음 문제가 생길 수 있습니다.

  • 과거 주문과 메뉴의 추적이 어려워진다.
  • 운영자가 과거에 어떤 메뉴가 있었는지 확인하기 어렵다.
  • 캐시 무효화와 데이터 정합성 판단이 어려워질 수 있다.

그래서 초기 Menu 삭제는 실제 delete가 아니라 INACTIVE 상태 변경으로 처리합니다.


11. Order / OrderItem 설계 방향

Order는 Store에 속하고, 여러 OrderItem을 가집니다.

OrderItem에는 주문 당시 메뉴 정보를 snapshot으로 저장합니다.

erDiagram
    STORES ||--o{ ORDERS : receives
    ORDERS ||--o{ ORDER_ITEMS : contains
    MENUS ||--o{ ORDER_ITEMS : referenced_by

    ORDERS {
        bigint id PK
        bigint store_id FK
        varchar order_status
        bigint total_amount
        datetime created_at
        datetime updated_at
    }

    ORDER_ITEMS {
        bigint id PK
        bigint order_id FK
        bigint menu_id FK
        varchar menu_name_snapshot
        bigint menu_price_snapshot
        int quantity
        bigint line_amount
        datetime created_at
    }
Loading

OrderItem에 menu_name_snapshot, menu_price_snapshot을 저장하는 이유는 메뉴명이나 가격이 나중에 변경되어도 과거 주문 금액이 바뀌면 안 되기 때문입니다.

금액 계산은 클라이언트나 LLM이 아니라 백엔드 서비스 로직에서 수행합니다.


12. Payment 설계 방향

Payment는 Order에 연결됩니다.

Payment는 상태 전이가 중요합니다.

stateDiagram-v2
    [*] --> REQUESTED
    REQUESTED --> APPROVED
    REQUESTED --> FAILED
    APPROVED --> CANCELED

    FAILED --> [*]
    CANCELED --> [*]
Loading

허용 전이:

  • REQUESTED → APPROVED
  • REQUESTED → FAILED
  • APPROVED → CANCELED

금지 전이:

  • FAILED → APPROVED
  • CANCELED → APPROVED
  • REQUESTED → CANCELED

Payment 상태 변경은 setter가 아니라 도메인 메서드로만 수행합니다.

예상 메서드:

  • payment.approve()
  • payment.cancel()
  • payment.fail()

상태 변경 시 PaymentHistory를 반드시 저장합니다.


13. Settlement 설계 방향

Settlement는 결제 승인/취소 이벤트를 기반으로 만들어집니다.

flowchart LR
    Payment[Payment Approved]
    Event[payment-approved Event]
    Kafka[Kafka Topic]
    Consumer[SettlementConsumer]
    ProcessedEvent[ProcessedEvent]
    Settlement[Settlement]

    Payment --> Event
    Event --> Kafka
    Kafka --> Consumer
    Consumer --> ProcessedEvent
    Consumer --> Settlement
Loading

Kafka Consumer는 at-least-once delivery를 전제로 설계합니다. 같은 이벤트가 중복 수신될 수 있기 때문에 eventId 기반으로 ProcessedEvent를 저장하고 중복 처리를 막습니다.

정산 금액은 Redis나 AI가 계산하지 않습니다. 정산 금액은 DB와 백엔드 서비스 로직이 계산합니다.


14. Redis 캐싱 전략

Redis는 처음부터 붙이지 않습니다.

Store/Menu/Order/Payment 기본 구현 이후 단계적으로 적용합니다.

Redis 사용 대상:

  • Store 조회 캐싱
  • Menu 조회 캐싱
  • 결제 idempotency key 처리

Redis를 사용하지 않는 대상:

  • 결제 금액
  • 정산 금액
  • 매출 금액
  • 정산 결과

이유는 결제/정산/매출 금액은 정합성이 중요하기 때문입니다. 원천 데이터는 DB를 기준으로 하고, Redis는 조회 성능 개선이나 중복 요청 방지를 위한 보조 수단으로 사용합니다.

예상 Redis key:

store:{storeId}
store:{storeId}:menus
payment:idempotency:{idempotencyKey}

15. AI Sales Query 설계 방향

AI 기능은 핵심 주문·결제·정산 기능이 만들어진 뒤 확장합니다.

AI는 원천 데이터를 생성하거나 수정하지 않습니다. AI는 금액을 직접 계산하지 않습니다.

AI의 역할:

  • 자연어 질의 해석
  • intent 추출
  • 날짜 조건 추출
  • 매장 조건 추출
  • 결제수단 조건 추출
  • 응답 문장 생성

백엔드의 역할:

  • 요청 검증
  • 권한 검증
  • 매장 존재 여부 확인
  • DB 조회
  • 금액 계산
  • 정산 기준 적용
  • AI 요청/응답 로그 저장
  • Tool 호출 로그 저장

AI 질의 흐름

sequenceDiagram
    participant User
    participant AiController
    participant LLM
    participant ToolRouter
    participant SalesService
    participant MySQL
    participant Log

    User->>AiController: "어제 강남점 카드 매출 얼마야?"
    AiController->>LLM: intent/parameter extraction
    LLM-->>AiController: structured output
    AiController->>AiController: validate intent and parameters
    AiController->>ToolRouter: getDailySales
    ToolRouter->>SalesService: getDailySales(store, date, method)
    SalesService->>MySQL: query sales data
    MySQL-->>SalesService: sales rows
    SalesService-->>ToolRouter: calculated amount
    ToolRouter-->>AiController: result
    AiController->>Log: save query/tool logs
    AiController-->>User: response summary
Loading

LLM은 금액을 계산하지 않습니다. 금액은 SalesService가 DB 조회 결과를 기반으로 계산합니다.


16. MCP 확장 방향

MCP는 초기 구현 대상이 아닙니다.

먼저 내부 Tool Calling 구조를 만든 뒤, 필요할 경우 MCP Server 형태로 조회성 tool을 외부 AI Client에 노출합니다.

초기 MCP 후보 tool:

  • getDailySales
  • getSettlement
  • getPaymentStatus
  • searchStoreByName

MCP tool은 결제 승인, 결제 취소, 정산 반영 같은 상태 변경 작업을 직접 수행하지 않습니다.


17. 테스트 전략

테스트는 핵심 도메인부터 작성합니다.

우선순위:

  1. Store 생성/조회 테스트
  2. Menu 생성/조회/수정/비활성화 테스트
  3. Order 금액 계산 테스트
  4. Payment 상태 전이 테스트
  5. PaymentHistory 저장 테스트
  6. Redis 캐시 hit/miss 테스트
  7. Kafka Consumer 중복 이벤트 테스트
  8. AI 기능 FakeLlmClient 기반 테스트

AI 테스트에서는 실제 LLM API를 호출하지 않습니다. 외부 API 응답은 매번 달라질 수 있고, 네트워크와 비용에 의존하면 테스트가 불안정해지기 때문입니다.