Skip to content

Handshake failed due to invalid Upgrade header: null

๋ณธ ๊ธ€์€ ์œ„์™€ ๊ฐ™์€ ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ ์‹œ์— ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์˜ค๋ฅ˜ ๋กœ๊ทธ๊ฐ€ ๋ฐœ์ƒํ•œ ๊ฑด์— ๋Œ€ํ•œ ๊ด€๋ จ ๋‚ด์šฉ์„ ๊ธฐ๋กํ•˜๊ธฐ ์œ„ํ•œ ๊ฒƒ ์ž…๋‹ˆ๋‹ค. ์ด ์˜ค๋ฅ˜ ๋กœ๊ทธ๋Š” ์Šคํ”„๋ง ์›น ์†Œ์ผ“ ๋ชจ๋“ˆ์—์„œ DefaultHandshakeHandler๋ฅผ ํ†ตํ•ด ํ•ธ๋“œ์‰์ดํฌ๋ฅผ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ณผ์ •์—์„œ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ์— ๋Œ€ํ•ด ์˜ค๋ฅ˜ ๋กœ๊ทธ๋กœ ๊ธฐ๋กํ•˜๋„๋ก ๋˜์–ด์žˆ๋Š”๋ฐ Upgrade ํ—ค๋”์— ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ๊ฐ’์ด ์ „๋‹ฌ๋˜์—ˆ๋‹ค๋Š” ์˜๋ฏธ์ž…๋‹ˆ๋‹ค.

Connection: Upgrade Upgrade: websocket

์ผ๋ฐ˜์ ์œผ๋กœ ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ์€ Protocol upgrade mechanism์œผ๋กœ HTTP ํ†ต์‹ ์— ๋Œ€ํ•ด ์ปค๋„ฅ์…˜์„ ์ „ํ™˜ํ•˜๋Š” ๊ณผ์ •์„ ๊ฑฐ์น˜๊ฒŒ ๋˜๋Š”๋ฐ ์„œ๋ฒ„์—์„œ๋Š” ์›น์†Œ์ผ“ ์—”๋“œํฌ์ธํŠธ์— ํ•ด๋‹นํ•˜๋Š” ์š”์ฒญ์— ๋Œ€ํ•ด์„œ๋Š” Upgrade ํ—ค๋”๋ฅผ ํ™•์ธํ•˜๊ณ  websocket ์ด ์ „๋‹ฌ๋˜์—ˆ๋Š”์ง€๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

์›น์†Œ์ผ“ ํ”„๋ก์‹œ โ€‹

์‹œ์Šคํ…œ์ด ๋™์ž‘ํ•˜๋Š” ์ธํ”„๋ผ ํ™˜๊ฒฝ์€ AWS ํด๋ผ์šฐ๋“œ ์„œ๋น„์Šค๋กœ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ผ๋ฐ˜์ ์œผ๋กœ ๋กœ๋“œ๋ฐธ๋Ÿฐ์‹ฑ์„ ์œ„ํ•ด์„œ ์‚ฌ์šฉํ•˜๋Š” Elastic Load Balancing ๊ธฐ๋Šฅ์— ๋”ฐ๋ฅด๋ฉด ALB, NLB ๋ชจ๋‘ ์›น์†Œ์ผ“์„ ์ง€์›ํ•œ๋‹ค๊ณ  ๋˜์–ด์žˆ์œผ๋ฏ€๋กœ ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ์— ์ œํ•œ์ ์ธ ํ™˜๊ฒฝ์€ ์•„๋‹™๋‹ˆ๋‹ค. ํ˜„์žฌ ์กฐ์ง์—์„œ ํ”Œ๋žซํผ์œผ๋กœ์จ ์ œ๊ณตํ•˜๋Š” ํ™˜๊ฒฝ์€ EC ํ‚ค ๊ธฐ๋ฐ˜์˜ ์ธ์ฆ์„œ์˜ ์ œ์•ฝ์‚ฌํ•ญ์œผ๋กœ NLB๋ฅผ ์‚ฌ์šฉํ•ด์„œ TCP ํ”„๋ก์‹œ๋ฅผ ์ˆ˜ํ–‰ํ•˜๊ณ  SSL ์˜คํ”„๋กœ๋“œ๋Š” Nginx์—์„œ ์ˆ˜ํ–‰ํ•œ ํ›„ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋ฒ„๋กœ ํŠธ๋ž˜ํ”ฝ์ด ์ „๋‹ฌ๋˜๋Š” ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค.

