레벨4에 들어서면서 그동안 우리가 흔히 사용했던 스프링에 대해 좀 더 딥하게 알아보고 구현해보는 시간을 가지게 됐다. 아래는 우테코 레벨4 일정이다.
그 중에서 첫 번째 미션은 [톰캣 구현하기]이다.
그 동안 우리는 스프링을 이용한 덕분에 HTTP 요청을 받으면 그에 맞는 응답을 편리하게 만들어줄 수 있었다. 이는 Dispatcher Servlet이 있었기에 가능한 것. 이번 미션을 통해서 WAS, tomcat, Dispatcher Servlet이 각각 무엇인지, 어떠한 역할을 하는지 배울 수 있었다.
특히 4단계의 '동시성 확장하기' 미션 덕분에 스레드, 스레드풀에 대한 개념을 공부할 수 있었다.
내 최종 코드 및 각 단계별 PR은 아래 레포에서 확인할 수 있다.
https://github.com/woowacourse/jwp-dashboard-http/tree/kth990303
WAS? tomcat? Dispatcher Servlet?
미션 후기를 작성하기 전에 해당 개념들을 간략하게 정리하고 넘어가보자.
브라우저에서 사용자 요청이 들어올 경우, (웹서버가 존재한다면) 해당 요청을 WS(Web Server, 웹서버)에서 먼저 받게 된다.
WS는 정적인 컨텐츠 요청을 받으면 정적인 페이지를 직접 제공해줄 수 있는 능력이 있지만, 동적인 컨텐츠 요청(JSP, PHP 등)은 처리하기 어려워 WAS에게 해당 요청을 넘긴다.
WAS는 동적인 컨텐츠를 처리해주어 동적 페이지를 제공해주는 역할을 한다.
참고로 WAS는 정적 컨텐츠를 처리하여 정적 페이지 제공 역할도 가능하긴 하다. 즉, WS 없이 WAS만으로도 개발은 가능하다. WS가 없다면 요청을 WAS에서 전부 다 받게 될 것이다. 하지만 WS가 있으면 WAS의 부담을 줄일 수 있고, WAS 자체가 노출되는 보안성 이슈도 해결할 수 있으므로 주로 WS를 두는 것을 권장하는 편이다.
tomcat은 아파치 재단에서 개발한 오픈소스로, WAS의 일종이다! 자바 환경에서 정적, 동적인 컨텐츠를 처리하여 응답 페이지를 생성해주는 역할을 한다.
스프링 환경에서 tomcat은 서블릿 컨테이너의 역할을 한다. 그렇기 때문에 클라이언트의 요청이 들어오면 서블릿 컨테이너, 즉 tomcat이 받게 된다. tomcat은 받은 요청들을 적절히 맞는 Controller에게 넘겨주는데, 스프링에선 제일 앞단에서 모든 요청을 받는 Front Controller에 해당하는 Dispatcher Servlet이라는 서블릿을 두도록 했다. 이 Dispatcher Servlet이 각 요청에 따른 적절한 컨트롤러와 메서드를 매핑해주는 역할을 해준다.
아래 글에 정리가 잘돼있으니 참고하는 걸 추천한다.
1단계 - HTTP 서버 구현하기
우리에게 제공되는 것은 html 파일과 간단한 틀 뿐이다. index.html을 띄우는 tomcat을 구현해야 한다!
체크리스트
- http://localhost:8080/index.html 페이지에 접근 가능하다.
- 접근한 페이지의 js, css 파일을 불러올 수 있다.
- uri의 QueryString을 파싱하는 기능이 있다.
처음에는 어떻게 요청을 읽어오고, 그에 맞는 해당 파일을 보내줄지 멘붕이었다.
다행히 우리를 위해 우테코 코치님들이 제공해주신 학습테스트가 존재했다.
학습 테스트를 통과하기 위해선 java.nio.Path, File에 대한 이해가 필요했다.
해당 클래스에 대한 정보들을 아래 노션에 별도로 기록했다.
https://clean-nutria-44b.notion.site/1-b15e4c19c2dc43868a8358dc0a90487b
파일 자체를 띄우는 거는 어렵지 않았지만, 해당 요청에 따라 적절한 파일을 보여주게 하는 과정이 너무 까다로웠다.
BufferedReader를 통해 Http Request를 읽어오긴 했다. 하지만 그 다음의 HTTP Request에 해당되는 Http Request Line을 파싱하고 각 요청에 따라 다른 html 파일을 응답하게 해주어야되니 설계를 이쁘게 짜지 않는 이상 if문 떡칠이 예상되는 상황이었다.
따라서 각 요청에 따른 알맞은 Response를 보내도록 처리해주는 RequestMappingHandler이라는 클래스를 만들어주었다.
RequestMappingHandler
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
public enum RequestMappingHandler {
STRING(RequestMappingHandler::isStringGetUrl, new HelloResponseMaker()),
FILE(RequestMappingHandler::isFileGetUrl, new FileGetResponseMaker()),
LOGIN_GET(RequestMappingHandler::isLoginGetUrl, new LoginGetResponseMaker()),
LOGIN_POST(RequestMappingHandler::isLoginPostUrl, new LoginPostResponseMaker()),
REGISTER_GET(RequestMappingHandler::isRegisterGetUrl, new RegisterGetResponseMaker()),
REGISTER_POST(RequestMappingHandler::isRegisterPostUrl, new RegisterPostResponseMaker());
private static final Pattern FILE_REGEX = Pattern.compile(".+\\.(html|css|js|ico)");
private final BiPredicate<String, String> condition;
private final ResponseMaker responseMaker;
RequestMappingHandler(final BiPredicate<String, String> condition, final ResponseMaker responseMaker) {
this.condition = condition;
this.responseMaker = responseMaker;
}
public static ResponseMaker findResponseMaker(final HttpRequest httpRequest) {
final String requestUrl = httpRequest.getRequestUrl();
final String requestMethod = httpRequest.getMethod();
return Arrays.stream(values())
.filter(value -> value.condition.test(requestUrl, requestMethod))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("잘못된 url 요청입니다."))
.getResponseMaker();
}
}
|
cs |
if문 떡칠을 없애기 위해 enum으로 관리해주었다.
물론 이 코드도 GET, POST 등 Http Method가 많아짐에 따라 불필요하게 늘어난다는 문제점이 존재하기 때문에 3단계 미션 때엔 컨트롤러를 만들어줌으로써 리팩터링됐다.
또한, response를 생성하는 ResponseMaker 인터페이스를 만들어주었는데, 이 인터페이스의 구현체들이 각 요청에 맞는 Http Response Message를 만들어줄 수 있도록 하였다. Http Response Message 또한 굉장히 복잡하기 때문에 일일이 String 문자열 형태로 만들기엔 꽤나 번거로워서 HttpResponse 객체를 따로 생성해주었다.
1단계를 진행하면서 내 자신이 HTTP Message 구조에 대한 이해가 부족했다는 생각을 하게 됐다.
HttpRequest, HttpResponse의 LINE에 어떠한 값이 담기는지 해당 미션을 진행하면서 알게 됐다.
또한, 이번 미션은 tomcat, spring 구조에 대해 좀 더 심도있게 공부하게 된 계기가 되었고, CSR, SSR에 대해서도 공부해보게 됐다. 우리가 프로젝트를 할 때는 React + Spring Boot 스택을 사용하여 CSR으로 하여 브라우저에서 렌더링을 해주지만, SSR을 한다면 우리가 구현한 톰캣에서 렌더링 작업이 일어난다는 점을 상기시켜주었다.
2, 3단계 - 로그인 구현하기, 리팩터링
1단계에서 우리는 적절한 파일을 보여주는 것만으로도 충분했지만, 이제는 POST 요청 처리 및 로그인을 위한 쿠키와 세션도 생성해야 한다! 또한 요청이 많아짐에 따라 HttpRequest, HttpResponse에 따른 분기처리가 복잡해졌다. 객체 및 컨트롤러 추상클래스, 인터페이스를 생성해보자!
체크리스트
- HTTP Reponse의 상태 응답 코드를 302로 반환한다.
- POST로 들어온 요청의 Request Body를 파싱할 수 있다.
- 로그인에 성공하면 HTTP Reponse의 헤더에 Set-Cookie가 존재한다.
- 서버에 세션을 관리하는 클래스가 있고, 쿠키로부터 전달 받은 JSESSIONID 값이 저장된다.
- HTTP Request, HTTP Response 클래스로 나눠서 구현했다.
- Controller 인터페이스와 RequestMapping 클래스를 활용하여 if절을 제거했다.
각 html 파일에서 어떠한 버튼을 누를 때 어떠한 method (GET, POST 등)을 어떠한 정보(input form 등등)와 함께 보내주는지 알려주고 있다.
Controller Interface
1
2
3
4
5
6
7
8
9
10
|
package nextstep.jwp.controller;
import org.apache.coyote.http11.request.HttpRequest;
import org.apache.coyote.http11.response.HttpResponse;
public interface Controller {
HttpResponse service(final HttpRequest request) throws Exception;
}
|
cs |
HttpRequest를 인자로 받아서 Http Method에 따라 적절한 컨트롤러의 메서드를 찾는 역할을 해주는 service 메서드를 생성해주었다.
AbstractController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
package nextstep.jwp.controller;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.coyote.http11.request.HttpMethod;
import org.apache.coyote.http11.request.HttpRequest;
import org.apache.coyote.http11.response.ContentType;
import org.apache.coyote.http11.response.HttpResponse;
import org.apache.coyote.http11.response.HttpStatus;
import org.apache.coyote.http11.utils.PathFinder;
public abstract class AbstractController implements Controller {
@Override
public HttpResponse service(final HttpRequest request) throws Exception {
if (request.getHttpMethod() == HttpMethod.GET) {
return doGet(request);
}
return doPost(request);
}
protected HttpResponse doGet(final HttpRequest request) throws Exception {
return defaultInternalServerErrorPage();
}
protected HttpResponse doPost(final HttpRequest request) throws Exception {
return defaultInternalServerErrorPage();
}
private HttpResponse defaultInternalServerErrorPage() throws URISyntaxException, IOException {
final Path path = PathFinder.findPath("/500.html");
final String responseBody = new String(Files.readAllBytes(path));
return new HttpResponse(HttpStatus.INTERNAL_SERVER_ERROR, responseBody, ContentType.HTML);
}
}
|
cs |
모든 컨트롤러마다 Http Method를 분류하는 중복 작업을 하지 않도록 Abstract Controller를 만들어주었다.
또한, 만약 예기치 못한 서버 에러가 발생할 수도 있어서 doGet, doPost 디폴트 메서드로는 500 에러를 띄우는 response를 반환하게 하고, 각 컨트롤러에서 해당 메서드를 오버라이딩하게 하여 적절한 response를 띄우게 했다.
Http Method enum 클래스에서 predicate를 이용하면 if문을 줄일 수 있게 리팩터링도 가능할 것 같은데, 그 작업은 따로 하진 않았다.
요청과 응답에 따른 커넥션 프로세스를 담당하는 Http11Processor는 아래와 같이 적절한 컨트롤러를 받을 수 있도록 작성해주었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
public class Http11Processor implements Runnable, Processor {
private static final Logger log = LoggerFactory.getLogger(Http11Processor.class);
private final Socket connection;
public Http11Processor(final Socket connection) {
this.connection = connection;
}
@Override
public void run() {
process(connection);
}
@Override
public void process(final Socket connection) {
try (final var inputStream = connection.getInputStream();
final var outputStream = connection.getOutputStream();
final BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))
) {
final HttpRequest httpRequest = HttpRequest.parse(bufferedReader);
final HttpResponse response = makeResponse(httpRequest);
outputStream.write(response.toString().getBytes(StandardCharsets.UTF_8));
outputStream.flush();
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
private HttpResponse makeResponse(final HttpRequest httpRequest) throws Exception {
try {
final Controller controller = RequestMappingHandler.findResponseMaker(httpRequest);
return controller.service(httpRequest);
} catch (final IllegalArgumentException e) {
final Path path = PathFinder.findPath("/400.html");
final String responseBody = new String(Files.readAllBytes(path));
return new HttpResponse(HttpStatus.FOUND, responseBody, ContentType.HTML, "/400.html");
}
}
}
|
cs |
실제 tomcat, Dispatcher Servlet은 훨씬 복잡하겠지만 해당 미션을 통해 대략적인 흐름을 이해할 수 있는 시간이었다.
Controller 구조를 처음에는 인터페이스로만 생각했었는데, 확실히 추상클래스로 Method에 따른 중복 로직을 줄여주는 효과를 낼 수 있어서 객체지향적인 설계의 중요성을 다시 한 번 느끼게 됐다.
실제로는 더 다양한 request가 많이 들어올텐데 톰캣과 dispatcher servlet은 그걸 다 처리해주어 우리가 비즈니스 로직에만 집중할 수 있게 해준다는 걸 몸소 실감할 수 있었고, 덕분에 톰캣에 대한 애정이 높아졌다 ㅎㅎ
4단계 - 동시성 확장하기
만약 요청이 동시에 여러 개 들어와서 서버가 다운돼버린다면? 그런 경우는 없어야 한다. 동시성을 확장해보자!
체크리스트
- Executors로 만든 ExecutorService 객체를 활용하여 스레드 처리를 하고 있다.
여러 명이 동시에 로그인하여 세션을 add할 때 동시성 문제가 발생할 수 있다. 따라서 이 때에는 ConcurrentHashMap으로 동시성 이슈가 발생하지 않는 자료구조를 선택했다.
SessionManager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
public class SessionManager {
private static final Logger log = LoggerFactory.getLogger(SessionManager.class);
private static final Map<String, Session> SESSIONS = new ConcurrentHashMap<>();
public static void add(final Session session) {
log.info("session add 완료: {}", session.getId());
SESSIONS.put(session.getId(), session);
}
public static Session findSession(final String id) {
for (Session session : SESSIONS.values()) {
log.info("로그인된 유저 파악할 때 찾은 SESSION의 JSESSION_ID = {}", session.getId());
}
return SESSIONS.get(id);
}
}
|
cs |
Collections.synchronizedMap 이라는 선택지도 있다. 해당 자료구조는 모든 요청 값에 synchronized 키워드를 붙여주어 write 외에 read 작업에도 synchronized를 붙여준다. 대신, 그만큼 느리다는 단점이 있다.
이번 미션에서는 멀티스레드 환경에서 write 작업을 하는 경우에 동시성 문제가 발생할 수 있다고 생각해 concurrentHashMap을 사용하는 것이 동시성 이슈 방지와 함께 성능도 더 좋은 결과를 낼 수 있게 해주었다.
자세한 내용은 아래 링크를 참고하자.
https://www.baeldung.com/java-synchronizedmap-vs-concurrenthashmap#when-to-use
Connector
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
public class Connector implements Runnable {
private static final Logger log = LoggerFactory.getLogger(Connector.class);
private static final int DEFAULT_PORT = 8080;
private static final int DEFAULT_ACCEPT_COUNT = 100;
private static final int DEFAULT_MAX_THREADS = 250;
private static final int TERMINATION_LIMIT_SECONDS = 60;
private final ServerSocket serverSocket;
private final ExecutorService executorService;
private boolean stopped;
public Connector() {
this(DEFAULT_PORT, DEFAULT_ACCEPT_COUNT, DEFAULT_MAX_THREADS);
}
public Connector(final int port, final int acceptCount, final int maxThreads) {
this.serverSocket = createServerSocket(port, acceptCount);
this.stopped = false;
this.executorService = Executors.newFixedThreadPool(maxThreads);
}
// ...
private void process(final Socket connection) {
if (connection == null) {
return;
}
log.info("connect host: {}, port: {}", connection.getInetAddress(), connection.getPort());
var processor = new Http11Processor(connection);
executorService.execute(processor);
new Thread(processor).start();
}
public void stop() {
stopped = true;
try {
executorService.shutdown();
terminationWhenTimeLimitExceeded();
serverSocket.close();
} catch (IOException e) {
log.error(e.getMessage(), e);
} catch (InterruptedException e) {
executorService.shutdownNow();
log.error(e.getMessage(), e);
Thread.currentThread().interrupt();
}
}
private void terminationWhenTimeLimitExceeded() throws InterruptedException {
if (!executorService.awaitTermination(TERMINATION_LIMIT_SECONDS, TimeUnit.SECONDS)) {
executorService.shutdownNow();
}
}
}
|
cs |
executorService를 상태로 가지게 하여 스레드풀을 관리할 수 있게 해주었다.
동시 접속자가 많아지면 스레드가 무한대로 생성되는 것을 막기 위해 스레드풀을 관리하고 더 많은 요청이 들어올 경우 queue에 넣도록 한 것이다. 해당 작업은 Connector 생성자, process 메서드를 참고하면 된다.
Connector 생성자를 보면 acceptCount, maxThreads 값이 존재한다.
각각 queue size, thread pool maxThreads 사이즈라 보면 된다. 예를 들어 모든 스레드가 사용 중인 상태(Busy)일 때 남은 요청 중 k개만 대기 상태로 만들려면 acceptCount를 k로 설정해주면 된다.
stop 메서드는 메인 메서드가 종료될 때 애플리케이션 스레드를 종료시키는 메서드이다. shutdown으로 스레드를 종료시키지만, 만약 종료가 정상적으로 되지 않으면(terminationWhenTimeLimitExceeded) 강제로 종료시키도록 shutdownNow()로 종료시켰다.
다만, 스레드가 deamon thread일 경우에는 메인 스레드가 종료될 때 같이 종료되므로 이 작업이 필요한지는 의문.
어렵다. 하지만 재밌어서 추가적인 학습을 많이 한 파트이기도 하다.
처음 접하는 부분이기도 하고, 아직까진 꽤 어렵다고 느껴져서 앞으로도 계속 추가학습을 해봐야될 것 같다.
내 리뷰이는 차리였다. 차리에게 리뷰를 해주면서, 그리고 차리의 의견을 같이 생각하면서 공부가 많이 됐으니 참고하면 좋을 듯하다. 해당 PR은 아래와 같다.
https://github.com/woowacourse/jwp-dashboard-http/pull/252
참고
- 서블릿, 톰캣: https://riimy.tistory.com/87
- 스레드풀 종료가 필요한 이유: https://stackoverflow.com/questions/16122987/reason-for-calling-shutdown-on-executorservice
- executorService execute() vs submit() https://stackoverflow.com/questions/17881183/difference-between-execute-submit-and-invoke-in-a-forkjoinpool
- 스레드풀: https://www.baeldung.com/thread-pool-java-and-guava
- 스레드풀 비교: https://www.baeldung.com/java-executors-cached-fixed-threadpool
- JSESSIONID: https://jojoldu.tistory.com/118
- executorService 종료 방법: https://howtodoinjava.com/java/multi-threading/executorservice-shutdown/
- concurrentHashMap: https://devlog-wjdrbs96.tistory.com/269
- Collections.synchronizedMap vs ConcurrentHashMap: https://www.baeldung.com/java-synchronizedmap-vs-concurrenthashmap
'JAVA > 우아한테크코스 4기' 카테고리의 다른 글
[220930] DB 제3정규화 위반 문제점을 무시하고 프로젝트 연관관계 수정한 삽질 후기 (7) | 2022.10.01 |
---|---|
[220928] 우아한테크코스 레벨4 - MVC 구현하기 미션 후기 (2) | 2022.09.28 |
[우아한테크코스] 레벨3 레벨로그 인터뷰 후기 (10) | 2022.09.05 |
[220819] 우아한테크코스 3차, 4차 데모데이 후기 (3) | 2022.08.24 |
[220722] 우아한테크코스 2차 데모데이 후기 (6) | 2022.07.24 |