Project2025.12 - 2026.02

StoLink

웹소설 작가를 위한 세컨드 브레인 서비스입니다. 에디터에서 집필하면 AI가 글을 분석하고, 월드 페이지에서 인물·관계·사건을 시각화합니다. 독자는 StoRead에서 관계도와 함께 작품을 열람할 수 있습니다.

Role

Fullstack Developer

Period

2025.12 - 2026.02

Team

5인 팀

크래프톤 정글 최종 프로젝트 (5인 팀). OAuth2 인증, 토스페이먼츠 결제 연동, 문서 CRUD 등 백엔드와 Canvas 관계도, Tiptap 에디터, 폴더 트리 사이드바 등 프론트엔드를 담당했습니다. SVG → Canvas 전환으로 관계도 INP를 420ms에서 64ms로 개선하고, 외부 API 타임아웃 누락으로 인한 Cascading Failure를 쓰레드 덤프 분석으로 추적하여 해결했으며, 결제 동시성 제어는 Testcontainers로 100스레드 환경에서 정합성을 검증했습니다.
Frontend
React 19TypeScriptVitereact-force-graph-2dCanvas APITiptapZustandTanStack Query
Backend
Spring BootJPAPostgreSQLNeo4j
테스트
JUnit 5Testcontainers
Tools
GitGitHub ActionsToss Payments MCP

System Architecture

StoLink Architecture Diagram

API 응답 450ms → 25ms (18배 개선)

계층형 문서 조회 N+1 → Fetch Join + In-Memory 트리

450ms → 25ms (18배)

Problem

사이드바 문서 목록 조회에서 쿼리가 대량 발생하고, 응답시간이 450ms까지 느려졌습니다.문서 30개를 조회할 때 각 문서의 부모(parent) 관계를 Lazy Loading으로 개별 SELECT하는 구조여서, 문서 수에 비례해 쿼리가 늘어나는 N+1 문제가 발생했습니다.
  • 실측 응답시간 450ms, 사이드바 열 때 뚜렷한 지연 체감
  • 쿼리 로그 확인 후 원인 특정

Approach

Fetch Join으로 1쿼리 로드 후, HashMap O(n) 트리 조립으로 해결했습니다.@BatchSize도 검토했지만, 사이드바 트리는 전체 문서가 항상 필요해 부분 로딩의 이점이 없었습니다. Fetch Join으로 전체 로드 후 DocumentTreeResponse.from()으로 필요 필드만 DTO에 매핑해 엔티티 그래프 탐색을 차단했습니다.스크리브닝 모드(무한 스크롤)는 Pageable이 필요해 Fetch Join 시 메모리 경고가 발생, 네이밍 쿼리로 분리해 해결했습니다.

Result

쿼리 61개 → 1개, 응답 450ms → 25ms로 18배 개선.Chrome DevTools Network 탭에서 응답 시간을 측정해 확인.
용도별 쿼리 분리: 사이드바 트리 = Fetch Join(페이징 불필요) / 스크리브닝 = Spring Data 네이밍 쿼리 + Pageable
HHH90003004 회피: 스크리브닝에서 Fetch Join + Pageable 시도 → 메모리 전체 로딩 경고 → 쿼리 분리로 해결
In-Memory 트리 구성: Fetch Join으로 Flat 데이터 로드 후 HashMap O(n) 트리 조립
DocumentRepository.javaFetch Join으로 1쿼리 조회
@Query("SELECT d FROM Document d LEFT JOIN FETCH d.parent WHERE d.project = :project ORDER BY d.order ASC")
List<Document> findByProjectWithParent(@Param("project") Project project);

