본문 바로가기

spring

Springboot Cookie에 대해

갑작스럽게 스프링부트 쿠키로 돌아온 게 이상하지만 블로그 부재중 동안 알고리즘 공부도 하고 이력서도 돌려보고 면접 준비도 하며 지내며 다음 개인 프로젝트를 진행하려 했다. 어느 정도 구성은 했고 문서 작성하고 실행하며 블로그 작성하면 되는데 이전에는 사용하지 않았던 oauth2를 사용하려 했다. 시큐리티와 jwt는 이전에 사용해 보았어서 어느 정도 코드를 구성할 수 있지만 ouath2는 사용해 본 적 없기에 간단하게 토이프로젝트를 진행하고 해 보자. 해서 구현하는데 레퍼런스로 레거시가 많았고 직접 찾으며 생각한 기능들을 토대로 구현하기가 막막했다. 그러던 와중 토이 프로젝트를 하고 있다가 mvc부터 로그인 과정 등등 모든 게 백지처럼 시작하기 앞서 아무것도 생각이 나지 않고 어떻게 했더라 라는 생각이 불쑥 들었다. 이대로는 안될 것 같아서 다시 기초를 쌓자 이전과 달리 기초적인 이해는 하고 있으니 더 진취적으로 대할 때가 되었다고 생각이 들었다 이전에 진행한 프로젝트도 기능적으로만 이렇게 하고 저렇게 구성하면 되니까 진행했지만 이번 포스팅 이후로는 더 많은 것을 궁금해하고 더 많은 것을 깊이 알아보려 한다. 디버그도 진행하고 하며 어떤 식으로 구성이 되는지 이 코드로 어떻게 더 나아질 수 있을지 테스트 코드도 겸해서 작성하며 진행하려 한다.

 

서론이 길었지만 여태 공부한 것에 대해 감을 잃은 것 같고 깊은 이해를 목표로 공부하고 있지만 이론적으로 어떤 일을 하고 어떤 동작을 하는지는 알지만 실제 내부적으로 사용되는 것과 구현에 있어 다른 방법이 있을지 어떤 식으로 더 사용되면 좋을지 알아보면서 차근차근 해보려 한다.

 

우선 쿠키에 대해서 다시 알아보고 코드로 확인해보도록 하겠다.

 

쿠키란

쿠키(Cookie)는 웹 브라우저가 사용자 정보를 기억하기 위해 사용하는 작은 데이터 파일이다. 쿠키는 클라이언트와 서버 간의 상태 정보를 저장하고 관리하기 위해 사용된다. 쿠키는 HTTP 헤더를 통해 서버에서 클라이언트로 전송되며, 클라이언트는 해당 쿠키를 저장하고 이후 요청 시 서버로 다시 전송한다.

서버에 request를 보내고 서버는 response에 쿠키를 설정해서 함께 보낼 수 있게 된다.

 

쿠키는 영속 쿠키과, 세션 쿠키가 있다. 만료 날짜에 관련된 것으로 만료 날짜가 있다면 영속 없다면 세션 쿠키로 브라우저 종료 시까지 유지된다.

쿠키는 주로 세션관리, 사용자 설정 및 선호도 저장, 트래킹 및 분석에 사용된다.

쿠키는 키와 값(key-value)으로 이루어져 있다.

쿠키의 기본 구조와 속성을 알아보자.

 

기본 구조 : Set-Cookie: name=value; expires=[Date]; domain=[Domain]; path=[Path]; [secure]

  • name: 쿠키의 이름
  • value: 쿠키의 값
  • domain: 쿠키가 유효한 도메인
  • path: 쿠키가 유효한 경로
  • expires 또는 max-age: 쿠키의 만료 시간
  • secure: HTTPS 연결에서만 전송되는지 여부
  • HttpOnly: JavaScript에서 접근할 수 없는지 여부

위 속성을 통해서 보안 요소를 추가하고 쿠키를 설정할 수 있다.

쿠키를 해보며 궁금한 것들이 있지만 이부분은 예제를 해보면서 차차 알아보기로 하자.

 

기본적으로 로그인을 사용하기 위해서 로그인을 진행할 수 있도록 환경을 구성하고 나서 진행하도록 하겠다.

 

사용 환경 설정

JAVA - java21

IDE - intellij

springboot - 3.3.1

