본문 바로가기

백엔드 엔지니어링 일지

마켓 백엔드 엔진 3 : flyway, JPA, CRUD API, Swagger, Filter

 

flyway로 초기 스키마 생성

설정값 세팅 

Flyway 의존성 추가: backend/build.gradle
org.flywaydb:flyway-core
org.flywaydb:flyway-database-postgresql

 

테스트 안정화: backend/src/test/resources/application.properties
spring.flyway.enabled=false 추가(H2 테스트와 PostgreSQL 전용 SQL 충돌 방지)

CREATE TABLE members (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255),
    name VARCHAR(100) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    price_amount NUMERIC(19, 4) NOT NULL CHECK (price_amount >= 0),
    stock_quantity INT NOT NULL CHECK (stock_quantity >= 0),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    member_id BIGINT NOT NULL,
    product_id BIGINT NOT NULL,
    quantity INT NOT NULL CHECK (quantity > 0),
    unit_price NUMERIC(19, 4) NOT NULL CHECK (unit_price >= 0),
    status VARCHAR(32) NOT NULL,
    total_amount NUMERIC(19, 4) NOT NULL CHECK (total_amount >= 0),
    created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT fk_orders_member
        FOREIGN KEY (member_id)
            REFERENCES members (id),
    CONSTRAINT fk_orders_product
        FOREIGN KEY (product_id)
            REFERENCES products (id)
);

테이블 구성

이번 스키마는 “먼저 동작하는 최소 주문 시스템”을 목표로 단순하게 잡았습니다.

핵심은 회원 → 상품 → 주문 흐름을 빠르게 구현할 수 있게 하는 것입니다.

  • members
    • 회원 기본 정보 (email, password_hash, name)
    • email은 유니크로 중복 가입 방지
  • products
    • 상품명, 가격, 재고 수량(stock_quantity)
  • orders
    • 주문자(member_id), 주문 상품(product_id)
    • 수량(quantity), 주문 단가(unit_price), 주문 총액(total_amount)
    • 현재는 단일 상품 주문 1건 기준 모델

JPA 매핑 요약 (기본형)

JPA도 스키마와 동일하게 최소로 매핑했습니다.

엔티티

  • Member ↔ members
  • Product ↔ products
  • Order ↔ orders

관계

  • Order → Member (ManyToOne)
  • Order → Product (ManyToOne)

주문 엔티티가 회원/상품을 직접 참조해 구조를 직관적으로 유지했습니다.

시간 컬럼 처리

  • created_at은 DB의 DEFAULT CURRENT_TIMESTAMP를 사용
  • 엔티티에서는 insertable=false, updatable=false로 읽기 전용 매핑

애플리케이션 코드에서 시간을 직접 채우지 않고 DB 기본값에 맡기는 가장 단순한 방식입니다.

 

CRUD API

1) Repository

  • member/domain/MemberRepository
  • product/domain/ProductRepository
  • order/domain/OrderRepository

2) Service

  • member/application/MemberService
  • product/application/ProductService
  • order/application/OrderService

3) API DTO

  • member/api/MemberDtos
  • product/api/ProductDtos
  • order/api/OrderDtos

4) Controller

  • member/api/MemberController (/api/members)
  • product/api/ProductController (/api/products)
  • order/api/OrderController (/api/orders)

제공되는 CRUD 엔드포인트

각 리소스 공통:

  • POST /api/{resource}
  • GET /api/{resource}/{id}
  • GET /api/{resource}
  • PUT /api/{resource}/{id}
  • DELETE /api/{resource}/{id}

리소스:

  • members
  • products
  • orders

모든 응답은 기존 공통 포맷 ApiResponse로 반환됩니다.

주문 API 현재 동작

  • 주문 생성 시 memberId, productId, quantity를 받음
  • 상품 현재 가격을 unit_price로 저장
  • total_amount = unit_price * quantity 계산
  • 재고 차감/락/멱등성은 아직 미구현 (다음 단계에서 개선 예정)

