로그인 -세션,쿠키(1)

이정원·2024년 11월 15일

1.로그인 개요

로그인은 사용자의 인증 상태를 유지하여 시스템에 안전하게 접근할 수 있도록 하는 기능이다. 이를 위해 세션과 쿠키는 로그인한 사용자가 인증 상태를 지속적으로 유지할 수 있도록 지원한다. 세션과 쿠키는 인증 상태를 유지하는 방식에서 차이가 있으며, 각각의 장단점을 가지고 있다.

1-1.세션(Session)

  • 개념: 서버에 사용자의 상태 정보를 저장하여 사용자가 로그인한 상태를 유지하는 방식이다.

  • 작동 원리:
    (1) 사용자가 로그인하면, 서버는 고유한 세션 ID를 생성하고 세션 저장소에 저장한다.

    (2) 세션 ID는 클라이언트(브라우저)에게 전달되며, 이후 서버에 요청할때마다 세션 ID가 함께 전송된다.

    (3) 서버는 요청에서 받은 세션 ID를 확인하여 사용자 상태를 조회하고, 로그인 상태를 유지한다.

  • 장단점: 사용자 정보가 서버에 저장되기 때문에 보안성은 높지만 사용자 수가 많아지면 서버 메모리 부담이 커진다.

1-2.쿠키(Cookie)

  • 개념: 클라이언트 측(브라우저)에 사용자의 인증 정보를 저장하여 로그인 상태를 유지하는 방식

  • 작동 원리:
    (1)사용자가 로그인하면 서버는 쿠키를 클라이언트 에게 전송한다.

    (2) 클라이언트는 서버에 요청을 보낼 때 쿠키를 함께 전송하여 인증 상태를 확인받는다.

    (3) 서버는 쿠키를 통해 사용자의 로그인 상태를 파악한다.

  • 장단점: 서버 자원을 소비하지 않지만 보안상 위험이 있다.

2.도메인/웹 역할 분리

역할 분리, 유지보수성 향상, 확장성 보장과 같은 소프트웨어 설계 원칙을 지키기 위해 도메인(Domain)웹(Web)을 명확히 구분하여 개발한다. 각 계층은 역할, 책임, 그리고 다루는 영역의 차이에 기반하여 설계된다.

2-1. 도메인

도메인은 시스템이 해결해야 하는 핵심 비즈니스 문제와 로직을 담당하는 부분이다. 이는 기술적인 구현 방식이 아니라, 사용자의 실제 요구와 비즈니스 가치를 다룬다.

역할과 책임:

  • 비즈니스 규칙과 규정을 포함
  • 엔티티(Entity), 값 객체(Value Object), 도메인 서비스 등으로 구성.
  • 기술적 세부사항(UI, 데이터 저장 방식 등)에 독립적이어야 함.

주요 특징:

  • UI, 데이터베이스, 인프라에 의존하지 않음.
  • 비즈니스 로직을 중심으로 설계됨.
  • 유지보수와 확장성이 뛰어남.

2-2. 웹(Web)

웹은 사용자와 시스템이 상호작용하는 인터페이스로, 주로 웹 브라우저나 HTTP를 통해 사용자 요청을 처리하고 응답을 반환하는 역할을 한다.

역할과 책임:

  • 사용자의 입력(폼 데이터, 클릭 등)을 처리.
  • 결과를 사용자가 이해할 수 있는 형태로 전달(UI, JSON, HTML 등).
  • 도메인 로직에 사용자 요청을 전달하고, 결과를 렌더링.

주요 특징:

  • 도메인 로직을 직접 포함하지 않음(권장 사항).
  • 프레젠테이션(화면)과 관련된 부분을 다룸.
  • REST API, HTML, JavaScript와 상호작용.

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

3.로그인/회원가입 구현

3-1.회원가입

private static Map<Long,Member> store = new HashMap<>();
private static Long squence = 0L; //static

public Member save(Member member){
        member.setId(++squence);
        log.info("save: member={}",member);
        store.put(member.getId(),member);
        return member;
    }
    
