문제 정의
기술적 의사결정 배경
FitPass 플랫폼에서 사용자(User)와 체육관(Gym) 간의 실시간 1:1 상담 기능이 필요했습니다. 사용자들이 체육관에 대한 문의사항, 운영 시간, 프로그램 정보 등을 실시간으로 소통할 수 있는 채팅 시스템의 도입이 요구되었습니다.
핵심 요구사항
- 실시간 메시징: 사용자와 체육관 간 즉시 메시지 전달
- 1:1 매칭: 각 사용자-체육관 쌍별로 독립적인 채팅방 생성
- 메시지 영속성: 채팅 내역 저장 및 조회 기능
- 연결 상태 관리: 접속/퇴장 이벤트 처리
- 확장성: 다수의 동시 접속자 처리
- 사용자 경험: 빠른 응답성과 직관적인 인터페이스
기술 스택 비교 분석
1. 실시간 통신 방식 선택
HTTP Polling vs WebSocket vs Server-Sent Events
방식 실시간성 리소스 효율성 구현 복잡도 양방향 통신
HTTP Polling | ❌ 낮음 | ❌ 낮음 | ✅ 단순 | ✅ 가능 |
Server-Sent Events | ⚠️ 중간 | ⚠️ 중간 | ⚠️ 중간 | ❌ 단방향 |
WebSocket | ✅ 높음 | ✅ 높음 | ⚠️ 중간 | ✅ 양방향 |
선택 결과: WebSocket (STOMP 프로토콜)
선택 이유
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.setApplicationDestinationPrefixes("/app");
registry.enableSimpleBroker("/topic", "/queue");
registry.setUserDestinationPrefix("/user");
}
}
- 진정한 실시간성: TCP 연결 기반으로 즉시 메시지 전달
- 리소스 효율성: 연결 유지로 불필요한 HTTP 헤더 오버헤드 제거
- 양방향 통신: 클라이언트-서버 간 자유로운 메시지 교환
- STOMP 프로토콜: 구조화된 메시징으로 채팅방별 라우팅 지원
2. 메시지 브로커 아키텍처
내장 브로커 vs 외부 브로커 (Redis/RabbitMQ)
선택: Spring 내장 브로커
// 현재 구현
registry.enableSimpleBroker("/topic", "/queue");
// vs 외부 브로커 옵션
// registry.enableStompBrokerRelay("/topic", "/queue")
// .setRelayHost("localhost")
// .setRelayPort(61613);
선택 근거
내장 브로커 장점:
- 단순한 아키텍처로 개발/운영 복잡도 감소
- 외부 의존성 없음 (Redis/RabbitMQ 불필요)
- 초기 서비스 단계에 적합한 성능
- 메모리 기반으로 빠른 메시지 처리
확장성 고려사항:
// 향후 확장 시 손쉬운 마이그레이션 가능
@Profile("production")
@Configuration
public class ProductionWebSocketConfig {
// Redis 기반 브로커로 전환 가능
}
3. 데이터베이스 설계 전략
채팅방 구조
@Entity
@Table(name = "chatRooms")
public class ChatRoom extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "gym_id", nullable = false)
private Gym gym;
}
메시지 구조
@Entity
@Table(name = "chatMessage")
public class ChatMessage extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "chatRoomId")
private ChatRoom chatRoom;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private SenderType senderType; // USER 또는 GYM
@Column(nullable = false)
private String content;
}
설계 특징
- 1:1 관계 보장: User-Gym 쌍별로 유니크한 채팅방
- SenderType 구분: 메시지 발신자 타입으로 UI 렌더링 최적화
- Lazy Loading: 성능 최적화를 위한 지연 로딩
- BaseEntity 상속: 생성/수정 시간 자동 관리
4. 메시지 라우팅 전략
글로벌 vs 개별 라우팅
현재 구현 (하이브리드 방식):
@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public") // 글로벌 브로드캐스트
public ChatMessageResponseDto sendMessage(@Payload ChatMessageRequestDto request) {
// 메시지 저장 후
ChatMessage chatMessage = chatMessageRepository.save(message);
// 개별 사용자에게 직접 전송
String receiverDestination = "/user/" + receiverId + "/queue/messages";
messagingTemplate.convertAndSend(receiverDestination, responseDto);
return responseDto;
}
이중 전송 방식의 장점
- 호환성: 기존 클라이언트와의 호환성 유지
- 안정성: 다중 경로로 메시지 전달 보장
- 확장성: 향후 그룹 채팅 등으로 확장 가능
5. 동시성 및 성능 최적화
메시지 처리 동시성
@MessageMapping("/chat.sendMessage")
public ChatMessageResponseDto sendMessage(@Payload ChatMessageRequestDto request) {
try {
// 채팅방 존재 확인 또는 생성
ChatRoom room = chatRoomRepository.findByUserAndGym(userId, gymId)
.orElseGet(() -> {
ChatRoom newRoom = ChatRoom.of(user, gym);
return chatRoomRepository.save(newRoom);
});
// 메시지 저장
ChatMessage chatMessage = ChatMessage.of(room, content, senderType);
chatMessage = chatMessageRepository.save(chatMessage);
} catch (Exception e) {
log.error("메시지 처리 중 오류 발생: {}", e.getMessage());
throw new RuntimeException("메시지 처리 중 오류가 발생했습니다: " + e.getMessage());
}
}
성능 최적화 전략
- 쿼리 최적화:
// ID 기반 조회로 성능 향상
@Query("SELECT cr FROM ChatRoom cr WHERE cr.user.id = :userId AND cr.gym.id = :gymId")
Optional<ChatRoom> findByUserAndGym(@Param("userId") Long userId, @Param("gymId") Long gymId);
- 배치 조회:
// 마지막 메시지와 함께 채팅방 목록 조회
public static ChatRoomResponseDto from(ChatRoom chatRoom, ChatMessage lastMessage) {
return new ChatRoomResponseDto(
chatRoom.getId(),
chatRoom.getUser().getId(),
chatRoom.getGym().getId(),
lastMessage != null ? lastMessage.getContent() : null,
lastMessage != null ? lastMessage.getSenderType() : null
);
}
6. 에러 처리 및 안정성
연결 상태 관리
@Component
public class WebSocketEventListener {
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String userId = (String) headerAccessor.getSessionAttributes().get("userId");
if (userId != null) {
log.info("사용자 {} 퇴장", userId);
// 퇴장 메시지 브로드캐스트
ChatMessageResponseDto chatMessage = new ChatMessageResponseDto(
"LEAVE", null, Long.parseLong(userId),
SenderType.valueOf(userType), "채팅방을 나갔습니다.", LocalDateTime.now()
);
messagingTemplate.convertAndSend("/topic/public", chatMessage);
}
}
}
예외 처리 전략
- 메시지 레벨: 개별 메시지 실패 시 사용자에게 재전송 안내
- 연결 레벨: WebSocket 연결 끊김 시 자동 재연결 시도
- 데이터 레벨: 엔티티 조회 실패 시 명확한 에러 메시지 제공
7. 보안 및 인증
CORS 설정
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOriginPatterns(
"http://localhost:5173",
"http://127.0.0.1:5500",
"https://www.fitpass-13.com",
"https://fitpass-13.com"
)
.withSockJS();
}
향후 보안 강화 계획
- JWT 토큰 기반 인증: WebSocket 헤더를 통한 사용자 검증
- 메시지 암호화: 민감한 정보 전송 시 암호화 적용
- Rate Limiting: 스팸 메시지 방지를 위한 전송 속도 제한
구현 결과 및 성과
핵심 기능 달성
✅ 1:1 매칭: User-Gym 쌍별 독립 채팅방 완벽 구현
✅ 메시지 영속성: 모든 채팅 내역 DB 저장 및 조회
✅ 연결 관리: 접속/퇴장 이벤트 자동 처리
사용자 경험 개선
// 직관적인 메시지 구조
public record ChatMessageResponseDto(
String type, // MESSAGE, JOIN, LEAVE
Long id, // 메시지 ID
Long senderId, // 발신자 ID
SenderType senderType, // USER or GYM
String content, // 메시지 내용
LocalDateTime createdAt // 전송 시간
) {
public static ChatMessageResponseDto from(ChatMessage entity) {
return new ChatMessageResponseDto(
"MESSAGE",
entity.getId(),
entity.getSenderType() == SenderType.USER
? entity.getChatRoom().getUser().getId()
: entity.getChatRoom().getGym().getId(),
entity.getSenderType(),
entity.getContent(),
entity.getCreatedAt()
);
}
}
향후 확장 계획
1. 확장성 개선
// Redis 기반 분산 메시지 브로커로 전환
@Configuration
@Profile("production")
public class ScalableWebSocketConfig {
@Bean
public ReactiveRedisTemplate<String, Object> redisTemplate() {
// Redis Pub/Sub 기반 멀티 서버 지원
}
}
2. 기능 확장
- 파일 전송: 이미지, 문서 첨부 기능
- 읽음 확인: 메시지 읽음 상태 표시
- 푸시 알림: 오프라인 사용자 알림 발송
- 메시지 검색: 채팅 내역 전문 검색
3. 모니터링 강화
@Component
public class ChatMetrics {
private final MeterRegistry meterRegistry;
public void recordMessageSent(String senderType) {
Counter.builder("chat.messages.sent")
.tag("sender", senderType)
.register(meterRegistry)
.increment();
}
}
교훈 및 인사이트
성공 요인
- 단순한 아키텍처: 초기 단계에서는 복잡성보다 안정성 우선
- 표준 프로토콜 활용: STOMP를 통한 구조화된 메시징
- 점진적 확장: 내장 브로커에서 시작하여 필요시 외부 브로커로 전환 가능
주요 학습 내용
- WebSocket vs HTTP: 실시간 요구사항에서는 WebSocket이 압도적 우위
- 내장 vs 외부 브로커: 초기 단계에서는 내장 브로커로 충분한 성능
- 메시지 라우팅: 글로벌 + 개별 전송의 하이브리드 방식이 효과적
- 동시성 제어: 채팅 시스템에서는 메시지 순서보다 전달 보장이 중요
기술적 의사결정의 핵심
"완벽한 기술보다는 비즈니스 요구사항에 적합한 기술을 선택하되, 향후 확장 가능성을 열어두는 것이 중요하다"
FitPass 채팅 시스템은 사용자와 체육관 간의 원활한 소통을 위한 실용적이고 확장 가능한 솔루션으로 구현되었으며, 향후 서비스 성장에 따른 기술적 확장도 유연하게 대응할 수 있는 기반을 마련했습니다.