Springboot 로 Socket(STOMP) 개발하기

2024. 10. 14. 13:39·Programming/BackEnd
반응형

- 이번 포스팅으로는 Springboot 로 Socket 을 개발 할 때, STOMP 를 사용하도록 하겠습니다.

 

- STOMP(Simple Text Oriented Messaging Protocol)

STOMP 는 텍스트 기반의 메시징 프로토콜로, 텍스트 메시지 전송을 위해 설계된 간단한 프로토콜입니다.

STOMP는 WebSocket 위에서 사용할 수 있으며, 다양한 메시징 시스템에 쉽게 적용할 수 있도록 만들어졌습니다.

STOMP는 클라이언트와 메시지 브로커 사이에서 상호작용을 단순화하는 역할을 합니다. 브로커 기반 메시징 시스템에서 클라이언트가 메시지를 보내고 받고, 구독하고 브로커와 소통하는 방식의 규칙을 정의합니다.

 

STOMP의 주요 특징은,
1. 텍스트 기반 프로토콜:
STOMP는 텍스트로 이루어진 프레임을 사용하여 통신합니다. 각 프레임은 간단한 헤더와 본문으로 구성되며, 메시지를 쉽게 주고받을 수 있습니다.

 

2. 메시지 큐와 토픽 지원:
STOMP는 메시지 큐(queue)와 토픽(topic)을 통해 메시지 전송을 지원합니다. 큐는 한 클라이언트가 소비하는 단방향 메시지 전송, 토픽은 여러 클라이언트가 구독하는 브로드캐스트 방식의 메시지 전송을 의미합니다.

 

3. 프레임 기반 통신:
STOMP는 프레임이라는 개념을 사용하여 클라이언트와 서버 간에 데이터를 주고받습니다. 프레임은 COMMAND, HEADER, BODY로 구성됩니다. 명령은 SEND, SUBSCRIBE, UNSUBSCRIBE, MESSAGE, ACK, NACK 등과 같은 작업을 나타냅니다.

 

4. 다양한 전송 계층 지원:
STOMP는 WebSocket뿐만 아니라 TCP, HTTP 등 다양한 프로토콜 위에서 동작할 수 있습니다. 이를 통해 STOMP를 사용하는 클라이언트는 메시지 전송을 위해 동일한 API를 사용할 수 있습니다.

 

5. 구독 및 메시지 전송:
STOMP는 클라이언트가 특정 토픽을 구독할 수 있도록 지원합니다. 이 구독을 통해 클라이언트는 브로커가 전송하는 메시지를 실시간으로 수신할 수 있습니다.
반대로 클라이언트가 메시지를 전송할 수도 있습니다. 전송된 메시지는 브로커가 처리하고 구독자들에게 전달됩니다.

 

이러하며,

 

STOMP 의 장점은,

1. 간단하고 이해하기 쉬운 프로토콜:
STOMP는 텍스트 기반이며, 메시지 형식이 매우 간단합니다. 이는 이해하기 쉽고 디버깅이 용이하다는 장점이 있습니다. 또한 클라이언트와 서버 간의 상호작용을 쉽게 처리할 수 있습니다.

 

2. 메시징 시스템에 대한 추상화:
STOMP는 메시지 브로커와 상호작용하는 방법을 표준화합니다. 브로커가 RabbitMQ, ActiveMQ와 같은 메시지 큐이든, 혹은 WebSocket 기반의 서버이든 상관없이 동일한 방식으로 STOMP를 사용하여 메시지를 전송하고 받을 수 있습니다.
이는 다양한 브로커에서 동일한 프로토콜을 사용할 수 있기 때문에 브로커 간의 전환이 쉬워집니다.

 

3. 구독 모델을 통한 효율적 메시징:
STOMP는 클라이언트가 토픽을 구독하고, 브로커가 해당 토픽에 메시지를 브로드캐스트하는 구조를 지원합니다. 이를 통해 실시간 통신이 매우 간단해집니다. 서버에서 여러 클라이언트에게 동시에 메시지를 전송할 때 효율적입니다.

 

