전자정부 프레임워크 공부

Spring Boot에서 만든 REST API를 호출하는 Vue 3 프로젝트

그랜파 개발자 2025. 5. 24. 18:37

Spring Boot + JPA로 만든 게시판 REST API를 Vue 3 프론트엔드 앱에서 호출하는 예제를 만들어보겠습니다. 아래는 가장 기본적인 게시글 목록 조회, 게시글 작성, 상세 보기, 삭제 기능을 포함한 Vue 앱 예제입니다.

 

✅ 1. Vue 3 프로젝트 생성

npm init vue@latest
cd vue-board
npm install

 

선택 시:

  • TypeScript: ❌ (JS로 설명)
  • Router: ✅ Yes
  • Pinia: ❌ (기본 프로젝트에선 생략)

✅ 2. 프로젝트 구조

vue-board/
├── public/
├── src/
│   ├── assets/
│   ├── components/
│   │   ├── PostList.vue
│   │   └── PostForm.vue
│   ├── views/
│   │   ├── HomeView.vue
│   │   ├── PostDetail.vue
│   │   └── CreateView.vue
│   ├── router/
│   │   └── index.js
│   ├── api.js
│   ├── App.vue
│   └── main.js
├── package.json

 

📡 3. Axios 설치 및 설정

npm install axios

 

src/api.js:

import axios from 'axios';

const api = axios.create({
  baseURL: 'http://localhost:8080/api',
});

export default api;

 

✅ 4. 라우터 설정

src/router/index.js

import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '../views/HomeView.vue';
import CreateView from '../views/CreateView.vue';
import PostDetail from '../views/PostDetail.vue';

const routes = [
  { path: '/', name: 'Home', component: HomeView },
  { path: '/create', name: 'Create', component: CreateView },
  { path: '/posts/:id', component: PostDetail, props: true }
];

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

export default router;

 

✅ 5. App 설정

src/main.js

//import './assets/main.css'

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(router)

app.mount('#app')

 

✅ 6. 게시글 목록 보기 (GET)

src/components/PostList.vue

<template>
  <div>
    <ul class="post-list">
      <li v-for="post in posts" :key="post.id" class="post-item">
        <RouterLink :to="`/posts/${post.id}`" class="post-title">
          {{ post.title }}
        </RouterLink>
        <span class="writer">by {{ post.writer }}</span>
        <button class="btn-delete" @click="deletePost(post.id)">삭제</button>
      </li>
    </ul>

    <!-- ✅ 페이지네이션 (10개 묶음) -->
    <div class="pagination" v-if="totalPages > 1">
      <button @click="prevGroup" :disabled="currentGroup === 0">«</button>
      <button
        v-for="page in visiblePages"
        :key="page"
        @click="goToPage(page)"
        :class="{ active: currentPage === page }"
      >
        {{ page }}
      </button>
      <button @click="nextGroup" :disabled="(currentGroup + 1) * 10 >= totalPages">»</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue'
import axios from 'axios'

const posts = ref([])
const totalPages = ref(1)
const currentPage = ref(1)
const size = 10

const currentGroup = computed(() => Math.floor((currentPage.value - 1) / 10))

const visiblePages = computed(() => {
  const start = currentGroup.value * 10 + 1
  const end = Math.min(start + 9, totalPages.value)
  return Array.from({ length: end - start + 1 }, (_, i) => start + i)
})

const fetchPosts = async (page = 1) => {
  try {
    const res = await axios.get(`http://localhost:8080/api/posts?page=${page - 1}&size=${size}`)
    posts.value = res.data.content
    totalPages.value = res.data.totalPages
    currentPage.value = res.data.number + 1
  } catch (e) {
    console.error(e)
  }
}

const goToPage = (page) => {
  if (page >= 1 && page <= totalPages.value) {
    fetchPosts(page)
  }
}

const prevGroup = () => {
  goToPage(currentGroup.value * 10)
}

const nextGroup = () => {
  goToPage((currentGroup.value + 1) * 10 + 1)
}

