1. 스프링 ai 와 rag

PostgreSQL과 pgvector를 활용하여 고성능 Vector Database를 직접 구축, 최신 기술 매뉴얼 같은 '비정형 데이터'를 AI가 읽을 수 있는 형태로 변환하는 과정.

 

기업 내부의 기밀 문서나 최신 기술 매뉴얼 같은 '비정형 데이터'를 AI가 읽을 수 있는 형태로 변환하는 과정으로 텍스트를 수치화하는 임베딩(Embedding)의 원리를 이해해야 한다. 그리고 대용량 문서를 효율적으로 관리하기 위한 문서 분할(Chunking) 전략이 또한 중요하다.ㅣ

 

docker run -d --name local-postgres --restart always -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=sparta -p 5432:5432 pgvector/pgvector:pg16

 

-- 벡터 기능을 이 데이터베이스에서 활성화합니다.
CREATE EXTENSION IF NOT EXISTS vector;

SELECT * FROM pg_extension WHERE extname = 'vector';

 

자 정리하니 이렇다. 4일과정을 들어보니 rag정도는 빙산의 일각에 불과했다. 여기까지는 챗봇으로서.. 돈이 안되는 녀석이다. 비개발자 녀석이 취미로 이것을 만든것을 본적이 있다. 바이브만으로 쉽게 되는 것이니.. 궂이 파이썬을 쓸필요도 없다. 파인튜닝 할거 아니면...

 

 

2. 스프링 ai 와 vector db

Vector DB를 기반으로, AI 서비스의 표준 모델인 RAG(Retrieval-Augmented Generation) 패턴을 본격적으로 구현

유사도 검색(Similarity Search) 기법과 LLM의 프롬프트와 결합하여 환각 현상(Hallucination)이 억제된 정확한 답변

 

SCENARIO 1: 단순 지식 검색 (Fact Retrieval)

SCENARIO 2: 조건부 질문 (Conditional Logic)

SCENARIO 3: 복합 조건 추론 (Complex Reasoning)

SCENARIO 4: 검색 결과 요약 (Search Summary)

 

성능 모니터링: AOP를 활용한 지연 시간 측정( RagPerformanceMonitor)

 

역시 챗봇을 만들수 있는 rag를 한번 더 다뤘다. 지피티, 제미나이, 클로드 등이 모르는 사내 내규 같은 것을 학습시켜 적절한 답변을 생성하게 하는 것이다. 학습시킨 데이터를 검색해서, 질문하고 , 복합조건도 추론해서 고도화된 질문에 답하고 대답하는것. 역시 이정도 단계는 아무것도 아닌것이었다. 그 친구가 너무 쉽게 만들었다.

 

3. Advisor와 FunctionCalling

 비즈니스 로직과 AI 공통 관심사를 명확하게 분리해 주는 Advisor 패턴을 학습 

 AI가 외부 시스템과 직접 상호작용하게 만드는 Function Calling을 마스터

Advisor는 Spring AI에서 ChatClient의 요청(Request)과 응답(Response) 사이를 가로채어 추가적인 로직을 수행하는 미들웨어(Middleware)이자 인터셉터(Interceptor) 패턴의 구현체

 

Context(공유 저장소)는 Advisor 체인을 따라 흐르는 공유 바구니와 같습니다. (Map<String, Object>)

 Spring AI의 Advisor 시스템을 깊이 있게 이해하려면 그 근간이 되는 BaseAdvisor 인터페이스와 데이터를 주고받는 객체 구조를 파악해야 합니다.

 BaseAdvisor는 동기(Call) 방식과 비동기(Stream) 방식 모두를 지원하며, 개발자가 복잡한 흐름 제어 대신 비즈니스 로직(Before/After)에만 집중할 수 있도록 설계되었습니다.

before(request, chain)

: LLM에 전달되기 전의 메시지, 옵션, 컨텍스트를 수정합니다. (예: 시스템 프롬프트 추가)

after(response, chain)

: LLM이 반환한 결과값을 검증하거나 저장합니다. (예: 대화 기록 DB 저장)

 

🔹 ChatClientRequest (요청 데이터) : LLM을 호출하기 위해 필요한 모든 '재료'가 담겨 있습니다.

 

대화형 AI를 개발할 때, 단순 텍스트 결합보다 더 정교한 관리가 필요할 때가 있습니다.MessageChatMemoryAdvisor

는 대화 내역을 단순한 문자열이 아닌 구조화된 객체(Message Object) 단위로 다루는 진화된 방식의 메모리 Advisor입니다.

 

AI 서비스가 실제 사용자에게 노출될 때 가장 중요한 것은 안전성(Safety)입니다. 모델이 부적절한 질문에 답변하거나, 예기치 않게 편향되거나 유해한 내용을 출력하는 것을 방지해야 합니다. SafeGuardAdvisor 는 이러한 안전 가드레일(Safety Guardrails) 역할을 수행합니다.

 

SafeGuardAdvisor로 할 수 있는 일들

  1. 욕설 및 혐오 표현 차단: 커뮤니티 가이드라인 준수.
  2. 개인정보 유출 방지: 주민등록번호, 전화번호 등이 답변에 포함될 경우 마스킹(**) 처리.
  3. 정치/종교 편향 방지: 민감한 주제에 대해 중립적인 답변을 하도록 유도하거나 답변 거부.

프롬프트 인젝션 방어: "이전 지시사항을 무시하고..."와 같은 해킹 시도 감지 및 차단.

ChatMemoryAdvisor

이 Advisor는 챗봇이 "방금 뭐라고 하셨죠?"라는 질문에 당황하지 않게 만드는 '단기 기억 장치' 역할을 합니다.

 

LLM의 손과 발: Function Calling (현실 세계 연결)

ChatClient 를 사용하여 LLM에게 도구 상자(tools)를 전달합니다.
검증 포인트 (Checklist)
  1. 연쇄 호출 (Chaining): 복합 질문 시 LLM이 한 번의 요청으로 두 번 이상의 Tool을 호출하는가?
  2. 자연어 생성 품질: Tool에서 반환된 날씨/계산기 데이터를 바탕으로 사용자 친화적인 문장을 생성하는가?
  3. 에러 핸들링: "0으로 나누기" 같은 잘못된 요청 시 Advisor나 Tool에서 발생한 예외를 AI가 어떻게 설명하는가?
 
자 여기서부터 본격적으로 어려웠고 약간 멘붕이었다.  advisor 조언가! 그래 뭔말인지는 알겠다. 그리고 FunctionCalling으로 LLM에 손과 발을 달아준다는 것도 뭔지 알겠다. 그러나 여기서부터 디테일이 조금 들어갈수 있다. rag보다 훨씬 깊게 들어가서...  조금 복잡했다. 사실 advisor는 간단하게 말하면 before, after 점검으로 안전한 답변 생성에 좋다. 그리고 FunctionCalling으로 AI가 부가정보를 수집할수 있는 여러 도구들을 달아준다. 여기서는 튜닝이 중요했다.
 

4. Agenticd Workflow 

 

Thought(what) - > Action  -> Observation -> Final Answe

 

시나리오별 API 호출

Scenario 1: 기본 ReAct 패턴

Scenario 2: 도구 제한 (Guardrail) => 보안과 비용 제어

Scenario 3: 계획-실행 (Plan-and-Execute) => 전략 수립과  결과의 정확도의 상관관계파악

Scenario 4: 자기 반성 (Self-Reflection) => 할루시네이션감소

 

[패턴 1] 전략적 계획 및 단계별 실행 (Plan-and-Execute)

패턴 1: 기본 Plan-and-Execute (전략가형) : 동작 원리: 목표 분석 → 전체 계획 수립 → 계획 일괄 실행 → 최종 요약

