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