의존성 

	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

 

db는 학습용이기에 경량화 되어 있고 사용하기 간편한 h2를 사용했으며 로그인을 구현하기 위한 thymeleaf로 html을 구성하고 validation으로 검증을 진행할 것이고 mvc 패턴을 사용해서 매핑해 뷰를 보여줄 것이고 뷰에서는 쿠키를 사용할 것이고 기본 화면에서 회원 가입과 로그인을 진행할 수 있게끔 구성하고 회원 가입한 정보를 토대로 로그인을 진행하고 쿠키를 부여해 메인화면으로 리다이렉트 했을 때 쿠키의 정보를 토대로 로그인된 화면을 띄워보도록 하겠다.

코드를 구성하기 이전에 설정 파일을 구성하고 진행하도록 하자.

필자는 yml을 주로 사용하였기에 application.yml을 사용하도록 하겠다.

spring:
  thymeleaf:
  prefix: classpath:/templates/
  suffix: .html
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
    defer-datasource-initialization: true
  datasource:
    url: jdbc:h2:mem:testcase
    username: sa
  h2:
    console:
      enabled: true
logging:
  level:
    root: INFO
    org.springframework: INFO
    org.hibernate.SQL: info 
    org.hibernate.type.descriptor.sql: info

thymeleaf와 jpa, h2, logging을 위한 구성이다. 각각 설정에 대해서 알아보는 시간은 아니니 thymeleaf 경로 설정과 application 실행마다 스키마 생성하게 하고 로그를 보이고 어떤 식으로 콘솔에 노출할지와 h2를 인메모리로 사용하겠다는 설정에 대한 정보를 기입한 환경설정이다.

다음 코드를 진행해보자.

 

가장 먼저 사용할 mvc 패턴에서 entity, repository, service를 만들어보자.

 

package com.example.oauth2.domain;

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String loginId;
    private String password;
    private String name;

    private MemberRole role;

    public Member(String loginId, String password, String name, MemberRole role) {
        this.loginId = loginId;
        this.password = password;
        this.name = name;
        this.role = role;
    }
}

 

package com.example.oauth2.domain;

public enum MemberRole {
    USER, ADMIN
}

 

사용할 Member와 Member의 권한을 설정할 enum으로 USER, ADMIN을 생성했다.

 

package com.example.oauth2.repository;

import com.example.oauth2.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
    boolean existsByLoginId(String loginId);
    Member findByLoginId(String loginId);
}

 

repository로 jpa를 상속받아 중복을 검증할 existsByLoginId와 member의 LoginId를 찾을 메서드를 만들어주었다.

 

서비스를 구성하기 전 실제 객체가 아닌 form에 맞는 데이터를 전달하고 받을 수 있는 dto 객체를 만들어주자.

package com.example.oauth2.dto;

import com.example.oauth2.domain.Member;
import com.example.oauth2.domain.MemberRole;
import jakarta.validation.constraints.NotBlank;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class JoinRequest {

    @NotBlank(message = "ID를 입력하세요.")
    private String loginId;

    @NotBlank(message = "비밀번호를 입력하세요.")
    private String password;
    private String passwordCheck;

    @NotBlank(message = "이름을 입력하세요.")
    private String name;

    public Member toEntity() {
        return Member.builder()
                .loginId(this.loginId)
                .password(this.password)
                .name(this.name)
                .role(MemberRole.USER)
                .build();
    }

    public Member toAdmin() {
        return Member.builder()
                .loginId(this.loginId)
                .password(this.password)
                .name(this.name)
                .role(MemberRole.ADMIN)
                .build();
    }

}
package com.example.oauth2.dto;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

@Getter
@Setter
@NoArgsConstructor
public class LoginRequest {

    private String loginId;
    private String password;
}

 

dto를 구성했다면 이번엔 서비스를 구성하도록 하자.

package com.example.oauth2.service;