패턴 2: 상세 추적형 (투명성 강조형) :  동작 원리: 계획 수립 → 단계별 개별 호출 → 단계별 결과 저장 및 컨텍스트 전달 → 최종 결과 조립

패턴 3: 적응형 계획 (회복 탄력성형) :  동작 원리: 계획 수립 → 실행 → 결과 검증(Success/Fail) → 실패 시 피드백 반영 후 재계획(Re-planning)

 

[패턴 2] 협업형 멀티 에이전트

패턴 1: 순차적 협업 (Sequential Chain)

  • 흐름: 연구원(데이터 수집) → 분석가(인사이트 도출) → 작성자(보고서 완성)
  • 특징: 상위 에이전트의 결과가 하위 에이전트의 입력값(Context)이 됩니다.

패턴 2: 동적 파이프라인 (Dynamic Orchestration)

  • 핵심: analyzeProblemType 메서드가 중재자(Router) 역할을 수행합니다.
  • 장점: 불필요한 에이전트 호출을 줄여 비용과 시간을 최적화합니다.

패턴 3: 피드백 루프 (Iterative Review)

결과물의 품질이 중요할 때 사용합니다. 검토자(Reviewer) 에이전트가 승인할 때까지 작업을 반복합니다.

  • 핵심: 분석 결과가 미흡하면 "APPROVED"를 받지 못하고 다시 연구 단계로 되돌아갑니다.
  • 장점: 에이전트의 환각(Hallucination)을 최소화하고 높은 신뢰도를 보장합니다.

 

에이전트의 손과 발: CustomerSupportTools

@Tool :어노테이션이 붙은 메서드들은 에이전트가 필요할 때 직접 실행할 수 있는 기능을 제공합니다.

에이전트의 두뇌: CustomerSupportAgent : 이 서비스는 상황에 따라 서로 다른 '페르소나'를 생성하여 고객에게 최적화된 응답을 제공합니다.

  • 표준 응대 (handleCustomerRequest): 기본적인 질문에 대해 도구를 사용하여 답변합니다.
  • 우선순위 응대 (handlePrioritizedRequest): VIP 고객에게는 더 정중한 페르소나를, 긴급 고객에게는 신속한 해결 중심의 페르소나를 적용합니다.
  • 감정 지능 (handleCustomerRequestWithSentiment): 에이전트가 내부적으로 analyzeSentiment 메서드를 먼저 실행합니다.
    • 고객이 ANGRY 상태라면 시스템은 자동으로 상급자에게 보고(escalateToSupervisor)하는 가드레일을 작동시킵니다.
  • 글로벌 지원 (handleMultilingualRequest): 요청된 언어에 맞춰 시스템 프롬프트를 동적으로 변경합니다.

 

agent 까지 오니까 이녀석들이 무엇을 해야하는지 생각하고 실행하고 , 검토하고 결과를 내놓는다.

뭐 클로드에 있는 agent 녀석들 보명 무엇을 해야하는지 적혀 있다. 그렇게 생각하면 단순해 보인다. 대리인적으로 그냥 무엇을 하는 동료랄지... 그런것들이 있는것이다. 뭐 페르소나라고 해서 정체성 부여를 통해 agent에 화룡점정을 찍는다. 이런부분들이 코드에 미세하게 조정되는 것을 보면서... 어떻게 agent 빌더들이 생기는지 조금 알것 같다.

 자바 25로 오면서 동시성 처리가 워낙 좋아서, 비동기 노드에 전혀 밀리지 않는.. 성능을 자랑한다. 메모리나 cpu가 부족한 상황이 아닌 엔터프라이즈 서비스는 자바가 더 안정적인듯하다. 역시 스프링은 자바 25를 통해 노드만의 강점을 따라잡았다.

LIST

"느리다"는 증상이지, 진단이 아니다. 어디가 느린지를 모르면 최적화는 도박이 된다.


Spring Boot 기반 백엔드 시스템을 운영하다 보면 어느 시점에 반드시 성능 문제를 마주하게 된다. 그런데 "성능 최적화"라는 말 자체가 너무 넓다. 인덱스를 걸어야 하나? GC를 튜닝해야 하나? 캐시를 붙여야 하나? 문제의 층위를 구분하지 않으면 엉뚱한 곳에 시간을 쏟게 된다.

이 글에서는 Java Spring 시스템의 성능 최적화를 언어(Java), DB, 프레임워크(Spring), 애플리케이션 네 개의 계층으로 분리하고, 각 계층에서 실질적으로 효과가 큰 기법들을 정리한다. 각각의 계층은 관심사가 다르고, 측정 방법도 다르고, 투자 대비 효과도 다르다.

1. 언어 레벨 — Java 런타임과 코드 수준의 최적화

언어 레벨 최적화는 마이크로 단위의 개선이다. 단일 항목의 효과는 미미할 수 있지만, 초당 수천 건의 요청을 처리하는 시스템에서는 이 작은 차이들이 누적된다.

String 처리의 함정

반복문 안에서 + 연산자로 문자열을 연결하면, 매 반복마다 새로운 String 객체가 생성된다. 10만 건의 로그를 조합하는 배치 작업이라면 이것만으로 GC 부담이 눈에 띄게 증가한다.

 
 
java
// 반복문 안에서는 StringBuilder를 명시적으로 사용
StringBuilder sb = new StringBuilder(1024);
for (OrderItem item : items) {
    sb.append(item.getName()).append(": ").append(item.getPrice()).append('\n');
}

Java 21+의 String Template(STR."...")은 가독성 면에서 매력적이지만, 성능이 민감한 핫 패스에서는 여전히 StringBuilder가 안전한 선택이다.

컬렉션, 제대로 고르고 있는가

ArrayList vs LinkedList 논쟁은 사실상 끝났다. CPU 캐시 지역성 때문에 현실적인 거의 모든 시나리오에서 ArrayList가 빠르다. 더 실질적으로 신경 써야 할 것은 HashMap의 초기 용량 설정이다. 1,000개의 엔트리가 예상되면 new HashMap<>(1024)로 잡아 리해싱 횟수를 줄이는 것이 간단하면서도 효과적이다.

Stream의 양면

Stream API는 선언적 코드를 작성하게 해주지만, 단순 반복에서는 전통적 for-loop보다 느리다. 더 중요한 건 parallelStream()의 함정이다. 이것은 내부적으로 ForkJoinPool.commonPool()을 공유하는데, 웹 서버처럼 이미 수백 개의 스레드가 동시에 요청을 처리하는 환경에서 ForkJoinPool까지 경합이 발생하면 오히려 전체 처리량이 떨어진다.

 
 
java
// 웹 요청 처리 중에는 이렇게 하지 말 것
List<Result> results = orders.parallelStream()  // ForkJoinPool 경합 유발
    .map(this::processOrder)
    .collect(toList());

// 대신 명시적 스레드풀 또는 순차 처리
List<Result> results = orders.stream()
    .map(this::processOrder)
    .collect(toList());

GC 튜닝

Java 17+ 기준으로 G1GC가 기본이지만, 저지연이 중요한 API 서버라면 ZGC를 고려할 만하다. GC 튜닝에서 가장 기본적이면서도 자주 빠뜨리는 것은 -Xms와 -Xmx를 동일하게 설정하는 것이다. 힙 리사이징은 그 자체로 Stop-the-World를 유발할 수 있다.

 
 
bash
# 힙 사이즈 고정으로 리사이징 오버헤드 제거
java -Xms4g -Xmx4g -XX:+UseZGC -jar app.jar

박싱/언박싱 회피

