인프런/스프링 MVC 2편

11) 로그인처리 - 쿠키, 세션 직접 개발,적용해보기(개념단계)

backend dev 2023. 2. 6.

프로젝트 패키지 구조

 

domain

  • item
  • memeber
  • login

web

  • item
  • memeber
  • login

 

도메인

도메인이 가장 중요하다.

도메인 = 화면, UI, 기술 인프라 등등의 영역을 제외한 시스템이 구현해야 하는 핵심 비즈니스 업무 영역을 말함

 

 

WEB 과 도메인이 나눠져있는 이유

향후 web을 다른 기술로 바꾸어도 도메인은 그대로 유지할 수 있어야 한다.

이렇게 하려면 web은 domain을 알고있지만 domain은 web을 모르도록 설계해야 한다.

이것을 web은 domain을 의존하지만, domain은 web을 의존하지 않는다고 표현한다.

 

예를 들어

web 패키지를 모두 삭제해도 domain에는 전혀 영향이 없도록 의존관계를 설계하는 것이 중요하다.

반대로 이야기하면 domain은 web을 참조하면 안된다.

(우리가 이전시간까지 만든 ItemController는 결국 타임리프를 사용하는 뷰 템플릿과  연관되어있는, 즉 웹기술과 연관되어 있는 컨트롤러이다. 웹 기술이 바뀌면 수정되어야할 파일이라는것이다.)

웹은 도메인을 의존하도록 만들어야하고 , 도메인은 웹을 의존하도록 만들면 안된다.

web -> domain 이런식으로 단방향으로 바꿔야 나중에 web기술을 바꾸기 위해 web부분을 도려내도 domain부분을 살릴 수 있지만.

web <-> domain 처럼 도메인이 web을 의존하게 만들면 나중에 web기술을 바꿀때 domain을 살리려면 많은 노력이 들어간다.

 

그래서 ItemController에서 ItemSaveForm , ItemUpdateForm 이라는 DTO를 사용했지만 

domain의 itemRepository의 메소드를 호출하기위해 Item이라는 엔티티로 바꿔서 호출해줬다.

그 이유는 itemRepository의 메소드가 web의 DTO를 받을 수 있게 설계하면 

도메인이 web을 의존하게 되는것이기 때문에 itemRepository의 메소드는 자신과 같은 패키지에 있는 item 엔티티를 받을 수 있게 설계해둔것이다. (API서버를 만들때는 web이 없고 , domain끼리만 패키지가 다르게 구성되니까 레포지토리의 메소드의 파라미터는 같은 도메인의 dto라면 받게끔 설계해도 되는것같다.)

 

+ (프론트엔드개발과 백엔드 개발을 나눠서 하는느낌?  web에서하는건 약간 프론트쪽일을 하는 느낌이고

domain은 서버에서하는 일 느낌이다.)

 

 

회원가입 개발

 

member

@Data
public class Member {

    private Long id;

    @NotBlank
    private String loginId; // 로그인용 아이디
    @NotBlank
    private String name; // 사용자이름
    @NotBlank
    private String password;

}

 

MemberRepository

@Slf4j
@Repository
public class MemberRepository { // 리포지토리 interface를 만들고 구현체화하는것이 나중에 더편하다. 지금은 예시니까 넘어감

    private static Map<Long, Member> store = new HashMap<>(); // static 사용, 원래는 hashmap이 아닌 ConcurrentHashMap같은걸 사용해아함
    private static long sequence = 0;

    public Member save(Member member) {
        member.setId(++sequence);
        log.info("save : member = {}", member);
        store.put(member.getId(), member);
        return member;
    }

    public Member findById(Long id) {
        return store.get(id);
    }

    public Optional<Member> findByLoginId(String loginId) { // 로그인아이디로 멤버를 찾는 메소드, 못찾을 수도 있으니까 Optional로 반환
//        //반환하려는 값이 null일 수 있을때 Optional을 사용한다.
//        List<Member> members = findAll();
//        for (Member member : members) {
//            if (member.getLoginId().equals(loginId)) {
//                return Optional.of(member); //Opitonal이라는 객체에 멤버를 담아서 반환한다.
//                // Returns an Optional describing the given value, if non-null, otherwise returns an empty Optional.
//            }
//        }
//        return Optional.empty(); // Opitonal이라는 객체를 비워서 반환한다.

        //위의 코드를 stream과 lambda를 사용하면 더 깔끔하게 줄일 수 있다.
        //리스트를 stream으로 바꾸고 filter를 이용해서 조건을 걸어서 하나씩 내용물을 검사한다.
        // 그리고 필터의 조건에 맞는게 있다면 그다음인 findFirst()가 실행 (만족하지않으면 다음단계로 넘어가지않는다)
        //즉 가장먼저 조건에 맞는 member객체가 Optional에 담겨 반환된다.
        //stream이 다 비었다면 findFirst는 비어있는 Optional 객체를 반환한다.
        return findAll().stream()
            .filter(m -> m.getLoginId().equals(loginId))
            .findFirst();

    }
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }

    public void clearStore() {
        store.clear();
    }

}

