Java Spring Boot와 Vue.js 기반 컴퓨터 교육 관리 시스템 설계 및 구현

Spring Boot는 내장형 서버(Tomcat, Jetty, Undertow 등)를 제공하여 별도 설치나 설정 없이 애플리케이션을 직접 실행할 수 있도록 지원합니다. Spring Boot의 주요 강점은 자동 구성(Auto-configuration) 기능으로, 프로젝트의 의존성을 분석하여 애플리케이션 설정을 자동으로 처리함으로써 개발자가 수동으로 각 의존성을 구성해야 하는 번거로움을 크게 줄여줍니다. 또한 Spring Data, Spring Security, Spring Cloud와 같은 풍부한 기능을 즉시 사용할 수 있는 다양한 기능과 플러그인을 제공합니다. 이러한 기능들은 개발자가 애플리케이션을 더 빠르게 구축하고 다른 기술과의 통합 및 확장을 용이하게 합니다. 자동 구성, 내장 서버, 플러그인 기능 덕분에 Spring Boot는 고품질 애플리케이션을 신속하고 효율적으로 개발하는 데 널리 사용되는 프레임워크입니다.

Vue.js의 핵심은 가상 DOM(Virtual DOM) 기술입니다. 가상 DOM은 메모리 내 데이터 구조로, Vue.js가 효율적인 DOM 조작을 수행하도록 돕습니다. Vue.js는 반응형 데이터 바인딩, 가상 DOM, 컴포넌트 기반 아키텍처와 같은 현대적인 기술을 채택하여 개발자에게 유연하고 효율적이며 유지보수가 용이한 개발 패러다임을 제공합니다. 데이터가 변경될 때 UI가 자동으로 업데이트되므로, 개발자는 수동으로 UI를 업데이트하는 대신 데이터 처리에 집중할 수 있게 되어 Vue.js의 간결성, 유연성, 효율성이 더욱 돋보입니다.

MyBatis-Plus는 MyBatis 프레임워크를 기반으로 개발 편의성을 향상시킨 오픈소스 Java 도구입니다. MySQL, Oracle, SQL Server, PostgreSQL 등 다양한 데이터베이스를 지원하며, 풍부한 API와 어노테이션을 통해 간단한 설정으로 ORM(객체 관계형 매핑) 작업을 수행할 수 있어 수동 SQL 작성 부담을 크게 줄여줍니다. 또한, 엔티티 클래스, Mapper 인터페이스, XML 매핑 파일을 자동 생성하는 코드 생성기를 제공하여 개발 프로세스를 간소화합니다. 페이징 쿼리, 동적 쿼리, 낙관적 잠금, 성능 분석과 같은 유용한 기능도 지원하여 개발자가 효율적으로 데이터를 조작하고 고품질의 데이터 접근 계층 코드를 빠르게 개발할 수 있도록 돕습니다.

시스템 테스트

시스템 테스트는 다양한 관점에서 잠재적 문제를 식별하고 해결하여 시스템의 견고성을 확보하는 데 중점을 둡니다. 기능 테스트를 통해 결함을 찾아 수정하고, 시스템이 고객 요구사항을 충족하는지 검증하며, 문제 발생 시 즉시 개선하여 최종적인 테스트 결론을 도출합니다.

시스템 테스트 목적

소프트웨어 개발 과정에서 시스템 테스트는 품질과 신뢰성을 보장하는 필수적인 최종 단계입니다. 사용자 경험을 향상하고 사용 중 발생할 수 있는 문제를 방지하기 위해 다각적인 시나리오를 시뮬레이션하여 시스템의 잠재적 결함을 발견하고 해결합니다. 이를 통해 시스템 기능의 완전성, 논리적 흐름의 원활성 등 전반적인 시스템 품질을 평가할 수 있습니다. 성공적인 시스템 테스트는 제품의 완성도를 크게 높여줍니다. 테스트의 궁극적인 목표는 시스템이 요구사항 명세서에 정의된 내용을 충족하는지 검증하고, 명세서와 불일치하거나 충돌하는 부분을 찾아내는 것입니다. 테스트는 반드시 사용자 관점에서 진행되어야 하며, 비현실적인 시나리오로 인한 시간 낭비를 피하고 실제 사용자 환경을 반영하여 예상 결과와 실제 결과의 불일치를 최소화해야 합니다.

시스템 기능 테스트

시스템 기능 모듈에 대한 테스트는 클릭, 경계값 및 필수/비필수 항목 입력 검증 등의 방법을 통해 일련의 블랙박스 테스트를 수행합니다. 테스트 케이스를 작성하고 그 내용에 따라 테스트를 진행한 후 최종 테스트 결론을 도출합니다.