CRUD 단위 테스트 (Mockito)

추가된 테스트 파일

  • backend/src/test/java/com/marketengine/backend/member/application/MemberServiceTest.java
  • backend/src/test/java/com/marketengine/backend/product/application/ProductServiceTest.java
  • backend/src/test/java/com/marketengine/backend/order/application/OrderServiceTest.java

테스트 범위

MemberServiceTest

  • 회원 생성 성공
  • 이메일 중복 시 CONFLICT 예외
  • 없는 회원 수정 시 RESOURCE_NOT_FOUND 예외

ProductServiceTest

  • 상품 생성 성공
  • 없는 상품 조회 시 RESOURCE_NOT_FOUND 예외
  • 상품 수정 시 필드 변경 반영 확인

OrderServiceTest

  • 주문 생성 시 상품 가격으로 unitPrice/totalAmount 계산 확인
  • 회원 없을 때 주문 생성 실패(RESOURCE_NOT_FOUND)
  • 주문 수정 시 상태/수량/총액 갱신 확인

Swagger/OpenAPI 자동화 적용

  • backend/build.gradle
    • springdoc-openapi-starter-webmvc-ui 의존성 추가
  • backend/src/main/resources/application.yml
    • OpenAPI 문서 경로: /api-docs
    • Swagger UI 경로: /swagger-ui.html
    • UI 정렬 옵션 추가
  • backend/src/main/java/com/marketengine/backend/common/config/OpenApiConfig.java
    • 문서 메타 정보(title/version/description) 설정

앱 실행 후 아래 주소로 접속

  • Swagger UI: http://localhost:8080/swagger-ui.html
  • OpenAPI JSON: http://localhost:8080/api-docs

 

Filter HTTP 요청 응답 로깅

  • backend/src/main/java/com/marketengine/backend/common/logging/RequestResponseLoggingFilter.java
package com.marketengine.backend.common.logging;

import java.io.IOException;
import java.util.UUID;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

@Component
public class RequestResponseLoggingFilter extends OncePerRequestFilter {

    private static final Logger log = LoggerFactory.getLogger(RequestResponseLoggingFilter.class);
    private static final String REQUEST_ID_KEY = "requestId";
    private static final String REQUEST_ID_HEADER = "X-Request-Id";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        long start = System.currentTimeMillis();

        String requestId = resolveRequestId(request);
        MDC.put(REQUEST_ID_KEY, requestId);
        response.setHeader(REQUEST_ID_HEADER, requestId);

        try {
            filterChain.doFilter(request, response);
        } catch (Exception ex) {
            log.error("Request failed method={} uri={} message={}",
                    request.getMethod(), request.getRequestURI(), ex.getMessage(), ex);
            throw ex;
        } finally {
            long durationMs = System.currentTimeMillis() - start;
            int status = response.getStatus();

            log.info("Request completed method={} uri={} status={} durationMs={}",
                    request.getMethod(), request.getRequestURI(), status, durationMs);
            MDC.remove(REQUEST_ID_KEY);
        }
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return uri.startsWith("/swagger-ui")
                || uri.equals("/swagger-ui.html")
                || uri.startsWith("/api-docs");
    }

    private String resolveRequestId(HttpServletRequest request) {
        String requestId = request.getHeader(REQUEST_ID_HEADER);
        if (requestId == null || requestId.isBlank()) {
            return UUID.randomUUID().toString();
        }
        return requestId;
    }
}
  • OncePerRequestFilter 기반으로 전역 요청/응답 로깅

로그에 남는 항목:

  • method
  • uri
  • status
  • durationMs
  • requestId (MDC + 응답 헤더 X-Request-Id)

예외 발생 시:

  • Request failed ... 에러 로그 출력 후 예외 재던짐
  • 최종적으로 완료 로그도 남김 (finally)