Portfolio · System Design

Lifelog Architecture

리소스 제약 환경에서 MSA 전환을 고려하여 설계한 Modular Monolith 기반 풀스택 플랫폼의 아키텍처와 서비스 구성을 소개합니다.

제약된 인프라 환경(단일 서버)에서 복잡도와 운영 비용을 최소화하기 위해 Modular Monolith 아키텍처를 설계했습니다.

정형/비정형/캐시 데이터를 분리(RDB, MongoDB, Google Drive, Redis)하고 Apache Kafka 기반 비동기 처리를 통해 성능과 확장성을 확보했습니다.

Prometheus, Loki, Tempo를 활용해 장애를 탐지하고 원인을 추적할 수 있는 Observability 환경을 구축했습니다.

Design Questions : 아래와 같은 실제 운영 환경의 제약과 문제를 가정하고 설계했습니다.

  • MSA를 적용하기 어려운 환경에서는 어떤 아키텍처가 적절한가?
  • 다양한 데이터 특성(정형/비정형/캐시)을 어떻게 분리할 것인가?
  • 운영 환경에서 장애를 어떻게 관측하고 대응할 것인가?

Modular Monolith Architecture

Architecture Decision

이 프로젝트는 Vultr 클라우드 서버(2 Core CPU / 4GB RAM)에 배포됩니다. 제한된 인프라에서 다수의 서비스 인스턴스와 API Gateway를 운용하는 순수 MSA 구현은 현실적으로 어렵습니다. 대신, 단일 프로세스로 배포 가능한 Monolith 구조를 유지하되 도메인별로 모듈을 분리하여 향후 MSA 전환 시 개별 모듈을 독립 서비스로 추출할 수 있는 구조를 선택했습니다.

📦

단일 배포 단위

하나의 JAR로 빌드·배포되므로 제한된 서버 환경에서도 안정적으로 운용할 수 있습니다.

🧱

모듈 간 경계 명확화

각 도메인이 독립된 Gradle 모듈로 분리되어 높은 응집도와 낮은 결합도를 유지합니다.

🚀

MSA 전환 용이성

모듈 간 의존성이 명시적으로 관리되어, 인프라 확보 시 개별 모듈을 독립 서비스로 분리할 수 있습니다.

빌드 효율성

변경된 모듈만 증분 빌드되어 개발 속도가 향상되고 CI/CD 파이프라인도 최적화됩니다.

시스템 아키텍처

System Overview

클라이언트 요청은 Nginx Reverse Proxy를 통해 Spring Boot 애플리케이션으로 전달됩니다. 메인 앱과 독립 실행 Worker 모듈이 메시지 큐인 Apache Kafka를 사이에 두고 비동기로 통신하며, Web 모듈도 Kafka Consumer를 통해 글 발행 이벤트를 수신하고 WebSocket으로 접속 중인 모든 Client에게 실시간 알림을 전송합니다. Prometheus / Loki / Tempo / Grafana로 구성된 Observability 스택이 운영을 지원합니다.

Lifelog 시스템 아키텍처 다이어그램

기술 스택

Tech Stack
구분 기술
Language Kotlin 2.2.21, Java 21
Framework Spring Boot 4.0.2
Architecture Multi-Module Modular Monolith (Gradle 9.3.0 Kotlin DSL)
Security Spring Security — 세션 + JWT 이중 인증, BCrypt, RSA 비밀번호 암호화, JJWT 0.12.6
ORM / Query Spring Data JPA + Hibernate, jOOQ (동적 검색·페이징)
Database MySQL (운영 RDB), H2 (개발), MongoDB (콘텐츠 도큐먼트), Valkey/Redis (캐시)
Template Engine Thymeleaf (SSR) + Thymeleaf Layout Dialect (Decorator Pattern)
Frontend Vanilla JS, Prism.js (코드 하이라이팅), exifr (EXIF 추출)
Markdown Commonmark 0.24.0 — GFM Tables, Strikethrough, Autolink, Heading Anchor
Object Mapping MapStruct 1.6.3
External Storage Google Drive API v3 (OAuth 2.0, 이미지 업로드·서빙·썸네일)
Concurrency Java 21 Virtual Thread, AsyncSupporter (CompletableFuture 래퍼)
Message Queue Apache Kafka (Aiven Cloud, SSL PEM 인증), Spring Kafka
Real-time Spring WebSocket — 글 발행 이벤트 실시간 알림 브로드캐스트 (/ws/notifications)
Encryption Jasypt 3.0.5 (PBEWithMD5AndDES, DB 접속 정보 암호화)
Observability Micrometer Prometheus, Loki4j, OpenTelemetry, Spring Boot Actuator
API Documentation Springdoc OpenAPI 2.7.0 (Swagger UI)
Cache Spring Cache 추상화, @DynamicCacheable + @Aspect AOP, DynamicRedisCacheManager
AI / LLM Spring AI (spring-ai-openai), OpenAI Chat Completions API (ChatClient, OpenAiChatModel)

모듈 구조

Module Dependency

각 도메인 서비스 모듈은 shared에만 의존하며, 서비스 모듈 간에는 직접 의존성이 없습니다. webapi가 모든 서비스 모듈을 조합하여 프레젠테이션 계층에서 오케스트레이션합니다.