๋‹ค๋งŒ, ์œ„ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ๋‹ค๊ณ  ์•ˆ๋‚ด๋œ ํŠน์ • ๊ณ ๊ฐ์ด ์ง์ ‘ ๊ตฌ์„ฑํ•˜๋Š” ํ™˜๊ฒฝ์—์„œ๋Š” ELB ๋ ˆ๋ฒจ์—์„œ SSL ์˜คํ”„๋กœ๋“œ๋ฅผ ์ˆ˜ํ–‰ํ•œ ํ›„์˜ ํŠธ๋ž˜ํ”ฝ๋งŒ Nginx๋กœ ์ „๋‹ฌ๋˜์–ด ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„œ๋ฒ„๋กœ ํ”„๋ก์‹œ๋˜๋ฏ€๋กœ ์•ฝ๊ฐ„์˜ ์š”์ฒญ์ด ์ „๋‹ฌ๋˜๋Š” ๋ฐฉ์‹์ด ๋‹ค๋ฆ…๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ, ELB ๋ ˆ๋ฒจ์—์„œ ํŠธ๋ž˜ํ”ฝ์„ ์ „๋‹ฌํ•˜๋Š” ๊ณผ์ •์—์„œ Upgrade ํ—ค๋”๊ฐ€ ์œ ์‹ค๋  ๊ฐ€๋Šฅ์„ฑ๋„ ์˜์‹ฌํ•ด๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ฐ ํ™˜๊ฒฝ์˜ Nginx์—๋Š” Upgarde ํ—ค๋”์— ๋Œ€ํ•œ ํ”„๋ก์‹œ ๊ตฌ์„ฑ์— ๋”ฐ๋ผ Upgarde ํ—ค๋” ๊ฐ’์„ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์œผ๋กœ ์ „๋‹ฌ๋˜๋„๋ก $http_upgrade ๋ณ€์ˆ˜๊ฐ€ ์„ค์ •๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

ํƒ€์ž„์•„์›ƒ โ€‹

์ผ๋ฐ˜์ ์œผ๋กœ AWS ํด๋ผ์šฐ๋“œ ํ™˜๊ฒฝ์—์„œ ELB๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ELB ๋กœ๋“œ๋ฐธ๋Ÿฐ์„œ ์†์„ฑ์˜ idle_timeout.timeout_seconds ๊ธฐ๋ณธ๊ฐ’์ด 60์ดˆ ์ด์–ด์„œ ๋ฐœ์ƒํ•˜๋Š” ๋ถ€๋ถ„์— ๋Œ€ํ•ด์„œ๋Š” ํƒ€์ž„์•„์›ƒ์„ 90์ดˆ๋กœ ์„ค์ •๋˜์–ด ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ์— ๋Œ€ํ•œ ๋ถ€๋ถ„์€ ์ •์ƒ์ ์œผ๋กœ ์œ ์ง€ํ•˜๋Š” ์ƒํƒœ์ž…๋‹ˆ๋‹ค.

ํด๋ผ์ด์–ธํŠธ์˜ ์ดˆ๊ธฐ ์—ฐ๊ฒฐ์„ ์ œ์™ธํ•˜๊ณ ๋Š” ์„œ๋ฒ„์—์„œ 1๋ถ„๋งˆ๋‹ค ์Šค์ผ€์ค„์— ์˜ํ•ด ์–ด๋– ํ•œ ๋ฉ”์‹œ์ง€๋ฅผ ์ „๋‹ฌํ•˜๋Š”๋ฐ ์„œ๋ฒ„๊ฐ€ ์ „๋‹ฌํ•˜๋Š” ํƒ€์ด๋ฐ ์ƒ 60์ดˆ ์ด๋‚ด์— ์ „๋‹ฌ๋˜๋Š” ํŠธ๋ž˜ํ”ฝ์ด ์—†๋‹ค๊ณ  ํŒ๋‹จ๋˜์–ด ์—ฐ๊ฒฐ์ด ํ•ด์ง€๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

HTTP2 โ€‹

์ผ๋ฐ˜์ ์ธ ์›น ์š”์ฒญ์€ HTTP2๋กœ ์—ฐ๊ฒฐ๋  ์ˆ˜ ์žˆ๋„๋ก ์ง€์›ํ•˜๊ณ  ์žˆ๋Š”๋ฐ ์›น์†Œ์ผ“์— ๋Œ€ํ•œ ์—ฐ๊ฒฐ์— ๋Œ€ํ•ด์„œ๋Š” HTTP 1.1์˜ ์—…๊ทธ๋ ˆ์ด๋“œ ๋งค์ปค๋‹ˆ์ฆ˜์„ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

