이전 글에서 이어서 작성하는 프론트 코드
1편을 보지 않았다면 먼저 1편부터 읽고 오시기를 바랍니다.
🎨 프론트앤드
public
├─src
├─assets
├─components
├─plugins
├─routers
│ └─routes
└─stores
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve --port 8085",
"build": "vue-cli-service build --watch",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@mdi/font": "^7.4.47",
"@stomp/stompjs": "^7.0.0",
"axios": "^1.7.4",
"core-js": "^3.8.3",
"pinia": "^2.2.2",
"pinia-plugin-persistedstate": "^4.0.0-alpha.4",
"qs": "^6.13.0",
"sockjs-client": "^1.6.1",
"stompjs": "^2.3.3",
"vue": "^3.2.13",
"vue-router": "^4.0.13"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"vite-plugin-vuetify": "^2.0.4",
"vuetify": "^3.7.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
}
🟢 outputDir : 빌드된 Vue 프로젝트의 결과물인 정적 파일이 저장되는 디렉토리를 지정한다.
🟢 devServer : 뷰의 개발서버 설정을 가지고 있다.
🟢 proxy : devServer가 백엔드 서버로 API 요청을 전달하도록 설정한다. 프록시 서버는 지정된 경로의 모든 요청을 자동으로 백엔드 서버로 포워딩한다.
const { defineConfig } = require("@vue/cli-service");
module.exports = defineConfig({
transpileDependencies: true,
outputDir: "../src/main/resources/static",
devServer: {
port: 8085,
proxy: {
"/": {
target: "http://localhost:8084",
changeOrigin: true,
logLevel: "debug",
ws: false,
pathRewirte: {
"^/": "/",
},
},
},
},
});
<template>
<NavigationBar v-if="!$route.meta.hideNav" />
<router-view />
</template>
<script>
import NavigationBar from "@/components/NavigationBar.vue";
export default {
name: "App",
components: { NavigationBar },
};
</script>
<style></style>
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import vuetify from "./plugins/vuetify";
import routers from "./routers";
import axios from "axios";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
const app = createApp(App);
const pinia = createPinia(App);
pinia.use(piniaPluginPersistedstate);
app.use(vuetify);
app.use(routers);
app.use(pinia);
app.provide("$axios", axios);
app.mount("#app");
경로 src/routers
🟢 조건별 경로 처리 로직
- 회원가입 페이지로의 접근 허용 : 인증되지 않은 상태에서 SignupComponent로 이동하려면 그대로 접근을 허용한다.
- 로그인 페이지가 아닌 경로 접근 시 리다이렉트 : 인증되지 않은 상태에서 /login 이외의 경로에 접근할 경우 로그인 페이지(LoginComponent)로 리다이렉트
- 이미 인증된 상태에서 로그인 페이지 접근 제한 : 이미 인증된 상태에서 다시 로그인 페이지로 이동하려고 하면 메인 페이지(HomeComponent)로 리다이렉트한다.
- 그 외의 경우 : 조건에 해당하지 않으면 원래 이동하려던 경로로 이동을 허용한다.
import { createWebHistory, createRouter } from "vue-router";
import autenticateIndex from "./routes/autenticateIndex";
const routes = [
{
path: "/",
redirect: { name: "HomeComponent" },
},
...autenticateIndex,
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to, _, next) => {
const isAuthenticated = !!localStorage.getItem("userData");
if (!isAuthenticated && to.name === "SignupComponent") {
return next();
} else if (!isAuthenticated && to.name !== "LoginComponent") {
// 인증되지 않았고, /login 이외의 경로에 접근할 경우 /login으로 리다이렉트
return next({ name: "LoginComponent" });
} else if (isAuthenticated && to.name === "LoginComponent") {
// 이미 인증되었고, /login으로 이동하려고 하면 /main으로 리다이렉트
return next({ name: "HomeComponent" });
} else {
return next();
}
});
export default router;
경로 src/routers/routes
import HomeComponent from "@/components/HomeComponent.vue";
import LoginComponent from "@/components/LoginComponent.vue";
import SignupComponent from "@/components/SignupComponent.vue";
export default [
{
path: "/main",
name: "HomeComponent",
component: HomeComponent,
meta: { requiresAuth: true },
},
{
path: "/login",
name: "LoginComponent",
component: LoginComponent,
meta: { hideNav: true }, // 네비게이션 바 숨김 설정
},
{
path: "/signup",
name: "SignupComponent",
component: SignupComponent,
meta: { hideNav: true }, // 네비게이션 바 숨김 설정
},
];
경로 src/components
<template>
<div id="navigation">
<v-app>
<v-app-bar :elevation="2">
<template v-slot:prepend>
<v-app-bar-nav-icon></v-app-bar-nav-icon>
</template>
<v-app-bar-title> Application </v-app-bar-title>
<v-btn variant="text" prepend-icon="mdi-home" @click="home"></v-btn>
{{ username + "님" }}
<v-btn
variant="text"
prepend-icon="mdi-user"
@click="openLogoutDialog()"
>Logout</v-btn
>
</v-app-bar>
</v-app>
</div>
<DialogComponent
v-if="showLogoutDialog"
openDialog="로그아웃하시겠습니까?"
@confirm="logout"
@cancel="showLogoutDialog = false"
/>
</template>
<script>
import { useUserStore } from "@/stores/userStore";
import DialogComponent from "./DialogComponent.vue";
import axios from "axios";
export default {
name: "NavigationBar",
data() {
return {
username: "",
showLogoutDialog: false,
userData: JSON.parse(localStorage.getItem("userData")),
};
},
components: {
DialogComponent,
},
methods: {
home() {
this.$router.push("/main");
},
openLogoutDialog() {
this.showLogoutDialog = true; // 다이얼로그 열기
},
logout() {
axios
.get("/logout/" + this.userData.username)
.then((response) => {
console.log(response);
localStorage.removeItem("userData"); // 로컬 스토리지에서 사용자 데이터 삭제
this.username = ""; // username 초기화
this.showLogoutDialog = false; // 다이얼로그 닫기
this.$router.push("/login");
})
.catch((error) => {
console.error("There was an error!", error);
});
},
},
mounted() {
const userStore = useUserStore();
if (userStore.username) {
this.username = userStore.username; // 사용자 이름 설정
} else {
this.username = "";
}
},
};
</script>
<style scoped>
#navigation {
height: 80px;
}
</style>
경로 src/components
이미 로그인 해서 로컬 스토리지에 userData가 있다면 위 경로 접속 시 /main 페이지로 리다이렉트되고, 로그인 하지 않았다면 /login 페이지로 리다이렉트된다.
<template>
<div>
<v-card
class="mx-auto pa-12 pb-8"
elevation="8"
max-width="448"
rounded="lg"
>
<div class="text-subtitle-1 text-medium-emphasis">Account</div>
<v-text-field
density="compact"
placeholder="Email address"
prepend-inner-icon="mdi-email-outline"
variant="outlined"
v-model="username"
></v-text-field>
<div
class="text-subtitle-1 text-medium-emphasis d-flex align-center justify-space-between"
>
Password
<a
class="text-caption text-decoration-none text-blue"
href="#"
rel="noopener noreferrer"
target="_blank"
>
Forgot login password?</a
>
</div>
<v-text-field
:append-inner-icon="visible ? 'mdi-eye-off' : 'mdi-eye'"
:type="visible ? 'text' : 'password'"
density="compact"
placeholder="Enter your password"
prepend-inner-icon="mdi-lock-outline"
variant="outlined"
@click:append-inner="visible = !visible"
v-model="password"
></v-text-field>
<b-card-text class="mb-2" style="color: red">
{{ errorMsg }}
</b-card-text>
<v-card class="mb-12" color="surface-variant" variant="tonal">
<v-card-text class="text-medium-emphasis text-caption">
Warning: After 3 consecutive failed login attempts, you account will
be temporarily locked for three hours. If you must login now, you can
also click "Forgot login password?" below to reset the login password.
</v-card-text>
</v-card>
<v-btn
class="mb-8"
color="blue"
size="large"
variant="tonal"
@click="login()"
>
Log In
</v-btn>
<v-card-text class="text-center">
<a
class="text-blue text-decoration-none"
href="#"
rel="noopener noreferrer"
target="_blank"
@click.prevent="signup()"
>
Sign up now <v-icon icon="mdi-chevron-right"></v-icon>
</a>
</v-card-text>
</v-card>
</div>
</template>
<script>
import axios from "axios";
import qs from "qs";
import { useUserStore } from "@/stores/userStore";
export default {
name: "LoginComponent",
data() {
return {
username: "",
password: "",
visible: false,
errorMsg: null,
rules: {
required: (value) => !!value || "Required.",
counter: (value) => value.length <= 20 || "Max 20 characters",
email: (value) => {
const pattern =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return pattern.test(value) || "Invalid e-mail";
},
},
};
},
methods: {
login() {
this.errorMsg = null;
const data = {
username: this.username,
password: this.password,
};
axios
.post("http://localhost:8084/login", qs.stringify(data), {
headers: {
"content-type": "application/x-www-form-urlencoded",
},
})
.then((response) => {
if (response.data.message === "login") {
const userStore = useUserStore();
userStore.setUsername(this.username);
this.$router.push("/main");
} else {
this.errorMsg = "아이디 혹은 패스워드가 올바르지 않습니다.";
}
})
.catch((error) => {
// 로그인 실패 시 오류 메시지 설정
this.errorMsg = "아이디 혹은 패스워드가 올바르지 않습니다.";
console.error("로그인 실패:", error);
});
},
signup() {
console.log("32");
this.$router.replace("/signup");
},
},
};
</script>
<style scoped>
.v-card {
margin-top: 50px;
}
</style>
경로 src/components
<template>
<div id="app">
<h2>Main Component</h2>
Main Page
</div>
</template>
<script>
export default {
name: "HomeComponent",
};
</script>
<style scoped>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
경로 src/components
<template>
<v-dialog v-model="dialog" width="auto">
<v-card width="500" prepend-icon="mdi-update" title="알림">
<v-card-text>{{ openDialog }}</v-card-text>
<v-card-actions>
<v-btn class="ms-auto" text="확인" @click="confirmLogout"></v-btn>
<v-btn class="ms-auto" text="취소" @click="cancelLogout"></v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
props: {
openDialog: String,
},
data() {
return {
dialog: true,
};
},
methods: {
confirmLogout() {
this.$emit("confirm"); // 상위 컴포넌트에 confirm 이벤트 전달
this.dialog = false;
},
cancelLogout() {
this.$emit("cancel"); // 상위 컴포넌트에 cancel 이벤트 전달
this.dialog = false;
},
},
};
</script>
경로 src/components
<template>
<div>
<v-card
class="mx-auto pa-12 pb-8"
elevation="8"
max-width="448"
rounded="lg"
>
<div class="text-subtitle-1 text-medium-emphasis">Create Account</div>
<v-text-field
density="compact"
placeholder="Email address"
prepend-inner-icon="mdi-email-outline"
variant="outlined"
v-model="email"
:rules="[rules.required, rules.email]"
></v-text-field>
<div class="text-subtitle-1 text-medium-emphasis">Password</div>
<v-text-field
:append-inner-icon="passwordVisible ? 'mdi-eye-off' : 'mdi-eye'"
:type="passwordVisible ? 'text' : 'password'"
density="compact"
placeholder="Enter your password"
prepend-inner-icon="mdi-lock-outline"
variant="outlined"
@click:append-inner="passwordVisible = !passwordVisible"
v-model="password"
:rules="[rules.required, rules.counter]"
></v-text-field>
<div class="text-subtitle-1 text-medium-emphasis">Confirm Password</div>
<v-text-field
:append-inner-icon="confirmPasswordVisible ? 'mdi-eye-off' : 'mdi-eye'"
:type="confirmPasswordVisible ? 'text' : 'password'"
density="compact"
placeholder="Confirm your password"
prepend-inner-icon="mdi-lock-outline"
variant="outlined"
@click:append-inner="confirmPasswordVisible = !confirmPasswordVisible"
v-model="confirmPassword"
:rules="[rules.required, rules.counter, rules.matchPassword]"
></v-text-field>
<b-card-text class="mb-2" style="color: red">
{{ errorMsg }}
</b-card-text>
<v-btn
class="mb-8"
color="blue"
size="large"
variant="tonal"
@click="signup()"
>
Sign Up
</v-btn>
<v-card-text class="text-center">
<a class="text-blue text-decoration-none" href="#" @click="goToLogin()">
Already have an account? Log in
</a>
</v-card-text>
</v-card>
</div>
</template>
<script>
import axios from "axios";
export default {
name: "SignupComponent",
data() {
return {
email: "",
password: "",
confirmPassword: "",
passwordVisible: false,
confirmPasswordVisible: false,
errorMsg: null,
rules: {
required: (value) => !!value || "Required.",
counter: (value) => value.length <= 20 || "Max 20 characters",
email: (value) => {
const pattern =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return pattern.test(value) || "Invalid e-mail";
},
matchPassword: (value) =>
value === this.password || "Passwords do not match",
},
};
},
methods: {
signup() {
console.log("this.email :: " + this.email);
console.log("this.password :: " + this.password);
axios
.post("/user", {
email: this.email,
password: this.password,
})
.then(() => {
this.$router.replace("/login"); // 회원가입 성공 시 로그인 페이지로 리다이렉트
})
.catch((error) => {
console.error("Error during signup:", error);
this.errorMsg =
"회원가입 중 오류가 발생했습니다. 다시 시도해 주세요.";
});
},
},
};
</script>
<style scoped>
.v-card {
margin-top: 50px;
}
</style>
🧩 defineStore를 사용하여 userStore라는 스토어를 정의한다. 이 스토어는 Pinia의 상태 관리 기능을 사용하며, Vue 3에서 Vuex를 대신할 수 있는 심플한 상태 관리 스토어이다.
🧩 id 속성에 "userData"를 지정하여 이 스토어를 구분하고, localStorage에 저장될 키 이름으로도 사용한다.
🧩 setUsername(name): 이 메서드는 username 상태를 주어진 이름으로 설정합니다. 사용자가 로그인할 때 호출되어, 로그인된 사용자의 이름이 username에 저장된다.
🧩 enabled: true로 설정하여 상태 저장을 활성화한다.
🧩 strategies: localStorage를 사용해 데이터를 저장하며, 저장될 키 이름(key)을 "userData"로 지정한다.
import { defineStore } from "pinia";
export const useUserStore = defineStore({
id: "userData", // localStorage에 저장될 키 이름
state: () => ({
username: "",
}),
actions: {
setUsername(name) {
this.username = name;
},
},
persist: {
enabled: true, // 이 옵션을 활성화해야 `localStorage`에 저장
strategies: [
{
key: "userData", // localStorage에 저장될 키 이름
storage: localStorage,
},
],
},
});