모듈 유형 역할
app Bootstrap Spring Boot 애플리케이션 진입점. 전역 Security 필터 설정, bootJar를 생성하는 실행 모듈. web과 api만 의존
web Presentation Thymeleaf 기반 SSR 컨트롤러. 메인 페이지, 게시글 뷰, 에디터, 프로필, 포토 아카이브 등 화면 렌더링. Kafka Consumer가 글 발행 이벤트를 수신하면 WebSocket(/ws/notifications)으로 접속 중인 모든 Client에 실시간 알림 브로드캐스트
api Presentation REST API 컨트롤러. 게시글 CRUD/검색, 인증(RSA 공개키), 카테고리 조회 API. Swagger UI API 문서
user-service Domain 사용자 엔티티·인증 로직. Spring Security UserDetailsService, BCrypt, 세션/JWT 이중 인증
blog-service Domain 블로그 핵심 도메인. 게시글·카테고리·태그 CRUD, jOOQ 동적 검색·페이징 (WITH RECURSIVE CTE), MapStruct DTO 변환
content-service Domain MongoDB Document 기반 비정형 콘텐츠 관리. 자기소개(PROFILE), 홈 소개(INTRO) 등 타입별 콘텐츠 저장·조회
photo-archive-service Domain 사진 아카이브 도메인. Google Drive 연동 이미지 업로드·서빙, 썸네일 자동 생성, EXIF 메타데이터 저장
shared Infrastructure 공통 유틸리티. JWT, RSA, Markdown 변환, Google Drive 설정, jOOQ 공통 설정, AsyncSupporter, @DynamicCacheable 등의 커스텀 애노테이션, Valkey/Redis 캐시 매니징, Apache Kafka 연결, 공통 예외
worker Worker 독립 실행 워커. Kafka Consumer가 블로그 게시글 저장 시 발행된 이벤트 메시지 수신 → PostgreSQL에 활동 감사 로그 적재. Scheduler가 주기적으로 Valkey/Redis에 적재된 게시글 조회수를 MySQL RDB에 동기화 처리. MySQL + PostgreSQL 이중 DataSource
sre-containers DevOps Docker Compose 기반 SRE 모니터링 스택. Grafana · Prometheus · Loki · Tempo 컨테이너 일괄 실행

핵심 기능

Key Features

단순 CRUD를 넘어, 실무에서 마주치는 문제들을 직접 해결하며 구현한 기술적 요소들입니다.

Polyglot Persistence

Multi-Datasource

데이터 성격과 도메인 구분에 따라 저장소를 분리하여 확장성과 유연성을 확보했습니다. 정형 데이터는 MySQL(JPA), 비정형 콘텐츠는 MongoDB, 활동 로그는 PostgreSQL, 캐시와 조회수는 Valkey/Redis로 관리합니다.

MySQLMongoDBPostgreSQLRedis / Valkey
spring-icon

AOP 기반 동적 캐시

@DynamicCacheable

Valkey/Redis를 활용한 캐시 적용을 메서드와 기능 별로 보다 유연하게 적용하고, 비즈니스 로직에 섞이지 않기 위해 AOP와 커스텀 애노테이션을 활용하여 동적으로 캐시를 적용할 수 있게 구현했습니다.
커스텀 애노테이션 @DynamicCacheable@Aspect를 활용해 메서드 레벨에서 캐시 TTL을 동적으로 제어합니다. DynamicRedisCacheManager로 캐시별 TTL을 독립적으로 관리합니다.

Spring AOPRedis CacheTTL 동적 설정

jOOQ 동적 쿼리

Type-safe DSL

최근 몇년 간 업데이트가 지지부진한 QueryDSL 대신, 동적 쿼리와 SQL 문에 가까운 형태의 코드를 작성할 수 있는 jOOQ를 채택했습니다. 이 2개의 기능에만 초점을 맞춰, 복잡한 DSL 생성 설정은 제외했습니다.
키워드·태그·카테고리 조건을 조합한 동적 쿼리 검색과 페이징을 구현했습니다. 카테고리 검색 시 WITH RECURSIVE CTE로 하위 카테고리 게시글까지 포함 조회하며, 타입 안전 쿼리 빌더 역할에 집중합니다.

jOOQWITH RECURSIVE동적 페이징

Virtual Thread 병렬 처리

Java 21

Java 21 Virtual Thread와 AsyncSupporter 공통 유틸리티를 활용해 포토 업로드, 게시글 이전/다음 조회 등을 병렬 처리합니다. CompletableFuture 래퍼로 가독성 높은 비동기 코드를 유지합니다.

Java 21 VTCompletableFuture비동기 병렬

이벤트 드리븐 아키텍처

Message Queue + Event Driven

Blog-service 모듈에서 블로그 게시글 저장 후 메시지를 발행합니다. 그 후 Worker 모듈의 @KafkaListener가 메시지를 수신하여 PostgreSQL에 활동 로그를 적재합니다. 또한 다른 모듈인 Web에서도 @KafkaListener가 메시지를 수신하여 게시글 상태가 'PUBLISHED' 일 경우, 접속해 있는 모든 사용자 Client에 새 글이 개시되었음을 실시간으로 알립니다.(WebSocket)
메시지 큐를 사용한 작은 이벤트 드리븐 아키텍처를 구현한 것은 App 모듈과 Worker 모듈의 결합도를 낮춰 장애 전파를 방지하고, 동기 처리 시 API latency 증가를 예방할 수 있으며, 시스템을 구성하는 각 요소에 메시지를 Broadcasting 하여 보다 유연하게 독립적으로 처리할 수 있기 때문입니다. 메시지 큐로는 Apache Kafka, 인증은 SSL PEM을 사용합니다.

Apache KafkaAiven CloudSSL PEM

Google Drive 스토리지

External Storage

Google Drive API v3를 활용한 이미지 업로드 및 서빙, 600px 썸네일 자동 생성을 구현했습니다. AWS S3 등의 스토리지 서비스에 비해 비용을 획기적으로 절감한 대신 (100GB 무료 용량) 성능을 희생한 결과입니다.
그러나 Valkey/Redis 캐싱으로 Drive API 호출을 최소화하였고, 서버가 아닌 클라이언트에서 exifr로 EXIF 메타데이터를 추출하는 것으로 고안하여 성능을 보완하였습니다.
개인 프로젝트 환경에서 비용 효율성을 고려하여 선택했으며, 실제 서비스에서는 S3 + CDN 구조를 사용하는 것이 더 적절하다고 판단합니다.

Google Drive API v3OAuth 2.0썸네일 생성

세션/JWT 이중 인증

Spring Security

Spring Security 기반 세션과 JWT 이중 인증을 구현했습니다. Spring Security 세션은 Web 환경에서 활용되며, JWT 인증은 API 호출 시 활용됩니다. 프런트엔드에서 API 호출로 수행되는 로그인 과정 중 비밀번호 평문 노출을 방지하기 위해 비밀번호는 RSA 공개키로 클라이언트에서 암호화 후 전송되며, BCrypt로 해시 저장합니다.
또한 백엔드에서의 DB 접속 정보는 Jasypt로 암호화하며, 암호화 키는 소스 코드에 노출없이 배포 서버에 환경변수로 설정하여 보안을 강화했습니다.