MemberController

@Controller
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/members")
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/add")
    public String addForm(@ModelAttribute("member") Member member) {
        return "members/addMemberForm";
    }

    @PostMapping("/add")
    public String save(@Valid @ModelAttribute Member member, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "members/addMemberForm";
        }

        memberRepository.save(member); //web은 도메인에 의존할 수 있다.
        return "redirect:/"; //홈으로 리다이렉트 , home == 서버 주소 == 127.0.0.1 == localhost
    }
}
@GetMapping("/add")
public String addForm(@ModelAttribute("member") Member member) {
    return "members/addMemberForm";
}

회원가입 버튼을 눌러서 http url 호출이 왔을때 회원가입 뷰 템플릿을 렌더링해서 보여주는 이 메소드를 보면

@ModelAttribute("member") Member member가 왜 있을까?

파라미터로 아무것도 넣어주지않고 호출이 될것이라서 결국 member 객체 내부 필드에는 아무값도 들어가있지않게되고

@ModelAttribute때문에 model에 member객체가 담겨서 뷰 템플릿으로 갈것이다.

그렇게 하는 이유 

1. th:object , th:field를 이용하여 깔끔하게 코드를 구성할수 있다.

2. 회원가입 뷰 템플릿으로 회원가입을 진행하려다가 필드에러 또는 오브젝트 에러가 생기면 다시 회원가입 뷰템플릿으로 보내진다. 그때는 사용자가 입력한 값을 모델에 담아 보내게 되는데 (자신이 입력한값을 봐야하니까)

그걸 위해서 멤버객체를 받고  th:object , th:field를 이용해서 form을 구성하게 되는것이고

맨처음 회원가입 뷰로 갈때는 아무런 값이 들어있지않아야하니까 비어있는 member객체를 모델에 담아 보내지게끔 하기위해 @ModelAttribute("member") Member member를 사용한것이다.

https://keeeeeepgoing.tistory.com/201

 

 

회원가입 테스트

validation 잘 동작하고, 회원가입 버튼 누르면 홈화면으로 잘 가진다.

저장된 member 객체에 대해 로그를 찍게 하였는데, 잘 보인다.

 

 

지금 현재 MemberRepository의 저장소는 Map 이므로 서버가 꺼지면 데이터가 다 사라진다.

그렇게 하면 테스트가 좀 어려워서 서버가 켜질때마다 자동으로 테스트용 데이터 몇개를 넣어주는 코드를 생성해보자.

 

동작방식

@PostConstruct를 이용해서 해당 스프링빈이 초기화가 완료되었을때

@PostConstruct가 매핑된 메소드가 실행된다.

(즉 서버가 켜지면서 스프링빈들이 생성되고 초기화될때 실행된다는뜻.)

(초기화란 스프링빈이 생성되고, 의존관계가 주입되고 난후를 의미한다.)

(@Component를 붙여서 컴포넌트스캔의 대상이 되게끔만들어, 스프링빈으로 등록한다)

@Component
@RequiredArgsConstructor
public class TestDataInit {

    private final ItemRepository itemRepository;
    private final MemberRepository memberRepository;

    /**
     * 테스트용 데이터 추가
     */
    @PostConstruct
    public void init() {
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));

        Member member = new Member();
        member.setLoginId("test");
        member.setPassword("test!");
        member.setName("테스터");

        memberRepository.save(member);
    }

}

Item도 테스트용으로 몇개 있어야하니까 미리 등록해준다.

 

 

 

로그인 개발

비즈니스 로직인 로그인을 개발해보자.

@Service
@RequiredArgsConstructor
public class LoginService {

    private final MemberRepository memberRepository;

