이번 시간에는 단순히 HTML만 내려주는 기능뿐이었던 프로젝트에 실제로 로그인 및 회원가입, 유저 정보 조회, 로그아웃 기능을 추가해보자. 이를 위해서는 WAS 서버에서 쿠키와 세션을 다룰 수 있어야 할 것이다.
더 나아가, HTML Form을 통한 통신뿐만 아니라 JSON을 통한 통신 방식도 지원하도록 개선해보자.
역순으로 개발을 진행하면서 필요한 부분을 순차적으로 개발해나가자!
UserController
로그인 및 회원가입 기능, 유저 정보 조회 기능, 로그아웃 기능을 추가하자
public class UserController {
...
@RequestMapping("/login-action")
public void loginAction(HttpRequest request, HttpResponse response) {
if (request.getMethod() != HttpMethod.POST) {
throw new NotImplementedException("잘못된 METHOD 입니다");
}
Map<String, String> parameters = request.getParameters();
String id = parameters.get("id");
String password = parameters.get("password");
if (id.isEmpty() || password.isEmpty()) {
throw new BadRequestException("잘못된 요청입니다");
}
User findUser = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("유저 정보를 찾을 수 없습니다"));
boolean isCorrectPassword = findUser.getPassword().equals(password);
if (!isCorrectPassword) {
throw new UnauthorizedException("인증 실패");
}
log.info("로그인 성공: {}", findUser.getName());
Session session = request.getSession();
session.setAttribute(LOGIN_USER, findUser);
response.setStatus(HttpStatus.FOUND);
response.setLocation("/");
response.addCookie(SESSION_COOKIE_NAME, session.getId());
}
...
@RequestMapping("/register-action")
public void registerAction(HttpRequest request, HttpResponse response) {
if (request.getMethod() != HttpMethod.POST) {
throw new NotImplementedException("잘못된 METHOD 입니다");
}
Map<String, String> parameters = request.getParameters();
String id = parameters.get("id");
String name = parameters.get("name");
String password = parameters.get("password");
if (id.isEmpty() || name.isEmpty() || password.isEmpty()) {
throw new BadRequestException("잘못된 요청입니다");
}
User user = new User(id, name, password);
userRepository.save(user);
log.info("회원가입 성공: {}", user.getName());
response.setStatus(HttpStatus.FOUND);
response.setLocation("/login");
}
@RequestMapping("/info")
public void userInfo(HttpRequest request, HttpResponse response) {
if (request.getMethod() != HttpMethod.GET) {
throw new NotImplementedException("잘못된 METHOD 입니다");
}
Session session = request.getSession(false);
if (session == null) {
throw new UnauthorizedException("로그인이 필요합니다.");
}
User loginUser = (User) session.getAttribute(LOGIN_USER);
response.setStatus(HttpStatus.OK);
response.setResponseBody(loginUser.toString());
}
@RequestMapping("/logout")
public void logout(HttpRequest request, HttpResponse response) {
if (request.getMethod() != HttpMethod.GET) {
throw new NotImplementedException("잘못된 METHOD 입니다");
}
Session session = request.getSession(false);
if (session != null) {
session.invalidate();
}
response.setStatus(HttpStatus.FOUND);
response.setLocation("/");
response.addCookie(SESSION_COOKIE_NAME, "; Path=/; Max-Age=0; HttpOnly");
}
}
간단한 프로젝트이므로 굳이 3-tier 아키텍처를 가져가지 않겠다. 또한, 비밀번호 암호화와 같은 기능 없이 아주 단순한 기능만을 제공한다
loginAction
: 로그인 기능- 쿼리 파라미터로 전달된 아이디와 비밀번호에 간단한 유효성 검사를 진행한다
- UserRepository를 통해 메모리 데이터베이스에 유저 정보가 있는지 확인한다.
- 유저 정보가 있다면 비밀번호가 일치하는지 확인한다
- 비밀번호가 일치한다면 세션을 발급하고, 해당 세션에 유저 정보를 저장한다.
- 응답 메시지는 "302 FOUND"로 "/" 경로로 리다이렉트 할 수 있도록 하며, "JSESSIONID"라는 이름의 쿠키에 세션 아이디를 담는다.
registerAction
: 회원가입 기능- 쿼리 파라미터로 전달된 아이디와 비밀번호, 유저명에 간단한 유효성 검사를 진행한다
- 유저 객체를 생성한 후 메모리 데이터베이스에 저장한다
- 이후 "/login" 페이지로 리다이렉트 한다
userInfo
: 유저 정보 조회 기능- 요청 객체로부터 세션을 조회한다.
- 세션이 없다면 401 에러를 반환한다
- 세션이 있다면 세션에 저장된 유저 정보를 가져와 이를 반환한다
- 요청 객체로부터 세션을 조회한다.
logout
: 로그아웃 기능- 요청 객체로부터 세션을 조회한다.
- 세션이 있다면 이를 메모리에서 삭제한다
- 이후 "/" 페이지로 리다이렉트 하고, "JSESSIONID"라는 이름의 쿠키는 만료시킨다
User
public class User {
private String id;
private String name;
private String password;
public User() {
}
public User(String id, String name, String password) {
this.id = id;
this.name = name;
this.password = password;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
@Override
public String toString() {
return "User{" +
"id='" + id + '\'' +
", name='" + name + '\'' +
'}';
}
}
UserRepository & MemoryUserRepository
public interface UserRepository {
void save(User user);
Optional<User> findById(String id);
List<User> findAll();
}
public class MemoryUserRepository implements UserRepository {
public final List<User> users = new CopyOnWriteArrayList<>();
@Override
public void save(User member) {
users.add(member);
}
@Override
public Optional<User> findById(String id) {
return users.stream()
.filter(user -> Objects.equals(user.getId(), id))
.findAny();
}
@Override
public List<User> findAll() {
return users;
}
}
- user가 저장되는 리스트는 메모리에서 여러 스레드에 의해 동시 접근이 가능하므로
ArrayList
가 아닌 동시성 컬렉션인CopyOnWriteArrayList
를 사용한다
Session
public class Session {
private final String id;
private final Map<String, Object> values = new HashMap<>();
public Session(final String id) {
this.id = id;
}
public String getId() {
return id;
}
public Object getAttribute(final String name) {
return values.getOrDefault(name, null);
}
public void setAttribute(final String name, final Object value) {
values.put(name, value);
}
public void removeAttribute(final String name) {
values.remove(name);
}
public void invalidate() {
values.clear();
}
}
- 세션은 고유한 아이디를 가지며, 내부의 values에 원하는 데이터를 저장할 수 있다.
- 우리는 values에 유저 정보를
loginUser-{유저 정보}
형태로 저장한다
- 우리는 values에 유저 정보를
- 세션 자체는 각 요청마다 고유하므로 동시 접근을 고려하지 않아도 된다
SessionConst
로그인한 유저 정보를 values에 저장할 때 사용할 키값이다. 고정된 값이므로 밖으로 빼두었다.
public interface SessionConst {
String LOGIN_USER = "loginUser";
}
SessionManager
세션들을 메모리에서 관리하기 위한 클래스이다.
public class SessionManager {
public static final String SESSION_COOKIE_NAME = "JSESSIONID";
private Map<String, Session> sessionStore = new ConcurrentHashMap<>();
/**
* 세션 조회
*/
public Session getSession(HttpRequest request, Boolean creation) {
String sessionId = request.getCookie(SESSION_COOKIE_NAME);
// 세션 ID가 없으면 새로운 세션을 생성
if (sessionId == null) {
return creation ? this.createNewSession() : null;
}
// 세션 저장소에서 세션을 가져옴
Session session = sessionStore.get(sessionId);
// 세션이 없으면 새로운 세션을 생성하거나 null 반환
return (session == null && creation) ? createNewSession() : session;
}
private Session createNewSession() {
Session newSession = new Session(UUID.randomUUID().toString());
sessionStore.put(newSession.getId(), newSession);
return newSession;
}
}
- "JSESSIONID"는 쿠키로 세션 아이디를 발급할 때 사용할 쿠키의 이름이다.
- 세션 저장소는 여러 스레드가 동시 접근 가능하므로 동시성 컬렉션인
ConcurrentHashMap
을 사용했다 createNewSession
: 세션을 새로 생성하여 세션 저장소에 저장하는 기능getSession
: 세션을 조회하는 기능- 요청 메시지에서 JSESSION이라는 이름의 쿠키의 값을 가져온다. 이는 세션 ID이다.
- 세션 ID가 없다면(세션 쿠키가 없다면) -> creation 파라미터가 true일 경우 새로운 세션을 발급하여 세션 저장소에 저장 후 반환하고, 아니라면 그대로 null을 반환한다.
- 세션 ID가 있다면(세션 쿠키가 있다면) -> 세션 저장소에서 세션을 조회해서 있다면 이를 반환하며, 만약 세션이 없다면 방금 생성한 세션을 반환한다.
HttpRequest
public class HttpRequest {
private static final Logger log = LoggerFactory.getLogger(HttpRequest.class);
private static final String COOKIE_HEADER_NAME = "Cookie"; // 추가
private HttpMethod method; // String -> HttpMethod 변경
private String requestURI;
private final Map<String, String> parameters = new HashMap<>();
private final Map<String, String> headers = new HashMap<>();
private final Map<String, String> cookies = new HashMap<>(); // 추가
private final SessionManager sessionManager; // 추가
public HttpRequest(BufferedReader reader, SessionManager sessionManager) throws IOException {
this.sessionManager = sessionManager; // 추가
parseRequestLine(reader); // 먼저 시작 라인 파싱
parseHeaders(reader); // 이후 헤더 파싱
parseBody(reader);
}
private void parseRequestLine(BufferedReader reader) throws IOException {
String requestLine = reader.readLine();
if (requestLine == null) {
throw new IOException("EOF: No request line received");
}
String[] parts = requestLine.split(" "); // -> { "GET", "/search?q=hello", "HTTP/1.1" }
if (parts.length != 3) {
throw new IOException("Invalid request line: " + requestLine);
}
method = HttpMethod.valueOf(parts[0]); // 변경
String[] pathParts = parts[1].split("\\?");
requestURI = pathParts[0];
if (pathParts.length > 1) {
parseQueryParameters(pathParts[1]);
}
}
private void parseQueryParameters(String queryString) {
for (String param : queryString.split("&")) {
String[] keyValue = param.split("=");
String key = URLDecoder.decode(keyValue[0], UTF_8);
String value = keyValue.length > 1 ?
URLDecoder.decode(keyValue[1], UTF_8) : "";
parameters.put(key, value);
}
}
private void parseHeaders(BufferedReader reader) throws IOException {
String line;
while (!(line = reader.readLine()).isEmpty()) {
String[] headerParts = line.split(":");
headers.put(headerParts[0].trim(), headerParts[1].trim());
// Cookie 헤더 파싱 부분 추가
if (headerParts[0].trim().equalsIgnoreCase(COOKIE_HEADER_NAME)) {
this.parseCookies(headerParts[1].trim());
}
}
}
// 추가
private void parseCookies(String cookieHeader) {
String[] cookiePairs = cookieHeader.split(";");
for (String cookie : cookiePairs) {
String[] keyValue = cookie.split("=", 2); // 쿠키는 'name=value' 형태로 전달됨
String key = keyValue[0].trim();
String value = keyValue.length > 1 ? keyValue[1].trim() : ""; // value가 없는 경우 빈 문자열로 처리
cookies.put(key, value);
}
}
private void parseBody(BufferedReader reader) throws IOException {
final String CONTENT_LENGTH_HEADER_KEY = "Content-Length";
final String CONTENT_TYPE_HEADER_KEY = "Content-Type";
if (!headers.containsKey(CONTENT_LENGTH_HEADER_KEY)) {
return;
}
int contentLength = Integer.parseInt(headers.get(CONTENT_LENGTH_HEADER_KEY));
char[] bodyChars = new char[contentLength];
int read = reader.read(bodyChars);
if (read != contentLength) {
throw new IOException("Failed to read entire body. Expected " +
contentLength + " bytes, but read " + read);
}
String body = new String(bodyChars);
String contentType = headers.get(CONTENT_TYPE_HEADER_KEY);
if ("application/x-www-form-urlencoded".equals(contentType)) {
parseQueryParameters(body);
}
}
public HttpMethod getMethod() {
return method;
}
public String getRequestURI() {
return requestURI;
}
public Map<String, String> getParameters() {
return parameters;
}
public Map<String, String> getHeaders() {
return headers;
}
// 추가
public Map<String, String> getCookies() {
return cookies;
}
// 추가
public String getCookie(String key) {
return cookies.get(key);
}
@Override
public String toString() {
return "HttpRequest{" +
"method='" + method + '\'' +
", path='" + requestURI + '\'' +
", queryParameters=" + parameters +
", headers=" + headers +
'}';
}
// 추가
public Session getSession() {
return sessionManager.getSession(this, true);
}
// 추가
public Session getSession(boolean creation) {
return sessionManager.getSession(this, creation);
}
}
- 쿠키 및 세션과 관련된 로직을 추가해주었다
- sessionManager 필드 추가
- cookie 파싱 로직 추가
- method 필드의 타입을 HttpMethod로 변경해주었다
자투리 변경 사항
HttpResponse
public class HttpResponse {
private static final Logger log = LoggerFactory.getLogger(HttpResponse.class);
private final StringBuilder messageBuilder = new StringBuilder();
private HttpStatus status;
private String location;
private String responseBody;
private String contentType = "text/html; charset=UTF-8";
private Map<String, String> headers = new HashMap<>();
private Map<String, String> cookies = new HashMap<>();
public HttpResponse() {
}
public String getResponse() throws IOException {
if (this.responseBody != null && this.responseBody.endsWith(".html")) {
this.responseBody = this.findResourceFromLocation(responseBody);
headers.put("Content-Type", "text/html; charset=UTF-8");
headers.put("Content-Length", String.valueOf(responseBody.getBytes().length));
}
generateStatusLine();
generateHeaderLine(); // 헤더, 쿠키 처리
messageBuilder.append(System.lineSeparator());
if (responseBody != null) {
messageBuilder.append(responseBody);
}
return messageBuilder.toString();
}
// 헤더, 쿠키
private void generateHeaderLine() {
for (Map.Entry<String, String> header : headers.entrySet()) {
messageBuilder.append(String.format("%s: %s\r\n", header.getKey(), header.getValue()));
}
// 쿠키 추가
for (Map.Entry<String, String> cookie : cookies.entrySet()) {
messageBuilder.append(String.format("Set-Cookie: %s=%s\r\n", cookie.getKey(), cookie.getValue()));
}
}
private void generateStatusLine() {
messageBuilder.append(String.format("HTTP/1.1 %s %s\r\n", status.getStatusCode(), status.getReason()));
}
private static String findResourceFromLocation(String location) throws IOException {
URL resource = ClassLoader.getSystemClassLoader().getResource("static" + location);
if (resource == null) {
throw new FileNotFoundException("Resource not found: " + location);
}
Path path = Paths.get(resource.getPath());
return Files.readString(path);
}
public void setStatus(HttpStatus status) {
this.status = status;
}
public void setLocation(String location) {
this.location = location;
headers.put("Location", location);
}
public void setResponseBody(String responseBody) {
this.responseBody = responseBody;
}
public void setContentType(String contentType) {
headers.put("Content-Type", contentType);
}
public void setContentLength(int length) {
headers.put("Content-Length", String.valueOf(length));
}
public void addCookie(String key, String value) {
cookies.put(key, value);
}
}
addCookie
메서드를 추가했다- 세션 쿠키를 응답 메시지에 넣어주기 위한 메서드이다
setLocation
를 추가했다- location 필드는 리다이렉트 대상 경로에 해당한다
- 헤더에 "Location" 이라는 키의 값은 리다이렉트 대상 경로이다. 이는 302 상태코드에서 활용된다.
HttpRequestHandler
public class HttpRequestHandler implements Runnable {
private static final Logger log = LoggerFactory.getLogger(HttpRequestHandler.class);
private final Socket socket;
private final ServletManager servletManager;
private final SessionManager sessionManager;
public HttpRequestHandler(Socket socket, ServletManager servletManager, SessionManager sessionManager) {
this.socket = socket;
this.servletManager = servletManager;
this.sessionManager = sessionManager;
}
@Override
public void run() {
try {
process();
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
private void process() {
try (
socket;
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
OutputStream outputStream = socket.getOutputStream()
) {
HttpRequest request = new HttpRequest(reader, sessionManager);
HttpResponse response = new HttpResponse();
log.info("HTTP 요청: {}", request);
servletManager.execute(request, response);
outputStream.write(response.getResponse().getBytes());
outputStream.flush();
} catch (IOException e) {
log.error(e.getMessage(), e);
}
}
}
- sessionMasnager를 필드로 갖도록 변경하여 HttpRequest 생성 시 전달한다.
Connector
public class Connector {
private static final Logger log = LoggerFactory.getLogger(Connector.class);
private final ExecutorService es = Executors.newFixedThreadPool(10);
private final int DEFAULT_PORT = 8080;
private final int DEFAULT_ACCEPT_COUNT = 100;
private final ServerSocket serverSocket;
private final ServletManager servletManager;
private final SessionManager sessionManager;
public Connector(final int port, final int acceptCount, ServletManager servletManager, SessionManager sessionManager) {
this.servletManager = servletManager;
this.serverSocket = createServerSocket(port, acceptCount);
this.sessionManager = sessionManager;
}
...
private void running() {
try {
while (true) {
Socket socket = serverSocket.accept();
es.submit(new HttpRequestHandler(socket, servletManager, sessionManager));
}
} catch (IOException e) {
log.error("서버 소켓 종료: {}", e.getMessage());
}
}
}
- sessionMasnager를 필드로 갖도록 변경하여 HttpRequestHandler 생성 시 전달한다.
MyWasMain
public class MyWasMain {
public static void main(String[] args) {
UserRepository userRepository = new MemoryUserRepository();
List<Object> controllers = List.of(new UserController(userRepository));
AnnotationServlet annotationServlet = new AnnotationServlet(controllers);
ServletManager servletManager = new ServletManager();
servletManager.setDefaultServlet(annotationServlet);
servletManager.add("/favicon.ico", new DiscardServlet());
SessionManager sessionManager = new SessionManager();
Connector connector = new Connector(8080, 100, servletManager, sessionManager);
connector.start();
}
}
- UserController 생성 시 UserRepository를 전달한다
- SessionManager를 생성하여 Connector 생성 시 전달한다
JSON 응답 추가
JSON을 사용하여 로그인 및 회원가입이 가능하도록 해보자!
의존성 추가
dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
}
UserController
public class UserController {
...
@RequestMapping("/login-json")
public void loginWithJson(HttpRequest request, HttpResponse response) {
if (request.getMethod() != HttpMethod.POST) {
throw new NotImplementedException("잘못된 METHOD 입니다");
}
Map<String, Object> body = request.getJsonBody();
String id = (String) body.get("id");
String password = (String) body.get("password");
if (id.isEmpty() || password.isEmpty()) {
throw new BadRequestException("잘못된 요청입니다");
}
User findUser = userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("유저 정보를 찾을 수 없습니다"));
boolean isCorrectPassword = findUser.getPassword().equals(password);
if (!isCorrectPassword) {
throw new UnauthorizedException("인증 실패");
}
log.info("로그인 성공: {}", findUser.getName());
Session session = request.getSession();
session.setAttribute(LOGIN_USER, findUser);
response.setStatus(HttpStatus.FOUND);
response.setLocation("/");
response.addCookie(SESSION_COOKIE_NAME, session.getId());
}
...
@RequestMapping("/register-json")
public void registerWithJson(HttpRequest request, HttpResponse response) {
if (request.getMethod() != HttpMethod.POST) {
throw new NotImplementedException("잘못된 METHOD 입니다");
}
Map<String, Object> body = request.getJsonBody();
String id = (String) body.get("id");
String name = (String) body.get("name");
String password = (String) body.get("password");
if (id.isEmpty() || name.isEmpty() || password.isEmpty()) {
throw new BadRequestException("잘못된 요청입니다");
}
userRepository.findById(id)
.ifPresent(user -> {
throw new BadRequestException("이미 존재하는 유저입니다");
});
User user = new User(id, name, password);
userRepository.save(user);
log.info("회원가입 성공: {}", user.getName());
response.setStatus(HttpStatus.FOUND);
response.setLocation("/login");
}
@RequestMapping("/info")
public void userInfo(HttpRequest request, HttpResponse response) {
if (request.getMethod() != HttpMethod.GET) {
throw new NotImplementedException("잘못된 METHOD 입니다");
}
Session session = request.getSession(false);
if (session == null) {
throw new UnauthorizedException("로그인이 필요합니다.");
}
User loginUser = (User) session.getAttribute(LOGIN_USER);
response.setStatus(HttpStatus.OK);
response.setResponseBody(loginUser);
}
}
- 기능은 폼 요청 방식과 동일하다. json을 사용한다는 점만 다르다
info
는 응답 본문이loginUser.toString()
이 아니라,loginUser
임을 주의하자
User
public class User {
...
@JsonIgnore
private String password;
...
}
@JsonIgnore
를 사용하여 password를 json 응답에서 제외하도록 변경한다
HttpRequest
public class HttpRequest {
...
private Map<String, Object> jsonBody;
...
private void parseBody(BufferedReader reader) throws IOException {
final String CONTENT_LENGTH_HEADER_KEY = "Content-Length";
final String CONTENT_TYPE_HEADER_KEY = "Content-Type";
if (!headers.containsKey(CONTENT_LENGTH_HEADER_KEY)) {
return;
}
int contentLength = Integer.parseInt(headers.get(CONTENT_LENGTH_HEADER_KEY));
char[] bodyChars = new char[contentLength];
int read = reader.read(bodyChars);
if (read != contentLength) {
throw new IOException("Failed to read entire body. Expected " +
contentLength + " bytes, but read " + read);
}
String body = new String(bodyChars);
String contentType = headers.get(CONTENT_TYPE_HEADER_KEY);
if ("application/x-www-form-urlencoded".equals(contentType)) {
parseQueryParameters(body);
} else if ("application/json".equals(contentType)) { // 추가
this.parseJsonBody(body);
}
}
// 추가
private void parseJsonBody(String body) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
jsonBody = objectMapper.readValue(body, Map.class);
}
...
public Map<String, Object> getJsonBody() {
return jsonBody;
}
...
}
- 요청 본문 파싱 시, Content-Type이 "application/json" 일 경우, jsonBody를 파싱하는 로직을 추가하였다
parseJsonBody
: ObjectMapper를 사용하여 데이터를 파싱한다
HttpResponse
public class HttpResponse {
...
private Object responseBody; // String -> Object
...
public String getResponse() throws IOException {
if (isHtmlResponseBody()) {
this.responseBody = findResourceFromLocation((String) responseBody);
updateHtmlHeaders();
} else {
this.responseBody = convertToJsonIfNecessary(responseBody);
updateJsonHeaders();
}
generateStatusLine();
generateHeaderLine(); // 헤더, 쿠키 처리
messageBuilder.append(System.lineSeparator());
if (responseBody != null) {
messageBuilder.append(responseBody);
}
return messageBuilder.toString();
}
private boolean isHtmlResponseBody() {
return this.responseBody instanceof String && ((String) this.responseBody).endsWith(".html");
}
private Object convertToJsonIfNecessary(Object responseBody) throws IOException {
if (!(responseBody instanceof String)) {
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.writeValueAsString(responseBody);
}
return responseBody;
}
private void updateHtmlHeaders() {
headers.put("Content-Type", "text/html; charset=UTF-8");
headers.put("Content-Length", String.valueOf(responseBody.toString().getBytes().length));
}
private void updateJsonHeaders() {
headers.put("Content-Type", "application/json; charset=UTF-8");
headers.put("Content-Length", String.valueOf(responseBody.toString().getBytes().length));
}
...
public void setResponseBody(Object responseBody) {
this.responseBody = responseBody;
}
...
}
- html 페이지를 반환해야 하는 응답과 json을 반환해야 하는 응답을 구분하였다.
responseBody
필드는 더 이상 String이 아니라 Object 타입임을 주의하자
Refactor: HttpCookie
쿠키와 세션 기능을 잘 구현은 했지만.. 쿠키를 String 타입으로 사용하고 있다는 점이 아쉽다(특히 logout 기능의 구현부...). HttpCookie라는 클래스를 만들어 쿠키를 더욱 효율적으로 다루도록 개선해보자
UserController
public class UserController {
...
@RequestMapping("/login-json")
public void loginWithJson(HttpRequest request, HttpResponse response) {
...
response.setStatus(HttpStatus.FOUND);
response.setLocation("/");
HttpCookie cookie = new HttpCookie(SESSION_COOKIE_NAME, session.getId()); // 변경
response.addCookie(cookie);
}
@RequestMapping("/login-action")
public void loginAction(HttpRequest request, HttpResponse response) {
...
response.setStatus(HttpStatus.FOUND);
response.setLocation("/");
HttpCookie cookie = new HttpCookie(SESSION_COOKIE_NAME, session.getId()); // 변경
response.addCookie(cookie);
}
...
@RequestMapping("/logout")
public void logout(HttpRequest request, HttpResponse response) {
if (request.getMethod() != HttpMethod.GET) {
throw new NotImplementedException("잘못된 METHOD 입니다");
}
Session session = request.getSession(false);
if (session == null) {
throw new UnauthorizedException("로그인이 필요합니다.");
}
session.invalidate();
response.setStatus(HttpStatus.FOUND);
response.setLocation("/");
HttpCookie cookie = new HttpCookie(SESSION_COOKIE_NAME, null);
cookie.setMaxAge(0);
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);
}
- 로그아웃 시 MaxAge를 0으로 주어 만료시키는 것을 볼 수 있다
HttpCookie
public class HttpCookie {
private static final String DOMAIN = "Domain";
private static final String MAX_AGE = "Max-Age";
private static final String PATH = "Path";
private static final String SECURE = "Secure";
private static final String HTTP_ONLY = "HttpOnly";
private final String name;
private String value;
private Map<String, String> attributes = new HashMap<>();
public HttpCookie(String name, String value) {
this.name = name;
this.value = value;
}
public void setMaxAge(int expiry) {
this.attributes.put(MAX_AGE, expiry < 0 ? null : String.valueOf(expiry));
}
public int getMaxAge() {
String maxAge = this.attributes.get(MAX_AGE);
return maxAge == null ? -1 : Integer.parseInt(maxAge);
}
public void setDomain(String domain) {
this.attributes.put(DOMAIN, domain != null ? domain.toLowerCase() : null);
}
public String getDomain() {
return this.attributes.get(DOMAIN);
}
public void setPath(String uri) {
this.attributes.put(PATH, uri);
}
public String getPath() {
return this.attributes.get(PATH);
}
public void setSecure(boolean flag) {
this.attributes.put(SECURE, String.valueOf(flag));
}
public boolean getSecure() {
return Boolean.parseBoolean(this.attributes.get(SECURE));
}
public void setHttpOnly(boolean httpOnly) {
this.attributes.put(HTTP_ONLY, String.valueOf(httpOnly));
}
public boolean isHttpOnly() {
return Boolean.parseBoolean(this.attributes.get(HTTP_ONLY));
}
public String getName() {
return name;
}
public String getValue() {
StringBuilder cookieBuilder = new StringBuilder();
// 기본 name=value 형식 추가
if (value != null) {
cookieBuilder.append(value);
}
// Domain
if (attributes.get(DOMAIN) != null) {
cookieBuilder.append("; ").append(DOMAIN).append("=").append(attributes.get(DOMAIN));
}
// Max-Age
if (attributes.get(MAX_AGE) != null) {
cookieBuilder.append("; ").append(MAX_AGE).append("=").append(attributes.get(MAX_AGE));
}
// Path
if (attributes.get(PATH) != null) {
cookieBuilder.append("; ").append(PATH).append("=").append(attributes.get(PATH));
}
// Secure (true인 경우 "Secure"만 추가)
if (getSecure()) {
cookieBuilder.append("; ").append(SECURE);
}
// HttpOnly (true인 경우 "HttpOnly"만 추가)
if (isHttpOnly()) {
cookieBuilder.append("; ").append(HTTP_ONLY);
}
return cookieBuilder.toString();
}
public String getRawValue() {
return value;
}
}
- 각 쿠키는 생명 주기(Expires, Max-Age), 도메인(Domain), 경로(Path), 보안(Secure, httpOnly, Same-Site)을 설정할 수 있다
- 여기서는 Max-Age, Domain, Path, Secure, httpOnly를 설정할 수 있도록 하였다
SessionManager
public class SessionManager {
...
public Session getSession(HttpRequest request, Boolean creation) {
HttpCookie sessionCookie = request.getCookie(SESSION_COOKIE_NAME);
// 세션 ID가 없으면 새로운 세션을 생성
if (sessionCookie == null) {
return creation ? createNewSession() : null;
}
String sessionId = sessionCookie.getRawValue();
// 세션 저장소에서 세션을 가져옴
Session session = sessionStore.get(sessionId);
// 세션이 없으면 새로운 세션을 생성하거나 null 반환
return (session == null && creation) ? createNewSession() : session;
}
...
}
HttpRequest
public class HttpRequest {
...
private final Map<String, HttpCookie> cookies = new HashMap<>();
...
private void parseCookies(String cookieHeader) {
String[] cookiePairs = cookieHeader.split(";");
for (String cookieString : cookiePairs) {
String[] keyValue = cookieString.split("=", 2); // 쿠키는 'name=value' 형태로 전달됨
String key = keyValue[0].trim();
String value = keyValue.length > 1 ? keyValue[1].trim() : ""; // value가 없는 경우 빈 문자열로 처리
// 쿠키 파싱 필요
HttpCookie cookie = new HttpCookie(key, value);
cookies.put(key, cookie);
}
}
...
public Map<String, HttpCookie> getCookies() {
return cookies;
}
public HttpCookie getCookie(String name) {
return cookies.get(name);
}
...
}
- 기존의 쿠키와 관련된 부분은 모두 HttpCookie를 사용하도록 변경하였다
HttpResponse
public class HttpResponse {
...
private Map<String, HttpCookie> cookies = new HashMap<>();
public HttpResponse() {
}
public String getResponse() throws IOException {
if (responseBody != null) {
if (isHtmlResponseBody()) {
this.responseBody = findResourceFromLocation((String) responseBody);
updateHtmlHeaders();
} else {
this.responseBody = convertToJsonIfNecessary(responseBody);
updateJsonHeaders();
}
}
generateStatusLine();
generateHeaderLine(); // 헤더, 쿠키 처리
appendMessageBody();
return messageBuilder.toString();
}
private void appendMessageBody() {
messageBuilder.append(System.lineSeparator());
if (responseBody != null) {
messageBuilder.append(responseBody);
}
}
...
// 헤더, 쿠키
private void generateHeaderLine() {
for (Map.Entry<String, String> header : headers.entrySet()) {
messageBuilder.append(String.format("%s: %s\r\n", header.getKey(), header.getValue()));
}
// 쿠키 추가
for (Map.Entry<String, HttpCookie> cookieEntry : cookies.entrySet()) {
HttpCookie cookie = cookieEntry.getValue();
messageBuilder.append(String.format("Set-Cookie: %s=%s\r\n", cookie.getName(), cookie.getValue()));
}
}
...
public void addCookie(HttpCookie cookie) {
cookies.put(cookie.getName(), cookie);
}
}
- 기존의 쿠키와 관련된 부분은 모두 HttpCookie를 사용하도록 변경하였다
getResponse
메서드를 조금 리팩토링하였다
마무리
이번 시간에는 Session & Cookie를 통해 유저를 특정할 수 있게 되어 다양한 유저 관련 기능을 제공할 수 있게 되었다. 또한, Json 요청 메시지를 파싱하거나 응답 메시지로 Json을 보내줄 수 있게 되었다.
다음 시간은 마지막으로 ETag & if-none-match를 통해 HTTP 헤더 캐시와 조건부 요청을 구현해보자!
'Project' 카테고리의 다른 글
[TODOMON] EP.2 투두 달성 보상 기능 (1) | 2025.01.28 |
---|---|
[TODOMON] EP.1 투두 비즈니스 로직에 대한 고민 (0) | 2025.01.27 |
[MY-WAS] 나만의 WAS 만들기 프로젝트 (완) - HTTP 헤더 캐시 (0) | 2025.01.12 |
[MY-WAS] 나만의 WAS 만들기 프로젝트 (2) - 커맨드 패턴, 리플렉션, 애노테이션 (0) | 2025.01.11 |
[MY-WAS] 나만의 WAS 만들기 프로젝트 (1) - 기본 구조 (0) | 2025.01.11 |