Spring SecurityJWT (JJWT)RSA 암호화Jasypt

조회수 처리

Redis INCR

Valkey/Redis INCR 커맨드로 게시글 조회수를 원자적으로 관리합니다. 이렇게 Single Thread로 동작하는 Redis를 적절히 잘 활용하면 DB write 병목을 제거하고 동시성 이슈를 해결할 수 있습니다. DB 부하 없이 실시간으로 조회수를 집계하며, 서버 재시작 시에도 조회수가 유지됩니다.
RDB에는 Work모듈의 Scheduler를 통해 일정 주기로 조회수 데이터를 동기화합니다. 조회수는 실시간 정합성보다 시스템 안정성을 우선하여 최종 반영 지연을 허용했습니다. RDB로 동기화 된 조회수는 게시글 리스트 화면에서 표시됩니다.

Redis INCR원자적 연산실시간 집계

Facade 패턴 + MapStruct

Clean Architecture

Controller → Facade → Service 구조로 비즈니스 오케스트레이션을 분리합니다. Facade에서는 여러 Service들의 Method들을 조합한 비즈니스 로직을 정의합니다.
MapStruct로 타입 안전한 DTO 변환을 구현하고, default 메서드로 변환 로직을 Mapper 내에 응집시킵니다.

Facade PatternMapStruct 1.6@Facade

Spring AI × OpenAI

OpenAI API

Spring AI(spring-ai-openai)와 OpenAI Chat Completions API를 연동하여 게시글 본문(Markdown)을 기반으로 3줄 요약을 자동 생성합니다.
shared 모듈에 OpenAiConfig(OpenAiApi·OpenAiChatModel·ChatClient Bean)와 OpenAiChatService를 공통화하고, blog-service에서 프롬프트를 구성하여 요약을 생성합니다. 게시 시 요약 필드가 비어있으면 자동으로 AI 요약을 생성한 뒤 게시글과 함께 저장합니다.

Spring AIOpenAI ChatClient게시글 요약 자동 생성

성능 최적화

Performance

제한된 서버 자원(2 Core / 4GB RAM)에서 응답 속도와 처리량을 높이기 위해 캐싱 전략, 비동기 병렬 처리, DB 쿼리 최적화, 네트워크 I/O 절감을 계층별로 적용했습니다.