로그인 기능 테스트 시나리오: 시스템에 로그인할 때, 계정 ID와 비밀번호 등의 기능 요소를 통해 인증을 수행합니다. 사용자는 데이터베이스에 저장된 데이터와 일치하는 내용을 입력해야 하며, 어느 한 항목이라도 잘못 입력하면 시스템은 오류 메시지를 표시합니다. 이 화면에서는 역할 기반 권한 검증도 이루어지며, 관리자 계정으로 로그인 시도 시에도 오류가 발생할 수 있습니다. 로그인 기능 테스트 케이스는 다음 표와 같습니다.

입력 데이터 예상 결과 실제 결과 결과 분석
사용자 ID: admin_user 비밀번호: secure_pass CAPTCHA: 올바른 입력 시스템 로그인 로그인 성공 예상과 일치
사용자 ID: admin_user 비밀번호: wrong_pass CAPTCHA: 올바른 입력 비밀번호 오류 비밀번호가 올바르지 않습니다. 다시 입력해주세요. 예상과 일치
사용자 ID: admin_user 비밀번호: secure_pass CAPTCHA: 잘못된 입력 CAPTCHA 오류 CAPTCHA 정보가 올바르지 않습니다. 예상과 일치
사용자 ID: (비어있음) 비밀번호: secure_pass CAPTCHA: 올바른 입력 사용자 ID 필수 사용자 ID를 입력해주세요. 예상과 일치
사용자 ID: admin_user 비밀번호: (비어있음) CAPTCHA: 올바른 입력 비밀번호 오류 비밀번호가 올바르지 않습니다. 다시 입력해주세요. 예상과 일치

사용자 관리 기능 테스트 시나리오: 사용자 관리는 주로 사용자 추가, 편집, 삭제, 검색 기능을 포함합니다. 사용자 추가 시 필수 항목을 입력하지 않았을 때 비어있음 검증이 이루어지는지 확인합니다. 이미 존재하는 사용자 정보를 추가할 경우 사용자 이름이 중복되었다는 메시지가 표시되는지 확인합니다. 사용자 정보를 삭제할 때 시스템이 삭제 여부를 묻고 확인 후 사용자가 삭제되는지 검증합니다. 사용자 정보를 변경한 후 변경된 정보가 페이지에 올바르게 표시되는지 확인합니다. 사용자 관리 테스트 케이스는 다음 표와 같습니다.

입력 데이터 예상 결과 실제 결과 결과 분석
사용자 기본 정보 입력 추가 성공, 사용자 목록에 표시 해당 사용자가 목록에 표시됨 예상과 일치
사용자 정보 수정 수정 성공, 정보가 변경됨 사용자 정보가 수정됨 예상과 일치
사용자 선택 후 삭제 시스템에서 삭제 여부 확인, 확인 후 사용자 삭제됨 시스템에서 삭제 여부 확인, 확인 후 사용자 정보를 찾을 수 없음 예상과 일치
사용자 추가 시 사용자 이름 미입력 사용자 이름은 비워둘 수 없습니다. 메시지 표시 사용자 이름은 비워둘 수 없습니다. 메시지 표시 예상과 일치
이미 존재하는 사용자 이름 입력 추가 실패, 사용자 이름 중복 메시지 표시 추가 실패, 사용자 이름 중복 메시지 표시 예상과 일치

시스템 테스트 결론

본 시스템은 주로 블랙박스 테스트를 활용하여 사용자가 시스템의 각 기능을 사용하는 시나리오를 모방하는 테스트 케이스를 작성하고 테스트를 진행했습니다. 이는 시스템 흐름의 정확성을 보장하기 위함입니다. 시스템 테스트는 필수적이며, 시스템의 완성도를 높이고 가용성을 향상시킵니다.

이 시스템을 테스트하는 주된 목적은 기능 모듈이 초기 설계 의도를 충족하는지, 그리고 각 기능 모듈의 논리가 올바른지 검증하는 것이었습니다. 이 시스템은 사용자가 쉽게 조작할 수 있도록 복잡한 논리 처리를 지양했습니다. 테스트의 궁극적인 목표 또한 사용자 경험에 초점을 맞추고 있습니다. 테스트 과정의 모든 시나리오는 사용자 요구사항을 충족해야 하며, 요구 목표에서 벗어나서는 안 됩니다. 문제 발생 시에는 사용자의 관점에서 사고해야 합니다. 일련의 테스트 과정을 거쳐 얻은 최종 테스트 결과에 따르면, 구현된 시스템은 기능 및 성능 측면에서 설계 요구사항을 충족하는 것으로 나타났습니다.

