React + Spring - 로그인 구현하기

김태훈·2023년 2월 5일
0

회원가입을 구현했으니, 이제는 회원가입한 데이터베이스를 토대로 로그인을 구현해보자.

1. 로그인 화면 구현하기

복습 !

1. 해당 파일은 사용자의 상태를 담을 user라는 리덕스 모듈 기능이다
리덕스 모듈이란 다음 항목들이 액션 타입, 액션 생성함수, 리듀서 를 모두 포함하는 자바스크립트 파일을 의미한다. 여러 리덕스 모듈을 루트리듀서를 통해 combine한다.

1. user 모듈 작성

import { createAction, handleActions } from 'redux-actions';
import { takeLatest } from 'redux-saga/effects';
import * as authAPI from '../lib/api/auth';
import createRequestSaga, {
  createRequestActionTypes
} from '../lib/createRequestSaga';

const TEMP_SET_USER = 'user/TEMP_SET_USER'; // 새로고침 이후 임시 로그인 처리
// 회원 정보 확인
const [CHECK, CHECK_SUCCESS, CHECK_FAILURE] = createRequestActionTypes(
  'user/CHECK'
);

export const tempSetUser = createAction(TEMP_SET_USER, user => user); //파라미터 받는것 명시하는 것뿐이다. (user라는 parameter를 받는다는 뜻)
export const check = createAction(CHECK);

const checkSaga = createRequestSaga(CHECK, authAPI.check);
export function* userSaga() {
  yield takeLatest(CHECK, checkSaga);
}

const initialState = {
  user: null,
  checkError: null
};

export default handleActions(
  {
    [TEMP_SET_USER]: (state, { payload: user }) => ({
      ...state,
      user
    }),
    [CHECK_SUCCESS]: (state, { payload: user }) => ({
      ...state,
      user,
      checkError: null
    }),
    [CHECK_FAILURE]: (state, { payload: error }) => ({
      ...state,
      user: null,
      checkError: error
    })
  },
  initialState
);

당연히 해당 리덕스 모듈을 루트리듀서에 포함시킨다.

다음으로, RegisterForm 파일을 수정하여, 회원 가입 성공 후 check를 호출하여서 현재 사용자가 로그인 상태가 되었는지 확인하는 과정을 거쳐보자.

복습 !

useSelector의 인자는 리덕스모듈로 등록해 놓은 상태들이다. 이 상태를 가져와서 가공 후에, form,auth,authError,user 변수로 넘긴다.
또한 현재 인자로 {auth,user} 가 들어가 있는데, 이는 루트리듀서에서 등록한 리듀서들 중, auth,user라는 상태(리덕스 모듈)를 호출하는 것이다.

2. RegisterForm 작성 및 check api 확인

import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, initializeForm, register } from '../modules/auth';
import AuthForm from '../components/auth/AuthForm';
import {check} from '../modules/user';

const RegisterForm = () => {
  const dispatch = useDispatch(); //스토어에서 컨테이너 컴포넌트를 가져옴
  
  const { form,auth,authError,user } = useSelector(({auth,user})=> ({
    form: auth.register,
    auth:auth.auth,
    authError:auth.authError,
    user:user.user
  }));
  // 인풋 변경 이벤트 핸들러
  const onChange = (e) => {
    const { value, name } = e.target;
    dispatch(
      changeField({
        form: 'register',
        key: name,
        value,
      }),
    );
  };

  // 폼 등록 이벤트 핸들러
  const onSubmit = (e) => {
    e.preventDefault();
    const {username,password,passwordConfirm} = form; // state 에 존재하는 form에 따른 username,password,passwordconfirm key-value를 객체비구조화로 나타냄.
    if (password!== passwordConfirm){
      //오류처리 해야함
      return;
    }
    dispatch(register({username,password}));
  };

  //컴포넌트가 처음 렌더링될 때 form 을 초기화함
  useEffect(()=>{
    dispatch(initializeForm('register'));},[dispatch]);
  
  //회원가입 성공/실패 처리
  useEffect(()=>{
    if (authError){
      console.log('오류 발생');
      console.log(authError);
      return;
    }
    if (auth){
      console.log('회원가입 성공');
      console.log(auth);
      dispatch(check());
    }
  },[auth,authError,dispatch]);

  //user잘 설정됨?
  useEffect(()=>{
    if (user){
      console.log('check API 성공');
      console.log(user);
    }
  },[user]);
  
  return (
    <AuthForm
      type="register"
      form={form}
      onChange={onChange}
      onSubmit={onSubmit}
    />
  );
};