캐싱 전략 Caching
AOP · Redis
@DynamicCacheable — 캐시별 TTL 동적 제어
🔴 문제: Spring의 기본 @Cacheable은 캐시별 TTL을 코드에서 동적으로 설정할 수 없음
🟢 해결: 커스텀 @DynamicCacheable 어노테이션 + @Aspect 인터셉터로 메서드 단위 TTL 지정. DynamicRedisCacheManager가 요청 시점에 TTL을 적용
@DynamicCacheable(
  cacheName = "category:tree",
  ttlSeconds = 900 // 15분
)
fun getCategoryTree(): List<CategoryDto>
캐시 종류별로 독립적인 TTL 적용. 카테고리 트리 15분, 콘텐츠 5분, Drive ID 1시간으로 세분화
Spring AOP@AspectDynamicRedisCacheManager
Redis · INCR
조회수 원자적 집계 — DB 부하 제거
🔴 문제: 게시글 조회 시마다 UPDATE posts SET view_count = view_count + 1을 실행하면 동시 요청 시 락 경합 및 DB 부하 발생
🟢 해결: Redis INCR view:post:{seq}로 원자적 증가. DB는 배치 또는 조회 시점에만 동기화
조회수 쓰기 연산에서 DB 완전 분리. 서버 재시작 후에도 Redis 값 유지
Redis INCRViewCountHelper원자적 연산
Redis · Google Drive
Drive API 호출 최소화 — ID 캐싱
🔴 문제: 이미지 서빙 시 Google Drive API를 매번 호출하면 외부 API 레이턴시(~300ms) 누적 및 Rate Limit 도달 위험
🟢 해결: 폴더 ID와 파일 ID를 gdrive:folder:{name}, gdrive:file:{seq} 키로 Redis에 TTL 1시간 캐싱
캐시 히트 시 Drive API 호출 0회. 갤러리 목록 로딩 레이턴시 대폭 감소
GoogleDriveHelperTTL 1hRate Limit 방어
DB 쿼리 최적화 Query
jOOQ · CTE
WITH RECURSIVE CTE — 계층 카테고리 단일 쿼리
🔴 문제: 카테고리는 최대 3 depth Self-referencing 구조. 하위 카테고리 포함 검색 시 N+1 쿼리 또는 애플리케이션 레벨 트리 순회 필요
🟢 해결: jOOQ로 WITH RECURSIVE CTE를 작성해 단일 SQL로 하위 카테고리 전체 ID를 수집 → posts 테이블 IN 조건으로 결합
카테고리 깊이와 무관하게 쿼리 1회로 해결. N+1 문제 원천 제거
jOOQ DSLWITH RECURSIVEN+1 제거
jOOQ · Bulk Insert
태그 Bulk Insert — 쿼리 횟수 감소
🔴 문제: 게시글 저장 시 태그를 JPA saveAll()로 처리하면 태그 개수만큼 INSERT가 개별 실행
🟢 해결: jOOQ insertInto().values().values()... 체인으로 단일 Bulk INSERT 실행
태그 10개 기준 INSERT 쿼리 10회 → 1회. DB 왕복 횟수 90% 감소
PostTagsQueryRepositoryBulk INSERT
JPA · Index
복합 인덱스 설계 — 검색·정렬 최적화
🔴 문제: 게시글 목록 검색·페이지네이션 시 Full Table Scan 발생
🟢 해결: 자주 조회되는 컬럼에 인덱스 적용: idx_slug(UQ), idx_status, idx_published_at, idx_category_seq
JPA LAZY fetch 전략으로 연관 엔티티 불필요한 즉시 로딩 방지. @ManyToOne(fetch = LAZY)
게시글 목록 검색·페이지네이션 쿼리가 Full Table Scan 없이 인덱스 활용
@IndexLAZY fetchN+1 방지
비동기·병렬 처리 Concurrency
Java 21 · Virtual Thread
이전/다음 게시글 병렬 조회
🔴 문제: 게시글 상세 페이지 렌더링 시 이전·다음 게시글을 순차 조회하면 쿼리 2회의 레이턴시가 직렬로 누적
🟢 해결: AsyncSupporter가 Virtual Thread 기반 CompletableFuture로 두 쿼리를 동시 실행
// 순차: T(prev) + T(next)
// 병렬: join(T(prev), T(next))
val prev = asyncSupply(virtualThreadExecutor) { prevPost() }
val next = asyncSupply(virtualThreadExecutor) { nextPost() }
CompletableFuture.allOf(prev, next).join()
응답 시간이 두 쿼리의 합산이 아닌 최대값으로 감소. Platform Thread 낭비 없음
Virtual ThreadAsyncSupporterCompletableFuture
EVENT DRIVEN · Kafka
메시지 큐를 이용한 활동 감사 로그 적재
🔴 문제: 게시글 저장 후 활동 감사 로그를 동일한 모듈에서 동기식으로 수행하거나, 다른 시스템에 REST API를 직접 호출하는 등의 방식으로 구현 시, 사용자 응답 경로에 추가 레이턴시가 발생할 수 있고 높은 결합도로 구성 요소 중 어느 한쪽에서 장애가 발생하면 전면 장애로 전파될 위험성이 있음
🟢 해결: 저장 완료 후 AOP의 @AfterReturning 메서드에서 Kafka 메시지만 발행. 독립 실행되는 Worker가 메시지를 수신하여 PostgreSQL에 적재
사용자 응답 경로에서 로그 적재 제거. 메인 앱과 Worker의 장애 격리도 확보
@AfterReturningKafkaTemplate장애 격리
네트워크·I/O Network & I/O
Google Drive · Thumbnail
600px 썸네일 자동 생성 — 전송량 절감
🔴 문제: 원본 사진(평균 5~15MB)을 갤러리 목록에 직접 사용하면 페이지 로딩 시간이 수십 초에 달함
🟢 해결: 업로드 시 서버에서 600px 리사이즈 썸네일을 자동 생성해 Google Drive에 별도 저장. 목록은 썸네일 URL 사용
갤러리 목록의 이미지 전송량 약 95% 감소. 원본은 라이트박스에서만 로딩
Java ImageIO600px resizeLazy Load
HikariCP · Connection Pool
커넥션 풀 최적화 — 연결 오버헤드 제거
🔴 문제: 매 요청마다 TCP 핸드셰이크 비용 발생. Multi-JPA 환경에서 풀 고갈 위험
🟢 해결: 운영 프로파일(application-live.yml)에서 HikariCP 커넥션 풀을 설정. MySQL과의 연결을 미리 확보해 매 요청마다 TCP 핸드셰이크 비용 제거
Worker 모듈은 MySQL + PostgreSQL 이중 DataSource를 별도 HikariCP 풀로 관리해 두 DB 연결이 서로 간섭하지 않음
커넥션 재사용으로 DB 응답 시간 안정화. Multi-JPA 환경에서도 풀 고갈 방지
HikariCPMulti-JPAapplication-live.yml
빌드·런타임 Build & Runtime
MapStruct · 컴파일 타임
DTO 변환 — 런타임 리플렉션 제거
🔴 문제: ModelMapper 등 리플렉션 기반 매퍼는 런타임에 클래스를 탐색하여 CPU 비용 발생. 타입 안전성도 낮음
🟢 해결: MapStruct 1.6으로 컴파일 타임에 변환 코드를 생성. default 메서드로 변환 로직을 Mapper 내에 응집
런타임 리플렉션 0회. 타입 오류는 빌드 시점에서 조기 발견
MapStruct 1.6컴파일 타임 생성타입 안전
Gradle · Incremental Build
모듈별 증분 빌드 — 빌드 시간 단축
🔴 문제: Monolith 단일 모듈 구성에서는 어느 한 파일 수정만으로도 전체를 재컴파일
🟢 해결: Gradle Multi-Module로 모듈 간 의존성을 명시. 변경된 모듈과 그 상위 모듈만 증분 빌드
blog-service 수정 시 user-service, content-service는 빌드 스킵
Gradle 9.3증분 빌드Kotlin DSL

DB 스키마

Schema Design

데이터 성격에 따라 MySQL·PostgreSQL·MongoDB·Redis 4개 저장소로 분리합니다. JPA 엔티티 ID 필드는 Seq 접미사를 사용하며, 소프트 삭제는 is_active 플래그로 처리합니다.

RDB ERD · 테이블 관계도

RDB ERD · 테이블 관계도

JPA + Hibernate로 관리하는 운영 관계형 DB입니다. snake_case 컬럼명, IDENTITY ID 전략, Soft Delete(is_active)를 공통 규칙으로 적용합니다.

users user-service
  • user_seq BIGINT PK · AI
  • email VARCHAR(100) UQ · IDX
  • name VARCHAR(50) UQ · IDX
  • password_hash VARCHAR(255) NOT NULL
  • display_name VARCHAR(100) NOT NULL
  • bio TEXT NULL
  • profile_image_url VARCHAR(500) NULL
  • is_active BOOLEAN default true
  • created_at TIMESTAMP immutable
  • updated_at TIMESTAMP auto
posts blog-service
  • post_seq BIGINT PK · AI
  • user_seq BIGINT IDX · FK
  • category_seq BIGINT IDX · FK
  • title VARCHAR(200) NOT NULL
  • slug VARCHAR(200) UQ · IDX
  • summary TEXT NULL
  • content LONGTEXT HTML
  • markdown_content LONGTEXT 원본 MD
  • thumbnail_url VARCHAR(500) NULL
  • status VARCHAR(20) ENUM · IDX
  • view_count INT default 0
  • published_at TIMESTAMP IDX
  • created_at TIMESTAMP immutable
  • updated_at TIMESTAMP auto
