인덱스 페이지(http://localhost:8080/index.html)
에 접근할 수 있도록 구현하였습니다.
더불어서 http://localhost:8080으로 요청시에도 index.html
페이지를 보여줄 수 있도록 구현하였습니다.
브라우저에서 index.html
응답을 받은 이후에, index.html 에 명시되어 있는 link
태그를 참고하여서 css/style.css
를 요청하는 것에 대한 처리도 수행해주었습니다.
해당 기능을 구현하면서 Content-Type
헤더가 무엇인지오 ㅏ중요성에 대해서 깨달을 수 있었습니다.
"?' (<- 이를 부르는 용어를 모르겠네요...ㅠ.ㅠ) 이후에 오게되는 Query String 을 파싱해서 InMemory 에 저장되어 있는 유저를 조회한 이후에 이를 로그로 찍도록 구현하였습니다.
이 과정에서 어떻게 해당 기능을 테스트할 수 있을까 고민해보았고, 원하는 로그가 제대로 찍혔는지도 JUnit을 이용해 테스트해보았습니다.
ClassLoader.getSystemResource()
메소드를 활용해서 간단하게 Class Path 에서 Resource를 찾는 방법을 배우게 되었다. 클래스를 로딩하기 위해서는 class 파일을 바이트로 읽어서 메모리에 로딩하게 된다. 설정 파일이나 다른 파일들은 바이트로 읽기 위해서 InputStream
을 얻어야 한다. 즉, 클래스 패스에 존재하는 모든 클래스 파일들, 설정 파일, 그 외 파일들 등등 모든 파일들은 ClassLoader
에서 찾을 수 있다.
참고 : [JAVA] CLASS PATH에서 RESOURCE 찾기
1단계 미션에서 다음과 같은 요구사항을 구현하였다.
http://localhost:8080/login?account=gugu&password=password
으로 접속하면 로그인 페이지(login.html)를 보여주도록 만들자.
그리고 로그인 페이지에 접속했을 때 Query String을 파싱해서 아이디, 비밀번호가 일치하면 회원을 조회한 결과가 나오도록 만들자.
그런데, 여기서 회원을 조회한 이후에 로그
를 통해서 회원을 조회한 결과를 확인하게 된다.
따라서 로그가 제대로 찍혔는지를 확인하는 테스트 코드를 작성하는 법에 대해서 고민하게 되었고, 다음의 글을 참고하여 로그 또한 테스트 코드에서 확인해볼 수 있음을 알게 되었다.
@DisplayName("존재하는 회원의 account 로 로그인시 로그인을 성공하여 로그를 남긴다.")
@Test
void login() {
// given
final ListAppender<ILoggingEvent> appender = new ListAppender<>();
final Logger logger = (Logger) LoggerFactory.getLogger(LoginHandler.class);
logger.addAppender(appender);
appender.start();
final String account = "gugu";
String requestUrl = "login?account=" + account + "&password=password";
// when
LoginHandler.login(requestUrl);
// then
final List<ILoggingEvent> logs = appender.list;
final String message = logs.get(0).getFormattedMessage();
final Level level = logs.get(0).getLevel();
final User user = InMemoryUserRepository.findByAccount(account)
.orElseThrow();
assertThat(message).isEqualTo(user.toString());
assertThat(level).isEqualTo(INFO);
}
참고 : 추가된 LOG를 JUnit에서 확인하는 방법
참고 : JUnit을 이용한 Log 메시지 테스트
HTTP 응답 헤더에 실어지는 Content-Type
이 무엇을 하는지에 대해서 알 수 있었다. Content-Type
헤더는 리소스의 media type
을 나타내기 위한 헤더로 클라이언트에게 반환되는 컨텐츠의 컨텐츠 유형이 실제로 무엇인지를 나타낸다. (브라우저들은 어떤 경우에는 MIME 스니핑을 해서 이 헤더의 값을 꼭 따르지는 않는다.) css 파일과 같은 것을 응답으로 반환해줄 때에는 'text/css' 와 같이 지정해주어야 브라우저에서 정확하게 인식할 수 있음으로 특히 주의해야 한다. 또한 클라이언트 입장에서는 HTTP 요청 헤더의 Accept
를 통해서 응답으로 받길 원하는 컨텐츠 형식을 지정해줄 수 있는데, 이 때 우선순위에 따라서 여러개를 명시해줄 수 있게 된다.
모든 요청이 들어오면 로직을 시작하게 되는 출발점은 바로 Http11Processor
클래스의 process()
메소드이다.
public class Http11Processor implements Runnable, Processor {
private static final int KEY_INDEX = 0;
private static final int VALUE_INDEX = 1;
private static final String HEADER_DELIMITER = ": ";
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 InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
final BufferedReader bufferedReader = new BufferedReader(inputStreamReader)) {
final HttpRequestHeader httpRequestHeader = makeHttpRequestHeader(bufferedReader);
String requestUrl = httpRequestHeader.getRequestUrlWithoutQuery();
handleLogin(httpRequestHeader, requestUrl);
String responseBody = makeResponseBody(requestUrl);
final HttpResponse httpResponse = new HttpResponse(OK, ContentType.from(requestUrl), responseBody);
final String response = httpResponse.getResponse();
outputStream.write(response.getBytes());
outputStream.flush();
} catch (IOException | UncheckedServletException e) {
log.error(e.getMessage(), e);
}
}
private static void handleLogin(final HttpRequestHeader httpRequestHeader, final String requestUrl) {
if (requestUrl.contains("login")) {
final String fullRequestUrl = httpRequestHeader.getRequestUrl();
LoginHandler.login(fullRequestUrl);
}
}
private String makeResponseBody(final String requestUrl) throws IOException {
return new String(readAllFile(requestUrl), UTF_8);
}
private static byte[] readAllFile(final String requestUrl) throws IOException {
final URL resourceUrl = ClassLoader.getSystemResource("static" + requestUrl);
final Path path = new File(resourceUrl.getPath()).toPath();
return Files.readAllBytes(path);
}
private HttpRequestHeader makeHttpRequestHeader(final BufferedReader bufferedReader) throws IOException {
final String httpStartLine = bufferedReader.readLine();
final Map<String, String> httpHeaderLines = makeHttpHeaderLines(bufferedReader);
return new HttpRequestHeader(httpStartLine, httpHeaderLines);
}
private static Map<String, String> makeHttpHeaderLines(final BufferedReader bufferedReader) throws IOException {
final Map<String, String> httpHeaderLines = new HashMap<>();
String line;
while ((line = bufferedReader.readLine()) != null) {
if (line.isBlank()) {
break;
}
final String[] header = line.split(HEADER_DELIMITER);
httpHeaderLines.put(header[KEY_INDEX], header[VALUE_INDEX]);
}
return httpHeaderLines;
}
}
Runnable
인터페이스와 Processor
인터페이스를 구현한 Http11Processor
클래스의 process()
메소드가 호출되면서 사용자의 요청을 처리하게 된다. Processor
인터페이스의 process()
메소드의 설명은 아래와 같다. 즉, 연결을 처리하며, 현재 처리되지 않은 연결에 대해 처리를 계속할 수 있는 이벤트(ex. 더 많은 데이터)가 발생할 때마다 호출된다. 라고 적혀있다.
즉, 사용자의 요청이 오면 해당 소켓을 이용해서 인풋과 아웃풋을 위한 stream과 reader 를 생성한다. 그리고 나서
입력스트림, 정확히는 bufferedReader 로 감싸진 해당 스트림을 통해서 HttpRequestHeader
객체를 생성한다.
즉, 요청되어온 HTTP Request Message 에서 가장 먼저 헤더 부분 부터 읽어서 별도의 클래스로 처리하게 되는 것이다.
그리고 헤더 라인, 그 중에서도 start line
에서 요청 URL을 읽어내는데, 이 때 쿼리스트링 부분을 빼고 읽어온다. 그리고는 만약 요청이 login
요청이면 로그인을 처리하게 되고, 그렇지 않다면 요청된 URL에 해당하는 파일로부터 응답을 만들어 반환해주게 된다.
public class HttpRequestHeader {
private static final String HTML_EXTENSION = ".html";
private static final String QUERY_START_CHARACTER = "?";
private static final String ROOT = "/";
private static final String EXTENSION_CHARACTER = ".";
private static final String DEFAULT_PAGE_URL = "/index.html";
private static final String START_LINE_REGEX = " ";
private static final int URL_INDEX = 1;
private final String startLine;
private final Map<String, String> headers;
public HttpRequestHeader(final String startLine, final Map<String, String> headers) {
this.startLine = startLine;
this.headers = headers;
}
public String getRequestUrlWithoutQuery() {
final String requestUrl = getRequestUrl();
if (requestUrl.contains(QUERY_START_CHARACTER)) {
final int index = requestUrl.indexOf(QUERY_START_CHARACTER);
return requestUrl.substring(0, index);
}
return requestUrl;
}
public String getRequestUrl() {
String requestUrl = startLine.split(START_LINE_REGEX)[URL_INDEX];
requestUrl = makeDefaultRequestUrl(requestUrl);
return requestUrl;
}
private String makeDefaultRequestUrl(String requestUrl) {
if (requestUrl.equals(ROOT)) {
return DEFAULT_PAGE_URL;
}
if (!requestUrl.contains(EXTENSION_CHARACTER)) {
return addExtension(requestUrl);
}
return requestUrl;
}
private String addExtension(final String requestUrl) {
final int index = requestUrl.indexOf(QUERY_START_CHARACTER);
if (index != -1) {
final String path = requestUrl.substring(0, index);
final String queryString = requestUrl.substring(index + 1);
return path + HTML_EXTENSION + QUERY_START_CHARACTER + queryString;
}
return requestUrl + HTML_EXTENSION;
}
}
HTTP 요청 메시지의 헤더와 관련된 부분을 담당하고 있다. 앞서 언급한 구현 내용 중에 '루트'요청에 대해서 index.html로 갈 수 있도록 구현하였다고 하였는데, 관련된 구현 내용은 makeDefaultRequestUrl()
메소드에 있다. 즉, 요청이 Root("/") 와 같으면 DEFAULT_PAGE_URL
인 index.html 이 request URL 이 되도록 변환해주는 것이다. 그리고 확장자를 포함하고 있지 않은 경우에는 기본적으로 .html
확장자를 추가해주는 모습이다.
마지막으로 getRequestUrlWithoutQuery()
메소드의 경우에는 쿼리 스트링 부분을 제외한 요청 URL 부분만을 반환해주고 있다. 이와 반대로 getRequestUrl()
메소드는 쿼리 스트링 부분까지 포함해서 반환해주고 있다.
즉, 요청 온 HTTP 메시지 중 헤더와 관련된 내용을 처리해주고 있다.
public class HttpResponse {
private final StatusCode statusCode;
private final ContentType contentType;
private final String responseBody;
public HttpResponse(final StatusCode statusCode, final ContentType contentType, final String responseBody) {
this.statusCode = statusCode;
this.contentType = contentType;
this.responseBody = responseBody;
}
public String getResponse() {
return String.join("\r\n",
"HTTP/1.1 " + statusCode + " ",
"Content-Type: " + contentType + ";charset=utf-8 ",
"Content-Length: " + responseBody.getBytes().length + " ",
"",
responseBody);
}
}
HttpResponse
는 별거 없다. 상태 코드와 contentType, 그리고 responseBody 를 받아서 적절한 HTTP 응답 메시지를 반환해주는 것이 책임의 끝이다.
이 때에는 아래와 같은 열거형 ContentType
의 from()
메소드를 활용하여 적절한 ContentType 을 만들어줌으로써 CSS 파일이나 JS 파일들을 지원해주도록 하는 것이 핵심이다.
즉 응답의 ContentType 에 따라서 브라우저는 이것이 css 인지 html 인지를 판단하고 css 를 지원해주기 때문에 이와 같은 처리가 톰캣에서 필요해지게 되는 것이다.
public enum ContentType {
HTML("text/html"),
CSS("text/css"),
JS("application/javascript");
private static final String EXTENSION_DELIMITER = "\\.";
private static final int EXTENSION_INDEX = 1;
private static final int NO_EXTENSION_SIZE = 1;
private final String contentType;
ContentType(final String contentType) {
this.contentType = contentType;
}
public static ContentType from(final String requestUrl) {
String[] splitRequestUrl = requestUrl.split(EXTENSION_DELIMITER);
if (splitRequestUrl.length == NO_EXTENSION_SIZE) {
return HTML;
}
String extension = splitRequestUrl[EXTENSION_INDEX];
return Arrays.stream(ContentType.values())
.filter(contentType -> isSameExtension(contentType, extension))
.findAny()
.orElse(HTML);
}
private static boolean isSameExtension(ContentType contentType, String extension) {
String contentTypeName = contentType.name().toLowerCase();
return contentTypeName.contains(extension);
}
@Override
public String toString() {
return this.contentType;
}
}
public class LoginHandler {
private static final String QUERY_START_CHARACTER = "?";
private static final int INVALID_INDEX = -1;
private static final Logger log = LoggerFactory.getLogger(LoginHandler.class);
private LoginHandler() {
}
public static void login(final String requestUrl) {
final int index = requestUrl.indexOf(QUERY_START_CHARACTER);
if (index == INVALID_INDEX) {
return;
}
String queryString = requestUrl.substring(index + 1);
final QueryParams queryParams = QueryParams.from(queryString);
final String account = queryParams.getValueFromKey("account");
final User user = InMemoryUserRepository.findByAccount(account)
.orElseThrow(NoSuchUserException::new);
final String userInformation = user.toString();
log.info(userInformation);
}
}
마지막으로 LoginHandler
정도를 정리하면 핵심적인 코드는 모두 정리되는 것 같다.
이 부분은 톰캣에 포함되는 부분이 아니라 우리가 만드는 컨트롤러 정도로 생각하면 좋을 것 같다. 요청 url 을 받고 쿼리 부분을 분리해낸다. 그런 이후에 쿼리 스트링에서 account 부분을 찾은 뒤 이를 통해서 현재 InMemoryDB 에 저장되어 있는 유저를 조회하고 이를 로그로 출력하고 있다.
여기서 QueryParams
를 이용하는데 생성시 주어진 문자열로 부터 구분자를 통해서 쿼리를 분리하는 책임을 가진 값 객체 정도로 생각하면 된다.
public class QueryParams {
private static final String QUERY_STRING_DELIMITER = "&";
private final List<QueryParam> queryParams;
public QueryParams(final List<QueryParam> queryParams) {
this.queryParams = queryParams;
}
public static QueryParams from(String queryParams) {
return new QueryParams(Stream.of(queryParams.split(QUERY_STRING_DELIMITER))
.map(QueryParam::from)
.collect(toList()));
}
public String getValueFromKey(String key) {
return queryParams.stream()
.filter(it -> it.isSameKey(key))
.findAny()
.map(QueryParam::getValue)
.orElseThrow(() -> new IllegalArgumentException("올바르지 않은 키입니다."));
}
}
public class QueryParam {
private static final String QUERY_PARAMETER_DELIMITER = "=";
private static final int KEY_INDEX = 0;
private static final int VALUE_INDEX = 1;
private final String key;
private final String value;
public QueryParam(final String key, final String value) {
this.key = key;
this.value = value;
}
public static QueryParam from(String queryParam) {
final String[] splitParam = queryParam.split(QUERY_PARAMETER_DELIMITER);
return new QueryParam(splitParam[KEY_INDEX], splitParam[VALUE_INDEX]);
}
public String getValue() {
return value;
}
public boolean isSameKey(final String key) {
return this.key.equals(key);
}
}
기존의 LoginHandler
에서 단순히 User 존재 여부만 확인하던 로직에서 비밀번호의 일치여부에 따라 로그인 성공 여부를 판별하도록 하였습니다. 그리고 로그인 성공 여부
에 따라서 index.html
혹은 401.html
로 리다이렉트 해줄 수 있도록 구현하였습니다!
이 때, 302 상태 코드
와 함께 Location 헤더
를 사용해주었습니다.
기존 로그인시 GET
요청에서 쿼리 스트링으로 받던 사용자 정보를 POST 요청으로 변경하고 요청 바디에 실어서 보내도록 수정해주었습니다. 해당 요청에 대한 처리는 헤더 정보를 모두 읽은 이후 content-length
만큼 요청 바디를 통해 읽도록 구현해주었습니다. (HTTP 요청 메시지에서 요청헤더 부분과 바디 부분의 한 줄 공백이 왜 필요한지 느낄 수 있었습니다.)
이 과정에서 HTT Method 의 유의미한 분리가 필요해져 HttpMethod 라는 열거형을 도출했으며, Http11Processor
쪽에서도 바디를 읽어내는 메소드를 추가로 구현하였습니다.
회원가입 시에도 비슷하게 로직을 구성하였으며 요청 메소드에 따라서 처리를 분기해주고 있습니다. 또한 만약 이미 존재하는 Account로 회원가입 요청시에는 ExistUserException
을 던지도록 구현해주었습니다.
로그인 성공시에 JSESSIONID
를 키로 가지는 쿠키를 생성해서 응답(Set-Cookie)에 실어서 응답하도록 구현하였으며, 만약 요청 헤더 Cookie 에 이미 JSESSIONID
가 존재하면 Set-Cookie 응답을 보내지 않도록 구현해주었습니다.
로그인 성공 시에 Session을 생성(이 대 UUID 이용해서 Session 키 할당)하고, 해당 세션의 attribute에 User 정보를 담도록 하였습니다. 그리고 해당 세션은 SessionManager
에 등록해주었습니다.
만약 다시 login GET 요청시, 헤더에 JSESSIONID 에 해당하는 쿠키가 존재하고, 해당 쿠키의 값이 우리가 앞서 저장한 SessionManager
에 등록되어 있을 경우 index.html 로 리다이렉트하도록 구현해주었습니다. 만약 그렇지 않으면 login.html 을 응답하도록 구현해주었습니다.
큰 의미가 없다고 생각했던 Content-Length
헤더 필드에 대해서 직접 POST 요청의 바디 부분을 읽으면서 그 사용용도를 알게 되었다. Content-Length
는 수신자에게 보내지는 바이트 단위를 가지는 요청 바디(Request Body)의 크기를 나타낸다. 수신자쪽에서는 해당 바이트의 크기만큼 읽어들여 처리할 수 있다.
final int contentLength = Integer.parseInt(contentLengthHeader.trim());
final char[] buffer = new char[contentLength];
bufferedReader.read(buffer, 0, contentLength);
return new String(buffer);
회원가입을 구현하고, 이를 테스트하기 위해 단위 테스트(RegisterHandlerTest)를 작성하다보니 각 단위테스트가 격리되지 못하는 문제가 발생하였다. 앞선 단위 테스트에서 저장한 User
가 다른 단위테스트에도 영향을 끼치는 것이다.
따라서 이를 격리해줄 필요성이 있게 되었고, 다음과 같은 메소드를 InMemoryUserRepositroy
에 만들고, BeforeEach
를 통해서 각 단위 테스트 진행 전에 호출하도록 구현해주었다. 하지만 아직 테스트를 위해서 Production 코드에 rollback()
과 같은 메소드를 만들어 두는 것이 최선인지에 대해서는 고민이든다. 조금 더 나은 방법을 고민해보아야겠다.
public static void rollback() {
database.clear();
final User user = new User(1L, "gugu", "password", "hkkang@woowahan.com");
database.put(user.getAccount(), user);
}
class RegisterHandlerTest {
@BeforeEach
void setUp() {
InMemoryUserRepository.rollback();
}
...
}
쿠키와 세션을 통한 인증&인가 과정을 이론으로만 알고 있다가 실제로 구현해보니 생각보니 어려웠다. 머릿속에 있는 내용이 코드로 잘 안옮겨지는 느낌이었다. 또한 세션 저장소를 통해서 별도로 세션에 대한 관리가 필요하다는 점이 실제로 구현을 진행해보니 JWT에 비해서 불편하다는 생각도 들었다.
기존에 단순히 200 Ok
상태코드와 함께 응담을 내려주던 Http11Processor
에서 이제는 적절한 상태코드와 함께 응답을 내려주도록 개선할 필요가 있었다. 예를 들어 login 페이지에서 로그인 버튼 클릭시 302 상태코드와 함께 리다이렉트 시켜야하는 경우가 그러하다. 따라서 다음과 같이 열거형 상수를 추가해주었다.
public enum StatusCode {
OK("200 OK"),
FOUND("302 Found");
private final String statusCode;
StatusCode(final String statusCode) {
this.statusCode = statusCode;
}
@Override
public String toString() {
return this.statusCode;
}
}
StatusCode 의 위와 같은 변경은 HttpResponse 에도 영향을 주었다.
만약 302 상태 코드가 넘어오는 경우에는 응답에 Location 헤더를 포함하게 될 것이고 이 경우에는 Location 헤더 필드를 추가해주도록 하였다.
public class HttpResponse {
private static final Logger log = LoggerFactory.getLogger(HttpResponse.class);
private final StatusCode statusCode;
private final ContentType contentType;
private final String responseBody;
private final Location location;
private final Cookie cookie;
private HttpResponse(StatusCode statusCode, ContentType contentType, String responseBody) {
this.statusCode = statusCode;
this.contentType = contentType;
this.responseBody = responseBody;
this.location = null;
this.cookie = Cookie.empty();
}
private HttpResponse(final StatusCode statusCode, final ContentType contentType, final String responseBody,
final Location location) {
this.statusCode = statusCode;
this.contentType = contentType;
this.responseBody = responseBody;
this.location = location;
this.cookie = Cookie.empty();
}
private HttpResponse(final StatusCode statusCode, final ContentType contentType, final String responseBody,
final Location location, final Cookie cookie) {
this.statusCode = statusCode;
this.contentType = contentType;
this.responseBody = responseBody;
this.location = location;
this.cookie = cookie;
}
public static HttpResponse of(final StatusCode statusCode, final ContentType contentType, final String requestUrl) {
final String responseBody = new String(Objects.requireNonNull(readAllFile(requestUrl)), UTF_8);
return new HttpResponse(statusCode, contentType, responseBody);
}
public static HttpResponse of(final StatusCode statusCode, final ContentType contentType, final Location location) {
final String responseBody = new String(Objects.requireNonNull(readAllFile(location.toString())), UTF_8);
return new HttpResponse(statusCode, contentType, responseBody, location);
}
public static HttpResponse of(final StatusCode statusCode, final ContentType contentType, final Location location,
final Cookie cookie) {
final String responseBody = new String(Objects.requireNonNull(readAllFile(location.toString())), UTF_8);
return new HttpResponse(statusCode, contentType, responseBody, location, cookie);
}
public String getResponse() {
StringBuilder builder = new StringBuilder();
builder.append("HTTP/1.1 ").append(statusCode).append(" \r\n");
builder.append("Content-Type: ").append(contentType).append(";charset=utf-8 \r\n");
builder.append("Content-Length: ").append(responseBody.getBytes().length).append(" \r\n");
appendLocation(builder);
appendSetCookie(builder);
builder.append("\r\n");
builder.append(responseBody);
return builder.toString();
}
public Cookie getCookie() {
return cookie;
}
private void appendLocation(StringBuilder builder) {
if (location != null) {
builder.append("Location: ").append(location).append(" \r\n");
}
}
private void appendSetCookie(StringBuilder builder) {
if (!cookie.isEmpty()) {
builder.append("Set-Cookie: ").append(cookie.toHeaderFormat()).append(" \r\n");
}
}
private static byte[] readAllFile(final String requestUrl) {
final URL resourceUrl = ClassLoader.getSystemResource("static" + requestUrl);
final Path path = new File(resourceUrl.getPath()).toPath();
try {
return Files.readAllBytes(path);
} catch (IOException e) {
log.error("파일을 읽어들이지 못했습니다.");
return null;
}
}
}
위의 코드를 보면 굉장히 많은 생성자를 가지고 있는데 그 이유는 Location을 포함하는지의 여부, 그리고 Cookie 를 포함하는지의 여부에 따라서 getResponse()
응답을 다르게 해주고 싶었기 때문이다.
Http11Processor 의 process()
메소드 부분만 보자.
@Override
public void process(final Socket connection) {
try (final var inputStream = connection.getInputStream();
final var outputStream = connection.getOutputStream();
final InputStreamReader inputStreamReader = new InputStreamReader(inputStream);
final BufferedReader bufferedReader = new BufferedReader(inputStreamReader)) {
final HttpRequest httpRequest = readHttpRequest(bufferedReader);
final HttpMethod requestMethod = httpRequest.getRequestMethod();
String requestUrl = httpRequest.getRequestUrlWithoutQuery();
HttpResponse httpResponse = HttpResponse.of(OK, ContentType.from(requestUrl), requestUrl);
if (requestUrl.contains("login") && requestMethod.equals(GET)) {
httpResponse = LoginHandler.loginWithGet(httpRequest);
}
if (requestUrl.contains("login") && requestMethod.equals(POST)) {
httpResponse = LoginHandler.login(httpRequest);
}
if (requestUrl.contains("register") && requestMethod.equals(POST)) {
httpResponse = RegisterHandler.register(httpRequest.getRequestBody());
}
final String response = httpResponse.getResponse();
outputStream.write(response.getBytes());
outputStream.flush();
} catch (IOException | UncheckedServletException e) {
log.error(e.getMessage(), e);
}
}
기본적으로 HttpResponse의 응답으로 "200 OK" 를 가지고 있다. 그런데 요청이 무엇이냐에 따라서 핸들러를 거치게 되고, httpResponse 가 대체되는 것을 볼 수 있다. (물론, 현재 분기문을 직접 활용하고 있어 확장성을 전혀 고려하지 못하고 있는 코드여서 불편하실 수도 있지만, 3단계 미션에서 리팩터링을 진행하므로 개선된 코드를 볼 수 있을 것이다!) Handler쪽에서 적절한 처리를 수행하고 그에 따른 응답을 생성해 넘겨주면 Http11Processor 는 이것을 그대로 outputStream을 통해서 날려주면 끝난다.
또한 POST 요청을 지원해주어야하게 되면서 요청 메시지에 함께 오는 Content-Length 헤더를 이용해서 POST 요청의 바디를 읽도록 구현해주었다.
private static HttpRequest readHttpRequest(final BufferedReader bufferedReader) throws IOException {
final String httpStartLine = bufferedReader.readLine();
final Map<String, String> httpHeaderLines = readHttpHeaderLines(bufferedReader);
final String requestBody = readRequestBody(bufferedReader, httpHeaderLines);
return HttpRequest.of(httpStartLine, httpHeaderLines, requestBody);
}
private static Map<String, String> readHttpHeaderLines(BufferedReader bufferedReader) throws IOException {
final Map<String, String> httpHeaderLines = new HashMap<>();
String line;
while ((line = bufferedReader.readLine()) != null) {
if (line.isBlank()) {
break;
}
final String[] header = line.split(HEADER_DELIMITER);
httpHeaderLines.put(header[KEY_INDEX], header[VALUE_INDEX].trim());
}
return httpHeaderLines;
}
private static String readRequestBody(BufferedReader bufferedReader, Map<String, String> httpHeaderLines)
throws IOException {
final String contentLengthHeader = httpHeaderLines.get("Content-Length");
if (contentLengthHeader == null) {
return "";
}
final int contentLength = Integer.parseInt(contentLengthHeader.trim());
final char[] buffer = new char[contentLength];
bufferedReader.read(buffer, 0, contentLength);
return new String(buffer);
}
LoginHandler 에서는 기존의 GET 요청에 쿼리 파라미터로 데이터를 포함하는 경우 뿐 아니라 POST 요청에 대해서도 지원해줄 수 있도록 메소드를 두 개 만들게 되었다. (loginWithGet()
과 login()
)
GET 요청의 경우에는 로그인 성공 여부에 따라서 index.html 로 리다이렉트하거나 login.html 을 보여주도록 구현해주었다.
그리고 실제로 로그인 요청과 같은 경우에는 POST 로 요청이 오게 되는데 이 때, 요청시의 데이터는 body에 쿼리스트링 형태로 담겨서 온다. 따라서 기존의 QueryParams
클래스를 그대로 활용해줄 수 있었고, 쿼리파라미터에서 account, password 를 꺼낸 뒤에 InMemoryRepository
를 활용하여 로그인을 처리한다. 그리고 만약 로그인이 적절하지 않은 경우에는 401 로 리다이렉트 시켰고, 만약 로그인이 정상적인 경우에는 index.html로 리다이렉트 하도록 구현해주었다.
그리고 이렇게 HTTP Method 에 따른 요청을 처리하기 위해서 HttpMethod
라는 클래스를 만들게 되었다. (코드는 생략. 열거형으로 구현)
뿐만 아니라 로그인 성공시에 JSESSIONID 쿠키를 생성해서 응답(Set-Cookie)에 함께 보낸다.
그리고 만약 HTTP 요청 헤더의 Cookie에 JSESSIONID 가 존재하면 Set-Cookie 응답을 보내지 않는다.
요구사항을 만족시키위해 makeLoginSucccessResponse()
메소드에서 쿠키에 JSESSIONID 를 포함하고 있는지 여부에 따라서 새로운 쿠키를 발급해서 응답해주거나 그렇지 않은 경우를 분기해주고 있는 모습이다.
public class LoginHandler {
private static final Logger log = LoggerFactory.getLogger(LoginHandler.class);
private LoginHandler() {
}
public static HttpResponse loginWithGet(HttpRequest httpRequest) {
final Optional<Cookie> optionalCookie = httpRequest.getJSessionCookie();
if (optionalCookie.isPresent()) {
final Cookie cookie = optionalCookie.get();
handleSession(cookie);
return HttpResponse.of(FOUND, HTML, Location.from("/index.html"));
}
return HttpResponse.of(OK, HTML, "/login.html");
}
public static HttpResponse login(final HttpRequest request) {
final QueryParams queryParams = QueryParams.from(request.getRequestBody());
final String account = queryParams.getValueFromKey("account");
final String password = queryParams.getValueFromKey("password");
final User user = InMemoryUserRepository.findByAccount(account)
.orElseThrow(NoSuchUserException::new);
if (user.checkPassword(password)) {
log.info("User : {}", user);
final Session session = saveUserInSession(user);
return makeLoginSuccessResponse(request, session);
}
return HttpResponse.of(FOUND, HTML, Location.from("/401.html"));
}
private static void handleSession(Cookie cookie) {
final Session session = SessionManager.findSession(cookie.getValue());
final User user = (User) session.getAttribute("user");
log.info("User : {}", user);
}
private static Session saveUserInSession(User user) {
final Session session = new Session();
session.setAttribute("user", user);
SessionManager.add(session);
return session;
}
private static HttpResponse makeLoginSuccessResponse(HttpRequest request, Session session) {
final Optional<Cookie> cookie = request.getJSessionCookie();
if (cookie.isEmpty()) {
final Cookie jsessionid = Cookie.ofJSessionId(session.getId());
return HttpResponse.of(FOUND, HTML, Location.from("/index.html"), jsessionid);
}
return HttpResponse.of(FOUND, HTML, Location.from("/index.html"));
}
}
public class RegisterHandler {
private static final Logger log = LoggerFactory.getLogger(RegisterHandler.class);
private RegisterHandler() {
}
public static HttpResponse register(final String requestBody) {
final QueryParams queryParams = QueryParams.from(requestBody);
final String account = queryParams.getValueFromKey("account");
final String password = queryParams.getValueFromKey("password");
final String email = queryParams.getValueFromKey("email");
checkAlreadyExistUser(account);
final User user = new User(account, password, email);
InMemoryUserRepository.save(user);
final String userInformation = user.toString();
log.info("회원가입 성공! : {}", userInformation);
return HttpResponse.of(FOUND, HTML, Location.from("/index.html"));
}
private static void checkAlreadyExistUser(String account) {
final Optional<User> foundUser = InMemoryUserRepository.findByAccount(account);
if (foundUser.isPresent()) {
throw new ExistUserException();
}
}
}
회원가입을 처리하는 핸들러이다. Http11Processor 에서 요청이 "/register" 이고 GET 요청인 경우에는 register.html 을 응답하도록 해주고 있다.
그런데 만약 POST 요청으로 오는 경우에는 해당 핸들러의 register()
메소드가 호출된다. 회원가입이 성공하는 경우에는 InMemoryDB 에 User 객체를 저장하고 index.html
로 302 응답과 함게 redirect 해준다. 그런데 만약 회원가입이 실패하는 경우에는 ExistUserException
예외를 던지도록 구현해주었다.
public class Cookies {
private static final String COOKIE_DELIMITER = "; ";
private final List<Cookie> cookies;
public Cookies() {
this.cookies = new ArrayList<>();
}
public Cookies(List<Cookie> cookies) {
this.cookies = cookies;
}
public static Cookies from(String cookie) {
if (cookie == null) {
return new Cookies();
}
final List<Cookie> cookies = Arrays.stream(cookie.split(COOKIE_DELIMITER))
.map(Cookie::from)
.collect(Collectors.toList());
return new Cookies(cookies);
}
public Optional<Cookie> getCookie(String cookieKey) {
return cookies.stream()
.filter(it -> it.isSameKey(cookieKey))
.findAny();
}
public Optional<Cookie> getJSessionCookie() {
return cookies.stream()
.filter(Cookie::isJSessionCookie)
.findAny();
}
}
public class Cookie {
private static final String COOKIE_DELIMITER = "=";
private static final int KEY_INDEX = 0;
private static final int VALUE_INDEX = 1;
private static final String JSESSIONID = "JSESSIONID";
private static final int NO_VALUE_IN_COOKIE_SIZE = 1;
private final String key;
private final String value;
public Cookie(String key, String value) {
this.key = key;
this.value = value;
}
public static Cookie from(String cookie) {
final String[] splitCookie = cookie.split(COOKIE_DELIMITER);
checkCookieValue(splitCookie);
return new Cookie(splitCookie[KEY_INDEX], splitCookie[VALUE_INDEX]);
}
public static Cookie empty() {
return new Cookie("", "");
}
public static Cookie ofJSessionId(String id) {
return new Cookie(JSESSIONID, id);
}
public boolean isEmpty() {
return key.equals("");
}
public boolean isSameKey(String key) {
return this.key.equals(key);
}
public String toHeaderFormat() {
return this.key + "=" + this.value;
}
public boolean isJSessionCookie() {
return key.equals(JSESSIONID);
}
public String getValue() {
return value;
}
private static void checkCookieValue(final String[] splitCookie) {
if (splitCookie.length == NO_VALUE_IN_COOKIE_SIZE) {
throw new IllegalArgumentException("쿠키 값이 존재하지 않습니다.");
}
}
}
로그인 성공 여부에 따라서 JSESSIONID
를 키로 가지는 쿠키를 생성해 응답에 실어줘야 하기 때문에 위와 같은 클래스들을 도출하게 되었다.
Cookies 는 말 그대로 쿠키 리스트를 가지고 있는 객체이며, 문자열을 파싱해서 쿠키 리스트로 만드는 from() 정적 팩토리 메소드와 쿠키의 키에 따라서 쿠키를 반환하는 getCookie()
그리고 JSESSIONID
를 키로 가지는 쿠키를 반환해주는 getJSessionCookie()
메소드를 가진다.
이를 활용하여 LoginHandler
에서는 로그인 성공시에 JSESSIONID 쿠키를 생성해 함께 반환하거나 이미 존재하면 보내지 않는 로직을 처리하게 된다.
public class Session {
private final String id;
private final Map<String, Object> values = new HashMap<>();
public Session() {
this.id = UUID.randomUUID().toString();
}
public String getId() {
return id;
}
public Object getAttribute(final String name) {
return values.get(name);
}
public void setAttribute(final String name, final Object value) {
values.put(name, value);
}
}
public class SessionManager {
private static final Map<String, Session> SESSIONS = new HashMap<>();
private SessionManager() {
}
public static void add(final Session session) {
SESSIONS.put(session.getId(), session);
}
public static Session findSession(final String id) {
if (SESSIONS.containsKey(id)) {
return SESSIONS.get(id);
}
throw new IllegalArgumentException("올바르지 않은 세션 ID 입니다.");
}
}
Session
과 SessionManager
를 구현해주었다. Session 클래스는 말 그대로 키(세션ID)와 매칭되는 Object 객체를 Map 형태로 가지고 있고, SessionManager 는 그런 세션들을 관리해준다.
LoginHandler
에서는 이를 활용하여 요청 쿠키에 JSESSIONID를 키로 가지는 쿠키가 담겨있으면 index.html로, 만약 요청에 JSESSIONID 를 키로 가지는 쿠키가 존재하지 않으면 새롭게 유저를 세션ID와 함께 세션에 저장하고, 해당 세션의 ID를 값으로 하는 쿠키를 만들어 index.html로 리다이렉트하도록 구현해주었다.
즉, 쿠키에서 전달받은 JSESSIONID 의 값을 통해서 로그인 여부를 체크할 수 있을 뿐 아니라 세션을 통해 로그인 여부를 판단하여 이미 로그인된 사용자는 바로 index.html로 리다이렉트할 수 있게 되는 것이다.
newFixedThreadPool
을 사용하여 고정된 개수(250)의 쓰레드를 재사용하며 초과되는 요청은 대기 상태(큐)SessionManager
에 ConcurrentHashMap
을 사용하여 Session 컬렉션에 대해 쓰레드 안정성을 보장HttpRequest
와 HttpResponse
각각에 대해서 책임을 어떻게 가장 적절하게 분배할 수 있을지를 고민하였다. (어떻게 객체들을 구성할지 고민하였다.)
위의 그림을 참고하여 클래스를 도출해내었다.
먼저 HttpRequest에 대해서는 HTTP Method 와 요청이 온 Request Path, 그리고 HttpVersion 으로 먼저 나누었고, 이 3개의 클래스를 모두 HTTP 요청 메시지의 첫 줄, 즉 StartLine 에 해당하므로 StartLine 클래스를 도출해서 해당 클래스가 위 3개의 클래스를 필드로 가지도록 구현하였다. 그런데 이 때, QueryParam 이 함께 Path 에 붙어서 올 수 있으므로 QueryParams 또한 가질 수 있도록 해주었다.
그리고 HttpRequest
는 크게 헤더와 바디, 시작줄을 가지는데, 이 때 미션의 경우 쿠키에 대해서 비중 있게 다루고 있으므로 Cookies
필드 또한 가질 수 있도록 하였다.
private final StartLine startLine;
private final HttpHeader headers;
private final Cookies cookies;
private final HttpRequestBody requestBody;
다음으로는 HTTP 응답인데, 위의 그림에서 Version of the protocol
에 대한 부분은 우리가 다루고 있지 않아 별도의 클래스로 분리해내지 않았다. 또한 Status Code 와 Status Message 의 경우 함께 사용되므로 하나의 StatusCode
로 분리해내었다. 그리고 응답 헤더의 모든 부분을 다루지 않고, 우리 미션에서 비중있게 사용되고 있는 Content-Type 과 Location에 대해서만 별도의 책임을 가지는 클래스로 도출해내었다.
컨트롤러를 도출해내면서 가장 많은 부분이 바뀌었다. 가장 먼저 Controller 인터페이스와 AbstractController 를 다음과 같이 구현하였다.
public interface Controller {
void service(HttpRequest request, HttpResponse response) throws Exception;
}
public abstract class AbstractController implements Controller {
@Override
public void service(HttpRequest request, HttpResponse response) throws Exception {
final HttpMethod method = request.getRequestMethod();
if (method.equals(POST)) {
doPost(request, response);
}
if (method.equals(GET)) {
doGet(request, response);
}
}
protected void doPost(HttpRequest request, HttpResponse httpResponse) throws Exception {
throw new UnsupportedOperationException();
}
protected void doGet(HttpRequest request, HttpResponse httpResponse) throws Exception {
throw new UnsupportedOperationException();
}
}
위와 같이 컨트롤러를 구현하고, 실제 Controller 구현체로 매핑이 될 책임은 RequestMapping
으로 위임하였다. RequestMapping
클래스에서는 요청이 온 URI 를 통해서 적절한 Controller를 반환해주게 되고, Http11Processor 에서는 RequestMapping
으로 부터 반환되어온 Controller 구현체의 service 메소드를 호출하여 요청을 처리하게 된다.
처음에는 실제 요청을 HTTP Method(GET or POST) 에 따라 처리하는 service()
가 request
를 받고, response
를 반환하는 식으로 먼저 리팩토링하였다. 하지만 이렇게 되면 Http11Processor
로 부터 모든 요청과 응답에 대한 책임이 Controller로 위임되지 않는다. Http11Processor
는 컨트롤러로부터 다시 응답을 받아 이를 처리해주어야한다. 이는 응답쪽의 책임이 완전히 분리되지 않는다. 또한 실제 package javax.servlet.http.HttpServlet
의 구조를 보면 위의 예시 코드와 같이 request, response 를 함께 받는다. 따라서 request와 response 를 모두 넘기고 response가 직접 HTTP 메시지를 만들어 클라이언트 측으로 전송해줄 수 있도록 리팩토링하게 되었다. Response는 OutputStream을 받아 생성되고, service()
메소드가 종료되기 직전에 Response 객체의 OutputStream을 통해 메시지를 클라이언트측으로 전송하게 된다. 하지만 이 모든 것이 HttpResponse
의 책임은 아니기 때문에 ResponsePrinter 로 별도로 분리해내었다.
추가적으로 이전에는 우리가 구현한 톰캣 쪽에 컨트롤러가 존재하는 형식이었다. 즉, 톰캣을 이용하는 개발자는 직접 우리 톰캣 쪽으로 들어와서 코드를 수정해야하는 것이다. 하지만 실제로 우리는 그렇게 구현하지 않는다. 단순히 톰켓이라고 하는 서블릿 컨테이너는 우리가 구현하는 컨트롤러를 지원해줄 뿐이다. 최대한 이와 같은 형태로 구현하려고 노력하였다고 보면 될 것 같다.
이번 3단계 리팩터링을 진행하며 예외처리에 대한 부분을 추가로 구현해주었다.
private static void handleRequest(final HttpRequest httpRequest, final HttpResponse httpResponse) {
try {
final Controller controller = RequestMapping.getController(httpRequest);
controller.service(httpRequest, httpResponse);
} catch(Exception e) {
ControllerAdvice.handle(httpResponse, e);
}
}
Http11Processor
에서 Controller 로 요청과 응답에 대한 책임을 위임하면서 발생한 예외를 ControllerAdvice
에서 처리해주도록 구현하였다. 이 과정에서 handle() 메소드 내부에서는 파라미터로 넘겨져온 Exception
의 instanceOf
를 활용해 분기하여 적절한 처리를 해주고 있다. 하지만 instanceof의 사용을 지양하자 는 글의 내용처럼 개선이 필요해보인다.
이번에 리팩터링을 진행하면서 정말 작은 단위로 나눠서 리팩터링을 진행했다고 생각했음에도 불구하고, 변경이 계속해서 전파되는 느낌을 받았다. Request
나 Response
쪽을 리팩터링할 때에는 괜찮았는데, Controller 를 도출해내면서 기존의 LoginHandler
와 같은 핸들러를 제거해야했으며, Http11Processor
에 너무 많은 책임이 있어, 한 step씩 리팩터링하기도 쉽지 않았다. 또한 머리속에서는 미리 앞서 나가 package javax.servlet.http.HttpServlet
와 같은 구조로 Controller를 구현하려고 하니 리팩터링 과정이 막막하게 다가오기도 하였다. 또한 내가 지금 버그를 만들지 않으면서 구조를 개선하고 있는지 확인받기 위한 test 코드도 프로덕션 코드와 함께 깨져 피드백도 제대로 받지 못하는 상태에서 리팩터링을 진행하였다. 이러한 과정에서 느낀점은 최대한 구현을 하면서 최소한의 리팩터링을 함께 진행하자는 점과 이렇게 구조 자체를 변경해야하는 큰 단위의 리팩터링의 경우, 기존 코드는 살려두고 새로운 .java 파일을 생성해서 기존의 테스트나 프로덕션 코드는 제대로 돌아가게 두고 하나씩 교체하는 방향으로 리팩터링 하는 것도 하나의 방법이 될 수 있겠다 이다.
newFixedThreadPool
은 공유 언바운드 큐에서 작동하는 고정된 수의 쓰레드를 재사용하는 쓰레드풀을 생성한다. 만약 동시에 실행되는 태스크의 수가 최대 쓰레드의 수를 초과한다면 작업 중 일부를 큐에 넣어 순서를 기다리게 된다. 반면 newCachedThreadPool
은 사용가능한 쓰레드가 없다면 새롭게 쓰레드를 생성한다.
두 메소드 모두 쓰레드풀에게 작업 처리를 요청하는 메소드이다. execute()
로 실행했을 때는 작업 처리 도중 예외가 발생하면 해당 쓰레드는 제거되고 새 쓰레드가 계속해서 생겨난다. 반면 submit()
의 경우에는 예외가 발생하더라도 쓰레드가 종료되지 않고 계속해서 재사용되어 다른 작업을 처리할 수 있다. 따라서 쓰레드 생성의 오베헤드를 줄일 수 있다. 또한 submit()
은 작업 처리 결과를 받을 수 있도록 Future를 리턴하는데, Future는 작업 실행 결과나 작업 상태(실행중)를 확인하기 위한 객체이다.
ExecutorService
의 shutdown()
메서드는 실행자 서비스를 즉시 종료시키지 않는다. 작업큐에 남아있는 작업까지 모두 마무리한 후 종료한다. 이와 다르게 shutdownNow()
메소드는 작업큐의 작업 잔량과 관계없이 강제 종료시킨다. (The shutdown() method doesn't cause immediate destruction of the ExecutorService. It will make the ExecutorService stop accepting new tasks and shut down after all running threads finish their current work)
ConcurrentHashMap
은 put()
메소드에 대해서 synchronized
를 붙여, 읽기에 대해서는 여러 쓰레드에서 동시에 읽을 수 있도록 하지만, 쓰기에 있어서는 동시에 하나의 쓰레드만 접근이 가능하도록 한다.
Q. 왜 이름에 Advice를 붙였나요? AOP를 떠올리고 이름을 붙인 걸까요? 적절한 이름인가요?
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public final class ControllerAdvice {
A. 넵..의도는 Spring에서 사용하는 @ControllerAdvice
와 비슷하게 예외를 일괄적으로 처리해주고 싶어서 이와 같이 구현하였습니다!
Advice라는 네이밍이 왜 붙은걸까?
하는 고민은 하지 않고 위와 같은 네이밍을 하였는데요..구구의 질문 덕분에 Advice(실질적으로 어떤 일을 해야할 지에 대한 것, 실질적인 부가기능을 담은 구현체 출처: https://engkimbs.tistory.com/746 [새로비:티스토리])
가 AOP 개념에서 나온 것이라는 것을 알게 되었습니다..!!
ControllerAdvice를 통해서 예외처리에 대한 관심을 Controller에서 분리해내었고, Controller에 AOP 를 적용한 느낌이 들기 때문에 현재 네이밍이 적절하다고 생각하는데요..
(현재 애플리케이션 전체에 걸쳐있는 예외를 분리해냈고, 부가기능을 담은 구현체이기 때문에 적절하다고 생각합니다.)
혹시 제가 놓치고 있는 부분이나 잘못 알고 있는 부분이 있으면 답변주시면 보충해서 학습하도록 하겠습니다!!😃
Q. 매번 List.of를 써야한다면 불편하지 않을까요?
private final List<String> exceptionClassName;
ExceptionType(final List<String> exceptionClassName) {
구구가 제안해준 변경 방법
ExceptionType(final String... exceptionClassName) {
this.exceptionClassName = List.of(exceptionClassName);
}
A. 넵 동의합니다! 더불어서 변경점이 생성자 한 곳으로 몰려있는 느낌이 들어, 추후에 어떤식으로 변경될지는 모르겠지만 변경이 발생해도 생성자만 변경해도 될 것 같다는 생각이 드네요..!!
추가적으로 하나의 예외 타입에 대해서만 다루고 있는 경우 ex. INTERNAL_SERVER_ERROR
에 대해서도 List.of
를 쓴다는게 어색하게 느껴졌었는데 가변 매개변수를 통해서 해결할 수 있겠네요!! 감사합니다!!
Q. 헤더를 쓰는 곳이 없네요??
private final HttpHeader headers;
A. 넵 맞습니다..!! 사용하지 않는 필드 제거하도록 하겠습니다..!!
A. 쿠키를 HttpHeader 가 갖도록 하고, 헤더를 읽는 로직을 HttpHeader 에 위임하면서 사용성이 생기게 되어 다시 살렸습니다!!
Q. 객체지향 생활체조, 한 줄에 점을 하나만 찍는다.
public QueryParams getQueryParams() {
if (startLine.getMethod().equals(GET)) {
A. 데이터를 꺼내서 로직을 수행하고 있었네요...😅
항상 신경쓰려고 하는 부분인데 한 번식 놓치게 되는 것 같습니다..ㅠ.ㅠ
startLine 에서 데이터를 꺼내서 로직을 수행하는 것이 아니라 startLine 에게 물어보도록 수정하였습니다!
Q. HttpRequest가 일일이 쪼개서 HttpHeader를 만들어줄 필요가 있을까요?
헤더 문자열만 HttpHeader에게 넘겨주면 객체가 알아서 key, value로 나누도록 만들면 HttpRequest 클래스의 복잡도가 줄어들거에요.
break;
}
final String[] header = line.split(HEADER_DELIMITER);
httpHeaderLines.put(header[KEY_INDEX], header[VALUE_INDEX].trim());
A. 피드백 반영하였습니다..!!
추가적으로 동일하게 요청 Body 를 읽는 부분도 HttpRequestBody 로 위임해주었습니다..!!
이렇게 수정을 하게 되면서 헤더쪽에서 Cookie 를 관리하게 되었으며, 기존에는 HttpRequest 에 몰려있던 많은 상수에 대한 관리도 적절한 클래스로 분리되었습니다!
Q. HttpRequestBody가 파는 일이 없는 것 같은데 QueryParams만 써도 되지 않나요?
HttpRequestBody는 원본 데이터만 가지고 있으면 되는 객체인가요?
public QueryParams getBodyWithQueryParam() {
return QueryParams.from(requestBody);
A. 요쳥 바디에 담긴 값이 꼭 QueryParam 형식이 아닐 수도 있다고 생각했습니다..(ex. json 형식의 데이터)
따라서 요청 바디 데이터를 String 형태로 가지고 있는 HttpRequestBody
를 생각하였고, getBodyWIthQueryParam()
메소드는 HttpRequestBody 가 가지고 있는 데이터를 쿼리 파라미터 형식으로 가져오겠다는 의도였습니다..!! (이렇게 보니 네이밍이 불분명한 것 같습니다..고민해보도록 하겠습니다.)
추가적으로 HttpRequestBody 에 Reader 를 통해서 바디 데이터를 읽는 책임을 추가해주었습니다!
Q. HttpRequestPath, HttpVersion는 하는 일이 없는데 왜 만들었나요?
클래스가 많아지면 관리하기 어렵지 않을까요?
package org.apache.coyote.request.startline;
public class HttpVersion {
A. 3단계 미션을 진행하면서 HTTP 요청 메시지와 동일한 구조로 만들려고 별도의 클래스를 만들어내었었습니다.
하지만 구구 말씀대로 특별한 책임을 가지고 있지도 않고, 심지어 HttpVersion 의 경우 사용되고 있지 않기 때문에 제거하는 것이 적절한 것 같습니다..!! 수정하도록 하겠습니다!!
Q. 로그로 컨트롤러의 로직을 확인하는게 적절한가요?
로그에 의존하는 테스트 코드가 올바른지 다시 고민해보시기 바랍니다.
final Logger logger = (Logger) LoggerFactory.getLogger(LoginController.class);
A. 현재 별도의 Service 단을 구분하지 않았기 때문에 Controller 단에서 요청이 들어오고 부터 응답이 나갈 때까지의 비즈니스 로직을 담당하고 있는데, 이를 로그로 확인한다는 것이 적절하지 못한 것 같습니다!
현재 요청이 정상적인 경우 예외 없이 처리가 완료되기 때문에 assertDoesNotThrow
를 통해서 확인하도록 수정하였습니다!
해당 답변의 추가적인 생각: 당시에는 요구사항이 로그가 제대로 찍히는지를 확인한다. 와같은 형식이었기 때문에 테스트 코드로 로그를 찍는게 최선이었다. 하지만 이제는 LoginController 요청에 대해서 비즈니스 로직이 생기고(분기문 등), 이를 검증하는 방법을 수행할 수 있으므로 레거시 테스트 코드는 제거하고 새로운 비즈니스 로직을 테스트하는 테스트 코드를 작성하는게 적절하였던 것 같다.
3단계의 경우에는 리팩터링이 핵심적인 요구사항이므로 비즈니스 로직에는 변화가 없다. 앞서 링크를 걸어둔 PR 로 가서 커밋 기록을 쭉 한 번 보는 것이 더 도움이 될 것이라고 생각하여 별도로 정리하지는 않겠다. 핵심은
기존 톰캣과 Controller의 분리
그리고 적절한 책임의 이동에 더불어Controller 인터페이스와 RequestMapping
에 따른 Http11Processor의 경량화 정도 인 것 같다.