[SideProject] - 개인 프로젝트 - TeddyLog 02. Auth


먼저 로그인 및 회원가입 기능을 만들어보려고 한다.

firebase Auth를 쓰기위해 firebase 셋팅부터 해주자.

셋팅방법은 생략하도록 하겠다.

로그인 화면, 회원가입 화면을 먼저 디자인해주었다.

antd를 활용해 빠르게 만들어보았다.

로그인 화면


스크린샷 2022-08-15 오전 8.14.55.png

회원가입 화면

스크린샷 2022-08-15 오전 8.17.16.png

스크린샷 2022-08-15 오전 8.19.25.png

이제 회원가입 기능을 redux-toolkit을 활용해서 만들어보자.

Redux-toolkit이란?


Redux Toolkit (리덕스 툴킷)은 정말 천덕꾸러기일까?

Redux의 표준을 제시하는 라이브러리이다.

  • configureStore
    • 설정을 간편하게 해준다.
  • createSlice
    • createAction ,immer등 자동으로 해주는 부분들이 많아 보일러플레이트가 줄어든다.
  • createAsyncThunk
    • 비동기 처리해주는 미들웨어인 redux-thunk를 기본적으로 지원해주고 자체적으로 생성해주는 함수를 지원해준다.

Redux를 써보며 recoil보다 코드량도 많아지고 반복작업이 많다고 느껴서 redux-toolkit을 써보려고 한다.

Auth 파트 개발


feature/auth/authSlice.ts


export interface AuthState {
  token: string | null;
  uid: string | null;
  email: string | null;

  loginLoading: AsyncType;
  loginError: string | null;

  signUpError: string | null;
  signUpLoading: AsyncType;
}

const initialState: AuthState = {
  token: null,
  uid: null,
  email: null,

  loginLoading: 'idle',
  loginError: null,

  signUpError: null,
  signUpLoading: 'idle',
};

fireauth로 로그인해야하니 받아올 token과 uid, email

그외에는 서버통신 로딩을 위한 변수들로 만들었다.

AsyncType은 아래와 같이 정의해놓았다.

export type AsyncType = 'idle' | 'pending' | 'succeeded' | 'failed';

회원가입 요청


feature/auth/authSlice.ts

export const fetchSignUpRequest = createAsyncThunk(
  'auth/fetchSignUpRequest',
  async ({email, password}: ISignUpRequest, { rejectWithValue }) => {
    try {
      const credential = await createUserWithEmailAndPassword(
        getAuth(),
        email,
        password
      );
      return {
        token: await credential.user.getIdToken(),
        email: credential.user.email,
        uid: credential.user.uid,
      };
    } catch (err) {
      throw rejectWithValue('회원가입 실패');
    }
  }
);
export const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchSignUpRequest.pending, (state, action) => {
        state.signUpError = null;
        state.signUpLoading = 'pending';
      })
      .addCase(fetchSignUpRequest.fulfilled, (state, action) => {
        state.signUpError = null;
        state.signUpLoading = 'succeeded';

        state.token = action.payload.token;
        state.email = action.payload.email;
        state.uid = action.payload.uid;
      })
      .addCase(fetchSignUpRequest.rejected, (state, action) => {
        state.signUpLoading = 'failed';
        state.signUpError = action.payload as string;

        state.token = null;
        state.email = null;
        state.uid = null;
      });
  },
});

회원가입은 fireAuth에 있는 createUserWithEmailAndPassword 함수를 통해서 진행하였다.

token과 email, uid를 반환하였고

여기서 추가적인 유저정보 (nickname, introduce)를 받는것은 다음 화면에서 진행하도록 하였다.

redux-toolkit의 slice는

reducer 말고 extraReducres로 비동기 요청들을 정리할 수 있다.

createAsyncThunk로 객체를 만들면 redux-toolkit 자체적으로

  • pending
  • fulfilled
  • rejected

의 상태를 만들어주기 때문에 편하게 사용이 가능하다.

또한 추가적인 불변성 라이브러리 설치 없이 자동으로 불변성을 유지하며 값을 바꿀 수 있다.

유저 정보 생성 요청


feature/user/userSlice.ts

export interface UserState {
    email: string | null;
    nickname: string | null;
    introduce: string | null;

    fetchLoading: AsyncType;
    fetchError: string | null;

    updateLoading: AsyncType;
    updateError: string | null;
}

const initialState: UserState = {
    email: null,
    nickname: null,
    introduce: null,

    fetchLoading: 'idle',
    fetchError: null,

    updateLoading: 'idle',
    updateError: null,
};
export const updateUserInfo = createAsyncThunk(
  'user/updateUserInfo',
  async (updateUserRequest: IUpdateUserRequest, { rejectWithValue }) => {
    try {
      const { uid, email, nickname, introduce } = updateUserRequest;
      if (uid === null || email == null) {
        throw rejectWithValue('유저 정보 오류');
      }

      const userCollection = doc(db, 'Users', uid);
      const userSnap = await getDoc(userCollection);
      if (userSnap.exists()) {
        await updateDoc(userSnap.ref, {
          email,
          nickname,
          introduce,
        });
      } else {
        await setDoc(userCollection, {
          email,
          nickname,
          introduce,
        });
      }

      return {
        email,
        uid,
        nickname,
        introduce,
      };
    } catch (err) {
      throw rejectWithValue('유저 정보 요청 실패');
    }
  }
);
export const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(updateUserInfo.pending, (state, action) => {
        state.updateError = null;
        state.updateLoading = 'pending';
      })
      .addCase(updateUserInfo.fulfilled, (state, action) => {
        state.updateError = null;
        state.updateLoading = 'succeeded';
        state.introduce = action.payload.introduce;
        state.nickname = action.payload.nickname;
      })
      .addCase(updateUserInfo.rejected, (state, action) => {
        state.updateLoading = 'failed';
        state.updateError = action.payload as string;
        state.introduce = null;
        state.nickname = null;
      });
  },
});