categories blog-service
  • category_seq BIGINT PK · AI
  • category_name VARCHAR(100) UQ
  • slug VARCHAR(100) UQ · IDX
  • description TEXT NULL
  • parent_category_id BIGINT Self-ref · IDX · FK
  • display_order INT default 0
  • is_active BOOLEAN Soft Del.
  • created_at TIMESTAMP immutable
  • updated_at TIMESTAMP auto
posts_tags blog-service
  • post_seq BIGINT PK 복합 · FK
  • tag_seq INT PK 복합
  • tag VARCHAR(100) NOT NULL
  • created_at TIMESTAMP immutable
photos photo-archive-service
  • photo_seq BIGINT PK · AI
  • user_seq BIGINT IDX · FK
  • category_seq BIGINT IDX · FK
  • title VARCHAR(200) NOT NULL
  • caption TEXT NULL
  • image_url VARCHAR(500) Google Drive
  • thumbnail_url VARCHAR(500) 600px
  • like_count INT default 0
  • exif_maker VARCHAR EXIF
  • exif_model VARCHAR EXIF
  • exif_aperture VARCHAR EXIF
  • exif_shutter VARCHAR EXIF
  • exif_iso VARCHAR EXIF
  • exif_focal_length VARCHAR EXIF
  • exif_lens VARCHAR EXIF
  • exif_flash VARCHAR EXIF
  • gps_latitude DECIMAL NULL
  • gps_longitude DECIMAL NULL
  • shot_at TIMESTAMP NULL
  • created_at TIMESTAMP immutable
  • updated_at TIMESTAMP auto
  • is_active BOOLEAN Soft Del.
photos_categories photo-archive-service
  • category_seq BIGINT PK · AI
  • category_name VARCHAR(100) UQ · IDX
  • icon VARCHAR NULL
  • is_active BOOLEAN Soft Del.
  • created_at TIMESTAMP immutable
  • updated_at TIMESTAMP auto
photos_tags photo-archive-service
  • photo_seq BIGINT PK 복합 · FK
  • tag_seq INT PK 복합
  • tag VARCHAR(100) IDX
  • created_at TIMESTAMP immutable

Worker 모듈이 독립적으로 사용하는 DB입니다. Kafka Consumer가 수신한 게시글 저장 이벤트를 활동 감사 로그로 적재합니다.

posts_log worker
  • log_seq BIGINT PK · AI
  • post_seq BIGINT NOT NULL
  • user_seq BIGINT NOT NULL
  • category_seq BIGINT NULL
  • title VARCHAR(200) NOT NULL
  • slug VARCHAR(200) NULL
  • summary TEXT NULL
  • markdown_content TEXT NULL
  • status VARCHAR(10) default 'DRAFT'
  • published_at TIMESTAMP NULL
  • created_at TIMESTAMP posts 기준
  • log_created_at TIMESTAMP 이벤트 수신 시각

콘텐츠 도큐먼트는 MongoDB Atlas에 저장됩니다.content 필드는 HashMap<String, Any>로 스키마 변경에 자유롭게 대응합니다.

content-documents content-service

  "_id" ObjectId("...")
  "contentType" "INTRO" // INTRO | PROFILE | CAR
  "content"
    // INTRO 예시
    "title" "String"
    "description" "String"
    "tags" ["String", ...]
    // PROFILE 예시 (resume 하위 구조 포함)
    "image" "String (URL)"
    "email" "String"
    "resume" "workExperience" [...] "skills" [...]
    // 스키마 변경에 자유로운 동적 구조
  
  "createdAt" ISODate("...")
  "updatedAt" ISODate("...")
  • _id ObjectId
  • contentType STRING INTRO | PROFILE | ARCHITECTURE
  • content HashMap<String, Any> 동적 구조 지원
  • createdAt ISODate
  • updatedAt ISODate

Valkey/Redis는 캐시 및 조회수 관리에 사용합니다.@DynamicCacheable AOP로 캐시별 TTL을 독립 설정합니다. 역직렬화 실패 시 자동으로 캐시를 삭제하고 재조회합니다.

view:post:{post_seq} 게시글 조회수. INCR 커맨드로 원자적 증가 TTL 없음
category:tree 블로그 카테고리 트리 전체 캐시 TTL 15분
content:{type} MongoDB 콘텐츠 도큐먼트 캐시 (INTRO/PROFILE/CAR) TTL 5분
gdrive:folder:{name} Google Drive 폴더 ID 캐시. API 호출 최소화 TTL 1시간
gdrive:file:{photo_seq} 사진 파일 ID 캐시. 이미지 서빙 URL 조합에 사용 TTL 1시간
photo:category:list 사진 카테고리 목록 캐시 (갤러리 필터 메뉴) TTL 10분

화면 구성 & 요청 흐름

Screens & Flow

모든 페이지는 Thymeleaf Layout Dialect의 Decorator Pattern으로 공통 nav / footer를 공유합니다. 사용자 요청은 Nginx → Spring Boot → Service Layer → DB 순서로 처리되며, 관리자 기능은 Spring Security 인증 필터를 통과해야 접근할 수 있습니다.