export default RegisterForm;

3. 문제점

axios.get 메소드로 auth/check로 API를 쏘지만, 스프링에서는 해당 작업을 진행할 수 없었다.(내가 무지한걸 수도...) 그래서 회원가입을 하고 성공하면, 로그인 폼으로 넘어가고, 로그인폼에서 로그인까지 진행하는 것으로 변경하였다. 이를 위해서 'user'라는 모듈을 사용하지 않기로 했다. 하지만 지금은 공부하는 단계니까 일단은 다 지우지는 말고, 루트리듀서에 해당 모듈을 지우고 진행하였다.

4. 변경한 RegisterForm

import React, { useEffect } from 'react';
import {useNavigate} from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, initializeForm, register } from '../modules/auth';
import AuthForm from '../components/auth/AuthForm';

const RegisterForm = () => {
  const navigate= useNavigate();
  const dispatch = useDispatch(); //스토어에서 컨테이너 컴포넌트를 가져옴

  const { form, auth, authError} = useSelector(({ auth}) => ({
    form: auth.register,
    auth: auth.auth,
    authError: auth.authError,
    // user: user.user
  }));

  // 인풋 변경 이벤트 핸들러
  const onChange = (e) => {
    const { value, name } = e.target;
    dispatch(
      changeField({
        form: 'register',
        key: name,
        value,
      }),
    );
  };

  // 폼 등록 이벤트 핸들러
  const onSubmit = (e) => {
    e.preventDefault();
    const { username, password, passwordConfirm } = form; // state 에 존재하는 form에 따른 username,password,passwordconfirm key-value를 객체비구조화로 나타냄.
    if (password !== passwordConfirm) {
      //오류처리 해야함
      return;
    }
    dispatch(register({ username, password }));
  };

  //컴포넌트가 처음 렌더링될 때 form 을 초기화함
  useEffect(() => {
    dispatch(initializeForm('register'));
  }, [dispatch]);

  //회원가입 성공/실패 처리
  useEffect(() => {
    if (authError) {
      console.log('오류 발생');
      console.log(authError);
      return;
    }
    if (auth){
      window.alert('회원가입 성공!');
      console.log('회원가입 성공');
      dispatch(initializeForm('register'));
      console.log(auth);
      navigate('/login');
      // dispatch(check());
    }
  }, [auth, authError,dispatch]);

  //user잘 설정됨?
  // useEffect(()=>{
  //   if (user){
  //     console.log('check API 성공');
  //     console.log(user);
  //   }
  // },[user]);
  
  return (
    <AuthForm
      type="register"
      form={form}
      onChange={onChange}
      onSubmit={onSubmit}
    />
  );
};

export default RegisterForm;

회원가입 성공/실패 부분을 보면, initializeForm 상태 함수를 dispatch하는 것을 볼 수 있다. 해당 부분은 'auth'모듈에서 InitializeForm 상태함수에 약간의 변형을 주어, auth부분을 초기화 하였다. 코드는 다음과 같다.

const auth = handleActions(// createAction생성시, 인자가 존재하면 payload를 추가로 붙여줘야함.
  {
    [CHANGE_FIELD]: (state, { payload: { form, key, value } }) =>
      produce(state, draft => {
        draft[form][key] = value; // 예: state.register.username을 바꾼다
      }),
    [INITIALIZE_FORM]: (state, { payload: form }) => ({ //이 payload는 이미 createAction할 때 건내준 인자임.
      ...state,
      [form]: initialState[form],
      auth:null, //이렇게 정의해야 로그인화면으로 넘어가지 않는다!!
      authError: null // 폼 전환 시 회원 인증 에러 초기화
    }),
    // 회원가입 성공
    [REGISTER_SUCCESS]: (state, { payload: auth }) => ({ //saga에서 정의한 register임 따라서 payload는 saga내 비동기 함수 response.data로 들어감.(api 결과)
      // 따로 인자를 주지 않아도, saga에서 이미 정의가 되어진 것임.
      ...state,
      authError: null,
      auth //스프링에서 쏜 member객체가 auth로 들어감
    }),
    // 회원가입 실패
    [REGISTER_FAILURE]: (state, { payload: error }) => ({
      ...state,
      authError: error
    }),
    // 로그인 성공
    [LOGIN_SUCCESS]: (state, { payload: auth }) => ({
      ...state,
      authError: null,
      auth
    }),
    // 로그인 실패
    [LOGIN_FAILURE]: (state, { payload: error }) => ({
      ...state,
      authError: error
    })
  },
  initialState
);

