전공서적 중고마켓을 만들어보자! (feat. 로그인 상태 관리)

nagosu·2024년 3월 16일
2
post-thumbnail

서론

이 프로젝트는 한국공학대학교 컴퓨터공학부에서 매년 개강 초에 실시하던 전공서적 중고마켓을 웹사이트로 만들어보면 어떨까? 라는 호기심에서 시작되었다.

구글폼과 구글시트로 판매자와 구매자를 일일이 관리하고 연결해줘야 했던 기존의 불편한 방식을 대체하고 사용자들끼리 알아서 판매할 책을 등록하고 구매를 원하는 사람이 판매 글을 보고 컨택하는 방식으로 구매/판매 자동화를 하고 싶었다.

그 과정에서 애를 먹었던 전역 로그인 상태 관리에 대한 글을 써보고자 한다.

  • 전공서적 중고마켓 실행 사진


로그인 상태 관리란?

우선 로그인 상태 관리에 대한 개념을 알아보자.

전역 로그인 상태 관리라고도 하는데, 웹 페이지에서는 사용자가 로그인을 했을 때만 들어갈 수 있는 페이지가 있고, 로그인을 하지 않아도 들어갈 수 있는 페이지가 나뉘어져 있다.

이처럼 로그인 상태 관리란 페이지의 여러 부분에서 사용자의 로그인 상태를 공유하고 관리하는 방법을 말한다.

이를 통해 웹 페이지 내에서 사용자가 한 번 로그인하면 다른 페이지나 컴포넌트에서도 로그인 상태를 유지할 수 있다.

그리고 전역 로그인 상태 관리를 구현함으로써, 사용자 경험(UX)을 개선하고 보완을 강화하며 웹 페이지의 개발과 유지보수를 용이하게 할 수 있다.

Session? JWT?

일단 사용자가 로그인을 했을 때, 로그인 여부는 보통 두가지 방식 중 하나로 관리된다.

Session 방식과 JWT를 이용한 방식이 있는데, 우선 각각의 방식에 대한 특징을 알아보자.

Session

Session 방식은 서버가 사용자의 로그인 상태를 관리하는 방식 중 하나이다.

사용자가 로그인을 하면, 서버는 이에 대한 정보를 Session에 저장하고, 사용자에게는 이 Session을 식별할 수 있는 Session ID쿠키 형태로 제공한다.

방문자가 웹 서버에 접속해 있는 상태를 하나의 단위로 보고 그것을 Session이라 하는데, 사용자가 다른 요청을 할 때마다 이 쿠키를 통해 서버는 사용자를 식별하고 사용자의 로그인 상태를 유지할 수 있다.

Session 방식의 장점은 상태를 서버에서 관리하기 때문에 보안성이 높다.

하지만, 이는 동시에 단점으로 작용할 수도 있는데, 사용자가 많아질수록 서버에 저장되는 Session의 양이 많아지고, 이에 따라 서버의 부하가 증가할 수 있다.

JWT

JWT(JSON Web Token) 방식은 로그인 상태를 클라이언트 측에서 관리하는 방식이다.

사용자가 로그인을 하면, 서버는 사용자의 정보를 기반으로 JWT를 생성하여 사용자에게 제공한다.

이후 사용자는 서버로 요청을 보낼 때마다 이 토큰을 포함시켜서 인증을 받는다.

JWT는 일반적으로 .을 기준으로 header, payload, signature 세 부분으로 구성되어 있고, 이를 통해 사용자의 정보와 토큰이 유효한지를 판단할 수 있다.

JWT 방식의 장점은 서버가 사용자의 상태를 저장할 필요가 없기 때문에 서버의 부하를 줄일 수 있다.

또한, 토큰 기반 인증은 확장성이 높아서 다양한 시스템에서의 인증으로 활용될 수 있다.

하지만, JWT는 한 번 발급되면 만료시간이 될 때까지 계속 유효하기 때문에, 보안에 더욱 신경을 써야한다.

특히 토큰이 탈취되는 경우, 사용자의 정보가 노출될 위험이 있으므로 주의가 필요하다.

그래서 이를 보완할 방법 중 하나로 Access Token & Refresh Token 방식이 있는데, 두개의 토큰 모두 JWT이다.

Access Token & Refresh Token 방식은 토큰에 유효기간을 주어서 보안을 강화시킨 것이다.

사용자가 로그인을 하면 클라이언트는 서버로부터 Access TokenRefresh Token을 함께 발급받고, 서버는 Refresh Token을 안전한 곳에 저장해놓는다.

Access Token은 유효기간이 짧은 토큰이고, 사용자가 어떠한 요청을 보낼 때 Access Token을 함께 보내면 서버에서 유효한 지 확인을 한 후 응답을 보낸다.

만약 Access Token이 유효 기간이 지났다면, 클라이언트에서 Refresh Token과 함께 요청을 보낸 뒤 서버에 저장된 Refresh Token과 비교한 후 유효하다면 새로운 Access Token이 발급된다.