홈 페이지 스크린샷 /
홈 · 블로그 목록
>Hero 섹션과 최신 게시글 목록을 표시합니다. 카테고리 트리·키워드 검색·페이지네이션을 포함하며, 게시글 목록은 REST API(/api/post/search)를 통해 클라이언트에서 비동기로 렌더링합니다.
jOOQ 동적 검색카테고리 트리Redis 캐시
게시글 상세 스크린샷 /post/{id-or-slug}
게시글 상세
Markdown → HTML 변환 콘텐츠를 렌더링합니다. 자동 생성 목차(TOC), 읽기 진행 바, 코드 복사 버튼, 이전/다음 글 네비게이션을 제공합니다. ID와 Slug 양방향 조회를 지원합니다.
Commonmark 렌더링TOC 자동생성조회수 (Redis)Virtual Thread
프로필 스크린샷 /profile
프로필 · 이력서
MongoDB에 저장된 PROFILE 도큐먼트를 렌더링합니다. 경력 타임라인, 기술 스택 그리드, 특허 목록, 외부 링크를 Thymeleaf로 서버 사이드 렌더링합니다.
MongoDB 콘텐츠SSRRedis 캐시 5분
포토 아카이브 스크린샷 /photos
포토 아카이브
Google Drive에서 서빙되는 이미지를 그리드/메이슨리 뷰로 표시합니다. 카테고리 필터, 라이트박스 뷰어(EXIF 정보 표시), 좋아요 기능, 무한 로딩을 지원합니다.
Google Drive 서빙EXIF 메타데이터jOOQ 동적 검색
SRE 대시보드 스크린샷 /sre
SRE 대시보드
Grafana 대시보드를 iframe으로 임베딩합니다. Prometheus 메트릭, Loki 로그, Tempo 트레이싱 데이터를 실시간으로 확인할 수 있습니다.
Grafana EmbedPrometheusLoki + Tempo
Markdown 에디터 스크린샷 /post/editor
Markdown 에디터 [Admin]
3-패널 IDE 스타일 에디터입니다. Markdown 작성·분할뷰·미리보기 모드 전환, 40+ 스니펫 라이브러리, 자동 개요 생성, 게시 모달(slug·날짜·메타)을 지원합니다. 게시 시 요약(summary) 필드가 비어있으면 Spring AI를 통해 OpenAI API를 호출하여 본문 기반 3줄 요약문을 자동 생성합니다. Spring Security로 인증된 관리자만 접근 가능합니다.
Commonmark 미리보기Spring AI 요약 생성Kafka 이벤트 발행Spring Security
포토 업로드 스크린샷 (관리자 전용) /photos/upload
포토 업로드 [Admin]
드래그 앤 드롭 업로드 인터페이스입니다. 업로드 큐에서 파일별 미리보기를 제공하며, 클라이언트(exifr)에서 추출한 EXIF 데이터를 자동으로 메타데이터 폼에 채웁니다. 제목·캡션·태그·카테고리·촬영일을 일괄 적용하는 "전체 적용" 토글을 지원합니다. Google Drive에 원본과 600px 썸네일을 동시 저장합니다.
Drag & Dropexifr EXIF 추출Google Drive 업로드Virtual Thread 병렬Spring Security
Content Editor 스크린샷 /content/editor
Content 에디터 [Admin]
MongoDB 도큐먼트를 직접 편집하는 관리자 전용 JSON 에디터입니다. 좌측 패널에서 Content Type(ARCHITECTURE, PROFILE 등)을 선택하면 우측 CodeMirror 에디터에 현재 저장된 JSON이 로드됩니다. Format 버튼으로 JSON 자동 정렬, 에러 발생 시 해당 라인 하이라이트 기능을 제공합니다. 신규 도큐먼트가 없는 타입은 + NEW 모드로 새 콘텐츠를 생성할 수 있습니다. Spring Security로 인증된 관리자만 접근 가능합니다.
CodeMirror JSON 에디터콘텐츠 관리Spring Security
Swagger UI 스크린샷 /swagger-ui.html
API 문서 (Swagger UI)
Springdoc OpenAPI 기반의 REST API 문서입니다. 게시글 CRUD/검색, 인증(RSA 공개키), 카테고리 조회 등 전체 API 엔드포인트를 브라우저에서 직접 테스트할 수 있습니다.
Springdoc OpenAPIREST API 명세Try it out

요청 처리 흐름

Request Flow Overview
Browser
클라이언트
HTTPS
Nginx
Reverse Proxy
proxy_pass
Spring Boot App
:8080
JPA / jOOQ
MySQL
Aiven Cloud

Spring Boot App
:8080
Spring Data
MongoDB
Atlas
Spring Boot App
:8080
Spring Cache
Valkey/Redis
Aiven Cloud
Spring Boot App
:8080
Drive API
Google Drive
이미지 스토리지

Spring Boot App
PostSaveEventAspect
@AfterReturning
Message Broker
lifelog.post.updated
@KafkaListener
Worker Module
lifelog-worker-group
JPA INSERT
PostgreSQL
posts_log · Aiven Cloud
@KafkaListener
Web Module
lifelog-web-group
WebSocket Broadcast
Browser
실시간 알림 팝업
Consumer Group

동일한 토픽(lifelog.post.updated)을 Worker와 Web 모듈이 각기 다른 Consumer Group으로 독립 구독합니다.

각 Consumer Group은 자신의 offset을 독립적으로 관리하므로, 한쪽이 처리에 실패하거나 재시작되어도 다른 쪽에 영향을 주지 않습니다.

Worker: lifelog-worker-group Web: lifelog-web-group

게시글 발행 화면 스크린샷
게시글 발행
에디터에서 게시글을 PUBLISHED 상태로 저장하면, AOP @AfterReturning이 Kafka 메시지를 발행합니다.
Kafka 이벤트 발행AOP @AfterReturning
활동 감사 로그 DB 화면 스크린샷
활동 감사 로그 적재
Worker 모듈의 Consumer(lifelog-worker-group)가 메시지를 수신하여 PostgreSQL posts_log 테이블에 감사 로그를 적재합니다.
PostgreSQLlifelog-worker-group
게시글 발행 알림 팝업 스크린샷
실시간 발행 알림
Web 모듈의 Consumer(lifelog-web-group)가 PUBLISHED 상태를 확인하고 WebSocket으로 접속 중인 모든 Client에게 알림 팝업을 브로드캐스트합니다.
WebSocketlifelog-web-group

Editor
게시 버튼 클릭
summary 비어있음
PostFacade
Spring AI
ChatClient
OpenAI API
Chat Completions
3줄 요약 반환
posts.summary
MySQL 저장
AI Summary Flow

① 에디터에서 게시글 본문을 작성한 뒤, 요약(summary) 필드를 비워둔 채 게시 버튼을 클릭합니다.

PostFacade가 본문(Markdown)을 OpenAiChatService에 전달하고, Spring AI ChatClient를 통해 OpenAI Chat Completions API를 호출하여 3줄 요약문을 생성합니다.

③ 생성된 AI 요약문이 게시글의 summary 필드에 자동 반영되어 함께 저장됩니다.