// DocumentService.java — HashMap으로 O(n) 트리 조립
private List<DocumentTreeResponse> buildTreeInMemory(List<Document> documents) {
    Map<UUID, DocumentTreeResponse> dtoMap = new HashMap<>();
    List<DocumentTreeResponse> roots = new ArrayList<>();

    for (Document doc : documents) {
        dtoMap.put(doc.getId(), DocumentTreeResponse.from(doc));
    }
    for (Document doc : documents) {
        DocumentTreeResponse dto = dtoMap.get(doc.getId());
        if (doc.getParent() == null) {
            roots.add(dto);                                    // ← 루트 노드
        } else {
            dtoMap.get(doc.getParent().getId()).getChildren().add(dto);  // ← O(1) 연결
        }
    }
    return roots;
}

쓰레드 덤프 분석 → 필드 주입 빈 라이프사이클 문제 발견 → 생성자 주입 리팩토링

외부 API 타임아웃 누락 → 톰캣 쓰레드풀·HikariCP 커넥션풀 연쇄 고갈

외부 서버 장애 → 전체 서비스 마비 전파 차단

Problem

프로덕션 서버가 간헐적으로 모든 API 요청에 응답하지 못하고 멈추는 장애가 발생했습니다.크롬 개발자 도구에서 API 요청이 타임아웃으로 실패하는 것을 확인했으나, 서버의 CPU·메모리는 정상이었습니다. 쓰레드 덤프를 떠서 분석한 결과, 다수의 톰캣 쓰레드가 AI 이미지 서버 헬스체크용 RestTemplate.getForObject()에서 Socket Read(RUNNABLE) 상태로 블로킹되어 있었습니다. 이 호출이 @TransactionalEventListener 내부에서 @Transactional(propagation = REQUIRES_NEW)로 별도 트랜잭션을 할당받은 상태에서 실행되고 있어, 외부 서버 무응답 시 쓰레드가 DB 커넥션을 점유한 채 무한정 대기했습니다.
  • 대기 쓰레드가 누적되며 HikariCP 커넥션 풀 고갈
  • 후속 요청이 커넥션을 획득하지 못하고 WAITING 상태로 전이
  • 최종적으로 톰캣 메인 쓰레드 풀 전체가 마비되는 Cascading Failure

Approach

쓰레드 덤프를 분석해 원인 추적에 들어갔습니다.추적해보니 해당 RestTemplate의 Timeout을 @Value 필드 주입으로 설정했으나, 빈 라이프사이클상 필드 주입은 생성자 이후에 이루어지므로 RestTemplate 생성 시점에는 값이 0인 채로 남아 기본값(무한 대기)으로 동작하고 있었습니다.
  • 필드 주입이 빈 라이프사이클 문제의 근본 원인임을 파악하고, 해당 서비스의 설정 주입 방식을 생성자 주입으로 리팩토링
  • 빈 생성 시점에 Timeout이 확실히 반영되도록 수정
  • 외부 서버 미응답 시 5초 후 ImageServerNotHealthyException을 던지고 즉각 DB 커넥션과 쓰레드를 반환

Result

이전에는 대기 요청 20건만으로 서버 전체가 마비되었으나, 수정 후에는 외부 서버가 완전히 다운된 상황에서도 코어 서비스 응답이 정상 유지되었습니다. 잦았던 수동 컨테이너 재시작 운영 이슈가 해소되었고, 외부 시스템 장애가 내부로 전파되는 Cascading Failure를 구조적으로 격리했습니다.
근본 원인: @Value 필드 주입이 생성자 이후에 실행되는 빈 라이프사이클 문제 → RestTemplate Timeout 미적용 → 무한 대기
연쇄 장애 흐름: Socket Read(RUNNABLE) 블로킹 → REQUIRES_NEW로 HikariCP 커넥션 점유 → 후속 요청 WAITING 전이 → 전체 서비스 마비
설계 파급 효과: 이 경험이 직접적 계기가 되어, 결제 PG 연동 시 TransactionTemplate으로 검증·외부 호출·DB 반영을 3단계로 분리하는 방어적 아키텍처를 적용
// Before — @Value 필드 주입 → 생성자 시점에 값이 0, RestTemplate 무한 대기
@Value("${app.image-server.health-timeout-ms:5000}")
private int timeoutMs;  // ← 필드 주입은 생성자 이후에 실행됨