sh
curl --include \
     --no-buffer \
     --http1.1 \
     --location \
     --header "Connection: Upgrade" \
     --header "Upgrade: websocket" \
     --header "Host: example.com" \
     --header "Origin: https://example.com" \
     --header "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
     --header "Sec-WebSocket-Version: 13" \
     https://example.com/websocket/server/sessionid/websocket

๊ทธ๋Ÿฐ๋ฐ ์œ„ cURL ๋ช…๋ น์–ด์—์„œ --http1.1 ์˜ต์…˜์„ ์ œ์™ธํ•˜๋ฉด Upgarde ํ—ค๋”์— websocket์— ์ „๋‹ฌ๋˜์ง€ ์•Š๋Š” ์ƒํ™ฉ์ด ๋ฐœ์ƒํ•จ์„ ํ™•์ธํ•˜์˜€์Šต๋‹ˆ๋‹ค. ๊ฒฐ๊ตญ ์ผ๋ฐ˜์ ์ธ ๋ธŒ๋ผ์šฐ์ €๋ฅผ ํ†ตํ•œ ์š”์ฒญ์ด ์•„๋‹ˆ๋ผ ํŠน์ • ํด๋ผ์ด์–ธํŠธ๊ฐ€ ์ง์ ‘ ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ์„ ์‹œ๋„ํ•  ๊ฐ€๋Šฅ์„ฑ๋„ ์žˆ๋‹ค๋Š” ์ด์•ผ๊ธฐ ์ž…๋‹ˆ๋‹ค.

wscat ๋˜๋Š” postman ์œผ๋กœ๋„ ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ์„ ์‹œ๋„ํ•ด๋ณด์•˜์ง€๋งŒ ์ •์ƒ์ ์œผ๋กœ ์—ฐ๊ฒฐ๋จ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

์Šคํ”„๋ง ์›น ์†Œ์ผ“ โ€‹

์‹œ์Šคํ…œ ์ž…์žฅ์—์„œ๋Š” ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ์›น์†Œ์ผ“ ์—ฐ๊ฒฐ์ด ์š”์ฒญ๋˜๋Š” ๋ถ€๋ถ„์ด๋ฏ€๋กœ ํ•ด๋‹น ์˜ค๋ฅ˜ ๋กœ๊ทธ๋งŒ์œผ๋กœ๋Š” ์–ด๋–ป๊ฒŒ ์š”์ฒญ๋˜์—ˆ๋Š”๊ฐ€๋ฅผ ๊ฒ€ํ† ํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ์•ˆ์ด ์—†์Šต๋‹ˆ๋‹ค. ์Šคํ”„๋ง ์›น ์†Œ์ผ“ ๋ชจ๋“ˆ์—์„œ ์š”์ฒญ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐฉ์•ˆ์„ ์ฐพ์•„๋ณด๋„๋ก ํ•ฉ์‹œ๋‹ค.

RequestUpgradeStrategy โ€‹

RequestUpgradeStrategy๋Š” HTTP ์š”์ฒญ์— ๋Œ€ํ•ด์„œ ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ๋กœ ์—…๊ทธ๋ ˆ์ด๋“œํ•˜๊ธฐ ์œ„ํ•œ ์ „๋žต์ด๋ฉฐ ์–ธ๋”ํ† ์šฐ๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ๋‹ค๋ฉด UndertowRequestUpgradeStrategy๊ฐ€ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

java
public class CustomRequestUpgradeStrategy extends UndertowRequestUpgradeStrategy {
    @Override
    protected void upgradeInternal(ServerHttpRequest request, ServerHttpResponse response, String selectedProtocol, List<Extension> selectedExtensions, Endpoint endpoint) throws HandshakeFailureException {
        // NOTE: ํ•ธ๋“œ์‰์ดํฌ ๊ณผ์ •์—์„œ ๊ฒ€์ฆ๋œ ์š”์ฒญ์— ๋Œ€ํ•ด์„œ ์—…๊ทธ๋ ˆ์ด๋“œ๋ฅผ ์ˆ˜ํ–‰ํ•œ๋‹ค.
        super.upgradeInternal(request, response, selectedProtocol, selectedExtensions, endpoint);
    }
}