하나 더 말하자면,
handleActions에서, payload부분은, createAction할 때, 파라미터로 준 것과 일치한다.
하지만 RegisterSuccess,LoginSuccess등, 회원가입, 로그인 상태함수들은 모두, createRequestSaga로 비동기화 처리를 진행한 상태이다.(즉, API 서버에 쏘고, 받는 상태함수이다) 따라서, 해당 함수들은 서버에서 받는 response가 곧 payload로 넘어간다. 나는 스프링에서 Controller측에서, Member라는 객체를 클라이언트로 전송하였기때문에, 해당 함수들의 payload는 서버에서 받은 Member 객체가 된다.

5. LoginForm + 회원 인증 에러 처리하기

먼저 회원인증 에러를 처리하는 메세지를 보여주기 위한 UI를 준비하자
AuthForm 컴포넌트를 먼저 수정하자.

  • AuthForm
import React from 'react';
import styled from 'styled-components';
import { Link } from 'react-router-dom';
import palette from '../../lib/styles/palette';
import Button from '../common/button';

/**
 * 회원가입 또는 로그인 폼을 보여줍니다.
 */

const AuthFormBlock = styled.div`
  h3 {
    margin: 0;
    color: ${palette.gray[8]};
    margin-bottom: 1rem;
  }
`;

/**
 * 스타일링된 input
 */
const StyledInput = styled.input`
  font-size: 1rem;
  border: none;
  border-bottom: 1px solid ${palette.gray[5]};
  padding-bottom: 0.5rem;
  outline: none;
  width: 100%;
  &:focus {
    color: $oc-teal-7;
    border-bottom: 1px solid ${palette.gray[7]};
  }
  & + & {
    margin-top: 1rem;
  }
`;

/**
 * 폼 하단에 로그인 혹은 회원가입 링크를 보여줌
 */
const Footer = styled.div`
  margin-top: 2rem;
  text-align: right;
  a {
    color: ${palette.gray[6]};
    text-decoration: underline;
    &:hover {
      color: ${palette.gray[9]};
    }
  }
`;

const ButtonWithMarginTop = styled(Button)`
  margin-top: 1rem;
`;

const textMap = {
  login: '로그인',
  register: '회원가입'
};

/**
 * 에러를 보여줍니다
 */
const ErrorMessage = styled.div`
  color: red;
  text-align: center;
  font-size: 0.875rem;
  margin-top: 1rem;
`;

const AuthForm = ({ type, form, onChange, onSubmit, error }) => {
  const text = textMap[type];
  return (
    <AuthFormBlock>
      <h3>{text}</h3>
      <form onSubmit={onSubmit}>
        <StyledInput
          autoComplete="username"
          name="username"
          placeholder="아이디"
          onChange={onChange}
          value={form.username}
        />
        <StyledInput
          autoComplete="new-password"
          name="password"
          placeholder="비밀번호"
          type="password"
          onChange={onChange}
          value={form.password}
        />
        {type === 'register' && (
          <StyledInput
            autoComplete="new-password"
            name="passwordConfirm"
            placeholder="비밀번호 확인"
            type="password"
            onChange={onChange}
            value={form.passwordConfirm}
          />
        )}
        {error && <ErrorMessage>{error}</ErrorMessage>}
        <ButtonWithMarginTop cyan fullWidth style={{ marginTop: '1rem' }}>
          {text}
        </ButtonWithMarginTop>
      </form>
      <Footer>
        {type === 'login' ? (
          <Link to="/register">회원가입</Link>
        ) : (
          <Link to="/login">로그인</Link>
        )}
      </Footer>
    </AuthFormBlock>
  );
};

export default AuthForm;

AuthForm의 함수 파라미터로 error가 들어가있는데, 이는 props로 error값을 받아왔을때 error메세지를 렌더링해주기 위한 역할이다.

  • LoginForm