public Optional<Member> findByLoginId(String loginId){
        return findAll().stream()
                .filter(m->m.getLoginId().equals(loginId))
                .findFirst();
    }

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

클래스 전역으로 공유하는 변수 store(key(회원 ID)-value(회원 객체))와 squence(독립적인 회원 id)를 정의하고 각 메서드(가입,찾기)를 구현한다.

3-2.로그인

서비스

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

컨트롤러

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

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
        if(loginMember==null){
            bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }
        //로그인 성공 처리 TODO
        log.info("login success={}",loginMember);
        return "redirect:/";
    }

사용자가 폼에서 입력한 ID와 password가 서비스에서 객체를 반환하지 않는 경우 bindingResult에 에러를 추가하여 뷰의 global-Error 필드에 오류를 렌더링한다.

쿠키를 사용한 로그인

일반적으로 요청 받은 서버가 쿠키에 세션ID를 포함하여 클라이언트에게 발급하고 클라이언트는 요청을 보낼때 쿠키를 포함시켜 인증 요청을 진행한다.(주로 웹 스토리지를 통해 필요할때 javaScripts를 통해 요청한다.)

Spring framework에서 로그인 성공 처리 로직에 쿠키를 생성하여 발급한다. 쿠키를 담을 응답 HttpServletResponse를 파라미터에 추가한다.

로그인 컨트롤러

@PostMapping("/login")
public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response){
		 //쿠키 발급 로직
        Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
        response.addCookie(idCookie);
}

응답 쿠키

클라이언트 요청에서 쿠키 유무를 통해 홈 화면을 다르게 보여주자.

홈 컨트롤러

@GetMapping("/")
    public String homeLogin(@CookieValue(name="memberId",required = false) Long memberId, Model model) {
        if(memberId==null){
            return "home";
        }
        Member loginMember = memberRepository.findById(memberId);
        if(loginMember==null){
            return "home";
        }
        model.addAttribute("member",loginMember);
        return "loginHome";

    }

@CookieValue는 HTTP 요청 값을 컨트롤러 메서드의 파라미터에 자동으로 바인딩 해준다. (name="")에서 이름을 지정하면 일치하는 쿠키 key의 값을 타입 컨버팅(string->Long)하여 바인딩해준다.

로그아웃 컨트롤러

@PostMapping("/logout")
    public String logout(HttpServletResponse response){
        Cookie cookie = new Cookie("memberId", null);
        cookie.setMaxAge(0);
        response.addCookie(cookie);
        return "redirect:/";
    }

이런식으로 쿠키만으로 로그인/로그아웃을 처리할수 있다. 하지만 해당 방식은 심각한 보안 문제가 있다. 쿠키의 값은 클라이언트 측에서 위/변조가 가능하여 다른 사용자 계정이 탈취 당할수 있다. 또한 쿠키에 중요한 정보(신용카드,개인정보)가 있다면 네트워크 전송구간,웹 브라우저 등 다양한 노출로 인해 위험도가 증가한다.

  • 대안:
    1.쿠키에 중요한 값을 노출하지 않고, 사용자 별로 예측 불가능한 임의의 토큰(랜덤 값)을 노출하여 서버에서 토큰과 사용자 id를 매핑해서 인식한다.(서버에서 토큰을 관리)

    2.해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게(예: 30분) 유지한다. 또는 해킹이 의심되는 경우 서버에서 해당 토큰을 강제로 제거한다.

해당 방법을 한번에 도입한 서버 세션을 사용하면 된다.

4.세션

앞선 문제를 해결하려면 결국 중요한 정보를 모두 서버에 저장해야 한다. 그리고 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 한다.

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


세션 Id는 UUID(유일무이한 랜덤값)로 설정하고 회원 객체명을 value로 look-up 테이블에 저장되고 관리한다. 추후 해당 값으로 쿠키를 생성하여 클라이언트에 보내고 브라우저 쿠키 저장소에 보관하게 된다.

결과
1.쿠키에서 회원 관련 정보는 네트워크 전송에 있어 노출되지 않는다.
2.예상 불가능한 복잡한 값으로 쿠키 값 변조에 대한 낮은 위험도
3.탈취 당하더라도 만료시간으로 인한 안전

