728x90

1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.

 

 

 

마실가실의 숨겨진 비밀..

한 번 로그인 한 고객님은.. 서버를 내리기 전까지 로그아웃할 수 없습니다.

 

이제는 보내드리려고 합니다.

안녕히 가세요, 고객님..

 

 

 

JWT, Token 방식으로 Session과 동작 과정을 비교해 볼 수 있습니다.

로그인

세션 방식

  * 서버는 세션 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 토큰을 자동으로 삭제할 수 있음

  * 기존 RDB에 저장 시, 주기적으로 삭제해야하는 번거러움 발생

 

Springboot에 Redis 설정

1. build.gradle

implementation 'org.springframework.boot:spring-boot-starter

 

2. application.yml

spring:
  redis:
    pool:
      min-idle: 0
      max-idle: 8
      max-active: 8
    port: 6379
    host: 127.0.0.1

 

3. RedisProperties.java

@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 주소에서 사용되는 것을 방지

 

 

🙋‍♀️

본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.

 

📑

참고 자료

 

[스프링 시큐리티] redis를 이용한 jwt 로그아웃 만들기

오늘은 redis를 이용해서 jwt 로그아웃 기능을 포스팅 해보겠습니다. 우선, 간단하게 redis를 왜 이용해야 하는지 고민해보겠습니다. 왜 redis를 사용할까? 바로 로그아웃을 요청한 access token이 만료

geunzrial.tistory.com

 

 

[우테코] JWT 방식에서 로그아웃, Refresh Token 만들기(1): JWT의 Stateless한 특징을 최대한 살리려면?

안녕! 우아한테크코스 5기 [스탬프크러쉬]팀 깃짱이라고 합니다. 스탬프크러쉬 서비스의 소스 코드 바로가기 사장모드: stampcrush.site/admin 고객모드: stampcrush.site 💋 인트로 스탬프크러쉬는 사용

engineerinsight.tistory.com

 

 

대동덕지도 | Spring Boot에서 Spring Security + JWT로 로그아웃을 구현하자! (feat. Redis)

JWT 적용한 로그인은 구현 했는데 말입니다 ... 로그아웃은 어떻게 하지? JWT를 적용한 로그인 기능은 Access Token(AT), Refresh Token(RT)를 이용하여 구현해냈다. 참고: https://un-lazy-midnight.tistory.com/159 대동

un-lazy-midnight.tistory.com

 

 

728x90
728x90

1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.

 

Java Web Token, JWT.. 

 

마실가실에 JWT가 적용되어 있지만, 제가 담당한 게 아니라 잘 모르고 있었습니다.

 

이제 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에 저장

 

 

Spring Security, JWT 적용 과정은 다음과 같이 진화했습니다.

 

1. 초기 환경 설정

2. JWT WeakKeyException 오류 수정

3. 보안을 위한 환경 변수 설정

 

1. 초기 환경 설정

  • build.gradle
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-config'

// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

 

  • User.java
@Entity
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User extends BaseEntity {

   @Id @GeneratedValue
   @Column(name = "user_id")
   private Integer id;

   @Column(nullable = false, columnDefinition="char(1)")
   private String status;

   @Enumerated(EnumType.STRING)
   private LoginType loginType;

   @Column(nullable = false, unique = true, length = 50)
   private String email;

   @Column(nullable = false, unique = true, columnDefinition="char(11)")
   private String phone;

   @Column(length = 30)
   private String nickname;

   @Column(length = 30)
   private String password;

   private String imagePath;
}

 

  • CustomUserDetailsService.java
@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));
}

 

 

 

🙋‍♀️

본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.

 

📑

참고 자료

 

[Spring Boot] JWT (JSON Web Token) 토큰 기반 인증