회원가입 요청 이후 유저 정보 페이지에서 추가적인 (nickname, introduce) 정보를 입력하게 된다.

이렇게 생성된 유저로 로그인을 만들어보자.

로그인 요청


feature/auth/authSlice.ts

...

export const fetchLoginRequest = createAsyncThunk(
  'auth/fetchLoginRequest',
  async ({ email, password }: ILoginRequest, { rejectWithValue, dispatch }) => {
    try {
      const credential = await signInWithEmailAndPassword(
        getAuth(),
        email,
        password
      );

      return {
        token: await credential.user.getIdToken(),
        email: credential.user.email,
        uid: credential.user.uid,
      };
    } catch (err) {
      throw rejectWithValue('로그인 실패');
    }
  }
);

...

fireAuth의 함수를 활용해 받아온 email과 password로 로그인 요청을 하였다.

error가 날 경우 ‘로그인 실패’ 라는 문구를 반환하게 하였다.

export const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchLoginRequest.pending, (state, action) => {
        state.loginError = null;
        state.loginLoading = 'pending';
      })
      .addCase(fetchLoginRequest.fulfilled, (state, action) => {
        console.log('data!');
        console.log(action.payload);
        state.loginError = null;
        state.email = action.payload.email;
        state.token = action.payload.token;
        state.uid = action.payload.uid;
        state.loginLoading = 'succeeded';
      })
      .addCase(fetchLoginRequest.rejected, (state, action) => {
        state.loginError = action.payload as string;
        state.loginLoading = 'failed';
        state.email = null;
        state.token = null;
        state.uid = null;
      });
  },
});

여기서 개발하다가 내가 만난 고민은 아래와 같다.

나는 유저 정보와 auth 정보를 분리하고 싶다. 왜냐하면 userInfo는 firestore에 저장되어있고 auth는 fireauth에서 관리하기 때문에 실제 유저 정보를 따로 가져와서 관리하고 싶었다.

즉, 로그인 이후 유저 정보를 다시 요청해야 했다. (dispatch다시 한번 더)

유저 정보를 가져오는 userSlice를 먼저 만들어보자.

유저 정보 요청


export interface UserState {
    email: string | null;
    nickname: string | null;
    introduce: string | null;

    fetchLoading: AsyncType;
    fetchError: string | null;

    updateLoading: AsyncType;
    updateError: string | null;
}

const initialState: UserState = {
    email: null,
    nickname: null,
    introduce: null,

    fetchLoading: 'idle',
    fetchError: null,

    updateLoading: 'idle',
    updateError: null,
};

state는 위와 같다.

export const fetchUserInfoRequest = createAsyncThunk(
  'user/fetchUserInfoRequest',
  async (userRequest: IUserRequest, { rejectWithValue }) => {
    try {
      const { uid, email } = userRequest;
      const userCollection = doc(db, 'Users', uid);
      const userSnap = await getDoc(userCollection);

      if (userSnap.exists()) {
        const data = userSnap.data();

        return {
          email,
          nickname: data.nickname,
          introduce: data.introduce,
        };
      } else {
        return {
          email,
          nickname: null,
          introduce: null,
        };
      }
    } catch (err) {
      throw rejectWithValue('유저 정보 요청 실패');
    }
  }
);

유저 정보를 가져오는 부분을 위코드 처럼 작성하였다.

문제는 이 부분을 누가 dispatch 해주는가 였다.

방법으로는 2가지가 있었다. (더 있을지도…)

  1. 화면에서 호출하기

pages/login/index.tsx

const onFinish = async (data: any) => {
    form.resetFields(['password']);
    const { email, uid } = await dispatch(
      // @ts-ignore
      fetchLoginRequest({ email: data.email, password: data.password })
    );
    dispatch(
      // @ts-ignore
      fetchUserInfoRequest({ email, uid })
    );
  };

dispatch를 await하여 결과를 받아오고 그 결과를 다시 dispatch 하는 방식이다.

  1. thunks 안에서 dispatch 하기

feature/auth/authSlice.ts

export const fetchLoginRequest = createAsyncThunk(
  'auth/fetchLoginRequest',
  async ({ email, password }: ILoginRequest, { rejectWithValue, dispatch }) => {
    try {
      const credential = await signInWithEmailAndPassword(
        getAuth(),
        email,
        password
      );

      dispatch(
        fetchUserInfoRequest({
          email: credential.user.email,
          uid: credential.user.uid,
        })
      );
      return {
        token: await credential.user.getIdToken(),
        email: credential.user.email,
        uid: credential.user.uid,
      };
    } catch (err) {
      throw rejectWithValue('로그인 실패');
    }
  }
);

위와 같이 하게 되면 화면에서 dispatch는 한개로 끝난다는 장점이 있다.

나는 위 2개의 선택지에서 2번째를 선택하여 개발을 진행하였다.

화면 코드에서 로직의 흐름을 보여주기보다 action 코드 안으로 넣는게 맞다고 생각했다.

로그인 기능이 정상동작함을 확인했으니

이제 habbit 기능을 만들어보자