게시글 요약 미입력 상태에서 게시 클릭
① 요약 없이 게시
Markdown 에디터에서 게시글 본문을 작성한 뒤, 요약(summary) 필드를 비워둔 채 게시 버튼을 클릭합니다.
에디터게시 모달
AI 요약문 생성 중 화면
② AI 요약 자동 생성
PostFacade가 본문(Markdown)을 OpenAiChatService에 전달하고, Spring AI ChatClient를 통해 OpenAI Chat Completions API를 호출하여 3줄 요약을 생성합니다. 상태바에 'AI 요약 생성 중...' 인디케이터가 표시됩니다.
Spring AIOpenAI APIChatClient
AI 요약문과 함께 게시글 저장 완료
③ 요약문과 함께 저장
생성된 AI 요약문이 게시글의 summary 필드에 자동 반영되어 함께 저장됩니다. 게시글 목록과 상세 페이지에서 요약문이 표시됩니다.
자동 summary 반영POST /api/post/save

Worker Module
@Scheduled · 매 5분
① GET view_count
Valkey / Redis
post:view:{postSeq}
② UPDATE view_count
MySQL
posts.view_count
Sync Order

① Scheduler가 MySQL에서 발행된 게시글 ID 목록을 조회합니다.

② 각 게시글 ID로 Valkey/Redis에서 누적 조회수(post:view:{postSeq})를 읽어옵니다.

③ 읽어온 값으로 MySQL posts.view_count를 UPDATE합니다. 게시글마다 독립적으로 처리하며, Virtual Thread를 활용해 배치를 병렬로 실행합니다.


Spring Boot App
Actuator + Logback
scrape / push
Prometheus
Loki · Tempo
sre-containers
Grafana
통합 대시보드

Observability 스택

운영 환경 구성

Docker Compose 기반 SRE 모니터링 스택으로 메트릭·로그·트레이싱을 통합 관제합니다. 실시간 대시보드는 SRE 페이지에서 확인할 수 있습니다.

Observability 스택 구조도

Observability 스택 구조도

Prometheus

Micrometer를 통해 Spring Boot Actuator 메트릭을 수집합니다. JVM, HTTP, DB 커넥션 풀 등 주요 지표를 스크래핑합니다.

Metrics Collection

Loki

Loki4j Logback Appender를 통해 애플리케이션 로그를 수집합니다. 프로파일별 전략으로 에러 로그를 분리하며 30일간 보관합니다.

Log Aggregation

Tempo

OpenTelemetry 기반 분산 트레이싱으로 요청 처리 흐름을 추적합니다. 지연 원인과 병목 지점을 가시화합니다.

Distributed Tracing

Grafana

Prometheus·Loki·Tempo를 연결하는 통합 대시보드입니다. 알림 설정과 탐색적 데이터 분석을 지원합니다.

Unified Dashboard
Spring Observability Spring Observability
Spring Observability
Micrometer + OpenTelemetry를 통해 Spring Boot 애플리케이션의 메트릭·트레이스·로그를 자동 계측합니다. Actuator 엔드포인트를 통해 health, metric, prometheus 스크래핑 경로를 노출합니다.
MicrometerOpenTelemetrySpring Actuator
트레이싱 추적 트레이싱 추적
트레이싱 추적
Grafana Tempo로 수집된 분산 트레이스를 시각화합니다. 서비스 간 호출 경로와 각 Span의 소요 시간을 Waterfall 뷰로 확인하여 병목 지점을 빠르게 특정합니다.
Grafana TempoSpan Waterfall서비스 맵
TraceID 연계 모니터링 TraceID 연계 모니터링
TraceID 연계 모니터링
단일 TraceID를 키로 Prometheus 메트릭, Loki 로그, Tempo 트레이스를 Grafana에서 상호 연계합니다. 장애 발생 시 메트릭 이상 → 로그 확인 → 트레이스 드릴다운까지 컨텍스트 전환 없이 분석합니다.
TraceID 연계Loki Log DrillExemplar
Prometheus 자원 상태 감시 Prometheus 자원 상태 감시
Prometheus 자원 상태 감시
Prometheus가 수집한 JVM 힙·GC, HTTP 요청률·오류율, HikariCP 커넥션 풀, 시스템 CPU·메모리 메트릭을 Grafana 대시보드로 실시간 시각화합니다.
JVM 메트릭HikariCP 풀Alert Rule
Loki 로그 통계 Loki 로그 통계
Loki 로그 통계
Loki에 집계된 애플리케이션 로그를 LogQL로 쿼리하여 에러율·레벨별 분포·시간대별 로그 볼륨을 시각화합니다. 프로파일 전략으로 분리된 에러 스트림을 중점 감시합니다.
LogQL에러 스트림 분리30일 보관
Kafka 클러스터 모니터링 Kafka 클러스터 모니터링
Kafka 클러스터 모니터링
Kafka 클러스터의 브로커 상태, 토픽별 메시지 처리량, 컨슈머 그룹 Lag, 파티션 분포 등을 KMinion 메트릭 기반 Grafana 대시보드로 실시간 모니터링합니다. 게시글 저장 이벤트(lifelog.post.updated) 등 토픽의 프로듀서/컨슈머 처리 현황을 한눈에 파악할 수 있습니다.
Kafka토픽 모니터링Consumer LagKMinion

배포 환경

Infrastructure

Vultr Cloud Server

제한된 리소스 환경에서도 안정적으로 운용 가능한 Modular Monolith 아키텍처를 실증합니다. Nginx가 Reverse Proxy 역할을 하며, 메인 앱과 Worker가 동일 서버에서 독립 프로세스로 실행됩니다.

Provider Vultr
CPU 2 Core
Memory 4 GB RAM
Reverse Proxy Nginx
Runtime Java 21

외부 SaaS 구성

Cloud Services

데이터베이스와 메시지 큐는 모두 관리형 클라우드 서비스를 사용합니다. 서버 직접 운용에 따른 운영 부담을 최소화하면서도, 프로덕션 수준의 안정성을 확보합니다.