public ImageServerHealthChecker() {
    // timeoutMs = 0 → 기본값 무한 대기(-1)로 동작
    this.restTemplate = new RestTemplate();
}

// After — 생성자 주입으로 빈 생성 시점에 Timeout 확실히 반영
public ImageServerHealthChecker(RestTemplateBuilder builder,
        @Value("${app.image-server.health-timeout-ms:5000}") int timeoutMs) {
    this.restTemplate = builder
            .setConnectTimeout(Duration.ofMillis(timeoutMs))  // 연결 타임아웃 5초
            .setReadTimeout(Duration.ofMillis(timeoutMs))     // 응답 타임아웃 5초
            .build();
}

Cascading Failure 발생 메커니즘과 Fail-Fast 타임아웃 적용 후 격리

sequenceDiagram participant C as Client participant T as Tomcat Thread Pool participant H as HikariCP (max 20) participant HC as HealthChecker participant AI as AI Image Server (Down) rect rgb(254, 226, 226) Note over T,AI: Before — @Value 필드 주입 → Timeout 미적용 → Cascading Failure C->>+T: API 요청 T->>+H: REQUIRES_NEW → 커넥션 획득 H->>+HC: RestTemplate.getForObject() HC->>AI: GET /health (timeout = ∞) Note over HC,AI: ⚠️ Socket Read(RUNNABLE) 상태로 블로킹 Note over H: ⚠️ DB 커넥션 점유 (반환 불가) Note over T,H: 풀 고갈 → 후속 요청 WAITING 전이 → 전 API 마비 end rect rgb(220, 252, 231) Note over T,AI: After — 생성자 주입으로 Timeout 확실히 반영 C->>+T: API 요청 T->>+H: REQUIRES_NEW → 커넥션 획득 H->>+HC: RestTemplate.getForObject() HC->>AI: GET /health (timeout = 5s) Note over HC,AI: 5초 후 SocketTimeoutException HC-->>-H: throw ImageServerNotHealthyException H-->>-T: 커넥션 즉시 반환 ✓ T-->>-C: 에러 응답 (장애 격리 성공) end

Cascading Failure 경험을 토대로 트랜잭션-외부 I/O 선제 분리

결제 PG 외부 API 호출에 의한 DB 커넥션 풀 고갈 방지

결제 장애 → 전체 서비스 전파 차단

Problem

위 Cascading Failure를 겪은 뒤, 결제 플로우에도 동일한 구조적 위험이 있음을 인지했습니다.결제 승인 로직이 하나의 트랜잭션 안에서 토스페이먼츠 API를 동기 호출하고 있었습니다. HikariCP 기본 풀은 10개인데 PG 응답이 1~2초 소요되므로, 동시 결제 10건이면 일반 API(문서 조회, 에디터 저장)까지 커넥션 부족 오류가 발생할 수 있는 구조였습니다.

Approach

네트워크 I/O와 DB 트랜잭션을 분리했습니다.첫 번째 트랜잭션에서 결제 검증과 상태 전환(IN_PROGRESS)만 수행하고 즉시 커밋하여 커넥션을 반환합니다. 이후 트랜잭션 밖에서 토스페이먼츠 API를 호출하므로, PG 응답을 1~2초 기다리는 동안에도 DB 커넥션을 점유하지 않습니다. PG 승인이 완료되면 두 번째 트랜잭션에서 크레딧 차감과 결제 완료 처리만 수행합니다.트랜잭션을 분리하면 PG는 승인했는데 두 번째 트랜잭션이 실패하는 데이터 불일치가 발생할 수 있습니다. 이때 즉시 PG 취소를 호출하면 사용자 입장에서는 결제가 실패한 경험이 됩니다. 대신 토스페이먼츠가 발송하는 웹훅을 백업 경로로 활용하여, 웹훅 수신 시 누락된 내부 트랜잭션을 다시 시도하는 최종적 일관성(Eventual Consistency) 전략을 택했습니다.웹훅으로도 해결되지 않는 경우를 대비해 PaymentScheduler가 주기적으로 실패 건을 재처리합니다. 웹훅 재시도는 최대 3회(5분 × 재시도 횟수 간격), 보상 트랜잭션 재시도는 최대 5회(10분 간격)로 구성했습니다. 최대 재시도를 초과하면 REQUIRES_MANUAL 상태로 전환하여 수동 처리 대상으로 분류합니다.