    /**
     * @return  null 이면 로그인 실패
     */
    public Member login(String loginId, String password) {
//        Optional<Member> findMemberOptional = memberRepository.findByLoginId(loginId); //전달된 로그인아이디를 가진 멤버를 가지고와서
//        Member member = findMemberOptional.get(); // Optional 내부의값은 get을 꺼낸다.
//        if (member.getPassword().equals(password)) { // 그 멤버의 비밀번호와 파라미터로 넘어온 비밀번호가 같은지 체크
//            return member; // 같으면 멤버 반환
//        } else {
//            return null;
//        }

        //위의 코드를 자바8 문법을 이용해서 깔끔하게 바꾼다.
        return memberRepository.findByLoginId(loginId) //findByLoginId()를 이용해서 Optional<Member>를 가져온다.
            .filter(m -> m.getPassword().equals(password)) // .filter()를 통해 조건을 통과하는지 검사한다 조건을 통과하면 다음으로 넘어간다.
            .orElse(null); // 위의 filter조건을 통과해서 여기로 넘어왔으면 그값을 그냥 반환해주고, 이제 filter 할게 없는데 넘어온게 없다면 null 을 반환한다.
        // If a value is present, returns the value, otherwise returns other.

    }

}

 

그 다음  로그인 컨트롤러와 dto다음과 같다!

파일위치 : web - login 아래 

 

DTO

@Data
public class LoginForm {

    @NotBlank
    private String loginId;
    @NotBlank
    private String password;

}

컨트롤러

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm") LoginForm form) {
        return "login/loginForm";
    }

    @PostMapping("/login")
    public String logint(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

        if (loginMember == null) { // login의 반환이 null이라는것은 로그인 아이디에 맞는 멤버가 없거나, 비밀번호가 틀렸거나이다.
            //이럴때는 필드에러가 아닌 복합적인 검증실패이므로 오브젝트에러(글로벌오류)를 생성해서 bindingResult에 담는 reject() 메소드를 사용한다.
            bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
                        //에러코드는 에러코드메시지파일에 추가해주면 defaultMessage를 null로 주면 되고, 추가하지않고 바로 사용하려면 이렇게 기본메시지를 넣어주면된다.
            return "login/loginForm";
        }
    
        //추가적으로 성공로직을 개발해야한다. 뒤에서 추가개발하는 내용이 나온다.
        return "redirect:/";
    }
}

 

테스트결과

로그인에 실패해서 다시 로그인뷰로 이동하였고, 오브젝트에러(글로벌에러)에 대한 메시지도 잘 확인되었다.

원래 틀린값을 입력하면 다시 모델에 담겨서 뷰에 입력된 값들을 뿌려줬었는데 비밀번호는 보이지않는다

어떻게된걸까?

 

input type이 패스워드일때는 모델에 들어온 값을 이용해서 뷰템플릿이 렌더링될때  password값을 보여주지않는다.

(로그인이 실패했을때 다시 로그인화면뷰로 이동할텐데 그때 아이디만 보여주고 비밀번호는 빈값으로 보여준다.)

<input type="password" id="password" th:field="*{password}" class="form-control"
       th:errorclass="field-error">

 

 

추가 개발이 필요하다

 

현재 로그인을 성공하면 홈화면으로 이동하도록 되어있다.

 

로그인의 상태를 유지하면서, 로그인에 성공한 사용자는 홈 화면에 접근시 고객의 이름을 보여주려면 어떻게 해야할까?

 

 

 

로그인 처리하기 - 쿠키 사용

 

쿠키를 사용해서 로그인, 로그아웃 기능을 구현해보자.

 

 

로그인의 상태를 어떻게 유지할 수 있을까?

쿼리 파라미터를 계속 유지하면서 보내는 것은 매우 어렵고 번거로운 작업이다.

쿠키를 사용해보자.

 

서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하자.

그러면 브라우저는 앞으로 해당 쿠키를 지속해서 보내준다.

 

 

쿠키생성

로그인에 성공하면 서버에서 쿠키를 만들어서 응답으로 보내주면 웹브라우저가 그 쿠키를 저장한다.

 

클라이언트 쿠키전달1

클라이언트는 이제 서버의 어느페이지를 접근해도 항상 그 쿠키를 같이 전송해준다 ( 따로 설정해주지 않았다면 )

 

클라이언트 쿠키전달2

서버의 주소가 포함된 url이라면 항상 쿠키정보를 포함해준다.

 

 

쿠키에는 영속 쿠키와 세션 쿠키가 있다

영속 쿠키: 만료 날짜를 입력하면 해당 날짜까지 유지

세션 쿠키: 만료 날짜를 생략하면 브라우저 종료시 까지만 유지

 

브라우저 종료시 로그아웃이 되길 기대하므로, 우리에게 필요한 것은 세션 쿠키이다

(여기서 세션은 http,서버 관련된것이 아니라 그냥 쿠키의 종류중 하나를 얘기하는것이다)

 

 

 

로그인 컨트롤러 메소드 수정

로그인을 성공했을시 멤버아이디를 쿠키에 담아 보내는 로직을 추가한다.