4. 웹 애플리케이션에서의 유용성:
STOMP는 WebSocket 위에서 동작하기 때문에 웹 애플리케이션에서 실시간 메시징을 구현하는 데 매우 유용합니다. 클라이언트는 STOMP를 통해 서버에 연결하고 실시간으로 데이터를 주고받을 수 있습니다.

 

5. 다양한 언어 및 클라이언트 지원:
STOMP는 다양한 클라이언트 라이브러리와 함께 제공됩니다. 자바스크립트, Java, Python, .NET 등 다양한 언어에서 STOMP 클라이언트를 쉽게 사용할 수 있으며, 이를 통해 다양한 플랫폼에서 STOMP 기반 메시징을 구현할 수 있습니다.

 

6. 실시간 알림 기능:
STOMP는 서버에서 실시간으로 클라이언트에게 메시지를 푸시할 수 있는 구조를 가지고 있습니다. 이를 통해 실시간 알림이나 업데이트가 필요한 웹 애플리케이션에서 효율적으로 사용할 수 있습니다.

 

이러합니다.

 

굳이 기존 Socket 개발 방식에 더하여 STOMP 프로토콜을 사용하냐에 대해 간단히 요약하자면,

STOMP 는 양방향 통신 기능에만 초점을 맞춘 Socket 과는 달리 그에 더하여 메시징 시스템에 필요한 구독, 브로드캐스팅, 라우팅과 같은 기능을 구현함에 있어서 필수적인 요소들을 강제하며, Springboot 와 같은 프레임워크에서 바로 개발이 가능하도록 지원해주기에 '효율적이고 확장 가능한 메시징 시스템을 구축하는데 유용한 도구'라고 할 수 있습니다.

 

- 실습

이번 실습은, 이전글인 Springboot 로 Socket(SockJS) 개발하기 글에서와 마찬가지로,

STOMP 개발의 근본적인 코드를 쉽게 파악할 수 있도록 클라이언트에서 접속 후 메시지를 서버에 보내면 서버에서 정해진 응답을 다시 반환하는 에코 시스템을 만들겠습니다.

 

먼저 build.grale 에서,

    // (WebSocket)
    // : 웹소켓
    implementation("org.springframework.boot:spring-boot-starter-websocket:3.3.0")
    
    // WebSocket STOMP Controller 에서 입력값 매핑시 사용됨
    implementation("javax.persistence:persistence-api:1.0.2")

 

위와 같이 라이브러리를 설정하세요.

 

다음으로는, Socket 접속 설정을 위한 @Config 파일을 생성할 것입니다.

WebSocketStompConfig.kt 파일을 생성후,

import org.springframework.context.annotation.Configuration
import org.springframework.messaging.Message
import org.springframework.messaging.MessageChannel
import org.springframework.messaging.simp.config.ChannelRegistration
import org.springframework.messaging.simp.config.MessageBrokerRegistry
import org.springframework.messaging.simp.stomp.StompCommand
import org.springframework.messaging.simp.stomp.StompHeaderAccessor
import org.springframework.messaging.support.ChannelInterceptor
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker
import org.springframework.web.socket.config.annotation.StompEndpointRegistry
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration

// [WebSocket STOMP 설정]
@EnableWebSocketMessageBroker
@Configuration
class WebSocketStompConfig : WebSocketMessageBrokerConfigurer {
    override fun registerStompEndpoints(registry: StompEndpointRegistry) {
        registry
            // 접속 EndPoint
            // ex : var socket = new SockJS('http://localhost:8080/stomp');
            .addEndpoint("/stomp")
            // webSocket 연결 CORS 는 WebConfig 가 아닌 여기서 설정
            .setAllowedOriginPatterns("*")
            .withSockJS()
            .setClientLibraryUrl("https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.2/sockjs.js")
    }

