Spring Boot 기반 대학원생 연구 조사 관리 플랫폼 구축

기술 스택

  • 백엔드: Java 8, Spring Boot 2.x, MyBatis-Plus
  • 프론트엔드: Vue.js 2.x, Element UI, Axios
  • 데이터베이스: MySQL 8.0
  • 빌드 도구: Maven 3.8+
  • 개발 환경: IntelliJ IDEA, Node.js 14+

시스템 아키텍처

본 시스템은 대학원생의 연구 주제 선정, 진행 상황 추적, 결과 보고를 계적으로 관리하는 웹 기반 플랫폼이다. RESTful API 설계를 적용하여 프론트엔드와 백엔드를 분리했으며, JWT 기반 인증으로 보안성을 확보했다.

주요 기능 모듈

모듈기능 설명
계정 관리학생/교수/관리자 역할 기반 접근 제어
연구 과제 관리과제 등록, 수정, 삭제, 상태 변경
일정 관리중간 보고, 최종 보고 일정 설정
자료실연구 관련 문서 업로드/다운로드
알림 센터마감 임박, 승인 완료 등 푸시 알림

핵심 구현 코드

인증 처리 컨트롤러

package com.research.platform.controller;

import com.research.platform.annotation.PassAuth;
import com.research.platform.entity.Member;
import com.research.platform.service.MemberService;
import com.research.platform.service.JwtProvider;
import com.research.platform.common.ApiResponse;
import com.research.platform.common.EncryptHelper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private MemberService memberService;
    
    @Autowired
    private JwtProvider jwtProvider;

    @PassAuth
    @PostMapping("/signin")
    public ApiResponse authenticate(@RequestParam String account, 
                                    @RequestParam String secret,
                                    HttpServletRequest req) {
        Member member = memberService.findByAccount(account);
        if (member == null || !EncryptHelper.verify(secret, member.getSecret())) {
            return ApiResponse.fail(401, "인증 정보가 올바르지 않습니다");
        }
        
        String jwt = jwtProvider.createToken(member.getId(), account, 
                                           member.getType(), member.getPermission());
        return ApiResponse.success("token", jwt);
    }

    @PassAuth
    @PostMapping("/signup")
    public ApiResponse enroll(@RequestBody Member candidate) {
        if (memberService.existsByAccount(candidate.getAccount())) {
            return ApiResponse.fail(409, "이미 등록된 계정입니다");
        }
        candidate.setSecret(EncryptHelper.hash(candidate.getSecret()));
        memberService.save(candidate);
        return ApiResponse.ok();
    }

    @PostMapping("/signout")
    public ApiResponse terminate(HttpServletRequest req) {
        req.getSession().invalidate();
        return ApiResponse.ok("로그아웃 완료");
    }

    @PassAuth
    @PostMapping("/secret/reset")
    public ApiResponse recoverSecret(@RequestParam String account) {
        Member target = memberService.findByAccount(account);
        if (target == null) {
            return ApiResponse.fail(404, "계정을 찾을 수 없습니다");
        }
        String temporary = "temp2024!";
        target.setSecret(EncryptHelper.hash(temporary));
        memberService.updateById(target);
        return ApiResponse.success("message", "임시 비밀번호: " + temporary);
    }
}

파일 전송 핸들러

package com.research.platform.controller;

import org.apache.commons.io.IOUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.*;
import java.nio.file.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@RestController
@RequestMapping("/api/storage")
public class StorageController {

    @Value("${app.upload.path}")
    private String uploadBase;

    @PostMapping("/upload")
    public ApiResponse receiveFile(@RequestParam("data") MultipartFile data,
                                   @RequestParam(required = false) String category) throws IOException {
        if (data.isEmpty()) {
            throw new IllegalArgumentException("전송된 파일이 없습니다");
        }
        
        String original = data.getOriginalFilename();
        String extension = original.substring(original.lastIndexOf(".") + 1);
        
        Path targetDir = Paths.get(uploadBase, LocalDateTime.now().format(
                                   DateTimeFormatter.ofPattern("yyyy/MM")));
        if (!Files.exists(targetDir)) {
            Files.createDirectories(targetDir);
        }
        
        String storedName = System.currentTimeMillis() + "_" + 
                          (int)(Math.random() * 10000) + "." + extension;
        Path destination = targetDir.resolve(storedName);
        
        data.transferTo(destination.toFile());
        
        if ("profile".equals(category)) {
            // 프로필 이미지 처리 로직
        }
        
        return ApiResponse.success("filename", storedName);
    }

    @GetMapping("/download/{filename}")
    public ResponseEntity deliverFile(@PathVariable String filename) {
        try {
            Path source = Paths.get(uploadBase).resolve(filename);
            if (!Files.exists(source)) {
                return ResponseEntity.notFound().build();
            }
            
            byte[] content = IOUtils.toByteArray(Files.newInputStream(source));
            
            return ResponseEntity.ok()
                    .header(HttpHeaders.CONTENT_DISPOSITION, 
                           "attachment; filename=\"" + filename + "\"")
                    .contentType(MediaType.APPLICATION_OCTET_STREAM)
                    .body(content);
        } catch (IOException ex) {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }
}

공통 응답 래퍼

package com.research.platform.common;

import java.util.LinkedHashMap;

public class ApiResponse extends LinkedHashMap {
    
    private ApiResponse() {
        this.put("status", 200);
        this.put("success", true);
    }
    
    public static ApiResponse ok() {
        return new ApiResponse();
    }
    
    public static ApiResponse ok(String message) {
        ApiResponse resp = new ApiResponse();
        resp.put("message", message);
        return resp;
    }
    
    public static ApiResponse success(String key, Object value) {
        ApiResponse resp = new ApiResponse();
        resp.put(key, value);
        return resp;
    }
    
    public static ApiResponse fail(int code, String reason) {
        ApiResponse resp = new ApiResponse();
        resp.put("status", code);
        resp.put("success", false);
        resp.put("error", reason);
        return resp;
    }
    
    public ApiResponse append(String key, Object value) {
        this.put(key, value);
        return this;
    }
}

데이터베이스 설계

-- 연구 과제 테이블
CREATE TABLE research_project (
    project_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    student_id BIGINT NOT NULL,
    advisor_id BIGINT NOT NULL,
    title VARCHAR(200) NOT NULL,
    abstract TEXT,
    status ENUM('PROPOSED','APPROVED','PROGRESS','REVIEW','COMPLETED'),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    deadline DATE,
    FOREIGN KEY (student_id) REFERENCES member(id),
    FOREIGN KEY (advisor_id) REFERENCES member(id)
);

-- 진행 기록 테이블
CREATE TABLE progress_log (
    log_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    project_id BIGINT NOT NULL,
    content TEXT NOT NULL,
    attachment VARCHAR(500),
    recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (project_id) REFERENCES research_project(project_id)
);

배포 구성

application.yml 설정 예시:

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/research_db?useSSL=false&serverTimezone=Asia/Seoul
    username: ${DB_USER:researcher}
    password: ${DB_PASS:secure1234}
    driver-class-name: com.mysql.cj.jdbc.Driver
  
  servlet:
    multipart:
      max-file-size: 50MB
      max-request-size: 100MB

app:
  upload:
    path: /var/research-platform/uploads
  jwt:
    secret: ${JWT_SECRET:platform-research-2024-secret-key}
    expiration: 86400000

태그: Spring Boot Vue.js MyBatis-Plus jwt MySQL

7월 19일 06:01에 게시됨