본문 바로가기
Front-end/Vue

Vue 3 쇼핑몰 만들기 - 1 (+ Spring Boot)

by javapp 자바앱 2023. 4. 16.
728x90

 

Spring Boot 

인텔리제이에서 간단하게 DataBase 사용하기

 

- View - Tool Windows - Database

- Data Source

연결 후 사용

 


 

로그인

JWT 적용하고, 쿠키에 토큰 저장

개발자 도구에서 응용프로그램(애플리케이션)에서 확인가능

 

컨트롤러

@RestController
public class AccountController {

    @Autowired
    private MemberRepository memberRepository;

    @PostMapping("/api/account/login")
    public ResponseEntity<Integer> login(@RequestBody Map<String,String> loginDto, HttpServletResponse httpServletResponse){
        Member member = memberRepository.findByEmailAndPassword(loginDto.get("email"), loginDto.get("password"))
                .orElseThrow(()-> new ResponseStatusException(HttpStatus.NOT_FOUND));

        JwtService jwtService = new JwtService();
        int id = member.getId();
        String token = jwtService.getToken("id",id);

        Cookie cookie = new Cookie("token", token);
        cookie.setHttpOnly(true);
        cookie.setPath("/");

        httpServletResponse.addCookie(cookie);
        return ResponseEntity.ok(id);
    }
}

 

JwtService

public class JwtService {

    private String secretKey = "javapp1234dlrjtdmstlzmfltzldlekdl&&rmflrhdlrjtdms256qlxmdltkddmlanswkdufdmfrkwlrhdlTdjdiehlsek";

    public String getToken(String key, Object value){
        Date expTime = new Date();
        expTime.setTime(expTime.getTime() + 1000 * 60 * 5);
        byte[] secretByteKey = DatatypeConverter.parseBase64Binary(secretKey);
        Key signKey = new SecretKeySpec(secretByteKey, SignatureAlgorithm.HS256.getJcaName());

        Map<String, Object> headerMap = new HashMap<>();
        headerMap.put("type","JWT");
        headerMap.put("alg", "HS256");

        Map<String, Object> map = new HashMap<>();
        map.put(key, value);

        JwtBuilder builder = Jwts.builder().setHeader(headerMap)
                .setClaims(map)
                .setExpiration(expTime)
                .signWith(signKey, SignatureAlgorithm.HS256);
        return builder.compact();
    }
}

 

 

Vue 화면 구현

갤러리

 

 

라우터 (vue-router)를 사용해 각 path마다 뷰를 등록

scripts - router.js

import Home from "@/pages/Home";
import Login from "@/pages/Login";
import {createRouter, createWebHistory} from "vue-router";

const routes= [
    {path: '/', component: Home},
    {path: '/login', component: Login}
]

const router = createRouter({
    history: createWebHistory(),
    routes
})

export default router;

 

 

Home.vue

<template>
  <div class="home">
    <div class="album py-5 bg-light">
      <div class="container">
        <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3">
          <div class="col" v-for="(item, idx) in state.items" :key="idx">
            <Card :item="item"/>
          </div>
        </div>
      </div>
    </div>

  </div>
</template>

<script>
import Card from "@/components/Card";
import axios from "axios"
import {reactive} from "vue";

export default {
  name: "Home",
  components: {Card},
  setup(){
    const state = reactive({
      items:[]
    })
    axios.get("/api/items").then(({data})=>{
      state.items = data
    })

    return {state}
  }
}
</script>

<style scoped>

</style>

v-for 으로 Card 컴포넌트를 반복

<Card :item="item"/>

각 Card 컴포넌트에는 item을 넘겨주었습니다.

 

setup(){
  const state = reactive({
    items:[]
  })
  axios.get("/api/items").then(({data})=>{
    state.items = data
  })

  return {state}
}

setup 내에 js 코드로 로직을 작성할 수 있음

reactive는 리액트의 useState와 비슷한 거 같음

axios를 통해 서버와 비동기 통신을 하고 Card에 대한 json 값을 받아옴

return으로 넘기면 template에서 사용할 수 있음 

 

 

Card.vue

파라미터로 넘긴 데이터를 props를 통해 받아옵니다.

<template xmlns:>
    <div class="card shadow-sm">
      <span class="img" :style="{backgroundImage: `url(${item.imgPath})`}" />
      <div class="card-body">
        <p class="card-text">{{item.name}}</p>
        <div class="d-flex justify-content-between align-items-center">
          <div class="btn-group">
            <button type="button" class="btn btn-sm btn-primary">구입하기</button>
          </div>
          <small class="price text-muted">{{ lib.getNumberFormatted(item.price) }}원</small>
          <small class="discount badge bg-danger ">{{ lib.getNumberFormatted(item.price - (item.price * item.discountPer / 100)) }}</small>
          <small class="real text-danger ">{{ item.discountPer }}%</small>
        </div>
      </div>
    </div>
</template>

<script>
  import lib from "@/scripts/lib";

  export default {
    name: "Card",
    props:{
      item: Object
    },
    setup(){
      return {lib}
    }
  }
