💡 구현 방법은 너무 다양한 방법들이 나올 수 있을 거 같기도 하고, 최근 블로그 글이 너무 길어지는 게 마음에 걸려서 생략했습니다. 대신 나름대로의 최종 아키텍처 설계까진 포함했습니다.
📕 목차
1. Introduction
2. How should a subscription request be handled?
3. Why can't we use a socket server for join the chatroom?
4. Final Design
1. Introduction
📌 Design
일반적으로 대부분의 요청은 무상태 서버, 즉 일반적인 RESTful server에서 모두 처리가 가능하다.
그러나 실시간(real-time)성이 요구되는 경우, socket 서버로 상태 유지 서버를 추가 도입하게 된다.
상태 유지 서버가 들어섰다고, 기존의 모든 무상태 서버에서 처리하던 로직을 stateful하게 처리할 이유는 없다.
따라서, 기존의 요청들은 stateless 서버에서 처리하고, 실시간 이벤트만 socket 서버로 통신하면 되는 것은 자명하다.
그러나 채팅 메시지를 전달하는 기능을 구현할 때, 일반적으로 다음과 같은 Usecase가 선행되기 마련이다.
- 채팅방 생성
- 채팅방 검색
- 채팅방 가입
채팅방 생성은 누가봐도 http api server로 요청하면 그만이다.
검색의 경우엔 검색 내용이 무엇이며, 요구 사항(검색 후에 매칭되는 값이 추가되고 있다면, 실시간으로 보여주어야 하는지, 채팅방 가입자 수 변화가 실시간으로 표시되어야 하는지 등)에 따라 다소 달라질 수 있다.
그러나 이 또한 일반적으로 http 요청으로 처리하며, 특히나 내 서비스에선 불필요한 사항이므로 http 서버로 요청하면 된다.
그러나 채팅 가입의 경우는 어떤가?
이 또한 당연히 HTTP 요청에서 처리해야 하는 것이 당연하다고 느껴지지만, 가입에 성공했을 때 채팅방에 "OO님이 들어왔어요"같은 메시지가 전달되어야 한다고 가정하자.
문제는 저 메시지를 전달하는 주체가 누군지가 문제를 복잡하게 만든다.
- Client가 HTTP 서버로부터 가입 성공 메시지를 받으면,
- idea1. Client가 Socket 서버로 가입 성공 메시지를 전달한다.
- idea2. API Server가 채팅방 신규 가입 메시지를 브로드 캐스트하고, Client는 성공 응답을 받는다.
- Client가 Socket 서버로 메시지를 보내서, 채팅방 가입과 참여 메시지 전달을 한 곳에서 처리한다
이기적으로 생각했을 때, 백엔드 개발자는 Socket 서버에서 채팅방 가입 로직을 구현하는 게 합리적으로 보인다.
그렇게 하면 구현 난이도가 훨씬 낮아지며, 채팅방 생성과 가입 참여 메시지를 한 곳에서 다룰 수 있으므로, 상당히 편리하다는 이점을 가지기 때문이다.
하지만 인생은 언제나 균형을 찾아가는 법. 이렇게 구현하면, Client 개발자는 지옥을 맛보게 된다.
우선 두 가지 방법의 구현 방법에 대해 생각해보자.
📌 case1. using HTTP API server
- 채팅방 가입 요청을 HTTP API 서버로 전송한다.
- API 서버에서 가입 처리 후, 채팅방 참여 이벤트를 발행(publish)하고 Client에게 가입 성공을 전달한다.
- Client에게 채팅방 가입 브로드 캐스트 역할을 떠넘기면 안 된다. 네트워크 불량 혹은 Client가 해당 로직 수행을 까먹으면, 신규 가입자 메시지가 전달이 되지 않는다. (이건 ch2에서 다루자.)
- Socket 서버는 성공 응답을 받은 후, 해당 채팅방 Exchange를 구독하여 입장한다.
이렇게 하면 장점은 다음과 같다.
- Resource(채팅 멤버)를 RESTful하게 관리할 수 있다.
- Tx 관리가 용이해진다. (socket 서버 특성 상, 이게 진짜 어려운데 ch3에서 다룸)
- 재시도 로직 구현이 쉽다.
- 관심사의 분리가 명확하다.
- API는 상태 변경을 다루고, Socket 서버는 실시간 메시지 전달에만 집중하면 된다.
- Socket 서버가 일시적으로 죽어도, 가입 자체는 성공하므로 장애 대응에 용이하다.
📌 case2. using Socket server
- 채팅방 가입 요청을 Socket 서버로 전송한다.
- Socket 서버에서 가입 처리와 입장 메시지를 한 번에 처리한다.
- 가입 정보는 DB에 저장한다.
장점은 말하지 않아도, 알 정도로 명확하다.
- 단일 서버에서 모든 처리가 이루어져 있으므로 구현이 단순하다.
- 실시간성이 보장된다.
📌 Standard
그렇다면, 우리는 어떠한 기준에서 최종 설계를 결정해야 할까?
원칙과 초기 설계를 따르는 case1과 백엔드 개발자에게 편의를 제공하는 case2.
얼핏 보기엔, case2를 선택하는 것이 빠른 구현과 실리적인 이점을 취할 수 있을 것 같으니,
자질구레한 원칙이니 뭐니하는 것들을 무시하는 것이 효율적이지 않을까?
그러나, WebSocket 프로토콜의 태생적인 한계와 "실시간성"이라는 특성으로 인해, 추가적인 문제들을 파생하게 될 수도 있다.
2. How should a subscription request be handled
📌 Where should the 'new participation message' be handled?
우선, 우리는 "채팅방 가입에 성공하면, 채팅방 신규 가입 메시지를 채팅방에 전달한다"라는 Usecase를 어디서 처리해야 할 지 결정해야 한다.
일반적인 채팅방 가입 시나리오는 다음과 같을 것이다.
- 사용자가 HTTP API 서버에 채팅방 참여 요청
- HTTP API 서버가 채팅방 참여자 정보를 등록하고, Exchange에 등록
- 가입 성공 응답을 받은 클라이언트가 채팅방 구독 신청
이렇게 되면 한 가지 문제가 발생한다.
일반적으로 물리적으로 가까운 (2)의 요청이 (3)의 요청보다 빠르다.
그럼 "채팅방 가입 메시지"를 채팅방에 가입해 있던 사람들은 받아도, 신규 가입자는 전달받지 못하는 문제가 생긴다.
대체 "신규 가입 메시지"는 누가 처리해야 할까?
📌 Client Side
// 클라이언트 의사코드
const execute = async () => {
// 1. 채팅방 가입
await joinChatRoom();
// 2. 구독 요청
await subscribe();
// 3. 신규 가입 메시지 전송
sendMessage();
}
첫 번째는 클라이언트가 가입 성공을 확인하고, 구독 준비까지 다 마친 후 신규 가입 메시지를 전달하게 만드는 방법이 있다.
(서버에서 구독 요청 시, 신규 가입 메시지를 보내는 로직을 포함하는 것은 너무 복잡하며, 단 한 번의 이벤트를 위해 구독 요청마다 불필요한 메시지가 실행되는 오버헤드가 발생하므로 제외한다.)
구현이 매우 단순해지며, 서버도 HTTP API 서버와 Socket 서버 분리에 대해 골머리를 앓지 않아도 된다.
그러나 비즈니스 로직이 클라이언트 구현에 의존하게 된다는 문제가 존재한다.
클라이언트가 구현 항목을 빠트리거나, 갑작스러운 네트워크 불안정 등의 이유로 마지막의 message만 전달되지 않았을 경우엔?
당연히 신규 가입을 했음에도, 그 누구도 반겨주지 않는 비자발적 유령 회원이 되어 버린다.
결국, 채팅방 가입 메시지는 server 단에서 반드시 처리해야 한다는 결론으로 귀결된다.
📌 Server Side
@Service
class ChatRoomService {
public JoinRoomResponse joinRoom(JoinRoomRequest request) {
// 1. DB 처리
User user = userRepository.find(...);
memberRepository.save(...);
// 2. 임시 큐에 메시지 저장
String tempQueueName = "temp.join." + UUID.randomUUID();
sendJoinMessage(userId.getName(), tempQueueName);
// 3. 채팅방에도 메시지 전달
sendJoinMessage(userId.getName(), request.getChatRoomId());
// 4. 클라이언트에게 큐 이름 반환
return new JoinRoomResponse(tempQueueName);
}
}
// 클라이언트는 임시 큐를 구독한 후,
// 채팅방을 구독하고 마지막으로 임시 큐의 메시지를 처리
두 번째는 신규 가입자를 위한 임시 큐를 준비해주는 방법이다.
사실 이 방법은 예시를 위해 어거지로 작성한 코드라 설명하고 싶지도 않다.
idea는 뒤늦게 구독을 할 신규 가입자를 위해, 멤버 가입 큐를 별도로 관리한다는 정도지만
이번엔 백엔드 측의 구현이 복잡해졌다.
📌 Last Event ID
사실 이 문제는 단순하게 해결할 수 있다.
어차피 채팅방 가입에 성공하고 채팅방 뷰에 입장하면, 가장 처음 하는 일이 채팅 이력을 조회하는 행위일 것이다.
이 때, 해당 사용자의 채팅 세션을 생성할 때, 가입 메시지 id를 함께 저장하는 것이다.
어차피 이전의 이력들을 조회하지 못 하게 막으려면, "사용자가 읽을 수 있는 채팅 이력의 시작점"이란 정보를 가지고 있어만 한다.
이걸 timestamp로 처리하는 건, 동일 ms에 수 백, 수 천 건의 메시지가 오고 갈 수도 있는 실시간 환경에선 적합하지 않다.
그래서 이전에 TSID 방식으로, 순서를 보장하는 전역 고유 ID 생성기를 도입한 적이 있었다.
그럼 클라이언트가 채팅 뷰에 진입할 시점엔 이미 신규 가입 채팅 이력이 저장되어 있을 것이며,
여기서부터 채팅이력을 불러오면 그만이다.
ch2의 중요한 점은 결국 "신규 가입 메시지는 서버가 처리해야 한다"였다.
그런데 어째 이야기가 다른 곳으로 샌 느낌이 없지 않아 든다...
3. Why can't we use a socket server to join the chatroom?
📌 SoC(Spparation of concerns)
단순히 관심사 분리라는 원칙만을 놓고 보면, 이렇게 정의할 수 있다.
- API server: 상태를 관리하는 비즈니스 로직 수행
- Socket server: 실시간 메시지 전달 로직 수행
여기까지만 놓고, "자, 원칙이 이러니까 가입 요청은 API server로 보내세요!"라고 한다면 부적절하다.
원칙을 고수했을 때의 이점보다, 어겼을 때의 실리적 이점이 클 때는 종종 원칙조차 어기는 것이 프로그래밍 세상이기 때문이다.
그렇다면, 우리는 대체 왜 관심사 분리라는 원칙을 준수해야 하는가?
1️⃣ 리소스 생명 주기 관리
REST의 R(resource)만 봐도 알 수 있듯이, REST API의 주요한 정보는 resource다.
그리고 여기서 채팅방 참여자(member)는 영속성을 가진 도메인 리소스에 해당한다.
- 채팅방에 누가 존재하는지 조회
- 참여자의 권한 관리
- 참여자의 히스토리 관리
- 채팅방 참여 제한 정책 적용
이외에도 수많은 관리가 필요한 핵심 도메인 객체에 해당한다.
물론, 여기서 이렇게 반박할 수도 있다.
🤔 이해할 수 있는 형식, 관계 타입을 갖추어 hypertext를 통한 link로 사용한다면 그것은 곧 REST 하다고 말할 수 있지 않나요? 그리고 REST는 꼭 HTTP가 아니어도 되는데, Web Socket을 사용하는 게 잘못되었다고 볼 순 없지 않을까요?
실제로 누가 나한테 반박한 건 아니고, 블로그 정리하다가 내 의견에 항상 비판적으로 포스팅을 작성하다보니, 분열된 나의 또 다른 자아가 반박해주었다. ㅋㅋ
이 생각이 들었을 때 쯤, '어라, 생각해보니 그렇네. 그럼 이 내용은 지워야 하는 건가?' 싶었지만, 조금만 더 생각해보니 그럴 이유가 없었다.
왜냐하면, WebSocket으로도 REST 스타일의 API를 구현할 수 있다는 것이 "좋은 선택"인가는 별개의 문제기 때문이다.
WebSocket의 주요 특징과 잘하는 역할이 무엇인가?
- Full-duplex 통신
- Event-driven
- Stateful
- 실시간 업데이트, 서버에서 클라이언트로의 push, 스트리밍 데이터 전송
(3년 전에 백엔드 처음 공부할 때 봤던 영상..당시에 이걸 이해하려고 한 내가 새삼 대견하다.)
그러나 REST 아키텍처 스타일엔 엄격한 제약 조건이 있다는 사실은 누구나 알고 있다.
- Client-Server
- stateless
- cache
- uniform interface
- layered system
- code-on-demend (optional)
여기서 결정적인 위반 사항은 Stateless 제약 조건이다.
- 각 요청은 필요한 모든 정보를 포함해야 한다.
- 서버는 클라이언트의 컨텍스트를 유지하지 않아야 한다.
따라서, 우선 첫 번째 원칙을 어기고 있음을 알 수 있다.
2️⃣ bounded-context
💡 DDD에서 bounded-context란, "하나의 도메인 모델이 유효한 경계"를 의미한다.
(나도 DDD를 자세하게 공부한 건 아니고, 감으로 이해한 정도라 틀릴 수 있지만..)
우선, API 서버와 Socket 서버의 책임을 명시해보자.
- HTTP API 서버의 주 책임
- 도메인 리소스 CRUD
- 비즈니스 규치 검증
- 트랜잭션 관리
- Socket 서버의 주 책임
- 실시간 메시지 라우팅
- 연결 상태 관리 (세션, 박동 검사)
- 메시지 브로드 캐스팅
DDD로 context를 분리해보는 건 처음해보지만, 위 이미지를 참고해보자.
- 비즈니스 관점
- 도메인 전문가와 대화할 때 사용되는 용어와 개념
- 비즈니스 규칙과 정책
- 사용자 경험과 직결되는 기능
- 비즈니스 가치를 직접적으로 창출하는 부분
- 예를 들어, 핵심 비즈니스 로직인 메시지 전송/수신, 채팅방 관리 정책, 사용자 권한과 제한 등이 포함된다.
- 기술 관점
- 비즈니스 규칙을 구현하기 위한 기술적 방법
- 성능, 확장성, 안정성을 위한 처리
- infrastructure 결정
- 예를 들어, 메시지 전달을 위한 서브 프로토콜, 데이터 저장소와 캐시 전략, 소켓 서버를 구현할 서버와 언어 등이 포함된다.
뭔 소린지 당췌 이해가 안 가지만, 차근차근 단계를 밟아보자.
우선 "사용자가 가입하면, 신규 가입자 알림 메시지를 채팅방으로 전달한다"라는 Usecase에는 두 가지 Domain이 등장한다.
// 비즈니스 도메인 모델
class ChatMember {
private Long id;
private User user;
private ChatRoom chatRoom;
private MemberRole role; // 비즈니스 개념
private MemberStatus status; // 비즈니스 개념
private LocalDateTime joinedAt;
...
}
// 기술 관점
@RestController
class ChatMemberController {
private final ChatMemberJoinService service;
@PostMapping("/chat-rooms/{roomId}/chat-members")
public void joinRoom(@PathVariable String roomId) {
// 기술적 처리
- 트랜잭션 관리
- 동시성 제어
- 캐시 관리
- 시큐리티 제어
}
}
@Service
class ChatMemberJoinService {
@Transactional
public void joinRoom(Long userId, String roomId) {
// 비지니스 처리
- 사용자 조회
- 사용자 가입 제한 이력 조회
- 채팅방 남은 자리 조회
- 채팅방 멤버 등록
// 이벤트 발행 (다른 컨텍스트와의 통신)
eventPublisher.publishEvent(
new MemberJoinedEvent(roomId, userId)
);
}
}
첫 번째로 채팅방 멤버 컨텍스트가 비즈니스 관점은 무엇인가?
- 인증된 사용자
- 권한 정책 (ex. 채팅방 비밀번호 검증)
- 제한 사항 (ex. 이전에 퇴장당한 이력이 있는 회원이면 거부)
그리고 기술 관점으로 봤을 때, 멤버 컨텍스트의 특성은 다음과 같다.
- 트랜잭션이 중요함
- 상태 변경의 신뢰성 요구
- 영속성 필요
- 실패 시 롤백, 동시성 제어 필요
=> 따라서, HTTP API 서버에서 처리하는 것이 적합.
문제는 "등록이 끝나면, 해당 채팅방으로 가입 메시지가 전달"되어야 한다는 부분이다.
메시지라는 도메인이 등장하면서, 하나의 Entity가 또 필요해지게 된다.
// 비즈니스 도메인 모델
class Chat {
private final long id;
private final Long chatRoomId;
private final Long senderId;
private final MessageType type;
private final String content;
privagte LocalDateTime sentAt;
...
}
// 기술 관점
@MessageMapping("/chat")
class MessageController {
public void handleMessage(ChatMessage message) {
// 기술적 처리
- 메시지 라우팅
- 브로드캐스팅
- 실패 처리
}
}
class MessagePolicy {
public boolean validateMessage(ChatMessage message) {
// 비즈니스 규칙
- 메시지 길이 제한
- 도배 방지
- 욕설 필터링
return ...;
}
}
메시지 전달 컨텍스트가 알아야 할 비즈니스 관점은 무엇인가?
- 일반 메시지, 시스템 메시지, 알림 메시지 등을 구분
- 각 메시지 타입별 유효성 검증
- 메시지 형식 (텍스트, 이미지, 파일 등)
- 읽음 여부 처리
중요한 건 채팅 메시지 컨텍스트는, 채팅방 관련 정보와 채팅 메시지를 보낸 주체에 딱히 관심이 없다.
senderId와 같은 필드는 그저 식별값일 뿐, 그 이상의 비즈니스 규칙을 갖지 않기 때문이다.
기술 관점으로는 다음과 같을 것이다.
- 실시간 전달 필요
- 순서 보장 필요
- 메시지 전달(at least once) 보장
- dead letter 처리
- 메시지는 영구 저장
- ID는 분산 환경에서 100,000개/ms의 생성이 가능해야 하며, 고유성을 보장해야 함.
=> 따라서, Socket 서버에서 처리하는 것이 적합.
비즈니스 관점으로 보면, 둘은 너무 밀접해서 다소 구분이 어려워 보일 수 있다. (그저 Entity를 분리해야 겠구나 정도만을 식별)
가장 큰 이유는 "멤버 가입이 완료되면, 메시지가 전달되어야 한다"라는 통합 context가 경계를 구분하기 어렵게 만들기 때문이다.
그러나 명백히 각자의 명확한 책임 영역과 비즈니스 규칙을 지니며, 상이한 기술을 필요로 한다.
이 말은 두 컨텍스트에서 변경은 서로에게 영향을 주지 않아야 하며, 독립적으로 발전되어야 한다는 의미를 내포한다.
- 채팅방 가입 정책이 변경되면, HTTP API 서버만 수정하고 메시지 전달 로직은 영향이 없어야 한다.
- 메시지 전달 방식이 변경되면, Socket 서버만 수정하고 채팅방 가입 정책은 영향이 없어야 한다.
- STOMP가 아닌 다른 서브 프로토콜, 혹은 다른 프레임워크를 사용하는 것이 API 서버에 영향을 주어선 안 된다. (외부 브로커는 공통 인프라 영역이므로, 영향이 전파될 수밖에 없지 않을까 싶다.)
이런 상황에서 채팅방 가입 로직을 메시지 전달 로직과 합치게 되면, context의 경계가 모호해진다는 문제가 생길 수 있다.
따라서 두 context는 필수적으로 분리되어야 한다.
3️⃣ Extensible Design
💡 확장 가능한 설계를 고려하라
(2)의 bounded context에서 나온 이야기를 되풀이 하는 정도의 내용.
원래는 쓸 게 많았는데, 포스팅을 쓸 때 항상 목차를 먼저 써두고 내용을 채우는 스타일이라, 이전에 전부 설명해버릴 때가 있다. 허허
여튼 채팅방 입장이라는 Usecase는 미래에 수정될 여지가 존재한다.
채팅방 가입 제한 사항을 추가하는 비즈니스 관점이든, 채팅방 가입을 처리하는 API 프레임워크의 변경이든
이러한 상태 관리의 영역은 메시지 전달을 담당하는 Socket 서버와는 독립적으로 발전해야 한다.
물론, 그 역도 마찬가지다.
4️⃣ SRP(Single Responsibility Principle)
💡 하나의 컴포넌트를 변경하는 이유는 오직 하나여야 한다.
결국, "사용자가 가입에 성공하면, 해당 채팅방에 메시지를 전달한다"는 시나리오는 2개의 context로 분리하게 되었다.
"사용자가 가입을 한다", "채팅방으로 신규 가입 메시지를 전달한다"
두 context가 변경되는 사유는 제각기 다르다.
- 채팅 멤버 컨텍스트의 변경 사유 예시
- 채팅방 인원 제한 정책의 변경
- 퇴장 이력이 있는 사용자 제한 정책의 변경
- 채팅방 비밀번호 검증 로직 변경
- 멤버 권한 체계 수정
- 채팅 메시지 컨텍스트의 변경 사유 예시
- 메시지 타입 추가
- 메시지 전달 방식 변경
- 메시지 유효성 검증 규칙 변경
- 시스템 메시지의 형식 수정
만약, 이 두 로직을 결합하게 되면 high coupling low cohesing(높은 결합도, 낮은 응집도) 문제와 단위 테스트 어려움과 같은 문제가 발생한다.
두 context는 자신의 도메인 모델에만 집중하고, 다른 context와는 이벤트로만 소통하도록 만듦으로써, context의 변화가 외부 context로의 변경이 전파되는 것을 막아야 한다.
여기까지 "기술적 문제"가 아닌, "원칙"을 준수하지 않았을 때의 문제점을 알아보았다. ㅎ
📌 Issue due to the absence of the request-response pattern
지금부터는 기술적 구현 한계점에 대해 분석해보자.
WebSocket 프로토콜 상에 "요청에 대한 응답을 주어야 한다"라는 규칙은 존재하지 않는다.
즉, 요청-응답 패턴이란 개념 자체가 없다.
Client의 메시지 헤더에 "receipt" 필드가 존재하면, 응답을 돌려주어야 한다는 규칙도 STOMP 서브 프로토콜을 도입했을 때나 통용되는 이야기.
WebSocket 서버에서 "채팅방 가입과 가입 성공 메시지를 전송한다"를 동시에 처리한다고 가정하자.
어쨌든 우리는 STOMP 프로토콜을 사용하고 있으므로, 해당 비즈니스 로직을 수행하다가 어떠한 이유로든 가입에 실패하면, 사용자의 error queue로 메시지를 전달하면 되지 않을까?
그럼 대체 클라이언트는 이 로직을 어떻게 수행해야 하는 걸까?
// Socket 서버로 처리 시 클라이언트 코드
const joinRoom = (joinRequest) => {
// 1. 에러 구독
stompClient.subscribe('/user/queue/errors', errorCallback);
// 2. 채팅방 가입 요청
stompClient.publish({
destination: "/pub/chat.join." + roomId,
headers: {
'Authorization': TokenManager.getAccessToken(),
'receipt': 'join-receipt-id'
},
body: JSON.stringify(joinRequest)
});
// 3. 성공/실패 여부를 어떻게 명확히 알 수 있을까?
// - receipt를 기다려야 하나?
// - error 구독에서 오는 메시지를 기다려야 하나?
// - 타임아웃은 어떻게 처리하지?
}
우리는 STOMP 서브 프로토콜을 채택했으니, receipt 헤더를 추가한다고 하더라도
이 또한 추적을 위한 부가적인 로직을 수행해야 함을 의미한다.
// 클라이언트 상태 관리가 매우 복잡해짐
class RoomManager {
#pendingJoinRequests = new Map(); // 진행 중인 요청 추적
#joinTimeouts = new Map(); // 타임아웃 처리
async joinRoom() {
const requestId = uuid();
return new Promise((resolve, reject) => {
// 타임아웃 설정
this.#joinTimeouts.set(requestId,
setTimeout(() => this.#handleJoinTimeout(requestId), 5000)
);
// 요청 추적
this.#pendingJoinRequests.set(requestId, { resolve, reject });
// 실제 요청 전송
stompClient.send(...);
});
}
// 에러 처리, 타임아웃 처리, 상태 정리 등등...
// 불필요하게 많은 보일러플레이트 코드 필요
}
결국 클라이언트는 위와 같은 복잡한 상태 관리를 수반해야만 한다.
반면, HTTP 요청을 분리하면 어떠한가?
// 단순하고 명확한 클라이언트 코드
async function joinRoom(request) {
try {
// 1. 명확한 요청-응답
await axios.post('/api/chat-rooms/${roomId}/chat-members', request);
// 2. 성공 시에만 구독 요청
await subscribeChatRoom(request.chatRoomId());
} catch (error) {
// 3. 실패 처리가 명확함
handleError(error);
}
}
Client의 로직은 명확하고 단순해지며, 아름다운 코드를 유지할 수 있게 된다.
백엔드 개발자가 편하자고 프론트 개발자에게 일을 떠넘길 수도 있겠지만, 결국 제품을 만들어나가는 건 팀이다.
이전의 방식은 프론트가 설계의 결함을 잡아내기 매우 어렵게 만들며, 결과적으로 프로덕트의 가치를 훼손시킬 수도 있는 문제로 번질 수 있다.
📌 The transaction boundary is unclear
@MessageMapping("/room/join")
public void handleJoinRoom(JoinRequest request) {
try {
memberRepository.save(...);
// 트랜잭션 실패 시...
// 1. 어떻게 클라이언트에게 알려줄 것인가?
// 2. 클라이언트는 이 실패를 어떻게 신뢰성있게 감지할 것인가?
} catch (Exception e) {
// 에러 발생 시 클라이언트에게 알리기 위한 복잡한 처리 필요
messagingTemplate.convertAndSendToUser(
username,
"/queue/errors",
new ErrorResponse(...)
);
}
}
비즈니스 로직을 한 곳에서 관리하면, 백엔드 개발자 또한 문제가 발생하는데
이는 트랜잭션의 경계가 불명확하다는 점에서 비롯한다.
아무리 둘을 한 곳에 합쳐놓고, 예외 처리는 프론트가 알아서 하라고 하고 싶더라도
백엔드 개발자 또한 관심사 분리 실패로 인한 지옥에서 온전히 벗어나지는 못 한다.
- 성공/실패 여부와 이유 대한 응답을 명확하게 클라이언트에게 전달해주어야 한다.
하지만 이 문제는 생각보다 쉽게 처리할 수 있긴 하다.
Spring의 전역 예외 필터를 만들고, 그저 예외를 던져버리기만 하면 그만이기 때문이다.
4. Final Design
📌 design
멀티 모듈 구조라서, 설계도가 다소 복잡하게 나와버렸다.
위의 큰 점선 박스가 HTTP API 영역이고, 아래 Socket 박스들이 실시간 서버들이라고 이해하면 된다.
구체적인 설계는 나도 이제 해봐야겠지만, 대략적으로 위와 같이 구현하면 충분히 만들 수 있지 않을까 싶다.
Inbox-outbox 패턴까지 도입해야 하나 싶었는데, RabbitMQ 쓰니까 진짜 편하넹..
📌 소감
내가 무슨 시니어 개발자도 아니고, DDD 배운 적도 없으면서 평소 주워먹은 지식 + 급하게 주워먹은 지식으로 추론한 내용들에 불과하다.
그럼에도 단순하기 짝이 없다고 생각한 기능에 대해서, 처음으로 DDD 관점으로 문제를 분석해보니 정말 즐거웠다.
(추가로 기존 API들의 설계가 잘못되었다는 것까지 알게 되었다...^^ 아놔)
사실 이렇게까지 어려운 내용을 다룰 예정은 아니었다.
처음엔 포스팅 쓸 만한 거리도 아닌 거 같은데, 괜히 시간 날리는 거 아닐까 싶을 정도였는데,
이게 또 쓰다가 머리에 물음표❓ 하나 꽂히면 급발진 하는 습성 때문에 여기까지 와버렸다. ㅋㅋㅋㅋㅋ