* 서버는 세션 DB에 사용자의 정보를 저장 → 생성된 세션 ID를 (일반적으로) 쿠키에 넣어서 클라이언트에 반환 JWT 방식
* 서버는 사용자의 정보 가운데 민감 정보가 아닌 정보로 토큰을 생성해 클라이언트에 반환
로그인 이후 요청
세션 방식
* 클라이언트는 세션 ID를 포함한 쿠키를 서버에 전달 → 서버는 쿠키에서 세션 ID 추출 → 세션 DB에서 해당 아이디에 해당하는 레코드가 존재하는지 조회 JWT 방식
* 서버는 서명 부분을 확인해 해당 토큰이 조작되었는지 판별 → 조작되지 않았다면 토큰으로부터 사용자의 아이디와 같이 식별할 수 있는 정보를 꺼내서 사용
JWT와 로그아웃
순수 JWT 방식을 사용할 때, 로그아웃은 클라이언트 측에서 쿠키나 로컬 스토리지에서 JWT 토큰을 삭제하거나무효화하는방식으로 구현할 수 있습니다. 그러나, 토큰이 유출될 경우 피해를 막을 수 있는 방법이 없다는 한계가 존재합니다.
그래서 토큰 유출에 대비해 Access Token의 유효 기간은 짧게, Refresh Token으로 Access Token을 재발급해주는 방법을 대안으로 사용합니다. 이 과정을 위해Refresh Token은 서버에 저장되어야 하며, 이 때 데이터 베이스를 필요로 합니다. 그래서 Redis를 설정하고자 합니다.
또한 반대로, 로그아웃 시에는 Refresh Token으로 Access Token을 발급받을 수 없게 해야합니다. 따라서 Refresh Token용 데이터 베이스를 사용하되, 로그아웃한 사용자의 Refresh Token을 데이터베이스에 저장하여 해당 데이터 베이스에 정보가 있을 경우 Access Token 발급을 거절할 수 있도록 합니다. 이를 블랙리스트 방식이라 합니다.
왜 Redis인가
1. Redis는 메모리 기반의 NoSQL 데이터베이스로, 기존의 관계형 데이터베이스(MySQL 등)보다 훨씬 빠르게 데이터를 처리할 수 있음
* 디스크 기반 데이터베이스이며, 많은 트래픽이 발생할 경우 성능에 문제가 생길 수 있음
2. 데이터에 TTL(Time-To-Live)을 설정할 수 있어, 만료된 JWT 토큰을 자동으로 삭제할 수 있음
@Component
@ConfigurationProperties(prefix = "spring.redis")
// application.yml 또는 application.properties 파일에서 spring.redis로 시작하는 설정 값을 이 클래스의 필드에 바인딩
@Getter @Setter
public class RedisProperties {
private int port;
private String host;
}
4. RedisConfig.java
@RequiredArgsConstructor
@Configuration
@EnableRedisRepositories
public class RedisConfig {
private final RedisProperties redisProperties;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort());
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
return redisTemplate;
}
}
5. RedisUtil.java
@Component
@RequiredArgsConstructor
public class RedisUtil {
private final RedisTemplate<String, Object> redisTemplate;
private final RedisTemplate<String, Object> redisBlackListTemplate;
public void set(String key, Object o, int minutes) {
redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(o.getClass()));
redisTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
}
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
public boolean delete(String key) {
return Boolean.TRUE.equals(redisTemplate.delete(key));
}
public boolean hasKey(String key) {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
public void setBlackList(String key, Object o, int minutes) {
redisBlackListTemplate.setValueSerializer(new Jackson2JsonRedisSerializer(o.getClass()));
redisBlackListTemplate.opsForValue().set(key, o, minutes, TimeUnit.MINUTES);
}
public Object getBlackList(String key) {
return redisBlackListTemplate.opsForValue().get(key);
}
public boolean deleteBlackList(String key) {
return Boolean.TRUE.equals(redisBlackListTemplate.delete(key));
}
public boolean hasKeyBlackList(String key) {
return Boolean.TRUE.equals(redisBlackListTemplate.hasKey(key));
}
}
+ 블랙 리스트 외 다른 로그아웃 방법
프론트에서 토큰 삭제
* storage에 있는 토큰을 삭제하는 방법
* 장점: 간단
* 단점: 토큰이 유출되었을 경우, 보안상 위험
++ 추가적인 보완책
IP 주소나 기기 정보를 토큰에 포함시켜 토큰을 사용하는 클라이언트 신원 확인 다른 기기나 IP 주소에서 사용되는 것을 방지
🙋♀️
본 포스트는 공부 목적으로 작성하였습니다. 보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.
이제 UI를 보면서 Controller를 수정하고 있는데, 마침 회원 가입의 마지막 장식을 JWT와 함께 해보고자 이 글을 작성했습니다.
Spring Security 스프링 기반 애플리케이션의 보안(인증과 권한)을 담당하는 프레임워크
*인증 절차를 거친 후 → 인가 절차를 진행
*인증과 인가를 위해 Principal을 아이디로, Credential을 비밀번호로 사용하는 Credential 기반의 인증 방식 사용
1. Http Request 수신 사용자가 로그인 정보와 함께 인증 요청
2/3. AuthenticationFilter 동작 아이디와 비밀번호의 유효성 검사 실시 후 인증용 객체인 UsernamePasswordAuthenticationToken 반환 → FIlter를 통해 AuthenticationToken을 AuthenticationManager로 전달
4. AuthenticationManager 동작 * Authentication Manager는 List형태로 Provider들을 갖고 있음 Token을 처리할 수 있는 Authentication Provider 선택 → 실제 인증을 할 AuthenticationProvider에게 인증용 객체를 다시 전달
5. AuthenticationProvider 실제 데이터베이스에서 사용자 인증 정보를 가져오는 UserDetailsService에 사용자 정보 전달
6. UserDetails를 이용해 User객체에 대한 정보 탐색 loadUserByUsername()을 호출 → DB에 있는 사용자의 정보를 UserDetails 형으로 가져옴 * 만약 사용자가 존재하지 않으면 예외 발생 User 객체의 정보들을 UserDetails가 UserDetailsService(LoginService)로 전달
7. AuthenticaitonProvider들은 UserDetails를 넘겨받고 사용자 정보를 비교 * DB에서 가져온 이용자 정보와 화면에서 입력한 로그인 정보를 비교 → 일치하면 Authentication 참조를 리턴 / 일치 하지 않으면 예외 호출
8/9. AuthenticationFilter에 Authentication 객체 반환
10. SecurityContext에 인증 객체를 설정 Authentication 객체를 Security Context에 저장
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
// UserDetailsService: Spring Security에서 유저 정보를 가져오는 Interface
// 기본 Override Method: loadUserByUsername
// loadUserByUsername: 유저 정보를 가져와 UserDetails로 return
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email).orElseThrow(()->
new BusinessException(NOT_FOUND_MEMBER));
//user의 role을 스프링시큐리티의 GrantedAuthority로 바꿔준다. 여러개의 role을 가질수 있으므로 Set
Set<GrantedAuthority> authorities = Set.of(SecurityUtils.convertToAuthority(user.getRole().name()));
return UserPrinciple.builder()
.email(user.getEmail())
.password(user.getPassword())
.user(user)
.build();
}
}
UserPrinciple.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserPrinciple implements UserDetails {
private String email;
transient private String password; // 직렬화 과정에서 제외
transient private User user; // 직렬화 과정에서 제외
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null; // 계정의 권한 목록
}
@Override
public String getUsername() {
return email; // 계정의 고유한 값 리턴
}
@Override
public String getPassword() {
return password; // 계정의 비밀번호 리턴
}
@Override
public boolean isAccountNonExpired() { return true; }
@Override
public boolean isAccountNonLocked() { return true;}
@Override
public boolean isCredentialsNonExpired() { return true; }
@Override
public boolean isEnabled() { return true; }
}
SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable)
.cors(httpSecurityCorsConfigurer -> corsConfigurationSource())
.authorizeRequests((auth) -> auth
.requestMatchers("/api/v2/users/me").permitAll())
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* CSRF(Cross Site Request Forgery): 사이트간 위조 요청
* csrf(): HTTP 요청에 대해 CSRF 토큰을 생성하고, 이 토큰을 쿠키에 저장
* 모든 POST, PUT, DELETE 요청에 대해 CSRF 토큰이 함께 전송되는지 검증
*
* REST API를 이용한 서버라면, session 기반 인증과는 다르게 stateless(= 서버에 인증 정보를 보관하지 않음)
* 사용자가 로그인 상태를 유지하고 있는 동안 다른 사이트에서 악의적인 요청을 보내는 것을 방지하는 데 유용한 CSRF 사용 필요성이 낮음
* 대신, Client는 권한이 필요한 요청을 하기 위해서는 요청에 필요한 인증 정보를(OAuth2, jwt토큰 등)을 포함시켜야 함
*
* AbstractHttpConfigurer::disable
* AbstractHttpConfigurer 클래스 안에 disable 메서드를 참조
* */
/**
* CORS(Cross-Origin Resource Sharing): 웹 페이지가 자신과 다른 출처의 리소스에 접근할 때 발생하는 보안 정책
* httpSecurityCorsConfigurer를 corsConfigurationSource()로 정의
* */
/**
* authorizeRequests: 특정 URL 경로에 대한 접근 권한을 설정
* requestMatchers("...").permitAll()은 "..." 엔드포인트에 대한 모든 요청을 허용
* 즉, 인증되지 않은 사용자도 이 경로에 접근할 수 있음
* */
/**
* addFilterBefore: JWT 인증 필터 추가
* JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가
* */
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET","POST","PUT","DELETE"));
configuration.setAllowedHeaders(Arrays.asList("*"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
JwtTokenProvider
@Slf4j
@Component // Spring이 이 클래스를 자동으로 스캔하고 빈으로 등록
@RequiredArgsConstructor
public class JwtTokenProvider {
private static final String AUTHORITIES_KEY = "roles";
private static final String BEARER_TYPE = "Bearer";
@Value("${jwt.secretKey}")
private String JWT_SECRET;
private final UserRepository userRepository;
// 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
public TokenInfo generateToken(UserPrinciple auth) {
Date now = new Date();
Date expiration = new Date(now.getTime() + Duration.ofMinutes(1).toMillis());
String authorites = auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Key secretKey = Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8));
// Access Token 생성
String accessToken = Jwts.builder()
.setSubject(auth.getEmail())
.claim(AUTHORITIES_KEY, authorites)
.setExpiration(expiration)
.signWith(secretKey)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(expiration)
.signWith(secretKey)
.compact();
return TokenInfo.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(ServletRequest request) {
//유저 정보 부분만 가져옴
Claims claims = parseClaims(request);
if(claims == null)
throw new BusinessException(NOT_FOUND_MEMBER);
if(claims.getSubject() == null)
throw new BusinessException(NOT_FOUND_MEMBER);
if (claims.get(AUTHORITIES_KEY) == null)
throw new BusinessException(NOT_FOUND_AUTHORITY);
// 클레임에서 권한 정보 가져오기
Set<GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY)
.toString()
.split(","))
.map(SecurityUtils::convertToAuthority) // 스트림의 각 요소를 다른 형태로 변환하는 데 사용
.collect(Collectors.toSet());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails userDetails = UserPrinciple.builder()
.email(claims.getSubject())
.authorities(authorities)
.build();
return new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
}
/**
* UsernamePasswordAuthenticationToken
* userDetails: 사용자의 정보(예: 이메일, 권한)를 담고 있는 객체
* null: 이 위치에 보통은 비밀번호나 인증 토큰이 들어가지만, 여기서는 이미 인증이 된 상태이므로 필요하지 않아 null로 설정
* authorities: 사용자가 가지고 있는 권한 목록
* */
// 토큰 정보를 검증하는 메서드
public boolean isValidToken(ServletRequest request) {
Key secretKey = Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8));
String accessToken = SecurityUtils.resolveToken((HttpServletRequest) request);
try {
Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(accessToken);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
System.out.println("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
System.out.println("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
System.out.println("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalStateException e) {
System.out.println("JWT 토큰이 잘못되었습니다.");
}
return false;
}
// 리퀘스트의 토큰에서 암호풀어 Claims 가져오기
private Claims parseClaims(ServletRequest request) {
String accessToken = SecurityUtils.resolveToken((HttpServletRequest) request);
if(accessToken == null)
return null;
Key secretKey = Keys.hmacShaKeyFor(JWT_SECRET.getBytes(StandardCharsets.UTF_8));
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(accessToken)
.getBody();
}
}
SecurityUtils.java
public class SecurityUtils {
//Token 값을 가져오는데 필요한 값들
public static final String AUTH_HEADER = "authorization";
public static final String AUTH_TOKEN_TYPE = "Bearer";
public static final String AUTH_TOKEN_PREFIX = AUTH_TOKEN_TYPE + " "; // 한 칸 띄우는것을 넣어줘야함
// Request Header에서 토큰 정보 추출
public static String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTH_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(AUTH_TOKEN_TYPE)) {
return bearerToken.substring(7);
}
return null;
}
}
JwtAuthentificationFilter
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
// 요청이 들어올 때마다 JWT 토큰을 검사하고 인증 정보를 설정
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final JwtTokenProvider jwtTokenProvider;
// JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext에 저장하는 역할 수행
@Override
public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
// 1. Request Header에서 JWT 토큰 추출
String accessToken = SecurityUtils.resolveToken(request);
// 2. 토큰 유효성 검사
// 토큰이 유효할 경우, 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
if (accessToken != null && jwtTokenProvider.isValidToken(request)) {
Authentication authentication = jwtTokenProvider.getAuthentication(request);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
// 현재 필터가 요청을 처리한 후, 필터 체인의 다음 필터로 요청과 응답을 전달
}
}
2. WeakKeyException 오류 수정
The specified key byte array is 96 bits which is not secure enough for any JWT HMAC-HA algorithm. The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HS256 MUST have a size >= 256 bits (the key size must be greater than or equal to the hash output size).
JWT(JSON Web Token)를 생성하거나 검증할 때 사용하는 서명 키의 크기가 HS256 알고리즘을 사용하기에 충분하지 않아서 발생한 것입니다. HS256 알고리즘을 사용할 때는 최소 256비트 이상의 키가 필요합니다. 하지만 현재 사용 중인 키는 96비트로, 이는 충분히 안전하지 않다고 경고하고 있습니다.
JwtTokenProvider
@Component
public class JwtTokenProvider {
private final Key secretKey;
public JwtTokenProvider() {
this.secretKey = Keys.secretKeyFor(SignatureAlgorithm.HS256);
}
public String createToken(String subject){
Date now = new Date();
Date expiration = new Date(now.getTime() + Duration.ofDays(1).toMillis());
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setIssuer("test")
.setIssuedAt(now)
.setExpiration(expiration)
.setSubject(subject)
.signWith(secretKey)
.compact();
}
public Claims parseJwtToken(String token) {
token = bearerRemove(token);
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
}
private String bearerRemove(String token) {
return token.substring("Bearer ".length());
}
}
SecretKey를 알고리즘에 맞게 생성해서 사용하면 됩니다.
+ 추가적으로, signWith(io.jsonwebtoken.SignatureAlgorithm, java.lang.String)'가 deprecated 되어 String값을 넣는 것이 아닌 Key값을 생성하고 서명을 진행하는 것으로 수정되었습니다.
하지만, 문제는 해당 방식으로 사용할 경우 애플리케이션이 시작될 때마다 새로운 키를 생성하기 때문에 토큰이 서버를 재시작할 때마다 유효하지 않게 될 수 있습니다.
운영 환경에서는 일반적으로 고정된 키를 사용하여 여러 서버 인스턴스 간에 일관된 JWT 검증이 가능하도록 해야 합니다.
분산된 시스템이나 로드 밸런싱된 환경에서는 동일한 비밀 키를 사용하여 여러 서버 인스턴스 간에 JWT 검증을 수행해야 합니다. 이 방식은 각 인스턴스마다 다른 키를 생성하게 되므로, 서로 다른 서버에서 생성된 JWT가 검증되지 않는 문제가 발생할 수 있습니다.
따라서, 환경 변수나 키 관리 도구를 사용해 키를 일관되게 관리하는 것이 좋습니다.
3. 환경 변수를 활용한 비밀 키 저장
application.yml
jwt:
secretKey: ...
이 때, scretKey는 git bash나 리눅스에서 아래 명령어를 쳐서 나온 걸 사용할 수 있습니다.
openssl rand -hex 64
생성자 부분을 수정해줍니다.
private final Key secretKey;
public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey) {
this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
🙋♀️
본 포스트는 공부 목적으로 작성하였습니다. 보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.
public void validateDuplicateMember(Member member) {
List<Member> findMembers = memberRepository.findByName(member.getName());
if(!findMembers.isEmpty()){
throw new IllegalStateException("이미 존재하는 회원입니다.");
}
}
그대로 코드를 사용할 수는 있지만, IllegalStateException 대신 CustomException을 사용해보고 싶었습니다.
왜냐면 HttpStatus도 함께 사용하는 멋진 코드를 발견했기 때문이죠.
그렇기에 이 글을
Error Code를 Enum 형태로 만들고
CustomException을 만들어
Test Code를 작성해 본다
를 정리한 글입니다.
1. Error code Enum 생성
package com.msgs.msgs.error;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public enum ErrorCode {
// 회원
CHECK_LOGIN_ID_OR_PASSWORD(HttpStatus.NOT_FOUND, "아이디 또는 비밀번호를 확인해주세요."),
DUPLICATED_EMAIL(HttpStatus.BAD_REQUEST, "이미 존재하는 이메일 입니다.");
private final HttpStatus httpStatus;
private final String message;
ErrorCode(HttpStatus httpStatus, String message) {
this.httpStatus = httpStatus;
this.message = message;
}
}
제가 알던 Enum과는 모양이 달라서 많이 놀랐습니다.
Enum은 아래와 같이 간단한 열거형만 써봤기 때문이죠.
public enum ErrorCode {
CHECK_LOGIN_ID_OR_PASSWORD,
DUPLICATED_EMAIL,
}
Enum 요소에 특정 값을 연결하려면 필드값을 추가해주면 됩니다.
Enum과 필드값을 매핑해주면, if문의 사용을 줄여 가독성을 높일 수 있습니다.
이 때, 유의해야 할 점은 반드시 Enum 값 선언 이후에 이후에 필드/생성자가 위치해야 한다는 것입니다.
그리고 Enum class에서 자체적으로 name()를 제공하기 때문에, 필드값을 name을 사용하지 않는 것이 좋습니다.
+ Enum은 Enum 값으로 데이터를 찾을 수 있지만 필드의 경우에는 따로 메서드를 만들어 줘야 합니다.
// HttpStatus -> ErrorCode 조회
private static final Map<HttpStatus, ErrorCode> BY_HTTPSTATUS =
Stream.of(values()).collect(Collectors.toMap(ErrorCode::getHttpStatus, e -> e));
public static Optional<ErrorCode> valueOfHttpStatus(HttpStatus httpStatus){
return Optional.ofNullable(BY_HTTPSTATUS.get(httpStatus));
}
// Message -> ErrorCode 조회
private static final Map<String, ErrorCode> BY_MESSAGE =
Stream.of(values()).collect(Collectors.toMap(ErrorCode::getMessage, e -> e));
public static Optional<ErrorCode> valueOfMessage(String message){
return Optional.ofNullable(BY_MESSAGE.get(message));
}
2. CustomException 생성
ErrorCode를 받아주는 CustomException을 만들어줍니다.
@Getter
public class BusinessException extends RuntimeException{
private ErrorCode errorCode;
public BusinessException(ErrorCode errorCode) {
this.errorCode = errorCode;
}
}
RuntimeException도 상속을 받습니다.
3. Test Code 작성
1 기능, 1 테스트 코스..
특히 테스트 코드는 익숙하지 않기 때문에 기능에 맞춰 계속 추가해 볼 예정입니다.
@SpringBootTest
@Transactional
public class UserServiceTest {
// ...
@Test
@DisplayName("이메일이 동일한 회원이 존재할 경우, 예외가 발생한다.")
public void emailDuplicateCheck() throws Exception {
// given
User userA = new User();
userA.setStatus("M");
userA.setEmail("test@email.com");
userA.setPhone("01023456789");
userService.create(userA);
String existingEmail = "test@email.com";
// when
// then
BusinessException exception = assertThrows(BusinessException.class,
() -> userService.emailDuplicateCheck(existingEmail));
assertEquals(DUPLICATED_EMAIL, exception.getErrorCode());
}
}
🙋♀️
본 포스트는 공부 목적으로 작성하였습니다. 보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.
Test Code가 없던 1년 전 마실가실, 리팩토링을 하면서 Test Code가 생겼습니다.
예상 동작과 실제 동작을 비교하여 빠르고 정확한 테스트를 위해서 Test Code를 작성한다고 하는데,
개인적으로 가장 큰 장점은 독립적인 테스트가 가능하다는 것이라고 생각합니다.
저의 리팩토링은 Front가 아닌 Back이고, 따라서 바인딩은 수정이 안된 상황입니다.
화면에서는 정확하게 나오지 않을 확률이 99%입니다.
바인딩까지 수정하면 너무 긴 시간을 기다리게 됩니다.
그렇다고, System.out.println()을 하나하나 확인하기에는 정확성이 떨어질 수 있습니다.
또한, 다른 기능이 아직 리팩토링되지 않아 동작을 하지 않을 경우 새로 고친 코드가 맞는지는 이런 식으로 하다가는 프로그램이 완성되어야 알 수 있게 됩니다.
이에 대한 대안이 Test Code라고 생각합니다.
기존에 설정조차 안되어 있었기 때문에
환경 설정을 하고
테스트 코드를 작성해서
오류를 수정할 예정입니다.
1. 환경 설정
build.gradle
// 추가
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
// 추가
test {
useJUnitPlatform()
}
// test 태스크가 정의될 때 설정을 직접 적용
// 삭제
task.named('test'){
useJUnitPlatform()
}
// 동적으로 태스크를 참조하거나 태스크가 조건부로 생성되는 경우 사용
2. Test Code 작성
UserServiceTest.java
@SpringBootTest
@Transactional
public class UserServiceTest {
@Autowired
UserService userService;
@Autowired
UserRepository userRepository;
@Test
public void 회원가입() throws Exception {
// given
User user = new User();
user.setStatus("M");
user.setEmail("test@email.com");
user.setPhone("010-2345-6789");
// when
Integer createdId = userService.create(user);
// then
assertThat(user).isEqualTo(userRepository.findOne(createdId));
}
}
@EnableJpaAuditing, @EntityListeners(AuditingEntityListener.class) 추가
3. 오류 수정
사실 자잘한 오류는 문제가 아니었습니다.
테스트 자체가 안돌아갔던 큰 문제가 있었기 때문이죠.
contextLoads()
java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@2caa5d7c testClass = com.msgs.main.MsgsApplicationTests, locations = [], classes = [com.msgs.main.MsgsApplication], contextInitializerClasses = [], activeProfiles = [], propertySourceLocations = [], propertySourceProperties = ["org.springframework.boot.test.context.SpringBootTestContextBootstrapper=true"], contextCustomizers = [org.springframework.boot.test.autoconfigure.actuate.observability.ObservabilityContextCustomizerFactory$DisableObservabilityContextCustomizer@1f, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@0, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@3cc41abc, org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@6f2cfcc2, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@ec2cc4, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.web.client.TestRestTemplateContextCustomizer@2f4205be, org.springframework.boot.test.web.reactive.server.WebTestClientContextCustomizer@544820b7, org.springframework.boot.test.context.SpringBootTestAnnotation@d3394ad], resourceBasePath = "src/main/webapp", contextLoader = org.springframework.boot.test.context.SpringBootContextLoader, parent = null]
at org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:142)
at org.springframework.test.context.support.DefaultTestContext.getApplicationContext(DefaultTestContext.java:127)
at org.springframework.test.context.web.ServletTestExecutionListener.setUpRequestContextIfNecessary(ServletTestExecutionListener.java:191)
at org.springframework.test.context.web.ServletTestExecutionListener.prepareTestInstance(ServletTestExecutionListener.java:130)
at org.springframework.test.context.TestContextManager.prepareTestInstance(TestContextManager.java:241)
at org.springframework.test.context.junit.jupiter.SpringExtension.postProcessTestInstance(SpringExtension.java:138)
at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.lambda$invokeTestInstancePostProcessors$10(ClassBasedTestDescriptor.java:377)
at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.executeAndMaskThrowable(ClassBasedTestDescriptor.java:382)
at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.lambda$invokeTestInstancePostProcessors$11(ClassBasedTestDescriptor.java:377)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.stream.ReferencePipeline$2$1.accept(ReferencePipeline.java:179)
at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1625)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.StreamSpliterators$WrappingSpliterator.forEachRemaining(StreamSpliterators.java:310)
at java.base/java.util.stream.Streams$ConcatSpliterator.forEachRemaining(Streams.java:735)
at java.base/java.util.stream.Streams$ConcatSpliterator.forEachRemaining(Streams.java:734)
at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:762)
at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.invokeTestInstancePostProcessors(ClassBasedTestDescriptor.java:376)
at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.lambda$instantiateAndPostProcessTestInstance$6(ClassBasedTestDescriptor.java:289)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.instantiateAndPostProcessTestInstance(ClassBasedTestDescriptor.java:288)
at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.lambda$testInstancesProvider$4(ClassBasedTestDescriptor.java:278)
at java.base/java.util.Optional.orElseGet(Optional.java:364)
at org.junit.jupiter.engine.descriptor.ClassBasedTestDescriptor.lambda$testInstancesProvider$5(ClassBasedTestDescriptor.java:277)
at org.junit.jupiter.engine.execution.TestInstancesProvider.getTestInstances(TestInstancesProvider.java:31)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.lambda$prepare$0(TestMethodTestDescriptor.java:105)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.prepare(TestMethodTestDescriptor.java:104)
at org.junit.jupiter.engine.descriptor.TestMethodTestDescriptor.prepare(TestMethodTestDescriptor.java:68)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$prepare$2(NodeTestTask.java:123)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.prepare(NodeTestTask.java:123)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:90)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:41)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$6(NodeTestTask.java:155)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:141)
at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:137)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$9(NodeTestTask.java:139)
at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:138)
at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:95)
at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:35)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:54)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:107)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:88)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.lambda$execute$0(EngineExecutionOrchestrator.java:54)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.withInterceptedStreams(EngineExecutionOrchestrator.java:67)
at org.junit.platform.launcher.core.EngineExecutionOrchestrator.execute(EngineExecutionOrchestrator.java:52)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:114)
at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:86)
at org.junit.platform.launcher.core.DefaultLauncherSession$DelegatingLauncher.execute(DefaultLauncherSession.java:86)
at org.junit.platform.launcher.core.SessionPerRequestLauncher.execute(SessionPerRequestLauncher.java:53)
at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor$CollectAllTestClassesExecutor.processAllTestClasses(JUnitPlatformTestClassProcessor.java:99)
at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor$CollectAllTestClassesExecutor.access$000(JUnitPlatformTestClassProcessor.java:79)
at org.gradle.api.internal.tasks.testing.junitplatform.JUnitPlatformTestClassProcessor.stop(JUnitPlatformTestClassProcessor.java:75)
at org.gradle.api.internal.tasks.testing.SuiteTestClassProcessor.stop(SuiteTestClassProcessor.java:62)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.base/java.lang.reflect.Method.invoke(Method.java:568)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:36)
at org.gradle.internal.dispatch.ReflectionDispatch.dispatch(ReflectionDispatch.java:24)
at org.gradle.internal.dispatch.ContextClassLoaderDispatch.dispatch(ContextClassLoaderDispatch.java:33)
at org.gradle.internal.dispatch.ProxyDispatchAdapter$DispatchingInvocationHandler.invoke(ProxyDispatchAdapter.java:94)
at jdk.proxy1/jdk.proxy1.$Proxy2.stop(Unknown Source)
at org.gradle.api.internal.tasks.testing.worker.TestWorker$3.run(TestWorker.java:193)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.executeAndMaintainThreadName(TestWorker.java:129)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:100)
at org.gradle.api.internal.tasks.testing.worker.TestWorker.execute(TestWorker.java:60)
at org.gradle.process.internal.worker.child.ActionExecutionWorker.execute(ActionExecutionWorker.java:56)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:113)
at org.gradle.process.internal.worker.child.SystemApplicationClassLoaderWorker.call(SystemApplicationClassLoaderWorker.java:65)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.run(GradleWorkerMain.java:69)
at worker.org.gradle.process.internal.worker.GradleWorkerMain.main(GradleWorkerMain.java:74)
Caused by: org.springframework.beans.factory.BeanDefinitionStoreException: Failed to parse configuration class [com.msgs.main.MsgsApplication]
at app//org.springframework.context.annotation.ConfigurationClassParser.parse(ConfigurationClassParser.java:178)
at app//org.springframework.context.annotation.ConfigurationClassPostProcessor.processConfigBeanDefinitions(ConfigurationClassPostProcessor.java:415)
at app//org.springframework.context.annotation.ConfigurationClassPostProcessor.postProcessBeanDefinitionRegistry(ConfigurationClassPostProcessor.java:287)
at app//org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanDefinitionRegistryPostProcessors(PostProcessorRegistrationDelegate.java:344)
at app//org.springframework.context.support.PostProcessorRegistrationDelegate.invokeBeanFactoryPostProcessors(PostProcessorRegistrationDelegate.java:115)
at app//org.springframework.context.support.AbstractApplicationContext.invokeBeanFactoryPostProcessors(AbstractApplicationContext.java:771)
at app//org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:589)
at app//org.springframework.boot.SpringApplication.refresh(SpringApplication.java:733)
at app//org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:435)
at app//org.springframework.boot.SpringApplication.run(SpringApplication.java:311)
at app//org.springframework.boot.test.context.SpringBootContextLoader.lambda$loadContext$3(SpringBootContextLoader.java:137)
at app//org.springframework.util.function.ThrowingSupplier.get(ThrowingSupplier.java:58)
at app//org.springframework.util.function.ThrowingSupplier.get(ThrowingSupplier.java:46)
at app//org.springframework.boot.SpringApplication.withHook(SpringApplication.java:1405)
at app//org.springframework.boot.test.context.SpringBootContextLoader$ContextLoaderHook.run(SpringBootContextLoader.java:545)
at app//org.springframework.boot.test.context.SpringBootContextLoader.loadContext(SpringBootContextLoader.java:137)
at app//org.springframework.boot.test.context.SpringBootContextLoader.loadContext(SpringBootContextLoader.java:108)
at app//org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContextInternal(DefaultCacheAwareContextLoaderDelegate.java:184)
at app//org.springframework.test.context.cache.DefaultCacheAwareContextLoaderDelegate.loadContext(DefaultCacheAwareContextLoaderDelegate.java:118)
... 87 more
Caused by: org.springframework.context.annotation.ConflictingBeanDefinitionException: Annotation-specified bean name 'userController2' for bean class [com.msgs.user.controller.UserController2] conflicts with existing, non-compatible bean definition of same name and class [com.msgs.msgs.jwt.controller.UserController2]
at app//org.springframework.context.annotation.ClassPathBeanDefinitionScanner.checkCandidate(ClassPathBeanDefinitionScanner.java:349)
at app//org.springframework.context.annotation.ClassPathBeanDefinitionScanner.doScan(ClassPathBeanDefinitionScanner.java:287)
at app//org.springframework.context.annotation.ComponentScanAnnotationParser.parse(ComponentScanAnnotationParser.java:128)
at app//org.springframework.context.annotation.ConfigurationClassParser.doProcessConfigurationClass(ConfigurationClassParser.java:289)
at app//org.springframework.context.annotation.ConfigurationClassParser.processConfigurationClass(ConfigurationClassParser.java:243)
at app//org.springframework.context.annotation.ConfigurationClassParser.parse(ConfigurationClassParser.java:196)
at app//org.springframework.context.annotation.ConfigurationClassParser.parse(ConfigurationClassParser.java:164)
... 105 more
너무 긴 에러는 눈에 잘 안들어옵니다.
읽히는 에러만 찾아서 해결해봅니다.
전 보통 제일 긴 부분이나 Caused by를 먼저 봅니다.
Caused by:
org.springframework.context.annotation.ConflictingBeanDefinitionException:
Annotation-specified bean name 'userController2' for bean class [com.msgs.user.controller.UserController2] conflicts with existing,
non-compatible bean definition of same name and class [com.msgs.msgs.jwt.controller.UserController2]
찾았습니다🫠.
package가 다르면 Controller 이름이 같아도 되겠지라고 생각한 저의 실수입니다.
같은 이름의 Bean을 등록할 수 없습니다.
🙋♀️
본 포스트는 공부 목적으로 작성하였습니다. 보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.
자원은 URI로, 자원의 행위는 HTTP Method로 표현 → 행위에 맞는 적절한 HTTP method를 사용한 것을 RESTful하다고 합니다.
조회: GET
생성: POST
일부 수정: PATCH / 전체 수정: PUT
삭제: DELETE
REST API 설계 규칙
소문자 사용
_ 대신 - 사용
마지막에 슬래시(/)를 포함하지 않음
행위를 포함하지 않음 행위는 URI 대신 Http Method를 사용하여 전달
파일 확장자는 URL에 포함시키지 않음
명사 사용, 예외적으로 컨트롤 자원을 의미하는 경우 동사 사용
URI에 작성되는 영어를 복수형으로 작성
// 기존
@RestController
@RequestMapping("user")
public class UserController {
@PostMapping("/signup")
public void userSignUp(@RequestBody User user) {
userService.signUp(user);
}
}
// 리팩토링
@RestController
@RequestMapping("api/v2/users")
@RequiredArgsConstructor
public class UserController {
@PostMapping("/new")
public String create(@RequestBody User user){
System.out.println("UserController.create");
Integer id = userService.create(user);
return id.toString();
}
}
signup → new
user → users
🙋♀️
본 포스트는 공부 목적으로 작성하였습니다. 보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.