Backend
home
📏

DungeonTalk 데이터베이스 아키텍처

생성 일시
2025/10/20 12:19
태그
Database
게시일
2025/10/20
최종 편집 일시
2025/10/20 14:43

DungeonTalk 데이터베이스 아키텍처:

DungeonTalk 프로젝트 링크

사용한 데이터베이스

PostgreSQL, MongoDB, Redis(Valkey)

왜 여러 데이터베이스를 사용했는가?

요구사항
선택한 DB
선택 이유
사용자, 인증 정보
PostgreSQL
ACID 트랜잭션, 강한 일관성
채팅 메시지 (수백만 건 - 사용량 많음)
MongoDB
수평 확장, 스키마 유연성
세션 관리, 캐싱
Redis/Valkey
빠른 조회, TTL 자동 만료

PostgreSQL: 관계형 데이터베이스의 핵심

1. 아키텍처 개요

PostgreSQL은 DungeonTalk의 마스터 데이터를 저장한다.
사용자 정보 (Member)
인증 정보 (Auth)
게임 캐릭터 (GameCharacter)
종족별 스탯 (RaceStats)
연결 풀: HikariCP
# application-dev.properties # HikariCP Connection Pool Settings (Performance Optimization) spring.datasource.hikari.maximum-pool-size=50 spring.datasource.hikari.minimum-idle=10 spring.datasource.hikari.connection-timeout=10000 spring.datasource.hikari.idle-timeout=300000 spring.datasource.hikari.max-lifetime=600000 spring.datasource.hikari.pool-name=DungeonTalkHikariCP
YAML
복사
Hikari 설정 설명:
설정
의미
maximum-pool-size
50
최대 연결 수 (부하 테스트 기준 최적값)
minimum-idle
10
항상 유지할 대기 연결 수
connection-timeout
10000ms
연결 획득 최대 대기 시간
idle-timeout
300000ms (5분)
유휴 연결 유지 시간
max-lifetime
600000ms (10분)
연결 최대 생존 시간

2. 엔티티 설계: BaseEntity와 UUID v7

