이전포스팅에 이어서 작성하도록 하겠습니다.
(원본글은 https://www.callicoder.com/spring-boot-spring-security-jwt-mysql-react-app-part-1/)
[Java/Spring-framework] - 스프링 부트(Spring Boot)와 Security, MySQL, React를 사용한 Spring Polling App (1)
이전 포스팅에서는, 기본 도메인 모델과 저장소를 만들었습니다.
이번에는 JWT 인증과 함께 스프링 보안을 구성하고, 사용자가 애플리케이션에 등록하고 로그인 할 수 있도록 API를 작성 해 보겠습니다.
포스팅의 모든 코드는 Github 에 있습니다.
프로젝트 전체 구조
이번 포스팅에서 추가 해 줄 클래스가 많기 때문에, src/main/java 의 패키지 구조만 보여드리도록 하겠습니다.
커스텀 스프링 보안 클래스, 필터, 어노테이션 만들기
com.woolbro.security 패키지를 만들고, 내부에 CurrentUser, CustomUserDetailsService, JwtAuthenticationEntryPoint, JwtAuthenticatioFilter, JwtTokenProvider, UserPrincipal 의 자바 클래스를 만들어 줍니다.
JwtAuthenticationEntryPoint.java
AuthenticationEntryPoint 인터페이스를 구현하는데요, 이 인터페이스는 아래의 링크에 나와있습니다.
AuthenticationEntryPoint 인터페이스를 사용함으로, 인증이 필요한 resource에 엑세스 하려고 시도 할 때에 호출하게 되는데요, 그 중에서 예외가 발생 할 때마다 이 메소드가 호출됩니다.
package com.woolbro.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class);
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
AuthenticationException e) throws IOException, ServletException {
logger.error("Responding with unauthorized error. Message - {}", e.getMessage());
httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
}
}
UserPrincipal.java
다음으로는 UserDetails를 구현할 UserPrincipal클래스를 정의하도록 하겠습니다.
Spring Security는 지금 작성하는 UserPrincipal 객체에 저장 된 정보를 사용하여 인증 및 권한부여를 수행 하게 됩니다.
package com.woolbro.security;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.woolbro.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
public class UserPrincipal implements UserDetails {
private Long id;
private String name;
private String username;
@JsonIgnore
private String email;
@JsonIgnore
private String password;
private Collection<? extends GrantedAuthority> authorities;
public UserPrincipal(Long id, String name, String username, String email, String password,
Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.name = name;
this.username = username;
this.email = email;
this.password = password;
this.authorities = authorities;
}
public static UserPrincipal create(User user) {
List<GrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority(role.getName().name())).collect(Collectors.toList());
return new UserPrincipal(user.getId(), user.getName(), user.getUsername(), user.getEmail(), user.getPassword(),
authorities);
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
@Override
public String getUsername() {
return username;
}
@Override
public String getPassword() {
return password;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
UserPrincipal that = (UserPrincipal) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
}
JWT 생성 및 검증을 위한 JwtTokenProvider.java
사용자가 성공적으로 로그인 한 후 JWT를 생성하고, JWT의 유효성을 검사하는데 사용 됩니다.
package com.woolbro.security;
import io.jsonwebtoken.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
public class JwtTokenProvider {
private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);
@Value("${app.jwtSecret}")
private String jwtSecret;
@Value("${app.jwtExpirationInMs}")
private int jwtExpirationInMs;
public String generateToken(Authentication authentication) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpirationInMs);
return Jwts.builder().setSubject(Long.toString(userPrincipal.getId())).setIssuedAt(new Date())
.setExpiration(expiryDate).signWith(SignatureAlgorithm.HS512, jwtSecret).compact();
}
public Long getUserIdFromJWT(String token) {
Claims claims = Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody();
return Long.parseLong(claims.getSubject());
}
public boolean validateToken(String authToken) {
try {
Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
return true;
} catch (SignatureException ex) {
logger.error("Invalid JWT signature");
} catch (MalformedJwtException ex) {
logger.error("Invalid JWT token");
} catch (ExpiredJwtException ex) {
logger.error("Expired JWT token");
} catch (UnsupportedJwtException ex) {
logger.error("Unsupported JWT token");
} catch (IllegalArgumentException ex) {
logger.error("JWT claims string is empty.");
}
return false;
}
}
위의 JWT를 사용하기 위해서 application.properties에 JWT 등록 정보를 저장 해 줍니다.
application.properties
## App Properties
app.jwtSecret= JWTSuperSecretKey
app.jwtExpirationInMs = 604800000
보안인증필터 JWTAuthenthicationFilter.java
package com.woolbro.security;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Autowired
private JwtTokenProvider tokenProvider;
@Autowired
private CustomUserDetailsService customUserDetailsService;
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Long userId = tokenProvider.getUserIdFromJWT(jwt);
UserDetails userDetails = customUserDetailsService.loadUserById(userId);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}
filterChain.doFilter(request, response);
}
private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7, bearerToken.length());
}
return null;
}
}
filter의 Authoriztion 헤더에서 가져온 JWT를 파싱하고, 사용자의 ID를 읽습니다.
그 후 데이터베이스에서 사용자의 세부 정보를 가져오고 스프링 시큐리티(Spring Security) 내에서 인증을 설정 해 줍니다.
현재 로그인 한 사용자에게 접근 할 수 있는 사용자 정의 Annotaion
CurrentUser.java
package com.woolbro.security;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import java.lang.annotation.*;
@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal
public @interface CurrentUser {
}
Spring Security와 관련된 Annotation을 너무 많이 사용해서 의존성이 높아지는 것을 막아주기 위해 CurrentUser Annotation을 설정 했습니다. 프로젝트에서 Spring Security를 제거해야 한다면, CurrentUser Annotation을 변경함으로써 쉽게 제거 할 수 있습니다. - 추후에 어노테이션을 삭제하는 방법을 자세히 보도록 하겠습니다.
Spring Security, WebMVC, Spring JWT 설정(Config) 하기
프로젝트의 모든 보안 구성을 포함하는 클래스를 작성하도록 하겠습니다.
SecurityConfig.java
package com.woolbro.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.woolbro.security.CustomUserDetailsService;
import com.woolbro.security.JwtAuthenticationEntryPoint;
import com.woolbro.security.JwtAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true, jsr250Enabled = true, prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
CustomUserDetailsService customUserDetailsService;
@Autowired
private JwtAuthenticationEntryPoint unauthorizedHandler;
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter();
}
@Override
public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
authenticationManagerBuilder.userDetailsService(customUserDetailsService).passwordEncoder(passwordEncoder());
}
@Bean(BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.cors().and().csrf().disable().exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests()
.antMatchers("/", "/favicon.ico", "/**/*.png", "/**/*.gif", "/**/*.svg", "/**/*.jpg", "/**/*.html",
"/**/*.css", "/**/*.js")
.permitAll().antMatchers("/api/auth/**").permitAll()
.antMatchers("/api/user/checkUsernameAvailability", "/api/user/checkEmailAvailability").permitAll()
.antMatchers(HttpMethod.GET, "/api/polls/**", "/api/users/**").permitAll().anyRequest().authenticated();
// Add our custom JWT security filter
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
}
}
위의 클래스에 대해서 Annotation 과 구성의 의미를 적어보도록 하겠습니다.
내용이 조금 길기 때문에 아래의 접은글에 넣도록 할게요 :)
1.@EnableWebSecurity
프로젝트에서 웹 보안을 가능하게 하는 기본 Spring Security Annotation 입니다.
2.@EnableGlobalMethodSecurity
메소드 보안을 위해 사용합니다.
@Secured, @RolesAllowd, @PreAuthorize, @PostAuthorize 에 사용됩니다.
아래는 예시입니다.
//secureEnalbed : @Secured 컨트롤러 / 메소드를 보호 할 수 있는 어노테이션
@Secured("ROLE_ADMIN")
public User getAllUsers() {}
@Secured({"ROLE_USER", "ROLE_ADMIN"})
public User getUser(Long id) {}
@Secured("IS_AUTHENTICATED_ANONYMOUSLY")
public boolean isUsernameAvailable() {}
//jsr250Enabled : @RolesAllowed 어노테이션 활성화
@RolesAllowed("ROLE_ADMIN")
public Poll createPoll() {}
//prePostEnabled : PreAuthorize , PostAuthorize 어노테이션 활성화
@PreAuthorize("isAnonymous()")
public boolean isUsernameAvailable() {}
@PreAuthorize("hasRole('USER')")
public Poll createPoll() {}
3. WebSecurityConfigurerAdapter
이 클래스는 Spring Security의 WebSecurityConfigurer 인터페이스를 구현합니다. 기본 보안 구성을 제공합니다.
위에서 작성한 SecurityConfig클래스에 적용된 WebSecurityConfigurerAdapter 는 사용자 정의 보안 구성을 제공하기 위해서 메소드를 확장하고 재정의 합니다.
4. JwtAuthenticationEntryPoint
이 클래스틑 인증절차 없이 자원에 엑세스 하려고 시도하는 클라이언트에게 401 오류를 반환하는데 사용됩니다.
Sprint Security의 AuthenticationEntyPoint 인터페이스를 구현합니다.
5. JwtAuthenticationFilter
JWT인증 토큰을 읽고, 유효성을 검사하고, 토큰과 관련된 세부사항을 로드합니다.
6. AuthenticationMangerBuilder / AuthenticationManger
사용자 인증을 위한 Spring Security 를 생성하는데 사용됩니다. 메모리 내 인증, JDBC 인증, 사용자 정의 인증 등을 사용 할 수 있지만, 이 예시에서는 passwordEncoder를 사용했습니다.
7. HttpSecurity Config
HttpSecurity 구성과 같은 보안기능을 구성하는데 사용됩니다.
csrf, sessionManagement 등의 보호기능 및 규칙을 추가 할 수 있습니다.
로그인 및 회원가입 API 작성
지금까지 작성 한 클래스로 보안 구성이 완료되었습니다. 이제 로그인과 가입 API를 작성하겠습니다.
com.woolbro.payload 패키지를 만들고 내부에 작성하도록 하곘습니다.
LoginRequest.java
package com.woolbro.payload;
import javax.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank
private String usernameOrEmail;
@NotBlank
private String password;
public String getUsernameOrEmail() {
return usernameOrEmail;
}
public void setUsernameOrEmail(String usernameOrEmail) {
this.usernameOrEmail = usernameOrEmail;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
SignUpRequest.java
package com.woolbro.payload;
import javax.validation.constraints.*;
public class SignUpRequest {
@NotBlank
@Size(min = 4, max = 40)
private String name;
@NotBlank
@Size(min = 3, max = 15)
private String username;
@NotBlank
@Size(max = 40)
@Email
private String email;
@NotBlank
@Size(min = 6, max = 20)
private String password;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
JwtAuthenticationResponse.java
package com.woolbro.payload;
public class JwtAuthenticationResponse {
private String accessToken;
private String tokenType = "Bearer";
public JwtAuthenticationResponse(String accessToken) {
this.accessToken = accessToken;
}
public String getAccessToken() {
return accessToken;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public String getTokenType() {
return tokenType;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
}
ApiResponse.java
package com.woolbro.payload;
public class ApiResponse {
private Boolean success;
private String message;
public ApiResponse(Boolean success, String message) {
this.success = success;
this.message = message;
}
public Boolean getSuccess() {
return success;
}
public void setSuccess(Boolean success) {
this.success = success;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
예외처리
요청이 유효하지 않거나 예외적인 상황이 발생하면 예외를 throw 하게 해야 합니다.
com.woolbro.exception 패키지에 작성 해 주겠습니다.
AppException.java
package com.woolbro.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public class AppException extends RuntimeException {
public AppException(String message) {
super(message);
}
public AppException(String message, Throwable cause) {
super(message, cause);
}
}
BadRequestException.java
package com.woolbro.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
public BadRequestException(String message, Throwable cause) {
super(message, cause);
}
}
ResourceNotFoundException.java
package com.woolbro.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
public BadRequestException(String message, Throwable cause) {
super(message, cause);
}
}
인증컨트롤러
마지막으로, AuthController를 작성하여 로그인 및 가입을 위한 API 가 포함 된 컨트롤러를 작성 해 보도록 하겠습니다.
com.woolbro.controller 에 작성하도록 하겠습니다.
AuthController.java
package com.woolbro.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import com.woolbro.exception.AppException;
import com.woolbro.model.Role;
import com.woolbro.model.RoleName;
import com.woolbro.model.User;
import com.woolbro.payload.ApiResponse;
import com.woolbro.payload.JwtAuthenticationResponse;
import com.woolbro.payload.LoginRequest;
import com.woolbro.payload.SignUpRequest;
import com.woolbro.repository.RoleRepository;
import com.woolbro.repository.UserRepository;
import com.woolbro.security.JwtTokenProvider;
import javax.validation.Valid;
import java.net.URI;
import java.util.Collections;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
AuthenticationManager authenticationManager;
@Autowired
UserRepository userRepository;
@Autowired
RoleRepository roleRepository;
@Autowired
PasswordEncoder passwordEncoder;
@Autowired
JwtTokenProvider tokenProvider;
@PostMapping("/signin")
public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginRequest.getUsernameOrEmail(), loginRequest.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = tokenProvider.generateToken(authentication);
return ResponseEntity.ok(new JwtAuthenticationResponse(jwt));
}
@PostMapping("/signup")
public ResponseEntity<?> registerUser(@Valid @RequestBody SignUpRequest signUpRequest) {
if (userRepository.existsByUsername(signUpRequest.getUsername())) {
return new ResponseEntity(new ApiResponse(false, "Username is already taken!"), HttpStatus.BAD_REQUEST);
}
if (userRepository.existsByEmail(signUpRequest.getEmail())) {
return new ResponseEntity(new ApiResponse(false, "Email Address already in use!"), HttpStatus.BAD_REQUEST);
}
// Creating user's account
User user = new User(signUpRequest.getName(), signUpRequest.getUsername(), signUpRequest.getEmail(),
signUpRequest.getPassword());
user.setPassword(passwordEncoder.encode(user.getPassword()));
Role userRole = roleRepository.findByName(RoleName.ROLE_USER)
.orElseThrow(() -> new AppException("User Role not set."));
user.setRoles(Collections.singleton(userRole));
User result = userRepository.save(user);
URI location = ServletUriComponentsBuilder.fromCurrentContextPath().path("/api/users/{username}")
.buildAndExpand(result.getUsername()).toUri();
return ResponseEntity.created(location).body(new ApiResponse(true, "User registered successfully"));
}
}
CORS 사용
WebMvcConfig를 추가 해 주도록 하겠습니다. com.woolbro.config 패키지에 추가 해 주도록 하겠습니다.
WebMvcConfig.java
package com.woolbro.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final long MAX_AGE_SECS = 3600;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**").allowedOrigins("*")
.allowedMethods("HEAD", "OPTIONS", "GET", "POST", "PUT", "PATCH", "DELETE").maxAge(MAX_AGE_SECS);
}
}
'Old Branch' 카테고리의 다른 글
스프링 부트(Spring Boot)와 Security, MySQL, React를 사용한 Spring Polling App(4) (3) | 2019.07.19 |
---|---|
스프링 부트(Spring Boot)와 Security, MySQL, React를 사용한 Spring Polling App(3) (0) | 2019.07.18 |
스프링 부트(Spring Boot)와 Security, MySQL, React를 사용한 Spring Polling App (1) (1) | 2019.07.17 |
Spring MVC 예제 - @RequestMapping 어노테이션 예제 (0) | 2019.07.16 |
Spring MVC 예제 - 직원 관리 프로그램 (2) | 2019.07.15 |