@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

    if (loginMember == null) { // login의 반환이 null이라는것은 로그인 아이디에 맞는 멤버가 없거나, 비밀번호가 틀렸거나이다.
        //이럴때는 필드에러가 아닌 복합적인 검증실패이므로 오브젝트에러(글로벌오류)를 생성해서 bindingResult에 담는 reject() 메소드를 사용한다.
        bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
        //에러코드는 에러코드메시지파일에 추가해주면 defaultMessage를 null로 주면 되고, 추가하지않고 바로 사용하려면 이렇게 기본메시지를 넣어주면된다.
        return "login/loginForm";
    }

    //로그인 성공처리


    Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
    // 쿠키를 생성한다. 쿠키이름,쿠키에 들어갈 값을 설정한다. 쿠키의 들어갈 값은 String형식이여야한다.
    // 쿠키에 시간정보를 주지않으면 세션쿠키가 된다.(브라우저 종료시까지만 유지되는 쿠키)
    response.addCookie(idCookie); // HttpServletResponse에 쿠키 추가


    return "redirect:/";
}

 

 

Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
// 쿠키를 생성한다. 쿠키이름,쿠키에 들어갈 값을 설정한다. 쿠키의 들어갈 값은 String형식이여야한다.
// 쿠키에 시간정보를 주지않으면 세션쿠키가 된다.(브라우저 종료시까지만 유지되는 쿠키)
response.addCookie(idCookie); // HttpServletResponse에 쿠키 추가

로그인에 성공하면 쿠키를 생성하고 HttpServletResponse 에 담는다.

쿠키 이름은 memberId 이고, 값은 회원의 id 를 담아둔다.

웹 브라우저는 종료 전까지 회원의 id 를 서버에 계속 보내줄 것이다. (세션 쿠키를 가지고있으니까)

 

 

쿠키가 잘 보내지고 있는지 확인

 

개발자도구를 키고 로그인화면에서 로그인해보면

응답메시지 헤더에 Set cookie가 잘 들어간것을 볼 수 있다.

이걸 클라이언트가 받아서 알아서 쿠키를 저장할것이다.

그리고 서버로 http요청을 할때마다 그 쿠키를 포함해줄것이다.

 

기본페이지 호출해보기

그냥 localhost:8080   http요청을 해본 상태이다.

요청 헤더를 보면 방금 보냈던 쿠키가 포함되어있는것을 확인할 수 있다.

 

 

크롬 브라우저를 통해 HTTP 응답 헤더에 쿠키가 추가된 것을 확인할 수 있다.

 

이제 요구사항에 맞추어 로그인에 성공하면 로그인 성공한

사용자 전용 홈 화면을 보여주자.

 

 

 

로그인됬을때 홈화면과 로그인 되지않은상태에서 홈화면은 달라야한다.

 

왼쪽이 로그인이 안되었을때 홈화면 , 오른쪽이 로그인 된상태에서 홈화면이다.