4-1.세션 구현

Cookie key="SESSION_COOKIE_NAME"
Cookie value="sessionId"

sessionStore key="sessionId"
sessionStore value="Member"

세션 관리는 다음 3가지 기능을 통해 운용된다.

  • 1.세션 생성
    (1) UUID를 통한 sessionId 생성
    (2) 세션 저장소에 sessionId와 보관할 값 저장
    (3) sessionId로 응답 쿠키를 생성해서 클라이언트에 전달
public void createSession(Object value, HttpServletResponse response){
        //세션 id 생성, 값을 세션에 저장
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId,value);

        //쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);
    }
  • 2.세션 조회
    클라이언트가 요청한 쿠키 값으로 세션 저장소 조회
public Object getSession(HttpServletRequest request){
        Cookie cookie = findCookie(request, SESSION_COOKIE_NAME);
        if(cookie==null){
            return null;
        }
        return sessionStore.get(cookie.getValue());
    }
    public Cookie findCookie(HttpServletRequest request,String cookieName){
        if(request.getCookies()==null){
            return null;
        }
        return Arrays.stream(request.getCookies())
                .filter(cookie -> cookie.getName().equals(SESSION_COOKIE_NAME))
                .findAny()
                .orElse(null);
    }

HttpServletRequest의 getCookies() 메서드는 서버가 요청에 포함된 모든 쿠키를 Cookie[] 형태로 반환

  • 3.세션 만료
    세션 저장소에 보관한 sessionId와 값 제거
public void expire(HttpServletRequest request){
        Cookie cookie = findCookie(request, SESSION_COOKIE_NAME);
        if(cookie!=null){
            sessionStore.remove(cookie.getValue());
        }
    }

SessionTest

 @Test
    void sessionTest() {
        //서버에서 쿠키에 세션을 만들어 발행
        MockHttpServletResponse response = new MockHttpServletResponse();
        Member member = new Member();
        sessionManager.createSession(member, response);
        
        ///브라우저에서 쿠키를 설정하여 서버에 요청
        MockHttpServletRequest request = new MockHttpServletRequest();
        request.setCookies(response.getCookies());
        
        //요청 받은 세션 조회
        Object result = sessionManager.getSession(request);
        assertThat(result).isEqualTo(member);
        
        //세션 만료
        sessionManager.expire(request);
        Object expired = sessionManager.getSession(request);
        assertThat(expired).isNull();
    }

HttpservletResponse는 구현체를 정의하기 애매하기 때문에 Spring에서 MockHttpServlet{Response,Request}()를 제공해준다.

5.Servlet HttpSession

세션은 대부분의 웹 애플리케이션에서 필수적인 개념이며, 이는 웹이 등장하면서부터 제기된 문제와 밀접한 관련이 있다. 서블릿은 이러한 세션 관리 문제를 해결하기 위해 HttpSession 기능을 제공하며, 이를 통해 세션 관리를 보다 쉽게 구현할 수 있다. 우리가 직접 구현했던 세션 관리 기능은 이미 서블릿에서 제공되며, 더욱 효율적이고 안정적으로 설계되어 있다.

로그인 Controller

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

        Member loginMember = loginService.login(form.getLoginId(), form.getPassword());
        if(loginMember==null){
            bindingResult.reject("loginFail","아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }
        //로그인 성공 처리 TODO
        HttpSession session = request.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER,loginMember);
        //쿠키 시간 정보x == 세션 쿠키(브라우저 종료시 삭제)
        sessionManager.createSession(loginMember,response);
        return "redirect:/";
    }
  • request.getSession(true)
    세션이 있으면 기존 세션을 반환한다.
    세션이 없으면 새로운 세션을 생성해서 반환한다.
  • request.getSession(false)
    세션이 있으면 기존 세션을 반환한다.
    세션이 없으면 새로운 세션을 생성하지 않는다. null을 반환한다.

로그아웃 Controller

@PostMapping("/logout")
    public String logoutV3(HttpServletRequest request){
        HttpSession session = request.getSession(false);
        if(session != null){
            session.invalidate();
        }
        return "redirect:/";
    }

