현대 웹 개발에서 프론트엔드와 백엔드 분리 아키텍처는 보편적인 표준이 되었습니다. 본 문서에서는 Vue 3 프론트엔드와 Spring Boot 백엔드 간의 효율적이고 안전한 데이터 통신 방안을 상세히 다룹니다.
1. 기술 스택
- 프론트엔드: Vue 3 (반응형 UI), Axios (HTTP 클라이언트)
- 백엔드: Spring Boot 3.x (Java 엔터프라이즈 프레임워크)
- 데이터 액세스: JPA (객체 관계 매핑), MySQL (관계형 데이터베이스)
- 보안: JWT (JSON Web Token 기반 인증)
2. 프론트엔드 데이터 계층 설계
2.1 Axios 인스턴스 설정
Axios를 사용하여 HTTP 요청을 처리하며, 인터셉터를 통해 일관된 인증 및 오류 처리를 구현합니다.
import axios from 'axios';
// Axios 인스턴스 생성
const apiClient = axios.create({
baseURL: 'http://localhost:8080', // 백엔드 API 기본 URL
timeout: 10000, // 요청 타임아웃
headers: {
'Content-Type': 'application/json',
},
});
// 요청 인터셉터: 인증 토큰 자동 추가
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 응답 인터셉터: 공통 오류 처리
apiClient.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// 토큰 만료 시 로그인 페이지로 리다이렉트
localStorage.clear();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default apiClient;
- `baseURL`은 환경별 백엔드 주소 관리에 용이합니다.
- 요청 인터셉터는 `localStorage`에서 JWT 토큰을 자동으로 추출하여 요청 헤더에 포함시킵니다.
- 응답 인터셉터는 401 Unauthorized 에러 발생 시 사용자 인증 상태를 초기화하고 로그인 페이지로 이동시킵니다.
2.2 API 경로 관리
API 경로를 중앙 집중화하여 유지보수성을 높입니다.
const API_PREFIX = '/api/v1';
const apiEndpoints = {
// 사용자 관련
user: {
login: `${API_PREFIX}/auth/login`,
register: `${API_PREFIX}/auth/register`,
profile: `${API_PREFIX}/user/profile`,
updateProfile: `${API_PREFIX}/user/profile`,
},
// 식단 기록
diet: {
records: `${API_PREFIX}/diet`,
summary: `${API_PREFIX}/diet/summary`,
favorite: (recordId) => `${API_PREFIX}/diet/${recordId}/favorite`,
},
// 운동 기록
workout: {
log: `${API_PREFIX}/workouts`,
stats: `${API_PREFIX}/workout/stats`,
},
// 수면 기록
sleep: {
log: `${API_PREFIX}/sleep`,
stats: `${API_PREFIX}/sleep/stats`,
},
};
export default apiEndpoints;
2.3 서비스 계층 캡슐화
비즈니스 모듈별로 API 호출을 캡슐화하여 코드 재사용성을 증대시킵니다.
import apiClient from './apiClient';
import apiEndpoints from '../utils/apiEndpoints';
const dataService = {
// 식단 기록 관련 서비스
diet: {
fetchRecords: async (userId, queryParams) => {
return apiClient.get(`${apiEndpoints.diet.records}?userId=${userId}`, { params: queryParams });
},
addRecord: async (recordData) => {
return apiClient.post(apiEndpoints.diet.records, recordData);
},
modifyRecord: async (recordId, updateData) => {
return apiClient.put(`${apiEndpoints.diet.records}/${recordId}`, updateData);
},
removeRecord: async (recordId) => {
return apiClient.delete(`${apiEndpoints.diet.records}/${recordId}`);
},
toggleFavoriteStatus: async (recordId, isFavorite) => {
return apiClient.post(apiEndpoints.diet.favorite(recordId), { isFavorite });
},
getSummaryData: async (userId, startDt, endDt) => {
return apiClient.get(apiEndpoints.diet.summary, {
params: { userId, startDt, endDt },
});
},
},
// 운동 기록 관련 서비스
workout: {
fetchLogs: async (userId, queryParams) => {
return apiClient.get(`${apiEndpoints.workout.log}?userId=${userId}`, { params: queryParams });
},
addLog: async (logData) => {
return apiClient.post(apiEndpoints.workout.log, logData);
},
// ... 기타 메소드
},
};
export default dataService;
3. 백엔드 데이터 계층 설계
3.1 엔티티 클래스 정의
JPA 어노테이션을 사용하여 데이터베이스 엔티티를 정의하고 객체-관계 매핑을 설정합니다.
import jakarta.persistence.*;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnore;
@Entity
@Table(name = "app_users", indexes = {
@Index(name = "idx_user_username", columnList = "username", unique = true)
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50)
private String username;
@Column(nullable = false)
private String passwordHash; // 비밀번호는 해시 처리
@Column(unique = true)
private String email;
// 사용자 프로필 정보
private String fullName;
@Enumerated(EnumType.STRING)
private UserGender gender;
private Double heightCm;
private Double weightKg;
private LocalDate birthDate;
private String locationCity;
private String userBio;
// 사용자 프로필 이미지 (1:1 관계)
@OneToOne(mappedBy = "ownerUser", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@JsonIgnore
private UserProfileImage profileImage;
// 사용자 활동 로그 (1:N 관계)
@OneToMany(mappedBy = "user", cascade = CascadeType.REMOVE, orphanRemoval = true, fetch = FetchType.LAZY)
@JsonIgnore
private List<UserActivityLog> activityLogs;
// Getters and Setters ( Lombok 사용 권장 )
// ...
}
// UserGender enum 예시
enum UserGender {
MALE, FEMALE, OTHER
}
- `@Entity`는 JPA 엔티티임을 명시합니다.
- `@Table`은 데이터베이스 테이블과 매핑되며, `indexes`를 통해 쿼리 성능을 최적화합니다.
- `@OneToOne`, `@OneToMany`는 엔티티 간의 관계를 정의합니다.
- `@JsonIgnore`는 JSON 직렬화 시 무한 순환 참조를 방지합니다.
3.2 데이터 액세스 계층 (Repository)
JpaRepository를 상속받아 기본 CRUD 기능을 활용하고, 사용자 정의 쿼리 메소드를 추가합니다.
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public interface DietRecordRepository extends JpaRepository {
// 사용자 ID로 식단 기록 조회
List<DietRecord> findByUserIdOrderByRecordTimeDesc(Long userId);
// 특정 날짜 범위 내 식단 기록 조회 (JPQL 사용)
@Query("SELECT d FROM DietRecord d WHERE d.userId = :userId AND d.recordTime BETWEEN :startTime AND :endTime ORDER BY d.recordTime ASC")
List<DietRecord> findByUserIdAndRecordTimeRange(
@Param("userId") Long userId,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime
);
// 사용자 ID를 기준으로 모든 기록 삭제 (트랜잭션 처리 필요)
@Transactional
void deleteByUserId(Long userId);
}
3.3 컨트롤러 계층 (Controller)
RESTful API 인터페이스를 정의하고, HTTP 요청 및 응답을 처리합니다.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.List;
import java.util.Map;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/diet")
public class DietController {
@Autowired
private DietRecordService dietRecordService;
@Autowired
private ObjectMapper objectMapper; // JSON 파싱을 위한 ObjectMapper
// 식단 기록 추가 (멀티파트 요청 처리)
@PostMapping
public ResponseEntity
4. 데이터 통신 시나리오
4.1 사용자 등록 절차
- 프론트엔드에서 사용자 입력 정보 수집:
{ username: 'testuser', password: 'securepassword', email: 'test@example.com' } dataService.user.register(userData)호출- Axios가
POST /api/v1/auth/register요청 전송 - 백엔드 컨트롤러가 요청 수신, 서비스 로직 수행, JWT 토큰 생성
- 응답으로 사용자 정보와 JWT 토큰 반환
- 프론트엔드에서 토큰을 `localStorage`에 저장:
localStorage.setItem('authToken', response.token)
4.2 데이터 조회 절차
- 프론트엔드 컴포넌트 마운트 시:
dataService.diet.fetchRecords('123', { date: '2024-07-20' })호출 - Axios가
GET /api/v1/diet?userId=123&date=2024-07-20요청 전송 (Authorization 헤더 포함) - JWT 필터가 토큰 유효성 검증
- 컨트롤러가 요청 처리, 서비스 및 리포지토리 통해 DB 조회
- 조회된 데이터 리스트를 프론트엔드로 반환
- 프론트엔드에서 데이터를 받아 화면 렌더링
4.3 파일 업로드 절차
- 프론트엔드에서 파일 선택 후
FormData객체 생성:formData.append('mealType', 'Lunch'); formData.append('receiptImage', file); - Axios가
POST /api/v1/diet요청 전송 (Content-Type: multipart/form-data) - 백엔드에서
@RequestParam으로 파일 및 기타 필드 수신 - 수신된 파일 데이터를 처리하여 DB에 저장
5. 데이터 형식 변환
5.1 백엔드 엔티티를 프론트엔드 JSON으로 변환
컨트롤러의 DTO 변환 메소드를 통해 응답 형식을 맞춥니다.
// 컨트롤러 내 convertToDto 메소드 참조
private Map convertToDto(DietRecord record) {
Map dto = new HashMap<>();
// ... 필드 매핑 ...
if (record.getImage() != null) {
// 이미지 데이터를 Base64 인코딩
String base64Encoded = java.util.Base64.getEncoder().encodeToString(record.getImage().getImageData());
dto.put("imagePreviewUrl", "data:" + record.getImage().getContentType() + ";base64," + base64Encoded);
}
return dto;
}
5.2 프론트엔드 렌더링 예시
<template>
<div class="diet-log">
<div v-for="entry in dietEntries" :key="entry.id" class="log-item">
<h4>{{ entry.mealType }} - {{ formatDateTime(entry.timestamp) }}</h4>
<p>칼로리: {{ entry.calories }} kcal</p>
<p>메모: {{ entry.notes }}</p>
<img v-if="entry.imagePreviewUrl" :src="entry.imagePreviewUrl" alt="영수증/음식 사진" class="food-image" />
<button @click="handleFavoriteToggle(entry)">
{{ entry.isFavorite ? '즐겨찾기 해제' : '즐겨찾기' }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import dataService from '@/services/dataService';
const dietEntries = ref([]);
const loadDietData = async () => {
const currentUserId = localStorage.getItem('userId'); // 사용자 ID 가져오기
try {
dietEntries.value = await dataService.diet.fetchRecords(currentUserId);
} catch (error) {
console.error('식단 데이터를 불러오지 못했습니다:', error);
}
};
const handleFavoriteToggle = async (entry) => {
try {
await dataService.diet.toggleFavorite(entry.id, !entry.isFavorite);
entry.isFavorite = !entry.isFavorite; // UI 즉시 업데이트
} catch (error) {
console.error('즐겨찾기 상태 변경 실패:', error);
}
};
// 날짜/시간 포맷팅 함수 (예시)
const formatDateTime = (isoString) => {
return new Date(isoString).toLocaleString();
};
onMounted(loadDietData);
</script>
6. 권장 사항
6.1 견고한 오류 처리
응답 인터셉터를 활용하여 API 오류를 중앙에서 관리하고 사용자에게 피드백을 제공합니다.
// apiClient.interceptors.response.use(...) 내부
(error) => {
const { response } = error;
let errorMessage = 'An unexpected error occurred.';
if (response) {
switch (response.status) {
case 400:
errorMessage = response.data?.message || 'Bad Request: Please check your input.';
break;
case 401:
errorMessage = 'Authentication required. Please log in again.';
localStorage.removeItem('authToken');
window.location.href = '/login';
break;
case 403:
errorMessage = 'Forbidden: You do not have permission.';
break;
case 404:
errorMessage = 'Resource not found.';
break;
case 500:
errorMessage = 'Server Error: Please try again later.';
break;
default:
errorMessage = `Error ${response.status}: ${response.data?.message || 'Unknown error'}`;
}
} else if (error.request) {
// 요청은 이루어졌으나 응답을 받지 못한 경우
errorMessage = 'Network Error: Could not connect to the server.';
} else {
// 요청 설정 중 오류 발생
errorMessage = `Request Setup Error: ${error.message}`;
}
console.error('API Error:', errorMessage, response);
// 필요시 사용자에게 알림 표시 (예: Vue Toastification 사용)
// toast.error(errorMessage);
return Promise.reject(error);
}
6.2 요청 취소 처리
장시간 소요되는 요청(예: 복잡한 데이터 분석)에 대해 사용자의 요청 취소 기능을 지원합니다.
import apiClient from './apiClient'; // 기존 Axios 인스턴스
let cancelRequest = null;
const fetchComplexData = async (params) => {
try {
const response = await apiClient.get('/api/v1/analysis/complex', {
params,
timeout: 180000, // 3분 타임아웃 설정
cancelToken: new AbortController().signal // 표준 AbortController 사용
});
return response;
} catch (error) {
if (error.name === 'AbortError') {
console.log('데이터 요청이 취소되었습니다.');
} else {
console.error('데이터 조회 중 오류 발생:', error);
throw error; // 다른 오류는 다시 던짐
}
}
};
// 요청 취소 함수
const abortFetchComplexData = () => {
if (cancelRequest) {
cancelRequest(); // 이전 요청 취소
cancelRequest = null;
}
};
// 사용 예시:
// const controller = new AbortController();
// fetchComplexData({ ...params, signal: controller.signal });
// controller.abort(); // 필요 시 호출
6.3 입력 데이터 유효성 검증
백엔드에서는 Bean Validation을 사용하여 API 요청 데이터의 무결성을 보장합니다.
// UserRegistrationRequest.java
import jakarta.validation.constraints.*;
public class UserRegistrationRequest {
@NotBlank(message = "사용자 이름은 필수입니다.")
@Size(min = 4, max = 25, message = "사용자 이름은 4자 이상 25자 이하이어야 합니다.")
private String username;
@NotBlank(message = "비밀번호는 필수입니다.")
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z\\d@$!%*#?&]{8,20}$",
message = "비밀번호는 8-20자, 영문자, 숫자 포함 필수")
private String password;
@NotBlank(message = "이메일 주소는 필수입니다.")
@Email(message = "유효한 이메일 형식이 아닙니다.")
private String email;
// Getters and Setters
}
// UserController.java
@PostMapping("/register")
public ResponseEntity> registerUser(@Valid @RequestBody UserRegistrationRequest request) {
// @Valid 어노테이션으로 인해 유효성 검증 자동 수행
// 유효성 검증 실패 시, Spring은 자동으로 400 Bad Request 응답 반환
// userService.register(request);
// ... 성공 시 응답 ...
return ResponseEntity.ok("User registered successfully.");
}