-> 블로그 이전

[Spring] Cookie

2022. 7. 7. 21:18Language`/Spring

HTTP는 Stateless한 프로토콜이므로 이전에 보낸 Request에 대해서 기억하지 못한다

이러한 Stateless한 특징을 해결하기 위해서 "쿠키"라는 개념을 활용한다

 

예를 들어서 로그인을 한다고 하자

어떤 사용자가 "A-1"라는 페이지에서 로그인을 하고나서 "A-2" 페이지로 이동하였다고 하자

stateless한 HTTP 특징에 의하면 A-2 페이지에서도 로그인을 유지하려면 query parameter를 통해서 로그인 정보를 유지해야 한다

 

여기서 이러한 상태 유지를 위해서 "쿠키"를 사용하는 것이다


Cookie

쿠키에는 2가지 종류가 존재한다

세션 쿠키 : 브라우저가 종료되면 사라지는 쿠키
영속 쿠키 : 브라우저가 종료되어도 "지정한 만료일"까지 유지되는 쿠키

 

기본적인 쿠키 로그인 테스트를 위해서 다음 화면을 준비하였다

가장 처음 메인페이지
회원가입 페이지
로그인 페이지
로그인 후 페이지

 

일단 로그인을 하려면 /login으로 request를 보내야 하고, 이 request를 처리하는 Controller는 다음과 같다

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
    private final LoginService loginService;

    @GetMapping("/login")
    public String login(@ModelAttribute("loginDTO") LoginDTO loginDTO){
        return "login/loginForm";
    }
}

단순하게 /login으로 GET 요청이 들어오면 "login/loginForm" :: HTML로 매핑이 되는 구조이다

  • LoginDTO를 전달함으로써 만약 로그인에 실패하게 되면 어떤 이유때문에 로그인에 실패한지 validation을 통해서 확인하고 여기서 도출된 오류를 bindingResult에 담아서 다시 loginForm으로 보내주는 것이다
  • FieldError나 rejectValue에 대한 정보들을 BindingResult에 담아서 로그인 시 그 정보를 유지하기 위해서 LoginDTO를 model에 담아서 보내주는 것이다

검증 & 로그인을 위한 LoginDTO는 다음과 같다

@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class LoginDTO {
    @NotEmpty
    private String loginId;

    @NotEmpty
    private String password;
}

 

이제 사용자가 로그인을 시도하게 되면 /login으로 POST 요청이 오고 이를 해결해주는 매핑 메소드도 만들어줘야 한다

@Service
@RequiredArgsConstructor
public class LoginService {
    private final MemberRepository repository;

    public Member login(String loginId, String password){
        return repository.findByLoginId(loginId)
                .filter(member -> member.getPassword().equals(password))
                .orElse(null);
    }
}

 

 

이제 "/login"에서 user가 Form Data 양식에 맞게 입력한 후 POST 메소드를 활용해서 서버로 데이터 보낸다고 하자.

그럼 당연히 서버측에서도 "/login"의 POST request에 대해서 처리할 수 있는 컨트롤러가 존재해야 한다

@PostMapping("/login")
public String loginCheck(
        @Validated @ModelAttribute(value = "loginDTO") LoginDTO loginDTO,
        BindingResult bindingResult
){
    if(bindingResult.hasErrors()){
        System.out.println(loginDTO);
        return "login/loginForm";
    }

    Member checkLogin = loginService.login(loginDTO.getLoginId(), loginDTO.getPassword());
    log.info("login? = {}", checkLogin);
    if(checkLogin == null){
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 올바르지 않습니다. 다시 확인해주세요");
        return "login/loginForm";
    }

    return "redirect:/";
}

1. Form Data(loginId & password)가 @ModelAttribute에 의해서 LoginDTO에 들어가게 되고 @Validated에 의해서 검증이 된다

여기서 LoginDTO에는 @Setter가 존재해야 한다.
왜냐하면 @ModelAttribute로 바인딩이 되는 시점에 1) 만약 @NoArgsConstructor, @AllArgsConstructor 둘다 존재한다면 @NoArgsConstructor을 호출하고 "setter"를 이용해서 필드에 각각 초기화하기 때문이다

 

2. @Validated 과정에서 검증에 오류가 발생하면 해당 오류는 bindingResult에 담기게 된다

3. 바인딩된 값들에 대해서 LoginService를 활용해서 유효한 유저인지 체크 후 유효하지 않다면 다시 loginForm으로 이동해서 재로그인을 시도하게 하고, 유효한 유저라면 "redirect"를 통해서 home으로 이동시킨다

  • redirect를 시키지 않는다면 새로고침을 하게되면 이전 POST 요청이 계속되어서 만약 회원가입이라면 중복 회원가입이 계속 진행되는 문제가 발생한다. 따라서 PRG : Post - Redirect - Get을 활용해서 POST 요청에 대해서 유효하다면 redirect를 시켜서 redirect location으로 get 요청을 보내게 한다

>> 위의 코드에는 "상태를 유지시켜주는 수단"이 존재하지 않는다. 이제 "쿠키"를 도입해보자


@PostMapping("/login")
public String loginCheck(
        @Validated @ModelAttribute(value = "loginDTO") LoginDTO loginDTO,
        BindingResult bindingResult,
        HttpServletResponse response
){
    if(bindingResult.hasErrors()){
        System.out.println(loginDTO);
        return "login/loginForm";
    }

    Member checkMember = loginService.login(loginDTO.getLoginId(), loginDTO.getPassword());
    log.info("login? = {}", checkMember);
    if(checkMember == null){
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 올바르지 않습니다. 다시 확인해주세요");
        return "login/loginForm";
    }

    // 로그인 성공
    Cookie myCookie = new Cookie("myCustomCookie", String.valueOf(checkMember.getId()));
    response.addCookie(myCookie);

    return "redirect:/";
}

 

위의 코드에서 Cookie부분을 살펴보자

 

Cookie는 HTTP Protocol상에서 "상태를 유지"시켜줄 수 있는 수단이다

1. 어떠한 인증에 대해서 통과한 user에게 "Server가 Cookie를 생성해서 Response Message에 담아서 전달"
2. user는 받은 Cookie를 통해서 이제부터 매 request때마다 Cookie를 포함해서 request를 보낸다

Server가 Client에게 Cookie를 생성해서 Response Message에 담아서 주게되면 Client는 다음 Request부터 매번 Cookie를 Request Message에 포함해서 Request를 보낸다

 

따라서 Client는 상태를 유지하기 위해서 쿼리 파라미터에 자신의 정보를 적을 필요없이 간단하게 유지할 수 있다

기본적으로 Cookie의 파라미터에는 2가지 종류의 인자가 들어간다

  1. String name : 말그대로 "쿠키의 이름"
  2. String value : 쿠키 내부에 들어있는 "쿠키 값"

 

서버측에서는 쿠키에 {"myCustomCookie", <Client의 DB저장 PK>}를 넣어서 response 해주었고 Client는 매 Request마다 이 정보를 반드시 포함시켜서 보내야 한다

 

"admin"이라는 client는 현재 서버에 {PK = 1}로 저장되어있다

따라서 서버측에서는 admin이 로그인을 시도하고 성공했다면 {"myCustomCookie", "1"}을 보낼 것이다

  • 반드시 name, value는 String으로 보내야 하기 때문에 getId()가 Integer라면 String.valueOf로 변환해서 보내야 한다

로그인을 성공하고나서 "브라우저 쿠키 저장소"에 서버측에서 보냈던 쿠키가 있음을 확인할 수 있다

 

여기서 다른 user에 대한 로그인 과정도 살펴보자

이 사람은 로그인을 성공하면 {"myCustomCookie", 2}를 서버로부터 받을거라고 예상된다. 한번 결과를 확인해보자

예상했던대로 서버로부터 쿠키를 받은 것을 확인하였다

 

그러면 이제 "로그인 성공 Client & 로그인 안한 Client"의 Page View를 약간 다르게 하고싶어졌다

따라서 로그인을 성공한 Client에게는 다른 Page로 redirect시키도록 컨트롤러를 수정하자

 

@GetMapping(value = {"/", ""})
public String home(
        @CookieValue(name = "myCustomCookie", required = false) Long memberId,
        Model model
){
    if(memberId == null){
        // 서버측에서 보낸 "myCustomCookie"에 대한 value를 가지고있지 않은 상태
        return "home";
    }

    Member findMember = repository.findById(memberId);
    if(findMember == null){
        return "home";
    }

    model.addAttribute("member", findMember);
    return "loginHome";
}

@CookieValue

사실 당연하게도 Cookie는 Client의 Request로부터 getCookies()를 사용하면 꺼낼 수 있다

하지만 HttpServletRequest의 getCookies()를 통해서 쿠키를 확인하려면 번거로운 작업의 연속이다

>> Spring에서는 이러한 번거로운 작업들을 없애주기 위해서 @CookieValue라는 애노테이션을 제공해준다

@CookieValue는 내부 속성에 name을 지정해주면 name에 해당하는 value를 파라미터에 매핑시켜주는 애노테이션이다

  • 위의 애노테이션을 살펴보면 "myCustomCookie"라는 Cookie의 Value가 Long memberId에 매핑됨을 예상할 수 있다
  • "myCustomCookie"의 value는 이전에 서버측에서 넣어주었던 "member.getId()"이다

그리고 로그인을 하지 않은 사용자라도 홈에는 접근할 수 있어야 하기 때문에 "requied = false"로 설정해주었다

 

1. @CookieValue를 통해서 "myCustomCookie"의 value를 바인딩하기

2. 여기서 바인딩 된 값이 없다면 쿠키를 아예 가지고 있지 않은 상태이므로 로그인 안한 메인 홈으로 return해준다

3. 쿠키를 가지고 있더라도 value에 해당하는 회원 정보가 DB에 존재하지 않는다면 메인 홈으로 return해준다

4. (2), (3)의 과정을 모두 통과했다면 {회원 & 쿠키 보유} 조건을 만족했기 때문에 회원 전용 홈으로 return해준다

 

 

회원 전용 홈을 보니까 "로그아웃"이라는 새로운 기능이 등장한 것을 볼 수 있다

그러면 이제 회원에 대해서 로그아웃 기능을 전담할 컨트롤러를 설계해보자

 

@PostMapping("/logout")
public String logout(HttpServletResponse response){
    Cookie logoutCookie = new Cookie("myCustomCookie", null);
    logoutCookie.setMaxAge(0);
    response.addCookie(logoutCookie);

    return "redirect:/";
}

이전에 LoginController를 보면 "myCustomCookie"라는 쿠키의 value가 null이라면 쿠키에 대한 정보를 가지고 있지 않은 User라고 판단해서 메인 홈으로 return시켜주었다

 

여기서도 그 전략을 활용해서 일단 로그아웃 전용 쿠키인 logoutCookie를 만들고 내부 파라미터는 {"myCustomCookie", null}을 생성해서 response해준다

 

그리고 가장 중요한 것은 쿠키의 "MaxAge"를 0으로 만들어주는 것이다

일단 서버측에서 보내는 것은 "세션 쿠키"이므로 웹 브라우저 종료시에 없어지는데 서버에서 애초에 쿠키의 maxage를 0으로 지정함에 따라서 해당 쿠키는 즉시 종료된다

 


Cookie의 문제점

상태를 "쿠키"라는 개념을 통해서 유지시켜준다는 것은 굉장히 좋은 전략으로 보인다

하지만 이러한 쿠키에도 굉장히 심각한 문제가 있다

 

1. 쿠키 값은 임의로 변경할 수 있다

쿠키 값의 변경은 다음 예시로 살펴보자

현재 DB에는 2명의 멤버가 존재하고 먼저 admin으로 로그인을 해보자

이처럼 쿠키가 세팅되었다

>> 여기서 쿠키의 값을 "2"로 바꾸면 어떤일이 발생할까

쿠키의 값만 바꿨는데도 아예 다른 user인척 접근할 수 있음을 확인할 수 있다

 

이러한 문제점은 굉장히 심각하고 따라서 쿠키를 보완할 다른 전략을 생각해봐야 한다

  • 쿠키를 보완할 다른 전략이 바로 "세션"이다

 

2. 쿠키에 보관된 정보는 그대로 훔쳐갈 수 있다

쿠키에 개인정보나 신용카드 정보와 같은 굉장히 중요한 정보가 들어있다면 이 정보들은 무방비로 노출된 상태이고 어느 누구나 이 정보를 훔쳐갈 수 있다

 

3. 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다

쿠키를 한번 훔쳐가면 해당 쿠키를 이용해서 악의적인 Request를 보낼 수 있다