홈 Controller

@GetMapping("/")
    public String homeLoginV3(HttpServletRequest request, Model model) {
        HttpSession session = request.getSession(false);
        if(session==null){
            return "home";
        }
        Member loginMember  = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);

        if(loginMember==null){
            return "home";
        }
        //세션이 있다면 로그인 창으로 이동
        model.addAttribute("member",loginMember);
        return "loginHome";
    }

로그인으로 Cookie를 확인해보면 JSESSIONID가 들어온것을 확인할수 있다.

또한 로그인된 사용자를 찾을때는 복잡한 로직없이 @SessionAttribute를 통해 세션값에 대한 객체를 바로 담을수 있다.

	 @GetMapping("/")
    public String homeLoginV3Spring(@SessionAttribute(name=SessionConst.LOGIN_MEMBER,required = false)Member loginMember, Model model) {
        if(loginMember==null){
            return "home";
        }
        //세션이 있다면 로그인 창으로 이동
        model.addAttribute("member",loginMember);
        return "loginHome";
    }

세션 정보 확인

@Slf4j
@RestController
public class SessionInfoController {
    @GetMapping("/session-info")
    public String sessionInfo(HttpServletRequest request){
        HttpSession session = request.getSession(false);
        if(session==null){
            return "세션이 없습니다.";
        }
        //세션 데이터 출력
        session.getAttributeNames().asIterator()
                .forEachRemaining(name -> log.info("session name={},value={}",name,session.getAttribute(name)));
        log.info("sessionId={}", session.getId());
        log.info("maxInactiveInterval={}", session.getMaxInactiveInterval());
        log.info("creationTime={}", new Date(session.getCreationTime()));
        log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
        log.info("isNew={}", session.isNew());
        return "세션 출력";

    }
}
  • sessionId : 세션Id, JSESSIONID 의 값

  • maxInactiveInterval : 세션의 유효 시간, 예) 1800초, (30분)

  • creationTime : 세션 생성일시

  • lastAccessedTime : 세션과 연결된 사용자가 최근에 서버에 접근한 시간, 클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청한 경우에 갱신된다.

  • isNew : 새로 생성된 세션인지, 아니면 이미 과거에 만들어졌고, 클라이언트에서 서버로 sessionId ( JSESSIONID )를 요청해서 조회된 세션인지 여부

대부분의 사용자는 로그아웃을 하지 않고 웹 브라우저를 종료하기 때문에 서버 입장에서 사용자가 웹 브라우저를 종료한것인지 아닌지를 인식할 수 없다. 이로 인해서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다. 따라서 세션은 메모리를 사용하기 때문에 사용이 누적되어 서버 자원을 낭비하게 된다.

대표적으로 발생할수 있는 문제점은

  • 세션과 관련된 쿠키(JSESSIONID)를 탈취 당했을 경우 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다.

  • 세션은 기본적으로 메모리에 생성된다. 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우만 생성해서 사용해야 한다. 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다.

-> 사용자가 서버에 최근에 요청한 시간을 기준으로 30분 정도를 유지해주는 것이다. (Httpsession은 이 방식을 사용한다.)

server.servlet.session.timeout=60

application.properties에 위와 같이 세션 종료시간을 설정할수 있고 특정 세션만 시간을 따로 관리하고 싶다면 다음과 같이 관리하면 된다.

session.setMaxInactiveInterval(1800); //1800초

실무에선 세션 값에 Member 객체를 그대로 넣기 보다 타이트한 객체(필드 수가 적은)로 메모리를 절약하는것이 바람직하다.

public class SessionUser {
    private Long id;
    private String name;
    private String email;  // 최소한의 정보만 유지

    public SessionUser(Member member) {
        this.id = member.getId();
        this.name = member.getName();
        this.email = member.getEmail();
    }
}

// 로그인 시 세션에 저장 (Member 객체 대신 SessionUser 사용)
SessionUser sessionUser = new SessionUser(member);
session.setAttribute("user", sessionUser);

0개의 댓글