Portfolio · System Design
리소스 제약 환경에서 MSA 전환을 고려하여 설계한 Modular Monolith 기반 풀스택 플랫폼의 아키텍처와 서비스 구성을 소개합니다.
제약된 인프라 환경(단일 서버)에서 복잡도와 운영 비용을 최소화하기 위해 Modular Monolith 아키텍처를 설계했습니다.
정형/비정형/캐시 데이터를 분리(RDB, MongoDB, Google Drive, Redis)하고 Apache Kafka 기반 비동기 처리를 통해 성능과 확장성을 확보했습니다.
Prometheus, Loki, Tempo를 활용해 장애를 탐지하고 원인을 추적할 수 있는 Observability 환경을 구축했습니다.
Design Questions : 아래와 같은 실제 운영 환경의 제약과 문제를 가정하고 설계했습니다.
이 프로젝트는 Vultr 클라우드 서버(2 Core CPU / 4GB RAM)에 배포됩니다. 제한된 인프라에서 다수의 서비스 인스턴스와 API Gateway를 운용하는 순수 MSA 구현은 현실적으로 어렵습니다. 대신, 단일 프로세스로 배포 가능한 Monolith 구조를 유지하되 도메인별로 모듈을 분리하여 향후 MSA 전환 시 개별 모듈을 독립 서비스로 추출할 수 있는 구조를 선택했습니다.
하나의 JAR로 빌드·배포되므로 제한된 서버 환경에서도 안정적으로 운용할 수 있습니다.
각 도메인이 독립된 Gradle 모듈로 분리되어 높은 응집도와 낮은 결합도를 유지합니다.
모듈 간 의존성이 명시적으로 관리되어, 인프라 확보 시 개별 모듈을 독립 서비스로 분리할 수 있습니다.
변경된 모듈만 증분 빌드되어 개발 속도가 향상되고 CI/CD 파이프라인도 최적화됩니다.
클라이언트 요청은 Nginx Reverse Proxy를 통해 Spring Boot 애플리케이션으로 전달됩니다. 메인 앱과 독립 실행 Worker 모듈이 메시지 큐인 Apache Kafka를 사이에 두고 비동기로 통신하며, Web 모듈도 Kafka Consumer를 통해 글 발행 이벤트를 수신하고 WebSocket으로 접속 중인 모든 Client에게 실시간 알림을 전송합니다. Prometheus / Loki / Tempo / Grafana로 구성된 Observability 스택이 운영을 지원합니다.
| 구분 | 기술 |
|---|---|
| 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) |
각 도메인 서비스 모듈은 shared에만 의존하며, 서비스 모듈 간에는 직접 의존성이 없습니다. web과 api가 모든 서비스 모듈을 조합하여 프레젠테이션 계층에서 오케스트레이션합니다.
| 모듈 | 유형 | 역할 |
|---|---|---|
| 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 컨테이너 일괄 실행 |
단순 CRUD를 넘어, 실무에서 마주치는 문제들을 직접 해결하며 구현한 기술적 요소들입니다.
데이터 성격과 도메인 구분에 따라 저장소를 분리하여 확장성과 유연성을 확보했습니다. 정형 데이터는 MySQL(JPA), 비정형 콘텐츠는 MongoDB, 활동 로그는 PostgreSQL, 캐시와 조회수는 Valkey/Redis로 관리합니다.
Valkey/Redis를 활용한 캐시 적용을 메서드와 기능 별로 보다 유연하게 적용하고, 비즈니스 로직에 섞이지 않기 위해 AOP와 커스텀 애노테이션을 활용하여 동적으로 캐시를 적용할 수 있게 구현했습니다.
커스텀 애노테이션 @DynamicCacheable과 @Aspect를 활용해 메서드 레벨에서 캐시 TTL을 동적으로 제어합니다. DynamicRedisCacheManager로 캐시별 TTL을 독립적으로 관리합니다.
최근 몇년 간 업데이트가 지지부진한 QueryDSL 대신, 동적 쿼리와 SQL 문에 가까운 형태의 코드를 작성할 수 있는 jOOQ를 채택했습니다. 이 2개의 기능에만 초점을 맞춰, 복잡한 DSL 생성 설정은 제외했습니다.
키워드·태그·카테고리 조건을 조합한 동적 쿼리 검색과 페이징을 구현했습니다. 카테고리 검색 시 WITH RECURSIVE CTE로 하위 카테고리 게시글까지 포함 조회하며, 타입 안전 쿼리 빌더 역할에 집중합니다.
Java 21 Virtual Thread와 AsyncSupporter 공통 유틸리티를 활용해 포토 업로드, 게시글 이전/다음 조회 등을 병렬 처리합니다. CompletableFuture 래퍼로 가독성 높은 비동기 코드를 유지합니다.
Blog-service 모듈에서 블로그 게시글 저장 후 메시지를 발행합니다. 그 후 Worker 모듈의 @KafkaListener가 메시지를 수신하여 PostgreSQL에 활동 로그를 적재합니다. 또한 다른 모듈인 Web에서도 @KafkaListener가 메시지를 수신하여 게시글 상태가 'PUBLISHED' 일 경우, 접속해 있는 모든 사용자 Client에 새 글이 개시되었음을 실시간으로 알립니다.(WebSocket)
메시지 큐를 사용한 작은 이벤트 드리븐 아키텍처를 구현한 것은 App 모듈과 Worker 모듈의 결합도를 낮춰 장애 전파를 방지하고, 동기 처리 시 API latency 증가를 예방할 수 있으며, 시스템을 구성하는 각 요소에 메시지를 Broadcasting 하여 보다 유연하게 독립적으로 처리할 수 있기 때문입니다. 메시지 큐로는 Apache Kafka, 인증은 SSL PEM을 사용합니다.
Google Drive API v3를 활용한 이미지 업로드 및 서빙, 600px 썸네일 자동 생성을 구현했습니다. AWS S3 등의 스토리지 서비스에 비해 비용을 획기적으로 절감한 대신 (100GB 무료 용량) 성능을 희생한 결과입니다.
그러나 Valkey/Redis 캐싱으로 Drive API 호출을 최소화하였고, 서버가 아닌 클라이언트에서 exifr로 EXIF 메타데이터를 추출하는 것으로 고안하여 성능을 보완하였습니다.
개인 프로젝트 환경에서 비용 효율성을 고려하여 선택했으며, 실제 서비스에서는 S3 + CDN 구조를 사용하는 것이 더 적절하다고 판단합니다.
Spring Security 기반 세션과 JWT 이중 인증을 구현했습니다. Spring Security 세션은 Web 환경에서 활용되며, JWT 인증은 API 호출 시 활용됩니다. 프런트엔드에서 API 호출로 수행되는 로그인 과정 중 비밀번호 평문 노출을 방지하기 위해 비밀번호는 RSA 공개키로 클라이언트에서 암호화 후 전송되며, BCrypt로 해시 저장합니다.
또한 백엔드에서의 DB 접속 정보는 Jasypt로 암호화하며, 암호화 키는 소스 코드에 노출없이 배포 서버에 환경변수로 설정하여 보안을 강화했습니다.
Valkey/Redis INCR 커맨드로 게시글 조회수를 원자적으로 관리합니다. 이렇게 Single Thread로 동작하는 Redis를 적절히 잘 활용하면 DB write 병목을 제거하고 동시성 이슈를 해결할 수 있습니다. DB 부하 없이 실시간으로 조회수를 집계하며, 서버 재시작 시에도 조회수가 유지됩니다.
RDB에는 Work모듈의 Scheduler를 통해 일정 주기로 조회수 데이터를 동기화합니다. 조회수는 실시간 정합성보다 시스템 안정성을 우선하여 최종 반영 지연을 허용했습니다. RDB로 동기화 된 조회수는 게시글 리스트 화면에서 표시됩니다.
Controller → Facade → Service 구조로 비즈니스 오케스트레이션을 분리합니다. Facade에서는 여러 Service들의 Method들을 조합한 비즈니스 로직을 정의합니다.
MapStruct로 타입 안전한 DTO 변환을 구현하고, default 메서드로 변환 로직을 Mapper 내에 응집시킵니다.
Spring AI(spring-ai-openai)와 OpenAI Chat Completions API를 연동하여 게시글 본문(Markdown)을 기반으로 3줄 요약을 자동 생성합니다.shared 모듈에 OpenAiConfig(OpenAiApi·OpenAiChatModel·ChatClient Bean)와 OpenAiChatService를 공통화하고, blog-service에서 프롬프트를 구성하여 요약을 생성합니다. 게시 시 요약 필드가 비어있으면 자동으로 AI 요약을 생성한 뒤 게시글과 함께 저장합니다.
제한된 서버 자원(2 Core / 4GB RAM)에서 응답 속도와 처리량을 높이기 위해 캐싱 전략, 비동기 병렬 처리, DB 쿼리 최적화, 네트워크 I/O 절감을 계층별로 적용했습니다.
@Cacheable은 캐시별 TTL을 코드에서 동적으로 설정할 수 없음
@DynamicCacheable 어노테이션 + @Aspect 인터셉터로 메서드 단위 TTL 지정. DynamicRedisCacheManager가 요청 시점에 TTL을 적용
UPDATE posts SET view_count = view_count + 1을 실행하면 동시 요청 시 락 경합 및 DB 부하 발생
INCR view:post:{seq}로 원자적 증가. DB는 배치 또는 조회 시점에만 동기화
gdrive:folder:{name}, gdrive:file:{seq} 키로 Redis에 TTL 1시간 캐싱
WITH RECURSIVE CTE를 작성해 단일 SQL로 하위 카테고리 전체 ID를 수집 → posts 테이블 IN 조건으로 결합
saveAll()로 처리하면 태그 개수만큼 INSERT가 개별 실행
insertInto().values().values()... 체인으로 단일 Bulk INSERT 실행
idx_slug(UQ), idx_status, idx_published_at, idx_category_seq@ManyToOne(fetch = LAZY)
AsyncSupporter가 Virtual Thread 기반 CompletableFuture로 두 쿼리를 동시 실행
@AfterReturning 메서드에서 Kafka 메시지만 발행. 독립 실행되는 Worker가 메시지를 수신하여 PostgreSQL에 적재
application-live.yml)에서 HikariCP 커넥션 풀을 설정. MySQL과의 연결을 미리 확보해 매 요청마다 TCP 핸드셰이크 비용 제거default 메서드로 변환 로직을 Mapper 내에 응집
데이터 성격에 따라 MySQL·PostgreSQL·MongoDB·Redis 4개 저장소로 분리합니다.
JPA 엔티티 ID 필드는 Seq 접미사를 사용하며,
소프트 삭제는 is_active 플래그로 처리합니다.
RDB ERD · 테이블 관계도
JPA + Hibernate로 관리하는 운영 관계형 DB입니다. snake_case 컬럼명, IDENTITY ID 전략, Soft Delete(is_active)를 공통 규칙으로 적용합니다.
Worker 모듈이 독립적으로 사용하는 DB입니다. Kafka Consumer가 수신한 게시글 저장 이벤트를 활동 감사 로그로 적재합니다.
콘텐츠 도큐먼트는 MongoDB Atlas에 저장됩니다.content 필드는 HashMap<String, Any>로 스키마 변경에 자유롭게 대응합니다.
Valkey/Redis는 캐시 및 조회수 관리에 사용합니다.@DynamicCacheable AOP로 캐시별 TTL을 독립 설정합니다. 역직렬화 실패 시 자동으로 캐시를 삭제하고 재조회합니다.
모든 페이지는 Thymeleaf Layout Dialect의 Decorator Pattern으로 공통 nav / footer를 공유합니다. 사용자 요청은 Nginx → Spring Boot → Service Layer → DB 순서로 처리되며, 관리자 기능은 Spring Security 인증 필터를 통과해야 접근할 수 있습니다.
/
/api/post/search)를 통해 클라이언트에서 비동기로 렌더링합니다.
/post/{id-or-slug}
/profile
/photos
/sre
/post/editor
/photos/upload
/content/editor
/swagger-ui.html
요청 처리 흐름
동일한 토픽(lifelog.post.updated)을 Worker와 Web 모듈이 각기 다른 Consumer Group으로 독립 구독합니다.
각 Consumer Group은 자신의 offset을 독립적으로 관리하므로, 한쪽이 처리에 실패하거나 재시작되어도 다른 쪽에 영향을 주지 않습니다.
Worker: lifelog-worker-group
Web: lifelog-web-group
@AfterReturning이 Kafka 메시지를 발행합니다.
lifelog-worker-group)가 메시지를 수신하여 PostgreSQL posts_log 테이블에 감사 로그를 적재합니다.
lifelog-web-group)가 PUBLISHED 상태를 확인하고 WebSocket으로 접속 중인 모든 Client에게 알림 팝업을 브로드캐스트합니다.① 에디터에서 게시글 본문을 작성한 뒤, 요약(summary) 필드를 비워둔 채 게시 버튼을 클릭합니다.
② PostFacade가 본문(Markdown)을 OpenAiChatService에 전달하고, Spring AI ChatClient를 통해 OpenAI Chat Completions API를 호출하여 3줄 요약문을 생성합니다.
③ 생성된 AI 요약문이 게시글의 summary 필드에 자동 반영되어 함께 저장됩니다.
PostFacade가 본문(Markdown)을 OpenAiChatService에 전달하고, Spring AI ChatClient를 통해 OpenAI Chat Completions API를 호출하여 3줄 요약을 생성합니다. 상태바에 'AI 요약 생성 중...' 인디케이터가 표시됩니다.
① Scheduler가 MySQL에서 발행된 게시글 ID 목록을 조회합니다.
② 각 게시글 ID로 Valkey/Redis에서 누적 조회수(post:view:{postSeq})를 읽어옵니다.
③ 읽어온 값으로 MySQL posts.view_count를 UPDATE합니다.
게시글마다 독립적으로 처리하며, Virtual Thread를 활용해 배치를 병렬로 실행합니다.
Docker Compose 기반 SRE 모니터링 스택으로 메트릭·로그·트레이싱을 통합 관제합니다. 실시간 대시보드는 SRE 페이지에서 확인할 수 있습니다.
Observability 스택 구조도
Micrometer를 통해 Spring Boot Actuator 메트릭을 수집합니다. JVM, HTTP, DB 커넥션 풀 등 주요 지표를 스크래핑합니다.
Metrics CollectionLoki4j Logback Appender를 통해 애플리케이션 로그를 수집합니다. 프로파일별 전략으로 에러 로그를 분리하며 30일간 보관합니다.
Log AggregationOpenTelemetry 기반 분산 트레이싱으로 요청 처리 흐름을 추적합니다. 지연 원인과 병목 지점을 가시화합니다.
Distributed TracingPrometheus·Loki·Tempo를 연결하는 통합 대시보드입니다. 알림 설정과 탐색적 데이터 분석을 지원합니다.
Unified Dashboard
Spring Observability
트레이싱 추적
TraceID 연계 모니터링
Prometheus 자원 상태 감시
Loki 로그 통계
Kafka 클러스터 모니터링
데이터베이스와 메시지 큐는 모두 관리형 클라우드 서비스를 사용합니다. 서버 직접 운용에 따른 운영 부담을 최소화하면서도, 프로덕션 수준의 안정성을 확보합니다.
posts_log 테이블에 적재하는 감사 로그 저장소로 사용합니다.lifelog.post.updated를 사용합니다.spring-ai-openai)를 통해 OpenAI Chat Completions API를 호출합니다.개발 과정에서 마주친 비자명한 문제들과 그 해결 과정을 기록합니다. 증상→원인→해결의 흐름으로 정리했습니다.
@DynamicCacheable AOP 설정 추가 후 Grafana 대시보드에서 메트릭이 전부 사라짐DefaultPointcutAdvisor의 TrueClassFilter가 모든 Bean(Actuator·Micrometer 포함)에 프록시를 적용하여, Prometheus가 원본 메트릭 Bean을 인식하지 못함DefaultPointcutAdvisor → @Aspect + @Around 방식으로 전환. @DynamicCacheable이 붙은 메서드에만 프록시가 적용되도록 포인트컷을 명시적으로 제한@Aspect Bean이 등록되지 않아 AOP가 전혀 동작하지 않음spring-boot-starter-aop 스타터가 제거됨. Spring Boot 3.x까지는 해당 스타터가 spring-aop와 aspectjweaver를 자동으로 포함시켜 주었으나, 4.0부터는 직접 선언 필요build.gradle.kts에 spring-aop와 aspectjweaver를 직접 의존성으로 추가InvalidDefinitionException: Java 8 date/time type java.time.LocalDateTime not supported by defaultnew ObjectMapper()로 직접 인스턴스를 생성하여 JavaTimeModule이 등록되지 않은 상태. Spring의 자동 설정 Bean이 아닌 수동 생성 ObjectMapper 사용JacksonConfig를 생성하여 JavaTimeModule 등록 + WRITE_DATES_AS_TIMESTAMPS 비활성화된 ObjectMapper Bean을 정의. 필요한 곳에 Bean으로 주입하여 사용SerializationException: Cannot deserialize — invalid stream header: ACED0005RedisSerializer.json() 적용으로 직렬화 형식을 JSON으로 통일. 추가로 DynamicCacheableInterceptor에 역직렬화 실패 시 해당 캐시를 자동 evict하고 재조회하는 fallback 로직을 추가하여 운영 중 형식 불일치에도 자동 복구되도록 처리redirect:/ 처리가 http://로 리다이렉트되어 브라우저가 보안 경고를 표시하거나 Mixed Content 오류 발생X-Forwarded-Proto: https 헤더를 인식하지 못해 자신의 스킴을 http로 판단하여 리다이렉트 URL을 생성application-live.yml에 server.forward-headers-strategy: native 설정 추가. 이를 통해 Tomcat이 X-Forwarded-Proto 헤더를 인식하고 올바른 HTTPS 스킴으로 리다이렉트 URL을 구성