Project2025.12 - 2026.01

StoLink

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

Role

Fullstack Developer

Period

2025.12 - 2026.01

Team

5인 팀

GitHub
크래프톤 정글 최종 프로젝트 (5인 팀). OAuth2 인증, 토스페이먼츠 결제 연동, 문서 CRUD 등 백엔드와 Canvas 관계도, Tiptap 에디터, 폴더 트리 사이드바 등 프론트엔드를 담당했습니다. SVG → Canvas 전환으로 관계도 INP를 420ms에서 64ms로 개선하고, 결제 동시성 제어는 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;
}

트랜잭션-외부 I/O 분리로 장애 격리

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

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

Problem

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

Approach

네트워크 I/O와 DB 트랜잭션을 분리했습니다.TX1에서 검증+상태 전환 후 커밋(커넥션 반환) → 트랜잭션 밖에서 PG API 호출(커넥션 미점유) → TX2에서 승인 결과 반영. PG 승인 후 TX2 실패 시 보상 트랜잭션(PaymentCompensation)으로 정합성 대비.

Result

PG 응답 지연이 DB 커넥션 풀에 영향을 주지 않는 구조로 전환.결제 API에 지연을 주입해도 일반 API가 정상 동작하는 것을 확인. 장애가 결제 기능에만 격리됩니다.
보상 트랜잭션: PG 승인 후 TX2 실패 시 PG 취소 API 호출로 정합성 확보 — 분리 구조의 엣지 케이스 대응
장애 격리 검증: 결제 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

동시 결제 시 Race Condition으로 잔액이 음수가 되거나, 네트워크 재시도로 이중 차감이 발생할 수 있는 구조였습니다.잔액 100크레딧 상태에서 50크레딧 결제가 동시에 두 건 들어오면, 둘 다 '100 읽음 → 50 차감 → 50 저장'을 실행해 잔액이 음수가 될 수 있습니다. 또한 네트워크 불안정으로 클라이언트가 같은 결제를 재시도하면 이중 차감이 발생할 위험도 있었습니다. 테스트 결제 환경이지만 실서비스와 동일한 수준의 정합성을 목표로 설계했습니다.

Approach

비관적 락(`SELECT FOR UPDATE`)으로 동시 접근을 직렬화하고, 멱등키로 중복 결제를 방어했습니다.SELECT FOR UPDATE로 잔액 행을 잠가서 같은 행에 대한 동시 접근을 순차 처리되도록 했습니다. 행 단위 잠금만으로 충분하기 때문에 낙관적 락(@Version)은 사용하지 않았고, 엔티티 변경은 @Transactional 커밋 시 JPA Dirty Checking에 맡겼습니다. 중복 결제는 멱등키에 DB UNIQUE 제약을 걸어 같은 요청이 두 번 처리되지 않도록 방어했습니다.
  • Testcontainers로 실제 PostgreSQL을 띄워 검증 (Mock DB에서는 락 동작 신뢰 불가)
  • 100 스레드 동시 차감 시나리오를 10회 반복 실행

Result

100개 스레드 동시 차감에서 10회 반복 모두 잔액 정합성 100%를 확인했습니다.동일 멱등키로 들어오는 중복 요청은 DB 트랜잭션 시작 전에 즉시 차단됩니다. 이 테스트 코드가 CI에 포함되어 결제 코드 변경 시 회귀를 자동으로 감지합니다.
CreditService.java비관적 락 + Dirty Checking
@Transactional
public CreditResponse useCredit(UUID userId, CreditUseRequest request) {

    // ① SELECT FOR UPDATE → 다른 TX가 같은 행을 읽지도 쓰지도 못하게 잠금
    Credit credit = creditRepository.findByUserIdWithLock(userId);  // ← 비관적 락

    // ② 엔티티 상태 변경만 하면 커밋 시 자동 UPDATE (Dirty Checking)
    credit.use(request.amount());  // ← balance -= amount, save() 불필요
}

// CreditRepository.java
@Lock(LockModeType.PESSIMISTIC_WRITE)  // ← SELECT c FROM Credit c ... FOR UPDATE
@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 비동기 전환 · 다층 캐시 개인화 · N+1 해결