import com.example.oauth2.domain.Member;
import com.example.oauth2.dto.JoinRequest;
import com.example.oauth2.dto.LoginRequest;
import com.example.oauth2.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    public boolean checkLoginIdDuplicate(String loginId) {
        return memberRepository.existsByLoginId(loginId);

    }
    public void save(Member member) {
        memberRepository.save(member);
    }

    public void join(JoinRequest joinRequest) {
        memberRepository.save(joinRequest.toEntity());
    }

    public Member login(LoginRequest loginRequest) {
        Member findMember = memberRepository.findByLoginId(loginRequest.getLoginId());

        if (findMember == null) {
            return null;
        }
        if (!findMember.getPassword().equals(loginRequest.getPassword())) {
            return null;
        }
        return findMember;
    }

    public Member getLoginMemberById(Long memberId) {
        if (memberId == null) return null;

        Optional<Member> findMember = memberRepository.findById(memberId);
        return findMember.orElse(null);
    }
}

 

서비스 구성이 되었다면 다음으로는 컨트롤러지만 컨트롤러를 구성하기 이전에 html을 먼저 구성해 보자.

<html lang="ko">
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="|${pageName}|"></title>
</head>
<body>
<div>
    <h1><a th:href="|/${loginType}|">[[${pageName}]]</a></h1> <hr/>
    <div th:if="${name == null}">
        <h3>로그인 되어있지 않습니다!</h3>
        <button th:onclick="|location.href='@{/{loginType}/join (loginType=${loginType})}'|">회원 가입</button> <br/><br/>
        <button th:onclick="|location.href='@{/{loginType}/login (loginType=${loginType})}'|">로그인</button>
    </div>
    <div th:unless="${name == null}">
        <h3>[[${name}]]님 환영합니다!</h3>
        <button th:onclick="|location.href='@{/{loginType}/info (loginType=${loginType})}'|">마이 페이지</button> <br/><br/>
        <button th:onclick="|location.href='@{/{loginType}/admin (loginType=${loginType})}'|">관리자 페이지</button> <br/><br/>
        <button th:onclick="|location.href='@{/{loginType}/logout (loginType=${loginType})}'|">로그아웃</button>
    </div>
</div>
</body>
</html>

 

home.html로 메인 화면을 구성할 html이다. 로그인 전에는 위 div가 나올 것이고 로그인 시 아래에 div가 나올 것이다.

 

<!DOCTYPE html>
<html lang="ko">
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="|${pageName}|"></title>
</head>
<body>
<div>
    <h1><a th:href="|/${loginType}|">[[${pageName}]]</a></h1> <hr/>
    <h2>회원 가입</h2>
    <form th:method="post" th:action="|@{/{loginType}/join (loginType=${loginType})}|" th:object="${joinRequest}">
        <div>
            <label th:for="loginId">ID : </label>
            <input type="text" th:field="*{loginId}" th:errorclass="error-input"/>
            <div class="error-class" th:errors="*{loginId}"></div>
        </div>
        <br/>
        <div>
            <label th:for="password">비밀번호 : </label>
            <input type="password" th:field="*{password}" th:errorclass="error-input"/>
            <div class="error-class" th:errors="*{password}"></div>
        </div>
        <br/>
        <div>
            <label th:for="passwordCheck">비밀번호 체크 : </label>
            <input type="password" th:field="*{passwordCheck}" th:errorclass="error-input"/>
            <div class="error-class" th:errors="*{passwordCheck}"></div>
        </div>
        <br/>
        <div>
            <label th:for="name">이름 : </label>
            <input type="text" th:field="*{name}" th:errorclass="error-input"/>
            <div class="error-class" th:errors="*{name}"></div>
        </div>
        <br/>
        <button type="submit">회원 가입</button>
    </form>
</div>
</body>
</html>

<style>
    .error-class {
        color: red;
    }
    .error-input {
        border-color: red;
    }
</style>

 

다음은 회원가입 html인 join.html로 각 필드는 컨트롤러에서 bindingresult로 바인딩한 에러가 Member에서 설정한 검증에 따라 검증하고 오류를 필드에 보여주게 된다.

 

<!DOCTYPE html>
<html lang="ko">
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="|${pageName}|"></title>
</head>
<body>
<div>
    <h1><a th:href="|/${loginType}|">[[${pageName}]]</a></h1> <hr/>
    <h2>로그인</h2>
    <form th:method="post" th:action="|@{/{loginType}/login (loginType=${loginType})}|" th:object="${loginRequest}">
        <div>
            <label th:for="loginId">ID : </label>
            <input type="text" th:field="*{loginId}"/>
        </div>
        <br/>
        <div>
            <label th:for="password">비밀번호 : </label>
            <input type="password" th:field="*{password}"/>
        </div>
        <div th:if="${#fields.hasGlobalErrors()}">
            <br/>
            <div class="error-class" th:each="error : ${#fields.globalErrors()}" th:text="${error}" />
        </div>
        <br/>
        <button type="submit">로그인</button>
        <button type="button" th:onclick="|location.href='@{/{loginType}/join (loginType=${loginType})}'|">회원 가입</button> <br/><br/>
    </form>
