[SPRING] 서버 측에서 스프링 프레임 워크로 안드로이드에 Stomp 클라이언트 설정
SPRING서버 측에서 스프링 프레임 워크로 안드로이드에 Stomp 클라이언트 설정
나는 봄에 구성된 돌풍 서버와 데이터를 교환하는 안드로이드 애플리케이션을 개발 중이다. 보다 역동적 인 안드로이드 응용 프로그램을 얻으려면 Stomp 메시지와 함께 WebSocket 프로토콜을 사용하려고합니다.
이 물건을 실현하기 위해 나는 봄에 웹 소켓 메시지 브로커를 설정했다.
@Configuration
//@EnableScheduling
@ComponentScan(
basePackages="project.web",
excludeFilters = @ComponentScan.Filter(type= FilterType.ANNOTATION, value = Configuration.class)
)
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.enableSimpleBroker("/message");
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/client");
}
}
Spring 컨트롤러의 SimpMessageSendingOperations를 사용하여 서버에서 클라이언트로 메시지 보내기 :
@Controller
public class MessageAddController {
private final Log log = LogFactory.getLog(MessageAddController.class);
private SimpMessageSendingOperations messagingTemplate;
private UserManager userManager;
private MessageManager messageManager;
@Autowired
public MessageAddController(SimpMessageSendingOperations messagingTemplate,
UserManager userManager, MessageManager messageManager){
this.messagingTemplate = messagingTemplate;
this.userManager = userManager;
this.messageManager = messageManager;
}
@RequestMapping("/Message/Add")
@ResponseBody
public SimpleMessage addFriendship(
@RequestParam String content,
@RequestParam Long otherUser_id
){
if(log.isInfoEnabled())
log.info("Execute MessageAdd action");
SimpleMessage simpleMessage;
try{
User curentUser = userManager.getCurrentUser();
User otherUser = userManager.findUser(otherUser_id);
Message message = new Message();
message.setContent(content);
message.setUserSender(curentUser);
message.setUserReceiver(otherUser);
messageManager.createMessage(message);
Message newMessage = messageManager.findLastMessageCreated();
messagingTemplate.convertAndSend(
"/message/add", newMessage);//send message through websocket
simpleMessage = new SimpleMessage(null, newMessage);
} catch (Exception e) {
if(log.isErrorEnabled())
log.error("A problem of type : " + e.getClass()
+ " has occured, with message : " + e.getMessage());
simpleMessage = new SimpleMessage(
new SimpleException(e.getClass(), e.getMessage()), null);
}
return simpleMessage;
}
}
stomp.js가있는 웹 브라우저에서이 구성을 테스트 할 때 어떤 문제도 없습니다. 웹 브라우저와 Jetty 서버간에 메시지가 완벽하게 교환됩니다. 웹 브라우저 테스트 용 JavaScript 코드 :
var stompClient = null;
function setConnected(connected) {
document.getElementById('connect').disabled = connected;
document.getElementById('disconnect').disabled = !connected;
document.getElementById('conversationDiv').style.visibility = connected ? 'visible' : 'hidden';
document.getElementById('response').innerHTML = '';
}
function connect() {
stompClient = Stomp.client("ws://YOUR_IP/client");
stompClient.connect({}, function(frame) {
setConnected(true);
stompClient.subscribe('/message/add', function(message){
showMessage(JSON.parse(message.body).content);
});
});
}
function disconnect() {
stompClient.disconnect();
setConnected(false);
console.log("Disconnected");
}
function showMessage(message) {
var response = document.getElementById('response');
var p = document.createElement('p');
p.style.wordWrap = 'break-word';
p.appendChild(document.createTextNode(message));
response.appendChild(p);
}
문제는 gozirra, activemq-stomp 또는 다른 라이브러리와 같이 Android에서 stomp를 사용하려고 할 때 발생합니다. 대부분의 경우 서버와의 연결이 작동하지 않습니다. 내 앱이 실행을 멈추고 잠시 후 logcat에 다음 메시지가 표시됩니다. java.net.UnknownHostException : 호스트 "ws : //192.168.1.39/client"를 확인할 수 없습니다 : 호스트 이름과 연결된 주소가 없습니다. 이유를 이해하지 못한다. 내 안드로이드 활동에서 stomp 호소력을 관리하는 Gozzira 라이브러리를 사용한 코드 :
private void stomp_test() {
String ip = "ws://192.172.6.39/client";
int port = 8080;
String channel = "/message/add";
Client c;
try {
c = new Client( ip, port, "", "" );
Log.i("Stomp", "Connection established");
c.subscribe( channel, new Listener() {
public void message( Map header, String message ) {
Log.i("Stomp", "Message received!!!");
}
});
} catch (IOException ex) {
Log.e("Stomp", ex.getMessage());
ex.printStackTrace();
} catch (LoginException ex) {
Log.e("Stomp", ex.getMessage());
ex.printStackTrace();
} catch (Exception ex) {
Log.e("Stomp", ex.getMessage());
ex.printStackTrace();
}
}
몇 가지 조사를 한 후에, 나는 자바 클라이언트와 함께 웹 소켓을 통해 스톰프를 사용하려는 대부분의 사람들이이 사이트 에서처럼 ActiveMQ 서버를 사용함을 발견했다. 그러나 봄 도구는 사용하기가 매우 쉽고 내 서버 계층을 그대로 유지하면 멋지게 될 것입니다. 누군가가 서버 측에서 스프링 구성을 사용하여 클라이언트 측에서 stomp java (Android)를 사용하는 방법을 알고 있습니까?
해결법
-
==============================
1.RxJava https://github.com/NaikSoftware/StompProtocolAndroid와 함께 안드로이드 (또는 일반 자바)에 대한 STOMP 프로토콜 구현. SpringBoot가 설치된 STOMP 서버에서 테스트되었습니다. 간단한 예제 (retrolambda 포함) :
RxJava https://github.com/NaikSoftware/StompProtocolAndroid와 함께 안드로이드 (또는 일반 자바)에 대한 STOMP 프로토콜 구현. SpringBoot가 설치된 STOMP 서버에서 테스트되었습니다. 간단한 예제 (retrolambda 포함) :
private StompClient mStompClient; // ... mStompClient = Stomp.over(WebSocket.class, "ws://localhost:8080/app/hello/websocket"); mStompClient.connect(); mStompClient.topic("/topic/greetings").subscribe(topicMessage -> { Log.d(TAG, topicMessage.getPayload()); }); mStompClient.send("/app/hello", "My first STOMP message!"); // ... mStompClient.disconnect();
프로젝트에 다음 클래스 경로를 추가하십시오.
classpath 'me.tatarka:gradle-retrolambda:3.2.0'
앱 build.gradle에 다음을 추가하십시오.
apply plugin: 'me.tatarka.retrolambda' android { ............. compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } } dependencies { ............................ compile 'org.java-websocket:Java-WebSocket:1.3.0' compile 'com.github.NaikSoftware:StompProtocolAndroid:1.1.5' }
모두 비동기 적으로 작동합니다! subscribe () 및 send () 후에 connect ()를 호출하면 메시지가 대기열로 푸시됩니다.
추가 기능 :
예 :
public class MainActivity extends AppCompatActivity { private StompClient mStompClient; public static final String TAG="StompClient"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); Button view = (Button) findViewById(R.id.button); view.setOnClickListener(e-> new LongOperation().execute("")); } private class LongOperation extends AsyncTask<String, Void, String> { private StompClient mStompClient; String TAG="LongOperation"; @Override protected String doInBackground(String... params) { mStompClient = Stomp.over(WebSocket.class, "ws://localhost:8080/app/hello/websocket"); mStompClient.connect(); mStompClient.topic("/topic/greetings").subscribe(topicMessage -> { Log.d(TAG, topicMessage.getPayload()); }); mStompClient.send("/app/hello", "My first STOMP message!").subscribe(); mStompClient.lifecycle().subscribe(lifecycleEvent -> { switch (lifecycleEvent.getType()) { case OPENED: Log.d(TAG, "Stomp connection opened"); break; case ERROR: Log.e(TAG, "Error", lifecycleEvent.getException()); break; case CLOSED: Log.d(TAG, "Stomp connection closed"); break; } }); return "Executed"; } @Override protected void onPostExecute(String result) { } } }
manifest.xml에 인터넷 권한 추가
<uses-permission android:name="android.permission.INTERNET" />
-
==============================
2.나는 안드로이드와 스프링 서버로 웹 소켓을 통해 스톰프를 사용하는 것을 성취한다.
나는 안드로이드와 스프링 서버로 웹 소켓을 통해 스톰프를 사용하는 것을 성취한다.
그런 일을하기 위해 나는 웹 소켓 라이브러리 werbench (이 링크를 따라 가며 다운로드)를 사용했다. 설치하려면 maven 명령 mvn install을 사용했고 로컬 저장소에 jar 파일을 다시 가져 왔습니다. 그런 다음 기본 웹 소켓 하나에 스톰프 레이어를 추가해야하지만 웹 소켓에서 스톰프를 관리 할 수있는 자바에서 스톰프 라이브러리를 찾을 수 없습니다 (gozzira를 포기해야 함). 그래서 저는 제 자신을 만듭니다 (모델과 같은 stomp.js로). 당신이 그것을보고 싶어하는지 나에게 묻는 것을 주저하지 마라. 그러나 나는 그것을 매우 빨리 실현했다. 그래서 그것은 stomp.js만큼 많이 관리 할 수 없다. 그럼, 내 스프링 서버와 인증을 실현해야합니다. 그것을 성취하기 위해 나는이 사이트의 표시를 따랐다. 내가 JSESSIONID 쿠키를 다시 얻었을 때, 나는 단지 stomp "라이브러리"에있는 werbench 웹 소켓의 인스턴스에이 쿠키가있는 헤더를 선언해야했습니다.
편집하다 : 이 라이브러리의 주요 클래스는 웹 소켓 연결을 통해 스톰프를 관리하는 클래스입니다.
import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; import android.util.Log; import de.roderick.weberknecht.WebSocket; import de.roderick.weberknecht.WebSocketEventHandler; import de.roderick.weberknecht.WebSocketMessage; public class Stomp { private static final String TAG = Stomp.class.getSimpleName(); public static final int CONNECTED = 1;//Connection completely established public static final int NOT_AGAIN_CONNECTED = 2;//Connection process is ongoing public static final int DECONNECTED_FROM_OTHER = 3;//Error, no more internet connection, etc. public static final int DECONNECTED_FROM_APP = 4;//application explicitely ask for shut down the connection private static final String PREFIX_ID_SUBSCIPTION = "sub-"; private static final String ACCEPT_VERSION_NAME = "accept-version"; private static final String ACCEPT_VERSION = "1.1,1.0"; private static final String COMMAND_CONNECT = "CONNECT"; private static final String COMMAND_CONNECTED = "CONNECTED"; private static final String COMMAND_MESSAGE = "MESSAGE"; private static final String COMMAND_RECEIPT = "RECEIPT"; private static final String COMMAND_ERROR = "ERROR"; private static final String COMMAND_DISCONNECT = "DISCONNECT"; private static final String COMMAND_SEND = "SEND"; private static final String COMMAND_SUBSCRIBE = "SUBSCRIBE"; private static final String COMMAND_UNSUBSCRIBE = "UNSUBSCRIBE"; private static final String SUBSCRIPTION_ID = "id"; private static final String SUBSCRIPTION_DESTINATION = "destination"; private static final String SUBSCRIPTION_SUBSCRIPTION = "subscription"; private static final Set<String> VERSIONS = new HashSet<String>(); static { VERSIONS.add("V1.0"); VERSIONS.add("V1.1"); VERSIONS.add("V1.2"); } private WebSocket websocket; private int counter; private int connection; private Map<String, String> headers; private int maxWebSocketFrameSize; private Map<String, Subscription> subscriptions; private ListenerWSNetwork networkListener; /** * Constructor of a stomp object. Only url used to set up a connection with a server can be instantiate * * @param url * the url of the server to connect with */ public Stomp(String url, Map<String,String> headersSetup, ListenerWSNetwork stompStates){ try { this.websocket = new WebSocket(new URI(url), null, headersSetup); this.counter = 0; this.headers = new HashMap<String, String>(); this.maxWebSocketFrameSize = 16 * 1024; this.connection = NOT_AGAIN_CONNECTED; this.networkListener = stompStates; this.networkListener.onState(NOT_AGAIN_CONNECTED); this.subscriptions = new HashMap<String, Subscription>(); this.websocket.setEventHandler(new WebSocketEventHandler() { @Override public void onOpen(){ if(Stomp.this.headers != null){ Stomp.this.headers.put(ACCEPT_VERSION_NAME, ACCEPT_VERSION); transmit(COMMAND_CONNECT, Stomp.this.headers, null); Log.d(TAG, "...Web Socket Openned"); } } @Override public void onMessage(WebSocketMessage message) { Log.d(TAG, "<<< " + message.getText()); Frame frame = Frame.fromString(message.getText()); boolean isMessageConnected = false; if(frame.getCommand().equals(COMMAND_CONNECTED)){ Stomp.this.connection = CONNECTED; Stomp.this.networkListener.onState(CONNECTED); Log.d(TAG, "connected to server : " + frame.getHeaders().get("server")); isMessageConnected = true; } else if(frame.getCommand().equals(COMMAND_MESSAGE)){ String subscription = frame.getHeaders().get(SUBSCRIPTION_SUBSCRIPTION); ListenerSubscription onReceive = Stomp.this.subscriptions.get(subscription).getCallback(); if(onReceive != null){ onReceive.onMessage(frame.getHeaders(), frame.getBody()); } else{ Log.e(TAG, "Error : Subscription with id = " + subscription + " had not been subscribed"); //ACTION TO DETERMINE TO MANAGE SUBCRIPTION ERROR } } else if(frame.getCommand().equals(COMMAND_RECEIPT)){ //I DON'T KNOW WHAT A RECEIPT STOMP MESSAGE IS } else if(frame.getCommand().equals(COMMAND_ERROR)){ Log.e(TAG, "Error : Headers = " + frame.getHeaders() + ", Body = " + frame.getBody()); //ACTION TO DETERMINE TO MANAGE ERROR MESSAGE } else { } if(isMessageConnected) Stomp.this.subscribe(); } @Override public void onClose(){ if(connection == DECONNECTED_FROM_APP){ Log.d(TAG, "Web Socket disconnected"); disconnectFromApp(); } else{ Log.w(TAG, "Problem : Web Socket disconnected whereas Stomp disconnect method has never " + "been called."); disconnectFromServer(); } } @Override public void onPing() { } @Override public void onPong() { } @Override public void onError(IOException e) { Log.e(TAG, "Error : " + e.getMessage()); } }); } catch (URISyntaxException e) { e.printStackTrace(); } } /** * Send a message to server thanks to websocket * * @param command * one of a frame property, see {@link Frame} for more details * @param headers * one of a frame property, see {@link Frame} for more details * @param body * one of a frame property, see {@link Frame} for more details */ private void transmit(String command, Map<String, String> headers, String body){ String out = Frame.marshall(command, headers, body); Log.d(TAG, ">>> " + out); while (true) { if (out.length() > this.maxWebSocketFrameSize) { this.websocket.send(out.substring(0, this.maxWebSocketFrameSize)); out = out.substring(this.maxWebSocketFrameSize); } else { this.websocket.send(out); break; } } } /** * Set up a web socket connection with a server */ public void connect(){ if(this.connection != CONNECTED){ Log.d(TAG, "Opening Web Socket..."); try{ this.websocket.connect(); } catch (Exception e){ Log.w(TAG, "Impossible to establish a connection : " + e.getClass() + ":" + e.getMessage()); } } } /** * disconnection come from the server, without any intervention of client side. Operations order is very important */ private void disconnectFromServer(){ if(this.connection == CONNECTED){ this.connection = DECONNECTED_FROM_OTHER; this.websocket.close(); this.networkListener.onState(this.connection); } } /** * disconnection come from the app, because the public method disconnect was called */ private void disconnectFromApp(){ if(this.connection == DECONNECTED_FROM_APP){ this.websocket.close(); this.networkListener.onState(this.connection); } } /** * Close the web socket connection with the server. Operations order is very important */ public void disconnect(){ if(this.connection == CONNECTED){ this.connection = DECONNECTED_FROM_APP; transmit(COMMAND_DISCONNECT, null, null); } } /** * Send a simple message to the server thanks to the body parameter * * * @param destination * The destination through a Stomp message will be send to the server * @param headers * headers of the message * @param body * body of a message */ public void send(String destination, Map<String,String> headers, String body){ if(this.connection == CONNECTED){ if(headers == null) headers = new HashMap<String, String>(); if(body == null) body = ""; headers.put(SUBSCRIPTION_DESTINATION, destination); transmit(COMMAND_SEND, headers, body); } } /** * Allow a client to send a subscription message to the server independently of the initialization of the web socket. * If connection have not been already done, just save the subscription * * @param subscription * a subscription object */ public void subscribe(Subscription subscription){ subscription.setId(PREFIX_ID_SUBSCIPTION + this.counter++); this.subscriptions.put(subscription.getId(), subscription); if(this.connection == CONNECTED){ Map<String, String> headers = new HashMap<String, String>(); headers.put(SUBSCRIPTION_ID, subscription.getId()); headers.put(SUBSCRIPTION_DESTINATION, subscription.getDestination()); subscribe(headers); } } /** * Subscribe to a Stomp channel, through messages will be send and received. A message send from a determine channel * can not be receive in an another. * */ private void subscribe(){ if(this.connection == CONNECTED){ for(Subscription subscription : this.subscriptions.values()){ Map<String, String> headers = new HashMap<String, String>(); headers.put(SUBSCRIPTION_ID, subscription.getId()); headers.put(SUBSCRIPTION_DESTINATION, subscription.getDestination()); subscribe(headers); } } } /** * Send the subscribe to the server with an header * @param headers * header of a subscribe STOMP message */ private void subscribe(Map<String, String> headers){ transmit(COMMAND_SUBSCRIBE, headers, null); } /** * Destroy a subscription with its id * * @param id * the id of the subscription. This id is automatically setting up in the subscribe method */ public void unsubscribe(String id){ if(this.connection == CONNECTED){ Map<String, String> headers = new HashMap<String, String>(); headers.put(SUBSCRIPTION_ID, id); this.subscriptions.remove(id); this.transmit(COMMAND_UNSUBSCRIBE, headers, null); } } }
이것은 Stomp 메시지의 프레임입니다.
import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; public class Frame { // private final static String CONTENT_LENGTH = "content-length"; private String command; private Map<String, String> headers; private String body; /** * Constructor of a Frame object. All parameters of a frame can be instantiate * * @param command * @param headers * @param body */ public Frame(String command, Map<String, String> headers, String body){ this.command = command; this.headers = headers != null ? headers : new HashMap<String, String>(); this.body = body != null ? body : ""; } public String getCommand(){ return command; } public Map<String, String> getHeaders(){ return headers; } public String getBody(){ return body; } /** * Transform a frame object into a String. This method is copied on the objective C one, in the MMPReactiveStompClient * library * @return a frame object convert in a String */ private String toStringg(){ String strLines = this.command; strLines += Byte.LF; for(String key : this.headers.keySet()){ strLines += key + ":" + this.headers.get(key); strLines += Byte.LF; } strLines += Byte.LF; strLines += this.body; strLines += Byte.NULL; return strLines; } /** * Create a frame from a received message. This method is copied on the objective C one, in the MMPReactiveStompClient * library * * @param data * a part of the message received from network, which represented a frame * @return * An object frame */ public static Frame fromString(String data){ List<String> contents = new ArrayList<String>(Arrays.asList(data.split(Byte.LF))); while(contents.size() > 0 && contents.get(0).equals("")){ contents.remove(0); } String command = contents.get(0); Map<String, String> headers = new HashMap<String, String>(); String body = ""; contents.remove(0); boolean hasHeaders = false; for(String line : contents){ if(hasHeaders){ for(int i=0; i < line.length(); i++){ Character c = line.charAt(i); if(!c.equals('\0')) body += c; } } else{ if(line.equals("")){ hasHeaders = true; } else { String[] header = line.split(":"); headers.put(header[0], header[1]); } } } return new Frame(command, headers, body); } // No need this method, a single frame will be always be send because body of the message will never be excessive // /** // * Transform a message received from server in a Set of objects, named frame, manageable by java // * // * @param datas // * message received from network // * @return // * a Set of Frame // */ // public static Set<Frame> unmarshall(String datas){ // String data; // String[] ref = datas.split(Byte.NULL + Byte.LF + "*");//NEED TO VERIFY THIS PARAMETER // Set<Frame> results = new HashSet<Frame>(); // // for (int i = 0, len = ref.length; i < len; i++) { // data = ref[i]; // // if ((data != null ? data.length() : 0) > 0){ // results.add(unmarshallSingle(data));//"unmarshallSingle" is the old name method for "fromString" // } // } // return results; // } /** * Create a frame with based fame component and convert them into a string * * @param command * @param headers * @param body * @return a frame object convert in a String, thanks to <code>toStringg()</code> method */ public static String marshall(String command, Map<String, String> headers, String body){ Frame frame = new Frame(command, headers, body); return frame.toStringg(); } private class Byte { public static final String LF = "\n"; public static final String NULL = "\0"; } }
이것은 stomp 프로토콜을 통해 가입을 설정하는 데 사용되는 객체입니다.
public class Subscription { private String id; private String destination; private ListenerSubscription callback; public Subscription(String destination, ListenerSubscription callback){ this.destination = destination; this.callback = callback; } public String getId() { return id; } public void setId(String id) { this.id = id; } public String getDestination() { return destination; } public ListenerSubscription getCallback() { return callback; } }
적어도, "Run"Java 클래스로 사용되는 두 개의 인터페이스가 웹 소켓 네트워크와 주어진 구독 채널을 수신 대기합니다.
public interface ListenerWSNetwork { public void onState(int state); } import java.util.Map; public interface ListenerSubscription { public void onMessage(Map<String, String> headers, String body); }
자세한 내용은 주저하지 말고 문의하십시오.
-
==============================
3.완벽한 솔루션에 페린은 고맙습니다. 예 : 전체 솔루션을 채우고 싶습니다. 귀하의 활동 / 서비스 단계에서 전화 연결 방법은 물론 MainThread에 없습니다.
완벽한 솔루션에 페린은 고맙습니다. 예 : 전체 솔루션을 채우고 싶습니다. 귀하의 활동 / 서비스 단계에서 전화 연결 방법은 물론 MainThread에 없습니다.
private void connection() { Map<String,String> headersSetup = new HashMap<String,String>(); Stomp stomp = new Stomp(hostUrl, headersSetup, new ListenerWSNetwork() { @Override public void onState(int state) { } }); stomp.connect(); stomp.subscribe(new Subscription(testUrl, new ListenerSubscription() { @Override public void onMessage(Map<String, String> headers, String body) { } })); }
그리고 webSocket weberknecht 라이브러리에서주의해야합니다. verifyServerHandshakeHeaders 메소드의 WebSocketHandshake 클래스의 버그입니다. (! headers.get ( "Connection"). equals ( "Upgrade"))와 서버가 업그레이드 대신 업그레이드를 보내는 경우에만 체크됩니다 당신은 오류 연결을 얻지 못했습니다 : 서버 핸드 셰이크의 헤더 필드가 누락되었습니다 : 연결을 해제해야합니다 (! headers.get ( "Connection"). equalsIgnoreCase ( "Upgrade"))
from https://stackoverflow.com/questions/24346068/set-up-a-stomp-client-in-android-with-spring-framework-in-server-side by cc-by-sa and MIT license
'SPRING' 카테고리의 다른 글
[SPRING] 스프링 MVC 애플리케이션에서 Null EntityManager 수정? (0) | 2019.01.15 |
---|---|
[SPRING] Spring-Boot : 여러 요청을 동시에 처리 (0) | 2019.01.15 |
[SPRING] @JsonView와 Spring MVC 사용하기 (0) | 2019.01.15 |
[SPRING] Spring Boot - ResourceLoader를 사용하여 텍스트 파일 읽기 (0) | 2019.01.15 |
[SPRING] Spring welcome-file-list 올바른 매핑 (0) | 2019.01.15 |