    override fun configureMessageBroker(registry: MessageBrokerRegistry) {
        /*
             WebSocketStompController 의 MessageMapping 연결 주소
             @MessageMapping("/test") 라고 되어있다면,
             stompClient.send("/app/test", {}, JSON.stringify({'chat': "sample Text"}));
             이처럼 요청 가능
         */
        registry.setApplicationDestinationPrefixes("/app")

        /*
             구독 주소
             stompClient.subscribe('/topic', function (topic) {
                 // 구독 콜백 : 구독된 채널에 메세지가 날아오면 여기서 받음
             });
             위와 같이 topic 이라는 것을 구독하면,
             @SendTo("/topic") 로 설정 된 메세지 함수 실행 혹은
             simpMessagingTemplate.convertAndSend("/topic", TopicVo("waiting..."))
             이렇게 메세지 전달시 그 메세지를 받을 수 있습니다.
         */
        registry.enableSimpleBroker("/topic")
    }

    override fun configureClientInboundChannel(registration: ChannelRegistration) {
        registration.interceptors(object : ChannelInterceptor {
            override fun preSend(message: Message<*>, channel: MessageChannel): Message<*> {
                val accessor = StompHeaderAccessor.wrap(message)

                // 클라이언트 세션 아이디
                val sessionId = accessor.sessionId

                // 인증 토큰 (ex : "Bearer asdafdsaflkj123432")
//                val authorization = accessor.getFirstNativeHeader("Authorization")

                if (StompCommand.CONNECT == accessor.command) {
                    // 클라이언트 연결시
                    println("CONNECT")
                } else if (StompCommand.SUBSCRIBE == accessor.command) {
                    // 구독시
                    val destination = accessor.destination
                    println("SUBSCRIBE $destination")
                } else if (StompCommand.DISCONNECT == accessor.command) {
                    // 연결 해제시
                    // JavaScript 에서 stompClient.disconnect(); 실행시 이것이 두번 실행됩니다.
                    // sessionId 를 사용해서 중복 방지 처리를 하세요.
                    println("DISCONNECT $sessionId")
                }
                return message
            }
        })
    }

    override fun configureWebSocketTransport(registry: WebSocketTransportRegistration) {
        // WebSocket으로 전송되는 메시지의 최대 크기를 설정
        registry.setMessageSizeLimit(160 * 64 * 1024)
        // 메시지 전송에 대한 시간 제한을 설정
        registry.setSendTimeLimit(100 * 10000)
        // 송신 버퍼의 크기 제한을 설정
        registry.setSendBufferSizeLimit(3 * 512 * 1024)
    }
}

 

위와 같이 설정할 수 있습니다.

 

해석하자면, 

registerStompEndpoints 에서는 본 springboot 의 socket 연결 통로를 설정하는 곳으로,

기본적으로 sockjs 를 사용하여 localhost:8080/stomp 이러한 경로에 연결하면 된다는 것을 설정하였습니다.

 

JavaScript 로 예시를 들자면,

var socket = new SockJS('http://localhost:8080/stomp');
stompClient = Stomp.over(socket);

 

위와 같이 연결합니다.

 

configureMessageBroker 함수는 pub / sub 구조의 STOMP 에서 구독, 발행을 설정하는 것으로,

registry.enableSimpleBroker("/topic")

 

위와 같이 구독 채널 설정을 했다면,

 

javascript 를 예로들면,

stompClient.connect({}, function (frame) {
    setConnectionStatus(true);
    console.log('Connected: ' + frame);
    stompClient.subscribe('/topic', function (greeting) {
        showMessage(JSON.parse(greeting.body).content);
    });
});

위와 같이 연결이 완료된 stompClient 객체로 /topic 에 구독을 할 수 있습니다.

 

그렇다면 이제 해당 위치에 구독한 모든 클라이언트에 메시지를 발행할 수 있으며,

        private val simpMessagingTemplate: SimpMessagingTemplate

        simpMessagingTemplate.convertAndSend(
            "/topic",
            WebSocketStompController.TopicVo("$inputVo : SimpMessagingTemplate Test")
        )

위와 같이 springboot 코드로 해당 채널에 메시지를 보낸다면 해당 채널을 구독중인 모든 클라이언트가 메시지를 받을 수 있습니다.

 