</div>
</body>
</html>

<style>
    .error-class {
        color: red;
    }
    .error-input {
        border-color: red;
    }
</style>

 

다음은 로그인 화면인 login.html이다. 

 

<!DOCTYPE html>
<html lang="ko">
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="|${pageName}|"></title>
</head>
<body>
<div>
    <h1><a th:href="|/${loginType}|">[[${pageName}]]</a></h1> <hr/>
    <h2>마이 페이지</h2>
    <div>
        <div th:text="|ID : ${member.loginId}|"/>
        <div th:text="|이름 : ${member.name}|"/>
        <div th:text="|role : ${member.role}|"/>
    </div>
</div>
</body>
</html>

 

info.html로 로그인하고 로그인한 member의 정보를 확인할 수 있는 페이지이다.

 

<!DOCTYPE html>
<html lang="ko">
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="|${pageName}|"></title>
</head>
<body>
<div>
    <h1><a th:href="|/${loginType}|">[[${pageName}]]</a></h1> <hr/>
    <h2>관리자 페이지</h2>
    <h3>인가 성공</h3>
</div>
</body>
</html>

 

여기는 admin.html로 이전에 부여한 권한에 따라 admin이라면 해당 페이지에 접근할 수 있다.

 

이제 컨트롤러 메서드를 하나씩 확인해 보자.

@GetMapping("")
public String home(@CookieValue(name = "memberId", required = false)
                   Long memberId, Model model) {
    log.info("memberId from cookie={}", memberId);

    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");

    Member loginMember = memberService.getLoginMemberById(memberId);

    if (loginMember != null) {
        model.addAttribute("name", loginMember.getName());
    }

    return "cookie/home";
}

기본 페이지 이동시 @CookieValue로 memberId를 바인딩해서 사용한다. 쿠키의 키 값으로 memberId를 가져와서 그 안에 값을 memberId에 바인딩해서 사용하는 것이다. 옵션 값으로 reqired 필수를 false를 두었고 MVC 패턴에 필요한 model을 파라미터로 사용한다. 디버그해서 확인을 해보았지만 실제 쿠키 값이 바인딩이 잘 되는지 확인하기 위해서 로그를 찍었다. 

@GetMapping("cookie-login/join")
public String joinPage(Model model) {
    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");

    model.addAttribute("joinRequest", new JoinRequest());
    return "cookie/join";
}

@PostMapping("cookie-login/join")
public String join(@Valid @ModelAttribute JoinRequest joinRequest,
                   BindingResult bindingResult, Model model) {
    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");

    if (memberService.checkLoginIdDuplicate(joinRequest.getLoginId())) {
        bindingResult.addError(new FieldError(
                "joinRequest",
                "loginId",
                "ID가 존재합니다."
        ));
    }
    if (!joinRequest.getPassword().equals(joinRequest.getPasswordCheck())) {
        bindingResult.addError(new FieldError(
                "joinRequest",
                "passwordCheck",
                "비밀번호가 일치하지 않습니다."
        ));
    }

    if (bindingResult.hasErrors()) {
        return "cookie/join";
    }

    memberService.join(joinRequest);
    return "redirect:/";
}

RestController에 cookie-login이 있는 이유는 구성한 html에서 a태그에 설정한 href가 컨트롤러에서 model에 담아 보내는 값에 따라 변하기 때문에 모델에 담은 값을 엔드포인트에 추가한 것이다.

@Vaild는 검증을 적용할 파라미터 바로 앞에 있어야 한다. 폼에서 전달받은 값을 검증하는 것이다. 아래에 bindingResult로 에러를 추가하고 에러가 있다면 뷰에서 설정한 에러 값을 보여주게 한다.

@GetMapping("cookie-login/login")
public String loginPage(Model model) {
    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");

    model.addAttribute("loginRequest", new LoginRequest());
    return "cookie/login";
}