이번 프로젝트에서는 요구사항, 보안 고려사항 그리고 기술 스택(React, NestJS) 등을 생각해 SessionhttpOnly 쿠키에 저장하는 방식을 선택했다.

httpOnly 쿠키란?

httpOnly 쿠키는 웹 서버가 HTTP 헤더를 통해 웹 브라우저에게 전송하는 쿠키 중 하나로, Javascript와 같은 클라이언트 사이드 스크립트를 통해 접근할 수 없도록 설정된 쿠키이다.

이 쿠키는 오직 HTTP 요청을 통해서만 서버에 전송될 수 있는데, httpOnly 쿠키의 주요 목적은 보안을 강화하는 것이다.

특히, XSS(Cross-site Scripting) 공격으로부터 사용자의 쿠키를 보호하는데 중요한 역할을 한다.

XSS 공격은 공격자가 웹 페이지에 악의적인 스크립트를 삽입하여 사용자의 세션 쿠키를 탈취하려고 시도하는 것인데, httpOnly 플래그가 설정된 쿠키는 클라이언트 사이드 스크립트로 접근할 수 없으므로, 이러한 공격으로부터 사용자의 쿠키를 보호할 수 있다.

어떻게 로그인 상태를 관리하였나?

로그인 시

우선 사용자가 서버에 로그인 요청을 하면 서버로부터 Session을 받아와야 하기 때문에 API 요청 함수를 만들어줬다.

이메일과 패스워드 state가 true이면 실행되고 실패 시 적절한 예외 처리를 해두었다.

// 로그인 API 요청 함수
const signInRequest = async () => {
  // 이메일과 비밀번호가 입력되어야 실행
  if (email && password) {
    try {
      await api.post<SignIn>(
        '/users/login',
        {
          email,
          password,
        },
        { withCredentials: true },
      );
      window.location.href = '/';	// 로그인 성공 시 메인페이지로 리다이렉트
    } catch (e) {
      alert('이메일 또는 비밀번호를 잘못 입력했습니다.');
    }
  } else if (email.length <= 0) {
    alert('이메일을 입력하십시오.');
  } else if (password.length <= 0) {
    alert('비밀번호를 입력하십시오.');
  }
};

클라이언트에서 위와 같이 로그인 요청을 하면 서버에서 요청에 대한 응답 코드를 만들어주어야 한다.

아래 코드는 사용자의 로그인 정보를 검증하고, 사용자의 이름과 userId가 들어있는 Session을 클라이언트에게 응답하는 로직이다.

@Post('/login')
async loginUser(
  @Body() userLoginRequestDto: UserLoginRequestDto,
  @Session() session: Record<string, any>,
  @Res() res: Response,
    ) {
      const response = await this.userService.loginUser(
        userLoginRequestDto,
        session,
      );
      res.status(HttpStatus.OK).json(response);
    }

Session미들웨어를 사용하여 다음과 같은 옵션으로 설정되어 있다.

여기서 중요한 옵션은 Session 쿠키가 httpOnly 쿠키로 전송되고, 최대 유효 시간이 3600000밀리초(1시간)로 설정되어있다는 점이다.

app.use(
  session({
    secret: process.env.SESSION_SECRET,
    resave: false,
    saveUninitialized: false,
    cookie: {
      httpOnly: true,
      maxAge: 3600000,
    },
  }),
);

이렇게 httpOnly 쿠키로 반환된 Session 정보는 클라이언트 사이드 스크립트에서 읽을 수 없게 클라이언트 측에 저장된다.

로그인 상태 확인

위와 같은 로직으로 로그인할 때 Session 정보를 반환받고 저장이 되었다면, 로그인이 필요한 페이지 혹은 컴포넌트마다 로그인이 되었는지 확인하는 코드가 필요하다.

다음은 사용자가 로그인이 되어 있는 상태인지 확인하는 함수이다.

useSetRecoilState 훅을 사용하여 userState라는 Recoil 상태를 설정하는 setUser 함수를 생성한다.

그리고 사용자의 로그인 여부를 로컬 컴포넌트 레벨에서 관리하기 위해 useState 훅을 사용해 isLoggedIn이라는 로컬 상태를 정의하고, null로 초기화한다.

useCheckLoginStatus 내부에는 서버에 로그인 상태를 확인하는 요청을 보내고, 응답을 기반으로 사용자의 로그인 상태를 업데이트하는 checkLoginStatus라는 비동기 함수를 정의한다.

요청을 보낼 때 withCredentials: true 옵션을 사용해 로그인 시 반환받은 Session 정보를 포함해서 전송한다.

요청이 성공(로그인이 되어있음)하면 반환된 사용자 정보와 로그인 상태(isLoggedIn)를 Recoil 상태(userState)에 저장하고 isLoggedIn 로컬 State를 업데이트해 로그인 상태를 새로 반영한다.

실패(로그인이 되어있지 않음) 시 setUser를 사용해 userState를 초기값으로 설정하고 isLoggedIn 로컬 State를 false로 설정한다.