대량 루프에서 Long 대신 long, Integer 대신 int를 쓸 수 있는 곳에서는 반드시 primitive를 사용한다. 오토박싱이 발생할 때마다 래퍼 객체가 힙에 생성되고, 이것이 수십만 번 반복되면 Young Generation GC 빈도가 눈에 띄게 올라간다.

불변 객체의 이점

Value Object를 불변으로 설계하면 동시성 문제를 근본적으로 회피하면서 GC에도 유리하다. Java 16+의 record가 이 용도에 딱 맞는다.

 
 
java
public record Money(BigDecimal amount, Currency currency) {
    public Money add(Money other) {
        // 새 객체를 반환 — 원본은 변하지 않음
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

2. DB 레벨 — 데이터 접근 효율의 최적화

현실적으로 대부분의 백엔드 성능 병목은 여기서 발생한다. 애플리케이션 코드를 아무리 다듬어도, 쿼리 한 줄이 Full Table Scan을 돌면 의미가 없다. ROI(투자 대비 효과)가 가장 높은 계층이기도 하다.

인덱스 전략

가장 효과 대비 비용이 낮은 최적화이며, 가장 먼저 점검해야 할 영역이다.

핵심 원칙은 세 가지다. 첫째, WHERE, JOIN, ORDER BY에 사용되는 컬럼에 인덱스를 건다. 둘째, 복합 인덱스의 컬럼 순서는 카디널리티가 높은 것을 앞에 둔다. 셋째, 커버링 인덱스를 활용하면 테이블 접근 자체를 생략할 수 있다.

 
 
sql
-- 주문 조회: user_id로 필터링하고 created_at으로 정렬하는 패턴이 빈번하다면
CREATE INDEX idx_order_user_created ON orders (user_id, created_at DESC);

-- 커버링 인덱스: 조회 컬럼까지 인덱스에 포함
CREATE INDEX idx_order_covering ON orders (user_id, created_at DESC) 
    INCLUDE (status, total_amount);

EXPLAIN ANALYZE를 습관화하라

쿼리를 작성하고 EXPLAIN ANALYZE를 돌려보지 않는 것은, 코드를 작성하고 테스트를 돌리지 않는 것과 같다. PostgreSQL 기준으로 주의해야 할 패턴은 이렇다.

대량 테이블에서 Seq Scan이 발생하면 인덱스 누락이나 통계 정보 갱신이 필요한 상황이다. 대량 데이터에서 Nested Loop Join이 나타나면 Hash Join이나 Merge Join으로 유도해야 한다. 그리고 SELECT *는 불필요한 I/O를 유발하므로 필요한 컬럼만 명시한다.

커넥션 풀

HikariCP의 maximumPoolSize를 무조건 크게 잡는 것은 흔한 실수다. 공식 권장 공식은 (코어 수 × 2) + 유효 디스크 수이며, 대부분의 경우 10~20이면 충분하다. 과도하게 크게 잡으면 DB 서버에서 컨텍스트 스위칭 비용이 증가해 오히려 처리량이 떨어진다.

 
 
yaml
spring:
  datasource:
    hikari:
      maximum-pool-size: 15
      minimum-idle: 5
      connection-timeout: 3000    # 3초 안에 커넥션을 못 얻으면 빠르게 실패
      idle-timeout: 600000
      max-lifetime: 1800000

전략적 반정규화

정규화는 데이터 정합성의 기본이지만, 읽기가 압도적으로 많은 테이블에서는 전략적 반정규화가 성능을 크게 개선한다. 예를 들어 정산 시스템에서 주문-결제-정산 3개 테이블을 매번 조인하는 대신, 정산 테이블에 주문 금액과 결제 수단을 스냅샷으로 보관하면 조인 비용을 없앨 수 있다.

파티셔닝

시계열 데이터(주문 로그, 정산 내역 등)는 날짜 기반 Range Partitioning을 적용하면 쿼리가 접근하는 물리적 데이터 범위를 줄일 수 있다.

 
 
sql
-- PostgreSQL Declarative Partitioning
CREATE TABLE settlements (
    id BIGSERIAL,
    settled_at TIMESTAMP NOT NULL,
    amount NUMERIC(15,2)
) PARTITION BY RANGE (settled_at);

CREATE TABLE settlements_2026_q1 PARTITION OF settlements
    FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');

읽기/쓰기 분리

트래픽이 높아지면 Primary-Replica 구조에서 읽기 쿼리를 Replica로 라우팅한다. Spring에서는 @Transactional(readOnly = true)를 선언한 서비스 메서드가 자동으로 Replica를 바라보도록 AbstractRoutingDataSource를 설정할 수 있다.

3. 프레임워크 레벨 — Spring과 JPA의 동작 원리를 아는가

이 계층의 최적화는 Spring과 JPA가 내부적으로 어떻게 동작하는지를 이해해야 가능하다. 프레임워크가 개발자 대신 해주는 일이 많을수록, 그 "대신 해주는 일"의 비용을 인식하지 못하면 성능 함정에 빠지기 쉽다.

JPA N+1 — 가장 흔하고 가장 치명적인 문제

부모 엔티티 1건을 조회한 뒤, 연관된 자식 엔티티를 건별로 추가 쿼리하는 N+1 문제는 JPA를 쓰는 모든 프로젝트에서 발생한다. LAZY 로딩을 기본 전략으로 설정하되, 한 번에 가져와야 하는 시점에서는 JOIN FETCH를 명시적으로 사용한다.

 
 
java
// QueryDSL에서 fetchJoin 활용
List<Order> orders = queryFactory
    .selectFrom(order)
    .join(order.orderItems, orderItem).fetchJoin()
    .join(order.payment, payment).fetchJoin()
    .where(order.userId.eq(userId))
    .fetch();

@EntityGraph를 사용하는 방법도 있지만, 복잡한 조건이 들어가는 쿼리에서는 QueryDSL의 fetchJoin()이 더 세밀한 제어가 가능하다.

Dirty Checking의 숨겨진 비용

JPA는 트랜잭션이 커밋될 때 영속성 컨텍스트에 올라간 모든 엔티티의 스냅샷을 비교해서 변경된 필드를 감지한다. 이것이 Dirty Checking인데, 영속성 컨텍스트에 엔티티가 1,000개 올라가 있으면 flush 시점에 1,000번의 스냅샷 비교가 발생한다.

대응 전략은 명확하다. 읽기 전용 쿼리에는 반드시 @Transactional(readOnly = true)를 건다. 이렇게 하면 Hibernate가 스냅샷 자체를 생성하지 않아 메모리와 CPU를 절약한다. 벌크 업데이트는 엔티티를 하나씩 수정하지 말고, JPQL executeUpdate()나 QueryDSL 벌크 연산을 사용한다.

 
 
java
// 엔티티 하나씩 수정 — 느림
orders.forEach(order -> order.updateStatus(COMPLETED));

// 벌크 업데이트 — 빠름
queryFactory
    .update(order)
    .set(order.status, COMPLETED)
    .where(order.id.in(orderIds))
    .execute();
em.clear();  // 벌크 연산 후에는 영속성 컨텍스트를 반드시 초기화

캐싱 전략

Spring Cache Abstraction(@Cacheable, @CacheEvict)은 적용이 간편하지만, 캐시 대상의 선택이 핵심이다. "자주 읽히고, 자주 변하지 않는" 데이터가 캐시 대상이다. 상품 정보, 카테고리 목록, 설정값 같은 것이 대표적이고, 정산 결과처럼 변경이 잦은 데이터를 캐시하면 정합성 문제가 생긴다.

실무에서는 로컬 캐시(Caffeine)와 분산 캐시(Redis)를 계층화하는 것이 효과적이다. 로컬 캐시로 1차 방어를 하고, 미스가 발생하면 Redis를 조회하고, 거기서도 미스면 DB를 조회하는 구조다.

비동기 처리

이메일 발송, 슬랙 알림, 감사 로그 기록 같은 비핵심 로직은 @Async로 메인 스레드에서 분리한다. 단, Spring의 기본 SimpleAsyncTaskExecutor는 호출마다 새 스레드를 생성하므로, 반드시 ThreadPoolTaskExecutor를 별도로 설정해야 한다.

 
 
java
@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean("notificationExecutor")
    public Executor notificationExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("notification-");
        executor.setRejectedExecutionHandler(new CallerRunsPolicy());
        return executor;
    }
}

