현재 진행 중인 ‘스킨 판매 플랫폼’에서는 판매자에게 문의할 때 사용되는 채팅이 모두 MySQL에 저장된다.
하지만 채팅이라는 기능의 특성을 생각해 봤을 때, 자주 INSERT 작업이 실행되는 기능이다.
그렇기 때문에 MySQL은 적합하지 않을 수 있다.
이 과정에서 고려한 두 가지 방안은 다음과 같다.
- Redis에 저장 후, 일정 시간 이후 or 일정 채팅 개수를 초과한 경우 Batch를 사용해 MySQL에 채팅 데이터 저장
- 채팅 데이터만 MongoDB에 저장
여기서 난 두 번째 방안을 적용하기로 했다.
그 이유는 아직까지 하나의 DB를 사용하다가, 한 프로젝트에서 다른 DB를 적용한 기억이 없기 때문이다.
⚙️ MongoDB
MongoDB란 대표적인 NoSQL DB로 채팅 데이터를 저장하는 데 매우 적합한 DB이다.
그 이유는 다음과 같다.
1️⃣ 비정형 및 유동적인 스키마 구조
보통 채팅에는 단순한 텍스트만 들어가지 않는다.
이모지가 들어갈 수도 있고, 이미지같은 미디어 파일이 포함될 수 있다.
MongoDB는 컬럼이 따로 없는 문서 형태로 데이터를 관리하기 때문에 고정적인 컬럼이 없어 이러한 유동적으로 채팅에 포함된 데이터를 저장할 수 있다.
2️⃣ 고속 쓰기 처리
채팅 시스템은 동시 다발적인 메시지 쓰기가 빈번하게 발생한다.
MongoDB는 쓰기 속도가 빠르기 때문에 이러한 작업에 적합하다.
두 가지 이유 말고도 더 많은 이유가 존재하지만, 오늘의 메인 주제는 이게 아니기 때문에 여기까지만 정리하겠다.
🤷♂️ MySQL과 MongoDB을 함께??
이전에도 언급했듯이 난 원래 채팅 기능에 MySQL을 사용중이었다.
하지만 채팅 기능만 별개로 MongoDB로 전환하기로 했으니 ChatService 코드를 변경해야 할 것이다.
근데 여기서 생각난 것이 ‘내 코드가 그렇게 이상한가요?’ 책에서 나왔던 전략 패턴이었다.
전략 패턴이란 말 그대로 인터페이스의 특징을 사용해 기능을 동적으로 변경할 수 있게 하는 것이다.
이러한 특징을 통해 기능 변경 시 코드 변경을 최소화하고, 유지보수성을 높일 수 있는 것이다.
현재는 MongoDB를 사용할 예정이지만, 언제 어떤 이유로 다시 MySQL로 돌아가야 할지 모른다.
이런 상황에 대비해, 사용하는 DB를 문자열 하나만 바꿔서 선택할 수 있도록 전략 패턴을 적용하면 좋을 것 같다.
한번 구현해 보자!
🧩 DB 선택 전략 패턴
전략 패턴을 적용하기 위해선 크게 세 가지 요소가 필요하다.
- 전략 인터페이스 (Strategy)
- 전략 인터페이스를 구현한 구현체 (Adapter)
- 전략 사용을 위한 컨텍스트 (Context)
🟥 전략 인터페이스
public interface ChatManageStrategy {
int CHAT_SIZE = 20;
LocalDateTime save(ChatRoom chatRoom, Member member, String chatContent);
List<ChatResponse> findChatList(Long chatRoomId, String chatId); // chatId는 마지막으로 조회한 채팅 ID
}
이 인터페이스는 채팅 관련 기능을 전략 패턴으로 설계하기 위해 정의한 것이다.
무한 스크롤을 구현할 예정이라, 한 번의 요청당 최대 20개의 채팅 메시지를 조회하도록 CHAT_SIZE 상수를 인터페이스 내부에 선언했다.
- save()는 채팅 메시지를 저장하는 기능
- findChatList()는 채팅방에 들어갔을 때 이전 메시지들을 조회하는 기능
만약 여기서 채팅 수정이나 채팅 삭제와 같은 기능이 필요할 경우 위 인터페이스에 해당 메서드를 추가해 주면 된다.
참고로 여기서 주의할 점은 위 인터페이스의 입력 파라미터를 정말 잘 설계해야 한다!!
왜냐하면 MySQL과 MongoDB 양쪽 모두 동일한 메서드를 구현해야 하기 때문이다.
즉, 언제든지 DB를 바꾸더라도 인터페이스만 유지되면 구현체만 바꾸면 되는 구조가 되는 것!
🟧 전략 인터페이스를 구현한 구현체
🗨️ MongoChatRepositoryAdapter
@Slf4j
@Component
@RequiredArgsConstructor
public class MongoChatRepositoryAdapter implements ChatManageStrategy {
private final MongoChatRepository mongoChatRepository;
@Override
public LocalDateTime save(ChatRoom chatRoom, Member member, String chatContent) {
MongoChat mongoChat = mongoChatRepository.save(MongoChat.builder()
.memberId(member.getId())
.memberNickname(member.getNickname())
.chatRoomId(chatRoom.getId())
.chatContent(chatContent)
.build());
return mongoChat.getCreatedAt();
}
@Override
public List<ChatResponse> findChatList(Long chatRoomId, String chatId) {
Pageable pageable = PageRequest.of(0, CHAT_SIZE, Sort.by(Sort.Direction.DESC, "id"));
List<MongoChat> chatList = new ArrayList<>();
if (chatId.equals("0")) {
chatList = mongoChatRepository.findChatByChatRoomIdOrderByIdDesc(chatRoomId, pageable);
} else {
chatList = mongoChatRepository.findChatByChatRoomIdAndIdLessThanOrderByIdDesc(chatRoomId, chatId, pageable);
}
return chatList.stream()
.map(mongoChat -> ChatResponse.builder()
.chatId(mongoChat.getId())
.nickname(mongoChat.getMemberNickname())
.chatContent(mongoChat.getChatContent())
.createdAt(mongoChat.getCreatedAt())
.build()
).toList();
}
}
위 코드는 전략 인터페이스인 ChatManageStrategy을 구현한 구현체이다.
클래스 명을 보다시피 위 구현체는 MongoDB에 접근하는 채팅과 관련된 기능을 구현하였다.
🗨️ JpaChatRepositoryAdapter
@Component
@RequiredArgsConstructor
public class JpaChatRepositoryAdapter implements ChatManageStrategy {
private final ChatRepository chatRepository;
private final QChatRepository qChatRepository;
@Override
public LocalDateTime save(ChatRoom chatRoom, Member member, String chatContent) {
Chat chat = chatRepository.save(Chat.builder()
.chatRoom(chatRoom)
.member(member)
.chatContent(chatContent)
.build());
return chat.getCreatedAt();
}
@Override
public List<ChatResponse> findChatList(Long chatRoomId, String chatId) {
List<Chat> chatList = qChatRepository.findChatList(Long.parseLong(chatId), chatRoomId);
return chatList.stream()
.map(chat -> ChatResponse.builder()
.chatId(Long.toString(chat.getId()))
.nickname(chat.getNickname())
.chatContent(chat.getChatContent())
.createdAt(chat.getCreatedAt())
.build())
.toList();
}
}
위 코드는 MySQL을 사용하는 JPA 기반 구현체다.
구조는 MongoDB와 유사하지만, 내부에서 JPA 및 QueryDSL을 활용해 데이터를 처리한다.
🟩 전략 사용을 위한 컨텍스트
@Component
@RequiredArgsConstructor
public class ChatManageContext {
private final Map<String, ChatManageStrategy> strategyMap;
public ChatManageStrategy getStrategy(String strategyKey) {
return strategyMap.get(strategyKey);
}
}
이 클래스는 전략 패턴을 적용하기 위한 핵심 컨텍스트다.
Spring이 ChatManageStrategy 타입의 빈들을 자동으로 주입하면서, 해당 전략을 strategyMap에 등록해 둔다.
그리고 key 값만 알면 원하는 전략을 꺼내 쓸 수 있는 구조다.
- MySQL 사용 시: "jpaChatRepositoryAdapter"
- MongoDB 사용 시: "mongoChatRepositoryAdapter"
💬 ChatServiceImpl
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ChatServiceImpl implements ChatService {
...
private final ChatManageContext chatManageContext;
public static final String STRATEGY_KEY = "mongoChatRepositoryAdapter";
@Override
public ChatListResponse findChatList(ChatListServiceRequest request) {
ChatRoom chatRoom = chatRoomRepository.findById(request.getChatRoomId())
.orElseThrow(() -> new RestApiException(ChatErrorCode.CAN_NOT_FOUND_CHATROOM));
ChatManageStrategy strategy = chatManageContext.getStrategy(STRATEGY_KEY);
List<ChatResponse> chatResponses = strategy.findChatList(chatRoom.getId(), request.getChatId());
return ChatListResponse.builder()
.chatResponses(chatResponses)
.build();
}
@Override
@Transactional
public void sendChat(ChatSendServiceRequest request) {
Member member = memberRepository.findMemberByEmail(new Email(request.getEmail()))
.orElseThrow(() -> new RestApiException(MemberErrorCode.MEMBER_NOT_FOUND));
ChatRoom chatRoom = chatRoomRepository.findById(request.getChatRoomId())
.orElseThrow(() -> new RestApiException(ChatErrorCode.CAN_NOT_FOUND_CHATROOM));
ChatManageStrategy strategy = chatManageContext.getStrategy(STRATEGY_KEY);
LocalDateTime createdAt = strategy.save(chatRoom, member, request.getChatContent());
KafkaChat kafkaChat = new KafkaChat(
chatRoom.getId(),
member.getNickname(),
request.getChatContent(),
createdAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
);
kafkaProducer.sendMessage("chat-exchange", kafkaChat);
}
...
}
ChatServiceImpl 클래스에서는 전략 컨텍스트에서 현재 사용할 전략을 가져와 기능을 실행한다.
- findChatList()는 채팅 목록을 조회
- sendChat()은 채팅을 저장하고 Kafka로 전송
두 메서드 모두 아래처럼 전략을 꺼내와 사용한다.
ChatManageStrategy strategy = chatManageContext.getStrategy(STRATEGY_KEY);
현재는 MongoDB 전략을 사용하고 있지만, 만약 MySQL로 되돌아가야 한다면 STRATEGY_KEY값만 "jpaChatRepositoryAdapter" 로 바꿔주면 끝이다.
✨ 마무리
이처럼 전략 패턴을 활용하면 데이터베이스를 교체해야 할 상황에서도 서비스 레이어의 수정을 최소화할 수 있다.
유연한 설계가 유지보수를 편하게 만든다!
이걸 실감하게 되는 순간이다.
'Spring' 카테고리의 다른 글
[Spring] Spring Security 정리 (0) | 2025.05.01 |
---|---|
[Spring] Spring 프로젝트 SonarQube 연동 (0) | 2025.01.09 |
[Spring] Spring Batch를 사용한 대량 데이터 저장 (2) (0) | 2024.12.22 |
[Spring] Spring Batch를 사용한 대량 데이터 저장 (1) (3) | 2024.12.21 |
[Spring] Controller 테스트 코드 (Feat : Spring Security) (1) | 2024.12.19 |