Result

PG 응답 지연이 DB 커넥션 풀에 영향을 주지 않는 구조로 전환되었습니다.결제 API에 의도적으로 지연을 주입한 상태에서도 문서 조회·에디터 저장 등 일반 API가 정상 응답하는 것을 직접 확인했습니다. 장애가 결제 기능에만 격리되어, Cascading Failure에서 경험한 '하나의 외부 호출이 전체 서비스를 마비시키는' 문제를 결제 플로우에서도 구조적으로 방지했습니다.
TX 분리 흐름: TX1(검증 + IN_PROGRESS 상태 전환 → 커밋) → Non-TX(PG API 호출, 커넥션 미점유) → TX2(크레딧 차감 + 완료 처리)
최종적 일관성 전략: TX2 실패 시 즉시 취소 대신 웹훅 재처리로 사용자 결제 완료 경험 보장 — 토스페이먼츠 웹훅 수신 시 누락된 내부 트랜잭션을 재시도
다단계 재시도: 웹훅 재시도 최대 3회(5분 × 재시도 횟수) + 보상 트랜잭션 재시도 최대 5회(10분 간격) — 최대 초과 시 REQUIRES_MANUAL로 수동 처리 전환
PaymentCompensation 엔티티: PENDING → RESOLVED 또는 REQUIRES_MANUAL 상태 관리로 미해결 건 추적
장애 격리 검증: 결제 API에 의도적 지연 주입 후 일반 API(문서 조회·에디터 저장)가 정상 응답하는 것을 직접 확인
PaymentService.javaTX1 → PG 호출(Non-TX) → TX2 분리 흐름
// ① TX1: 검증만 수행하고 커넥션 즉시 반환
Payment payment = transactionTemplate.execute(status -> {  // ← 짧은 TX
    Payment p = paymentRepository.findByOrderIdWithLock(orderId);
    p.markAsInProgress(paymentKey);
    return p;
});  // ← COMMIT — 여기서 커넥션 반납

// ② Non-TX: PG API 호출 — DB 커넥션을 점유하지 않음
tossResponse = tossPaymentClient.confirmPayment(  // ← 1~2초 대기해도 안전
        paymentKey, orderId, amount, orderId);

// ③ TX2: PG 승인 결과를 DB에 반영
return completePaymentProcess(orderId, paymentKey, tossResponse.method());

트랜잭션-외부 API 분리 — PG 응답 대기 중 DB 커넥션을 점유하지 않음

sequenceDiagram participant C as Client participant W as WAS (Spring Boot) participant PG as 토스페이먼츠 API participant DB as DB (PostgreSQL) rect rgb(254, 226, 226) Note over W,DB: Before — 트랜잭션 안에서 PG 호출 C->>+W: POST /payments/confirm W->>+DB: BEGIN TX + SELECT FOR UPDATE W->>+PG: confirmPayment (1~2s 대기) Note over DB: ⚠️ 커넥션 점유 중 PG-->>-W: 승인 결과 W->>DB: credit.charge() + COMMIT DB-->>-W: OK W-->>-C: 200 OK end rect rgb(220, 252, 231) Note over W,DB: After — 트랜잭션 밖에서 PG 호출 C->>+W: POST /payments/confirm W->>+DB: TX1: 검증 + 상태 IN_PROGRESS DB-->>-W: COMMIT (즉시 반환) W->>+PG: confirmPayment (커넥션 미점유) PG-->>-W: 승인 결과 W->>+DB: TX2: credit.charge() + 완료 처리 DB-->>-W: COMMIT W-->>-C: 200 OK Note over W: PG 실패 시 TX2에서 fail 처리 end

