이번 팀 프로젝트에서 기본적인 CRUD 외 실시간 알림 기능을 구현하게 되었다.
해당 기능을 구현하면서 정리한 내용을 기록하고자 한다.
실시간 알림을 구현한 이유
- 특정 사용자에게 메시지를 전할 수 있도록 쪽지 기능이 구현되어 있음
- 쪽지 보관함을 확인하는 경우 DB에서 데이터를 가져오기 때문에 알림 기능이 없는 경우 사용자가 직접 확인을 위해 수시로 쪽지 보관함에 접근해야 함
- 다수의 사용자 쪽지를 보기 위해 여러 번 요청을 날리는 경우 서버에 부담이 됨
- 실시간 알림을 사용하면 사용자가 수시로 쪽지 보관함을 확인하는 수고를 덜 수 있으므로 사용자 경험을 향상 시키고 요청을 줄여서 서버의 부담을 줄일 수 있음
처음에는 SSE로 구현하였고 이유는 다음과 같다.
SSE로 구현한 이유
SSE(Server-Sent Events)
- 서버에서 클라이언트로 실시간 데이터를 전송하는 기술
- 클라이언트는 서버에 데이터를 요청할 수 없고 서버가 클라이언트에 지속적으로 데이터를 전송
- 클라이언트로 일방적인 데이터 전송이 필요한 실시간 알림 메시지에 적합
- 연결이 끊어진 경우, 브라우저는 자동으로 재연결 시도
- 네트워크 오류나 일시적인 연결 문제를 자동으로 복구할 수 있게 해줌
- 이벤트 ID 지원
- 연결이 끊어진 후 마지막으로 수신한 이벤트로부터 이벤트를 다시 수신할 수 있게 해 줌
- HTTP 프로토콜 사용으로 웹소켓과 비교했을 때 방화벽이나 프록시 서버 등의 네트워크 장애에 더 잘 대처하며, 장애로 인한 영향을 덜 받음 (양방향 통신이 필요한 경우에는 웹소켓 사용)
실시간 알림의 경우 SSE만으로 충분하지만 다음과 같은 이유로 WebSocket으로 전환하게 되었다.
SSE에서 WebScoket으로 전환한 이유
- 양방향 통신 지원
- HTTP 프로토콜 기반으로 초기 연결 설정 후 데이터 전송은 WebSocket 프로토콜 사용
- SSE를 사용하는 경우 클라이언트에서 서버로 요청 시 HTTP 헤더에 token을 담아 전송하는 데 어려움이 있음 (추가적인 라이브러리를 사용해야 함) - Client
- WebSocket을 사용하는 경우 HTTP 헤더 외 메시지 본문을 통해서 데이터를 전송할 수 있어서 token 등을 전송하기 쉬움
- Client 측에서 EventSourcePolyfill 라이브러리를 사용해 해결할 수 있으나 해당 라이브러리의 최근 업데이트 X
- WebSocket을 사용해 서비스를 제공받길 원함
- SSE보다 일반적으로 더 많이 사용되며, 추후 확장에 유연하게 대응할 수 있음
- 현재는 쪽지 알림의 기능만 하지만 이후 채팅 등 양방향 통신이 필요한 기능이 추가되는 경우
- 네트워크 효율성이 좋음
- 한 번의 연결로 서버와 지속적인 연결 가능
- 헤더 크기가 작아 overhead가 적음
추가로 Redis pub/sub을 적용했는데 이유는 다음과 같다.
Redis pub/sub을 적용한 이유
- 여러 서버 인스턴스 간에 메시지를 쉽게 전달할 수 있음
- 알림 서비스에서 여러 서버가 동일한 알림 채널을 구독하고 있다면 한 서버에서 발생한 메시지를 모든 서버가 받아 사용자에게 전달할 수 있음 (시스템의 처리 능력을 유연하게 조정할 수 있어 scale-out에 유용)
기능 구현을 위한 코드 - Spring Boot
WebSocket 설정
@Configuration
@EnableWebSocketMessageBroker
class WebSocketConfig : WebSocketMessageBrokerConfigurer {
override fun configureMessageBroker(registry: MessageBrokerRegistry) {
registry.enableSimpleBroker("/sub")
registry.setApplicationDestinationPrefixes("/pub")
}
override fun registerStompEndpoints(registry: StompEndpointRegistry) {
registry
.addEndpoint("/ws")
.setAllowedOriginPatterns("*")
.withSockJS()
}
}
- @EnableWebSocketMessageBroker: stomp 사용을 위한 메시지 브로커 활성화
- configureMessageBroker: 메시지 브로커 옵션 설정
- enableSimpleBroker: 메시지 브로커 등록, 구분자(,)를 사용해 여러 개의 메시지 브로커를 등록할 수 있음
- setApplicationDestinationPrefixes: 엔드포인트 prefix 설정
- Controller에서 @MessageMapping("/notification")을 사용하는 경우 /pub/notification로 매핑됨
- registerStompEndpoints: stomp 엔드포인트 등록
- addEndpoint: 엔드포인트 등록
- 위와 같이 /ws로 지정한 경우 http://localhost:8080/ws로 요청을 보내야 함
- setAllowedOriginPatterns: 접근을 허용할 도메인 설정, *는 모든 도메인에서 허용을 나타냄(개발용)
- withSockJS: 웹소켓이 지원되지 않는 브라우저에서도 웹소켓과 유사한 기능을 할 수 있도록 도와줌
- addEndpoint: 엔드포인트 등록
Redis 설정
@Configuration
class RedisConfig(
@Value("\${spring.data.redis.host}")
private val host: String,
@Value("\${spring.data.redis.port}")
private val port: Int,
) {
@Bean
fun redisConnectionFactory(): RedisConnectionFactory =
LettuceConnectionFactory(host, port)
@Bean
fun redisTemplate(): RedisTemplate<String, Any> {
val redisTemplate = RedisTemplate<String, Any>()
redisTemplate.connectionFactory = redisConnectionFactory()
redisTemplate.keySerializer = StringRedisSerializer()
redisTemplate.valueSerializer = Jackson2JsonRedisSerializer(Object::class.java)
return redisTemplate
}
@Bean
fun messageListenerAdapter(subscriber: RedisSubscriber): MessageListenerAdapter =
MessageListenerAdapter(subscriber)
@Bean
fun redisMessageListenerContainer(
connectionFactory: RedisConnectionFactory,
listenerAdapter: MessageListenerAdapter
): RedisMessageListenerContainer {
val container = RedisMessageListenerContainer()
container.setConnectionFactory(connectionFactory)
container.addMessageListener(listenerAdapter, ChannelTopic("notification"))
return container
}
}
- redisConnectionFactory: Redis 연결을 위한 설정
- redisTemplate: Redis 데이터 액세스를 위한 템플릿 설정
- keySerializer: key 직렬화 도구 설정으로 StringRedisSerializer는 key를 문자열로 직렬화함
- valueSerializer: value 직렬화 도구 설정으로 Jackson2JsonRedisSerializer는 value를 JSON으로 직렬화
- messageListenerAdapter: Redis 메시지 리스너 설정(메시지 처리 메서드 지정)
- redisMessageListenerContainer: Redis 메시지 리스너를 관리하는 컨테이너 설정
- 메시지 리스너를 관리하고, 메시지를 수신할 때 해당 리스너를 호출하는 역할
- addMessageListener를 사용해 토픽별 메시지 리스너를 설정할 수 있음
DTO 설정
data class MessageSubResponse(
val subjectId: UUID,
val targetId: UUID,
val message : String
)
RedisPublisher 설정
@Service
class RedisPublisher(
private val channelTopic: ChannelTopic,
private val redisTemplate: RedisTemplate<String, Any>
) {
fun publish(message: MessageSubResponse) =
redisTemplate.convertAndSend(channelTopic.topic, message)
}
- convertAndSend: 메시지를 channelTopic에 게시
RedisSubscriber 설정
@Service
class RedisSubscriber(
private val objectMapper: ObjectMapper,
private val redisTemplate: RedisTemplate<String, Any>,
private val messageTemplate: SimpMessageSendingOperations
): MessageListener {
private val logger = LoggerFactory.getLogger(RedisSubscriber::class.java)
override fun onMessage(message: Message, pattern: ByteArray?) {
runCatching {
val publishMessage = redisTemplate.stringSerializer.deserialize(message.body)
val message = objectMapper.readValue(publishMessage, MessageSubResponse::class.java)
messageTemplate.convertAndSend("/sub/notification/${message.targetId}", message)
}.onFailure {
logger.error("Exception: {}", it.message)
}
}
}
- onMessage: Redis에서 메시지를 받을 때 호출 됨
- stringSerializer.deserialize: 받은 메시지(바이트 배열)를 문자열로 변환
- objectMapper.readValue: 변환된 문자열을 지정한 타입의 객체로 변환
- convertAndSend: 변환된 메시지를 WebSocket을 통해 클라이언트에게 전송
Controller 설정
알림은 서버에서 클라이언트로 보내기만 하면 되기 때문에 아래 Controller 설정은 예시이며, 실제는 쪽지를 전송하는 Service 부분에서 DB에 전송과 동시에 알림을 보내도록 publish 메서드를 호출했다.
@RestController
class NotificationController(
private val redisPublisher: RedisPublisher
) {
@MessageMapping("/notification")
fun sendNotification(message: MessageSubResponse) =
redisPublisher.publish(message)
}
- @MessageMapping: 지정된 경로로 들어오는 메시지를 해당 메서드가 처리하게 함
기능 구현을 위한 코드 - React
- stompjs: v7.0.0
- sockjs-client: v1.6.1
button을 로그인 버튼이라 생각하고 구현했으며, 로그아웃 및 브라우저가 닫힐 때 연결을 종료하는 부분에 대해 추가 구현이 필요하다.
import { Client } from "@stomp/stompjs";
import { useEffect, useState } from "react";
import SockJS from "sockjs-client";
const MainPage = () => {
const [client, setClient] = useState(null);
const requestNotification = () => {
console.log("client :>> ", client);
// 메시지 전송, 알림 채널에 메시지 전송
client.publish({
destination: "/pub/notification",
body: JSON.stringify({
subjectId: "subjectId",
targetId: "targetId",
message: "hello",
}),
});
};
useEffect(() => {
const stompClient = new Client({
webSocketFactory: () => new SockJS("http://localhost:8080/ws"),
connectHeaders: {
token: "token",
},
debug: (msg) => console.log(msg),
reconnectDelay: 50000,
onConnect: () => {
console.log("Connected");
stompClient.subscribe(
"/sub/notification/subjectId",
(notification) => {
console.log("notification: ", notification.body);
},
);
},
});
stompClient.activate();
setClient(stompClient);
return () => {
stompClient.deactivate();
};
}, []);
return <button onClick={requestNotification}>Button</button>;
};
export default MainPage;
Client 실행 순서
- Client에서 http://localhost:8080/ws로 연결 요청을 보낸다.
- 연결이 되면, 주제(/sub/notification/{memberId})에 대한 메시지를 구독한다.
- 해당 주제에 대한 메시지가 도착하면 콜백 함수가 실행된다.
- button을 누르면 지정된 주제(/pub/notification)로 메시지를 보낸다.
Server 실행 순서
- http://localhost:8080/ws로 요청이 들어오면 웹소켓 연결을 생성한다.
- SockJS를 사용하여 WebSocket을 대체할 수 있는 대체 전송을 활성화
- Client에서 pub/notification로 메시지를 보낸다.
- Controller대신 Service에서 호출하는 경우 제외
- RedisPublisher의 publish 메서드가 Topic에 대한 메시지를 발행(pub)한다.
- RedisPublisher에서 발행된 메시지를 RedisSubscriber가 전달 받고 구독 중인 Client에게 메시지를 발송한다.
- /sub/notification/${message.targetId}: 메시지에 포함된 수신자에게 발송
- 수신자는 위 주제를 구독하고 있어야 함
이번에 알림 기능을 구현하면서 클라이언트와 통신하는 여러가지 방법과 그에 따른 다양한 구현 방식, WebSocket, Redis pub/sub 등 다뤄보지 않았던 새로운 기술들을 사용하는 방법을 배우게 되었고, 이를 통해 실시간 알림 시스템의 중요성과 그 구현에 필요한 것들을 이해할 수 있는 좋은 경험이었다.
'Spring Boot' 카테고리의 다른 글
[Spring boot] S3 이미지 업로드 및 예외 처리 (0) | 2024.06.26 |
---|---|
[Spring Boot] Scheduler로 Soft Delete 데이터 삭제 (0) | 2024.06.21 |
[Spring Boot] Soft Delete 적용 (0) | 2024.06.20 |