@PostMapping("cookie-login/login")
public String login(@ModelAttribute LoginRequest loginRequest,
                    BindingResult bindingResult,
                    HttpServletResponse response, Model model) {
    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");

    Member member = memberService.login(loginRequest);
    Member admin = new Member("1","1","1",MemberRole.ADMIN);
    memberService.save(admin);

    if (member == null) {
        bindingResult.reject("loginFail", "로그인 아이디 또는 비밀번호가 틀렸습니다.");
    }

    if (bindingResult.hasErrors()) {
        return "cookie/login";
    }

    Cookie cookie = new Cookie("memberId", String.valueOf(member.getId()));
    cookie.setMaxAge(60 * 60);
    cookie.setPath("/");
    response.addCookie(cookie);

    log.info("member.name={}",member.getName());
    log.info("Cookie set with memberId={}", member.getId());
    log.info("Cookie ={}", cookie);


    return "redirect:/";
}

 

다음은 로그인하는 코드이다. 코드들의 대부분은 위와 비슷하니 재설명은 하지 않겠다.

다른 부분은 쿠키 설정인데 쿠키 설정을 둘러보자.

쿠키를 생성하며 key와 value를 설정했다. 키 값으로는 memberId로 value는 로그인하는 member의 id를 value로 설정했으며 쿠키의 유효기간을 60 * 60으로 1시간으로 설정했다. setPath는 쿠키를 쿠키가 유효한 경로를 준 것이고 domain으로 설정하면 domain에 대해서 유효해진다. 쿠키를 사용해서 서비스를 실제 해야 한다면 secure나 HttpOnly로 보안 설정을 해주어야 한다. 각 https에만 유효하게 한다던지 자바스크립트에 대한 접근을 거부하는 등 보안 요소를 적절히 구성하는 것이 좋지만 쿠키 자체가 보안성이 떨어지므로 쿠키에 중요한 정보는 되도록 가지지 않도록 하는 것이 좋다.

설정된 쿠키를 사용할 수 있게 되었다. 다른 컨트롤러에서 파라미터 값에 쿠키의 키 값을 가지고 쿠키의 밸류 값을 가지고 사용할 수 있게 된다.

중간에 admin이라는 멤버를 하나 생성해 주었는데 관리자 페이지인 admin.html을 확인하기 위해 별도의 서비스를 두는 것은 큰 의미가 없기 때문에 개별적으로 확인하려 생성한 것이다.

@GetMapping("cookie-login/logout")
public String logout(HttpServletResponse response, Model model) {
    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");

    Cookie cookie = new Cookie("memberId", null);
    cookie.setMaxAge(0);
    cookie.setPath("/");
    response.addCookie(cookie);
    return "redirect:/";
}

이 컨트롤러는 로그아웃시 쿠키를 없애주게 된다. 쿠키를 같은 키로 생성해서 밸류를 null을 넣어주면 된다.

같은 쿠키가 덮여 씌워지는 것으로 만료시간을 0으로 두어야 하고 생성한 쿠키와 삭제할 쿠키는 같은 속성 값을 가져야 한다.

이때 쿠키를 삭제하는 방법이 궁금했다. 이 방식밖에 없는 것인지 궁금해서 찾아보았는데 쿠키를 삭제하는 방법은 이 방법밖에 없었다.

같은 쿠키를 재생성해서 삭제하도록 했다.

@GetMapping("cookie-login/info")
public String info(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");

    Member loginMember = memberService.getLoginMemberById(memberId);

    if (loginMember == null) {
        return "redirect:/cookie-login/login";
    }

    model.addAttribute("member", loginMember);
    return "cookie/info";
}

@GetMapping("cookie-login/admin")
public String adminPage(@CookieValue(name = "memberId", required = false) Long MemberId, Model model) {
    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");

    Member loginMember = memberService.getLoginMemberById(MemberId);

    if (loginMember == null) {
        return "redirect:/cookie-login/login";
    }

    if (!loginMember.getRole().equals(MemberRole.ADMIN)) {
        return "redirect:/";
    }
    return "cookie/admin";
}

로그인한 사용자의 정보를 확인할 수 있는 info와 관리자 권한이 있는 사용자가 들어갈 수 있는 admin으로 이동하는 컨트롤러이다.

구성은 끝났으니 실행을 해서 쿠키를 확인해 보고 구성한 것들 확인하도록 하고 디버깅해서 쿠키 생성되는 것을 확인해 보자.

 