Tomcat 튜닝

Spring Boot 내장 Tomcat의 기본 설정은 범용적이지만 최적은 아니다. server.tomcat.threads.max(기본 200)를 워크로드에 맞게 조정하고, server.tomcat.accept-count로 대기열 크기를 제어한다. WebFlux 전환 없이도 Tomcat 튜닝만으로 상당한 처리량 개선이 가능하다.

Spring Security 필터 체인

인증이 필요 없는 경로(/health, /actuator, 정적 리소스)는 WebSecurity.ignoring()으로 필터 체인 자체를 우회시켜야 한다. 매 요청마다 불필요한 필터 10~15개를 통과하는 것은 순수한 오버헤드다.

4. 애플리케이션 레벨 — 아키텍처와 설계 판단

이 계층의 최적화는 코드 한 줄의 문제가 아니라, 시스템의 구조적 결정에 해당한다. 효과가 가장 크고, 나중에 바꾸기 가장 어려운 영역이기도 하다.

API 설계

Over-fetching은 API 레벨에서 가장 흔한 성능 낭비다. 목록 조회용 SummaryDTO와 상세 조회용 DetailDTO를 분리하는 것만으로도 네트워크 전송량과 직렬화 비용이 줄어든다.

페이지네이션은 Offset 방식(LIMIT 100 OFFSET 10000)은 오프셋이 커질수록 느려진다. 대량 데이터에서는 Cursor 기반(Keyset Pagination)이 일관된 성능을 보장한다.

 
 
java
// Offset 방식 — 뒤로 갈수록 느려짐
SELECT * FROM orders ORDER BY id DESC LIMIT 20 OFFSET 100000;

// Cursor 방식 — 항상 일정한 속도
SELECT * FROM orders WHERE id < :lastSeenId ORDER BY id DESC LIMIT 20;

이벤트 기반 아키텍처

동기 호출 체인이 길어지면 응답 시간이 선형으로 증가한다. 주문 완료 → 결제 확인 → 재고 차감 → 알림 발송 → 포인트 적립이라는 흐름에서, 사용자가 기다려야 하는 건 결제 확인까지다. 나머지는 이벤트로 비동기 전환하면 사용자 체감 응답 시간을 수백 밀리초 단위로 줄일 수 있다.

Spring의 ApplicationEventPublisher로 시작하되, 서비스 간 통신이 필요한 MSA 환경에서는 Kafka나 RabbitMQ 같은 메시지 브로커로 확장한다.

서킷 브레이커

MSA에서 하나의 서비스가 느려지면, 그것을 호출하는 모든 서비스의 스레드가 블로킹되면서 연쇄 장애(Cascading Failure)가 발생한다. Resilience4j로 서킷 브레이커를 적용하면 장애가 전파되기 전에 빠르게 차단하고, fallback 로직으로 graceful degradation을 구현할 수 있다.

 
 
java
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
public PaymentResult processPayment(PaymentRequest request) {
    return paymentClient.process(request);
}

private PaymentResult paymentFallback(PaymentRequest request, Throwable t) {
    // 결제 서비스 장애 시 → 재시도 큐에 적재
    retryQueue.enqueue(request);
    return PaymentResult.pending();
}

배치 처리 최적화

정산 시스템처럼 대량 데이터를 주기적으로 처리하는 경우, Spring Batch의 chunk 사이즈가 성능을 좌우한다. JPA를 사용할 때는 chunk 단위로 entityManager.clear()를 호출해서 영속성 컨텍스트가 무한히 커지는 것을 방지해야 한다. 대량 삽입이 필요하면 JdbcBatchItemWriter가 JPA 대비 수 배 빠르다.

모니터링 — 측정 없는 최적화는 도박이다

Spring Actuator + Micrometer로 메트릭을 수집하고, Prometheus로 저장하고, Grafana로 시각화하는 조합이 사실상 표준이다. 최소한 다음 네 가지는 항상 추적해야 한다.

응답 시간 분포(P50, P95, P99)는 평균이 아니라 백분위로 봐야 한다. 평균 100ms인데 P99가 5초라면 100명 중 1명은 5초를 기다리고 있다는 뜻이다. 커넥션 풀 사용률이 80%를 넘기면 풀 크기를 키우거나 쿼리 속도를 개선해야 한다. GC 빈도와 pause time은 GC가 자주 돌거나 pause가 길면 힙 사이즈와 GC 알고리즘을 재검토해야 한다. 스레드 풀 상태에서 Tomcat 스레드가 전부 점유되어 있으면 외부 호출 지연이나 DB 병목을 의심해야 한다.

Graceful Shutdown

배포 시 진행 중인 요청을 안전하게 마무리하는 것도 시스템 안정성의 일부다. server.shutdown=graceful 한 줄 추가하면 Spring Boot가 새 요청은 거부하고 기존 요청이 완료될 때까지 기다린 뒤 종료한다.

계층별 ROI — 어디부터 손대야 하는가

네 개의 계층을 정리했지만, 실무에서 모든 것을 동시에 최적화할 수는 없다. 일반적으로 투자 대비 효과가 큰 순서는 이렇다.

DB > 애플리케이션 > 프레임워크 > 언어

인덱스 하나 추가하는 데 5분이 걸리지만, 쿼리 응답 시간을 10배 줄일 수 있다. 아키텍처 결정(동기→비동기 전환, 캐시 도입)은 시간이 더 걸리지만 시스템 전체의 처리량을 바꿀 수 있다. 프레임워크 레벨 튜닝(N+1 해결, Security 필터 최적화)은 중간 수준의 효과를 준다. 언어 레벨 마이크로 최적화는 위 세 가지를 다 한 뒤에 병목이 남아 있을 때 의미가 있다.

그리고 이 모든 것의 전제 조건은 모니터링이다. 측정 없이 최적화하면 감으로 코딩하는 것과 다를 바 없다. 프로파일링 도구를 켜고, 병목 지점을 확인하고, 거기를 최적화하고, 다시 측정하는 사이클을 반복하는 것이 성능 최적화의 정석이다.


이 글에서 다룬 기법들은 Spring Boot + JPA + PostgreSQL 조합을 기준으로 작성했지만, 계층별 사고방식 자체는 어떤 스택에서든 적용 가능하다. 결국 성능 최적화의 본질은 "어디서 시간이 소모되는지 찾아내고, 그 지점을 개선하는 것"이기 때문이다.

LIST

1. 어디에 쓰는 설계인가

이 설계는 아래 같은 서비스에 맞습니다.

  • 사내 업무 API
  • BFF
  • CRUD 중심 백엔드
  • 외부 API 호출이 많은 중계/집계 서비스
  • DB I/O, HTTP I/O 비중이 높은 시스템

반대로 CPU 연산이 주업무인 서비스는 Virtual Thread만으로 이득이 크지 않습니다. Virtual Thread는 “연산을 빠르게”가 아니라 대기 시간을 견디는 동시성 모델에 더 가깝기 때문입니다. Spring 쪽도 virtual threads가 많아지면 비동기 모델의 필요성이 줄어드는 많은 경우를 이야기하지만, 네트워크 지연 자체를 없애는 건 아니라고 설명합니다.