목차 1. 환경 Spring Boot 2.5.6 (Gradle) JDK 11(Java 11) IntelliJ Postman 2. JWT 클라이언트와 서버, 서비스와 서비스 사이 통신 시 권한 인가(Authorization)를 위해 사용하는 토큰 Bearer Authentication (JWT 혹은 OAuth에

veneas.tistory.com

 

jwt secret key 만들기

리눅스에 openssl rand -hex 64 입력하면 된다 윈도우도 리눅스 켤 수 있다

blossoming-man.tistory.com

 

[JWT] Spring Boot 환경에서 JWT(Json Web Token)생성 하기

첫번째의 JWT는 JWT에 대한 간단한 설명을 정리했고 2번째는 Spring Boot 환경에서 JWT를 직접 생성해보고자 한다. 사실 JWT 생성은 https://jwt.io/ JWT.IO JSON Web Tokens are an open, industry standard RFC 7519 method for r

erjuer.tistory.com

 

[SpringBoot] Spring Security란?

대부분의 시스템에서는 회원의 관리를 하고 있고, 그에 따른 인증(Authentication)과 인가(Authorization)에 대한 처리를 해주어야 한다. Spring에서는 Spring Security라는 별도의 프레임워크에서 관련된 기능

mangkyu.tistory.com

 

🔒 Spring Security 구조, 흐름 그리고 역할 알아보기 🌱

스프링 시큐리티는 인증 (Authentication) ,권한(Authorize) 부여 및 보호 기능을 제공하는 프레임워크다.Java / Java EE 프레임워크개발을 하면서 보안 분야는 시간이 많이 소요되는 활동들 중 하나다. Sprin

velog.io

 

[백엔드] JSON Web Token (JWT) 설정 및 properties 설정 -> 포스트맨 테스트

JWT 란? JWT(Json Web Token)는 말그대로 웹에서 사용되는 JSON 형식의 토큰에 대한 표준 규격 JWT 토큰 웹에서 보통 Authorization HTTP 헤더를 Bearer 으로 설정하여 클라이언트에서 서버로 전송 서버에서는 토

euni8917.tistory.com

 

728x90
728x90

1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.

 

회원 가입을 진행할 때, Email 중복 검사하는 로직을 추가했습니다.

 

처음에는 Email로 조회해서 리스트의 갯수가 0일 경우 통과하는 로직으로 작성했습니다.

그 다음으로는 JPA 강의를 들으면서 정리한 코드를 살펴보았는데,

Exceopion이 왜 거기서 나와...?

 

public void validateDuplicateMember(Member member) {
    List<Member> findMembers = memberRepository.findByName(member.getName());
    if(!findMembers.isEmpty()){
        throw new IllegalStateException("이미 존재하는 회원입니다.");
    }
}

 

그대로 코드를 사용할 수는 있지만, IllegalStateException 대신 CustomException을 사용해보고 싶었습니다.

왜냐면 HttpStatus도 함께 사용하는 멋진 코드를 발견했기 때문이죠.

 

그렇기에 이 글을

  1. Error Code를 Enum 형태로 만들고
  2. CustomException을 만들어
  3. 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());
    }
}

 

 

 

🙋‍♀️

본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.

 

📑

참고 자료

 

Java Enum 1편 : Enum 기본적인 사용

1. Overview Java Enum 타입은 일정 개수의 상수 값을 정의하고, 그 외의 값은 허용하지 않습니다. 과거에는 특정 상수값을 사용하기 위해선 모두 상수로 선언해서 사용했습니다. public static final String M

bcp0109.tistory.com

 

GitHub - dbswhd4932/shoppingmall_project: Java Springboot Jpa 를 이용한 쇼핑몰 API 프로젝트

Java Springboot Jpa 를 이용한 쇼핑몰 API 프로젝트. Contribute to dbswhd4932/shoppingmall_project development by creating an account on GitHub.

github.com

 

728x90
728x90

1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.

 

Test Code가 없던 1년 전 마실가실, 리팩토링을 하면서 Test Code가 생겼습니다.

예상 동작과 실제 동작을 비교하여 빠르고 정확한 테스트를 위해서 Test Code를 작성한다고 하는데,

개인적으로 가장 큰 장점은 독립적인 테스트가 가능하다는 것이라고 생각합니다.

 

저의 리팩토링은 Front가 아닌 Back이고, 따라서 바인딩은 수정이 안된 상황입니다.

화면에서는 정확하게 나오지 않을 확률이 99%입니다.

바인딩까지 수정하면 너무 긴 시간을 기다리게 됩니다.

그렇다고, System.out.println()을 하나하나 확인하기에는 정확성이 떨어질 수 있습니다.

 

또한, 다른 기능이 아직 리팩토링되지 않아 동작을 하지 않을 경우 새로 고친 코드가 맞는지는 이런 식으로 하다가는 프로그램이 완성되어야 알 수 있게 됩니다.

 