다른 설정인

registry.setApplicationDestinationPrefixes("/app")

는, 클라이언트에서 서버로 보낼 메시지 주소의 접두사로,

뒤에서 설명할 것인데,

 

자바 스크립트로

stompClient.send("/app/test", {}, JSON.stringify({ 'chat': $("#message").val() }));

위와 같이 메시지를 보냅니다.

뒤에 다시 설명하겠습니다.

 

configureClientInboundChannel 설정은, 

STOMP 명령(연결, 구독, 해제 등)에 대한 처리를 위해 인터셉터를 등록합니다.

STOMP 명령(예: CONNECT, SUBSCRIBE, DISCONNECT)을 가로채서 세션 ID 등을 확인하거나, 클라이언트의 연결 상태에 대한 로깅 및 사용자 정의 로직을 처리할 수 있습니다.

 

- 자, 위와 같이 socket STOMP 의 설정은 완료되었습니다.

이제 이를 처리하는 로직을 만들 것으로,

이전 글에서 소켓 핸들러를 구현하는 것과는 다르게 이미 많은 기능이 준비가 된 상태입니다.

연결된 클라이언트를 저장하는 로직과, 메시지를 브로드캐스팅하는 기능이 준비된 것입니다.

 

이제 여기서 필요한 것은, 클라이언트 측에서 서버에 메시지를 보내는 발송 창구를 만드는 것 뿐입니다.

앞서 

registry.setApplicationDestinationPrefixes("/app")

라고 설명했는데, 이곳에 요청을 보내면 처리를 담당할 함수를 마치 rest api 컨트롤러 개발 방식과 같이 개발하는 것입니다.

 

WebSocketStompController.kt 라는 Springboot 컨트롤러 파일을 만들고,

import com.fasterxml.jackson.annotation.JsonProperty
import org.springframework.messaging.handler.annotation.MessageMapping
import org.springframework.messaging.handler.annotation.SendTo
import org.springframework.stereotype.Controller

// [WebSocket STOMP 컨트롤러]
// api1 은 external_files/files_for_api_test/html_file_sample/websocket-stomp.html 파일로 테스트 가능
@Controller
class WebSocketStompController(
    private val service: WebSocketStompService
) {
    // 메세지 함수 호출 경로 (WebSocketStompConfig 의 setApplicationDestinationPrefixes 설정과 합쳐서 호출 : /app/test)
    @MessageMapping("/test")
    // 이 함수의 리턴값 반환 위치(/topic 을 구독중인 유저에게 return 값을 반환)
    @SendTo("/topic")
    fun api1SendToTopicTest(inputVo: Api1SendToTopicTestInputVo): TopicVo {
        return service.api1SendToTopicTest(inputVo)
    }

    data class Api1SendToTopicTestInputVo(
        @JsonProperty("chat")
        val chat: String
    )


    // ---------------------------------------------------------------------------------------------
    // <채팅 데이터 VO 선언 공간>
    data class TopicVo(
        @JsonProperty("content")
        val content: String
    )
}

 

위와 같이 @Controller 클래스 안에,

@MessageMapping 으로 앤드포인트를 설정하면 됩니다.

자, 이렇게 된다면, 

stompClient.send("/app/test", {}, JSON.stringify({ 'chat': $("#message").val() }));

 

위와 같이 클라이언트에서 요청을 보내면 이 함수가 받게 됩니다.

 

보시다시피 일반적인 Springboot 컨트롤러 작성법과 같고, 입력값 매핑도 가능합니다.

입력 데이터 매핑을 통해 {chat : string} 타입의 텍스트 데이터를 받아 처리합니다.

 

참고로, 앞서 설정 파일에서

registry.setApplicationDestinationPrefixes("/app")

 

이렇게 입력했기에, 접두사와 매핑 앤드포인트를 합쳐 /app/test 로 정보를 전송한 것으로,

만약 

registry.setApplicationDestinationPrefixes("/app", "/app2")

 

이렇게 2개를 설정했다면 어떻게 될까요?

 

그렇다면 

