๊ธฐ์กด๊น์ง QnA ์น์ ๋ง๋ค์ด ์ฌ์ฉ์ค์ด์๋๋ฐ,
์ถ๊ฐ๋ก ์ฑํ ๋ฐฉ ์ฒ๋ผ, ์ค์๊ฐ ์ํต๋ ๊ฐ๋ฅํ๋ฉด ์ข๊ฒ ๋ค๋ ์๊ฐ์ด ๋ค์๋ค.
์ค์๊ฐ ์ฑํ ์ ์ํด ์ฌ์ฉํ ๊ธฐ์ ์คํ์ ๋ค์๊ณผ ๊ฐ๋ค.
|
๊ตฌ์ฑ ์์
|
์ ํ ๊ธฐ์
|
์ด์
|
|
๋ฉ์์ง ๋ธ๋ก์ปค
|
STOMP over WebSocket (Spring)
|
๋ณ๋ ์ธ๋ถ ๋ธ๋ก์ปค ์์ด ๋ด์ฅ์ผ๋ก ๊ฐ๋ฅ
|
|
์ค์๊ฐ ๋ฐ์ดํฐ ์ ์ฅ์
|
Redis Sorted Set
|
๊ฐ๋ณ ๋ฉ์์ง TTL ์ฒ๋ฆฌ์ ์ต์ ํ
|
|
์ฌ์ฉ์ ์๋ณ
|
JSESSIONID (HTTP ์ธ์
)
|
์๋ก๊ณ ์นจํด๋ ๋๋ค์ ์ ์ง
|
|
๋ณด์/์ธ์ฆ
|
Spring Security ์ธ์
๊ธฐ๋ฐ
|
๊ด๋ฆฌ์ ํ์ด์ง์ ์์ฐ์ค๋ฝ๊ฒ ์ฐ๋
|
WebSocket๊ณผ STOMP
WebSocket์ ํ ๋ฒ ์ฐ๊ฒฐ์ด ๋งบ์ด์ง๋ฉด ์๋ฒ์ ํด๋ผ์ด์ธํธ๊ฐ ์๋ฐฉํฅ์ผ๋ก ์์ ๋กญ๊ฒ ๋ฐ์ดํฐ๋ฅผ ์ฃผ๊ณ ๋ฐ์ ์ ์๋ ํต์ ํ๋กํ ์ฝ์ด๋ค.
์ฑํ ์ฒ๋ผ ์ค์๊ฐ์ฑ์ด ์ค์ํ ๊ธฐ๋ฅ์ ๋ง๋ค ๋๋ ํด๋ง ๋ฐฉ์๋ณด๋ค ํจ์ฌ ํจ์จ์ ์ด๋ค.
STOMP๋ WebSocket ์์์ ๋์ํ๋ ๋ฉ์์ง ํ๋กํ ์ฝ์ด๋ค.
๋ฉ์์ง์ ๋ชฉ์ ์ง์ ๊ตฌ๋ ๊ฐ๋ ์ ์ง์ํด ์ฃผ๊ธฐ ๋๋ฌธ์,
๋จ์ํ WebSocket๋ง ์ฐ๋ ๊ฒ๋ณด๋ค ์ฑํ ๋ฐฉ ๊ด๋ฆฌ, ํน์ ์ฌ์ฉ์์๊ฒ๋ง ๋ฉ์์ง ๋ณด๋ด๊ธฐ ๋ฑ์ ๊ตฌํํ๊ธฐ๊ฐ ํจ์ฌ ํธ๋ฆฌํ๋ค.
๊ธฐ๋ฅ ๊ตฌํ
๋จผ์ ์ฑํ ์ ๋ด์ DTO๋ ๋ค์๊ณผ ๊ฐ์ด ๊ตฌํํ๋ค.
id๋ Redis์์ ์ฐพ๊ธฐ ์ํ ๊ณ ์ ์๋ณ์๊ณ , sessionId์๋ JSESSIONID๋ฅผ ๋ด๋๋ค.
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
public class ChatMessage {
private String id;
private String sender;
private String content;
private String roomId;
private String timestamp;
private String sessionId;
}
๊ทธ๋ฆฌ๊ณ WebSocketConfig ํด๋์ค
Spring Boot์์๋ @EnableWebSocketMessageBroker ์ด๋ ธํ ์ด์ ์ผ๋ก STOMP ๋ธ๋ก์ปค๊ฐ ํ์ฑํ๋๋ค.
ํด๋ผ์ด์ธํธ๊ฐ /pub์ผ๋ก ๋ฉ์์ง๋ฅผ ๋ฐํํ๋ฉด, ์๋ฒ๋ /topic์ ๊ตฌ๋ ์ค์ธ ๋ชจ๋์๊ฒ ์ ์กํ๋ค.
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/pub");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-chat")
.addInterceptors(new HttpSessionHandshakeInterceptor())
.withSockJS();
}
}
๊ทธ๋ฆฌ๊ณ WebSocket ์ฐ๊ฒฐ์ ๋งค๋ฒ ์ STOMP ์ธ์ ์ ๋ง๋ค๊ธฐ ๋๋ฌธ์,
HTTP ์ธ์ ์ JSESSIONID๋ฅผ ๊ทธ๋๋ก ์ฐ๋ ค๋ฉด ํธ๋์ ฐ์ดํฌ ์์ ์ ๋ณต์ฌํด์ผ ํ๋ค.
๋ฐ๋ผ์ ์ธํฐ์ ํฐ์ ๋ช ์๋ฅผ ํด์ฃผ์๋ค.
public class HttpSessionHandshakeInterceptor implements HandshakeInterceptor {
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Map<String, Object> attributes) {
if (request instanceof ServletServerHttpRequest servletRequest) {
HttpSession session = servletRequest.getServletRequest().getSession(false);
if (session != null) {
attributes.put("JSESSIONID", session.getId());
}
}
return true;
}
}
์ถ๊ฐ๋ก ์ฑํ ์ ์ํด ํ์ํ ์น๋ SecurityConfig์ ๋ช ์ํ์๋ค.
@Bean
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/", "/questions/**", "/admin/**", "/css/**", "/js/**", "/images/**", "/webjars/**",
"/ws-chat/**", "/chat/**", "/api/chat/**")
...
return http.build();
}
์ด์ ์๋น์ค ๋ถ๋ถ์ ๊ตฌํํด๋ณด์.
ChatService ํด๋์ค๋ฅผ ์์ฑ ํ, ๋ฉ์์ง ์ ์ก ๋ฉ์๋๋ฅผ ๊ตฌํํ๋ค.
Sorted Set์ score๋ก ๋ฉ์์ง ์์ฑ ์๊ฐ๊ณผ ZADD๋ก JSON์ ์ ์ฅํด์ ์ ์กํ๋ค.
public void sendMessage(ChatMessage message) {
if (message.getId() == null) {
message.setId(UUID.randomUUID().toString());
}
String roomKey = "chat:zset:room:" + message.getRoomId();
long score = Instant.parse(message.getTimestamp()).toEpochMilli();
String json = objectMapper.writeValueAsString(message);
redisTemplate.opsForZSet().add(roomKey, json, score);
messagingTemplate.convertAndSend("/topic/room/" + message.getRoomId(), message);
}
๋ฉ์์ง๋ 6์๊ฐ์ด ์ง๋๋ฉด ๋ง๋ฃ์ฒ๋ฆฌ๋ฅผ ํ ๊ฒ์ด๋ฏ๋ก, ํด๋น ๋ฉ์๋๋ ๊ตฌํํด๋ณด์
์ฒ์์๋ Redis List๋ฅผ ์ผ์ง๋ง, List๋ ํค ์ ์ฒด์ TTL์ด ๊ฑธ๋ ค์ 6์๊ฐ ์ด์ง๋๋ฉด ๋ชจ๋ ๋ฉ์์ง๊ฐ ์ฌ๋ผ์ง๋ ๋ฌธ์ ๊ฐ ์์๋ค.
๋ฐ๋ผ์, Sorted Set์ผ๋ก ๋ฐ๊พธ๊ณ ์กฐํํ ๋๋ง๋ค ZREMRANGEBYSCORE๋ก ์ค๋๋ ๋ฐ์ดํฐ๋ฅผ ์ธ์ด๋๋ค.
์ด๋ก์จ ๋ณ๋ ์ค์ผ์ค๋ฌ ์์ด๋ ํด๋น ๊ธฐ๋ฅ์ ๊ตฌํ ํ ์ ์์๋ค.
public List<ChatMessage> getRecentMessages(String roomId) {
String roomKey = "chat:zset:room:" + roomId;
long cutoff = Instant.now().minusSeconds(60 * 60 * 6).toEpochMilli();
redisTemplate.opsForZSet().removeRangeByScore(roomKey, 0, cutoff);
Set<String> jsonSet = redisTemplate.opsForZSet().range(roomKey, 0, -1);
if (jsonSet == null || jsonSet.isEmpty()) return Collections.emptyList();
return jsonSet.stream()
.map(json -> {
try { return objectMapper.readValue(json, ChatMessage.class); }
catch (Exception e) { return null; }
})
.filter(Objects::nonNull)
.sorted(Comparator.comparingLong(m -> Instant.parse(m.getTimestamp()).toEpochMilli()))
.collect(Collectors.toList());
}
๋ง์ง๋ง์ผ๋ก ๊ด๋ฆฌ์์ฉ ๋ฉ์์ง ์ญ์ ๊ธฐ๋ฅ๋ ๊ตฌํํ๋ค.
public boolean deleteMessage(String roomId, String messageId) {
String roomKey = "chat:zset:room:" + roomId;
Set<String> all = redisTemplate.opsForZSet().range(roomKey, 0, -1);
if (all == null) return false;
for (String json : all) {
try {
ChatMessage msg = objectMapper.readValue(json, ChatMessage.class);
if (messageId.equals(msg.getId())) {
redisTemplate.opsForZSet().remove(roomKey, json);
ChatMessage notice = new ChatMessage();
notice.setId(UUID.randomUUID().toString());
notice.setSender("์์คํ
");
notice.setContent("๊ด๋ฆฌ์๊ฐ ๋ฉ์์ง๋ฅผ ์ญ์ ํ์ต๋๋ค.");
notice.setRoomId(roomId);
notice.setTimestamp(Instant.now().toString());
messagingTemplate.convertAndSend("/topic/room/" + roomId, notice);
return true;
}
} catch (Exception e) { ... }
}
return false;
}
๊ทธ๋ฆฌ๊ณ ChatSessionService ํด๋์ค๋ ๊ตฌํ
ConcurrentHashMap์ผ๋ก ์๋ฒ ๋ฉ๋ชจ๋ฆฌ์ ๋๋ค์์ JSESSIONID๋ณ๋ก ์ ์ฅํ๊ณ
์ด๋ฆ์ ์ต๋ช 1~99 ์ฌ์ด์์ ๋ฌด์์๋ก ์์ฑ ์์ผฐ๋ค.
์ฐจ๋จ์, Redis Set์ JSESSIONID๋ฅผ ๋ฃ๊ณ 6์๊ฐ TTL์ ๊ฑธ์๋ค. (์๊ฐ ์ง๋๋ฉด ์๋ ํด์ )
@Service
public class ChatSessionService {
private final ConcurrentHashMap<String, String> sessionNames = new ConcurrentHashMap<>();
private final StringRedisTemplate redisTemplate;
public String getOrCreateName(String sessionId) {
return sessionNames.computeIfAbsent(sessionId,
id -> "์ต๋ช
" + ThreadLocalRandom.current().nextInt(1, 100));
}
public boolean isBanned(String sessionId) {
return Boolean.TRUE.equals(redisTemplate.opsForSet().isMember("chat:banned:sessions", sessionId));
}
public void banSession(String sessionId) {
redisTemplate.opsForSet().add("chat:banned:sessions", sessionId);
redisTemplate.expire("chat:banned:sessions", Duration.ofHours(6));
}
}
์ด์ Controller๋ฅผ ๊ตฌํํด๋ณด์, ๋ฉ์์ง ์ ์ก๊ณผ ์ฐจ๋จ ์๋ฆผ ๊ธฐ๋ฅ์ด ํ์ํ๋ค.
์ฒ์์๋ convertAndSendToUser๋ฅผ ์จ์ ๊ฐ์ธ ๋ฉ์์ง๋ฅผ ๋ณด๋ด๋ ค ํ์ง๋ง,
/user/queue/ban ๊ตฌ๋ ์ด ์ ๋๋ก ๋์ํ์ง ์์๋ค.
๋ฐ๋ผ์ ์ฐจ๋จ๋ฉ์์ง๋ฅผ ๊ณต๊ฐ ์ฑ๋(/topic/room/main)์ผ๋ก ๋ณด๋ด๊ณ
ํด๋ผ์ด์ธํธ์์ ์์ ์ JSESSIONID์ ๋น๊ตํ์ฌ ํ์ํ๋ ๋ฐฉ์์ผ๋ก ๋ฐ๊ฟจ๋ค.
@MessageMapping("/chat.send")
public void sendMessage(ChatMessage message, Principal principal,
SimpMessageHeaderAccessor headerAccessor) {
String jsessionId = (String) headerAccessor.getSessionAttributes().get("JSESSIONID");
String sessionId = (jsessionId != null) ? jsessionId : headerAccessor.getSessionId();
if (chatSessionService.isBanned(sessionId)) {
ChatMessage banNotice = new ChatMessage();
banNotice.setSender("์์คํ
");
banNotice.setContent("๊ด๋ฆฌ์์ ์ํด ์ผ์์ ์ผ๋ก ์ฐจ๋จ๋์์ต๋๋ค.");
banNotice.setTimestamp(Instant.now().toString());
banNotice.setRoomId(message.getRoomId());
banNotice.setSessionId(sessionId);
messagingTemplate.convertAndSend("/topic/room/" + message.getRoomId(), banNotice);
return;
}
String nickname = chatSessionService.getOrCreateName(sessionId);
if (principal != null) {
Authentication auth = (Authentication) principal;
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"));
message.setSender(isAdmin ? "๊ด๋ฆฌ์" : nickname);
} else {
message.setSender(nickname);
}
message.setSessionId(sessionId);
message.setTimestamp(Instant.now().toString());
chatService.sendMessage(message);
}
AdminChatController์์๋ ์ ์ ์ผ์ ์ฐจ๋จ, ๋ฉ์์ง ์ญ์ ๊ธฐ๋ฅ์ ๊ตฌํํ๋ค.
@RestController
@RequestMapping("/admin/chat")
@RequiredArgsConstructor
public class AdminChatController {
private final ChatSessionService chatSessionService;
private final ChatService chatService;
@PostMapping("/ban/{sessionId}")
public ResponseEntity<?> banUser(@PathVariable String sessionId) {
chatSessionService.banSession(sessionId);
return ResponseEntity.ok().build();
}
@DeleteMapping("/messages/{messageId}")
public ResponseEntity<?> deleteMessage(@PathVariable String messageId) {
boolean deleted = chatService.deleteMessage("main", messageId);
return deleted ? ResponseEntity.ok().build() : ResponseEntity.notFound().build();
}
}
๊ธฐ๋ฅ ๊ตฌํ์ ์๋ฃํ์ผ๋ ํ๋ฉด์ ๊ตฌํํด๋ณด๋๋ก ํ์
ํ๋ฉด ๊ตฌํ
๊ธฐ๋ณธ์ ์ธ HTML ๋ผ๋๋ฅผ ๋ง๋ค๊ณ (์ฝ๋ ์๋ต)
ํ์ํ ๊ธฐ๋ฅ๋ค์ javascript๋ก ๋ง๋ค์ด์ผ ํ๋ค.
WebSocket ์ฐ๊ฒฐ๊ณผ ์์คํ ๋ฉ์์ง ํํฐ๋ง์ ํ๋ ๊ธฐ๋ฅ
์ฐจ๋จ ์๋ฆผ์ ๊ณต๊ฐ ์ฑ๋๋ก ์ค์ง๋ง, ์ฐจ๋จ ๋นํ ๋ณธ์ธ์ผ ๊ฒฝ์ฐ์ showMessage๋ฅผ ํธ์ถํ๋ค.
var stompClient = null;
var roomId = "main";
function connect() {
var socket = new SockJS('/ws-chat');
stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
stompClient.subscribe('/topic/room/' + roomId, function(message) {
var msg = JSON.parse(message.body);
if (msg.sender === '์์คํ
' && msg.sessionId !== myJSessionId) {
return;
}
showMessage(msg);
});
loadHistory();
});
}
๋ฉ์์ง ์ ์ก์ STOMP๋ฅผ ํตํด, /pub/chat.send๋ก JSON์ ๋ณด๋ธ๋ค.
์๋ฒ๋ ๋ฉ์์ง๋ฅผ ๋ฐ๊ณ ๋ฐ์ ์ ๋๋ค์์ ๊ฒฐ์ , redis์ ์ ์ฅ ํ ๊ตฌ๋ ์๋ค์๊ฒ broadcast ํ๋ค.
function sendMessage() {
var content = document.getElementById('message-input').value.trim();
if (content && stompClient) {
stompClient.send("/pub/chat.send", {}, JSON.stringify({
content: content,
roomId: roomId
}));
document.getElementById('message-input').value = '';
}
}
๋ฉ์์ง ํ์์, ๊ด๋ฆฌ์ ์ ์ฉ ๋ฒํผ ๋ฉ์๋
์ญ์ ๋ฒํผ์ ์์ฑ์ ๋ฉ์์ง UUID๋ฅผ ๋ด์, ํด๋ฆญ์ DELETE ์์ฒญ์ ๋ณด๋ด๊ณ
์ฐจ๋จ ๋ฒํผ์ ๋งค ์ฑํ ์ ๋ณด์ด๋ฉด ๋ณต์กํด์ง๋ฏ๋ก, ํด๋น ์ธ์ ์ ๋ง์ง๋ง ๋ฉ์์ง์๋ง ๋ณด์ด๊ฒ ํ๋ค.
var lastBanButtonElements = {};
function showMessage(message) {
var container = document.getElementById('chat-messages');
var div = document.createElement('div');
if (message.sender === '์์คํ
') {
div.className = 'chat-message system-chat';
} else if (message.sender === '๊ด๋ฆฌ์') {
div.className = 'chat-message admin-chat';
} else {
div.className = 'chat-message user-chat';
}
var html = '<strong>' + message.sender + ':</strong> ' + message.content +
' <small>' + new Date(message.timestamp).toLocaleTimeString() + '</small>';
if (isAdmin && message.id && message.sender !== '์์คํ
') {
html += ' <button class="delete-msg-btn" data-message-id="' + message.id + '">โ</button>';
}
div.innerHTML = html;
container.appendChild(div);
if (isAdmin && message.sessionId && message.sender !== '๊ด๋ฆฌ์' && message.sender !== '์์คํ
') {
addOrReplaceBanButton(div, message.sessionId);
}
...
}
๊ธฐ๋ฅ๊ณผ ํ๋ฉด๊น์ง ์๋ฃํ์ผ๋ ๋ง์ง๋ง CSS๋ก ๋์์ธ์ ๋ค๋ฌ์ด ๋ณด์
๋ฉ์์ง ์คํ์ผ์, ๋ฌธ์์ด์ด ์ฑํ ๋ฐฉ์ ๋์ง ์๊ฒ ํ๊ณ
๊ด๋ฆฌ์๋, ์์คํ ๋ฉ์์ง๋ ๋์์ธ ๋ณ๋๋ก ์ฃผ์๋ค.
.chat-message {
padding: 8px 12px;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
margin-bottom: 6px;
word-break: break-word;
}
.admin-chat {
background: #e3f2fd;
border-left: 4px solid #007aff;
}
...
์ฑํ ์ ๋ ฅ์ฐฝ๊ณผ ์ ์ก ๋ฒํผ์ ๋์์ธ๋ ์ค์
์ ๋ ฅ์ฐฝ๊ณผ ์ ์ก๋ฒํผ์ด ํ์ค์ ์์นํ๊ฒ nowrap๊ณผ flex์์ฑ์ ์กฐ์ ํ๋ค.
.input-group {
display: flex;
flex-wrap: nowrap;
align-items: stretch;
width: 100%;
}
...
๋ชจ๋ฐ์ผ ๋์์์๋ ์ฑํ ์ฐฝ์ด ๋๋ฌด ์์์ง์ง ์๊ฒ calc๋ก ๋์ ๋์ด๋ฅผ ์ค์ ํ๋ค.
@media (max-width: 768px) {
.chat-container {
height: calc(100vh - 220px);
min-height: 280px;
}
...
}
์ด์ธ ๊ธฐํ ์ธ๋ถ์ ์ธ ๋์์ธ ์กฐ์ ์ ํ๊ณ ๊ฐ๋ฐ์ ๋.
https://github.com/101Sean/devlog-qna
GitHub - 101Sean/devlog-qna: ๊ฐ๋ฐ ๋ธ๋ก๊ทธ QnA๋ฅผ ์ํ ๋๊ธ + ์ค์๊ฐ ์ฑํ ํ์ ์น
๊ฐ๋ฐ ๋ธ๋ก๊ทธ QnA๋ฅผ ์ํ ๋๊ธ + ์ค์๊ฐ ์ฑํ ํ์ ์น. Contribute to 101Sean/devlog-qna development by creating an account on GitHub.
github.com
ํ ์คํธ
์ด์ ๋ผ์ด๋ธ ์๋ฒ์ ์ฌ๋ฆฌ๊ธฐ ์ , ํ ์คํธ๋ฅผ ํด๋ณด์
๋จผ์ ์ฑํ ์ ์ธ์ ๋ณ๋ก ์ ์์ ์ผ๋ก ๋์์ด ๋๋ค.

๊ด๋ฆฌ์ ๊ด์ ์์๋ ์ฑํ ์ญ์ ์, ์ฐจ๋จ ๋ฒํผ์ด ์ ์ ๋์

๊ทธ๋ฆฌ๊ณ ์ฐจ๋จ๋ ์ธ์ ์ ๋ฉ์์ง๋ฅผ ์น ์ ์๊ฒ ๊ฒฝ๊ณ ์๋๊น์ง ์๋ฃ

์ด๋ก์จ ์ค์๊ฐ QnA ๊ธฐ๋ฅ๊น์ง ์์ฑํ๊ฒ ๋์๋ค.
๋น๋ถ๊ฐ์ ๋ ์ถ๊ฐํ ๊ธฐ๋ฅ์ ์์ด ๋ณด์ด์ง๋ง ํ์ํ ๋ ํ๋ํ๋ ์ถ๊ฐํด ๋๊ฐ์ผ๊ฒ ๋ค.
์๋ณธ ๊ฒ์๊ธ
https://blog.naver.com/101artspace/224339806196
[Spring/JS] Websocket์ ์ด์ฉํ ์ค์๊ฐ QnA ์ฑํ ๋ฐฉ ๋ง๋ค๊ธฐ
๊ธฐ์กด๊น์ง QnA ์น์ ๋ง๋ค์ด ์ฌ์ฉ์ค์ด์๋๋ฐ, ์ถ๊ฐ๋ก ์ฑํ ๋ฐฉ ์ฒ๋ผ, ์ค์๊ฐ ์ํต๋ ๊ฐ๋ฅํ๋ฉด ์ข๊ฒ ๋ค๋ ์๊ฐ...
blog.naver.com