(홈화면 == http://localhost:8080)

 

 

홈화면 컨트롤러

@GetMapping("/")
//쿠키는 httpServletRequest 등을 이용해서 꺼내는 방법이 존재한다. 여기서는 스프링이 제공하는 @CookieValue를 사용한다.
public String homeLogin(@CookieValue(name = "memberId", required = false) Long memberId,
    Model model) { // 받을 쿠키이름을 적고, 필수쿠키인지 여부를 적어준다(로그인 하지않은 사용자를 위해서 false로 둔다) 그리고 쿠키값을 담을 변수를 적어준다.(알아서 타입컨버팅해준다.)

    // 로그인하지않아 쿠키가 없는 사용자는 일반 홈화면으로
    if (memberId == null) {
        return "home";
    }

    //쿠키를 넘겼는데 없는 사용자인지 체크
    Member loginMember = memberRepository.findById(memberId);
    if (loginMember == null) {
        return "home"; // 없는 사용자에 대한 쿠키면 일반 홈화면으로 ( 쿠키는 조작이 가능하니까 유효한 값인지 검증한다.)
    }

    model.addAttribute("member", loginMember); // 진짜 있는 사용자라면 모델에 값을 담아준다 (뷰에서 데이터를 뿌려주기 위해)
    return "loginHome"; // 로그인한 사용자 전용 홈화면을 렌더링
}

 

@CookieValue 를 사용하면 편리하게 쿠키를 조회할 수 있다.

로그인 하지 않은 사용자도 홈에 접근할 수 있기 때문에 required = false 를 사용한다.

 

로그인 쿠키( memberId )가 없는 사용자는 기존 home 으로 보낸다.

추가로 로그인 쿠키가 있어도 회원이 없으면 home 으로 보낸다.

로그인 쿠키( memberId )가 있는 사용자는 로그인 사용자 전용 홈 화면인 loginHome 으로 보낸다.

추가로 홈 화면에 화원 관련 정보도 출력해야 해서 member 데이터도 모델에 담아서 전달한다.

 

(세션쿠키이므로,  웹브라우저를 완전종료하면 쿠키가 사라진다, 웹브라우저를 켜놓고 있었다면 서버를 다시켜도 로그인 되어있다.)

테스트

로그인을 하면 홈으로 리다이렉트 되는데 

리다이렉트 될때 요청헤더에 쿠키가 담겨서 요청이 되고,

그걸 홈컨트롤러에서 쿠키에 담긴 memberId를 검증 후 모델에 멤버를 담아 뷰를 렌더링 해준 결과이다.

 

 

 

로그아웃 기능

@PostMapping("/logout")
public String logout(HttpServletResponse response) {
    expireCookie(response,"memberId");
    return "redirect:/";
}

private static void expireCookie(HttpServletResponse response, String cookieName) {
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    response.addCookie(cookie);
}

쿠키명을 이전에 전달한 쿠키명인 memberId로 같게해서 쿠키를 덮어씌운다.

쿠키값은 중요한게 아니니까 null로 해두고

해당 쿠키의 유효시간을 0으로 설정한다.

그렇게 만든 쿠키를 응답http헤더에 담아 보내면 클라이언트는 쿠키를 다시 저장하려고할때

이미 저장된 memberId가 있다면 지금 받아온 쿠키로 덮어씌운다.

 

 

쿠키.setMaxAge() 의 설명

Sets the maximum age of the cookie in seconds. (쿠키의 유효시간을 초 단위로 설정)
A positive value indicates that the cookie will expire after that many seconds have passed. 

Note that the value is the maximum age when the cookie will expire, not the cookie's current age.


A negative value means that the cookie is not stored persistently and will be deleted when the Web browser exits. 

(음수 값은 쿠키가 지속적으로 저장되지 않고 웹 브라우저가 종료될 때 삭제됨을 의미합니다.)

A zero value causes the cookie to be deleted (값이 0이면 쿠키가 삭제됩니다.)

 

로그아웃 테스트

일반 홈화면으로 이동되고, 응답헤더의 쿠키를 보면  set Cookie가 잘들어간것을 확인가능하다.

 

 

쿠키는 application탭의 cookies에서도 확인가능하다.

 

 

쿠키와 보안 문제

쿠키를 사용해서 로그인Id를 전달해서 로그인을 유지할 수 있었다. 그런데 여기에는 심각한 보안 문제가 있다.

 

 

보안 문제

 

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

  • 클라이언트가 쿠키를 강제로 변경하면 다른 사용자가 된다.

테스트) id가 2 와 3인 멤버 2명을 추가했다.

 

 

id가 2인 test2로 로그인한 후 application탭의 cookies에서 memberId를 3으로 수정하고 새로고침을 누른다면?

 

memberId가 3인 test3의 화면이 보인다.

 

이렇게 간단하게 쿠키의 값을 수정할 수 있다. (postman에서도 가능)

 

 

쿠키에 보관된 정보는 훔쳐갈 수 있다.

만약 쿠키에 개인정보나, 신용카드 정보가 있다면?

이 정보가 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.

쿠키의 정보가 나의 로컬 PC에서 털릴 수도 있고, 네트워크 전송 구간에서 털릴 수도 있다

 

 

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

해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다

(위에서 만든 예처럼, 쿠키에 memberId를 이용해서 검증한다면, 한번 쿠키를 가지면 계속 사용가능할것이다.)

 

대안

쿠키에 중요한 값을 노출하지 않고,

 

사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고, 

 

서버에서 토큰과 사용자 id를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리한다.

 

토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능 해야 한다

 

해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록

서버에서 해당 토큰의 만료시간을 짧게(예: 30분) 유지한다.

또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거하면 된다. (아이피 오던게 평소와 다른나라이거나 등등)

 

위의 대안책을 한번에 적용하는것이 세션이라는것을 도입하는것이다.

 

 

로그인 처리하기 - 세션 동작 방식

 

앞서 쿠키에 중요한 정보를 보관하는 방법은 여러가지 보안 이슈가 있었다.

문제를 해결하려면 결국 중요한 정보를 모두 서버에 저장해야 한다.

그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.

 

이렇게 버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다.

세션은 저장소라고 생각하면 되고 그런 세션들을 저장하는곳이 세션저장소라고 생각하면 된다.

