Vue 3와 Spring Boot 간의 원활한 데이터 연동

현대 웹 개발에서 프론트엔드와 백엔드 분리 아키텍처는 보편적인 표준이 되었습니다. 본 문서에서는 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> addDietRecord(
            @RequestParam("userId") Long userId,
            @RequestParam("mealType") String mealType,
            @RequestParam("timestamp") String timestampStr,
            @RequestParam("calories") Double calories,
            @RequestParam("notes") String notes,
            @RequestParam("itemsJson") String itemsJson,
            @RequestParam(value = "receiptImage", required = false) MultipartFile imageFile) throws Exception {

        DietRecord newRecord = new DietRecord();
        newRecord.setUserId(userId);
        newRecord.setMealType(mealType);
        newRecord.setRecordTime(LocalDateTime.parse(timestampStr));
        newRecord.setCalories(calories);
        newRecord.setNotes(notes);

        // JSON 문자열을 FoodItem 리스트로 파싱
        List<FoodItem> foodItems = objectMapper.readValue(itemsJson, new TypeReference() {});
        newRecord.setFoodItems(foodItems);

        // 이미지 파일 처리
        if (imageFile != null && !imageFile.isEmpty()) {
            DietImageRecord imageRecord = new DietImageRecord();
            imageRecord.setFileName(imageFile.getOriginalFilename());
            imageRecord.setContentType(imageFile.getContentType());
            imageRecord.setImageData(imageFile.getBytes());
            imageRecord.setDietRecord(newRecord);
            newRecord.setImage(imageRecord);
        }

        DietRecord savedRecord = dietRecordService.saveRecord(newRecord);
        return ResponseEntity.ok(convertToDto(savedRecord));
    }

    // 사용자별 식단 기록 조회
    @GetMapping
    public ResponseEntity>> getDietRecordsByUser(
            @RequestParam("userId") Long userId,
            @RequestParam(value = "startDate", required = false) String startDate,
            @RequestParam(value = "endDate", required = false) String endDate) {

        List<DietRecord> records;
        if (startDate != null && endDate != null) {
            LocalDateTime startDateTime = LocalDateTime.parse(startDate);
            LocalDateTime endDateTime = LocalDateTime.parse(endDate).plusDays(1).minusSeconds(1); // 해당 날짜의 끝까지 포함
            records = dietRecordService.getRecordsByUserIdAndDateRange(userId, startDateTime, endDateTime);
        } else {
            records = dietRecordService.getRecordsByUserId(userId);
        }

        List> dtoList = records.stream()
                .map(this::convertToDto)
                .collect(Collectors.toList());

        return ResponseEntity.ok(dtoList);
    }

    // 식단 기록 수정
    @PutMapping("/{id}")
    public ResponseEntity> updateDietRecord(
            @PathVariable("id") Long recordId,
            @RequestBody DietRecordUpdateRequest updateRequest) { // DTO 사용 권장

        DietRecord updatedRecord = dietRecordService.updateRecord(recordId, updateRequest);
        return ResponseEntity.ok(convertToDto(updatedRecord));
    }

    // 식단 기록 삭제
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteDietRecord(@PathVariable("id") Long recordId) {
        dietRecordService.deleteRecord(recordId);
        return ResponseEntity.noContent().build();
    }

    // 즐겨찾기 상태 변경
    @PostMapping("/{id}/favorite")
    public ResponseEntity> toggleFavorite(@PathVariable("id") Long recordId,
                                                            @RequestBody FavoriteStatusRequest request) {
        DietRecord updatedRecord = dietRecordService.toggleFavorite(recordId, request.isFavorite());
        return ResponseEntity.ok(convertToDto(updatedRecord));
    }

    // DTO 변환 메소드 (예시)
    private Map convertToDto(DietRecord record) {
        Map dto = new HashMap<>();
        dto.put("id", record.getId());
        dto.put("userId", record.getUserId());
        dto.put("mealType", record.getMealType());
        dto.put("timestamp", record.getRecordTime());
        dto.put("calories", record.getCalories());
        dto.put("notes", record.getNotes());
        dto.put("isFavorite", record.isFavorite());
        dto.put("foodItems", record.getFoodItems());

        // 이미지 데이터를 Base64로 인코딩하여 포함
        if (record.getImage() != null) {
            String base64Image = java.util.Base64.getEncoder().encodeToString(record.getImage().getImageData());
            dto.put("imageUrl", "data:" + record.getImage().getContentType() + ";base64," + base64Image);
        }
        return dto;
    }
}

// 요청 DTO 예시
class DietRecordUpdateRequest {
    // Getter, Setter
    private String mealType;
    private LocalDateTime timestamp;
    private Double calories;
    private String notes;
    private List<FoodItem> items;
    private boolean isFavorite;
    // ...
}

class FavoriteStatusRequest {
    private boolean favorite;
    public boolean isFavorite() { return favorite; }
    // Getter, Setter
}

4. 데이터 통신 시나리오

4.1 사용자 등록 절차

  1. 프론트엔드에서 사용자 입력 정보 수집:
    { username: 'testuser', password: 'securepassword', email: 'test@example.com' }
  2. dataService.user.register(userData) 호출
  3. Axios가 POST /api/v1/auth/register 요청 전송
  4. 백엔드 컨트롤러가 요청 수신, 서비스 로직 수행, JWT 토큰 생성
  5. 응답으로 사용자 정보와 JWT 토큰 반환
  6. 프론트엔드에서 토큰을 `localStorage`에 저장: localStorage.setItem('authToken', response.token)

4.2 데이터 조회 절차

  1. 프론트엔드 컴포넌트 마운트 시:
    dataService.diet.fetchRecords('123', { date: '2024-07-20' }) 호출
  2. Axios가 GET /api/v1/diet?userId=123&date=2024-07-20 요청 전송 (Authorization 헤더 포함)
  3. JWT 필터가 토큰 유효성 검증
  4. 컨트롤러가 요청 처리, 서비스 및 리포지토리 통해 DB 조회
  5. 조회된 데이터 리스트를 프론트엔드로 반환
  6. 프론트엔드에서 데이터를 받아 화면 렌더링

4.3 파일 업로드 절차

  1. 프론트엔드에서 파일 선택 후 FormData 객체 생성:
    formData.append('mealType', 'Lunch'); formData.append('receiptImage', file);
  2. Axios가 POST /api/v1/diet 요청 전송 (Content-Type: multipart/form-data)
  3. 백엔드에서 @RequestParam으로 파일 및 기타 필드 수신
  4. 수신된 파일 데이터를 처리하여 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.");
}

태그: Vue.js Spring Boot RESTful API axios jwt

7월 30일 14:08에 게시됨