100 스레드 동시 요청 / 잔액 정합성 100% 검증

크레딧 결제 동시성 제어와 멱등성 보장

100 스레드 동시 / 잔액 정합성 100%

Problem

AI 코드 리뷰를 통해 트랜잭션 경합 시 발생하는 갱신 손실(Lost Update)과 이중 차감 취약점을 인지하게 되었습니다.동일 계정에 동시 결제가 들어오면, 양쪽 트랜잭션이 차감 전 잔액을 읽고 각자 차감한 결과를 기록해 한쪽 차감이 사라지는 문제가 있었습니다. 또한 네트워크 타임아웃으로 클라이언트가 결제를 재시도할 경우, 이미 처리된 요청인지 판별하지 못해 같은 금액이 이중 차감되는 위험도 있었습니다.Testcontainers로 실제 DB를 띄우고 100개 스레드가 동시에 같은 계정에 결제를 보내는 테스트를 구축했고, 락 없이 실행하자 잔액이 예상보다 높게 남는 갱신 손실을 재현하며 결함을 확인했습니다.

Approach

정합성이 금전과 직결되는 결제 도메인 특성을 고려해, 재시도 비용이 큰 낙관적 락 대신 비관적 락을 선택하고 멱등키를 병행했습니다.낙관적 락은 충돌 시 애플리케이션이 재시도해야 하는데, 결제에서는 재시도 자체가 PG 이중 승인을 유발할 수 있어 부적합하다고 판단했습니다.
  • 동시성 제어: 비관적 락으로 트랜잭션을 직렬화하되, 테이블 락이 아닌 인덱스 기반의 행 단위 락으로 점유 범위를 좁혀 서로 다른 유저의 결제는 병렬 처리되도록 설계했습니다.
  • 멱등성 보장: 멱등키를 도입하여 DB UNIQUE 제약으로 내부 중복을 차단하고, 동일한 키를 토스페이먼츠 API 헤더에도 전달하여 PG 통신 구간까지 이중 승인을 방지했습니다.

Result

100스레드 동시 경합에서 단 한 건의 오차 없이 잔액 정합성 100%를 통과했습니다.동일 멱등키로 들어온 중복 결제 요청도 트랜잭션 진입 전에 즉시 차단됨을 확인했습니다. 현재 이 동시성 검증 테스트는 CI 파이프라인에 통합되어, 결제 로직 수정 시 회귀 오류를 자동으로 감지하고 있습니다.
CreditService.java비관적 락으로 트랜잭션 직렬화
@Transactional
public CreditResponse useCredit(UUID userId, CreditUseRequest request) {

    // ① 인덱스 기반 행 단위 비관적 락 획득
    Credit credit = creditRepository.findByUserIdWithLock(userId);

    // ② 상태 변경 시 트랜잭션 커밋과 함께 즉시 반영 (Dirty Checking)
    credit.use(request.amount());
}

// CreditRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Credit c WHERE c.userId = :userId")
Optional<Credit> findByUserIdWithLock(UUID userId);

토큰 효율 2배 이상 향상, 교대 개발 시 문서-코드 불일치 해소

AI 컨텍스트 오염 → 계층형 문서 + 멀티 에이전트 파이프라인

토큰 효율 2배+ / 교대 개발 맥락 무결성

Problem