서버를 켜고 실행해 보면 JESSIONID와 Idea-281304fd 이름이 붙은 쿠키가 이미 생성된 것을 확인할 수 있다.

이 쿠키에 대해 알아보고 넘어가도록 하자.

우선 JESSIONID이다. 

JSESSIONID 쿠키는 자바에서 웹 애플리케이션을 실행할 때, 서블릿 컨테이너(예: Tomcat, Jetty 등)가 자동으로 생성하는 세션 식별자 쿠키이다. 이 쿠키는 서버가 클라이언트를 식별하고 세션 상태를 유지하기 위해 사용된다. 설정은 설정 파일에서 설정을 위해

secure: HTTPS에서만 전송, http-only: 자바스크립트에서 접근 불가, same-site: 엄격한 SameSite 정책 적용

이렇게 설정할 수 있다.

다음은 Idea-281304fd이다.

Idea-281304fd와 같은 이름의 쿠키는 IntelliJ IDEA나 JetBrains 제품군에서 생성하는 임시 쿠키이다. 이러한 쿠키는 사용자 경험을 개선하거나 일시적인 데이터 저장에 사용될 수 있다. 보통 이런 쿠키들은 개발 도구나 IDE를 사용하는 동안 생성되며, 특정 동작이나 설정을 유지하거나 임시 데이터를 저장하는 데 사용될 수 있다. 이는 내부적인 메커니즘으로 작동하기 때문에 일반적이 사용자가 뜯어보기 어렵다.

 

자동으로 생성되는 쿠키에 대해 알아보았고 다음으로 계속 진행해 보도록 하겠다.

 

 

 

각 순서대로 회원가입 - 로그인 - 메인화면 - 로그아웃 순으로 알아보았다. 각 쿠키가 생성되고 쿠키를 통해 로그인하고 로그아웃해 쿠키를 삭제한 것도 확인할 수 있다.

Hibernate: 
    select
        m1_0.member_id,
        m1_0.login_id,
        m1_0.name,
        m1_0.password,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.login_id=?
Hibernate: 
    insert 
    into
        member
        (login_id, name, password, role, member_id) 
    values
        (?, ?, ?, ?, default)
2024-07-11T05:16:39.618+09:00  INFO 26309 --- [oauth2] [nio-8080-exec-1] c.e.oauth2.controller.MemberController   : member.name=123
2024-07-11T05:16:39.618+09:00  INFO 26309 --- [oauth2] [nio-8080-exec-1] c.e.oauth2.controller.MemberController   : Cookie set with memberId=1
2024-07-11T05:16:39.618+09:00  INFO 26309 --- [oauth2] [nio-8080-exec-1] c.e.oauth2.controller.MemberController   : Cookie =jakarta.servlet.http.Cookie@a189c5fa
2024-07-11T05:16:39.638+09:00  INFO 26309 --- [oauth2] [io-8080-exec-10] c.e.oauth2.controller.MemberController   : memberId from cookie=1
Hibernate: 
    select
        m1_0.member_id,
        m1_0.login_id,
        m1_0.name,
        m1_0.password,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.member_id=?
2024-07-11T05:16:51.566+09:00  INFO 26309 --- [oauth2] [nio-8080-exec-2] c.e.oauth2.controller.MemberController   : memberId from cookie=null

 

콘솔에 로그를 통해 쿠키가 생성되는 것을 확인할 수 있고 

 

컨트롤러 중간에 ADMIN을 생성한 부분이 지금 사용됐다. admin도 쿠키가 생성된 것을 확인할 수 있다. 이제 쿠키가 생성되는 것을 디버그로 확인해 보도록 하자.

쿠키 클래스에 Break를 걸어서 확인했다. 처음으로 생성되는 쿠키로 아까 위에서 설명한 idea를 확인할 수 있다.

Step out으로 다음 쿠키로 생성되는 JESSIONID를 확인할 수 있다.

서버를 켜서 첫 페이지를 들어가게 되면 

@GetMapping("")
public String home(@CookieValue(name = "memberId", required = false)
                   Long memberId, Model model) {
    log.info("memberId from cookie={}", memberId);

    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");

    Member loginMember = memberService.getLoginMemberById(memberId);

    if (loginMember != null) {
        model.addAttribute("name", loginMember.getName());
    }

    return "cookie/home";
}