BaseEntity - 공통 필드 관리
@MappedSuperclass @EntityListeners(AuditingEntityListener.class) public class BaseEntity { @Id private String id; // UUIDv7 자동 생성 @CreatedDate private Instant createdAt; @LastModifiedDate private Instant updatedAt; @PrePersist public void generateIdIfNull() { if (this.id == null || this.id.isBlank()) { this.id = UuidV7Creator.create(); // UUIDv7 생성 } } }
Java
복사
UUIDv7을 선택한 이유:
방식
장점
단점
DungeonTalk 적용
Auto Increment
간단, 순차적
분산 환경 충돌
UUIDv4
완전 랜덤
인덱스 성능 저하
UUIDv7
시간 순서 보장, 인덱스 효율
-
 선택
Snowflake
순차적, 빠름
중앙 서버 필요
UUIDv7 구조:
unix_ts_ms (48비트) + rand_a (12비트) + rand_b (62비트) └─ 시간 기반 정렬 가능 ─┘ └─ 충돌 방지 랜덤 ─┘ 예시: 018d3f5a-3c7e-7000-8000-123456789abc └─ 2025-10-17 10:30:00 UTC ─┘
Java
복사
효과:
MongoDB와 PostgreSQL 모두 시간 순서 정렬에 효율적
분산 환경에서 중앙 ID 서버 없이도 충돌 방지 가능
created_at 인덱스 없이도 시간 순서 조회 가능

3. 엔티티 관계 매핑

Member 엔티티
@Entity @Table(name = "member") public class Member extends BaseEntity { @Column(name = "password", nullable = false) private String password; // BCrypt 암호화 @Column(name = "name", length = 20, unique = true) private String name; // 로그인 ID @Column(name = "nick_name", unique = true) private String nickName; // 게임 내 닉네임 }
Java
복사
Auth 엔티티 - ManyToOne 관계
@Entity @Table(name = "auth") public class Auth extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member member; @Column(name = "email", length = 50) private String email; @Column(name = "access_token", columnDefinition = "text") private String accessToken; @Column(name = "refresh_token", columnDefinition = "text") private String refreshToken; }
Java
복사
FetchType.LAZY의 중요성:
// ❌ EAGER Loading (기본값) - N+1 문제 발생 List<Auth> authList = authRepository.findAll(); // SELECT * FROM auth for (Auth auth : authList) { auth.getMember().getName(); // SELECT * FROM member WHERE id = ? (N번 실행!) } // ✅ LAZY Loading + Fetch Join - 1번의 쿼리로 해결 @Query("SELECT a FROM Auth a JOIN FETCH a.member") List<Auth> findAllWithMember(); // SELECT * FROM auth JOIN member ...
Java
복사
관계 매핑 시 참고해야 할 사항
1. 기본값을 LAZY로 설정 2. 필요한 경우에만 Fetch Join 사용 3. @EntityGraph로 동적 로딩 제어 연관 관계의 기본 FetchType: - @ManyToOne, @OneToOne: EAGER (기본값 위험!) - @OneToMany, @ManyToMany: LAZY → 모든 관계를 LAZY로 명시하는 것이 안전
Markdown
복사
GameCharacter 엔티티 - OneToOne 관계
@Entity @Table(name = "game_character") public class GameCharacter extends BaseEntity { @Column(name = "member_id") private String memberId; @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", referencedColumnName = "id", insertable = false, updatable = false) private Member member; @Column(name = "race_id") private String raceId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "race_id", referencedColumnName = "id", insertable = false, updatable = false) private RaceStats raceStats; // 스탯 필드들 private Integer strength; private Integer willpower; private Integer intelligence; private Integer wisdom; private Integer dexterity; private Integer luck; }
Java
복사
insertable=false, updatable=false의 의미:
읽기 전용 연관 관계에 한해서는 INSERT와 UPDATE가 실행되지 않도록 설정
-- 문제 상황: 두 개의 필드가 같은 컬럼을 참조 CREATE TABLE game_character ( id VARCHAR(255), member_id VARCHAR(255), -- ← 이 컬럼을 ... ); -- member_id 컬럼을 두 곳에서 사용 1. private String memberId; -- 직접 관리 (INSERT/UPDATE) 2. private Member member; -- 읽기 전용 (JOIN) -- insertable=false, updatable=false: -- "이 연관 관계는 읽기 전용입니다. INSERT/UPDATE 시 무시하세요"
Markdown
복사
예시
// ✅ 올바른 사용법 GameCharacter character = new GameCharacter(); character.setMemberId("member-123"); // FK 직접 설정 characterRepository.save(character); Member member = character.getMember(); // LAZY 로딩으로 Member 조회 (읽기 전용) // ❌ 잘못된 사용법 character.setMember(member); // insertable=false이므로 DB에 반영 안 됨!
Java
복사

4. JPA 쿼리 최적화

N+1 문제 해결 사례
문제 코드:
// 1번의 쿼리로 GameCharacter 조회 List<GameCharacter> characters = characterRepository.findAll(); // N번의 추가 쿼리 발생! for (GameCharacter character : characters) { String raceName = character.getRaceStats().getName(); // SELECT * FROM race_stats WHERE id = ? } // 총 쿼리 수: 1 + N (100명이면 101번!)
Java
복사
해결 방법 1: Fetch Join
@Query("SELECT gc FROM GameCharacter gc JOIN FETCH gc.raceStats") List<GameCharacter> findAllWithRaceStats(); // 생성되는 SQL: SELECT gc.*, rs.* FROM game_character gc INNER JOIN race_stats rs ON gc.race_id = rs.id
Java
복사
해결 방법 2: @EntityGraph
@EntityGraph(attributePaths = {"member", "raceStats"}) List<GameCharacter> findAll(); // 두 개의 연관 관계를 한 번에 로딩
Java
복사
성능 비교
Before (N+1 발생): - 쿼리 수: 101(100명 조회 시) - 응답 시간: ~500ms After (Fetch Join): - 쿼리 수: 1- 응답 시간: ~50ms → 90% 성능 향상!
Java
복사

MongoDB: 대용량 메시지 저장소

1. 왜 MongoDB를 선택했는가?

채팅 메시지의 특성:
대량 데이터 (일 수십만 ~ 수백만 건)
대용량, 입력/저장이 빈번한 데이터
읽기 중심 (Write Once, Read Many)
시간 역순 정렬 조회 빈번
스키마 유연성 필요 (메시지 타입별 추가 필드)
PostgreSQL vs MongoDB 비교:
항목
PostgreSQL
MongoDB
수평 확장
어려움 (Sharding 복잡)
쉬움 (Built-in Sharding)
시간 역순 정렬
인덱스 필수
Timestamp 기반 효율적
스키마 변경
ALTER TABLE 비용 높음
유연함

2. ChatMessage

@Document(collection = "chat_messages") @CompoundIndex(name = "room_created_idx", def = "{'roomId': 1, 'createdAt': 1}") public class ChatMessage { @Id private String messageId; // UUIDv7 @Indexed private String roomId; // 채팅방 ID (파티셔닝 키 후보) private String senderId; private String receiverId; private String content; private MessageType type; // JOIN, TALK, LEAVE @CreatedDate private Instant createdAt; @LastModifiedDate private Instant updatedAt; }
Java
복사
인덱스 전략:
// 1. 단일 필드 인덱스 db.chat_messages.createIndex({ "roomId": 1 }) // 2. 복합 인덱스 (쿼리 최적화) db.chat_messages.createIndex({ "roomId": 1, "createdAt": 1 }) // 실제 쿼리: db.chat_messages.find({ roomId: "room-123" }) .sort({ createdAt: -1 }) // 인덱스로 정렬 커버 .limit(50)
Java
복사
인덱스 선택 기준
자주 조회되는 필드: roomId
정렬 필드: createdAt
복합 인덱스 순서: 등호(’=’) 조건 → 범위 조건 → 정렬
왜 (roomId, createdAt) 순서인가?
roomId: 등호 조건 (=)
createdAt: 정렬 조건 (ORDER BY)
인덱스 스캔으로 정렬까지 해결 (Sort 연산 제거)

3. ChatRoomMember - 멤버 상태 관리

@Document(collection = "chat_room_members") public class ChatRoomMember { @Id private String id; @Indexed private String roomId; @Indexed private String memberId; @Enumerated(EnumType.STRING) private Status status; // ONLINE, OFFLINE private Instant joinedAt; private Instant lastSeenAt; }
Java
복사
왜 PostgreSQL이 아닌 MongoDB에 저장하는가?
측면
이유
데이터 양
누적 참여 이력 (수백만 건)
쿼리 패턴
roomId 기반 조회 → Sharding 효율적
일관성 요구
최종 일관성으로 충분 (실시간 세션은 Redis 사용)
확장성
채팅방 증가에 따라 수평 확장

Redis/Valkey: 세션과 캐시의 이중 전략

0. Valkey 참고

구분
Valkey
Redis
핵심 개발 방향
성능 최적화 (멀티스레딩, RDMA 지원, 메모리 효율성 등)
AI, 편의 기능(VectorSet, AutoComplete 등) 추가
라이선스
BSD 라이선스
SPL 라이선스 (2024년 3월 변경)
성능
멀티스레딩 도입으로 인한 높은 성능과 낮은 지연 시간
8.0 버전 이후 멀티스레딩 지원이 강화되었으나, 초기 Valkey는 더 나은 성능을 보여주었음
호환성
Redis 7.2.x와 완벽하게 호환
Valkey와 같은 오픈소스 버전은 호환성을 유지하나, Redis 8.0 이후로는 일부 기능에서 차이 발생 가능
커뮤니티
Linux Foundation이 후원하는 빠른 성장의 오픈 소스 커뮤니티
기존의 방대한 사용자 기반
참고링크:

1. 왜 Redis 인스턴스를 2개 사용하는가?

DungeonTalk는 Session용과 Cache용 Valkey(Redis 호환)를 분리 운영하고 있다.
# Session Valkey - ElastiCache spring.session.store-type=${SPRING_SESSION_STORE_TYPE} spring.data.session.host=${SPRING_DATA_SESSION_HOST} spring.data.session.port=${SPRING_DATA_SESSION_PORT} # Cache Valkey - EC2 로컬 spring.cache.type=${SPRING_CACHE_TYPE} spring.redis.cache.host=${SPRING_REDIS_CACHE_HOST} spring.redis.cache.port=${SPRING_REDIS_CACHE_PORT}
YAML
복사
분리 이유:
항목
Session Valkey
Cache Valkey
용도
세션 관리, 실시간 데이터
조회 결과 캐싱
데이터 특성
휘발성 높음
상대적으로 안정적
TTL
짧음 (30분)
길 수 있음 (1시간+)
트래픽 패턴
매 요청 (읽기/쓰기 균형)
캐시 미스 시만 쓰기
장애 영향
치명적 (세션 유실)
제한적 (DB 폴백)

2. Lettuce Connection Pool 설정

# Lettuce Connection Pool Settings (Performance Optimization) spring.data.redis.lettuce.pool.max-active=50 spring.data.redis.lettuce.pool.max-idle=20 spring.data.redis.lettuce.pool.min-idle=10 spring.data.redis.lettuce.pool.max-wait=3000ms spring.data.redis.timeout=5000ms
YAML
복사

3. ValkeyConfig - 이중 Redis 설정

@Configuration public class ValkeyConfig { @Value("${spring.data.session.host}") private String sessionRedisHost; @Value("${spring.redis.cache.host}") private String cacheRedisHost; // Session Redis 연결 팩토리 @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)); if (sessionRedisSslEnabled) { builder.useSsl(); // AWS ElastiCache SSL 지원 } return new LettuceConnectionFactory(config, builder.build()); } // Cache Redis 연결 팩토리 @Bean(name = "cacheRedisConnectionFactory") public RedisConnectionFactory cacheRedisConnectionFactory() { RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); config.setHostName(cacheRedisHost); config.setPort(cacheRedisPort); return new LettuceConnectionFactory(config); } // Session용 RedisTemplate @Bean(name = "sessionRedisTemplate") public RedisTemplate<String, String> sessionRedisTemplate( @Qualifier("sessionRedisConnectionFactory") RedisConnectionFactory factory) { RedisTemplate<String, String> template = new RedisTemplate<>(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new StringRedisSerializer()); return template; } }
Java
복사
@Qualifier의 중요성:
// ❌ 여러 Bean이 있을 때 자동 주입 실패 @Autowired private RedisConnectionFactory connectionFactory; // 어떤 Factory를 주입할지 모호! // ✅ @Qualifier로 명시 @Autowired @Qualifier("sessionRedisConnectionFactory") private RedisConnectionFactory connectionFactory;
Java
복사

4. 세션 관리: TTL 기반 자동 만료

@Service @RequiredArgsConstructor public class ChatSessionService { private final ValkeyService valkeyService; private final ChatRoomRepository chatRoomRepository; private final ObjectMapper objectMapper; // ChatConstants에 정의된 상수 private static final int DEFAULT_SESSION_TIMEOUT_SECONDS = 1800; // 30분 /** * 세션 시작 (채팅방 입장) */ @Transactional public ChatSessionDto startSession(String roomId, String memberId, String nickname, String websocketSessionId) { log.info("채팅 세션 시작 요청: roomId={}, memberId={}, nickname={}", roomId, memberId, nickname); // 채팅방 존재 확인 ChatRoom room = chatRoomRepository.findById(roomId) .orElseThrow(() -> new ChatException(ErrorCode.CHAT_ROOM_NOT_FOUND, "roomId=" + roomId)); Instant now = Instant.now(); ChatSessionDto sessionDto = ChatSessionDto.builder() .roomId(roomId) .memberId(memberId) .nickname(nickname) .status(Status.ONLINE) .joinedAt(now) .lastActivity(now) .websocketSessionId(websocketSessionId) .build(); String sessionKey = buildSessionKey(roomId, memberId); // "chat:session:{roomId}:{memberId}" String sessionData = serializeSessionData(sessionDto); log.info("Valkey 세션 저장: sessionKey={}, timeout={}초", sessionKey, DEFAULT_SESSION_TIMEOUT_SECONDS); valkeyService.setWithExpiration(sessionKey, sessionData, DEFAULT_SESSION_TIMEOUT_SECONDS); return sessionDto; } /** * 세션 연장 (활동 시간 갱신) */ public void extendSession(String roomId, String memberId) { String sessionKey = buildSessionKey(roomId, memberId); if (!valkeyService.exists(sessionKey)) { log.warn("세션 연장 실패 - 세션이 존재하지 않음: roomId={}, memberId={}", roomId, memberId); return; } try { // 기존 세션 데이터 조회 String sessionData = valkeyService.get(sessionKey); ChatSessionDto sessionDto = objectMapper.readValue(sessionData, ChatSessionDto.class); // 마지막 활동 시간 갱신 ChatSessionDto updatedSession = sessionDto.toBuilder() .lastActivity(Instant.now()) .build(); // Valkey에 갱신된 세션 저장 및 TTL 연장 valkeyService.setWithExpiration(sessionKey, serializeSessionData(updatedSession), DEFAULT_SESSION_TIMEOUT_SECONDS); log.debug("채팅 세션 연장: roomId={}, memberId={}", roomId, memberId); } catch (JsonProcessingException e) { log.error("세션 데이터 역직렬화 실패: roomId={}, memberId={}", roomId, memberId, e); } } /** * Heartbeat 처리 (세션 유지) */ public boolean heartbeat(String roomId, String memberId) { String sessionKey = buildSessionKey(roomId, memberId); if (!valkeyService.exists(sessionKey)) { log.warn("Heartbeat 실패 - 세션이 존재하지 않음: roomId={}, memberId={}", roomId, memberId); return false; } // TTL만 연장 (데이터 재전송 없이 효율적) valkeyService.expire(sessionKey, DEFAULT_SESSION_TIMEOUT_SECONDS); log.debug("Heartbeat 처리: roomId={}, memberId={}", roomId, memberId); return true; } private String buildSessionKey(String roomId, String memberId) { return CHAT_SESSION_PREFIX + roomId + ":" + memberId; // "chat:session:" } }
Java
복사
TTL 전략의 장점
1.
메모리 누수방지
문제: WebSocket 비정상 종료 시 세션 정리 안 됨 해결: 30분 후 Redis가 자동 삭제 결과: 메모리 누수 없음, 명시적 정리 코드 불필요
Markdown
복사
2.
세션 유효성 검증
// 매번 DB 조회 없이 Redis 존재 여부로 판단 if (!valkeyService.exists(sessionKey)) { throw new SessionExpiredException("세션이 만료되었습니다."); }
Java
복사
3.
성능 최적화
Heartbeat 처리: Before: Session 데이터 전체 재전송 (1KB × 1000명 = 1MB/30초) After: EXPIRE 명령만 전송 (1B × 1000명 = 1KB/30초) → 네트워크 트래픽 감소
Markdown
복사

5. Caffeine 로컬 캐시 - 마스터 데이터 최적화

@Configuration @EnableCaching public class CaffeineCacheConfig { @Bean @Primary public CacheManager caffeineCacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager("worldTypes", "chatRooms"); cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(100) // 최대 100개 항목 .expireAfterWrite(1, TimeUnit.HOURS) // 1시간 후 만료 .recordStats()); // 캐시 통계 수집 return cacheManager; } }
Java
복사
Redis Cache vs Caffeine Cache
항목
Redis Cache
Caffeine Cache
저장 위치
Redis 서버
JVM 힙 메모리
네트워크
필요 (1-5ms)
불필요
성능
~1ms
~1μs (마이크로초)
데이터 공유
여러 인스턴스 간 공유
인스턴스별 독립
적합한 데이터
자주 변경되는 데이터
변경 없는 마스터 데이터
적용 사례
/** * 모든 활성화된 WorldType 엔티티 조회 (내부 서비스 간 호출용) * Caffeine 로컬 캐시 적용: 1시간 동안 메모리에 캐시 유지 */ @Cacheable(value = "worldTypes", key = "'activeWorldTypes'") public List<WorldType> findAllActiveWorldTypeEntities() { log.info("DB에서 활성화된 세계관 목록 조회 (캐시 미스)"); return worldTypeRepository.findActiveWorldTypesOrderBySortOrder(); } // 실제 Repository 쿼리: @Query("SELECT w FROM WorldType w WHERE w.isActive = true ORDER BY w.sortOrder ASC, w.id ASC") List<WorldType> findActiveWorldTypesOrderBySortOrder(); // 성능 비교: Before (DB 직접 조회): - 응답 시간: ~50ms - 동시 요청 시 DB 부하 증가 After (Caffeine Cache): - 응답 시간: ~0.1ms (500배 향상) - 서버 시작 후 1번만 DB 조회
Java
복사
Cache Stampede 문제
// 문제 상황 (docs/worldtypes-query-optimization.md 참고) // 서버 시작 시 여러 스레드가 동시에 DB 조회 public List<WorldType> getAllActiveWorldTypes() { if (cachedActiveWorldTypes == null || isExpired()) { // 여러 스레드가 동시에 이 체크를 통과! cachedActiveWorldTypes = worldTypeRepository.findActiveWorldTypesOrderBySortOrder(); // DB 쿼리 3번 실행됨! } return cachedActiveWorldTypes; } // 해결: Caffeine 캐시 사용 @Cacheable(value = "worldTypes", key = "'activeWorldTypes'") public List<WorldType> findAllActiveWorldTypeEntities() { log.info("DB에서 활성화된 세계관 목록 조회 (캐시 미스)"); // Caffeine이 내부적으로 동기화 처리 // 첫 요청만 DB 조회, 나머지는 대기 후 캐시 반환 return worldTypeRepository.findActiveWorldTypesOrderBySortOrder(); }
Java
복사