diff --git a/study/build.gradle b/study/build.gradle index 5c69542f84..87a1f0313c 100644 --- a/study/build.gradle +++ b/study/build.gradle @@ -19,7 +19,6 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-webflux' - implementation 'ch.qos.logback:logback-classic:1.5.7' implementation 'org.apache.commons:commons-lang3:3.14.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' implementation 'pl.allegro.tech.boot:handlebars-spring-boot-starter:0.4.1' diff --git a/study/src/main/java/cache/com/example/GreetingController.java b/study/src/main/java/cache/com/example/GreetingController.java index c0053cda42..af2da3176d 100644 --- a/study/src/main/java/cache/com/example/GreetingController.java +++ b/study/src/main/java/cache/com/example/GreetingController.java @@ -2,6 +2,7 @@ import org.springframework.http.CacheControl; import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @@ -11,8 +12,10 @@ public class GreetingController { @GetMapping("/") - public String index() { - return "index"; + public ResponseEntity index() { + return ResponseEntity.ok() + .header("Content-Encoding", "gzip") + .body("index"); } /** @@ -29,8 +32,9 @@ public String cacheControl(final HttpServletResponse response) { } @GetMapping("/etag") - public String etag() { - return "index"; + public ResponseEntity etag() { + return ResponseEntity.ok() + .body("index"); } @GetMapping("/resource-versioning") diff --git a/study/src/main/java/cache/com/example/cachecontrol/CacheControlInterceptor.java b/study/src/main/java/cache/com/example/cachecontrol/CacheControlInterceptor.java new file mode 100644 index 0000000000..fa967b3edb --- /dev/null +++ b/study/src/main/java/cache/com/example/cachecontrol/CacheControlInterceptor.java @@ -0,0 +1,15 @@ +package cache.com.example.cachecontrol; + +import org.springframework.web.servlet.HandlerInterceptor; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class CacheControlInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + response.addHeader("Cache-Control", "no-cache, private"); + return true; + } +} diff --git a/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java b/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java index 305b1f1e1e..ad7f9638a0 100644 --- a/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java +++ b/study/src/main/java/cache/com/example/cachecontrol/CacheWebConfig.java @@ -9,5 +9,7 @@ public class CacheWebConfig implements WebMvcConfigurer { @Override public void addInterceptors(final InterceptorRegistry registry) { + registry.addInterceptor(new CacheControlInterceptor()) + .addPathPatterns("/"); } } diff --git a/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java b/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java index 41ef7a3d9a..2c58520179 100644 --- a/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java +++ b/study/src/main/java/cache/com/example/etag/EtagFilterConfiguration.java @@ -1,12 +1,17 @@ package cache.com.example.etag; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.filter.ShallowEtagHeaderFilter; @Configuration public class EtagFilterConfiguration { -// @Bean -// public FilterRegistrationBean shallowEtagHeaderFilter() { -// return null; -// } -} + @Bean + public FilterRegistrationBean shallowEtagHeaderFilter() { + FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>(new ShallowEtagHeaderFilter()); + filterRegistrationBean.addUrlPatterns("/etag"); + return filterRegistrationBean; + } +} \ No newline at end of file diff --git a/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java b/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java index 6da6d2c795..a4297c335b 100644 --- a/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java +++ b/study/src/main/java/cache/com/example/version/CacheBustingWebConfig.java @@ -1,9 +1,13 @@ package cache.com.example.version; +import java.util.concurrent.TimeUnit; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; +import org.springframework.http.CacheControl; import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.servlet.resource.VersionResourceResolver; @Configuration public class CacheBustingWebConfig implements WebMvcConfigurer { @@ -20,6 +24,9 @@ public CacheBustingWebConfig(ResourceVersion version) { @Override public void addResourceHandlers(final ResourceHandlerRegistry registry) { registry.addResourceHandler(PREFIX_STATIC_RESOURCES + "/" + version.getVersion() + "/**") - .addResourceLocations("classpath:/static/"); + .addResourceLocations("classpath:/static/") + .setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic()) + .resourceChain(false) + .addResolver(new VersionResourceResolver().addContentVersionStrategy("/**")); } } diff --git a/study/src/main/java/cache/com/example/version/ResourceVersion.java b/study/src/main/java/cache/com/example/version/ResourceVersion.java index 27a2f22813..8c531e3efa 100644 --- a/study/src/main/java/cache/com/example/version/ResourceVersion.java +++ b/study/src/main/java/cache/com/example/version/ResourceVersion.java @@ -2,10 +2,11 @@ import org.springframework.stereotype.Component; -import jakarta.annotation.PostConstruct; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import jakarta.annotation.PostConstruct; + @Component public class ResourceVersion { diff --git a/study/src/main/resources/application.yml b/study/src/main/resources/application.yml index 4e8655a962..4a38f2f811 100644 --- a/study/src/main/resources/application.yml +++ b/study/src/main/resources/application.yml @@ -6,4 +6,7 @@ server: accept-count: 1 max-connections: 1 threads: + min-spare: 2 max: 2 + compression: + enabled: true diff --git a/study/src/test/java/study/FileTest.java b/study/src/test/java/study/FileTest.java index e1b6cca042..f8c0359a5e 100644 --- a/study/src/test/java/study/FileTest.java +++ b/study/src/test/java/study/FileTest.java @@ -2,13 +2,23 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDirFactory; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; import java.util.Collections; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import javax.swing.filechooser.FileSystemView; + /** * 웹서버는 사용자가 요청한 html 파일을 제공 할 수 있어야 한다. * File 클래스를 사용해서 파일을 읽어오고, 사용자에게 전달한다. @@ -28,7 +38,10 @@ class FileTest { final String fileName = "nextstep.txt"; // todo - final String actual = ""; + URL url = getClass().getClassLoader().getResource(fileName); + File file = new File(url.getPath()); + + final String actual = file.getAbsolutePath(); assertThat(actual).endsWith(fileName); } @@ -40,15 +53,16 @@ class FileTest { * File, Files 클래스를 사용하여 파일의 내용을 읽어보자. */ @Test - void 파일의_내용을_읽는다() { + void 파일의_내용을_읽는다() throws IOException { final String fileName = "nextstep.txt"; // todo - final Path path = null; + final URL url = getClass().getClassLoader().getResource(fileName); + final File file = new File(url.getPath()); + final Path path = file.toPath(); // todo - final List actual = Collections.emptyList(); - + final List actual = Files.readAllLines(path); assertThat(actual).containsOnly("nextstep"); } } diff --git a/study/src/test/java/study/IOStreamTest.java b/study/src/test/java/study/IOStreamTest.java index 47a79356b6..f0a9602dac 100644 --- a/study/src/test/java/study/IOStreamTest.java +++ b/study/src/test/java/study/IOStreamTest.java @@ -53,6 +53,7 @@ class OutputStream_학습_테스트 { * todo * OutputStream 객체의 write 메서드를 사용해서 테스트를 통과시킨다 */ + outputStream.write(bytes); final String actual = outputStream.toString(); @@ -78,6 +79,7 @@ class OutputStream_학습_테스트 { * flush를 사용해서 테스트를 통과시킨다. * ByteArrayOutputStream과 어떤 차이가 있을까? */ + outputStream.flush(); verify(outputStream, atLeastOnce()).flush(); outputStream.close(); @@ -97,6 +99,8 @@ class OutputStream_학습_테스트 { * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. */ + outputStream.close(); + verify(outputStream, atLeastOnce()).close(); } } @@ -128,7 +132,7 @@ class InputStream_학습_테스트 { * todo * inputStream에서 바이트로 반환한 값을 문자열로 어떻게 바꿀까? */ - final String actual = ""; + final String actual = new String(inputStream.readAllBytes()); assertThat(actual).isEqualTo("🤩"); assertThat(inputStream.read()).isEqualTo(-1); @@ -148,6 +152,7 @@ class InputStream_학습_테스트 { * try-with-resources를 사용한다. * java 9 이상에서는 변수를 try-with-resources로 처리할 수 있다. */ + inputStream.close(); verify(inputStream, atLeastOnce()).close(); } @@ -169,12 +174,12 @@ class FilterStream_학습_테스트 { * 버퍼 크기를 지정하지 않으면 버퍼의 기본 사이즈는 얼마일까? */ @Test - void 필터인_BufferedInputStream를_사용해보자() { + void 필터인_BufferedInputStream를_사용해보자() throws IOException { final String text = "필터에 연결해보자."; final InputStream inputStream = new ByteArrayInputStream(text.getBytes()); - final InputStream bufferedInputStream = null; + final InputStream bufferedInputStream = new BufferedInputStream(inputStream); - final byte[] actual = new byte[0]; + final byte[] actual = bufferedInputStream.readAllBytes(); assertThat(bufferedInputStream).isInstanceOf(FilterInputStream.class); assertThat(actual).isEqualTo("필터에 연결해보자.".getBytes()); @@ -197,7 +202,7 @@ class InputStreamReader_학습_테스트 { * 필터인 BufferedReader를 사용하면 readLine 메서드를 사용해서 문자열(String)을 한 줄 씩 읽어올 수 있다. */ @Test - void BufferedReader를_사용하여_문자열을_읽어온다() { + void BufferedReader를_사용하여_문자열을_읽어온다() throws IOException { final String emoji = String.join("\r\n", "😀😃😄😁😆😅😂🤣🥲☺️😊", "😇🙂🙃😉😌😍🥰😘😗😙😚", @@ -206,7 +211,15 @@ class InputStreamReader_학습_테스트 { final InputStream inputStream = new ByteArrayInputStream(emoji.getBytes()); final StringBuilder actual = new StringBuilder(); - + try ( + InputStreamReader inputStreamReader = new InputStreamReader(inputStream); + BufferedReader bufferedReader = new BufferedReader(inputStreamReader); + ) { + String line; + while((line = bufferedReader.readLine()) != null) { + actual.append(line + "\r\n"); + } + } assertThat(actual).hasToString(emoji); } } diff --git a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java index bb14184757..390db0c886 100644 --- a/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java +++ b/tomcat/src/main/java/org/apache/coyote/http11/Http11Processor.java @@ -1,18 +1,38 @@ package org.apache.coyote.http11; + +import com.techcourse.db.InMemoryUserRepository; import com.techcourse.exception.UncheckedServletException; +import com.techcourse.model.User; + import org.apache.coyote.Processor; +import org.apache.coyote.http11.request.HttpRequest; +import org.apache.coyote.http11.request.RequestHandler; +import org.apache.coyote.http11.session.SessionManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.BufferedReader; +import java.io.File; import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; import java.net.Socket; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; public class Http11Processor implements Runnable, Processor { private static final Logger log = LoggerFactory.getLogger(Http11Processor.class); private final Socket connection; + private static final Map httpRequestHeader = new HashMap<>(); + private static final String sessionId = "JSESSIONID=sessionId"; public Http11Processor(final Socket connection) { this.connection = connection; @@ -29,18 +49,13 @@ public void process(final Socket connection) { try (final var inputStream = connection.getInputStream(); final var outputStream = connection.getOutputStream()) { - final var responseBody = "Hello world!"; + BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); + HttpRequest httpRequest = new HttpRequest(reader); - final var response = String.join("\r\n", - "HTTP/1.1 200 OK ", - "Content-Type: text/html;charset=utf-8 ", - "Content-Length: " + responseBody.getBytes().length + " ", - "", - responseBody); + RequestHandler requestHandler = new RequestHandler(httpRequest, outputStream); + requestHandler.handleRequest(); - outputStream.write(response.getBytes()); - outputStream.flush(); - } catch (IOException | UncheckedServletException e) { + } catch (IOException e) { log.error(e.getMessage(), e); } } diff --git a/tomcat/src/main/java/org/apache/coyote/http11/request/HttpRequest.java b/tomcat/src/main/java/org/apache/coyote/http11/request/HttpRequest.java new file mode 100644 index 0000000000..aaf2d5f417 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/request/HttpRequest.java @@ -0,0 +1,57 @@ +package org.apache.coyote.http11.request; + +import java.io.BufferedReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +public class HttpRequest { + + private static final String CONTENT_LENGTH = "Content-Length"; + + private final String method; + private final String path; + private final Map headers = new HashMap<>(); + private final String body; + + public HttpRequest(BufferedReader reader) throws IOException { + String initialLine = reader.readLine(); + this.method = initialLine.split(" ")[0]; + this.path = initialLine.split(" ")[1]; + String line; + while ((line = reader.readLine()) != null && !line.isEmpty()) { + String[] header = line.split(":"); + headers.put(header[0].trim(), header[1].trim()); + } + this.body = parseBody(reader); + } + + private String parseBody(BufferedReader reader) throws IOException { + if(headers.get(CONTENT_LENGTH) == null) { + return null; + } + int contentLength = Integer.parseInt(headers.get(CONTENT_LENGTH)); + if(contentLength > 0) { + char[] body = new char[contentLength]; + reader.read(body, 0, contentLength); + return new String(body); + } + return null; + } + + public String getMethod() { + return method; + } + + public String getPath() { + return path; + } + + public String getCookie() { + return headers.get("Cookie"); + } + + public String getBody() { + return body; + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/request/RequestHandler.java b/tomcat/src/main/java/org/apache/coyote/http11/request/RequestHandler.java new file mode 100644 index 0000000000..3bd3462836 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/request/RequestHandler.java @@ -0,0 +1,98 @@ +package org.apache.coyote.http11.request; + +import java.io.IOException; +import java.io.OutputStream; + +import org.apache.coyote.http11.response.ResponseHandler; +import org.apache.coyote.http11.session.Session; +import org.apache.coyote.http11.session.SessionManager; + +import com.techcourse.db.InMemoryUserRepository; +import com.techcourse.model.User; + +public class RequestHandler { + + private final HttpRequest httpRequest; + private final OutputStream outputStream; + + public RequestHandler(HttpRequest httpRequest, OutputStream outputStream) { + this.httpRequest = httpRequest; + this.outputStream = outputStream; + } + + public void handleRequest() throws IOException { + String httpMethod = httpRequest.getMethod(); + String urlPath = httpRequest.getPath(); + + if(urlPath.endsWith("html") || urlPath.endsWith("css") || urlPath.endsWith("js")) { + ResponseHandler.printFileResource("static" + urlPath, outputStream); + } else if (urlPath.startsWith("/login")) { + handleLoginRequest(httpMethod, urlPath); + } else if (urlPath.startsWith("/register")) { + handleRegisterRequest(httpMethod, urlPath); + } else { + sendHelloWorldResponse(); + } + } + + private void handleLoginRequest(String httpMethod, String urlPath) { + if (urlPath.equals("/login") && httpMethod.equals("GET")) { + Session session = new Session(httpRequest); + SessionManager.findUserBySession(session) + .ifPresentOrElse( + user -> ResponseHandler.redirect("/index.html", outputStream), + () -> ResponseHandler.printFileResource("static" + urlPath + ".html", outputStream)); + } else if (httpMethod.equals("POST")) { + login(); + } + } + + private void login() { + String body = httpRequest.getBody(); + if (body != null) { + String account = body.split("&")[0].split("=")[1]; + String password = body.split("&")[1].split("=")[1]; + InMemoryUserRepository.findByAccount(account) + .ifPresentOrElse( + user -> loginUser(user, password), + () -> ResponseHandler.redirect("/401.html", outputStream) + ); + } + ResponseHandler.redirect("/401.html", outputStream); + } + + private void loginUser(User user, String password) { + if (user.checkPassword(password)) { + Session session = SessionManager.createSession(user); + ResponseHandler.redirectWithSetCookie("/index.html", session.getId(), outputStream); + } + } + + private void handleRegisterRequest(String httpMethod, String urlPath) { + if (httpMethod.equals("GET")) { + ResponseHandler.printFileResource("static" + urlPath + ".html", outputStream); + return; + } + String body = httpRequest.getBody(); + if (body != null) { + String account = body.split("&")[0].split("=")[1]; + String mail = body.split("&")[1].split("=")[1]; + String password = body.split("&")[2].split("=")[1]; + User user = new User(account, mail, password); + InMemoryUserRepository.save(user); + ResponseHandler.redirect("/index.html", outputStream); + } + } + + private void sendHelloWorldResponse() throws IOException { + String responseBody = "Hello world!"; + String response = String.join("\r\n", + "HTTP/1.1 200 OK", + "Content-Type: text/html;charset=utf-8", + "Content-Length: " + responseBody.getBytes().length, + "", + responseBody); + outputStream.write(response.getBytes()); + outputStream.flush(); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/response/ResponseHandler.java b/tomcat/src/main/java/org/apache/coyote/http11/response/ResponseHandler.java new file mode 100644 index 0000000000..f286e2861e --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/response/ResponseHandler.java @@ -0,0 +1,96 @@ +package org.apache.coyote.http11.response; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URL; +import java.nio.file.Files; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.techcourse.exception.UncheckedServletException; + +public class ResponseHandler { + + private static final Logger log = LoggerFactory.getLogger(ResponseHandler.class); + + public static void printFileResource(String fileName, OutputStream outputStream) { + final URL url = ResponseHandler.class.getClassLoader().getResource(fileName); + if (url == null) { + sendNotFoundResponse(outputStream); + return; + } + try { + File file = new File(url.getPath()); + String contentType = determineContentType(fileName); + String responseBody = new String(Files.readAllBytes(file.toPath())); + String response = String.join("\r\n", + "HTTP/1.1 200 OK", + "Content-Type: " + contentType + ";charset=utf-8", + "Content-Length: " + responseBody.getBytes().length, + "", + responseBody); + outputStream.write(response.getBytes()); + outputStream.flush(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + private static String determineContentType(String fileName) { + if (fileName.endsWith("css")) { + return "text/css"; + } + if (fileName.endsWith("js")) { + return "application/javascript"; + } + if(fileName.endsWith("html")) { + return "text/html"; + } + return "application/json"; + } + + private static void sendNotFoundResponse(OutputStream outputStream) { + try { + String response = "HTTP/1.1 404 Not Found \r\n" + + "Content-Length: 0 \r\n" + + "\r\n"; + outputStream.write(response.getBytes()); + outputStream.flush(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static void redirect(String path, OutputStream outputStream) { + try { + String contentType = "text/html"; + var response = "HTTP/1.1 302 Found \r\n" + + "Location: http://localhost:8080" + path + "\r\n" + + String.format("Content-Type: %s;charset=utf-8 \r\n", contentType) + + "Content-Length: 0"; + + outputStream.write(response.getBytes()); + outputStream.flush(); + } catch (IOException | UncheckedServletException e) { + log.error(e.getMessage(), e); + } + } + + public static void redirectWithSetCookie(String path, String sessionId, OutputStream outputStream) { + try { + String contentType = "text/html"; + var response = "HTTP/1.1 302 Found \r\n" + + "Set-Cookie: JSESSIONID=" + sessionId + " \r\n" + + "Location: http://localhost:8080" + path + " \r\n" + + String.format("Content-Type: %s;charset=utf-8 \r\n", contentType) + + "Content-Length: 0"; + + outputStream.write(response.getBytes()); + outputStream.flush(); + } catch (IOException | UncheckedServletException e) { + log.error(e.getMessage(), e); + } + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/session/Session.java b/tomcat/src/main/java/org/apache/coyote/http11/session/Session.java new file mode 100644 index 0000000000..ebce4c6da0 --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/session/Session.java @@ -0,0 +1,43 @@ +package org.apache.coyote.http11.session; + +import java.util.Arrays; +import java.util.Objects; +import java.util.UUID; + +import org.apache.coyote.http11.request.HttpRequest; + +public class Session { + public static final String SESSION_HEADER_KEY = "JSESSIONID"; + private final String id; + + public Session() { + this.id = UUID.randomUUID().toString(); + } + + public Session(HttpRequest httpRequest) { + this.id = Arrays.asList(httpRequest.getCookie().split("; ")) + .stream() + .filter(cookie -> cookie.startsWith(SESSION_HEADER_KEY)) + .findAny() + .orElseGet(null); + } + + public String getId() { + return id; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + Session session = (Session)o; + return Objects.equals(id, session.id); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } +} diff --git a/tomcat/src/main/java/org/apache/coyote/http11/session/SessionManager.java b/tomcat/src/main/java/org/apache/coyote/http11/session/SessionManager.java new file mode 100644 index 0000000000..040a07fa0b --- /dev/null +++ b/tomcat/src/main/java/org/apache/coyote/http11/session/SessionManager.java @@ -0,0 +1,21 @@ +package org.apache.coyote.http11.session; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import com.techcourse.model.User; + +public final class SessionManager { + private static final Map sessions = new HashMap<>(); + + public static Session createSession(User user) { + Session session = new Session(); + sessions.put(session, user); + return session; + } + + public static Optional findUserBySession(Session session) { + return Optional.ofNullable(sessions.get(session)); + } +} diff --git a/tomcat/src/main/resources/static/login.html b/tomcat/src/main/resources/static/login.html index f4ed9de875..bc933357f2 100644 --- a/tomcat/src/main/resources/static/login.html +++ b/tomcat/src/main/resources/static/login.html @@ -20,7 +20,7 @@

로그인

-
+