์œ„ ์ฒ˜๋Ÿผ ์—…๊ทธ๋ ˆ์ด๋“œ๋ฅผ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ณผ์ •์—์„œ ์š”์ฒญ๊ณผ ์‘๋‹ต์— ๋Œ€ํ•ด ๋ถ€๊ฐ€ ์ฒ˜๋ฆฌ๋ฅผ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค๋งŒ DefaultHandshakeHandler ๋ผ๋Š” ํด๋ž˜์Šค์—์„œ upgradeInternal ํ•จ์ˆ˜๊ฐ€ ํ˜ธ์ถœ๋˜๋Š” ์œ„์น˜๋ฅผ ์‚ดํŽด๋ณด๋ฉด ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ ์š”์ฒญ์— ๋Œ€ํ•ด์„œ ๊ฒ€์ฆ์„ ์ˆ˜ํ–‰ํ•˜๊ณ ๋‚˜์„œ ๋งˆ์ง€๋ง‰์— upgradeInternal ํ•จ์ˆ˜๊ฐ€ ํ˜ธ์ถœ๋˜๋ฏ€๋กœ ๋ณธ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„๋•Œ๋Š” ์š”์ฒญ ์ •๋ณด๋ฅผ ํŒŒ์•…ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

DefaultHandshakeHandler โ€‹

DefaultHandshakeHandler๋Š” ์Šคํ”„๋ง ์›น ์†Œ์ผ“ ๋ชจ๋“ˆ์—์„œ ๊ธฐ๋ณธ์ ์œผ๋กœ ์‚ฌ์šฉํ•˜๋Š” ์›น ์†Œ์ผ“ ์—ฐ๊ฒฐ์„ ์ˆ˜ํ–‰ํ•˜๋Š” ํ•ธ๋“ค๋Ÿฌ๋กœ HandshakeHandler๋กœ ๋“ฑ๋ก๋œ ๋นˆ์ด ์—†๋‹ค๋ฉด ๋‚ด๋ถ€์ ์œผ๋กœ DefaultHandshakeHandler๋ฅผ ์ƒ์„ฑํ•˜์—ฌ ์‚ฌ์šฉํ•˜๋„๋ก ๋˜์–ด์žˆ์Šต๋‹ˆ๋‹ค. ์•ž์„œ RequestUpgradeStrategy๋ฅผ ์ด์šฉํ•  ์ˆ˜ ์—†๋Š” ์ด์œ ๋ฅผ ํ™•์ธํ•˜๊ธฐ ์œ„ํ•ด ํ•ธ๋“œ์‰์ดํฌ๋ฅผ ์ˆ˜ํ–‰ํ•˜๋Š” ์ฝ”๋“œ๋ฅผ ์‚ดํŽด๋ณด๋„๋ก ํ•˜๊ฒ ์Šต๋‹ˆ๋‹ค.

java
public static class AbstractHandshakeHandler implements HandshakeHandler {
    @Override
	public final boolean doHandshake(ServerHttpRequest request, ServerHttpResponse response,
			WebSocketHandler wsHandler, Map<String, Object> attributes) throws HandshakeFailureException {

		WebSocketHttpHeaders headers = new WebSocketHttpHeaders(request.getHeaders());
		if (logger.isTraceEnabled()) {
			logger.trace("Processing request " + request.getURI() + " with headers=" + headers);
		}
		try {
			if (HttpMethod.GET != request.getMethod()) {
				response.setStatusCode(HttpStatus.METHOD_NOT_ALLOWED);
				response.getHeaders().setAllow(Collections.singleton(HttpMethod.GET));
				if (logger.isErrorEnabled()) {
					logger.error("Handshake failed due to unexpected HTTP method: " + request.getMethod());
				}
				return false;
			}
			if (!"WebSocket".equalsIgnoreCase(headers.getUpgrade())) {
				handleInvalidUpgradeHeader(request, response);
				return false;
			}
			if (!headers.getConnection().contains("Upgrade") && !headers.getConnection().contains("upgrade")) {
				handleInvalidConnectHeader(request, response);
				return false;
			}
			if (!isWebSocketVersionSupported(headers)) {
				handleWebSocketVersionNotSupported(request, response);
				return false;
			}
			if (!isValidOrigin(request)) {
				response.setStatusCode(HttpStatus.FORBIDDEN);
				return false;
			}
			String wsKey = headers.getSecWebSocketKey();
			if (wsKey == null) {
				if (logger.isErrorEnabled()) {
					logger.error("Missing \"Sec-WebSocket-Key\" header");
				}
				response.setStatusCode(HttpStatus.BAD_REQUEST);
				return false;
			}
		}
		catch (IOException ex) {
			throw new HandshakeFailureException(
					"Response update failed during upgrade to WebSocket: " + request.getURI(), ex);
		}

		String subProtocol = selectProtocol(headers.getSecWebSocketProtocol(), wsHandler);
		List<WebSocketExtension> requested = headers.getSecWebSocketExtensions();
		List<WebSocketExtension> supported = this.requestUpgradeStrategy.getSupportedExtensions(request);
		List<WebSocketExtension> extensions = filterRequestedExtensions(request, requested, supported);
		Principal user = determineUser(request, wsHandler, attributes);

		if (logger.isTraceEnabled()) {
			logger.trace("Upgrading to WebSocket, subProtocol=" + subProtocol + ", extensions=" + extensions);
		}
		this.requestUpgradeStrategy.upgrade(request, response, subProtocol, extensions, user, wsHandler, attributes);
		return true;
	}
}