</script>

<style scoped>
.card .img{
  display: inline-block;
  width: 100%;
  height: 250px;
  background-size: cover;
  background-position: center;
}
.card .card-body .price{
  text-decoration: line-through;
}
</style>

갤러리

 

가격에 대한 숫자 포맷을 js 메소드를 통해 변경했습니다.

 

scripts - lib.js

export default {
    getNumberFormatted(val){
        return val.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
    }
}

 

로그인 과정

로그인

 

Login.vue

<template>
  <div class="form-signin">
    <h1 class="h3 mb-3 font-weight-normal">Please sign in</h1>
    <label for="inputEmail" class="sr-only">Email address</label>
    <input type="email" id="inputEmail" class="form-control" placeholder="Email address" required autofocus v-model="state.form.email">
    <label for="inputPassword" class="sr-only">Password</label>
    <input type="password" id="inputPassword" class="form-control" placeholder="Password" required v-model="state.form.password">
    <div class="checkbox mb-3">
      <label>
        <input type="checkbox" value="remember-me"> Remember me
      </label>
    </div>
    <button class="btn btn-lg btn-primary btn-block" @click="submit()">Sign in</button>
    <p class="mt-5 mb-3 text-muted">&copy; 2017-2018</p>
  </div>
</template>

<script>
import {reactive} from "vue";
import axios from "axios";
import store from "@/scripts/store";
import router from "@/scripts/router";

export default {
  name: "Login",
  setup(){
    const state = reactive({
      form:{
        email:"",
        password:""
      }
    })

    const submit = () =>{
      axios.post('/api/account/login',state.form).then((res)=>{
        store.commit('setAccount', res.data); // id 가 넘어온다.
        router.push({path:"/"})
        window.alert("로그인 완료")
      }).catch(()=>{
        window.alert("로그인 정보 없음")
      })
    }

    return {state, submit}
  }
}
</script>

<style scoped>
.form-signin {
  width: 100%;
  max-width: 330px;
  padding: 15px;
  margin: 0 auto;
}
.form-signin .checkbox {
  font-weight: 400;
}
.form-signin .form-control {
  position: relative;
  box-sizing: border-box;
  height: auto;
  padding: 10px;
  font-size: 16px;
}
.form-signin .form-control:focus {
  z-index: 2;
}
.form-signin input[type="email"] {
  margin-bottom: -1px;
  border-bottom-right-radius: 0;
  border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
  margin-bottom: 10px;
  border-top-left-radius: 0;
  border-top-right-radius: 0;
}
</style>

 

input 태그로 입력받은 이메일과 패스워드 값은 v-model 을 사용해 담아둠

<label for="inputEmail" class="sr-only">Email address</label>
<input type="email" id="inputEmail" class="form-control" placeholder="Email address" required autofocus v-model="state.form.email">
<label for="inputPassword" class="sr-only">Password</label>
<input type="password" id="inputPassword" class="form-control" placeholder="Password" required v-model="state.form.password">

 