2. 권장 방향 한 줄 요약

Spring MVC + Tomcat + JPA/JDBC + RestClient + Virtual Thread
이 조합으로 가는 게 제일 현실적입니다.

Spring Boot 4는 Tomcat 11 / Servlet 6.1 기반이고, Jakarta EE 11 정렬이 이미 되어 있습니다. 즉 Boot 4에서는 굳이 “무조건 WebFlux”로 갈 이유가 더 줄었습니다.


3. 아키텍처 그림

[ Client ]
|
v
[ API Gateway / LB ]
|
v
[ Spring Boot 4 App ]
- Spring MVC Controller
- Service Layer
- Domain / UseCase
- Repository (JPA/JDBC)
- Outbound Client (RestClient / HTTP Interface Client)
|
+--> [ PostgreSQL / MySQL ]
+--> [ Redis ]
+--> [ External APIs ]
+--> [ Kafka / MQ ] (선별 적용)
 

실행 모델은 이렇게 보시면 됩니다.

요청 1건 = Virtual Thread 1개
Controller -> Service -> JPA/JDBC -> 외부 API 호출
블로킹 코드를 유지하되, 스레드 비용을 낮춰 동시성을 확보
 

4. 설계 원칙

원칙 1) “동기 코드 유지”를 기본값으로 둡니다

Virtual Thread의 가장 큰 장점은 복잡한 reactive 체인 없이도 높은 동시성 구조를 만들기 쉽다는 점입니다. 그래서 Boot 4에서는 먼저 동기 코드로 설계하고, 정말 필요한 곳만 별도 비동기/리액티브로 분리하는 게 낫습니다. Spring은 virtual threads를 활성화하면 기본 task executor와 scheduler가 virtual-thread 기반으로 바뀐다고 문서화하고 있습니다.


원칙 2) CPU-bound 작업은 분리합니다

이건 제 설계 판단입니다.
Virtual Thread는 I/O 대기 처리엔 좋지만, 이미지 변환, 대용량 압축, 암호화 일괄처리, 복잡한 통계 계산처럼 CPU를 오래 점유하는 작업은 별도 platform-thread 풀이나 배치 워커로 분리하는 게 맞습니다.

즉:

  • I/O-bound: Virtual Thread
  • CPU-bound: 고정 크기 platform thread pool
  • 대량 비동기 배치: 별도 worker / queue

원칙 3) DB 커넥션 풀은 그대로 “작게, 정확하게” 잡습니다

여기서 많이 착각합니다.

Virtual Thread를 쓴다고 DB 커넥션도 무한정 늘릴 수 있는 게 아닙니다.
실제 병목은 대부분 DB 커넥션 풀, DB lock, 외부 API rate limit입니다.

그래서:

  • Virtual Thread 수 ≠ DB 커넥션 수
  • HikariCP 풀 크기는 DB가 감당 가능한 수준으로 유지
  • 애플리케이션 동시성은 높여도, 외부 자원은 제한해야 함

Boot는 DataSource 계측과 Hikari 메트릭을 기본 지원합니다. 그래서 튜닝은 감으로 하지 말고 메트릭 보고 가야 합니다.


원칙 4) pinned virtual thread를 운영 지표로 봐야 합니다

Spring Boot 문서는 virtual thread 활성화 전에 Pinned Virtual Threads를 꼭 확인하라고 경고합니다. 감지 방법으로 JDK Flight Recorder와 jcmd도 안내합니다.

실무적으로 pinned가 자주 생기는 구간은 보통 이렇습니다.

  • 오래 잡는 synchronized
  • 오래 블로킹되는 네이티브 호출
  • 일부 라이브러리의 내부 락
  • 메시징/리스너 계열의 특정 동시성 패턴

Spring Kafka 문서도 virtual threads와 concurrent message listener 조합에서는 pinning 및 race condition 가능성을 주의하라고 명시합니다.


원칙 5) 스케줄러는 “virtual thread니까 무한정”으로 생각하면 안 됩니다

Virtual Thread가 켜지면 scheduler도 SimpleAsyncTaskScheduler 쪽으로 가고, pooling 관련 설정은 무시됩니다. 즉 기존 풀 크기 조절 감각과 다르게 봐야 합니다. 또 virtual thread는 daemon thread라서 스케줄러가 앱 생존을 보장하지 않으므로 spring.main.keep-alive=true를 같이 두는 쪽이 안전합니다.


5. 추천 기술 스택

르무엘님 스타일로 현실적인 조합 드리면:

  • Spring Boot 4
  • Spring MVC
  • Tomcat 11

데이터

  • Spring Data JPA 또는 JdbcClient/JdbcTemplate
  • HikariCP
  • PostgreSQL

외부 호출

  • RestClient 또는 HTTP Interface Client
  • Resilience4j 또는 Spring Cloud 쪽 circuit breaker는 선택

관측성

  • Actuator
  • Micrometer
  • Prometheus
  • Grafana
  • JFR

비동기

  • 기본은 Virtual Thread
  • CPU-heavy 전용 executor 따로

6. 패키지 구조 추천

com.example.order
├─ api
│ ├─ OrderController
│ └─ dto
├─ application
│ ├─ OrderService
│ ├─ OrderQueryService
│ └─ port
├─ domain
│ ├─ Order
│ ├─ OrderStatus
│ └─ policy
├─ infrastructure
│ ├─ persistence
│ ├─ client
│ ├─ config
│ └─ messaging
└─ support
├─ concurrency
├─ logging
└─ observability
 

포인트는 support.concurrency를 따로 두는 겁니다.
여기에 아래를 모읍니다.

  • virtual thread 관련 설정
  • CPU-bound executor
  • bulkhead / semaphore
  • timeout 정책

7. 설정 예시

application.yml

 
spring:
threads:
virtual:
enabled: true
main:
keep-alive: true

management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,threaddump
 

spring.threads.virtual.enabled=true와 spring.main.keep-alive=true는 Spring Boot 공식 문서의 virtual threads 섹션에 근거한 설정입니다.


CPU 작업 분리용 Executor

 
@Configuration
public class ExecutorConfig {

@Bean(name = "cpuBoundExecutor")
public ExecutorService cpuBoundExecutor() {
int n = Runtime.getRuntime().availableProcessors();
return Executors.newFixedThreadPool(Math.max(2, n));
}
}
 

그리고 서비스에서는 이렇게 분리합니다.

  • DB 조회 / 외부 API / 일반 요청 처리 → 기본 virtual thread 흐름
  • 엑셀 대량 생성 / 이미지 리사이징 / 대용량 해시 → cpuBoundExecutor

이건 Spring 공식 강제 규칙은 아니고, 운영 안정성을 위한 실무 설계입니다.


8. 서비스 레이어 설계 규칙

요청 처리

Controller
-> Application Service
-> Repository / External Client
 

여기서는 그냥 평범한 blocking 코드로 가시면 됩니다.

 
@Transactional
public OrderDetail getOrder(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
CustomerInfo customer = customerClient.getCustomer(order.getCustomerId());
return mapper.toDetail(order, customer);
}
 

이런 구조가 Virtual Thread에서 가장 이득 보기 좋습니다.
코드를 reactive 스타일로 비틀지 않아도 동시성 이점을 가져가기 쉽기 때문입니다. 이는 Spring의 virtual thread 방향성과도 맞습니다.


9. 운영상 꼭 넣어야 할 보호장치

1) 외부 API bulkhead / semaphore

Virtual Thread가 많다고 외부 API까지 무한 호출하면 바로 터집니다.
외부 파트너 API는 반드시 동시 호출 제한을 거세요.