Aiven Cloud
aiven.io
MySQL (MariaDB)
운영 환경 관계형 DB. 게시글·사용자·사진 등 정형 데이터를 JPA + Hibernate로 관리합니다. HikariCP 커넥션 풀 적용.
PostgreSQL
Worker 모듈 전용 DB. Kafka Consumer가 수신한 게시글 저장 이벤트를 posts_log 테이블에 적재하는 감사 로그 저장소로 사용합니다.
Apache Kafka
게시글 저장 이벤트를 메인 앱에서 발행하여 2개의 Consumer가 구독합니다. Worker 모듈은 활동 감사 로그를 PostgreSQL에 적재하고, Web 모듈은 PUBLISHED 상태 확인 후 WebSocket으로 실시간 알림을 브로드캐스트합니다. SSL PEM 인증으로 보안 연결을 구성하며, 토픽은 lifelog.post.updated를 사용합니다.
Valkey (Redis)
캐시 및 조회수 관리. 카테고리 트리(TTL 15분), MongoDB 콘텐츠(TTL 5분), Google Drive 폴더·파일 ID 캐싱, 게시글 조회수 원자적 집계에 사용합니다.
MongoDB Atlas
mongodb.com/atlas
MongoDB
비정형 콘텐츠 저장소. 자기소개(PROFILE)·홈 소개(INTRO)·아키텍처 소개(ARCHITECTURE) 등 페이지별 콘텐츠를 타입별 Document로 저장합니다. Spring Data MongoDB로 연동하며, 스키마 변경에 유연하게 대응합니다.
Google Drive API
developers.google.com/drive
Image Storage & Serving
사진 아카이브의 이미지 업로드·서빙 스토리지로 사용합니다. OAuth 2.0(Service Account) 인증, 600px 썸네일 자동 생성, 폴더·파일 ID를 Redis에 캐싱하여 API 호출을 최소화합니다.
OpenAI
platform.openai.com
Chat Completions API
Spring AI(spring-ai-openai)를 통해 OpenAI Chat Completions API를 호출합니다.

트러블슈팅

Trouble Shooting

개발 과정에서 마주친 비자명한 문제들과 그 해결 과정을 기록합니다. 증상→원인→해결의 흐름으로 정리했습니다.

1
DefaultPointcutAdvisor가 Prometheus 메트릭 수집을 깨뜨린 문제
shared · AOP
🔴 증상
@DynamicCacheable AOP 설정 추가 후 Grafana 대시보드에서 메트릭이 전부 사라짐
🔍 원인
DefaultPointcutAdvisorTrueClassFilter모든 Bean(Actuator·Micrometer 포함)에 프록시를 적용하여, Prometheus가 원본 메트릭 Bean을 인식하지 못함
🟢 해결
DefaultPointcutAdvisor@Aspect + @Around 방식으로 전환. @DynamicCacheable이 붙은 메서드에만 프록시가 적용되도록 포인트컷을 명시적으로 제한
2
Spring Boot 4.0에서 spring-boot-starter-aop 미제공
shared · Spring Boot 4
🔴 증상
@Aspect Bean이 등록되지 않아 AOP가 전혀 동작하지 않음
🔍 원인
Spring Boot 4.0에서 spring-boot-starter-aop 스타터가 제거됨. Spring Boot 3.x까지는 해당 스타터가 spring-aopaspectjweaver를 자동으로 포함시켜 주었으나, 4.0부터는 직접 선언 필요
🟢 해결
build.gradle.ktsspring-aopaspectjweaver를 직접 의존성으로 추가
3
Google Drive 이미지 프록시 속도 저하
photo-archive-service
🔴 증상
사진 갤러리 페이지 로딩이 매우 느림 — 이미지 1장당 1~3초 소요
🔍 원인
매 요청마다 Google Drive API를 순차 호출 — 폴더 탐색 → 파일 검색 → 파일 다운로드의 3단계가 직렬로 실행되어 외부 API 레이턴시가 누적됨
🟢 해결
Virtual Thread로 메타데이터 조회와 파일 다운로드를 병렬 처리
Valkey/Redis로 폴더 ID·파일 ID를 TTL 1시간으로 캐싱하여 반복 API 호출 제거
4
Jackson LocalDateTime 직렬화 실패
shared · Jackson
🔴 증상
InvalidDefinitionException: Java 8 date/time type java.time.LocalDateTime not supported by default
🔍 원인
new ObjectMapper()로 직접 인스턴스를 생성하여 JavaTimeModule이 등록되지 않은 상태. Spring의 자동 설정 Bean이 아닌 수동 생성 ObjectMapper 사용
🟢 해결
shared 모듈에 JacksonConfig를 생성하여 JavaTimeModule 등록 + WRITE_DATES_AS_TIMESTAMPS 비활성화된 ObjectMapper Bean을 정의. 필요한 곳에 Bean으로 주입하여 사용
5
Redis 역직렬화 오류 — JDK → JSON Serializer 전환
shared · Redis
🔴 증상
SerializationException: Cannot deserialize — invalid stream header: ACED0005
🔍 원인
JDK 직렬화(바이너리) 형식으로 저장된 기존 캐시 데이터를 JSON Serializer로 읽으려 시도. Serializer를 변경했지만 이전 형식의 데이터가 Redis에 잔류
🟢 해결
RedisSerializer.json() 적용으로 직렬화 형식을 JSON으로 통일. 추가로 DynamicCacheableInterceptor에 역직렬화 실패 시 해당 캐시를 자동 evict하고 재조회하는 fallback 로직을 추가하여 운영 중 형식 불일치에도 자동 복구되도록 처리
6
HTTPS 리버스 프록시 뒤에서 HTTP로 리다이렉트
app · Nginx
🔴 증상
HTTPS로 접속 시 redirect:/ 처리가 http://로 리다이렉트되어 브라우저가 보안 경고를 표시하거나 Mixed Content 오류 발생
🔍 원인
Nginx가 HTTPS를 종료(TLS Termination)하고 내부적으로는 HTTP로 전달. Spring Boot(내장 Tomcat)는 X-Forwarded-Proto: https 헤더를 인식하지 못해 자신의 스킴을 http로 판단하여 리다이렉트 URL을 생성
🟢 해결
application-live.ymlserver.forward-headers-strategy: native 설정 추가. 이를 통해 Tomcat이 X-Forwarded-Proto 헤더를 인식하고 올바른 HTTPS 스킴으로 리다이렉트 URL을 구성
블로그에 새로운 글이 발행되었습니다.