본문 바로가기

개발 기록/미국 증시 분석 자동화 시스템 구축기

Spring Boot MyBatis JWT 백엔드 설계 — 도메인 중심 패키지 구조와 인증 구현 [구축기 2편]

이전 글: 1편 — 왜 만들었고, 어떻게 설계했나
다음 글: 3편 — 매일 새벽 자동으로 돌아가는 파이프라인 설계

 


백엔드 설계에서 가장 먼저 결정할 것

 
백엔드를 시작할 때 가장 먼저 결정한 건 패키지 구조다.
선택지는 두 가지였다.
 

  • 기능별 구조: controller/, service/, repository/ 폴더 안에 모든 도메인 파일을 모아두는 방식
  • 도메인 중심 구조: domain/user/, domain/trade/, domain/news/ 처럼 도메인별로 독립시키는 방식

 

이 프로젝트는 도메인 중심을 선택했다.
이유는 단순하다. user와 관련된 코드(Controller, Service, Mapper, DTO)는 항상 함께 수정된다.

trade를 바꿀 때 user 폴더를 뒤질 일이 없다.

 
도메인이 독립성을 가질수록 코드를 찾는 시간이 줄고, 나중에 한 도메인만 잘라내서 분리하기도 쉬워진다.
 

com.stock.account/
├── domain/
│ ├── user/
│ │ ├── controller/
│ │ ├── service/
│ │ ├── mapper/
│ │ └── dto/
│ ├── trade/
│ ├── news/
│ └── blog/
├── common/
│ ├── exception/
│ └── response/
└── security/

 
 

각 도메인은 controller → service → mapper → DB 계층을 독립적으로 소유한다.

다른 도메인의 Mapper를 직접 참조하는 일은 없고, 필요하면 Service 레이어끼리만 호출한다.


 

ORM 대신 MyBatis를 선택한 이유

스프링 프로젝트라면 JPA를 먼저 떠올리는 게 일반적이다.
이 프로젝트에서는 처음부터 MyBatis를 선택했다.
 
이유는 데이터 접근 패턴에 있다.
금융 데이터는 단순 CRUD보다 복잡한 집계 쿼리가 훨씬 많다.
 
 

  • 보유 종목별 평균 단가 계산 → 매매 내역 전체 집계
  • 손익 계산 → 현재가 + 평균단가 + 환율 + 배당금 복합 연산
  • 날짜 범위 + 계좌별 필터 + 카테고리 구분 복합 조건

JPA + JPQL로 이런 쿼리를 쓰면 가독성이 떨어지고, N+1 문제를 의식하면서 설계해야 하는 피로가 생긴다.
MyBatis XML에 SQL을 직접 쓰면 EXPLAIN으로 성능을 바로 확인할 수 있다.

 
 

실제 쿼리 예시를 보면 왜 MyBatis가 편한지 바로 알 수 있다.

<!-- mapper/balance/BalanceMapper.xml -->
<select id="calculateBalance" resultType="BalanceDto">
    SELECT
        s.ticker,
        s.stock_name,
        SUM(t.quantity) AS total_quantity,
        SUM(t.quantity * t.price) / SUM(t.quantity) AS avg_price,
        sp.close_price AS current_price,
        (sp.close_price - SUM(t.quantity * t.price) / SUM(t.quantity))
            * SUM(t.quantity) AS unrealized_pnl
    FROM trades t
    JOIN stocks s ON t.stock_id = s.id
    JOIN stock_prices sp ON sp.stock_id = s.id
        AND sp.price_date = #{lastTradingDate}
    WHERE t.account_no = #{accountNo}
      AND t.trade_type = 'BUY'
    GROUP BY s.ticker, s.stock_name, sp.close_price
</select>

SQL이 XML에 명시적으로 드러나 있어서 디버깅이 훨씬 쉽다.
실행 중 슬로우 쿼리가 생기면 해당 XML만 꺼내서 EXPLAIN을 돌리면 된다.
 


 

