Vue 3 + Firebase 기반 실시간 채팅 앱 개발

7. Vue 3 + Firebase 기반 실시간 채팅 앱 v-chat 개발 - 로그인, 로그아웃, 자동 로그인

그랜파 개발자 2025. 5. 7. 10:11

로그인, 로그아웃, 자동 로그인

Firebase Authentication의 signInWithEmailAndPassword() 함수를 사용하여 이메일과 비밀번호로 로그인합니다.
signOut() 함수로 로그아웃을 하고,
signInWithEmailAndPassword()는 onAuthStateChanged() 함수로 로그인 상태를 알 수 있습니다.

 

로그인에 성공하면 전체 등록된 사용자의 프로필을 가져옵니다. 이때 로그인한 사용자의 프로필도 가져옵니다.
로그인 버튼과 로그아웃 버튼을 App-bar에 둡니다.
로그인 상태에 따라 메뉴 항목이 다르게 나타납니다.

Firebase Authentication 사용자 인증

signInWithEmailAndPassword()는

Firebase Authentication에서 사용자가 이메일과 비밀번호로 로그인할 수 있게 해주는 함수로서
createUserWithEmailAndPassword()로 생성된 계정에 대해 이메일과 비밀번호를 사용해 로그인하는 데 사용합니다.

signOut()은

Firebase Authentication에서 제공하는 함수로, 현재 로그인된 사용자를 로그아웃시키는 역할을 합니다.
Firebase Authentication에서 로그아웃하려면 signOut() 함수를 사용합니다.

  • Firebase의 인증 세션을 종료시킵니다.
  • 브라우저에 저장된 로그인 정보를 제거합니다.
  • 로그아웃 후에는 getAuth().currentUser가 null이 됩니다.

onAuthStateChanged는

Firebase Authentication에서 현재 로그인된 사용자의 상태를 실시간으로 감지할 수 있게 해주는 리스너 함수입니다.
사용자가 로그인하거나 로그아웃할 때마다 자동으로 실행됩니다.
앱이 시작될 때 로그인 상태인지 아닌지를 확인하는 데 가장 중요한 함수입니다.

사용자 인증

로그인은 로그인 페이지에서 합니다.
로그 아웃은 앱의 app-bar에 로그아웃 버튼이 있어 이것을 누르면 로그아웃 합니다.
로그인 상태는 앱을 시작할 때 main.js에서 onAuthStateChanged로 확인합니다.

로그인 전과 후의 메뉴 항목은 다릅니다.

로그인, 로그아웃 - App.vue의 template

Login.vue

<!-- src/views/Login.vue -->
<template>
  <v-container fluid>
    <v-row align="center" justify="center">
      <v-col cols="12" sm="10" md="6">
        <v-card elevation="10" class="pa-4">
          <v-card-title class="text-h6 font-weight-bold">로그인</v-card-title>

          <v-card-text>
            <v-form @submit.prevent="onLogin" v-model="formValid" ref="formRef">
              <v-text-field
                v-model="email"
                label="이메일"
                type="email"
                :rules="emailRules"
                prepend-inner-icon="mdi-email"
                required
              />

              <v-text-field
                v-model="password"
                label="비밀번호"
                type="password"
                :rules="passwordRules"
                prepend-inner-icon="mdi-lock"
                required
              />

              <v-btn :loading="auth.loading" type="submit" color="primary" class="mt-4" block>
                로그인
              </v-btn>
            </v-form>
          </v-card-text>

          <v-card-actions class="justify-center">
            <RouterLink to="/register">계정이 없으신가요?</RouterLink>
          </v-card-actions>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>

<script setup>
import { ref } from 'vue';
import { useAuthStore } from '@/stores/authStore';

const auth = useAuthStore();

const email = ref('');
const password = ref('');
const formRef = ref(null);
const formValid = ref(false);

const emailRules = [
  (v) => !!v || '이메일을 입력해주세요.',
  (v) => /.+@.+\..+/.test(v) || '올바른 이메일 형식이 아닙니다.',
];

const passwordRules = [
  (v) => !!v || '비밀번호를 입력해주세요.',
  (v) => v.length >= 6 || '비밀번호는 최소 6자 이상이어야 합니다.',
];

const onLogin = () => {
  if (formRef.value?.validate()) {
    auth.login(email.value, password.value);
  }
};
</script>
 