https://www.inflearn.com/questions/543698/%EC%84%B8%EC%85%98-%EA%B4%80%EB%A0%A8-%EC%9D%B4%ED%95%B4%ED%95%9C-%EB%82%B4%EC%9A%A9%EC%9D%B4-%EB%A7%9E%EB%8A%94%EC%A7%80-%ED%99%95%EC%9D%B8%EB%B6%80%ED%83%81%EB%93%9C%EB%A6%BD%EB%8B%88%EB%8B%A4

 

세션 동작 방식

 

1. 로그인

 사용자가 loginId , password 정보를 전달하면 서버에서 해당 사용자가 맞는지 확인한다

 

2. 세션 id 생성, 쿠키에 담기, 세선저장소에 세션id와 보관할값을 매핑해서 저장해둔다.

세션 ID를 생성하는데, 추정 불가능해야 한다.

UUID는 추정이 불가능하다. (uuid란 네트워크상에서 고유성이 보장되는 id를 만들기 위한  표준 규약)

 

세션Id를 uuid를 이용해서 만들어서 쿠키 생성후 쿠키안에 담아준다.

예시 )  Cookie   :   mySessionId = zz0101xx-bab9-4b92-9b32-dadb280f4b61

 

생성된 세션 ID와 세션에 보관할 값( memberA )을 서버의 세션 저장소에 보관한다.

(세션id를 알면 멤버객체를 꺼낼수있게끔)

 

 

3. 세션id를 쿠키로 전달한다.

쿠키에 세션아이디가 담겨있는데 그 쿠키를 응답http헤더에 담아 보내준다.

 

클라이언트와 서버는 결국 쿠키로 연결이 되어야 한다.

서버는 클라이언트에 mySessionId 라는 이름으로 세션ID 만 쿠키에 담아서 전달한다.

클라이언트는 쿠키 저장소에 mySessionId 쿠키를 보관한다.

 

중요

여기서 중요한 포인트는 회원과 관련된 정보는 전혀 클라이언트에 전달하지 않는다는 것이다.

오직 추정 불가능한 세션 ID만 쿠키를 통해 클라이언트에 전달한다.

(쿠키만 봐서는 어떤사용자인지,무슨정보인지 알수가 없다)

 

 

4. 클라이언트의 세션id 쿠키 전달

클라이언트는 요청시 항상 mySessionId 쿠키를 전달한다.

서버에서는 클라이언트가 전달한 mySessionId 쿠키 정보로 세션 저장소를 조회해서

로그인시 보관한 세션 정보를 사용한다

 

정리

세션을 사용해서 서버에서 중요한 정보를 관리하게 되었다. 덕분에 다음과 같은 보안 문제들을 해결할 수 있다.

 

 

쿠키 값을 변조 가능 --> 예상 불가능한 복잡한 세션Id를 사용한다.

 

쿠키에 보관하는 정보는 클라이언트 해킹시 털릴 가능성이 있다. --> 세션Id가 털려도 여기에는 중요한 정보가 없다.

 

쿠키 탈취 후 사용  --> 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 세션의 만료시간을