코드 예시


import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import com.example.app.common.response.ServiceResponse; // 가상의 응답 객체
import com.example.app.user.annotation.SkipAuthentication; // 가상의 어노테이션
import com.example.app.user.model.UserAccount; // 가상의 사용자 계정 모델
import com.example.app.user.service.UserAccountService; // 가상의 사용자 계정 서비스
import com.example.app.auth.service.UserAuthService; // 가상의 인증 서비스

// 가상의 R, EntityWrapper 클래스를 대체
class ServiceResponse {
    private int code;
    private String message;
    private Object data;

    public ServiceResponse(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public ServiceResponse(Object data) {
        this.code = 200;
        this.message = "success";
        this.data = data;
    }

    public static ServiceResponse success(String key, Object value) {
        // 간단화를 위해 Map 대신 직접 data 필드에 할당
        return new ServiceResponse(java.util.Collections.singletonMap(key, value));
    }

    public static ServiceResponse fail(String message) {
        return new ServiceResponse(400, message); // 400 Bad Request 가정
    }

    // Getters
    public int getCode() { return code; }
    public String getMessage() { return message; }
    public Object getData() { return data; }
}

@SkipAuthentication // 인증 절차를 건너뛸 메소드에 적용되는 가상의 어노테이션
@PostMapping("/auth/login") // 로그인 API 엔드포인트
public ServiceResponse userLogin(@RequestParam String accountId, @RequestParam String loginSecret, HttpServletRequest req) {
    // 사용자 ID로 사용자 계정 정보 조회
    UserAccount userEntry = userAccountService.findByAccountId(accountId);

    // 사용자 정보가 없거나 입력된 비밀번호가 일치하지 않을 경우
    if (userEntry == null || !userEntry.getLoginPasswordHash().equals(loginSecret)) {
        return ServiceResponse.fail("계정 또는 비밀번호가 올바르지 않습니다.");
    }

    // 로그인 성공 시 인증 토큰 생성
    String authToken = userAuthService.generateAuthToken(userEntry.getUserId(), accountId, "user_accounts", userEntry.getUserType());
    
    return ServiceResponse.success("token", authToken); // 성공 응답과 함께 토큰 반환
}

// UserAuthService (가상) 구현의 일부
import java.util.Calendar;
import java.util.Date;
// import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; // MyBatis-Plus의 ServiceImpl 가정

interface UserAuthService {
    String generateAuthToken(Long userId, String loginId, String entityName, String userRole);
    SessionToken getSessionTokenInfo(String authToken);
}

// SessionToken (가상) 모델
class SessionToken {
    private Long id;
    private Long userId;
    private String loginId;
    private String entityName;
    private String userRole;
    private String authToken;
    private Date createdAt;
    private Date expiryTimestamp;

    public SessionToken() {}

