[MY-WAS] 나만의 WAS 만들기 프로젝트 (3) - Session & Cookie & JSON

2025. 1. 11. 16:43·Project
목차
  1. UserController
  2. User
  3. UserRepository & MemoryUserRepository
  4. Session
  5. SessionConst
  6. SessionManager
  7. HttpRequest
  8. 자투리 변경 사항
  9. HttpResponse
  10. HttpRequestHandler
  11. Connector
  12. MyWasMain
  13. JSON 응답 추가
  14. 의존성 추가
  15. UserController
  16. User
  17. HttpRequest
  18. HttpResponse
  19. Refactor: HttpCookie
  20. UserController
  21. HttpCookie
  22. SessionManager
  23. HttpRequest
  24. HttpResponse
  25. 마무리
728x90
반응형

이번 시간에는 단순히 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: 로그인 기능
    1. 쿼리 파라미터로 전달된 아이디와 비밀번호에 간단한 유효성 검사를 진행한다
    2. UserRepository를 통해 메모리 데이터베이스에 유저 정보가 있는지 확인한다.
    3. 유저 정보가 있다면 비밀번호가 일치하는지 확인한다
    4. 비밀번호가 일치한다면 세션을 발급하고, 해당 세션에 유저 정보를 저장한다.
    5. 응답 메시지는 "302 FOUND"로 "/" 경로로 리다이렉트 할 수 있도록 하며, "JSESSIONID"라는 이름의 쿠키에 세션 아이디를 담는다.
  • registerAction: 회원가입 기능
    1. 쿼리 파라미터로 전달된 아이디와 비밀번호, 유저명에 간단한 유효성 검사를 진행한다
    2. 유저 객체를 생성한 후 메모리 데이터베이스에 저장한다
    3. 이후 "/login" 페이지로 리다이렉트 한다
  • userInfo: 유저 정보 조회 기능
    1. 요청 객체로부터 세션을 조회한다.
      • 세션이 없다면 401 에러를 반환한다
    2. 세션이 있다면 세션에 저장된 유저 정보를 가져와 이를 반환한다
  • logout: 로그아웃 기능
    1. 요청 객체로부터 세션을 조회한다.
    2. 세션이 있다면 이를 메모리에서 삭제한다
    3. 이후 "/" 페이지로 리다이렉트 하고, "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-{유저 정보} 형태로 저장한다
  • 세션 자체는 각 요청마다 고유하므로 동시 접근을 고려하지 않아도 된다

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 헤더 캐시와 조건부 요청을 구현해보자!

728x90
반응형

'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
  1. UserController
  2. User
  3. UserRepository & MemoryUserRepository
  4. Session
  5. SessionConst
  6. SessionManager
  7. HttpRequest
  8. 자투리 변경 사항
  9. HttpResponse
  10. HttpRequestHandler
  11. Connector
  12. MyWasMain
  13. JSON 응답 추가
  14. 의존성 추가
  15. UserController
  16. User
  17. HttpRequest
  18. HttpResponse
  19. Refactor: HttpCookie
  20. UserController
  21. HttpCookie
  22. SessionManager
  23. HttpRequest
  24. HttpResponse
  25. 마무리
'Project' 카테고리의 다른 글
  • [TODOMON] EP.1 투두 비즈니스 로직에 대한 고민
  • [MY-WAS] 나만의 WAS 만들기 프로젝트 (완) - HTTP 헤더 캐시
  • [MY-WAS] 나만의 WAS 만들기 프로젝트 (2) - 커맨드 패턴, 리플렉션, 애노테이션
  • [MY-WAS] 나만의 WAS 만들기 프로젝트 (1) - 기본 구조
mxruhxn
mxruhxn
소소하게 개발 공부 기록하기
    반응형
    250x250
  • mxruhxn
    maruhxn
    mxruhxn
  • 전체
    오늘
    어제
    • 분류 전체보기 (150)
      • Java (21)
      • Spring (4)
      • Database (13)
      • Operating Syste.. (1)
      • Computer Archit.. (0)
      • Network (24)
      • Data Structure (6)
      • Algorithm (11)
      • Data Infra (7)
      • DevOps (12)
      • ETC (27)
      • Project (21)
      • Book (1)
      • Look Back (1)
  • 블로그 메뉴

    • 링크

      • Github
    • 공지사항

    • 인기 글

    • 태그

    • 최근 댓글

    • 최근 글

    • hELLO· Designed By정상우.v4.10.0
    mxruhxn
    [MY-WAS] 나만의 WAS 만들기 프로젝트 (3) - Session & Cookie & JSON

    개인정보

    • 티스토리 홈
    • 포럼
    • 로그인
    상단으로

    티스토리툴바

    단축키

    내 블로그

    내 블로그 - 관리자 홈 전환
    Q
    Q
    새 글 쓰기
    W
    W

    블로그 게시글

    글 수정 (권한 있는 경우)
    E
    E
    댓글 영역으로 이동
    C
    C

    모든 영역

    이 페이지의 URL 복사
    S
    S
    맨 위로 이동
    T
    T
    티스토리 홈 이동
    H
    H
    단축키 안내
    Shift + /
    ⇧ + /

    * 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.