참고 : 패키지 구조 설계
도메인이 가장 중요하다.(도메인 : 화면, UI, 기술 인프라 등등의 영역은 제외한 시스템이 구현해야하는 핵심 비즈니스 영역)
domain-webweb을 다른 기술로 바꾸어도 domain은 그대로 유지될 수 있도록 설계해야한다.즉, web은 domain을 알지만 domain은 web을 모르도록..(web을 모두 삭제해도 domain에는 영향이 가지 않게)의존관계는 단방향으로 흐르게 하는 것이 좋다.
2021.07.17 - [Spring] - 패키지 구조 설계
1. 회원 가입
들어온 Form 데이터를 검증하고, Repository에 save를 진행하는 단순한 구조이다.
2. 로그인
파라미터로 들어온 로그인id로 Member 객체를 조회하고 조회된 객체의 password와 파라미터 password를 비교한다.
일치하면 해당 객체를 반환하고 그렇지 않다면 null을 반환하는 식으로 간단하게 구현하였다.
null이라면 로그인 실패이므로 reject()를 사용하여 GlobalError를 담아준다. 따로 오류 코드는 사용하지 않고 defaultMessage만 지정하겠다.
이제 쿠키를 사용해보자. 쿠키를 사용하여 로그인 상태를 유지할 수 있다.
서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아 브라우저에 전달하면 브라우저는 해당 쿠키를 지속해서 보내주기 때문에 로그인 상태를 유지할 수 있다.
잠깐 얘기해보자면, 기본적으로 HTTP는 stateless(무상태)이다. 이걸 사람에 비유하자면 건망증 환자와 같다. 매번 얼굴을 봐도 "안녕하세요 처음뵙겠습니다."라고 말한다.
그렇다고 매번 요청을 보낼 때마다 로그인을 한다? 굉장히 번거롭고 비효율적이다.
이를 해결하기 위해서 사용하는 것이 쿠키와 세션이다. 쿠키는 기본적으로 브라우저가 사용하고 사용자의 하드디스크에 저장된다. 세션은 서버에서 사용하는 저장소라고 생각하면 이해하기 편할 것이다.
세션과 쿠키 사용 과정
로그인 요청(아이디, 패스워드 전달) -> 서버(아이디, 패스워드 확인) -> 로그인 성공 -> 세션에 정보 저장하고 세션id 반환
-> 다음 요청부터는 브라우저에서 쿠키에 세션 id를 담아서 서버에 전달 -> 세션 id를 보고 "아! 민철이구나"하고 서버가
매번 번거로운 검증없이 인식
로그인이 성공하면 쿠키를 만들어 클라이언트에게 전달해준다.
이후 요청에는 항상 쿠키 정보가 자동 포함되어 전달된다.(클라이언트에서)
쿠키에는 영속 쿠키와 세션 쿠키가 있다.
- 영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
- 세션 쿠키 : 만료 날짜를 생략하면 브라우저 종료시까지만 유지
로그인 성공시 세션 쿠키를 생성하여 반환해주자.
쿠키를 생성하고 로그인한 멤버의 id값을 넣고 HttpServletResponse에 추가해준다.
쿠키 생성자의 파라미터로 value는 String을 받기 때문에 String.valueOf로 타입을 변환해주었다.
이렇게하면 웹 브라우저는 종료 전까지 회원의 id를 서버로 계속 보내줄 것이다.(요청할 때마다)
쿠키가 잘 생성되는지 확인해보자.
로그인을 진행하면 쿠키가 생성되어 memberId가 담겨있는 것을 확인할 수 있다.
스프링이 제공하는 @CookieValue를 사용하여 쿠키를 편리하게 조회할 수 있다. required = false 속성을 통해
쿠키가 없는, 로그인을 하지 않은 사용자도 접속할 수 있게한다. 쿠키를 통해 회원을 조회하고 로그인 유무를 판단하여 각각 다른 View로 보내준다. 로그인되었다면 해당 Member객체를 모델에 담아 넘긴다.
참고로 @CookieValue의 required는 default가 true이다. required가 true면 해당 쿠키가 없으면 400(Bad Request)에러가 발생한다.
쿠키를 통해 로그인 유무를 판단하고 각각 다른 뷰를 랜더링한다. 로그인했다면 위와 같이 회원이름을 출력해주었다.
로그아웃은 단순히 쿠키를 날려버리면 된다.
별도의 메서드를 만들어 구현하였다. HttpServletResponse와 쿠키이름을 파라미터로 넘기면 쿠키를 날려준다.
setMaxAge(초 단위)는 쿠키의 생존시간을 지정해주는 것으로 0은 즉시 지우라는 의미이고 -1은 세션이 끝나면 지우라는 의미이다. 그 외에 초를 지정하면 해당 초만큼 쿠키의 생존시간이 지정된다.
사실 이 방식은 매우 심각한 보안 문제가 존재한다.
쿠키의 값은 임의로 변경할 수 있다.
- 만약 클라이언트가 쿠키를 강제로 변경하면 서버에서는 다른 사용자로 인식한다.
현재 테스터로 로그인한 상태이다. 여기서 쿠키의 memberId를 다른 사람의 Id로 변경하고 새로고침을 하면
서버에서는 다른 회원으로 인식한다. 쿠키의 값은 임의로 변경할 수 있기 때문에 발생하는 문제이다.
쿠키에 보관된 정보는 훔쳐갈 수 있다.
- 쿠키는 웹 브라우저에도 보관되고, 네트워크 요청마다 계속 클라이언트에서 서버로 전달된다.
- 사용자의 로컬 PC가 공격받거나, 네트워크 전송 시에 공격을 받는다면?
해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.
- 해커가 쿠키를 훔쳐가서 그 쿠키로 악의적인 요청을 계속 시도할 수 있다.
그럼 대안은 없을까?..
- 쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하고,
서버에서 토큰과 사용자 id를 매핑해서 인식한다.(토큰은 서버에서 관리)
- 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능해야 한다.
- 해커가 토큰을 해킹해도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지한다.
- 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거한다.
서버에 세션 개념을 도입하면 이러한 보안 문제를 한번에 해결할 수 있다.
세션 동작 방식
앞서 언급한 보안 문제를 해결하기 위해서는 중요한 정보를 모두 서버에 저장하고, 클라이언트와 서버는 추정 불가능한 임의의 식별자 값(토큰)으로 연결해야한다.
이렇게 서버에 중요 정보를 보관하고 연결을 유지하는 방법을 세션이라 한다.
사용자가 ID, Password를 전달하면 서버에서 해당 사용자가 맞는지 확인한다.
서버에 세션 저장소를 만들고 관리한다. 회원이 맞으면 여기에 추정 불가능한 토큰을 세션 ID로 생성한다.
==> UUID 사용
세션 ID와 세션에 보관할 값을 서버의 세션 저장소에 보관한다.
클라이언트와 서버는 결국 쿠키로 연결되어야 한다.
서버는 클라이언트에 세션ID만 쿠키에 담아서 전달하고 클라이언트는 이를 보관한다.
여기서 핵심 내용은 회원과 관련된 중요 정보는 하나도 클라이언트에 전달되지 않는다는 것이다.
클라이언트가 요청시 항상 해당 세션ID가 담긴 쿠키를 전달한다. 이를 서버가 세션 저장소를 조회하여 세션 정보를 사용하게 된다.
따라서, 보안문제를 모두 해결할 수 있다.
추정 불가능한 토큰을 사용하여 변조 가능성을 방지하고, 쿠키가 탈취되더라도 중요 정보가 없기 때문에 위험성이 없다.
서버에서 세션의 만료시간을 짧게 유지하고 해킹이 의심되는 경우 세션을 강제로 제거하면 되기 때문에 쿠키의 탈취에 대한 문제를 해결할 수 있다.
세션 직접 만들기
세션 관리는 크게 다음 3가지 기능을 제공하면 된다.
세션 생성
- sessionId 생성(임의의 추정 불가능한 랜덤 값)
- 세션 저장소에 sessionId와 보관할 값(Value) 저장
- sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
세션 조회
- 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 값 조회
세션 만료
- 클라이언트가 요청한 sessionId 쿠키의 값으로, 세션 저장소에 보관한 sessionId와 값 제거
Session을 관리하는 SessionManager 클래스를 만들고 @Component를 통해 스프링 빈으로 등록한다.
세션 저장소는 동시성 문제를 해결하기 위해 ConcurrentHashMap을 사용했다.
세션 생성
UUID를 생성하여 sessionId로 지정한다. 이를 세션 저장소에 key-value 형식으로 저장하는데 value는 넘어온 객체이다.
이제 쿠키를 만들어 세션Id를 담아 HttpServletRespone에 추가하여 클라이언트에게 전달한다.
세션 조회
getCookies()는 반환 타입이 배열이기 때문에 stream을 통해 찾는 쿠키이름과 일치하는 쿠키를 반환하도록 하였다.
클라이언트가 전달한 쿠키들 중에서 원하는 쿠키를 찾고, 그것을 가지고 서버의 세션 저장소에서 조회하는 방식으로 동작한다.
클라이언트가 전달한 쿠키 중에서 위와 같이 findCookie메서드를 사용하여 쿠키를 찾고 존재한다면 쿠키의 value를 사용하여 서버의 세션 저장소에서 지워준다.
Junit으로 테스트 코드를 작성하고 테스트해보자.
테스트 코드에서 HttpServletRequest나 HttpServletResponse 객체를 직접 사용할 수 없기 때문에 비슷한 역할을 해주는 가짜 MockHttpServletRequest와 MockHttpServletResponse를 사용한다.
먼저 createSession을 사용하면 HashMap(정확히는 ConcurrentHashMap)에 "UUID - 회원 객체"가 key-value쌍으로 묶여 저장되고, response에 mySessionId(지정해준 값임)라는 쿠키가 담긴다.(value는 UUID)
원래는 웹 브라우저에서 쿠키 저장을 자동으로 해주지만, 현재 테스트 코드이기 때문에 가상화하여 직접 request에 쿠키를 담아준다.
request에 담긴 쿠키로 세션을 조회한다. 반환되는 객체가 앞서 만든 회원 객체와 동일한지를 테스트한다.
다음으로 expire메서드를 통해 세션을 만료하고 다시 세션을 조회해 반환된 것이 null인지 테스트한다.
==> expire를 통해 세션 저장소에서 remove했으므로 조회가 되지 않아 null이 반환됨.
직접 만든 세션을 애플리케이션에 적용시켜보자.
로그인이 성공하면 SessionManager를 통해 세션을 생성한다.
로그아웃을 하면 세션을 만료시킨다. 서버의 세션 저장소에서 삭제하였기 때문에 클라이언트가 해당 세션Id를 가지고 서버로 전달을 해도 인식하지 못한다.
기존의 쿠키에 담긴 회원 정보를 가져오는 것이 아닌 세션id를 통해 서버의 세션 저장소에 저장된 회원 정보를 가져옴.
서버를 실행시키고 로그인을 해보면 이제 mySessionId라는 쿠키에 value로 UUID(sessionId)가 들어와있는 것을 확인할 수 있다. 이를 통해 서버에게 로그인했는지, 내가 누구인지를 알려줄 수 있다.
사실 Servlet이 이 세션 기능을 공식적으로 지원해준다. 공식적으로 지원하는 세션은 위에서 직접 만든 세션과 동작 방식이 거의 동일하다. 추가로 Servlet이 지원하는 세션은 일정시간 사용하지 않으면 해당 세션을 자동 삭제하는 기능을 제공한다.
Servlet Http Session 사용하기
Servlet은 세션을 위해 HttpSession이라는 기능을 제공한다.
HttpSession을 생성하면 "JSESSIONID"라는 쿠키를 생성한다.(값은 추정 불가능한 랜덤 값)
여러 곳에서 사용될 세션 상수를 지정해주었다.
세션을 생성하려면 HttpServletRequest의 getSession()을 사용하면 된다.
getSession은 파라미터로 boolean create를 받는데 default는 true이다.
getSession(true)
- 세션이 있으면 기존 세션 반환
- 세션이 없으면 새로운 세션을 생성해서 반환
getSession(false)
- 세션이 있으면 기존 세션 반환
- 세션이 없으면 새로운 세션을 생성하지 않고 null을 반환
위에서는 세션을 생성하고 setAttribute를 통해 로그인한 회원 loginMember를 세션에 보관한다. 하나의 세션에 여러 값을 저장할 수도 있다.
마찬가지로 getSession을 통해 세션을 가져오는데 이 때는 create속성을 false로 하여 새로 생성하지 않도록 한다.
가져온 세션이 null이 아니라면 invalidate()를 통해 세션 저장소에서 세션을 삭제한다.
마찬가지로 getSession(false)를 통해 세션을 가져온다.(세션을 생성하는 것은 메모리를 사용하기 때문에 꼭 필요한 곳에서만 생성하도록 하자!)
그 후 getAttribute를 통해 세션 저장소에 저장된 회원을 꺼내오는데 반환 타입이 Object이기 때문에 Member로 캐스팅
서버를 실행시키고 로그인해보면 JSESSIONID라는 쿠키에 추정 불가능한 토큰이 값으로 들어가있는 것을 확인할 수 있다.
로그아웃을 하면 여전히 JSESSIONID를 브라우저가 가지고 있지만 서버의 세션 저장소에서 삭제되었기 때문에 로그인을 했다고 판단하지 않는다.
@SessionAttribute
(이름만 봐도 뭔가 익숙한 기분인데) 스프링은 세션을 편리하게 사용할 수 있도록 @SessionAttribute를 지원한다!!
위에서 표시된 부분의 번거로운 코드를 스프링이 자동화해준다.
코드 반토막!!
이미 로그인된 사용자를 찾을 때 이와 같이 사용할 수 있다.(세션을 생성하지 않음)
TrackingModes
로그인을 맨 처음 시도하면 URL에 위와 같이 jsessionid가 포함된다.(물론 쿠키도 존재)
이것은 웹 브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법이다. 이 방식은 URL에 jsessionid를 계속 포함해서 전달해야한다.(거의 사용 X)
서버 입장에서는 웹 브라우저가 쿠키를 지원하는지 하지 않는지 최초에는 판단하지 못하므로 쿠키 값과 URL에
jsessionid도 함께 전달한다.
이 방식을 off하고 쿠키를 통해서만 세션을 유지하고 싶을 때는 application.properties에서 아래와 같이 추가한다.
세션 정보와 타임아웃 설정
RestController를 통해 간단하게 세션에 담겨있는 정보들을 log로 찍어보자.
로그인을 하여 세션을 생성하고 해당 url로 접속해보면 세션의 정보가 로그로 출력될 것이다.
- sessionId : 세션Id(JSESSIONID의 값)- maxInactiveInterval : 세션의 유효시간- creationTime : 세션 생성일시- lastAccessedTime : 세션과 연결된 사용자의 최근 서버 접근 시간- isNew : 새로 생성된 세션인지, 이미 만들어진 세션을 조회한 것인지?
Session Timeout
세션은 사용자가 로그아웃을 하여 session.invalidate()가 호출되면 삭제된다. 대부분의 사용자는 로그아웃을 누르지 않고 그냥 웹 브라우저를 종료하는 경우가 많다. 이 때 문제는 HTTP는 비연결성(ConnectionLess)이므로 서버 입장에서는 해당 사용자가 웹 브라우저를 종료한 것인지 아닌지 인식할 수 없다. 따라서 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다.
남아있는 세션을 무한정 보관하면 아래와 같은 문제가 발생할 수 있다. - 세션과 관련된 쿠키(JSESSIONID)가 탈취당하면 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다. - 세션은 기본적으로 메모리에 생성되기 때문에, 꼭 필요한 경우에만 생성하여 사용해야한다.(메모리는 유한)
세션의 종료 시점은 어떻게 정하는 것이 좋을까?
세션의 만료 시간을 정해주는 것이 가장 좋은 대안이다. 만료 시간을 정해주는 시점은 세션 생성 시점이 아니라 사용자가서버에 요청을 보낸 시점을 기준으로 30분 정도 세션을 유지해주는 것이다.
위에서 HttpSession정보 중 maxInactiveInterval가 1800초로 30분으로 설정되어있는 것을 확인할 수 있다. 여기에 HttpSession은 사용자가 서비스를 계속 사용하고 있다면(세션 유지 중에 서버에 요청을 다시 보내면) 세션의 유지 시간이 다시 30분으로 늘어난다.
개발자가 세션의 만료 시간을 지정해줄 수도 있다.application.properties에 옵션을 추가하여 글로벌 설정을 하면 된다.
기본 값은 1800초(30분)이다. 글로벌 설정은 분 단위로 설정해야하기 때문에 60(1분), 120(2분)...
또한 특정 세션 단위로 시간을 설정해줄 수도 있다.
session.setMaxInactiveInterval(120);
HttpSession은 LastAccessedTime을 이후로 timeout 시간이 지나면 WAS가 내부에서 해당 세션을 제거한다.
세션에는 최소한의 데이터만 보관해야한다. 그렇지 않으면 세션의 메모리 사용량이 급격히 늘어나 장애가 발생할 수 있다.
또한 세션의 시간을 너무 길게 설정하면 메모리 사용이 누적되므로 적당한 시간을 선택해야한다.
'Spring > Spring MVC' 카테고리의 다른 글
예외 처리, 오류 페이지 (0) | 2021.07.24 |
---|---|
필터, 인터셉터 (0) | 2021.07.21 |
Bean Validation (0) | 2021.07.14 |
Validation (0) | 2021.07.07 |
메시지, 국제화 (0) | 2021.07.06 |