예:

  • 결제 API 동시 50
  • 사내 레거시 API 동시 20
  • 파일 변환 동시 4

2) DB timeout / HTTP timeout

Virtual Thread는 대기 비용을 줄여주지만, 느린 자원을 빠르게 만들진 않습니다.
그래서 timeout이 더 중요합니다.

  • DB query timeout
  • RestClient connect/read timeout
  • transaction timeout

3) 메트릭

Boot는 HTTP 서버 메트릭, task executor/scheduler 메트릭, datasource/Hikari 메트릭, application startup 메트릭 등을 Actuator/Micrometer로 노출합니다.

최소한 이건 보셔야 합니다.

  • http.server.requests
  • jdbc.connections.*
  • hikaricp.*
  • application.ready.time
  • JVM thread / GC
  • pinned VT 관련 JFR 이벤트

10. 메시징/Kafka는 보수적으로

HTTP 요청-응답 API는 Virtual Thread와 궁합이 좋습니다.
그런데 Kafka listener처럼 내부 라이브러리 동시성 모델이 강한 쪽은 바로 “전면 virtual thread”로 가기보다 별도 검증이 필요합니다. Spring Kafka 문서가 virtual threads + concurrent listener에 주의를 주는 이유가 그겁니다.

제 권장안은 이겁니다.

  • Web/API 레이어: 먼저 virtual thread 적용
  • Batch / Kafka consumer / scheduler-heavy 구간: 별도 검증 후 단계 적용

11. 르무엘님용 최종 권장안

추천 아키텍처

Boot 4 + Java 21/24+ + MVC + Tomcat + JPA + RestClient + Virtual Thread

적용 우선순위

  1. 사내 API/BFF부터 적용
  2. 외부 API aggregation 서비스 적용
  3. DB-heavy CRUD 적용
  4. Kafka/Batch는 마지막

적용 금지 구간

  • CPU 소모 큰 파일 변환을 전부 virtual thread에 태우는 것
  • thread 수가 늘었으니 DB pool도 같이 크게 늘리는 것
  • scheduler daemon 이슈 무시하는 것
  • pinned VT 관측 없이 운영 투입하는 것

12. 한 줄 결론

Spring Boot 4 + Virtual Thread의 정석은 “동기 코드를 유지하면서 I/O 동시성을 높이고, CPU 작업과 외부 자원은 따로 제한하는 구조”입니다.
기술 포인트는 Virtual Thread 자체보다, DB/외부 API/메시징 병목을 분리해서 설계하는 것입니다. Spring Boot 4는 Java 17+와 Tomcat 11/Servlet 6.1 기반이며, virtual threads는 Java 21+에서 spring.threads.virtual.enabled=true로 켤 수 있고, Boot는 기본 executor/scheduler 동작과 keep-alive 주의사항까지 공식 문서로 안내하고 있습니다.

LIST

Keycloak은 설치만으로 인증 서버가 동작하지만,
그 상태는 단순히 **“로그인 가능한 시스템”**일 뿐이다.

실무에서 중요한 것은
👉 “어떤 토큰을, 누구에게, 어떤 범위로 발급할 것인가”

이 설계가 잘못되면 다음 문제가 발생한다.

  • 토큰 탈취 시 장시간 악용
  • 권한 과다 노출
  • 서비스 간 인증 오남용
  • 관리자 권한 유출

즉, Keycloak의 핵심은 설정이 아니라
👉 토큰 설계 + 권한 모델링 + 검증 전략이다.


📌 Keycloak에서 흔히 하는 설계 실수 7가지


1. Access Token 만료 시간을 길게 설정

가장 흔한 실수다.

Access Token: 8시간 / 24시간
 

이렇게 설정하면 토큰 탈취 시 피해가 그대로 유지된다.

👉 해결

  • Access Token: 5~15분
  • Refresh Token: 별도 관리
  • 관리자 계정: 더 짧게

👉 핵심
“토큰 만료 시간 = 공격자가 쓸 수 있는 시간”


2. Refresh Token을 사실상 영구 세션처럼 사용

많은 시스템이 refresh token을 무제한처럼 사용한다.

문제:

  • 탈취 시 재발급 계속 가능
  • 세션 강제 종료 어려움

👉 해결

  • Refresh Token 재사용 제한
  • Logout 시 refresh token 폐기
  • 비밀번호 변경 시 세션 전체 무효화

3. JWT에 너무 많은 정보 넣기

나쁜 설계:

 
{
"name": "홍길동",
"department": "개발팀",
"menu": ["A", "B", "C"],
"roles": ["ADMIN"],
"permissions": [...]
}
 

문제:

  • 토큰 크기 증가
  • 내부 정보 노출
  • 권한 변경 반영 지연

👉 해결

  • JWT는 최소 정보만
    • userId
    • role
    • scope

👉 핵심
JWT는 “프로필 저장소”가 아니라 “인가 판단용 증표”


4. Audience(aud) 검증을 안 함

이거 실무에서 진짜 많이 터집니다.

상황:

  • A 서비스용 토큰
  • B 서비스에서도 통과됨

👉 문제
토큰 오남용 (서비스 간 침범)

👉 해결

  • 서비스별 audience 설정
  • API에서 aud 검증 필수
issuer + signature + exp + aud
 

👉 핵심
“이 토큰이 나를 위한 것인가?”를 반드시 확인


5. Full Scope Allowed 켜둠

Keycloak 기본 설정 중 하나인데,
이걸 그대로 두면 거의 “권한 무제한” 상태입니다.

문제:

  • 필요 없는 role까지 토큰에 포함
  • 권한 과다 노출

👉 해결

  • Full Scope Allowed OFF
  • Client별 Scope 명확히 정의

예:

order.read
order.write
admin.user.manage
 

6. Redirect URI / Web Origins 느슨하게 설정

개발할 때 이렇게 많이 합니다.

*
https://*.domain.com/*
 

👉 문제

  • 토큰 탈취 가능
  • 인증 리디렉션 공격

👉 해결

👉 핵심
화이트리스트는 최대한 좁게


7. Public / Confidential Client 구분 안 함

잘못된 설계:

  • SPA인데 secret 사용
  • 서버인데 public client 사용

👉 올바른 기준

유형                                                                                  방식
SPA / 모바일 Public + PKCE
백엔드 Confidential
서비스 간 Client Credentials

👉 핵심
클라이언트 유형 = 인증 방식


📌 실무 Keycloak 아키텍처 설계


구조

[ Client (Web / App) ]

[ Keycloak ]

[ API Gateway ]

┌───────────────┬───────────────┐
│ Order Service │ Payment │
└───────────────┴───────────────┘
 

인증 흐름

  1. Client → Keycloak 로그인
  2. Access Token 발급
  3. API 호출 시 Bearer Token 전달
  4. Gateway / Service에서 검증

📌 권장 설계 (실무 기준)


1. Client 구성

frontend-client (public)
admin-client (public)
api-client (confidential)
 

2. 토큰 정책

Access Token: 10분
Refresh Token: 회전 방식
SSO Session: 제한
 

3. 권한 모델

Realm Role → 최소 (ADMIN)
Client Role → 서비스별 권한
Scope → API 접근 단위
Group → 조직
 

4. Token 최소화

 
{
"sub": "userId",
"roles": ["USER"],
"scope": "order.read"
}
 

5. API 검증 정책

모든 서비스에서:

  • 서명 검증
  • issuer 검증
  • 만료 시간
  • audience
  • scope/role

👉 여기서 aud 빠지면 설계 실패


📌 실무 적용 체크리스트


필수

  • Access Token 짧게 설정
  • Refresh Token 회전/회수
  • Full Scope Allowed OFF
  • Redirect URI 제한
  • Audience 설정
  • Role 최소화

