SpringBoot과 Vue를 활용한 기업 정보 관리 시스템 설계 및 구현

기술 스택 개요

백엔드: SpringBoot

SpringBoot는 내장형 서버(Tomcat, Jetty, Undertow)를 제공하여 별도의 설치와 설정 없이 애플리케이션을 실행할 수 있습니다. 자동 설정(Auto-configuration) 기능은 프로젝트의 의존성을 분석하여 필요한 빈(Bean)들을 자동으로 등록합니다. 또한 Spring Data, Spring Security, Spring Cloud 같은 다양한 스타터 의존성을 통해 개발 생산성을 크게 향상시킬 수 있습니다.

프론트엔드: Vue.js

Vue.js는 가상 DOM(Virtual DOM) 기반의 반응형 프레임워크입니다. 데이터 바인딩을 통해 모델과 뷰를 자동으로 동기화하며, 컴포넌트 기반 아키텍처로 재사용성과 유지보수성을 높였습니다. 상태 관리 라이브러리인 Vuex와 라우팅을 담당하는 Vue Router를 함께 사용하여 SPA(Single Page Application)를 효율적으로 구축할 수 있습니다.

영속성 계층: MyBatis-Plus

MyBatis-Plus는 MyBatis의 기능을 확장한 ORM 프레임워크입니다. CRUD 연산을 위한 범용 Mapper와 강력한 람다식 쿼리 래퍼를 제공하여 SQL 작성 비용을 절감합니다. 페이징 처리, 논리적 삭제, 낙관적 잠금, 자동 채우기 등의 기능을 어노테이션 기반으로 손쉽게 적용할 수 있으며, 코드 생성기로 엔티티, Mapper 인터페이스, XML 매퍼 파일을 자동 생성할 수 있습니다.

시스템 테스트

테스트 목적

시스템 테스트는 요구사항 명세서에 정의된 기능 요건을 충족하는지 검증하는 과정입니다. 사용자 관점에서 다양한 시나리오를 시뮬레이션하여 결함을 조기에 발견하고 수정함으로써 최종 제품의 품질과 신뢰성을 확보합니다.

기능 테스트

블랙박스 테스트 방식을 적용하여 각 모듈의 입력과 출력을 검증합니다. 경계값 분석, 동등 분할, 오류 추정 기법을 활용하여 테스트 케이스를 설계하였습니다.

로그인 기능 테스트:

테스트 데이터기대 결과실제 결과비고
아이디: admin, 비밀번호: validPwd, 캡차: 일치로그인 성공로그인 성공정상 흐름
아이디: admin, 비밀번호: wrongPwd, 캡차: 일치비밀번호 오류 메시지비밀번호 오류 메시지인증 실패
아이디: admin, 비밀번호: validPwd, 캡차: 불일치캡차 오류 메시지캡차 오류 메시지보안 검증
아이디: (공백), 비밀번호: validPwd아이디 필수 입력 안내아이디 필수 입력 안내유효성 검사

사용자 관리 기능 테스트:

테스트 항목입력 데이터기대 결과실제 결과
사용자 등록유효한 사용자 정보등록 완료, 목록에 표시등록 완료, 목록에 표시
정보 수정변경된 사용자 정보수정 완료, 변경 사항 반영수정 완료, 변경 사항 반영
사용자 삭제선택된 사용자 ID삭제 확인 후 목록에서 제거삭제 확인 후 목록에서 제거
필수 필드 누락아이디 미입력필수 입력 안내필수 입력 안내
중복 아이디이미 존재하는 아이디중복 오류 메시지중복 오류 메시지

핵심 코드 구현

인증 컨트롤러

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

    @Autowired
    private MemberService memberService;
    
    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @PostMapping("/signin")
    public ApiResponse<AuthResponse> signIn(@RequestBody SignInRequest request, HttpServletRequest httpRequest) {
        Member member = memberService.findByAccount(request.getAccount());
        
        if (member == null || !member.getPassword().equals(request.getPassword())) {
            return ApiResponse.error("인증 정보가 올바르지 않습니다.");
        }
        
        String accessToken = jwtTokenProvider.createAccessToken(
            member.getId(), 
            member.getAccount(), 
            member.getRole()
        );
        
        AuthResponse response = new AuthResponse();
        response.setAccessToken(accessToken);
        response.setMemberId(member.getId());
        response.setRole(member.getRole());
        
        return ApiResponse.success(response);
    }
}