이렇게 사용하기 위해서 setup에 form 을 구조체같이 만들어 줌

  setup(){
    const state = reactive({
      form:{
        email:"",
        password:""
      }
    })

 

@click="submit()" 클릭 이벤트

<button class="btn btn-lg btn-primary btn-block" @click="submit()">Sign in</button>

 

setup에서 submit 메소드를 작성하고 return

    const submit = () =>{
      axios.post('/api/account/login',state.form).then((res)=>{
        store.commit('setAccount', res.data); // id 가 넘어온다.
        router.push({path:"/"})
        window.alert("로그인 완료")
      }).catch(()=>{
        window.alert("로그인 정보 없음")
      })
    }

    return {state, submit}

 

추가 작업을 하지 않으면 새로고침시 로그인이 풀리게되는데

vuex 로 전역으로 상태를 관리하고, 

sessionStorage 로 유저id를 세션으로 관리할 수 있습니다.

router.push 로 페이지 이동

 

 

vuex 로 로그인한 id 관리하기

scripts - store.js

import { createStore } from 'vuex'

// vuex : 전역으로 상태를 관리
const store = createStore({
    state () {
        return {
            account:{
                id: 0
            }
        }
    },
    mutations: {
        setAccount(state, payload){
            state.account.id = payload;
        }
    }
})

export default store;

 

 

main.js

import { createApp } from 'vue'
import App from './App.vue'
import store from '@/scripts/store'
import router from "@/scripts/router";

createApp(App).use(store).use(router).mount('#app')

use에 넣어두어서 해당 앱에서 자유롭게 사용

 

 

App.vue

<template>
  <Header/>
  <RouterView/>
  <Footer/>
</template>

<script>
import Header from "@/components/Header"
import Footer from "@/components/Footer"
import store from "@/scripts/store";
import axios from "axios";
import {watch} from "vue";
import {useRoute} from "vue-router";

export default {
  name: 'App',
  components: {
    Header, Footer
  },
  setup(){
    const check = () =>{
      axios.get("/api/account/check").then(({data})=>{
        console.log(data);
        store.commit("setAccount",data || 0);
      })
    };

    const route = useRoute();
    watch(route, ()=>{
      check();
    })

  }
}
</script>

<style>
.bd-placeholder-img {
  font-size: 1.125rem;
  text-anchor: middle;
  -webkit-user-select: none;
  -moz-user-select: none;
  user-select: none;
}

@media (min-width: 768px) {
  .bd-placeholder-img-lg {
    font-size: 3.5rem;
  }
}
</style>

 

새로고침시 check api 를 통해 

쿠키를 확인하여 로그인 유지

  setup(){
    const check = () =>{
      axios.get("/api/account/check").then(({data})=>{
        store.commit("setAccount",data || 0);
      })
    };

    const route = useRoute();
    watch(route, ()=>{
      check();
    })
  }

 

벡엔드 check api

@GetMapping("/api/account/check")
public ResponseEntity check(@CookieValue(value = "token", required = false) String token){
    Claims claims = jwtService.getClaims(token);
    if(claims != null){
        int id = Integer.parseInt(claims.get("id").toString());
        return new ResponseEntity<>(id, HttpStatus.OK);
    }
    return new ResponseEntity<>(null, HttpStatus.OK);
}

 

 

헤더

Header.vue

<template>
  <header>
    <div class="collapse bg-dark" id="navbarHeader">
      <div class="container">
        <div class="row">
          <div class="col-sm-4 py-4">
            <h4 class="text-white">사이트맵</h4>
            <ul class="list-unstyled">
              <li>
                <router-link to="/" class="text-white">메인 화면</router-link>
              </li>
              <li>
                <router-link to="/login" class="text-white" v-if="!$store.state.account.id">로그인</router-link>
                <a to="/login" class="text-white" @click="logout()" v-else>로그아웃</a>
              </li>
            </ul>
          </div>
        </div>
      </div>
    </div>
    <div class="navbar navbar-dark bg-dark shadow-sm">
      <div class="container">
        <a href="/" class="navbar-brand d-flex align-items-center">
          <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" stroke="currentColor"
               stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="me-2"
               viewBox="0 0 24 24">
            <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
            <circle cx="12" cy="13" r="4"/>
          </svg>
          <strong>Album</strong>
        </a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarHeader"
                aria-controls="navbarHeader" aria-expanded="false" aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
      </div>
    </div>
  </header>
</template>

<script>
import store from "@/scripts/store";
import router from "@/scripts/router";
import axios from "axios";

export default {
  name: 'Header',
  setup() {
    const logout = () => {
      axios.get(('/api/account/logout')).then((res)=>{
        if(res){
          store.commit('setAccount', 0);
          router.push({path: "/login"})
        }
      })
    }
    return {logout}
  }
}
</script>

<style scoped>

</style>

 

v-if 조건으로 store의 id 가 없으면 "로그인",

v-else , 있으면 "로그아웃"

<router-link to="/login" class="text-white" v-if="!$store.state.account.id">로그인</router-link>
<a to="/login" class="text-white" @click="logout()" v-else>로그아웃</a>

로그인

 

로그아웃

setup() {
    const logout = () => {
      axios.get(('/api/account/logout')).then((res)=>{
        if(res){
          store.commit('setAccount', 0);
          router.push({path: "/login"})
        }
      })
    }
    return {logout}
  }

로그아웃은 store의 id 상태값을 0 으로 초기화,

 

 

벡엔드

@GetMapping("/api/account/logout")
public ResponseEntity<Boolean> logout(HttpServletRequest request, HttpServletResponse httpServletResponse){
    Cookie cookie = new Cookie("token", "0"); // 변경할 쿠키의 이름과 값 설정
    cookie.setMaxAge(0); // 쿠키의 유효기간 설정 (초단위, 예: 1시간 = 3600초)
    cookie.setPath("/"); // 쿠키의 유효 경로 설정 (루트 경로인 경우 "/")

    httpServletResponse.addCookie(cookie);
    return ResponseEntity.ok(true);
}

쿠키를 다시 생성해서 0초 설정하여 없어지도록 함.

 


 

이슈해결

multi-word-component-names

 

명칭을 2개의 단어로 이어라는 에러

 

 

package.json

    "rules": {
      "vue/multi-word-component-names": 0
    }

한단어로도 가능하게 해결

 

 

마지막으로 CORS 문제

같은 IP에서 port 번호가 달라 서로 다른 출처에서 통신을 하기 때문에 설정을 따로 해둡니다

vue.config.js

module.exports = {
  devServer: {
    proxy: {
      "/api": {
        target: "http://localhost:8080",
        changeOrigin: true,
        pathRewrite:{
          '^/': ''
        }
      },
    },
  }
}

 

'Front-end > Vue' 카테고리의 다른 글

Vue 3 쇼핑몰 만들기 - 2 (+ Spring Boot)  (0) 2023.04.20
VUE 3 간단한 메모앱 실습  (0) 2023.04.13

댓글