stompClient.send("/app/test", {}, JSON.stringify({ 'chat': $("#message").val() }));

를 해도 위의 함수가 받고,

 

stompClient.send("/app2/test", {}, JSON.stringify({ 'chat': $("#message").val() }));

를 해도 위의 함수가 받아서 실행될 것입니다.

 

위 컨트롤러 함수를 구현하는 서비스 클래스 파일을 마지막으로 구현하겠습니다.

WebSocketStompService.kt 파일 안에,

import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.stereotype.Service

@Service
class WebSocketStompService(
    // (프로젝트 실행시 사용 설정한 프로필명 (ex : dev8080, prod80, local8080, 설정 안하면 default 반환))
    @Value("\${spring.profiles.active:default}") private var activeProfile: String,
    private val simpMessagingTemplate: SimpMessagingTemplate
) {
    // <멤버 변수 공간>
    private val classLogger: Logger = LoggerFactory.getLogger(this::class.java)


    // ---------------------------------------------------------------------------------------------
    // <공개 메소드 공간>
    fun api1SendToTopicTest(inputVo: WebSocketStompController.Api1SendToTopicTestInputVo): WebSocketStompController.TopicVo {
        // 이렇게 SimpMessagingTemplate 객체로 메세지를 전달할 수 있습니다.
        // /topic 을 구독하는 모든 유저에게 메시지를 전달하였습니다.
        simpMessagingTemplate.convertAndSend(
            "/topic",
            WebSocketStompController.TopicVo("$inputVo : SimpMessagingTemplate Test")
        )

        Thread.sleep(1000)

        // 이렇게 @SendTo 함수 결과값으로 메세지를 전달할 수도 있습니다.
        // 앞서 @SendTo 에 설정한 /topic 을 구독하는 모든 유저에게 마시지를 전달하였습니다.
        return WebSocketStompController.TopicVo("$inputVo : @SendTo Test")
    }
}

 

위와 같이 작성했습니다.

simpMessagingTemplate 라는 STOMP Socket 발송 객체를 주입받아서, 이를 위와 같이 convertAndSend 함수로 바로 전송도 가능하고, 위와 같이 컨트롤러 함수의 반환값(토픽별로 데이터 구조가 결정되도록 설계했습니다.)으로 반환을 하면,

앞서 컨트롤러에서 설정한,

@SendTo("/topic")

의 채널 경로로 데이터를 전송하게도 할 수 있습니다.

 

- 마지막으로 Socket STOMP 역시 테스트해봅시다.

<!DOCTYPE html>
<html>

<head>
    <title>WebSocket STOMP</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@3.3.7/dist/css/bootstrap.min.css" rel="stylesheet">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.6.1/sockjs.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
    <script>
        var stompClient = null;

        function setConnectionStatus(connected) {
            $("#connect").prop("disabled", connected);
            $("#disconnect").prop("disabled", !connected);
            if (connected) {
                $("#conversation").show();
            }
            else {
                $("#conversation").hide();
            }
            $("#chat").html("");
        }

        function connectSocket() {
            var socket = new SockJS('http://localhost:8080/stomp');
            stompClient = Stomp.over(socket);
            stompClient.connect({}, function (frame) {
                setConnectionStatus(true);
                console.log('Connected: ' + frame);
                stompClient.subscribe('/topic', function (greeting) {
                    showMessage(JSON.parse(greeting.body).content);
                });
            });
        }

        function disconnectSocket() {
            if (stompClient !== null) {
                stompClient.disconnect();
            }
            setConnectionStatus(false);
            console.log("Disconnected");
        }

        function sendMessage() {
            stompClient.send("/app2/test", {}, JSON.stringify({ 'chat': $("#message").val() }));
        }

        function showMessage(message) {
            $("#chat").append("<tr><td>" + message + "</td></tr>");
        }

        $(function () {
            $("form").on('submit', function (e) {
                e.preventDefault();
            });
            $("#connect").click(function () { connectSocket(); });
            $("#disconnect").click(function () { disconnectSocket(); });
            $("#send").click(function () { sendMessage(); });
        });
    </script>