짧게(예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 세션을 강제로 제거하면 된다

 

 

 

로그인 처리하기 - 세션 직접 만들기

(버에 중요한 정보를 보관하고 연결을 유지하는 방법을 세션이라고 한다.)

세션을 직접 개발해서 적용해보자.

 

세션 관리는 크게 다음 3가지 기능을 제공하면 된다.

 

1. 세션 생성

sessionId 생성 (임의의 추정 불가능한 랜덤 값)

 

세션 저장소에 sessionId와 보관할 값 저장

 

sessionId로 응답 쿠키를 생성해서 클라이언트에 전달

 

 

2. 세션 조회

클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회

 

 

 

3. 세션 만료

클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거

 

 

 

세션관리 - SessionManger

@Component
public class SessionManager {

    public static final String SESSION_COOKIE_NAME = "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>(); // 동시에 여러쓰레드가 접근을 할떄는 ConcurrentHashMap을 사용해야한다.
    //물론 지금은 그냥 HashMap을 사용해도된다.

    /**
     * 세션생성
     * sessionId 생성 (임의의 추정 불가능한 랜덤 값)
     * 세션 저장소에 sessionId와 보관할 값 저장
     * sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
     */
    public void createSession(Object value, HttpServletResponse response) {

        //sessionId를 생성하고 값을 세션에 저장
        String sessionId = UUID.randomUUID().toString(); //randomUUID를 이용해서 확실한 랜덤값을 받는다.
        sessionStore.put(sessionId, value); // 그리고 세션저장소에 세션ID를 키값으로 넘어온 value객체를 벨류값으로 넣어준다.

        //쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME,
            sessionId);// 쿠키이름은 상수로 만들어놨다. 위에서 확인가능, 그리고 값은 세션아이디를 넣어서 쿠키생성
        response.addCookie(mySessionCookie);
    }

    /**
     * 세션조회
     * 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회
     */
    public Object getSession(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request,SESSION_COOKIE_NAME); // 쿠키를 찾은 결과를 반환받는다.
        if (sessionCookie == null) { //쿠키가 없다면 null
            return null;
        }
        return sessionStore.get(sessionCookie.getValue()); // mySessionId라는 쿠키가 있다면 그 쿠키값(세션Id)를 이용해서 세션저장소에서 보관했던 값(value)을 꺼낸다.
    }

    private Cookie findCookie(HttpServletRequest request, String cookieName) {
        // http요청 헤더에 쿠키가 들어있는지 체크
        if (request.getCookies() == null) {
            return null;
        }

        //쿠키 중 mySessionId라는 이름의 쿠키가 있는지 체크하고 있다면 그 쿠키를 반환
        return Arrays.stream(request.getCookies()) // Arrays.stream()을 사용하면 배열을 stream으로 바꿔준다.
            .filter(c -> c.getName().equals(cookieName))
            .findAny()  // findAny()까지하면 Optional 객체를 반환해준다. filter를 성공해서 조건에 맞는게 반환되면 findAny()에 오면 그 객체를 담은 Optional, 결국없으면 빈 Optional를 반환한다.
            .orElse(null); // findAny()가 반환한게 Opitional 객체의 내용뮬을 확인한다. 객체가 있으면 그걸 반환 없으면 매개변수를 반환한다.
    }

    /**
     * 세션 만료
     */
    public void expire(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME); // http요청헤더에서 세션아이디를 가지는 쿠키를 찾는다.
        if (sessionCookie != null) { //존재한다면
            sessionStore.remove(sessionCookie.getValue()); //세션저장소에서 해당 (세션ID,value) 를 지운다.
        }

    }


}

@Component : 스프링 빈으로 자동 등록한다. (세션이 필요한곳에서 주입받아 쓸거라서)

 

ConcurrentHashMap : HashMap 은 동시 요청에 안전하지 않다. 동시 요청에 안전한 ConcurrentHashMap 를 사용했다.

 

SessionManagerTest 

class SessionManagerTest {

    SessionManager sessionManager = new SessionManager();

    @Test
    void sessionTest() {

        //세션 생성

        // HttpServletResponse는 스프링이 실행되고 넣어주는것이다. 그리고 구현체를 사용하기도 어렵고해서 스프링이 지원하는것이있다.
        //MockHttpServletResponse는 테스트에서 비슷한 역할을 하는 가짜 HttpServletResponse이다.   (mock이 모조품이라는뜻)
        MockHttpServletResponse response = new MockHttpServletResponse(); //
        Member member = new Member();
        sessionManager.createSession(member, response); // 결과적으로 response에 쿠키가 담겨있다.

        //요청에 응답 쿠키 저장
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setCookies(response.getCookies()); // 위에 응답에 담은 쿠키들을 요청에 그대로 담는다. ( 응답에 담긴 쿠키들은 어차피 클라이언트에 저장되고, 요청할때 다시 담길거니까)

        //세션 조회
        Object result = sessionManager.getSession(request);// http요청으로 세션저장소에 저장된 value를 가져온다.
        Assertions.assertThat(result).isSameAs(member); // 같은 객체인지 체크

        //세션 만료
        sessionManager.expire(request);
        Object expiredResult = sessionManager.getSession(request);
        Assertions.assertThat(expiredResult).isNull();
    }
}

HttpServletRequest , HttpservletResponse 객체를 직접 사용할 수 없기 때문에

테스트에서 비슷한 역할을 해주는 가짜 MockHttpServletRequest , MockHttpServletResponse 를 사용했다.

 

 

로그인 처리하기 - 직접 만든 세션 적용

 

위에서 만든 세션 관리 기능을 적용해보자

 

로그인 V2 ( 로그인 성공처리 부분을 수정)