AI가 폐기된 API를 참조해 존재하지 않는 코드를 생성하는 일이 반복됐습니다.Claude Code의 Skills·Custom Commands로 반복 작업을 자동화하고 있었지만, CLAUDE.md가 1,000줄을 넘어가면서 기본 기능만으로는 해결할 수 없는 문제가 드러났습니다.
  • 1,000줄 문서를 매번 전부 로딩 → 토큰의 절반 이상이 규칙 읽기에 소모
  • 새 규칙과 폐기된 규칙이 뒤섞여 AI가 outdated 정보를 참조
  • 팀원 교대 시 문서 갱신 누락 → 다음 사람의 AI가 틀린 코드 생성
  • 간단한 작업에도 7단계 PRD를 거치는 프로세스 과잉

Approach

1,000줄 문서를 3-Layer로 분리하고, 작업 규모에 따라 필요한 문서만 로딩하는 구조로 변경했습니다.
  • Core (~150줄, 항상 로딩): 핵심 규칙·아키텍처 원칙만
  • Appendix (작업별 선택): tech-stack, design-system, api-reference 등
  • Spec (L 작업만): API_SPEC, DATA_MODEL 전체 명세
Supervisor 에이전트가 요청을 S/M/L로 분류해 문서 참조량과 프로세스를 결정하고, 역할별 전문 에이전트가 순차적으로 작업합니다.
  • Architect → 구조·로직 구현 / Stylist → 디자인 시스템 적용
  • Auditor → 품질 검증 / Librarian → 코드 변경 시 문서 자동 동기화

Result

3-Layer 모델 전환으로 토큰 소모를 극적으로 줄여 AI 가용 시간을 2배 이상 연장했습니다. 무엇보다 outdated 문서 참조로 인한 할루시네이션(잘못된 코드 생성)을 없앴고, 단순 작업(S) 프로세스를 7단계에서 2단계로 단축했습니다. 또한 교대 개발 시 맥락 단절을 막기 위해 Librarian 에이전트 기반의 자동 문서 동기화 파이프라인을 구축, 팀 전체의 생산성을 크게 높였습니다.
3-Layer 설계 근거: 1,000줄 전체 로딩 → 150줄 Core + 필요한 Appendix만 선택 로딩으로 토큰 효율 2배 이상
T-Shirt Sizing: S(간단한 수정, Core만) / M(신규 API, Core+Appendix) / L(아키텍처 변경, 전체) — 규모별 프로세스 차등 적용
문서 자동 동기화: Librarian 에이전트가 코드 변경 감지 → 관련 Appendix·Spec 자동 갱신

역할별 멀티 에이전트 파이프라인 — 각 에이전트가 한 가지 역할에만 집중

flowchart TD REQ["작업 요청"] --> SUP{"Supervisor (S/M/L 분류)"} SUP -- "S: Core만" --> ARC["Architect 구조·로직"] SUP -- "M: Core+Appendix" --> ARC SUP -- "L: 전체" --> ARC ARC --> STY["Stylist 디자인 적용"] STY --> AUD["Auditor 품질 검증"] AUD --> LIB["Librarian 문서 동기화"] LIB --> DONE["완료"] style SUP fill:#f59e0b,stroke:#d97706,color:#fff style ARC fill:#3b82f6,stroke:#2563eb,color:#fff style STY fill:#8b5cf6,stroke:#7c3aed,color:#fff style AUD fill:#ef4444,stroke:#dc2626,color:#fff style LIB fill:#10b981,stroke:#059669,color:#fff

Key Results

450ms → 25ms

문서 조회 API

N+1 쿼리 61개 → 1개, 18배 개선

100스레드 / 100%

결제 잔액 정합성

동시 결제 100건 잔액 정합성 100%, Testcontainers 검증

420ms → 64ms

인물 관계도

SVG → Canvas, 650명 환경 실시간 인터랙션

1,000줄 → 150줄

AI 컨텍스트 최적화

3-Layer 문서 + 멀티 에이전트 파이프라인, 토큰 효율 2배 이상

Next Project

Aidiary

RabbitMQ 비동기 전환 · 컨텍스트 해시 캐시로 API 비용 최소화

포트폴리오에 대해 질문해보세요!