JWT Stateless 인증 구조

인증은 JWT Stateless 방식으로 구현했다. 서버에 세션을 저장하지 않는다.
선택 이유는 단순하다. REST API 서버이고, 사용자가 나 혼자이며, 토큰 검증만 하면 되는 구조다.
 
 
토큰 구조

토큰 유효 시간 저장 위치
Access Token 1시간 서버 미저장
Refresh Token 24시간 DB (refresh_token 테이블)

모든 요청은 JwtAuthFilter를 통과한다.
Authorization: Bearer {token} 헤더를 읽고 유효성을 검증한 뒤

SecurityContextHolder에 인증 정보를 등록한다.

 

 
 

// JwtAuthFilter.java
@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain filterChain) {
    String token = resolveToken(request);

    if (token != null && jwtTokenProvider.validateToken(token)) {
        Authentication auth = jwtTokenProvider.getAuthentication(token);
        SecurityContextHolder.getContext().setAuthentication(auth);
    }

    filterChain.doFilter(request, response);
}

인증 제외 경로는 /api/auth/**, /swagger-ui/**, /api-docs/** 세 가지다.
CSRF는 REST API이므로 비활성화, 세션은 STATELESS 정책을 명시했다.

 
 


공통 응답 구조와 예외 처리

초반에 귀찮아서 건너뛰고 싶었던 부분이다. 결과적으로 가장 잘 한 결정이 됐다.
모든 REST 응답은 동일한 구조를 사용한다.
 

{
  "code": 200,
  "message": "success",
  "data": { ... }
}

ApiResponse<T>를 제네릭으로 만들어두면 Controller는 ApiResponse.success(data) 한 줄로 응답을 만든다.

프론트엔드도 response.data.code === 200 하나로 성공/실패를 판별한다.

 

예외 처리는 @RestControllerAdvice 하나에 집중했다.

 
 

// 사용 예시 — Service 레이어
if (trade.getQuantity() <= 0) {
    throw new CustomException(HttpStatus.BAD_REQUEST, "수량은 0보다 커야 합니다");
}

덕분에 Controller에 try-catch가 없다.

 


외부 API 호출 — 추가 의존성 없이

외부 API 호출에 RestTemplate이나 WebClient 대신 Java 11+ 내장 HttpClient를 직접 사용했다.

이유는 세 가지다.
 

  • 추가 의존성이 없다. build.gradle에 항목이 하나 줄어든다
  • 동기/비동기 모두 지원한다. 나중에 비동기 전환 시 API가 동일하다
  • 개인 프로젝트 수준에서 HTTP 클라이언트 추상화 레이어를 하나 더 만드는 건 오버엔지니어링이다
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create(url))
    .header("Content-Type", "application/json")
    .GET()
    .build();

HttpResponse<String> response = client.send(request,
    HttpResponse.BodyHandlers.ofString());

 

RSS 파싱에는 com.rometools:rome 라이브러리를 사용했다.

XML을 직접 파싱하는 것보다 훨씬 깔끔하게 피드 항목을 순회할 수 있다.
 


마무리

백엔드 설계에서 선택한 것들을 정리하면 다음과 같다.

패키지 구조도메인 중심수정 범위가 도메인 안에서 끝남
ORMMyBatis집계 쿼리 비중이 높고 SQL 직접 제어 필요
인증JWT StatelessREST API + 단일 사용자 구조
응답 구조ApiResponse 공통화프론트엔드 처리 단순화
HTTP 클라이언트Java 내장 HttpClient추가 의존성 없이 충분

 

다음 편에서는 이 백엔드 위에서 매일 새벽 돌아가는 파이프라인을 어떻게 설계했는지 다룬다.
@Scheduled, Quartz, Spring Batch 세 가지 스케줄링 전략을 왜 함께 쓰는지,

9단계 파이프라인의 시간 배치 기준도 함께 정리한다.