const deletePost = async (id) => {
  await axios.delete(`http://localhost:8080/api/posts/${id}`)
  // 삭제 후 데이터가 비어 있으면 이전 페이지로 이동
  const newPage = posts.value.length === 1 && currentPage.value > 1
    ? currentPage.value - 1
    : currentPage.value
  fetchPosts(newPage)
}

onMounted(() => {
  fetchPosts()
})
</script>

<style scoped>
.post-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.post-item {
  display: grid;
  grid-template-columns: 1fr 240px 80px; /* 제목 / 작성자 / 삭제 버튼 */
  align-items: center;
  border-bottom: 1px solid #ccc;
  padding: 8px 0;
}

.post-title {
  font-weight: bold;
  color: #333;
  text-decoration: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.writer {
  color: #666;
  text-align: right;
  padding: 0 10px;
}

.btn-delete {
  color: red;
  background: none;
  border: none;
  cursor: pointer;
}

.btn-create {
  display: inline-block;
  margin-bottom: 1rem;
  text-decoration: none;
  padding: 6px 12px;
  background: #007bff;
  color: white;
  border-radius: 4px;
}
.pagination {
  margin-top: 1rem;
  text-align: center;
}
.pagination button {
  margin: 0 4px;
  padding: 6px 10px;
  border: 1px solid #ccc;
  background: #fff;
  cursor: pointer;
}
.pagination button.active {
  background: #007bff;
  color: #fff;
}
.pagination button:disabled {
  opacity: 0.5;
  cursor: default;
}
</style>

 

src/views/HomeView.vue

<template>
  <PostList />
</template>

<script setup>
import PostList from '../components/PostList.vue';
</script>

 

✅ 7. 게시글 작성 (POST)

src/components/PostForm.vue

<template>
  <form class="post-form" @submit.prevent="submitPost">
    <h2>📝 새 글 작성</h2>

    <input
      v-model="post.title"
      type="text"
      placeholder="제목을 입력하세요"
      required
      class="input"
    />

    <textarea
      v-model="post.content"
      placeholder="내용을 입력하세요"
      required
      class="textarea"
      rows="6"
    ></textarea>

    <input
      v-model="post.writer"
      type="text"
      placeholder="작성자 이름"
      required
      class="input"
    />

    <button type="submit" class="btn-submit">등록</button>
  </form>
</template>



<script setup>
import { ref } from 'vue';
import api from '../api';
import { useRouter } from 'vue-router';

const router = useRouter();
const post = ref({ title: '', content: '', writer: '' });

const submitPost = async () => {
  await api.post('/posts', post.value);
  router.push('/');
};
</script>

<style scoped>
.post-form {
  max-width: 600px;
  margin: 0 auto;
  background: #fff;
  padding: 24px;
  border-radius: 10px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
}

.post-form h2 {
  margin-bottom: 1.2rem;
  text-align: center;
}

.input,
.textarea {
  width: 100%;
  padding: 10px 12px;
  margin-bottom: 1rem;
  border: 1px solid #ccc;
  border-radius: 6px;
  font-size: 1rem;
}

.btn-submit {
  display: block;
  width: 100%;
  padding: 10px;
  background-color: #1976d2;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 1rem;
  font-weight: bold;
  cursor: pointer;
}

.btn-submit:hover {
  background-color: #125aa0;
}
</style>

 

src/views/CreateView.vue

<template>
  <PostForm />
</template>

<script setup>
import PostForm from '../components/PostForm.vue';
</script>

 

✅ 8. 상세 보기

src/views/PostDetail.vue

<template>
  <div class="post-detail">
    <div v-if="loading" class="loading">⏳ 게시글 불러오는 중...</div>
    <div v-else-if="error" class="error">⚠️ 게시글을 불러오지 못했습니다.</div>
    <div v-else-if="post">
      <h2 class="title">{{ post.title }}</h2>
      <p class="meta">✍️ 작성자: {{ post.writer }}</p>
      <p class="content">{{ post.content }}</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'

const route = useRoute()
const post = ref(null)
const loading = ref(true)
const error = ref(false)

onMounted(async () => {
  try {
    const res = await axios.get(`http://localhost:8080/api/posts/${route.params.id}`)
    post.value = res.data
  } catch (e) {
    console.error(e)
    error.value = true
  } finally {
    loading.value = false
  }
})
</script>

<style scoped>
.post-detail {
  max-width: 700px;
  margin: 2rem auto;
  background: #fff;
  padding: 24px;
  border-radius: 10px;
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}

.title {
  font-size: 1.8rem;
  margin-bottom: 10px;
}

.meta {
  color: #666;
  margin-bottom: 1rem;
}

.content {
  font-size: 1.1rem;
  line-height: 1.6;
  white-space: pre-line; /* 줄바꿈 적용 */
}

.loading,
.error {
  text-align: center;
  font-size: 1.2rem;
  padding: 2rem;
  color: #555;
}
</style>

 

✅ 9. Spring Boot에서 JPA 기반 게시판 API에 페이지네이션 기능 추가

Service

package com.example.board.service;

import com.example.board.entity.Board;
import com.example.board.repository.BoardRepository;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

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

@Service
public class BoardService {

    private final BoardRepository boardRepository;

    // 생성자를 통한 의존성 주입
    public BoardService(BoardRepository boardRepository) {
        this.boardRepository = boardRepository;
    }

    // 모든 게시글 조회
    public List<Board> findAll() {
        return boardRepository.findAll();
    }

    public Page<Board> findAll(Pageable pageable) {
        return boardRepository.findAll(pageable);
    }

    // ID로 게시글 조회
    public Optional<Board> findById(Long id) {
        return boardRepository.findById(id);
    }

    public Board save(Board post) {
        return boardRepository.save(post);
    }

    public void delete(Long id) {
    	boardRepository.deleteById(id);
    }
    
    
    public Board updatePost(Long id, Board updatedPost) {
        return boardRepository.findById(id)
            .map(post -> {
                post.setTitle(updatedPost.getTitle());
                post.setContent(updatedPost.getContent());
                post.setWriter(updatedPost.getWriter());
                return boardRepository.save(post);
            }).orElseThrow(() -> new RuntimeException("Post not found with id " + id));
    }

    // ID로 게시글 삭제
    public void deleteById(Long id) {
        boardRepository.deleteById(id);
    }
    
    public List<Board> getAllPostsOrderByCreatedAtDesc() {
        return boardRepository.findAllByOrderByCreatedAtDesc();
    }
    
    // PostService.java
    public Page<Board> getPostPage(int page, int size) {
        Pageable pageable = PageRequest.of(page, size, Sort.by("id").descending());
        return boardRepository.findAll(pageable);
    }

}

 

Controller

package com.example.board.controller;

import com.example.board.entity.Board;
import com.example.board.service.BoardService;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@CrossOrigin(origins = "http://localhost:5173")
@RequestMapping("/api/posts")
public class PostRestController {

    private final BoardService boardService;

    public PostRestController(BoardService postService) {
        this.boardService = postService;
    }

    @GetMapping
    public Page<Board> getPosts(@PageableDefault(size = 10, sort = "id", direction = Sort.Direction.DESC) Pageable pageable) {
        return boardService.findAll(pageable);
    }

    @PostMapping
    public Board createPost(@RequestBody Board post) {
        return boardService.save(post);
    }

    // 특정 게시글 조회
    @GetMapping("/{id}")
    public ResponseEntity<Board> getPostById(@PathVariable Long id) {
        return boardService.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    // 게시글 수정
    @PutMapping("/{id}")
    public ResponseEntity<Board> updatePost(@PathVariable Long id, @RequestBody Board post) {
        try {
        	Board updated = boardService.updatePost(id, post);
            return ResponseEntity.ok(updated);
        } catch (RuntimeException e) {
            return ResponseEntity.notFound().build();
        }
    }

    // 게시글 삭제
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deletePost(@PathVariable Long id) {
    	boardService.deleteById(id);
        return ResponseEntity.noContent().build();
    }
}

 

🧪 10. 실행 방법

  1. Spring Boot 백엔드 먼저 실행 (localhost:8080)
  2. Vue 프론트엔드 실행:
 
npm run dev

브라우저에서 http://localhost:5173 접속.