AbstractHandshakeHandler ํด๋ž˜์Šค์— ๋Œ€ํ•ด ๋กœ๊ทธ ๋ ˆ๋ฒจ์„ Trace๋กœ ์„ค์ •ํ•˜๋ฉด ์š”์ฒญ ์ •๋ณด๋ฅผ ๋กœ๊ทธ๋กœ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ์ง€๋งŒ ๋ชจ๋“  ์š”์ฒญ์— ๋Œ€ํ•ด์„œ ๊ธฐ๋กํ•˜๋ฏ€๋กœ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ–ˆ์„๋•Œ๋งŒ ์š”์ฒญ ์ •๋ณด๋ฅผ ๋‚จ๊ธธ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.

AbstractHandshakeHandler.handleInvalidUpgradeHeader โ€‹

์•ž์„œ doHandshake ํ•จ์ˆ˜๋ฅผ ์‚ดํŽด๋ณธ ๊ฒฐ๊ณผ Upgrade ํ—ค๋”์— ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ๊ฐ’์ด ์ „๋‹ฌ๋˜๋Š” ๊ฒฝ์šฐ์—๋Š” handleInvalidUpgradeHeader ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด์ œ ์šฐ๋ฆฌ๋Š” handleInvalidUpgradeHeader ํ•จ์ˆ˜๋ฅผ ์˜ค๋ฒ„๋ผ์ด๋“œ ํ•˜์—ฌ ์š”์ฒญ ์ •๋ณด๋ฅผ ํ™•์ธํ•˜๊ณ  ์˜ค๋ฅ˜ ๋กœ๊ทธ๋กœ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ๋Š” ์œ„์น˜๋ฅผ ์•Œ๊ฒŒ ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

java
@Slf4j
public class CustomHandshakeHandler extends DefaultHandshakeHandler {
    @Override
    protected void handleInvalidUpgradeHeader(ServerHttpRequest request, ServerHttpResponse response) throws IOException {
        // NOTE: Upgrade ํ—ค๋”์— ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ๊ฐ’์ด ์ „๋‹ฌ๋˜์—ˆ์„๋•Œ ํ˜ธ์ถœ๋œ๋‹ค.
        log.error("Method: {}, URI: {}, Principal: {}, Headers: {}", request.getMethodValue(), request.getURI(), request.getPrincipal(), request.getHeaders()); 
        super.handleInvalidUpgradeHeader(request, response);
    }
}

์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ ˆ๋ฒจ์—์„œ๋Š” Upgrade ํ—ค๋”์— ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์€ ๊ฐ’์ด ์ „๋‹ฌ๋˜๋Š” ๊ฒฝ์šฐ์— ๋Œ€ํ•ด์„œ ์›์ธ์„ ํŒŒ์•…ํ•˜๊ธฐ๋Š” ์–ด๋ ต์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿผ์—๋„ ๋ถˆ๊ตฌํ•˜๊ณ  ๋ณธ ๋ฌธ์ œ๊ฐ€ ๋‹ค์‹œ ๋ฐœ์ƒํ–ˆ์„ ๋•Œ ์–ด๋–ค ์ •๋ณด๋กœ ์š”์ฒญ๋˜์—ˆ๋Š”์ง€์— ๋Œ€ํ•œ ๋กœ๊ทธ๊ฐ€ ๊ธฐ๋ก๋˜์—ˆ์œผ๋ฏ€๋กœ ์›์ธ ํŒŒ์•…์„ ์œ„ํ•œ ์‹ค๋งˆ๋ฆฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์žˆ๋Š” ๋ฐฉ์•ˆ์„ ๋งˆ๋ จํ•  ์ˆ˜ ์žˆ๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.

์ฐธ๊ณ  โ€‹

Released under the MIT License.