이에 대한 대안이 Test Code라고 생각합니다.

 

기존에 설정조차 안되어 있었기 때문에

  1. 환경 설정을 하고
  2. 테스트 코드를 작성해서
  3. 오류를 수정할 예정입니다.

 

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));
    }
}
  • @SpringBootTest
    • 통합 테스트를 제공하는 기본적인 스프링 부트 테스트 어노테이션
  • NullPointerException
    • @Test import class에 유의
      • import org.junit.Test;
        → import org.junit.jupiter.api.Test;
    • @CreatedDate, @LastModifiedDate 사용 시, 
      • @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을 등록할 수 없습니다.

 

 

 

🙋‍♀️

본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.

 

📑

참고 자료

 

Spring Boot에서의 Test 어노테이션

Spring Boot는 기본적인 테스트 스타터를 제공한다. 스타터에 웬만한 테스트 라이브러리들을 한데 뭉쳐놓았기 때문에 편리하게 사용할 수 있다. 스타터는 크게 두 가지 모듈로 구성된다.

velog.io

 

 

OrderServiceTest 코드의 NullPointerExce... - 인프런 | 커뮤니티 질문&답변

누구나 함께하는 인프런 커뮤니티. 모르면 묻고, 해답을 찾아보세요.

www.inflearn.com

 

[Spring] Spring Data JPA에서 Auditing 사용하는 법

JPA AuditingEntityListener 알아보기 위의 코드는 제가 작성했던 코드인데요. 누군가 저에게 아래와 같이 물었습니다. LocalDateTime.now() 코드는 왜 작성한거야? 위의 질문을 듣고 아.. BaseEntity를 통해서 생

devlog-wjdrbs96.tistory.com

 

 

728x90
728x90

1년 전 진행했던 마실가실 프로젝트를 🛠️리팩토링하며 정리한 내용입니다.

 

RESTful한 구현이 안된 프로젝트는 1년 전 마실가실입니다.

 

1년 후 마실가실은 RESTful 할 수 있습니다.

 

간단한 정의는

  자원의 이름(표현)으로 자원의 상태(정보)를 주고 받는 것

입니다.

 

자원은 URI로, 자원의 행위는 HTTP Method로 표현 → 행위에 맞는 적절한 HTTP method를 사용한 것을 RESTful하다고 합니다.

  • 조회: GET
  • 생성: POST
  • 일부 수정: PATCH / 전체 수정: PUT
  • 삭제: DELETE

REST API 설계 규칙

  1. 소문자 사용
  2. _ 대신 - 사용
  3. 마지막에 슬래시(/)를 포함하지 않음
  4. 행위를 포함하지 않음
    행위는 URI 대신 Http Method를 사용하여 전달
  5. 파일 확장자는 URL에 포함시키지 않음
  6. 명사 사용, 예외적으로 컨트롤 자원을 의미하는 경우 동사 사용
  7. 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

 

 

 

🙋‍♀️

본 포스트는 공부 목적으로 작성하였습니다.
보시는 도중 잘못된 부분이나 개선할 부분이 있다면 댓글로 알려주시면 수정하도록 하겠습니다.

 

📑

참고 자료

 

REST API URL 규칙

API 개발을 하는데 있어서 URI를 어떻게 명명할지에 대한 정리를 하기 위해 포스팅 하였다. 1. RESTful API 란 Rest : Representational State Tranfer의 약자로 웹을 이용할때 제약조건들을 정의하는 소프트웨어

dev-cool.tistory.com

 

[간단정리] REST, REST API, RESTful 특징

개요 REST, REST API, RESTful 특징 알아보기 REST REST 정의 REST(Representational State Transfer)는 월드 와이드 웹과 같은 분산 하이퍼미디어 시스템을 위한 소프트웨어 아키텍처의 한 형식 REST는 기본적으로 웹

hahahoho5915.tistory.com

 

REST란? REST API 와 RESTful API의 차이점

참고 REST(REpresentational State Transfer)란? REST의 정의 "REpresentational State Transfer" 의 약자로, 자원을 이름(자원의 표현)으로 구분해 해당 자원의 상태(정보)를 주고 받는 모든 것을 의미합니다. 즉, 자원(

dev-coco.tistory.com

 

 

728x90