@CookieValue를 확인하기 때문에 처음 페이지 접속 시 null을 반환하게 된다.

이 부분은 웹 요청에 대한 디버깅으로 세션, 쿠키, 요청 등에 대한 정보가 들어있고 맨 아래에 scookie를 보면 필자는 서버를 껐다 켰는데 이전에 ADMIN 권한을 가진 member의 쿠키로 memberId2 값이 있는 이유는 서버가 꺼져도 쿠키는 만료시간에 따라 유지되고 필자가 쿠키를 삭제하지 않았기 때문에 남아있는 것이다. 이 쿠키는 서버가 꺼져도 만료시간에 따라 쿠키는 삭제된다.

디버깅하는 김에 디스패처 서블릿에서 agumentResolver가 작동하는 것도 확인해보자. 이미지에서 빈과 빈 타입 이름, 파라미터 등 스텝 오버에 따라서 handler를 찾아 breakpoint를 찍으면 핸들러, 핸들러 어탭터, 디스패처서블릿, 아규먼트 리졸버, 뷰리졸버, 모델에 데이터 담는 것을 차례로 확인할 수도 있다. 이는 개인적으로 진행하기로하고 다음은 쿠키가 생길 때를 확인해보자.

 

 

이는 로그인을 할 때 쿠키가 생기는 부분이다.

 

response에 쿠키 값을 생성해서 반환하는 부분을 확인할 수 있다.

여기까지 쿠키를 알아보고 쿠키를 사용해 보았다 쿠키를 통한 자동 로그인은 컨트롤러에 쿠키가 있는지 검사하는 구문속에 home 메서드에 플래그를 주고 플래그에 따라 로그인된 정보를 반환하면 된다. 차후 세션과 시큐리티 등에 html이 사용되기 때문에 고쳐 사용하진 않았다.

지금까지 쿠키를 알아보았지만 현재 쿠키의 값이 외부로 노출되는 문제가 있다 이는 보안상 문제가 되기 때문에 쿠키 값을 암호화해보는 예제를 끝으로 끝을 내보도록 하자.

보통 Spring Security의 Encryptors 클래스를 사용해 AES 암호화를 사용하지만 Spring Security를 사용하지 않으니 자바가 제공하는 javax.crypto의 AES를 통해서 암호화 해보도록 하겠다.

코드로 확인해보자.

package com.example.oauth2.crypt;

import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;

public class AESUtil {
    private static final String ALGORITHM = "AES";
    private static final byte[] keyValue = "1234567890123456".getBytes();

    public static String encrypt(String valueToEnc) throws Exception {
        SecretKeySpec key = new SecretKeySpec(keyValue, ALGORITHM);
        Cipher c = Cipher.getInstance(ALGORITHM);
        c.init(Cipher.ENCRYPT_MODE, key);
        byte[] encValue = c.doFinal(valueToEnc.getBytes());
        return Base64.getEncoder().encodeToString(encValue);
    }

    public static String decrypt(String encryptedValue) throws Exception {
        SecretKeySpec key = new SecretKeySpec(keyValue, ALGORITHM);
        Cipher c = Cipher.getInstance(ALGORITHM);
        c.init(Cipher.DECRYPT_MODE, key);
        byte[] decValue = Base64.getDecoder().decode(encryptedValue);
        byte[] decVal = c.doFinal(decValue);
        return new String(decVal);
    }
}

 

자바가 제공하는 패키지를 사용해 AES를 통해 암호화 했다. 예제이기에 사용하는 키는 간단히 생성했고 쿠키를 만들 때 사용할 encrypt와 쿠키를 사용하는 곳에서 사용할 decrypt를 만들었다. 이를 controller에 추가해서 사용하도록 하자.