권장

  • API Gateway에서 1차 검증
  • 서비스별 scope 분리
  • 관리자 MFA 적용
  • 세션 정책 정의

📌 결론

Keycloak은 “인증 서버”가 아니라
👉 보안 아키텍처의 중심 컴포넌트

 

설치만으로 끝내면:

👉 로그인 시스템

설계를 제대로 하면:

👉 엔터프라이즈 IAM 플랫폼


📌 한 줄 정리

👉 “토큰을 어떻게 발급하고 검증하느냐가 시스템 보안을 결정한다”

LIST

1. Monolith (모놀리식 아키텍처)

개념

모든 기능이 하나의 애플리케이션으로 통합되어 배포되는 구조입니다.

[ Client ]


┌─────────────────────┐
│ Monolith App │
│ │
│ Controller │
│ Service │
│ Repository │
│ Domain │
│ │
└─────────────────────┘


DB
 

  • Spring Boot 하나
  • WAR 하나
  • DB 하나

2. MSA (Microservice Architecture)

개념

시스템을 여러 개의 독립 서비스로 분리하여 운영하는 구조

Client


API Gateway

├───────────────┐
▼ ▼
Order Service User Service
│ │
▼ ▼
Order DB User DB
 

서비스

  • 독립 배포
  • 독립 DB
  • 독립

핵심 차이

항목                                                    Monolith                                                        MSA
배포 하나 서비스별
코드 구조 하나의 프로젝트 여러 프로젝트
DB 하나 서비스별
통신 내부 메서드 호출 REST / Message
장애 영향 전체 영향 부분 영향
확장 전체 확장 서비스별 확장

Monolith 장점

1. 단순한 구조

설계가 단순합니다.

Controller → Service → Repository
 

이게 끝입니다.


2. 트랜잭션 관리 쉬움

하나의 DB

@Transactional
 

으로 해결됩니다.


3. 개발 속도 빠름

  • 작은
  • 스타트업
  • MVP

에서 가장 빠릅니다.


4. 운영 비용 낮음

  • 서버 적음
  • 네트워크 없음
  • 메시지 브로커 없음

Monolith 단점

1. 시스템 커지면 유지보수 어려움

대표적인 문제

God Service
Huge Repository
Massive Build Time
 

2. 배포 리스크

작은 수정도

전체 시스템 재배포
 

3. 기술 스택 제한

전체 Java
 

다른 언어 사용 어려움.


MSA 장점

1. 독립 배포

Order Service만 배포
 

가능합니다.


2. 서비스별 확장

검색 서비스 → 트래픽 많음
검색 서비스만 scale
 

3. 조직에 맞음

팀 A → 주문
팀 B → 결제
팀 C → 검색
 

4. 기술 다양성

검색 → Go
AI → Python
Core → Java
 

MSA 단점

1. 운영 복잡도

필요한

Service Discovery
API Gateway
Monitoring
Tracing
Logging
 

대표 도구

  • Kubernetes
  • Prometheus
  • Jaeger
  • ELK

2. 데이터 일관성 문제

모놀리식

ACID Transaction
 

MSA

Eventual Consistency
Saga Pattern
 

3. 네트워크 비용

서비스 통신

REST
gRPC
Kafka
 

latency 증가


4. 분산 시스템 문제

대표 문제

Timeout
Retry
Circuit Breaker
Partial Failure
 

언제 Monolith좋은가

조건

팀 < 10명
서비스 초기
도메인 단순
 

대표 사례

  • 스타트업 MVP
  • 내부 시스템

언제 MSA좋은가

조건

팀 > 30명
서비스 규모 큼
도메인 복잡
트래픽 많음
 

대표 사례

  • Netflix
  • Amazon
  • Uber

현실적인 아키텍처

요즘은 대부분

Modular Monolith

입니다.

Monolith
├ Order Module
├ Payment Module
├ User Module
 

장점

  • 모놀리식 장점 유지
  • MSA확장 가능

유명한

Martin Fowler

"Most people should start with a monolith and evolve to microservices."


실제 회사 아키텍처

대부분

초기 → Monolith
성장 → Modular Monolith
대규모 → MSA

 

LIST

시스템 설계의 진짜 목적

소프트웨어 아키텍처를 이야기할 때 많은 개발자들이 먼저 떠올리는 것은 기술이다.

예를 들어 다음과 같은 질문들이다.

  • MSA가 좋은가 모놀리스가 좋은가
  • Java가 좋은가 Node.js가 좋은가
  • REST가 좋은가 메시지 큐가 좋은가
  • Kubernetes가 필요한가

하지만 경험이 쌓일수록 한 가지 사실을 깨닫게 된다.

 
좋은 아키텍처는 특정 기술을 선택하는 것이 아니다
 

좋은 아키텍처의 목적은 단 하나다.

 
변경 비용(Change Cost)을 줄이는 것
 

이다.


소프트웨어의 본질은 변화다

소프트웨어 시스템은 처음 설계한 그대로 유지되는 경우가 거의 없다.

서비스가 성장하면 다음과 같은 변화가 발생한다.

  • 새로운 기능 추가
  • 비즈니스 규칙 변경
  • 트래픽 증가
  • 조직 구조 변화

즉 소프트웨어는 본질적으로 변화하는 시스템이다.

문제는 이 변화가 얼마나 쉽게 이루어질 수 있느냐다.


나쁜 아키텍처의 특징

나쁜 아키텍처는 처음에는 잘 동작하는 것처럼 보인다.

하지만 시간이 지나면서 다음과 같은 문제가 나타난다.

  • 작은 기능 변경에도 많은 코드 수정
  • 여러 서비스 동시 수정 필요
  • 배포 리스크 증가
  • 시스템 이해도 감소

이런 시스템에서는 다음과 같은 일이 발생한다.

 
기능 하나 수정하는데 며칠이 걸린다
 

이것이 바로 높은 변경 비용이다.


좋은 아키텍처의 특징

좋은 아키텍처는 변경 비용을 낮춘다.

예를 들어 다음과 같은 특징이 있다.

  • 서비스 간 결합도 낮음
  • 모듈 경계 명확
  • 테스트 가능성 높음
  • 독립적인 배포 가능

이 구조에서는 다음과 같은 일이 가능하다.

 
하나의 기능 변경이
다른 시스템에 영향을 주지 않는다
 

즉 변경이 빠르고 안전하게 이루어진다.


기술 선택은 수단일 뿐이다

많은 개발자들이 아키텍처를 기술 선택 문제로 생각한다.

예를 들어

 
MSA → 좋은 아키텍처
 

라고 생각하는 경우가 많다.

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

MSA도 잘못 설계하면

 
분산 모놀리스
 

가 될 수 있다.

반대로 모놀리식 시스템도 잘 설계하면
오랫동안 안정적으로 운영될 수 있다.

즉 기술은 목표가 아니라 도구다.


아키텍처의 진짜 질문

좋은 아키텍처를 설계할 때 중요한 질문은 다음과 같다.

 
이 구조는 앞으로 변경하기 쉬운가?
 

예를 들어 다음 질문을 던질 수 있다.

  • 새로운 기능을 추가하기 쉬운가
  • 특정 서비스를 독립적으로 수정할 수 있는가
  • 장애가 전체 시스템으로 확산되지 않는가
  • 팀이 독립적으로 개발할 수 있는가

이 질문에 긍정적으로 답할 수 있다면
아키텍처는 잘 설계된 것이다.


마이크로서비스 아키텍처의 목적

MSA도 같은 관점에서 이해할 수 있다.

MSA의 목적은 서비스 개수를 늘리는 것이 아니다.

목적은 다음과 같다.

 
변경 영향을 줄이는 것
 

예를 들어 주문 시스템이 있다고 가정하자.