    public SessionToken(Long userId, String loginId, String entityName, String userRole, String authToken, Date expiryTimestamp) {
        this.userId = userId;
        this.loginId = loginId;
        this.entityName = entityName;
        this.userRole = userRole;
        this.authToken = authToken;
        this.createdAt = new Date(); // 현재 시간으로 생성 시간 설정
        this.expiryTimestamp = expiryTimestamp;
    }

    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public Long getUserId() { return userId; }
    public void setUserId(Long userId) { this.userId = userId; }
    public String getLoginId() { return loginId; }
    public void setLoginId(String loginId) { this.loginId = loginId; }
    public String getEntityName() { return entityName; }
    public void setEntityName(String entityName) { this.entityName = entityName; }
    public String getUserRole() { return userRole; }
    public void setUserRole(String userRole) { this.userRole = userRole; }
    public String getAuthToken() { return authToken; }
    public void setAuthToken(String authToken) { this.authToken = authToken; }
    public Date getCreatedAt() { return createdAt; }
    public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
    public Date getExpiryTimestamp() { return expiryTimestamp; }
    public void setExpiryTimestamp(Date expiryTimestamp) { this.expiryTimestamp = expiryTimestamp; }
}


// TokenGenerator (가상) 유틸리티 클래스
class TokenGenerator {
    public static String generateRandomToken(int length) {
        String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        StringBuilder sb = new StringBuilder(length);
        java.util.Random random = new java.util.Random();
        for (int i = 0; i < length; i++) {
            sb.append(chars.charAt(random.nextInt(chars.length())));
        }
        return sb.toString();
    }
}

// UserAuthServiceImpl (가상 구현체)
// class UserAuthServiceImpl extends ServiceImpl<AuthTokenMapper, SessionToken> implements UserAuthService {
//     @Override
//     public String generateAuthToken(Long userId, String loginId, String entityName, String userRole) {
//         // 기존 토큰 엔티티 조회 (사용자 ID와 역할 기준)
//         // 여기서 'this.query().eq(...).one()'는 MyBatis-Plus의 쿼리 빌더를 가정한 것입니다.
//         // 실제 구현에서는 AuthTokenMapper를 통해 데이터베이스를 조회하게 됩니다.
//         SessionToken existingToken = this.query().eq("user_id", userId).eq("user_role", userRole).one();
        
//         // 32자리 임의 문자열로 새 토큰 생성
//         String newAuthToken = TokenGenerator.generateRandomToken(32);
        
//         // 만료 시간 설정: 현재 시간으로부터 1시간 후
//         Calendar calendar = Calendar.getInstance();
//         calendar.add(Calendar.HOUR_OF_DAY, 1);
//         Date expirationTime = calendar.getTime();

//         if (existingToken != null) {
//             // 기존 토큰이 있으면 업데이트
//             existingToken.setAuthToken(newAuthToken);
//             existingToken.setExpiryTimestamp(expirationTime);
//             this.updateById(existingToken);
//         } else {
//             // 기존 토큰이 없으면 새로 삽입
//             SessionToken newToken = new SessionToken(userId, loginId, entityName, userRole, newAuthToken, expirationTime);
//             this.save(newToken);
//         }
//         return newAuthToken;
//     }

//     @Override
//     public SessionToken getSessionTokenInfo(String authToken) {
//         // 실제 구현에서는 auth_token으로 데이터베이스에서 SessionToken을 조회합니다.
//         // return this.query().eq("auth_token", authToken).one();
//         return null; // 예시를 위해 null 반환
//     }
// }

import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.method.HandlerMethod;
import org.springframework.http.HttpStatus;
import org.springframework.util.StringUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import com.fasterxml.jackson.databind.ObjectMapper; // JSON 직렬화를 위한 라이브러리

// Custom annotations and service for rewritten names
// import com.example.app.user.annotation.PublicAccess;
// import com.example.app.auth.service.UserAuthService;
// import com.example.app.auth.model.SessionToken;
import org.springframework.beans.factory.annotation.Autowired;

/**
 * 사용자 세션 및 권한(인증 토큰) 검증 인터셉터
 */
@Component
public class SessionValidationInterceptor implements HandlerInterceptor {

    public static final String AUTH_HEADER_NAME = "X-Auth-Token"; // 인증 토큰을 전달하는 HTTP 헤더 이름

    @Autowired
    private UserAuthService userAuthService; // 사용자 인증 관련 서비스 주입
    
	@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // CORS(Cross-Origin Resource Sharing) 설정: 모든 출처, 메서드, 헤더 허용
        response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE");
        response.setHeader("Access-Control-Max-Age", "3600"); // 3600초 (1시간) 동안 Preflight 요청 캐시
        response.setHeader("Access-Control-Allow-Credentials", "true"); // 자격 증명(쿠키, HTTP 인증) 허용
        response.setHeader("Access-Control-Allow-Headers", "x-requested-with,X-Auth-Token, Origin, Content-Type, cache-control, Accept");
        response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin")); // 요청 Origin에 따라 허용

        // HTTP OPTIONS 요청은 Preflight 요청으로, 실제 요청 전에 브라우저가 보냅니다.
        // 인증 절차 없이 즉시 OK 응답을 반환하여 CORS 문제를 방지합니다.
        if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
        	response.setStatus(HttpStatus.OK.value());
            return false;
        }
        
        // 핸들러가 메소드인지 확인하고, PublicAccess 어노테이션이 있는지 검사
        // PublicAccess 어노테이션이 있는 메소드는 인증 절차를 건너뜝니다.
        SkipAuthentication publicAccessAnnotation = null; // @SkipAuthentication 어노테이션 사용
        if (handler instanceof HandlerMethod) {
            publicAccessAnnotation = ((HandlerMethod) handler).getMethodAnnotation(SkipAuthentication.class);
        } else {
            // 핸들러가 메소드가 아닌 경우 (예: 정적 리소스 핸들러)는 인증을 건너뜁니다.
            return true;
        }

        // @SkipAuthentication 어노테이션이 있으면 인증 절차 건너뛰기
        if(publicAccessAnnotation != null) {
        	return true;
        }
        
