전자정부 프레임워크 공부
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. 실행 방법
- Spring Boot 백엔드 먼저 실행 (localhost:8080)
- Vue 프론트엔드 실행:
npm run dev
브라우저에서 http://localhost:5173 접속.