기술 스택
- 백엔드: 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