// authService.ts
const useCheckLoginStatus = () => {
  const setUser = useSetRecoilState<UserState>(userState);
  const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);

  // 로그인 상태 확인하는 함수
  const checkLoginStatus = async () => {
    try {
      const response = await api.get<UserState>('/users/status', {
        withCredentials: true,
      });
      // 로그인 상태일 때 사용자 정보를 Recoil 상태에 저장
      setUser({
        isLoggedIn: response.data.isLoggedIn,
        user: response.data.user,
      });
      setIsLoggedIn(response.data.isLoggedIn);
    } catch (error) {
      // 로그인 상태가 아닐 때 Recoil 상태를 기본값으로 초기화
      setUser({
        isLoggedIn: false,
        user: {
          email: '',
          id: 0,
          major: '',
          name: '',
          studentId: '',
        },
      });
      setIsLoggedIn(false);
    }
  };

  useEffect(() => {
    // 컴포넌트가 마운트될 때 로그인 상태 확인
    checkLoginStatus();
  }, []);

  return isLoggedIn;
};

위와 같은 로직으로 서버에 로그인 상태인지 확인하는 요청을 보내면 서버에서 다음과 같이 처리한다.

SessionuserId가 존재하는지 확인하고 만약 존재한다면 this.userService.findUserById 메소드를 사용해 해당 userId를 가진 사용자의 정보를 DB에서 조회한다.

사용자 정보 조회가 성공(user 객체가 존재함)한다면 로그인 상태(isLoggedIn: true)와 사용자의 상세 정보를 JSON 형식으로 클라이언트에게 반환한다.

만약 세션에 userId가 없거나 사용자 정보 조회 시 user 객체를 찾을 수 없을 때, 클라이언트에게 isLoggedIn: false를 반환한다.

@Get('/status')
async getLoginStatus(
  @Session() session: Record<string, any>,
  @Res() res: Response,
  ) {
    if (session.userId) {
      const user = await this.userService.findUserById(session.userId);
      if (user) {
        return res.status(HttpStatus.OK).json({
          isLoggedIn: true,
          user: {
            id: user.id,
            email: user.email,
            name: user.name,
            studentId: user.studentId,
            major: user.major,
          },
        });
      }
    }
    return res.status(HttpStatus.OK).json({ isLoggedIn: false });
  }

로그아웃 시

마지막으로 사용자가 로그아웃 버튼을 클릭했을 때, 사용자의 로그인 상태를 false로 변경해주어야 하기 때문에 로그아웃 API 요청 로직도 만들어주어야 한다.

마찬가지로 withCredentials: true 옵션을 이용해 사용자의 Session 정보를 서버에게 전송한다.

response.status가 200(로그아웃 성공)이면 setUserStateValue를 이용해 isLoggedIn 상태를 false로 설정하고 저장되어 있던 사용자의 정보를 초기화한다.

// 로그아웃 API 요청 함수
const logOutRequest = async () => {
  try {
    const response = await api.post<string>(
      '/users/logout',
      {},
      { withCredentials: true },
    );
    if (response.status === 200) {
      setUserStateValue({
        isLoggedIn: false,
        user: {
          email: '',
          id: 0,
          major: '',
          name: '',
          studentId: '',
        },
      });
    }
    moveToMainPageWithRefresh();
  } catch (e) {
    alert(e);
  }
};

위와 같이 로그아웃 요청을 서버에게 보내면 서버는 다음과 같이 처리한다.

클라이언트가 전송한 Session 정보를 받아 session.destroy() 메소드를 호출해 현재 Session을 파괴한다.

Session 파괴에 성공하면 클라이언트에게 성공적으로 로그아웃을 했다고 알리는 메시지를 JSON 형태로 전송한다.

@Post('/logout')
async logoutUser(
  @Session() session: Record<string, any>,
  @Res() res: Response,
  ) {
    const name = session.username;
    session.destroy((err) => {
      if (err) {
        return res
          .status(HttpStatus.INTERNAL_SERVER_ERROR)
          .json({ message: 'Logout failed' });
      }
      res.status(HttpStatus.OK).json({ message: `${name}님 안녕히가세요!` });
    });
  }

마무리(느낀점)

이번 프로젝트를 통해서 전역 로그인 상태 관리의 중요성과 어려움을 정말 잘 느낄 수 있었다.

그리고 SessionJWT를 이용한 방법이 어떻게 다른지 이번 기회에 더 확실히 알아볼 수 있었고 아직 많이 부족하지만 httpOnly 쿠키를 이용해 보안을 강화하는 방법에 대해서도 배울 수 있었다.

하지만 위에서 언급하지는 않았지만 httpOnly 쿠키는 CSRF(Cross-Site Request Forgery) 공격에 대한 보호를 제공하지는 않는다.

그래서 다음 프로젝트에서는 JWT를 이용해 조금 더 보안을 강화하고 로그인 상태 확인 코드도 조금 더 간결하고 재사용성이 좋게 만들어 봐야겠다!

참고자료

profile
프론트엔드 개발자..일걸요?

1개의 댓글

comment-user-thumbnail
2024년 5월 19일

잘 읽고 갑니다

답글 달기

관련 채용 정보