모놀리스 구조에서는 주문 로직 수정이
전체 시스템에 영향을 줄 수 있다.

MSA에서는 주문 서비스만 수정하면 된다.

즉 변경 범위가 줄어든다.


DevOps와 Observability의 역할

DevOps와 Observability도 같은 맥락에서 이해할 수 있다.

예를 들어 다음 도구들이 있다.

  • Docker
  • Kubernetes
  • Prometheus

이 도구들의 목적도 결국 같다.

 
시스템 운영과 변경을 쉽게 만드는 것
 

즉 DevOps도 변경 비용을 줄이기 위한 접근 방식이다.


좋은 아키텍처의 기준

좋은 아키텍처는 다음 질문에 답할 수 있어야 한다.

질문                                                                                                          의미
변경이 쉬운가 기능 추가 비용
배포가 쉬운가 운영 비용
확장이 쉬운가 시스템 성장
이해하기 쉬운가 유지보수 비용

이 네 가지가 충족되면
아키텍처는 성공적인 구조라고 볼 수 있다.


정리

아키텍처 논의에서 기술은 항상 중심에 놓인다.

하지만 진짜 중요한 것은 기술 자체가 아니다.

중요한 것은

 
이 시스템을 얼마나 쉽게 바꿀 수 있는가
 

이다.

좋은 아키텍처는 복잡한 구조를 만드는 것이 아니라
변화를 쉽게 만드는 구조다.

결국 아키텍처의 목적은 하나다.

 
변경 비용을 줄이는 것
 

 

LIST

분산 시스템에서 데이터 정합성을 유지하는 방법

모놀리식 시스템에서는 데이터 일관성을 유지하는 것이 비교적 간단하다.

대부분의 경우 하나의 데이터베이스와 트랜잭션을 사용하기 때문이다.

예를 들어 주문 시스템을 생각해 보자.

 
BEGIN
INSERT ORDER
UPDATE INVENTORY
UPDATE PAYMENT
COMMIT
 

이 구조에서는 데이터베이스 트랜잭션이 모든 작업을 보장한다.

만약 문제가 발생하면

ROLLBACK
 

을 통해 전체 작업을 취소할 수 있다.

하지만 마이크로서비스 아키텍처(MSA)에서는 상황이 달라진다.


MSA에서 데이터 일관성 문제가 발생하는 이유

MSA에서는 각 서비스가 자신의 데이터베이스를 가진다.

예를 들어 다음 구조를 생각해 보자.

Order Service
→ orders DB

Payment Service
→ payments DB

Inventory Service
→ inventory DB
 

이 구조에서는 하나의 비즈니스 작업이 여러 서비스에 걸쳐 실행된다.

예를 들어 주문 생성 과정은 다음과 같다.

1. 주문 생성
2. 결제 처리
3. 재고 차감
 

문제는 이 작업이 하나의 트랜잭션으로 묶일 수 없다는 것이다.

즉 모든 데이터가 동시에 일관성을 유지하기 어렵다.

이 때문에 MSA에서는 보통 Eventual Consistency(최종 일관성) 모델을 사용한다.


Eventual Consistency란 무엇인가

Eventual Consistency는 다음 개념을 의미한다.

데이터가 즉시 일관성을 가지지 않아도
결국에는 일관된 상태에 도달한다
 

즉 잠시 동안 데이터가 완전히 일치하지 않을 수 있지만
시간이 지나면 정상 상태로 수렴한다.

예를 들어 다음 상황을 생각해 보자.

Order 생성 완료
Payment 처리 완료
Inventory 업데이트는 잠시 후 반영
 

이 경우 짧은 시간 동안 재고 정보가 정확하지 않을 수 있다.

하지만 이벤트 처리 후에는 데이터가 일관된 상태가 된다.


Eventual Consistency를 관리하는 주요 방법

MSA에서는 여러 패턴을 사용해 데이터 일관성을 관리한다.


1. 이벤트 기반 데이터 동기화

가장 일반적인 방법은 이벤트 기반 데이터 동기화다.

예를 들어 주문 생성 이벤트가 발생하면

OrderCreated Event
 

다른 서비스들이 이를 구독한다.

Inventory Service → 재고 차감
Notification Service → 알림 발송
Analytics Service → 통계 업데이트
 

이 방식은 서비스 간 결합도를 낮추고
비동기적으로 데이터 동기화를 수행할 수 있다.


2. Saga 패턴

앞서 설명한 Saga 패턴도 데이터 일관성 관리 방법 중 하나다.

Saga는 여러 서비스에 걸친 트랜잭션을
단계별로 실행하고 보상 작업으로 관리한다.

예를 들어

Order 생성
→ Payment 처리
→ Inventory 차감
 

만약 실패하면

Payment 취소
Order 취소
 

와 같은 보상 작업을 실행한다.

Saga 패턴은 분산 트랜잭션 대신
비즈니스 로직 기반 트랜잭션 관리를 수행한다.


3. Outbox 패턴

이벤트 기반 시스템에서 중요한 문제 중 하나는
이벤트 유실 문제다.

예를 들어 다음 상황을 생각해 보자.

DB 저장 성공
이벤트 발행 실패
 

이 경우 데이터와 이벤트 상태가 불일치하게 된다.

Outbox 패턴은 이를 해결한다.

1. 비즈니스 데이터 저장
2. 이벤트를 Outbox 테이블에 기록
3. 별도 프로세스가 이벤트 발행
 

이 방식은 데이터와 이벤트를 같은 트랜잭션에서 저장하기 때문에
이벤트 유실 문제를 방지할 수 있다.


4. Idempotency (멱등성)

분산 시스템에서는 메시지가 중복 처리될 수 있다.

예를 들어 메시지 브로커가 재전송하면
같은 이벤트가 두 번 처리될 수 있다.

이를 방지하기 위해 멱등성 처리가 필요하다.

예를 들어 다음과 같은 구조다.

OrderID 기반 처리
이미 처리된 이벤트인지 확인
 

즉 같은 이벤트가 여러 번 들어와도
결과는 동일해야 한다.


Eventual Consistency의 장단점

Eventual Consistency는 분산 시스템에서 매우 중요한 개념이지만
장단점이 존재한다.

 

장점

  • 서비스 간 결합도 감소
  • 높은 확장성
  • 장애 격리
  • 비동기 처리 가능

단점

  • 즉시 일관성 보장 어려움
  • 시스템 이해도 증가
  • 디버깅 복잡성 증가

즉 Eventual Consistency는
확장성과 일관성 사이의 트레이드오프라고 볼 수 있다.


언제 Eventual Consistency를 사용할까

다음과 같은 상황에서는 Eventual Consistency가 적합하다.

상황                                                                                                설명
MSA 구조 서비스별 DB
이벤트 기반 시스템 비동기 처리
대규모 트래픽 확장성 중요
분산 환경 강한 트랜잭션 어려움

반면 금융 거래 같은 경우에는
강한 일관성이 필요하기 때문에 다른 접근이 필요할 수 있다.


정리

마이크로서비스 아키텍처에서는
모놀리식 시스템처럼 강한 트랜잭션을 사용하기 어렵다.

그래서 많은 시스템이 Eventual Consistency 모델을 사용한다.

이를 관리하기 위해 다음 패턴이 활용된다.

패턴                                                                                             역할
Event Driven 데이터 동기화
Saga Pattern 분산 트랜잭션 관리
Outbox Pattern 이벤트 유실 방지
Idempotency 중복 처리 방지

결국 중요한 것은
완벽한 즉시 일관성을 추구하는 것이 아니라

시스템이 결국 올바른 상태에 도달하도록 설계하는 것
 

이다.

LIST

+ Recent posts