WebSocketConfig
package org.example.backendproject.stompwebsocket.config;
import org.example.backendproject.stompwebsocket.handler.CustomHandshakeHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
/**
* ๊ตฌ๋
์ค Prefix
* /topic ์ผ๋ฐ ์ฑํ
์ ๋ฐ์ ์ ๋์ด
* /queue ๊ท์๋ง์ ๋ฐ์ ์ ๋์ด
* ์๋ฒ๊ฐ ๋ณด๋ด๋ ๋ฉ์์ง๋ฅผ ํด๋ผ์ด์ธํธ๊ฐ ๊ตฌ๋
ํ ๋ ์ฌ์ฉํ๋ ๊ฒฝ๋ก
*/
registry.enableSimpleBroker("/topic", "/queue"); // ๊ตฌ๋
์ฉ ๊ฒฝ๋ก
/** ์ ์ก์ฉ Prefix **/
// ํด๋ผ์ด์ธํธ๊ฐ ์๋ฒ์ ๋ฉ์์ง๋ฅผ ๋ณด๋ผ ๋ ์ฌ์ฉํ๋ ๊ฒฝ๋ก ์ ๋์ด
registry.setApplicationDestinationPrefixes("/app"); // ํด๋ผ์ด์ธํธ -> ์๋ฒ
// ์๋ฒ๊ฐ ํน์ ์ฌ์ฉ์์๊ฒ ๋ฉ์์ง๋ฅผ ๋ณด๋ผ ๋, ํด๋ผ์ด์ธํธ๊ฐ ๊ตฌ๋
ํ ๊ฒฝ๋ก ์ ๋์ด
registry.setUserDestinationPrefix("/user"); // ์๋ฒ -> ํน์ ์ฌ์ฉ์
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-chat")
.setHandshakeHandler(new CustomHandshakeHandler())
.setAllowedOriginPatterns("*");
}
}
Java
๋ณต์ฌ
1. @EnableWebSocketMessageBroker
โข
์ด ์ด๋
ธํ
์ด์
์ Spring์ด WebSocket ๋ฉ์์ง ๋ธ๋ก์ปค ๊ธฐ๋ฅ์ ํ์ฑํํ๋๋ก ์ง์ํ๋ค. ์ด๋ฅผ ํตํด STOMP ๋ฉ์์ง ์ฒ๋ฆฌ๋ฅผ ์ํ ํ๊ฒฝ์ด ์๋์ผ๋ก ๊ตฌ์ฑ๋๋ค.
2. configureMessageBroker(MessageBrokerRegistry registry)
์ด ๋ฉ์๋๋ ๋ฉ์์ง ๋ธ๋ก์ปค์ ๋์ ๋ฐฉ์์ ์ค์ ํ๋ค.
โข
registry.enableSimpleBroker("/topic", "/queue");
โฆ
@EnableWebSocketMessageBroker์ ํจ๊ป ์ฌ์ฉ๋ ๋, ์ด ์ค์ ์ ์ธ๋ฉ๋ชจ๋ฆฌ(in-memory) ๋ฉ์์ง ๋ธ๋ก์ปค๋ฅผ ํ์ฑํํ๋ค.
โฆ
/topic: ์ผ๋ฐ์ ์ผ๋ก ๊ณต๊ฐ ์ฑํ
์ด๋ ์ฌ๋ฌ ์ฌ์ฉ์๊ฐ ๊ตฌ๋
ํ๋ ๋ฉ์์ง์ ์ฌ์ฉ๋๋ค. ์๋ฅผ ๋ค์ด, "/topic/publicChat"๊ณผ ๊ฐ์ ๊ฒฝ๋ก๋ก ๋ฉ์์ง๋ฅผ ๋ณด๋ด๋ฉด ํด๋น ๊ฒฝ๋ก๋ฅผ ๊ตฌ๋
ํ๋ ๋ชจ๋ ํด๋ผ์ด์ธํธ๊ฐ ๋ฉ์์ง๋ฅผ ์์ ํ๋ค.
โฆ
/queue: ์ฃผ๋ก ํน์ ์ฌ์ฉ์์๊ฒ 1:1 ๋ฉ์์ง(๊ท์๋ง)๋ฅผ ๋ณด๋ผ ๋ ์ฌ์ฉ๋ฉ๋๋ค. ํด๋ผ์ด์ธํธ๋ /user/queue/privateMessages์ ๊ฐ์ ๊ฒฝ๋ก๋ฅผ ๊ตฌ๋
ํ์ฌ ์์ ์๊ฒ ์จ ๋ฉ์์ง๋ฅผ ๋ฐ๊ฒ ๋๋ค.
โข
registry.setApplicationDestinationPrefixes("/app");
โฆ
๋ถ์: ํด๋ผ์ด์ธํธ๊ฐ ์๋ฒ๋ก ๋ฉ์์ง๋ฅผ ๋ณด๋ผ ๋ ์ฌ์ฉํ๋ ๋ชฉ์ ์ง(destination)์ ์ ๋์ด๋ฅผ ์ค์ ํ๋ค. ์๋ฅผ ๋ค์ด, ํด๋ผ์ด์ธํธ๊ฐ /app/chat.sendMessage๋ก ๋ฉ์์ง๋ฅผ ๋ณด๋ด๋ฉด, Spring์ @MessageMapping("/chat.sendMessage")์ ๊ฐ์ ์ด๋
ธํ
์ด์
์ด ๋ถ์ ์ปจํธ๋กค๋ฌ ๋ฉ์๋๋ฅผ ์ฐพ์ ํด๋น ๋ฉ์์ง๋ฅผ ์ฒ๋ฆฌํ๋ค.
โข
registry.setUserDestinationPrefix("/user");
โฆ
Spring STOMP๋ ์ฌ์ฉ์๋ณ(user-specific) ๋ฉ์์ง์ ์ง์ํ๋ค. ์ด ์ ๋์ด๋ ์๋ฒ๊ฐ ํน์ ์ฌ์ฉ์์๊ฒ ๋ฉ์์ง๋ฅผ ๋ณด๋ผ ๋ ์ฌ์ฉ๋๋ ๊ฒฝ๋ก์ ์ ๋์ด๋ฅผ ์ ์ํ๋ค. ์๋ฅผ ๋ค์ด, ์๋ฒ๊ฐ ํน์ userId๋ฅผ ๊ฐ์ง ์ฌ์ฉ์์๊ฒ ๋ฉ์์ง๋ฅผ ๋ณด๋ด๊ณ ์ ํ ๋, ๋ด๋ถ์ ์ผ๋ก /user/{userId}/queue/messages์ ๊ฐ์ ๊ฒฝ๋ก๋ฅผ ์ฌ์ฉํ๊ฒ ๋๋ค. ํด๋ผ์ด์ธํธ ์
์ฅ์์๋ stompClient.subscribe('/user/queue/messages')์ ๊ฐ์ด ๊ตฌ๋
ํ๋ฉด ์์ ์๊ฒ ์ ์ก๋ ๋ฉ์์ง๋ฅผ ๋ฐ์ ์ ์๋ค.
3. registerStompEndpoints(StompEndpointRegistry registry)
์ด ๋ฉ์๋๋ WebSocket ์ฐ๊ฒฐ์ ์ํ STOMP ์๋ํฌ์ธํธ๋ฅผ ๋ฑ๋กํ๋ค.
โข
registry.addEndpoint("/ws-chat"):
โฆ
ํด๋ผ์ด์ธํธ๊ฐ WebSocket ์ฐ๊ฒฐ์ ์๋ํ URL ๊ฒฝ๋ก๋ฅผ ์ ์ํ๋ค. ํด๋ผ์ด์ธํธ๋ new WebSocket("ws://localhost:8080/ws-chat")์ ๊ฐ์ด ์ด ๊ฒฝ๋ก๋ฅผ ์ฌ์ฉํ์ฌ ์ฐ๊ฒฐ์ ์์ํ๋ค. ์ด ๊ฒฝ๋ก๋ HTTP ํธ๋์
ฐ์ดํฌ๋ฅผ ์ฒ๋ฆฌํ๋ฉฐ, ์ฐ๊ฒฐ์ด ์ฑ๊ณตํ๋ฉด WebSocket ํ๋กํ ์ฝ๋ก ์
๊ทธ๋ ์ด๋ ๋๋ค.
โข
.setHandshakeHandler(new CustomHandshakeHandler()):
โฆ
WebSocket ํธ๋์
ฐ์ดํฌ ๊ณผ์ ์์ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์ปค์คํฐ๋ง์ด์งํ๊ฑฐ๋ ํน์ ๋ก์ง์ ์ถ๊ฐํ ๋ ์ฌ์ฉ๋ฉ๋๋ค. CustomHandshakeHandler ํด๋์ค๋ฅผ ํตํด ์ฌ์ฉ์ ์ธ์
์ ํน์ ์ ๋ณด๋ฅผ ์ ์ฅํ๊ฑฐ๋, ์ธ์ฆ/์ธ๊ฐ ๋ก์ง์ ์ฒ๋ฆฌํ ์ ์์ต๋๋ค. ์๋ฅผ ๋ค์ด, HTTP ์ธ์
์ ์๋ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ STOMP ์ธ์
์ ์ฐ๊ฒฐํ๋ ๋ฐ ์ ์ฉํ๋ค.
โข
.setAllowedOriginPatterns("*"):
โฆ
๋ถ์: ์ด ์ค์ ์ CORS(Cross-Origin Resource Sharing) ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด ์ฌ์ฉ๋๋ค. "*"๋ ๋ชจ๋ ์ค๋ฆฌ์ง(origin)์์์ ์ฐ๊ฒฐ์ ํ์ฉํ๋ค๋ ์๋ฏธ์ด๋ค. ๊ฐ๋ฐ ๋จ๊ณ์์๋ ํธ๋ฆฌํ์ง๋ง, ํ๋ก๋์
ํ๊ฒฝ์์๋ ๋ณด์์ ํน์ ๋๋ฉ์ธ์ผ๋ก ์ ํํ๋ ๊ฒ์ด ๊ฐ๋ ฅํ ๊ถ์ฅ๋๋ค. ์๋ฅผ ๋ค์ด, .setAllowedOriginPatterns("https://yourfrontend.com", "http://localhost:3000")์ ๊ฐ์ด ๋ช
์์ ์ผ๋ก ํ์ฉํ ์ค๋ฆฌ์ง์ ์ง์ ํด์ผ ํ๋ค.
CustomHandshakeHandler
CustomHandshakeHandler๋ WebSocket ํธ๋์
ฐ์ดํฌ ๊ณผ์ ์์ ์ฌ์ฉ์ ์ ๋ณด๋ฅผ ์ถ์ถํ๊ณ Principal ๊ฐ์ฒด๋ฅผ ์ค์ ํ๋ ๋ฐ ์ฌ์ฉ๋๋ค.
package org.example.backendproject.stompwebsocket.handler;
import java.security.Principal;
import java.util.Map;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
public class CustomHandshakeHandler extends DefaultHandshakeHandler {
@Override
protected Principal determineUser(ServerHttpRequest request,
WebSocketHandler wsHandler,
Map<String, Object> attributes) {
String nickname = getNickName(request.getURI().getQuery());
return new StompPrincipal(nickname);
}
private String getNickName(String query) {
if (query == null || !query.contains("nickname=")) {
return "๋๋ค์์์";
} else {
return query.split("nickname=")[1];
}
}
}
Java
๋ณต์ฌ
โข
DefaultHandshakeHandler๋ฅผ ์์๋ฐ์ WebSocket ํธ๋์
ฐ์ดํฌ์ ๊ธฐ๋ณธ ๋์์ ํ์ฅํ๋ค.
โข
determineUser ๋ฉ์๋๋ฅผ ์ค๋ฒ๋ผ์ด๋ํ์ฌ WebSocket ์ฐ๊ฒฐ ์ ์ฌ์ฉ์(Principal)๋ฅผ ๊ฒฐ์ ํ๋ค.
โข
์ด ๋ฉ์๋์์๋ ServerHttpRequest ๊ฐ์ฒด์์ ์์ฒญ URI์ ์ฟผ๋ฆฌ ์คํธ๋ง์ ๊ฐ์ ธ์ getNickName ๋ฉ์๋๋ฅผ ํธ์ถํ๋ค.
โข
getNickName ๋ฉ์๋๋ ์ฟผ๋ฆฌ ์คํธ๋ง์์ "nickname="์ผ๋ก ์์ํ๋ ๋ถ๋ถ์ ์ฐพ์ ๊ทธ ๋ค์ ๊ฐ์ ์ถ์ถํ๋ค. ๋ง์ฝ ์ฟผ๋ฆฌ ์คํธ๋ง์ด ์๊ฑฐ๋ "nickname="์ ํฌํจํ์ง ์์ผ๋ฉด ๊ธฐ๋ณธ๊ฐ์ผ๋ก "๋๋ค์์์"์ ๋ฐํํ๋ค.
โข
์ถ์ถ๋ ๋๋ค์ ๋ฌธ์์ด์ ์ฌ์ฉํ์ฌ StompPrincipal ๊ฐ์ฒด๋ฅผ ์์ฑํ๊ณ ์ด๋ฅผ Principal๋ก ๋ฐํํ๋ค. ์ด๋ ๊ฒ ์ค์ ๋ Principal์ ํด๋น WebSocket ์ธ์
์ ์ฌ์ฉ์ ์๋ณ์๋ก ์ฌ์ฉ๋๋ค.
โข
๋๋ค์์ URI ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ์์ ์ง์ ์ถ์ถํ๋ ๋ฐฉ์์ ๊ฐ๋จํ์ง๋ง, ๋ณด์์ ์ธ ์ธก๋ฉด์์ ์ทจ์ฝํ ์ ์๋ค. ์ค์ ์๋น์ค์์๋ ํ ํฐ(JWT ๋ฑ)์ด๋ ์ธ์
๊ธฐ๋ฐ ์ธ์ฆ์ ํตํด ์ฌ์ฉ์๋ฅผ ์์ ํ๊ฒ ์๋ณํ๊ณ , ํด๋น ์ธ์ฆ ์ ๋ณด์์ ์ฌ์ฉ์ ๊ณ ์ ID๋ ๋๋ค์์ ์ถ์ถํ๋ ๊ฒ์ด ํจ์ฌ ์์ ํ๋ค.
โข
์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ ํ์ฑ ๋ก์ง(getNickName)์ด ๋งค์ฐ ๊ฐ๋จํ์ฌ, "nickname=" ๋ฌธ์์ด์ด ์ฟผ๋ฆฌ ์ค๊ฐ์ ์๊ฑฐ๋ ๋ค๋ฅธ ํ๋ผ๋ฏธํฐ์ ์กฐํฉ๋ ๊ฒฝ์ฐ ์์์น ๋ชปํ ๋์์ ํ ์ ์๋ค. ์๋ฅผ ๋ค์ด, ?param=value&nickname=myname๊ณผ ๊ฐ์ ๊ฒฝ์ฐ์๋ ์ ์ ์๋ํ์ง๋ง, ?nickname=myname¶m=value์ฒ๋ผ ๋๋ค์์ด ๋จผ์ ๋์ค์ง ์์ผ๋ฉด ๋ฌธ์ ๊ฐ ๋ฐ์ํ ์ ์๋ค. UriComponentsBuilder๋ UriUtils ๊ฐ์ Spring ์ ํธ๋ฆฌํฐ๋ฅผ ํ์ฉํ์ฌ ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ๋ฅผ ์์ ํ๊ฒ ํ์ฑํ๋ ๊ฒ์ด ๋ ๊ฒฌ๊ณ ํ ๊ตฌํ ๋ฐฉ๋ฒ์ด๋ค.
โข
๋๋ค์์ด ์์ ๊ฒฝ์ฐ "๋๋ค์์์"์ผ๋ก ์ฒ๋ฆฌํ๋ ๋ถ๋ถ์ ๋๋ฒ๊น
์ด๋ ๊ฐ๋จํ ํ
์คํธ์๋ ์ ์ฉํ์ง๋ง, ์ค์ ์๋น์ค์์๋ ๋ช
ํํ ์๋ฌ ์ฒ๋ฆฌ๋ ๊ธฐ๋ณธ ๋๋ค์ ๋ถ์ฌ ์ ์ฑ
์ ๊ณ ๋ คํด์ผ ํ๋ค.
StompPrincipal
StompPrincipal์ Principal ์ธํฐํ์ด์ค๋ฅผ ๊ตฌํํ์ฌ STOMP ์ธ์
์์ ์ฌ์ฉ์๋ฅผ ์๋ณํ๋ ๋ฐ ์ฌ์ฉ๋๋ ์ปค์คํ
Principal ๊ตฌํ์ฒด์ด๋ค.
package org.example.backendproject.stompwebsocket.entity;
import java.security.Principal;
public class StompPrincipal implements Principal {
private final String name;
public StompPrincipal(String name) {
this.name = name;
}
@Override
public String getName() {
return name;
}
}
Java
๋ณต์ฌ
โข
java.security.Principal ์ธํฐํ์ด์ค๋ฅผ ๊ตฌํํ๋ค. ์ด ์ธํฐํ์ด์ค๋ ์ธ์ฆ๋ ๊ฐ์ฒด(์ฌ์ฉ์)์ ์ด๋ฆ์ ๋ํ๋ด๋ ๊ธฐ๋ณธ์ ์ธ ๊ธฐ๋ฅ์ ์ ๊ณตํ๋ค.
โข
name ํ๋๋ฅผ ํตํด ์ฌ์ฉ์์ ๊ณ ์ ์๋ณ์(์ด๋ฆ, ์ฌ๊ธฐ์๋ ๋๋ค์)๋ฅผ ์ ์ฅํ๋ค.
โข
getName() ๋ฉ์๋๋ฅผ ์ค๋ฒ๋ผ์ด๋ํ์ฌ name ํ๋์ ๊ฐ์ ๋ฐํํ๋ค.
Spring STOMP ๋ฉ์์ง ๋ธ๋ก์ปค๋ ์ด getName() ๋ฉ์๋๊ฐ ๋ฐํํ๋ ๊ฐ์ ๊ธฐ๋ฐ์ผ๋ก ์ฌ์ฉ์๋ณ ๋ฉ์์ง (์:/user/{userName}/queue/messages)์ ์ฒ๋ฆฌํ๋ค.
STOMP ํ๋กํ ์ฝ์ ํ์ฉํ ํต์ ํ๋ฆ
WebSocketConfig์์ /ws-chat ์๋ํฌ์ธํธ์ CustomHandshakeHandler๋ฅผ ๋ฑ๋กํ์ผ๋ฏ๋ก, ํด๋ผ์ด์ธํธ๊ฐ /ws-chat์ผ๋ก WebSocket ์ฐ๊ฒฐ์ ์๋ํ๋ฉด ๋ค์๊ณผ ๊ฐ์ ํ๋ฆ์ผ๋ก ์งํ๋๋ค.
1.
ํด๋ผ์ด์ธํธ์ WebSocket ์ฐ๊ฒฐ ์์ฒญ: ์น ๋ธ๋ผ์ฐ์ ๋ STOMP ํด๋ผ์ด์ธํธ ๋ผ์ด๋ธ๋ฌ๋ฆฌ๊ฐ ws://localhost:8080/ws-chat?nickname=์ฌ์ฉ์๋๋ค์๊ณผ ๊ฐ์ URL๋ก WebSocket ํธ๋์
ฐ์ดํฌ ์์ฒญ์ ๋ณด๋ธ๋ค.
2.
Spring์ ํธ๋์
ฐ์ดํฌ ์ฒ๋ฆฌ: Spring WebSocket ์ปจํ
์ด๋๋ ์ด ์์ฒญ์ ๊ฐ์งํ๊ณ WebSocketConfig์ ๋ฑ๋ก๋ CustomHandshakeHandler์ determineUser ๋ฉ์๋๋ฅผ ํธ์ถํ๋ค.
3.
determineUser ๋ฉ์๋ ์คํ:
โข
CustomHandshakeHandler์ determineUser ๋ฉ์๋๊ฐ ์คํ๋๋ค.
โข
request.getURI().getQuery()๋ฅผ ํตํด nickname=์ฌ์ฉ์๋๋ค์๊ณผ ๊ฐ์ ์ฟผ๋ฆฌ ์คํธ๋ง์ ๊ฐ์ ธ์จ๋ค.
โข
getNickName ๋ฉ์๋๊ฐ ํธ์ถ๋์ด ์ด ์ฟผ๋ฆฌ ์คํธ๋ง์์ "์ฌ์ฉ์๋๋ค์" ๋ถ๋ถ์ ์ถ์ถํ๋ค.
โข
์ถ์ถ๋ ๋๋ค์์ ์ธ์๋ก new StompPrincipal(nickname)์ ํธ์ถํ์ฌ StompPrincipal ๊ฐ์ฒด๊ฐ ์์ฑ๋๋ค.
โข
์ด StompPrincipal ๊ฐ์ฒด๊ฐ determineUser ๋ฉ์๋์ ๋ฐํ ๊ฐ์ผ๋ก Spring์ ์ ๋ฌ๋๋ค.
4.
Principal ๊ฐ์ฒด ์ค์ : Spring์ determineUser ๋ฉ์๋๊ฐ ๋ฐํํ StompPrincipal ๊ฐ์ฒด๋ฅผ ํ์ฌ WebSocket ์ธ์
์ Principal๋ก ์ค์ ํ๋ค. ์ด Principal์ STOMP ๋ฉ์์ง์ ํค๋(SimpMessageHeaderAccessor.USER_HEADER ๋ฑ)์ ์๋์ผ๋ก ์ถ๊ฐ๋์ด, ๋์ค์ @MessageMapping์ด ์ ์ฉ๋ ์ปจํธ๋กค๋ฌ ๋ฉ์๋๋ ๋ฉ์์ง ๊ฐ๋ก์ฑ๊ธฐ(Interceptor) ๋ฑ์์ ํ์ฌ ๋ฉ์์ง๋ฅผ ๋ณด๋ธ ์ฌ์ฉ์๋ฅผ ์๋ณํ๋ ๋ฐ ์ฌ์ฉ๋ ์ ์๋ค. ๋ํ, /user ์ ๋์ฌ๋ฅผ ์ฌ์ฉํ ์ฌ์ฉ์๋ณ ๋ฉ์์ง ์ ์ด Principal์ getName() ๊ฐ์ด ์ฌ์ฉ์ ์๋ณ์๋ก ํ์ฉ๋๋ค.
5.
WebSocket ์ฐ๊ฒฐ ์๋ฆฝ: ํธ๋์
ฐ์ดํฌ๊ฐ ์ฑ๊ณต์ ์ผ๋ก ์๋ฃ๋๋ฉด, ํด๋ผ์ด์ธํธ์ ์๋ฒ ๊ฐ์ WebSocket ์ฐ๊ฒฐ์ด ์๋ฆฝ๋๊ณ STOMP ๋ฉ์์ง๋ฅผ ์ฃผ๊ณ ๋ฐ์ ์ค๋น๊ฐ ๋๋ค.
6.
STOMP ๋ฉ์์ง: ์ดํ ํด๋ผ์ด์ธํธ๊ฐ /app ์ ๋์ด๋ฅผ ๊ฐ์ง ๋ชฉ์ ์ง๋ก ๋ฉ์์ง๋ฅผ ๋ณด๋ด๊ฑฐ๋, /topic, /queue, /user ์ ๋์ด๋ฅผ ๊ฐ์ง ๋ชฉ์ ์ง๋ฅผ ๊ตฌ๋
ํ ๋, ์ด ์ธ์
์ ์ฐ๊ฒฐ๋ StompPrincipal ๊ฐ์ฒด์ getName() ๋ฉ์๋๊ฐ ๋ฐํํ๋ ๋๋ค์ ๊ฐ์ด ์ฌ์ฉ์ ์๋ณ์๋ก ์ฌ์ฉ๋๋ค. ์๋ฅผ ๋ค์ด, ์๋ฒ๊ฐ /user/{nickname}/queue/messages๋ก ๋ฉ์์ง๋ฅผ ๋ณด๋ด๋ฉด, ์ด nickname์ ํด๋นํ๋ StompPrincipal์ ๊ฐ์ง ํด๋ผ์ด์ธํธ๋ง ๋ฉ์์ง๋ฅผ ์์ ํ๋ค.
RedisConfig
RedisConfig ํด๋์ค๋ Redis pub/sub ๋ฉ์์ง ์์คํ
์ ํต์ฌ ์ค์ ์ผ๋ก, ๋ฉ์์ง ๋ฆฌ์ค๋ ์ปจํ
์ด๋๋ฅผ ์ ์ํ๋ค.
package org.example.backendproject.stompwebsocket.redis;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
@RequiredArgsConstructor
@Configuration
public class RedisConfig {
private final RedisSubscriber redisSubscriber;
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory redisConnectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(redisConnectionFactory);
container.addMessageListener(new MessageListenerAdapter(redisSubscriber), new PatternTopic("room.*"));
container.addMessageListener(new MessageListenerAdapter(redisSubscriber), new PatternTopic("private.*")); // ๊ท์๋ง
return container;
}
}
Java
๋ณต์ฌ
โข
@Configuration ์ด๋
ธํ
์ด์
์ ์ด ํด๋์ค๊ฐ Spring์ ์ค์ ํด๋์ค์์ ๋ํ๋ธ๋ค.
โข
@RequiredArgsConstructor๋ final ํ๋์ธ redisSubscriber์ ๋ํ ์์ฑ์๋ฅผ ์๋์ผ๋ก ์์ฑํ๋ค.
โข
container ๋น์ RedisMessageListenerContainer๋ฅผ ์์ฑํ๊ณ ์ค์ ํ๋ค. ์ด ์ปจํ
์ด๋๋ Redis๋ก๋ถํฐ ๋ฉ์์ง๋ฅผ ์์ ํ๋ ์ญํ ์ ํ๋ค.
โข
container.setConnectionFactory(redisConnectionFactory);๋ฅผ ํตํด Redis ์ฐ๊ฒฐ ํฉํ ๋ฆฌ๋ฅผ ์ค์ ํ์ฌ Redis ์๋ฒ์์ ํต์ ์ ๊ด๋ฆฌํ๋ค.
โข
container.addMessageListener๋ฅผ ์ฌ์ฉํ์ฌ ๋ฉ์์ง ๋ฆฌ์ค๋(RedisSubscriber๋ฅผ ๋ํํ MessageListenerAdapter)๋ฅผ ํน์ ์ฑ๋ ํจํด์ ๋ฑ๋กํ๋ค.
โฆ
new PatternTopic("room.*"): "room."์ผ๋ก ์์ํ๋ ๋ชจ๋ ์ฑ๋(์: room.1, room.2)์์ ๋ฐํ๋๋ ๋ฉ์์ง๋ฅผ redisSubscriber๊ฐ ์์ ํ๋๋ก ์ค์ ํ๋ค. ์ด๋ ์ผ๋ฐ ์ฑํ
๋ฐฉ ๋ฉ์์ง ์ฒ๋ฆฌ์ ์ฌ์ฉ๋๋ค.
โฆ
new PatternTopic("private.*"): "private."์ผ๋ก ์์ํ๋ ๋ชจ๋ ์ฑ๋(์: private.user1, private.user2)์์ ๋ฐํ๋๋ ๋ฉ์์ง๋ฅผ redisSubscriber๊ฐ ์์ ํ๋๋ก ์ค์ ํ๋ค. ์ด๋ ๊ท์๋ง ์ฒ๋ฆฌ์ ์ฌ์ฉ๋๋ค.
RedisPublisher
RedisPublisher ํด๋์ค๋ Redis ์ฑ๋๋ก ๋ฉ์์ง๋ฅผ ๋ฐํํ๋ ์ญํ ์ ๋ด๋นํ๋ค.
package org.example.backendproject.stompwebsocket.redis;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@RequiredArgsConstructor
@Component
public class RedisPublisher {
private final StringRedisTemplate stringRedisTemplate;
/** ๋ฉ์ธ์ง๋ฅผ ๋ฐํํ๋ ํด๋์ค **/
public void publish(String channel, String msg) {
stringRedisTemplate.convertAndSend(channel, msg);
}
}
Java
๋ณต์ฌ
โข
@Component ์ด๋
ธํ
์ด์
์ ์ด ํด๋์ค๊ฐ Spring์ ์ปดํฌ๋ํธ ์ค์บ ๋์์ด ๋์ด ๋น์ผ๋ก ๋ฑ๋ก๋จ์ ๋ํ๋ธ๋ค.
โข
@RequiredArgsConstructor๋ StringRedisTemplate์ ๋ํ ์์ฑ์๋ฅผ ์๋์ผ๋ก ์์ฑํ๋ค.
โข
StringRedisTemplate์ Redis์์ ์ํธ์์ฉ์ ์ํ ํธ๋ฆฌํ ํ
ํ๋ฆฟ์ผ๋ก, ํนํ ๋ฌธ์์ด ๊ธฐ๋ฐ์ ํค-๊ฐ ๋ฐ์ดํฐ๋ฅผ ๋ค๋ฃฐ ๋ ์ ์ฉํ๋ค.
โข
publish ๋ฉ์๋๋ ์ฃผ์ด์ง channel๋ก msg๋ฅผ ๋ฐํํ๋ค. stringRedisTemplate.convertAndSend๋ Redis์ PUBLISH ๋ช
๋ น์ ์คํํ๋ค.
RedisSubscriber
RedisSubscriber ํด๋์ค๋ Redis ์ฑ๋๋ก๋ถํฐ ๋ฉ์์ง๋ฅผ ์์ ํ๊ณ , ์ด๋ฅผ WebSocket ํด๋ผ์ด์ธํธ๋ก ์ ์กํ๋ ์ญํ ์ ํ๋ค.
package org.example.backendproject.stompwebsocket.redis;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.example.backendproject.stompwebsocket.dto.ChatMessage;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class RedisSubscriber implements MessageListener {
private final SimpMessagingTemplate simpMessagingTemplate;
private ObjectMapper objectMapper = new ObjectMapper();
@Override
public void onMessage(Message message, byte[] pattern){
try {
String msgBody = new String(message.getBody());
ChatMessage chatMessage = objectMapper.readValue(msgBody, ChatMessage.class);
if (chatMessage.getTo() != null && !chatMessage.getTo().isEmpty()) {
simpMessagingTemplate.convertAndSendToUser(chatMessage.getTo(),
"/queue/private", chatMessage);
} else {
simpMessagingTemplate.convertAndSend("/topic/room." +
chatMessage.getRoomId(), chatMessage);
}
} catch (Exception e) {
// ์์ธ ์ฒ๋ฆฌ ๋ก์ง (๋ก๊น
๋ฑ)
}
}
}
Java
๋ณต์ฌ
โข
@Service ์ด๋
ธํ
์ด์
์ ์ด ํด๋์ค๊ฐ Spring์ ์๋น์ค ๋ ์ด์ด ์ปดํฌ๋ํธ์์ ๋ํ๋ธ๋ค.
โข
MessageListener ์ธํฐํ์ด์ค๋ฅผ ๊ตฌํํ์ฌ Redis ๋ฉ์์ง๋ฅผ ์์ ํ ์ ์๋๋ก ํ๋ค.
โข
@RequiredArgsConstructor๋ SimpMessagingTemplate์ ๋ํ ์์ฑ์๋ฅผ ์๋์ผ๋ก ์์ฑํ๋ค. SimpMessagingTemplate์ Spring WebSocket STOMP ๋ฉ์์ง ๋ธ๋ก์ปค๋ฅผ ํตํด ํด๋ผ์ด์ธํธ์๊ฒ ๋ฉ์์ง๋ฅผ ๋ณด๋ผ ๋ ์ฌ์ฉ๋๋ ํต์ฌ ๋๊ตฌ์ด๋ค.
โข
ObjectMapper๋ JSON ๋ฌธ์์ด์ Java ๊ฐ์ฒด๋ก ์ญ์ง๋ ฌํํ๋ ๋ฐ ์ฌ์ฉ๋๋ค.
โข
onMessage ๋ฉ์๋๋ Redis ์ฑ๋์์ ๋ฉ์์ง๊ฐ ์์ ๋ ๋๋ง๋ค ํธ์ถ๋๋ค.
โฆ
์์ ๋ Message ๊ฐ์ฒด์ ๋ณธ๋ฌธ(body)์ String์ผ๋ก ๋ณํํ๋ค.
โฆ
ObjectMapper๋ฅผ ์ฌ์ฉํ์ฌ ๋ฉ์์ง ๋ณธ๋ฌธ(JSON ๋ฌธ์์ด)์ ChatMessage ๊ฐ์ฒด๋ก ์ญ์ง๋ ฌํํ๋ค.
โฆ
chatMessage.getTo() ํ๋์ ์กด์ฌ ์ฌ๋ถ๋ฅผ ํ์ธํ์ฌ ๊ท์๋ง์ธ์ง ์ผ๋ฐ ๋ฉ์์ง์ธ์ง๋ฅผ ํ๋จํ๋ค.
โช
๊ท์๋ง์ธ ๊ฒฝ์ฐ: simpMessagingTemplate.convertAndSendToUser(chatMessage.getTo(), "/queue/private", chatMessage);๋ฅผ ์ฌ์ฉํ์ฌ ํน์ ์ฌ์ฉ์(chatMessage.getTo())์๊ฒ /queue/private ๋ชฉ์ ์ง๋ก ๋ฉ์์ง๋ฅผ ์ ์กํ๋ค. convertAndSendToUser๋ /user/{userName}/queue/private ํํ๋ก ๋ฉ์์ง๋ฅผ ๋ผ์ฐํ
ํ๋ค.
โช
์ผ๋ฐ ๋ฉ์์ง์ธ ๊ฒฝ์ฐ: simpMessagingTemplate.convertAndSend("/topic/room." + chatMessage.getRoomId(), chatMessage);๋ฅผ ์ฌ์ฉํ์ฌ ํด๋น ์ฑํ
๋ฐฉ์ topic ๋ชฉ์ ์ง(/topic/room.{roomId})๋ฅผ ๊ตฌ๋
ํ๊ณ ์๋ ๋ชจ๋ ํด๋ผ์ด์ธํธ์๊ฒ ๋ฉ์์ง๋ฅผ ์ ์กํ๋ค.
ChatController
ChatController ํด๋์ค๋ ํด๋ผ์ด์ธํธ๋ก๋ถํฐ STOMP ๋ฉ์์ง๋ฅผ ์์ ํ๊ณ , ์ด๋ฅผ Redis ์ฑ๋๋ก ๋ฐํํ๋ ์ญํ ์ ํ๋ค.
package org.example.backendproject.stompwebsocket.controller;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.example.backendproject.stompwebsocket.dto.ChatMessage;
import org.example.backendproject.stompwebsocket.redis.RedisPublisher;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
@Controller
@RequiredArgsConstructor
public class ChatController {
private final RedisPublisher redisPublisher;
private ObjectMapper objectMapper = new ObjectMapper();
@Value("${PROJECT_NAME:web Server}")
private String instanceName;
// ๋์ ์ผ๋ก ๋ฐฉ ์์ฑ ๊ฐ๋ฅ
@MessageMapping("/chat.sendMessage")
public void sendMessage(ChatMessage message) throws JsonProcessingException {
message.setMessage(instanceName + " " + message.getMessage());
String channel = null;
String msg = null;
if (message.getTo() != null && !message.getTo().isEmpty()) {
// ๊ท์๋ง
// ๋ด ์์ด๋๋ก ๊ท์๋ง ๊ฒฝ๋ก๋ฅผ ํ์ฑํ ํจ
channel = "private." + message.getRoomId(); // Note: room.Id in private chat might be confusing if it's not a real room
msg = objectMapper.writeValueAsString(message);
} else {
// ์ผ๋ฐ ๋ฉ์์ง
// message์์ roomId๋ฅผ ์ถ์ถํด์ ํด๋น roomId๋ฅผ ๊ตฌ๋
ํ๊ณ ์๋ ํด๋ผ์ด์ธํธ์๊ฒ ๋ฉ์ธ์ง๋ฅผ ์ ๋ฌ
channel = "room." + message.getRoomId();
msg = objectMapper.writeValueAsString(message);
}
redisPublisher.publish(channel, msg);
}
}
Java
๋ณต์ฌ
โข
@Controller ์ด๋
ธํ
์ด์
์ ์ด ํด๋์ค๊ฐ Spring MVC ์ปจํธ๋กค๋ฌ์์ ๋ํ๋ด์ง๋ง, WebSocket ๋ฉ์์ง์์๋ @MessageMapping์ ์ฒ๋ฆฌํ๋ ์ปดํฌ๋ํธ ์ญํ ์ ํ๋ค.
โข
@RequiredArgsConstructor๋ RedisPublisher์ ๋ํ ์์ฑ์๋ฅผ ์๋์ผ๋ก ์์ฑํ๋ค.
โข
@Value("${PROJECT_NAME:web Server}")๋ ํ๊ฒฝ ๋ณ์ PROJECT_NAME์ ๊ฐ์ instanceName ํ๋์ ์ฃผ์
ํ๋ฉฐ, ๊ธฐ๋ณธ๊ฐ์ "web Server"์ด๋ค. ์ด ๊ฐ์ ๋ฉ์์ง์ ์๋ฒ ์ธ์คํด์ค ์ด๋ฆ์ ์ถ๊ฐํ๋ ๋ฐ ์ฌ์ฉ๋๋ค.
โข
ObjectMapper๋ Java ๊ฐ์ฒด๋ฅผ JSON ๋ฌธ์์ด๋ก ์ง๋ ฌํํ๋ ๋ฐ ์ฌ์ฉ๋๋ค.
โข
@MessageMapping("/chat.sendMessage")๋ ํด๋ผ์ด์ธํธ๊ฐ /app/chat.sendMessage ๋ชฉ์ ์ง๋ก ๋ฉ์์ง๋ฅผ ๋ณด๋ผ ๋ ์ด sendMessage ๋ฉ์๋๊ฐ ํธ์ถ๋๋๋ก ๋งคํํ๋ค.
โข
sendMessage ๋ฉ์๋ ๋ด๋ถ:
โฆ
์์ ๋ ChatMessage์ ๋ฉ์์ง ๋ณธ๋ฌธ์ instanceName์ ์ถ๊ฐํ๋ค.
โฆ
chatMessage.getTo() ํ๋์ ์กด์ฌ ์ฌ๋ถ๋ฅผ ํ์ธํ์ฌ ๊ท์๋ง์ธ์ง ์ผ๋ฐ ๋ฉ์์ง์ธ์ง๋ฅผ ํ๋จํ๋ค.
โช
์ฃผ์์ฌํญ
โข
๊ท์๋ง์ธ ๊ฒฝ์ฐ: Redis ์ฑ๋์ private.{roomId}๋ก ์ค์ ํ๊ณ , ChatMessage ๊ฐ์ฒด๋ฅผ JSON ๋ฌธ์์ด๋ก ๋ณํํ๋ค. ์ฌ๊ธฐ์ roomId๊ฐ ๊ท์๋ง ์ฑ๋์ ์ฌ์ฉ๋๋ ๊ฒ์ ์กฐ๊ธ ์์ํ ์ ์๋ค. ๋ณดํต ๊ท์๋ง์ private.{์์ ์ID} ํํ๊ฐ ๋ ์ผ๋ฐ์ ์ด๋ค. ๋ง์ฝ roomId๊ฐ ์ค์ ์์ ์์ ๊ณ ์ ID๋ฅผ ๋ํ๋ธ๋ค๋ฉด ๋ฌธ์ ๊ฐ ์์ง๋ง, ๊ทธ๋ ์ง ์๋ค๋ฉด ํผ๋์ ์ค ์ ์๋ค.
โข
์ผ๋ฐ ๋ฉ์์ง์ธ ๊ฒฝ์ฐ: Redis ์ฑ๋์ room.{roomId}๋ก ์ค์ ํ๊ณ , ChatMessage ๊ฐ์ฒด๋ฅผ JSON ๋ฌธ์์ด๋ก ๋ณํํ๋ค.
โฆ
์ค๋น๋ channel๊ณผ msg๋ฅผ redisPublisher.publish ๋ฉ์๋๋ฅผ ํตํด Redis๋ก ๋ฐํํ๋ค.
Redis Pub/Sub์ ํตํ ์ ์ฒด ํต์ ํ๋ฆ
1.
ํด๋ผ์ด์ธํธ์ ๋ฉ์์ง ์ ์ก: WebSocket ํด๋ผ์ด์ธํธ๊ฐ STOMP๋ฅผ ํตํด /app/chat.sendMessage ๋ชฉ์ ์ง๋ก ChatMessage๋ฅผ ๋ณด๋ธ๋ค. (์: { "roomId": "general", "sender": "user1", "message": "Hello" } ๋๋ { "to": "user2", "roomId": "privateChatWithUser2", "sender": "user1", "message": "Hi private" })
2.
ChatController์ ๋ฉ์์ง ์์ : Spring์ @MessageMapping("/chat.sendMessage")์ ๋ฐ๋ผ ChatController์ sendMessage ๋ฉ์๋๋ฅผ ํธ์ถํ๋ค.
3.
Redis ์ฑ๋ ์ ํ ๋ฐ ๋ฐํ:
โข
ChatController๋ ์์ ๋ ChatMessage์ to ํ๋๋ฅผ ํ์ธํ์ฌ ์ผ๋ฐ ๋ฉ์์ง์ธ์ง ๊ท์๋ง์ธ์ง๋ฅผ ํ๋จํ๋ค.
โข
์ผ๋ฐ ๋ฉ์์ง: Redis ์ฑ๋์ room.{roomId}๋ก ์ค์ ํ๊ณ , ChatMessage๋ฅผ JSON ๋ฌธ์์ด๋ก ์ง๋ ฌํํ์ฌ RedisPublisher.publish๋ฅผ ํตํด Redis๋ก ๋ฐํํ๋ค.
โข
๊ท์๋ง: Redis ์ฑ๋์ private.{roomId} (์ฌ๊ธฐ์ roomId๋ ๊ท์๋ง ๋์์ ๊ณ ์ ID๊ฐ ๋์ด์ผ ํจ)๋ก ์ค์ ํ๊ณ , ChatMessage๋ฅผ JSON ๋ฌธ์์ด๋ก ์ง๋ ฌํํ์ฌ RedisPublisher.publish๋ฅผ ํตํด Redis๋ก ๋ฐํํ๋ค.
4.
Redis์ ๋ฉ์์ง ์ ๋ฌ: Redis๋ ๋ฐํ๋ ๋ฉ์์ง๋ฅผ ํด๋น ์ฑ๋์ ๊ตฌ๋
ํ๊ณ ์๋ ๋ชจ๋ RedisMessageListenerContainer ์ธ์คํด์ค(์ฆ, ์ฌ๋ฌ ์๋ฒ ์ธ์คํด์ค)๋ก ์ ๋ฌํ๋ค.
5.
RedisSubscriber์ ๋ฉ์์ง ์์ : ๊ฐ ์๋ฒ ์ธ์คํด์ค์ ์๋ RedisConfig์ ์ํด ๋ฑ๋ก๋ RedisSubscriber์ onMessage ๋ฉ์๋๊ฐ Redis๋ก๋ถํฐ ๋ฉ์์ง๋ฅผ ์์ ํ๋ค.
6.
๋ฉ์์ง ์ญ์ง๋ ฌํ ๋ฐ WebSocket ์ ์ก:
โข
RedisSubscriber๋ ์์ ๋ ๋ฉ์์ง ๋ณธ๋ฌธ์ ChatMessage ๊ฐ์ฒด๋ก ์ญ์ง๋ ฌํํ๋ค.
โข
ChatMessage์ to ํ๋๋ฅผ ๋ค์ ํ์ธํ๋ค.
โฆ
๊ท์๋ง: simpMessagingTemplate.convertAndSendToUser(chatMessage.getTo(), "/queue/private", chatMessage);๋ฅผ ์ฌ์ฉํ์ฌ ์๋ ๊ท์๋ง์ ๋์(chatMessage.getTo())์๊ฒ๋ง WebSocket ๋ฉ์์ง๋ฅผ ์ ์กํ๋ค. chatMessage.getTo()์ ํด๋นํ๋ ์ฌ์ฉ์๋ง "/user/{to}/queue/private"๋ฅผ ๊ตฌ๋
ํ์ฌ ์ด ๋ฉ์์ง๋ฅผ ๋ฐ๊ฒ ๋๋ค.
โฆ
์ผ๋ฐ ๋ฉ์์ง: simpMessagingTemplate.convertAndSend("/topic/room." + chatMessage.getRoomId(), chatMessage);๋ฅผ ์ฌ์ฉํ์ฌ ํด๋น ์ฑํ
๋ฐฉ์ ๊ตฌ๋
ํ๊ณ ์๋ ๋ชจ๋ ํด๋ผ์ด์ธํธ์๊ฒ WebSocket ๋ฉ์์ง๋ฅผ ์ ์กํ๋ค. ์ด๋ค์ "/topic/room.{roomId}"๋ฅผ ๊ตฌ๋
ํ๊ณ ์๋ค.
7.
ํด๋ผ์ด์ธํธ์ ๋ฉ์์ง ์์ : WebSocket ํด๋ผ์ด์ธํธ๋ ๊ตฌ๋
ํ๊ณ ์๋ STOMP ๋ชฉ์ ์ง(์: /topic/room.{roomId} ๋๋ /user/{myNickname}/queue/private)๋ฅผ ํตํด ๋ฉ์์ง๋ฅผ ์์ ํ๋ค.
์ด ์ค๊ณ์ ์ฅ์ :
โข
์ํ ํ์ฅ์ฑ: Redis Pub/Sub์ ์ฌ์ฉํ๋ฉด ์ฌ๋ฌ Spring WebSocket ์๋ฒ ์ธ์คํด์ค๋ฅผ ์คํํ์ฌ ๋ถํ๋ฅผ ๋ถ์ฐํ๊ณ ์๋น์ค์ ํ์ฅ์ฑ์ ํ๋ณดํ ์ ์๋ค. ์ด๋ค ์๋ฒ ์ธ์คํด์ค๋ก ๋ฉ์์ง๊ฐ ๋ค์ด์ค๋ Redis๋ฅผ ํตํด ๋ชจ๋ ๊ด๋ จ ์๋ฒ ์ธ์คํด์ค์ ์ ๋ฌ๋๊ณ , ๊ฐ ์ธ์คํด์ค๋ ์ฐ๊ฒฐ๋ ํด๋ผ์ด์ธํธ์๊ฒ ๋ฉ์์ง๋ฅผ ์ ๋ฌํ ์ ์๋ค.
โข
Decoupling (๊ฒฐํฉ๋ ๊ฐ์): ๋ฉ์์ง ๋ฐํ์์ ๊ตฌ๋
์๊ฐ ์ง์ ํต์ ํ๋ ๊ฒ์ด ์๋๋ผ Redis๋ฅผ ํตํด ๊ฐ์ ์ ์ผ๋ก ํต์ ํ๋ฏ๋ก, ์์คํ
์ปดํฌ๋ํธ ๊ฐ์ ๊ฒฐํฉ๋๊ฐ ๋ฎ์์ง๋ค.