DungeonTalk 프로젝트를 통해 학습한 CS 지식 정리
개요
DungeonTalk은 AI가 실시간으로 스토리를 생성하고 플레이어와 상호작용하는 TRPG 게임 플랫폼이다.
플레이어는 판타지 세계, 좀비 아포칼립스 등의 세계관을 선택하여 AI 게임 마스터가 진행하는 게임에
참여합니다. AI 게임 마스터는 RAG 기반 대화 문맥을 유지하는 구조를 통해 플레이어들의 행동과 선택을
실시간으로 분석하여 그에 맞는 스토리를 생성하고, 이전 대화 맥락을 기억하며 일관된 게임 경험을 제공
한다. 플레이어들은 AI 게임 마스터와의 대화뿐만 아니라 파티원들과 실시간으로 소통하며 전략을 세
우고 협력하여 게임을 진행할 수 있다.
목적
프로젝트를 진행하며 접한 CS 지식들을 정리
1. 운영체제 (Operating System)
프로세스와 스레드 (Process & Thread)
•
프로세스: 실행 중인 프로그램, 독립적인 메모리 공간 보유
•
스레드: 프로세스 내에서 실행되는 작업 단위, 같은 메모리 공간 공유
프로젝트 적용: 비동기 처리를 위한 Thread Pool 설계
채팅 도메인에서 메시지 처리, Redis Pub/Sub, Kafka 발행, 이벤트 처리를 각각 독립적인 스레드 풀로 분리
@Configuration
@EnableAsync
public class ChatAsyncConfig {
/**
* 채팅 메시지 처리용 스레드 풀
*/
@Bean(name = "chatMessageExecutor")
public Executor chatMessageExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10); // 기본 스레드 수
executor.setMaxPoolSize(50); // 최대 스레드 수
executor.setQueueCapacity(500); // 대기 큐 크기
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix("Chat-Message-");
// 큐가 가득 찰 경우 정책: 호출 스레드에서 직접 실행
executor.setRejectedExecutionHandler((runnable, threadPoolExecutor) -> {
log.warn("채팅 메시지 실행자 큐가 가득 찼습니다.");
runnable.run();
});
executor.initialize();
return executor;
}
/**
* Kafka 메시지 발행용 스레드 풀
*/
@Bean(name = "chatKafkaExecutor")
public Executor chatKafkaExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(15);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("Chat-Kafka-");
executor.initialize();
return executor;
}
}
Java
복사
학습한 내용
•
Thread Pool Pattern: 스레드 생성/소멸 비용을 줄이기 위해 미리 생성된 스레드를 재사용
•
작업 큐 (Queue): 스레드가 모두 사용 중일 때 요청을 큐에 대기
•
Core Pool Size vs Max Pool Size
◦
Core: 항상 유지되는 기본 스레드의 수 (10개)
◦
Max: 부하가 높을 때 추가 생성 가능한 최대 스레드 수 (50개)
◦
큐가 가득 차면 Max까지 스레드 증가
이슈 해결 사례
•
문제: Kafka 메시지 발행 시 응답이 느려짐 (블로킹)
•
원인: 동기적으로 Kafka에 메시지 발행
•
해결: @Async와 전용 스레드 풀 사용
@Component
@RequiredArgsConstructor
public class KafkaPublisher {
private final KafkaTemplate<String, Object> kafkaTemplate;
@Async("chatKafkaExecutor")
public CompletableFuture<Void> publishChatAsync(String roomId, Object message) {
return kafkaTemplate.send(chatTopic, roomId, message)
.thenAccept(result -> {
log.debug("Kafka 메시지 발행 완료: topic={}, partition={}, offset={}",
result.getRecordMetadata().topic(),
result.getRecordMetadata().partition(),
result.getRecordMetadata().offset());
})
.exceptionally(ex -> {
log.error("Kafka 메시지 발행 실패: error={}", ex.getMessage());
return null;
});
}
}
Java
복사
동시성 제어
•
Race Condition: 여러 스레드가 공유 자원에 동시 접근하여 예기치 않은 결과 발생
•
Thread-Safety: 여러 스레드가 동시에 접근해도 안전하게 동작
프로젝트 적용: 캐시 초기화 시 동시성 문제 해결
// 문제가 있던 코드 - Thread-Safe하지 않음
public List<WorldType> getAllActiveWorldTypes() {
// 여러 스레드가 동시에 이 체크를 통과할 수 있음!
if (cachedActiveWorldTypes == null || isExpired()) {
cachedActiveWorldTypes = worldTypeService.findAll(); // DB 조회
}
return cachedActiveWorldTypes;
}
Java
복사
•
이슈 내용: 서버 시작 시 동일한 DB 쿼리가 3번 실행됨
•
해결 방법:
◦
synchronized 키워드로 상호 배제
◦
@PostConstrut로 사전 초기화
◦
Caffeine 로컬 캐시 적용 ⇒ 최종 해결책
@Cacheable(value = "worldTypes", key = "'activeWorldTypes'")
public List<WorldType> findAllActiveWorldTypeEntities() {
log.info("DB에서 활성화된 세계관 목록 조회 (캐시 미스)");
return worldTypeRepository.findActiveWorldTypesOrderBySortOrder();
}
Java
복사
@Configuration
@EnableCaching
public class CaffeineCacheConfig {
@Bean
@Primary
public CacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("worldTypes");
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(100)
.expireAfterWrite(1, TimeUnit.HOURS)
.recordStats());
return cacheManager;
}
}
Java
복사
•
개선 결과
◦
Before: 서버 시작 시 SELECT 쿼리 3번 실행 (동시성 이슈)
◦
After: 서버 시작 시 SELECT 쿼리 1번만 실행, 1시간 동안 캐시 사용
학습한 내용:
•
Mutex (Mutual Exclusion): 한 번에 하나의 스레드만 접근 가능
•
Critical Selection: 공유 자원에 접근하는 코드 영역
•
Cache 스탬피드(Stampede) 문제: 캐시 만료 시 동시에 여러 스레드가 DB 조회하는 현상
네트워크 (Network)
OSI 7계층 & TCP/IP
OSI 계층 | TCP/IP | 프로젝트 적용 | 파일 위치 |
Application | Application | HTTP, WebSocket, STOMP | WebSocketConfig.java |
Presentation | - | JSON (Jackson) | RedisCacheManagerConfig.java |
Session | - | WebSocket Session | ChatSessionService.java |
Transport | Transport | TCP | - |
Network | Internet | IP | - |
Data Link | Network Access | Ethernet | - |
WebSocket & STOMP
•
WebSocket 설정
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 클라이언트가 메시지를 받을 경로 (/sub/chat/room/{roomId})
registry.enableSimpleBroker("/sub")
.setHeartbeatValue(new long[]{30_000, 30_000})
.setTaskScheduler(stompTaskScheduler);
// 클라이언트가 메시지를 보낼 경로 (/pub/chat/send)
registry.setApplicationDestinationPrefixes("/pub");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// WebSocket 연결 엔드포인트
registry.addEndpoint("/ws-chat")
.addInterceptors(jwtHandshakeInterceptor)
.setAllowedOriginPatterns("*")
.withSockJS()
.setHeartbeatTime(30_000);
}
/**
* STOMP 인바운드 채널 설정
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.taskExecutor()
.corePoolSize(20)
.maxPoolSize(100)
.queueCapacity(1000);
}
}
Java
복사
•
메시지 플로우:
◦
클라이언트 → /pub/chat/send (메시지 전송)
◦
@MessageMapping에서 메시지 처리
◦
Kafka로 메시지 발행
◦
Kafka Consumer가 메시지 수신
◦
/sub/chat/room/{roomId}로 브로드캐스트
◦
해당 채널을 구독한 모든 클라이언트가 메시지 수신
TCP 연결 관리
•
WebSocket Disconnect 처리
@Component
public class WebSocketDisconnectHandler {
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
// TCP 4-Way Handshake 완료 후 호출됨
String sessionId = event.getSessionId();
chatSessionService.handleDisconnect(sessionId);
}
}
Java
복사
학습한 내용
•
WebSocket은 TCP 위에서 동작
•
연결 수립: TCP 3-Way Handshake
•
연결 종료: TCP 4-Way Handshake
•
비정상 종료 대응: TTL 기반 세션 자동 만료
3. 데이터베이스 (Database)
•
Redis/Valkey의 핵심 특징: 싱글 스레드
•
왜 싱글 스레드인가?
◦
멀티 스레드의 Lock 오버헤드 제거
◦
원자적 연산 (Atomic Operation) 보장
◦
단순한 아키텍처로 높은 성능 보장
프로젝트 적용: 2개의 독립적인 Valkey (Redis 호환) 인스턴스를 사용
@Configuration
public class ValkeyConfig {
// Session Valkey 설정
@Value("${spring.data.session.host}")
private String sessionRedisHost;
@Value("${spring.data.session.port}")
private int sessionRedisPort;
// Cache Valkey 설정
@Value("${spring.redis.cache.host}")
private String cacheRedisHost;
@Value("${spring.redis.cache.port}")
private int cacheRedisPort;
@Bean(name = "sessionRedisConnectionFactory")
@Primary
public RedisConnectionFactory sessionRedisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName(sessionRedisHost);
config.setPort(sessionRedisPort);
LettuceClientConfiguration.LettuceClientConfigurationBuilder builder =
LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(10));
return new LettuceConnectionFactory(config, builder.build());
}
@Bean(name = "cacheRedisConnectionFactory")
public RedisConnectionFactory cacheRedisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName(cacheRedisHost);
config.setPort(cacheRedisPort);
return new LettuceConnectionFactory(config);
}
}
Java
복사
세션 관리 (Session Valkey)
•
TTL 기반 자동 만료
@Service
public class ValkeyService {
private final RedisTemplate<String, String> sessionRedis;
/**
* 만료 시간과 함께 키-값 저장
*/
public void setWithExpiration(String key, String value, int timeoutSeconds) {
sessionRedis.opsForValue().set(key, value, timeoutSeconds, TimeUnit.SECONDS);
}
/**
* 키가 존재하지 않을 때만 설정 (분산 락 구현용)
*/
public boolean setIfNotExists(String key, String value, int timeoutSeconds) {
Boolean result = sessionRedis.opsForValue()
.setIfAbsent(key, value, timeoutSeconds, TimeUnit.SECONDS);
return result != null && result;
}
}
Java
복사
•
채팅 세션 관리
@Service
@RequiredArgsConstructor
public class ChatSessionService {
private final ValkeyService valkeyService;
private final ObjectMapper objectMapper;
/**
* 세션 시작 (채팅방 입장)
*/
@Transactional
public ChatSessionDto startSession(String roomId, String memberId,
String nickname, String websocketSessionId) {
Instant now = Instant.now();
ChatSessionDto sessionDto = ChatSessionDto.builder()
.roomId(roomId)
.memberId(memberId)
.nickname(nickname)
.status(Status.ONLINE)
.joinedAt(now)
.lastActivity(now)
.websocketSessionId(websocketSessionId)
.build();
// Valkey에 세션 저장 (TTL: 30분)
String sessionKey = buildSessionKey(roomId, memberId);
String sessionData = serializeSessionData(sessionDto);
valkeyService.setWithExpiration(sessionKey, sessionData,
DEFAULT_SESSION_TIMEOUT_SECONDS);
return sessionDto;
}
/**
* Heartbeat 처리 (세션 유지)
*/
public boolean heartbeat(String roomId, String memberId) {
String sessionKey = buildSessionKey(roomId, memberId);
if (!valkeyService.exists(sessionKey)) {
return false;
}
// TTL만 연장
valkeyService.expire(sessionKey, DEFAULT_SESSION_TIMEOUT_SECONDS);
return true;
}
/**
* 세션 종료 (채팅방 퇴장)
*/
@Transactional
public void endSession(String roomId, String memberId) {
String sessionKey = buildSessionKey(roomId, memberId);
valkeyService.delete(sessionKey);
}
private String buildSessionKey(String roomId, String memberId) {
return "chat:session:" + roomId + ":" + memberId;
}
}
Java
복사
•
세션 관리 효과
◦
TTL (Time To Live): 자동 만료로 메모리 관리
◦
Heartbeat: 클라이언트가 주기적으로 신호 보내 세션 유지
◦
싱글 스레드의 장점: setIfAbsent 같은 원자적 연산이 Lock 없이 안전
•
캐싱
@Configuration
public class RedisCacheManagerConfig {
@Bean(name = "redisCacheManager")
public CacheManager redisCacheManager(
@Qualifier("cacheRedisConnectionFactory") RedisConnectionFactory connectionFactory) {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
GenericJackson2JsonRedisSerializer genericSerializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)) // TTL 30분
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(genericSerializer));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.build();
}
}
Java
복사
Session vs Cache 분리 이유
•
용도, TTL 등 조건이 상이한 점을 고려하여 분리
구분 | Session Valkey | Cache Valkey |
용도 | 세션 관리, 실시간 데이터 | 조회 결과 캐싱 |
TTL | 짧음 (30분) | 상대적으로 긺 (1시간+) |
휘발성 | 높음 (손실 허용) | 중간 (재조회 가능) |
트래픽 | 높음 (매 요청) | 중간 (캐시 미스 시) |
로컬 캐시 - Caffeine
@Configuration
@EnableCaching
public class CaffeineCacheConfig {
@Bean
@Primary
public CacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("worldTypes");
cacheManager.setCaffeine(Caffeine.newBuilder()
.maximumSize(100) // 최대 엔트리 수
.expireAfterWrite(1, TimeUnit.HOURS) // 1시간 후 만료
.recordStats()); // 통계 수집
return cacheManager;
}
}
Java
복사
Caffeine vs Redis Cache 비교
구분 | Redis Cache | Caffeine Cache |
저장 위치 | Redis 서버 | 애플리케이션 메모리 |
네트워크 | 필요 (1-5ms) | 불필요 |
성능 | ~1ms | ~1μs (마이크로초) |
데이터 공유 | 여러 인스턴스 간 공유 | 인스턴스별 독립 |
적합한 용도 | 분산 캐시 | 로컬 마스터 데이터 |
선택 기준
•
Caffeine 사용: 변경이 거의 없는 마스터 데이터 (세계관 정보, 종족 통계 등)
•
Redis 사용: 여러 서버 인스턴스 간 공유가 필요한 데이터
관계형 데이터데비스 - PostgreSQL
•
JPA & N+1 문제 해결
문제 상황
// 문제가 있는 코드
List<GameRoom> rooms = gameRoomRepository.findAll(); // 1번 쿼리
for (GameRoom room : rooms) {
room.getWorldType().getName(); // N번 쿼리 (Lazy Loading)
}
// 총 1 + N번의 쿼리 실행!
Java
복사
해결 방법: Fetch Join
@Query("SELECT gr FROM GameRoom gr JOIN FETCH gr.worldType")
List<GameRoom> findAllWithWorldType();
// 한 번의 쿼리로 WorldType까지 함께 조회
Java
복사
NoSQL 데이터베이스 - MongoDB
•
AI 채팅 메시지 저장
package org.com.dungeontalk.domain.aichat.entity;
import lombok.*;
import org.com.dungeontalk.domain.aichat.common.AiMessageType;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
/**
* AI 게임 메시지를 저장하는 MongoDB 문서 엔티티
*
* 기존 ChatMessage와 분리하여 턴제 게임 메시지 및 AI 응답 히스토리 관리
*/
@Document(collection = "ai_game_messages")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder(toBuilder = true)
public class AiGameMessage {
@Id
private String id;
private String aiGameRoomId; // 메시지가 속한 AI 게임방 ID
private String gameId; //연결된 게임 ID (조회 성능을 위한 중복 저장)
private String senderId; //메시지 발신자 ID
private String senderNickname; // 발신자 닉네임 (UI 표시용)
private String content; // 메시지 내용
private AiMessageType messageType; // 메시지 타입 (USER, AI, SYSTEM, TURN_START, TURN_END)
private int turnNumber; //메시지가 속한 턴 번호 (1부터 시작) 파이썬에게 보낼때 활용
private int messageOrder; //메시지 순서 (같은 턴 내에서의 순서)
@CreatedDate
private Instant createdAt;
/**
* AI 메시지인지 확인
*/
public boolean isAiMessage() {
return this.messageType == AiMessageType.AI;
}
/**
* 사용자 메시지인지 확인
*/
public boolean isUserMessage() {
return this.messageType == AiMessageType.USER;
}
/**
* 시스템 메시지인지 확인
*/
public boolean isSystemMessage() {
return this.messageType == AiMessageType.SYSTEM ||
this.messageType == AiMessageType.TURN_START ||
this.messageType == AiMessageType.TURN_END;
}
}
Java
복사
4. 분산 시스템 (Distributed System)
메시지 큐 (Message Queue) - Kafka
•
Pub/Sub 패턴
•
Producer - 메시지 발행
@Component
@RequiredArgsConstructor
public class KafkaPublisher {
private final KafkaTemplate<String, Object> kafkaTemplate;
@Value("${kafka.topics.chat.regular}")
private String chatTopic;
@Async("chatKafkaExecutor")
public CompletableFuture<Void> publishChatAsync(String roomId, Object message) {
return kafkaTemplate.send(chatTopic, roomId, message)
.thenAccept(result -> {
log.debug("Kafka 메시지 발행 완료: partition={}, offset={}",
result.getRecordMetadata().partition(),
result.getRecordMetadata().offset());
})
.exceptionally(ex -> {
log.error("Kafka 메시지 발행 실패: error={}", ex.getMessage());
return null;
});
}
}
Java
복사
•
Consumer - 메시지 수신
@Service
@RequiredArgsConstructor
public class KafkaSubscriber {
private final SimpMessageSendingOperations messagingTemplate;
@KafkaListener(
topics = "${kafka.topics.chat.regular}",
groupId = "${spring.kafka.consumer.group-id}"
)
public void consumeChat(ConsumerRecord<String, Object> record) {
String roomId = record.key();
Object message = record.value();
try {
// WebSocket으로 브로드캐스트
String destination = "/sub/chat/room/" + roomId;
messagingTemplate.convertAndSend(destination, message);
log.debug("WebSocket 브로드캐스트 완료: roomId={}", roomId);
} catch (Exception e) {
log.error("메시지 처리 실패: roomId={}, error={}", roomId, e.getMessage());
// 에러 발생 시 로깅만 하고 계속 진행 (메시지 손실 방지)
}
}
}
Java
복사
•
파티셔닝 (Partitioning)
Topic: dungeontalk.chat.regular (Partition 3개)
roomId=1 (hash % 3 = 1) → Partition 1
roomId=2 (hash % 3 = 2) → Partition 2
roomId=3 (hash % 3 = 0) → Partition 0
→ 같은 방의 메시지는 항상 같은 파티션으로 전송
→ 파티션 내에서 순서 보장
Markdown
복사
Redis Pub/Sub 에서 Kafka로 마이그레이션
항목 | Redis Pub/Sub | Kafka |
메시지 지속성 | ||
메시지 재처리 | ||
처리량 | ~5K msg/s | ~50K+ msg/s |
순서 보장 | ||
구독자 없을 때 | 메시지 손실 | 메시지 보존 |
5. 보안 (Security)
인증과 인가 (Authentication & Authorization)
•
BruteForce 방어
@Service
@RequiredArgsConstructor
public class AuthService {
private final BruteForceManager bruteForceManager;
private final ActualLoginManager actualLoginManager;
public TokenResponse login(AuthLoginRequest request, HttpServletRequest httpServletRequest)
throws InterruptedException {
// 로그인 시도 전 이상 행동 체크
bruteForceManager.preCheck(request.name(), httpServletRequest);
try {
TokenResponse tokenResponse = actualLogin(request);
bruteForceManager.loginSucceeded(request.name()); // 실패 기록 삭제
return tokenResponse;
} catch (MemberException ex) {
bruteForceManager.loginFailed(request.name()); // Delay, Cool Down 방어
throw ex;
}
}
public TokenResponse actualLogin(AuthLoginRequest request) {
Member member = actualLoginManager.validateMember(request);
JwtTokenResponse jwtTokenResponse = actualLoginManager.generateToken(member);
actualLoginManager.updateMemberRefreshToken(member, jwtTokenResponse);
return new TokenResponse(
jwtTokenResponse.getAccessToken(),
jwtTokenResponse.getRefreshToken()
);
}
}
Java
복사
•
Refresh Token Rotation (RTR)
public JwtTokenResponse refreshAccessToken(String refreshToken) {
rtrManager.validateRefreshToken(refreshToken);
Auth auth = rtrManager.findAuthByRefreshToken(refreshToken);
String newAccessToken = rtrManager.generateNewAccessToken(auth);
String newRefreshToken = rtrManager.generateNewRefreshToken(auth);
// 새로운 JWT 적용 (Redis + DB 업데이트)
rtrManager.applyNewRefreshToken(auth, newRefreshToken);
return new JwtTokenResponse(newAccessToken, newRefreshToken);
}
Java
복사
Spring Security 설정
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private static final String[] PUBLIC_API_URLS = {
"/v1/member/register",
"/v1/auth/login",
"/v1/auth/refresh",
"/v1/worlds",
"/ws-chat/**"
};
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http,
JwtAuthenticationFilter jwtAuthenticationFilter)
throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(
CorsConfig.corsConfigurationSource()))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers(PUBLIC_API_URLS).permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Java
복사
6. 소프트웨어 엔지니어링 (Software Engineering)
레이어별 아키텍처
┌─────────────────────────────────────┐
│ Controller Layer │ ← @RestController, @MessageMapping
│ (ChatStompController.java) │ 요청 받고 응답 반환
├─────────────────────────────────────┤
│ Service Layer │ ← @Service
│ (ChatMessageService.java) │ 비즈니스 로직 처리
├─────────────────────────────────────┤
│ Repository Layer │ ← @Repository
│ (ChatMessageRepository.java) │ 데이터 접근
├─────────────────────────────────────┤
│ Database Layer │ ← PostgreSQL, MongoDB, Valkey
│ (Databases) │ 데이터 저장
└─────────────────────────────────────┘
Markdown
복사