@PostMapping("cookie-login/login")
public String login(@ModelAttribute LoginRequest loginRequest,
                    BindingResult bindingResult,
                    HttpServletResponse response, Model model) {
    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");

    Member member = memberService.login(loginRequest);
    Member admin = new Member("1","1","1",MemberRole.ADMIN);
    memberService.save(admin);

    if (member == null) {
        bindingResult.reject("loginFail", "로그인 아이디 또는 비밀번호가 틀렸습니다.");
    }

    if (bindingResult.hasErrors()) {
        return "cookie/login";
    }

    try {
        // 쿠키 값 암호화
        String encryptedMemberId = AESUtil.encrypt(String.valueOf(member.getId()));

        Cookie cookie = new Cookie("memberId", encryptedMemberId);
        cookie.setMaxAge(60 * 60);
        cookie.setPath("/");
        response.addCookie(cookie);

        log.info("member.name={}", member.getName());
        log.info("Cookie set with memberId={}", encryptedMemberId);
        log.info("Cookie ={}", cookie);
    } catch (Exception e) {
        log.error("Error encrypting member ID", e);
    }


    return "redirect:/";
}

 

로그인시 쿠키 값을 생성하는 곳에 암호화 구문을 넣었다. 여기는 바뀐 부분이 크지 않으며 다음 컨트롤러를 확인해보자.

@GetMapping("")
public String home(@CookieValue(name = "memberId", required = false)
                   String encryptedMemberId, Model model) {
    log.info("memberId from cookie={}", encryptedMemberId);

    model.addAttribute("loginType", "cookie-login");
    model.addAttribute("pageName", "쿠키 로그인");


    Long memberId = null;
    if (encryptedMemberId != null) {
        try {
            memberId = Long.parseLong(AESUtil.decrypt(encryptedMemberId));
        } catch (Exception e) {
            log.error("Failed to decrypt cookie value", e);
        }
    }
    Member loginMember = memberService.getLoginMemberById(memberId);
    if (loginMember != null) {
        model.addAttribute("name", loginMember.getName());
    }

    return "cookie/home";
}

 

바뀐 부분은 암호화에 String을 사용하기 때문에 쿠키 값을 받는 파라미터를 String으로 바꾸었다.

이후 복호화하는 부분에서 memberId를 null로 주었는데 기본적으로 생성한 쿠키 값이 없다면 Null이 들어가기 때문에 null로 초기 값을 설정했다. 이후 쿠키 값이 있다면 이를 복호화해서 원래 값을 사용할 수 있도록 구성했다. 다음으로 실제 암, 복호화가 되는지 확인하자.

 

위 이미지를 확인하면 value 값이 암호화 된 값을 확인할 수 있다. 로그인 시에 암호화해서 쿠키를 만들고 홈으로 이동하면서 로그인된 부분을 확인할 수 있는데 여기서 복호화가 잘 돼서 값이 잘 나온 것을 확인할 수 있다.

 

여기까지 쿠키에 대해서 알아보았다.

쿠키는 어렵지 않다. 원래 기존에 알고 있던 지식이기도 하고 이전에 프로젝트할 때 쿠키를 사용해서 장바구니도 구현해본적도 있기 때문에 이해하기도 어렵지 않았고 코드 구성에서도 막힘은 없었으며 기존 다른 분의 포스팅에 도움을 받아 작성된 것인데 부족한 부분은 더 채운것이다. 기존 포스팅시에 포스팅 시간은 짧았다. 보통 새로운 지식을 하는 것이 아니라면 2-3 시간이면 포스팅 하나 끝내지만 쿠키가 엄청 오래 걸린 포스팅이다. 사실 테스트 코드도 구성하며 했어야 하는데 진행하다보니 까먹어서 단위 테스트를 하지 못했다. 서비스 코드를 구성할 때와 컨트롤러를 구성하고나서 했어야 하지만 서비스에 대한 부분은 건너 뛰고 다음 포스팅은 세션을 사용해볼 것인데 세션 부분 부터 꼭 컨트롤러를 구성하고 테스트 코드를 같이 넣도록 하겠다. 포스팅 진행하며 기존 공부하던 방법과 달리했다. 기존에는 원하는 기능과 설명, 이해에 있어 하고자 하는 기능에 관련해서만 알아보았지만 이번 포스팅에서는 다시 쿠키에 대해 알아보면서 궁금한게 생기면 중간중간 바로 궁금증을 해결했다. 앞으로는 이런식으로 포스팅을 하게 될 것 같다. 더 오래 걸리겠지만 더욱 깊이 알게 되니 좋은 것 같다. 디버깅이 많이 미숙해서 어렵지만 앞으로는 더 많이 사용해보며 익숙해져보도록 하겠다. 포스팅이 점점 길어지니 이만 줄이고 다음 세션으로 돌아오도록 하겠다.