@PostMapping("/login")
public String loginV2(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult,
    HttpServletResponse response) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(form.getLoginId(), form.getPassword());

    if (loginMember == null) { // login의 반환이 null이라는것은 로그인 아이디에 맞는 멤버가 없거나, 비밀번호가 틀렸거나이다.
        //이럴때는 필드에러가 아닌 복합적인 검증실패이므로 오브젝트에러(글로벌오류)를 생성해서 bindingResult에 담는 reject() 메소드를 사용한다.
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        //에러코드는 에러코드메시지파일에 추가해주면 defaultMessage를 null로 주면 되고, 추가하지않고 바로 사용하려면 이렇게 기본메시지를 넣어주면된다.
        return "login/loginForm";
    }

    //로그인 성공처리
    sessionManager.createSession(loginMember, response); //세션아이디를 만들고, 세션저장소에 (세션Id,멤버)를 저장하고, 쿠키에 세션아이디를 담고 response에 쿠키를 담는다.

    return "redirect:/";
}

 

로그아웃 V2

@PostMapping("/logout")
public String logoutV2(HttpServletRequest request) {
    sessionManager.expire(request);
    return "redirect:/";
}

 

로그인 V2

@GetMapping("/")
public String homeLoginV2(HttpServletRequest request, Model model) {

    // 로그인 한 사람인지 체크하는것을 원래는 해당 쿠키를 꺼내서 있는지 체크했지만.
    // 지금은 getSession에 request를 줘서 null을 반환하도록 구성했다.
    // 세션관리자에 저장된 회원 정보 조회
    Member member = (Member)sessionManager.getSession(request);

    if (member == null) {  // 해당 쿠키가 없거나 (로그인을 하지않은사람) , 쿠키에 세션ID가 적절하지못한 사람이거나 (만료가 됬거나, 틀린 세션ID이거나)
        return "home"; //그런 사람들은 일반 홈화면으로 보낸다.
    }

    model.addAttribute("member", member); // 진짜 있는 사용자라면 모델에 값을 담아준다 (뷰에서 데이터를 뿌려주기 위해)
    return "loginHome"; // 로그인한 사용자 전용 홈화면을 렌더링
}

 

 

결과 확인

 

로그인시 응답헤더에 쿠키가 잘 담겨있다 (UUID 형식의 세션아이디를 담은)

그리고 새로고침을 해보면 요청헤더에 쿠키가 잘 담겨서 오는것을 확인할 수 있다.

로그아웃시 쿠키에 mySessionId = ~ 가 아직 남아있다. 

그 이유는

우리가 만든 세션매니저는 쿠키의 유효시간을 0초로 바꾼것이 아니고

 

세션저장소에 있는 

(세션Id,value)를 지운것이니까 그렇다.

 

어차피 그 쿠키를 가지고 와도 이미 세션저장소에는 지워진 세션id이기때문에 일반홈화면으로 간다.

 

 

정리

이번 시간에는 세션과 쿠키의 개념을 명확하게 이해하기 위해서 직접 만들어보았다.

사실 세션이라는 것이 뭔가 특별한 것이 아니라 단지 쿠키를 사용하는데,

서버에서 데이터를 유지하는 방법일 뿐이라는 것을 이해했을 것이다.

 

그런데 프로젝트마다 이러한 세션 개념을 직접 개발하는 것은 상당히 불편할 것이다.

그래서 서블릿도 세션 개념을 지원한다.

이제 직접 만드는 세션 말고, 서블릿이 공식 지원하는 세션을 알아보자.

서블릿이 공식 지원하는 세션은 우리가 직접 만든 세션과 동작 방식이 거의 같다.

 

추가로 세션을 일정시간 사용하지 않으면 해당 세션을 삭제하는 기능을 제공한다.

 

 

 

우리가 위에서 만든 세션은

 

하나의 세션으로 다수의 유저를 관리하는 세션이고 ( 즉 그 세션이 세션저장소 역할을 했던것)

 

다음에 배울 HttpSession은

 

각 유저별로 하나의 세션을 가지고 

 

그런 세션들을 저장하는 세션저장소가 있다.

 

 

https://www.inflearn.com/questions/543698/%EC%84%B8%EC%85%98-%EA%B4%80%EB%A0%A8-%EC%9D%B4%ED%95%B4%ED%95%9C-%EB%82%B4%EC%9A%A9%EC%9D%B4-%EB%A7%9E%EB%8A%94%EC%A7%80-%ED%99%95%EC%9D%B8%EB%B6%80%ED%83%81%EB%93%9C%EB%A6%BD%EB%8B%88%EB%8B%A4

 

세션 관련 이해한 내용이 맞는지 확인부탁드립니다 - 인프런 | 질문 & 답변

1명의 유저에게 1개 세션(저장소) & 1개 세션 Key(JSESSIONID) 할당 다수의 세션을 갖는 하나의 세션 저장소(Tomcat이 관리) 다수의 키(SessionConst.LOGIN_MEMBER etc..) & 상응하는 값(loginMember etc...

www.inflearn.com

 

댓글