JWT 토큰 생성 서비스

@Service
public class JwtTokenProvider {

    @Value("${jwt.secret-key}")
    private String secretKey;
    
    @Value("${jwt.expiration-hours}")
    private int expirationHours;
    
    @Autowired
    private TokenRepository tokenRepository;

    public String createAccessToken(Long memberId, String account, String role) {
        String token = generateRandomToken(32);
        LocalDateTime expiryTime = LocalDateTime.now().plusHours(expirationHours);
        
        TokenEntity existingToken = tokenRepository.findByMemberIdAndRole(memberId, role);
        
        if (existingToken != null) {
            existingToken.updateToken(token, expiryTime);
            tokenRepository.save(existingToken);
        } else {
            TokenEntity newToken = TokenEntity.builder()
                .memberId(memberId)
                .account(account)
                .role(role)
                .token(token)
                .expiryTime(expiryTime)
                .createdAt(LocalDateTime.now())
                .build();
            tokenRepository.save(newToken);
        }
        
        return token;
    }
    
    private String generateRandomToken(int length) {
        byte[] randomBytes = new byte[length];
        new SecureRandom().nextBytes(randomBytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
    }
}

인증 인터셉터

@Component
public class AuthenticationInterceptor implements HandlerInterceptor {

    private static final String AUTH_HEADER = "Authorization";
    
    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // CORS 설정
        configureCorsHeaders(request, response);
        
        // OPTIONS 요청 처리
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
            return false;
        }
        
        // SkipAuth 어노테이션 확인
        if (handler instanceof HandlerMethod) {
            HandlerMethod handlerMethod = (HandlerMethod) handler;
            if (handlerMethod.getMethodAnnotation(SkipAuth.class) != null) {
                return true;
            }
        }
        
        String token = request.getHeader(AUTH_HEADER);
        
        if (StringUtils.isBlank(token)) {
            sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "로그인이 필요합니다.");
            return false;
        }
        
        TokenEntity tokenEntity = jwtTokenProvider.validateToken(token);
        
        if (tokenEntity == null) {
            sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다.");
            return false;
        }
        
        // 세션에 사용자 정보 저장
        request.setAttribute("currentMemberId", tokenEntity.getMemberId());
        request.setAttribute("currentRole", tokenEntity.getRole());
        request.setAttribute("currentAccount", tokenEntity.getAccount());
        
        return true;
    }
    
    private void configureCorsHeaders(HttpServletRequest request, HttpServletResponse response) {
        response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
        response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
        response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Max-Age", "3600");
    }
    
    private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException {
        response.setStatus(status);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(String.format("{\"code\":%d,\"message\":\"%s\"}", status, message));
    }
}

데이터베이스 설계

토큰 관리 테이블

CREATE TABLE `auth_tokens` (
  `token_id` BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '토큰 식별자',
  `member_id` BIGINT NOT NULL COMMENT '회원 식별자',
  `account` VARCHAR(50) NOT NULL COMMENT '계정명',
  `role_code` VARCHAR(30) DEFAULT 'MEMBER' COMMENT '권한 코드',
  `access_token` VARCHAR(200) NOT NULL COMMENT '접근 토큰',
  `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '생성 일시',
  `expiry_time` DATETIME NOT NULL COMMENT '만료 일시',
  INDEX `idx_member_role` (`member_id`, `role_code`),
  INDEX `idx_token` (`access_token`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='인증 토큰 관리';

-- 샘플 데이터
INSERT INTO `auth_tokens` VALUES 
(1, 1, 'master', 'ADMIN', 'k7x9m2pqz4wv8n3j6h5ytr1e0safdcgb', NOW(), DATE_ADD(NOW(), INTERVAL 24 HOUR)),
(2, 15, 'user01', 'MEMBER', 'b3n7k9t5m2x8p1q4w6r0zvyjsheuclad', NOW(), DATE_ADD(NOW(), INTERVAL 24 HOUR)),
(3, 22, 'manager01', 'MANAGER', 'q2w5e8r1t4y7u0i3o6p9asdfghjklzxv', NOW(), DATE_ADD(NOW(), INTERVAL 24 HOUR));

태그: SpringBoot Vue.js MyBatis-Plus jwt 시스템테스트

10월 3일 09:16에 게시됨