        // HTTP 헤더에서 인증 토큰 추출
        String authToken = request.getHeader(AUTH_HEADER_NAME);
        
        SessionToken sessionInfo = null;
        // 토큰 문자열이 유효한지 확인 후 세션 정보 조회
        if(StringUtils.hasText(authToken)) {
        	sessionInfo = userAuthService.getSessionTokenInfo(authToken);
        }
        
        // 세션 정보가 유효하고 토큰 만료 시간이 현재 시간보다 이후인 경우
        if(sessionInfo != null && sessionInfo.getExpiryTimestamp().after(new java.util.Date())) {
            // 유효한 토큰인 경우 세션 속성 설정
        	request.getSession().setAttribute("currentUserId", sessionInfo.getUserId());
        	request.getSession().setAttribute("currentUserRole", sessionInfo.getUserRole());
        	request.getSession().setAttribute("relatedEntity", sessionInfo.getEntityName());
        	request.getSession().setAttribute("userLoginId", sessionInfo.getLoginId());
        	return true; // 요청 처리 계속 진행
        }
        
        // 인증 실패 또는 토큰 만료 시 401 Unauthorized 응답 반환
		response.setCharacterEncoding("UTF-8");
		response.setContentType("application/json; charset=utf-8");
        PrintWriter out = null;
		try {
		    out = response.getWriter();
            // JSON 응답 메시지 생성 (ServiceResponse 객체 사용)
            String errorResponseJson = new ObjectMapper().writeValueAsString(
                new ServiceResponse(HttpStatus.UNAUTHORIZED.value(), "인증 토큰이 유효하지 않거나 만료되었습니다. 다시 로그인해주세요.")
            );
		    out.print(errorResponseJson);
		} finally {
		    if(out != null){
		        out.close();
		    }
		}
		return false; // 요청 처리 중단
    }
}

데이터베이스 스키마 예시


-- ----------------------------
-- 테이블 구조: user_sessions (사용자 세션 토큰 관리)
-- ----------------------------
DROP TABLE IF EXISTS `user_sessions`;
CREATE TABLE `user_sessions` (
  `session_id`          BIGINT(20)    NOT NULL AUTO_INCREMENT COMMENT '세션 고유 ID',
  `user_id`             BIGINT(20)    NOT NULL COMMENT '연결된 사용자 ID',
  `login_id`            VARCHAR(100)  NOT NULL COMMENT '사용자 로그인 ID',
  `associated_entity`   VARCHAR(100)  DEFAULT NULL COMMENT '관련 엔티티 유형 (예: student, admin, teacher)',
  `user_role`           VARCHAR(100)  DEFAULT NULL COMMENT '사용자 역할 (예: 학생, 관리자, 강사)',
  `auth_token`          VARCHAR(200)  NOT NULL COMMENT '인증 토큰 문자열',
  `created_at`          TIMESTAMP     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '세션 생성 시간',
  `expires_at`          TIMESTAMP     NOT NULL DEFAULT '0000-00-00 00:00:00' COMMENT '세션 만료 시간',
  PRIMARY KEY (`session_id`) USING BTREE,
  UNIQUE KEY `idx_user_role_unique` (`user_id`, `user_role`) -- 사용자별 역할 토큰 중복 방지 (활성 세션은 하나만 유지)
) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 세션 토큰 관리 테이블';

-- ----------------------------
-- user_sessions 테이블 샘플 레코드
-- ----------------------------
INSERT INTO `user_sessions` (`session_id`, `user_id`, `login_id`, `associated_entity`, `user_role`, `auth_token`, `created_at`, `expires_at`) VALUES
('1001', '101', 'std001', 'student', '학생', 'abcdefg12345hijklmn67890opqrstuvwxyz', '2023-08-01 10:00:00', '2023-08-01 11:00:00'),
('1002', '102', 'adm001', 'admin', '관리자', 'qwertyuiopasdfghjklzxcvbnm123456789', '2023-08-01 10:05:00', '2023-08-01 11:05:00'),
('1003', '103', 'tch001', 'teacher', '강사', 'mnbvcxzlkjhgfdsapoiuytrewq0987654321', '2023-08-01 10:10:00', '2023-08-01 11:10:00'),
('1004', '101', 'std001', 'student', '학생', 'newtokenforstd001xyzabcdefghijklmnopqrst', '2023-08-01 10:30:00', '2023-08-01 11:30:00');

태그: java Spring Boot Vue.js MyBatis-Plus 시스템 테스트

5월 25일 07:56에 게시됨