import React, { useEffect, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { changeField, initializeForm, login } from '../modules/auth';
import AuthForm from '../components/auth/AuthForm';
// import { check } from '../../modules/user';
import { useNavigate } from 'react-router-dom';

const LoginForm = () => {
  const navigate = useNavigate();
  const [error, setError] = useState(null);
  const dispatch = useDispatch();
  const { form, auth, authError} = useSelector(({ auth }) => ({
    form: auth.login,
    auth: auth.auth,
    authError: auth.authError,
    // user: user.user,
  }));
  // 인풋 변경 이벤트 핸들러
  const onChange = (e) => {
    const { value, name } = e.target;
    dispatch(
      changeField({
        form: 'login',
        key: name,
        value,
      }),
    );
  };

  // 폼 등록 이벤트 핸들러
  const onSubmit = (e) => {
    e.preventDefault();
    const { username, password } = form;
    dispatch(login({ username, password }));
  };

  // 컴포넌트가 처음 렌더링 될 때 form 을 초기화함
  useEffect(() => {
    dispatch(initializeForm('login'));
  }, [dispatch]);

  useEffect(() => {
    if (authError) {
      console.log('오류 발생');
      console.log(authError);
      setError('로그인 실패');
      return;
    }
    if (auth) {
      window.alert('로그인 성공!');
      console.log('로그인 성공');
      navigate('/');
      try {
        localStorage.setItem('auth', JSON.stringify(auth));
      } catch (e) {
        console.log('localStorage is not working');
      }
      // dispatch(check());
    }
  }, [auth, authError, dispatch]);

  // useEffect(() => {
  //   if (user) {
  //     navigate('/');
  //     try {
  //       localStorage.setItem('user', JSON.stringify(user));
  //     } catch (e) {
  //       console.log('localStorage is not working');
  //     }
  //   }
  // }, [navigate, user]);

  return (
    <AuthForm
      type="login"
      form={form}
      onChange={onChange}
      onSubmit={onSubmit}
      error={error}
    />
  );
};

export default LoginForm;
  • RegisterForm
import React, { useEffect,useState } from 'react';
import {useNavigate} from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { changeField, initializeForm, register } from '../modules/auth';
import AuthForm from '../components/auth/AuthForm';

const RegisterForm = () => {
  const [error,setError] = useState(null);
  const navigate= useNavigate();
  const dispatch = useDispatch(); //스토어에서 컨테이너 컴포넌트를 가져옴

  const { form, auth, authError} = useSelector(({ auth}) => ({
    form: auth.register,
    auth: auth.auth,
    authError: auth.authError,
    // user: user.user
  }));

  // 인풋 변경 이벤트 핸들러
  const onChange = (e) => {
    const { value, name } = e.target;
    dispatch(
      changeField({
        form: 'register',
        key: name,
        value,
      }),
    );
  };

  // 폼 등록 이벤트 핸들러
  const onSubmit = (e) => {
    e.preventDefault();
    const { username, password, passwordConfirm } = form; // state 에 존재하는 form에 따른 username,password,passwordconfirm key-value를 객체비구조화로 나타냄.
     // 하나라도 비어있다면
     if ([username, password, passwordConfirm].includes('')) {
      setError('빈 칸을 모두 입력하세요.');
      return;
    }
    // 비밀번호가 일치하지 않는다면
    if (password !== passwordConfirm) {
      setError('비밀번호가 일치하지 않습니다.');
      dispatch(changeField({ form: 'register', key: 'password', value: '' }));
      dispatch(
        changeField({ form: 'register', key: 'passwordConfirm', value: '' }),
      );
      return;
    }
    dispatch(register({ username, password }));
  };

  //컴포넌트가 처음 렌더링될 때 form 을 초기화함
  useEffect(() => {
    dispatch(initializeForm('register'));
  }, [dispatch]);

  //회원가입 성공/실패 처리
  useEffect(() => {
    if (authError) {
      // 계정명이 이미 존재할 때
      if (authError.response.status === 409) {
        setError('이미 존재하는 계정명입니다.');
        return;
      }
      // 기타 이유
      setError('회원가입 실패');
      return;
    }
    if (auth){
      window.alert('회원가입 성공!');
      console.log('회원가입 성공');
      dispatch(initializeForm('register'));
      console.log(auth);
      navigate('/login');
      // dispatch(check());
    }
  }, [auth, authError,dispatch]);

  //user잘 설정됨?
  // useEffect(()=>{
  //   if (user){
  //     console.log('check API 성공');
  //     console.log(user);
  //   }
  // },[user]);
  
  return (
    <AuthForm
      type="register"
      form={form}
      onChange={onChange}
      onSubmit={onSubmit}
      error={error}
    />
  );
};

export default RegisterForm;

6. 로그인 구현하기

본격적으로 로그인을 구현해보자.

먼저, 로그인을 구현하기 위해서 '세션'을 사용하기로 했다. cookie를 사용하는 방법은 보안상의 이유로 사용할 수 없었다. 하지만 그렇다고 Session을 써보지도 않은 상태에서 벌써부터 SpringSecurity를 다루는 것은 욕심이라고 생각했다.

1. repository에서 유저가 입력한 id를 찾고, 해당 id에 따른 password가 일치하는지 확인

  • MemberRepository 인터페이스
package com.example.SpringAndReact.Repository;

import com.example.SpringAndReact.Domain.Member;

import java.util.Optional;

public interface MemberRepository {
    Member saveMember(Member member);
    Optional<Member> findByUserId(String id);
    Optional<Member> findMember(String id, String password);
}
  • 기존 Memory를 활용한 MemberRepository 구현체
package com.example.SpringAndReact.Repository;

import com.example.SpringAndReact.Domain.Member;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class MemoryMemberRepository implements MemberRepository {
    private static Map<Long,Member> store = new HashMap<>();
    private static long sequence = 0L;

    @Override
    public Member saveMember(Member member) {
        member.setId(++sequence);
        store.put(member.getId(), member);
        return member;
    }

    @Override
    public Optional<Member> findByUserId(String id) {
        return store.values().stream().filter(member->member.getUsername().equals(id)).findAny();
    }

    @Override
    public Optional<Member> findMember(String id, String password) {
        return store.values().stream()
                .filter(member -> (member.getUsername().equals(id) && member.getPassword().equals(password)))
                .findAny();
    }
}
  • JpaMemberRepository 구현체
package com.example.SpringAndReact.Repository;

import com.example.SpringAndReact.Domain.Member;
import jakarta.persistence.EntityManager;
import jakarta.persistence.TypedQuery;

import java.util.List;
import java.util.Optional;

public class JpaMemberRepository implements MemberRepository{
    private final EntityManager em;

    public JpaMemberRepository(EntityManager em) {
        this.em = em;
    }

    @Override
    public Member saveMember(Member member) {
        em.persist(member);
        return member;
    }

    @Override
    public Optional<Member> findByUserId(String id) {
        List<Member> result = em.createQuery("select m from Member m where m.username = :name", Member.class)
                .setParameter("name", id)
                .getResultList();
        return result.stream().findAny();
    }

    @Override
    public Optional<Member> findMember(String id, String password) {
        if(!findByUserId(id).isPresent()){
            try{
                throw new IllegalAccessException("세상에 그런 아이디는 등록된 적이 없어요!");
            }catch(IllegalAccessException e){
                throw new RuntimeException(e);
            }
        }
        TypedQuery<Member> query = em.createQuery("select m from Member m where m.username = :name and m.password = :password", Member.class);
        query.setParameter("name",id);
        query.setParameter("password",password);
        List<Member> resultList = query.getResultList();
        if (resultList.isEmpty()){
            try{
                throw new IllegalAccessException("password를 다시 확인해 주세요!");
            } catch(IllegalAccessException e){
                throw new RuntimeException(e);
            }
        }
        return resultList.stream().findAny();
    };
}

다음과 같이 "JPQL" 쿼리문을 직접 작성해보는 연습을 하는겸, 쿼리를 직접 짜보았다.

2. Service와 Repository 를 이용하여 로그인 정보 확인하기

  • MemberSerivce 인터페이스
package com.example.SpringAndReact.Service;

import com.example.SpringAndReact.Domain.Member;

import java.util.Optional;


public interface MemberService {
    Long join(Member member);
    void validateDuplicateMember(Member member);
    Optional<Member> checkIdPassword(Member member);
}
  • MemberServiceImpl (구현체)
package com.example.SpringAndReact.Service;

import com.example.SpringAndReact.Domain.Member;
import com.example.SpringAndReact.Repository.MemberRepository;
import jakarta.transaction.Transactional;

import java.util.Optional;

@Transactional
public class MemberServiceImpl implements MemberService {
    private final MemberRepository memberRepository;

    public MemberServiceImpl(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public Long join(Member member) {
        validateDuplicateMember(member);
        memberRepository.saveMember(member);
        return member.getId(); //고유 id 반환
    }

    @Override
    public void validateDuplicateMember(Member member) {
        memberRepository.findByUserId(member.getUsername()).ifPresent(mem -> {
            try {
                throw new IllegalAccessException("이미 존재하는 회원입니다.");
            } catch (IllegalAccessException e) {
                throw new RuntimeException(e);
            }
        });
    }

    @Override
    public Optional<Member> checkIdPassword(Member member) {
        Optional<Member> resultMember = memberRepository.findMember(member.getUsername(), member.getPassword());
        if (!resultMember.isPresent()){
            try{
                throw new IllegalAccessException("아이디, 비밀번호를 다시 확인해주세요.");
            } catch(IllegalAccessException e){
                throw new RuntimeException(e);
            }
        }
        return resultMember;
    }
}
profile
기록하고, 공유합시다

0개의 댓글