로그인, 로그아웃, 자동 로그인 - authStore

  // 로그인
  const login = async (email, password) => {
    try {
      loading.value = true;
      const userCredential = await signInWithEmailAndPassword(auth, email, password);
      user.value = userCredential.user;
      fetchProfiles();      
      router.push('/');
    } catch (error) {
      alert('로그인 실패: ' + error.message);
    } finally {
      loading.value = false;
    }
  };

  // 로그아웃
  const logout = async () => {
    try {
      if (user.value) { 
        await signOut(auth);        
        router.push('/');          

        user.value = null;      
      }
    } catch (error) {
      alert('로그아웃 실패: ' + error.message);
    }
  };

  // 로그인 상태 유지
  const initializeAuth = () => {    
    onAuthStateChanged(auth, async (currentUser) => {
      user.value = currentUser;
      if (currentUser) {
        fetchProfiles();
      }
    });
  };

자동 로그인 - main.js

// src/main.js
import { createApp } from 'vue';
import App from './App.vue';
import { createPinia } from 'pinia';  // Pinia import
import 'vuetify/styles';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import { aliases, mdi } from 'vuetify/iconsets/mdi' // 추가
import '@mdi/font/css/materialdesignicons.css'
import router from './router';
import { useAuthStore } from "@/stores/authStore";

const vuetify = createVuetify({
  components,
  directives,
  icons: {
    defaultSet: 'mdi', // 기본 아이콘 셋을 mdi로 설정
    aliases,
    sets: {
      mdi,
    },
  },
});

const app = createApp(App);

app.use(createPinia());  // Pinia 플러그인 사용
app.use(vuetify);
app.use(router);

const authStore = useAuthStore();
authStore.initializeAuth(); // 자동 로그인 실행

app.mount('#app');

App.vue

<!-- src/App.vue -->
<template>
  <v-app>
    <!-- App Bar -->
    <v-app-bar app color="primary" dark>
      <v-app-bar-nav-icon @click="drawer = !drawer" />
      <v-toolbar-title>실시간 채팅</v-toolbar-title> 

      <v-spacer />

      <div v-if="!auth.isAuthenticated">
        <v-btn icon to="/login" title="로그인">
          <v-icon>mdi-login</v-icon>
        </v-btn>
      </div>
      <div v-else>
        <v-btn icon @click="auth.logout" title="로그아웃">
          <v-icon>mdi-logout</v-icon>
        </v-btn>
      </div>
    </v-app-bar>

    <!-- Navigation Drawer -->
    <v-navigation-drawer app v-model="drawer">
      <v-list nav dense>
        <v-list-item
          v-for="item in menuItems"
          :key="item.title"
          :to="item.to"
          link
          @click="drawer = !drawer" 
        >
          <v-list-item-title>
            <v-icon start>{{ item.icon }}</v-icon> 
            {{ item.title }}
          </v-list-item-title>
        </v-list-item>
      </v-list>
    </v-navigation-drawer>

    <!-- Main Content -->
    <v-main>
      <v-container>
        <router-view />
      </v-container>      
    </v-main>

    <!-- Footer -->
    <v-footer app color="primary" dark>
      <v-col class="text-center">&copy; 2025 My App</v-col>
    </v-footer>
  </v-app>
</template>

<script setup>
import { ref, computed } from 'vue';
import { useAuthStore } from '@/stores/authStore';

const auth = useAuthStore();

const drawer = ref(false);

// 메뉴 항목 배열
// 로그인 상태에 따라 메뉴 항목 다르게 구성
const menuItems = computed(() => {
  if (auth.user) {
    return [
      { title: '홈', icon: 'mdi-home', to: '/' },
      { title: '채팅룸', icon: 'mdi-chat', to: '/chatList' },
      { title: '새채팅', icon: 'mdi-chat-plus', to: '/startChat' },
      { title: '계정 설정', icon: 'mdi-account-cog', to: '/profile' },      
    ];
  } else {
    return [
      { title: '홈', icon: 'mdi-home', to: '/' },
      { title: '로그인', icon: 'mdi-login', to: '/login' },
      { title: '계정 만들기', icon: 'mdi-account-plus', to: '/register' },
    ];
  }
});

</script>

<style scoped>
.main-content {
  display: flex;
  flex-direction: column;
  min-height: calc(100vh - 64px - 64px); /* AppBar와 Footer의 높이 */
}
</style>