</head>

<body>
    <noscript>
        <h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being
            enabled. Please enable
            Javascript and reload this page!</h2>
    </noscript>
    <div id="main-content" class="container">
        <div class="row">
            <div class="col-md-6">
                <form class="form-inline">
                    <div class="form-group">
                        <label for="connect">WebSocket connection:</label>
                        <button id="connect" class="btn btn-default" type="submit">Connect</button>
                        <button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect
                        </button>
                    </div>
                </form>
            </div>
            <div class="col-md-6">
                <form class="form-inline">
                    <div class="form-group">
                        <label for="message">Chat Message</label>
                        <input type="text" id="message" class="form-control" placeholder="chat message here...">
                    </div>
                    <button id="send" class="btn btn-default" type="submit">Send</button>
                </form>
            </div>
        </div>
        <div class="row">
            <div class="col-md-12">
                <table id="conversation" class="table table-striped">
                    <thead>
                        <tr>
                            <th>Chat</th>
                        </tr>
                    </thead>
                    <tbody id="chat">
                    </tbody>
                </table>
            </div>
        </div>
    </div>
</body>

</html>

 

위와 같이 테스트용 HTML 을 작성할 수 있으며,

 

Socket 테스트 화면

실행하면 위와 같이,

simpMessagingTemplate.convertAndSend(
            "/topic",
            WebSocketStompController.TopicVo("$inputVo : SimpMessagingTemplate Test")
        )

 

이 코드로 반환한 값과,

 

return WebSocketStompController.TopicVo("$inputVo : @SendTo Test")

이 리턴으로 반환한 값이 잘 도착함을 볼 수 있습니다.

 

- 이상입니다.

다음에는 시간이 된다면 Kafka 와 같은 외부 메시지 브로커로 양방향 통신을 개선하는 방식을 정리하겠습니다.

반응형
저작자표시 비영리 변경금지 (새창열림)

'Programming > BackEnd' 카테고리의 다른 글

Springboot kotlin JPA QueryDSL 설정 및 테스트  (11) 2024.10.16
Springboot logback 적용  (0) 2024.10.16
Springboot 로 Socket(SockJS) 개발하기  (3) 2024.10.14
Springboot kotlin Excel 파일 읽기, 쓰기 기능 구현  (0) 2024.10.11
Springboot Kotlin String 문자열을 투명 배경 서명 이미지(BufferedImage) 로 변경하기  (2) 2024.10.08
'Programming/BackEnd' 카테고리의 다른 글
  • Springboot kotlin JPA QueryDSL 설정 및 테스트
  • Springboot logback 적용
  • Springboot 로 Socket(SockJS) 개발하기
  • Springboot kotlin Excel 파일 읽기, 쓰기 기능 구현
Railly Linker
Railly Linker
IT 지식 정리 및 공유 블로그
Railly`s IT 정리노트IT 지식 정리 및 공유 블로그
  • Railly Linker
    Railly`s IT 정리노트
    Railly Linker
  • 전체
    오늘
    어제
  • 공지사항

    • 분류 전체보기 (170)
      • Programming (75)
        • BackEnd (36)
        • FrontEnd (13)
        • DBMS (3)
        • ETC (23)
      • Study (88)
        • Computer Science (21)
        • Data Science (24)
        • Computer Vision (22)
        • Computer Graphics (1)
        • NLP (15)
        • ETC (4)
      • Error Note (6)
      • ETC (1)
  • 인기 글

  • 최근 글

  • 최근 댓글

  • 태그

    springboot 배포
    docker 배포
    list
    jvm 메모리 누수
    지리 정보
    docker compose
    논리적 삭제
    kotlin arraylist
    MacOS
    network_mode: "host"
    kotlin mutablelist
    localhost
    kotlin linkedlist
    Kotlin
    unique
    데이터베이스 제약
    단축키
  • 링크

    • RaillyLinker Github
  • hELLO· Designed By정상우.v4.10.0
Railly Linker
Springboot 로 Socket(STOMP) 개발하기
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.