학교에서 프로젝트를 진행하면서 웹 사이트를 구축하고 있습니다!

저는 백엔드를 담당하여 Spring Security + OAuth2.0 + JWT를 이용한 소셜로그인 기능을 구현하였는데요.

생각보다 소셜 로그인이 정말 복잡하고 어렵다는 것을 알게 되었습니다..ㅎ  

 

버전 정보

Spring Boot : 3.0.x

Spring Security : 6.x

 

Spring에서는 인증인가를 쉽게 구현할 수 있도록 Spring Security라는 하위 프로젝트를 제공하고 있습니다.

(근데 쉽지 않습니다.)

 스프링 시큐리티의 핵심은 Security Filter Chain으로 기본적으로 여러가지 보안 필터를 제공하며 요청에 대한 필터링을 수행합니다.이 필터 체인에 저희가 구현한 커스텀 필터를 추가하여 인증, 인과 과정을 Customizing 할 수 있습니다!

 

스프링 시큐리티는 기본적으로 Form 기반의 로그인 ( 아이디, 비밀번호가 넘어오면 그걸 기반으로 ) 을 수행하며 이때 Security Filter 중에서 UsernamePasswordAuthenticationFilter 필터가 작용하여 인증 프로세스를 수행합니다.

저희는 Form기반의 로그인이 아닌 JWT를 이용한 토큰 기반의 로그인을 사용할 것이기 떄문에 UsernamePasswordAuthenticationFilter 필터의 앞에 저희의 커스텀 JWT 인증 필터를 만들어서 인증 과정을 대신 수행하도록 하여 인증 성공UsernamePasswordAuthenticationFilter를 통과하도록 할 것 입니다.

 

OAuth2.0의 경우 Authorization Code Grant 방식을 사용하였으며, OAuth2.0 동작 방식까지 포스팅하면 내용이 너무 많아져서 검색한번 해보시는 것을 추천드립니다! 


* https://developers.kakao.com/에서  애플리케이션을 등록한 상태여야 합니다.

 

로그인 프로세스

  1. 최초 로그인 요청
    - 사용자가 저희 웹사이트에 로그인을 요청하는 경우입니다! ( 카카오 로그인 버튼을 누르는 경우)
  2. 소셜 로그인 요청
    -  클라이언트(프론트엔드 서버)는 Redirect URI, Client ID, Scope 등을 담아 Resource Server에 로그인 요청을 보냅니다.
  3. 소셜 로그인 페이지 제공
    - Redirect URI, Client ID 등을 검증하고 유효한 경우 사용자에게 로그인 페이지를 제공합니다.
  4. 소셜 로그인 ID / PW 제공 
    - 사용자는 로그인 페이지에 로그인을 합니다.
  5. Authorization Code 발급
    - 사용자가 로그인에 성공하면, Authorization code를 발급합니다.
  6. Redirect URI로 리다이렉트
    - 위에서 지정한 Redirect URI로 리다이렉트 합니다.
  7. 인가 코드 전달
    - 프론트엔드에서 백엔드(API 서버)로 Authorization code를 담아 Post 요청을 보냅니다.
    ( POST /oauth2/login/kakao/{Authorizationcode} )
  8. Access token 요청 ( 여기서부터 구현했습니다 )
    - 해당 요청을 컨트롤러에서 받고, OAuth2TokenService에서 인가 코드를 이용하여 Authorization ServerAccess token을 요청합니다.
  9. Access token 발급
    - 넘어온 요청 정보들 ( 인가코드, 아까 사용한 redirec uri 등 )을 검증하고 성공시 access token을 발급해줍니다.
  10. User Information 요청 
    - Access token을 발급받았으니 이 토큰으로 로그인한 유저가 동의한 Scope에 있는 소셜 정보들을 불러올 수 있습니다. 제가 구현한 경우에는 이름, 이메일, 식별자(OauthId) 정도만 가져왔습니다!
  11. User Information 응답
    - 요청한 정보를 담아 응답해줍니다. ( JSON )
  12. User 정보 전달 - UserRepository에 받아온 사용자 정보를 전달하여 처리합니다.
  13. User 저장 or 이미 있으면 Update
    - DB에 유저 정보가 없으면 저장하고 만약 이미 있으면 (변경사항이 있을 수도 있으므로 반영을 위해 )수정하는 쿼리를 날립니다! 
  14. JwtTokenProvider에게 받아온 유저 정보를 넘겨줍니다.
    - 유저 정보를 사용하여 JWT 토큰을 생성합니다  
  15. JWT 토큰 발급 ( ~ 17 ) 
    - JWT 토큰을 발급하여 응답합니다.

OAuth2TokenService

    public OauthToken getKakaoAccessToken(String authorizationCode)  {

        // Set URI
        URI uri = UriComponentsBuilder
                .fromUriString("https://kauth.kakao.com")
                .path("/oauth/token")
                .encode()
                .build()
                .toUri();

        RestTemplate restTemplate = new RestTemplate();

        // Set header
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.add("Accept", "application/json");

        // Set parameter
        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", KAKAO_REST_API_KEY);
        params.add("redirect_uri", KAKAO_REDIRECT_URI);
        params.add("code", authorizationCode);

        // Set http entity
        RequestEntity<MultiValueMap<String, String>> requestEntity = RequestEntity.post(uri).headers(headers).body(params);
        ResponseEntity<String> responseEntity;

        try {
            // 토큰 받기
            responseEntity = restTemplate.exchange(requestEntity, String.class);
        } catch (Exception e) {
            log.error("[kakao] access token 발급 실패 ");
            throw (new RuntimeException("authorization code가 잘못되었습니다."));
        }

        // JSON String to OauthToken
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        OauthToken oauthToken;

        try {
            oauthToken = objectMapper.readValue(responseEntity.getBody(), OauthToken.class);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }

        return oauthToken;
    }
    public OAuthAttributes loadKakao(String accessToken, String refreshToken) {
 	        RestTemplate restTemplate = new RestTemplate();
        // 유저 정보 불러오기
        ResponseEntity<String> responseEntity = null;
        try {
            responseEntity = restTemplate.exchange(requestEntity, String.class);
        } catch (RuntimeException e) {
           log.error("[kakao] loadKakao 유저 정보 불러오기 실패");
            throw (new RuntimeException());
        }
        // JSON String to Object
        ObjectMapper objectMapper = new ObjectMapper();
        Map<String, Object> attributes;
        try {
            attributes = objectMapper.readValue(responseEntity.getBody(), HashMap.class);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }

        return OAuthAttributes.of(SocialType.KAKAO, "", attributes);
    }
  • access token을 이용하여 사용자 정보를 불러오는 부분입니다! 
  • Map<String , Object> 형태로 사용자 정보를 반환합니다.  

OAuthAttributes

public class OAuthAttributes {
	private Map<String, Object> attributes;
	private String oAuthId;     // OAuth2.0에서 사용하는 PK
	private String nickName;    // 닉네임 정보
	private String email;       // 이메일 주소
	private SocialType socialType;
    }
  • 각각의 Resource Server ( 카카오, 구글, 네이버 등) 넘어온 데이터로부터 뽑아낼 정보들입니다.
   private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {

        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");

        String nickname = (String) profile.get("nickname");
        String email = (String) kakaoAccount.get("email");
        
        // 리소스 서버별 사용자 식별하는 값입니다.
        String oAuthId = String.valueOf(attributes.get(userNameAttributeName));

        return OAuthAttributes.builder()
                .oAuthId(oAuthId)
                .email(email)
                .nickName(nickname)
                .attributes(attributes)
                .socialType(SocialType.KAKAO)
                .build();
    }
  • Resource Server마다 속성 이름이나 넘겨주는 데이터 구조 등이 달라서 통일화 하기 위한 클래스입니다!
    참고 자료 (스프링 부트와 AWS로 혼자 구현하는 웹 서비스 | 이동욱)

JwtTokenProvider

   public String createToken(String userOAuthId) {
        log.info("[createToken] userOAuthId = {}", userOAuthId);
        Claims claims = Jwts.claims().setSubject(userOAuthId);
        Date now = new Date();

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return token;
    }
  • JwtTokenProvider는 JWT 토큰의 생성, 유효성확인, Authentication 생성 등 토큰과 관련된 모든 처리를 담당하는 모듈입니다.
  • OAuthId를 받아 그 정보를 토큰의 subject로 지정하고, 토큰을 발급하는 코드입니다. 
    public boolean validateToken(String token) {
        log.info("[validateToken] 토큰 유효성 확인");
        try {
            Claims body = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
            return !body.getExpiration().before(new Date());
        } catch (Exception e) {
            log.error("[validateToken] 유효하지 않은 토큰입니다.");
            return false;
        }

    }
  • 토큰이 넘어오면 유효기간을 체크하는 기능입니다.
    public Authentication getAuthentication(String token) {
        log.info("[getAuthentication] 토큰 기반 정보 조회 시작");
        String userName = getUserName(token);
        UserDetails userDetails = new CustomUserDetails(userName);
        return new UsernamePasswordAuthenticationToken(userDetails, "", null);
    }
  • 토큰에서 유저 정보를 추출하여 Authentication 객체를 생성합니다. 이 Authentication 객체는 인증 정보를 담고 있으며 Spring Security의 SecurityContextHolder라는 인증 저장소에 담기게 됩니다!

SecurityConfig

@Configuration
@EnableWebSecurity
@EnableWebMvc
public class SecurityConfig{

    private final JwtTokenProvider jwtTokenProvider;
    @Autowired
    public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    // HttpSecurity 설정
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .httpBasic().disable()  // UI 사용하는 것을 기본값으로 가진 시큐리티 설정 비활성화
                .formLogin().disable()

                .csrf().disable()       // CSRF 보안 비활성화

                .sessionManagement()    // 세션 관리 정책 설정
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)     // JWT로 인증, 인증에서 세션은 사용하지 않음

                .and()
                // swagger 설정
                .authorizeHttpRequests().requestMatchers("v3/api-docs/**", "/swagger-resources/**", "/swagger-ui*/**",
                        "/webjars/**", "/swagger/**").permitAll()

                .and()
                // 인증이 필요한 요청
                .authorizeHttpRequests().requestMatchers("/user/detail/**").authenticated()

                .anyRequest().permitAll()

                .and()
                // 접근 권한이 없는 경우 발생하는 예외 처리
                .exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler())
                .and()
                // 인증 실패시 발생하는 예외 처리
                .exceptionHandling().authenticationEntryPoint(new CustomAuthenticationEntryPoint())

                .and()
                // 인증을 담당하는 UsernamePasswordAuthenticationFilter 앞에 커스텀 필터를 배치하여 커스텀 필터가 인증 과정을 수행하도록 하였습니다.
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

}
  • 스프링 시큐리티 필터에 대한 설정입니다!
  • 인증이 필요한 요청에 대해서는 authenticated를 적용해 인증된 접근인지 검증합니다.
  • accessDeniedHandler() : 접근 권한이 없는 리소스에 접근할시 해당 클래스에서 예외처리

JwtAuthenticationFilter 

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(request);
        log.info("[JwtAuthenticationFilter] token 값 추출. token : {}", token);

        log.info("token 유효성 검증시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
            log.info("token 인증성공");
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }

}
  • 직접 구현한 커스텀 필터입니다! OncePerRequestFilter 또는 GenericFilterBean를 상속받아 쉽게 구현이 가능합니다. OncePerRequestFilter는 이름 그대로 요청당 한번만 수행되는 필터입니다.
  • 토큰을 검증하고, 토큰이 있는 경우 Authentication 객체를 SecurityConyextHolder에 추가하여 인증을 수행합니다.
  • 커스텀 필터에 @Component가 없는 이유 : 해당 필터를 스프링 빈으로 등록하게 되면, 스프링 시큐리티의 범위에서 벗어나 스프링 자체에서 필터 역할을 하기 때문에 저희가 지정한 요청 제어 등이 제대로 수행되지 않습니다. 따라서 해당 클래스는 스프링 빈으로 등록하지 않습니다.

이번 프로젝트를 진행하며 처음으로 Spring Security, OAuth2.0, JWT 기능들을 사용해봤는데요.  정말 오래걸리고 힘들었지만 찾아보며 구현하는 재미가 있었던 것 같습니다! 읽어주셔서 감사합니다 

10.3 스프링 부트에서의 유효성 검사


image

  • 유효성 검사는 각 계층으로 데이터가 넘어오는 시점에 해당 데이터에 대한 검사를 실시합니다.
  • 스프링 부트 프로젝트에서는 계층 간 데이터 전송에 대체로 DTO 객체를 활용하고 있기 때문에 유효성 검사DTO 객체를 대상으로 수행하는 것이
    일반적입니다.

1. ValidRequestDto 클래스 생성

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidRequestDto {

    @NotBlank
    String name;

    @Email
    String email;

    @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
    String phoneNumber;

    @Min(value = 20) @Max(value = 40)
    int age;

    @Size(min = 0, max = 40)
    String description;

    @Positive
    int count;

    @AssertTrue
    boolean booleanCheck;
}

각 필드에 어노테이션이 선언된 것을 볼 수 있습니다. 각 어노테이션은 유효성 검사를 위한 조건을 설정하는 데 사용됩니다.

유효성 검사를 위한 대표적인 어노테이션

  1. 문자열 검증
    • @Null : null 값만 허용합니다.
    • @NotNull : null을 허용하지 않습니다. "", " "는 허용합니다.
    • @NotEmpty : null, ""을 허용하지 않습니다. " "는 허용합니다.
    • @NotBlank : null, "", " "을 허용하지 않습니다.
  1. 최댓값/최솟값 검증
    • BigDecimal, BigInteger, int, long 등의 타입을 지원합니다.
    • @DecimalMax(value = "$numberString") : $numberString보다 작은 값을 허용합니다.
    • @DecimalMin(value = "$numberString") : $numberString보다 큰 값을 허용합니다.
    • @Min(value=$number) : $number 이상의 값을 허용합니다.
    • @Max(value=$number) : $number 이하의 값을 허용합니다.
  1. 값의 범위 검증
    • BigDecimal, BigInteger, int, long 등의 타입을 지원합니다.
    • @Positive : 양수를 허용합니다.
    • @PositiveOrZero : 0를 포함양수를 허용합니다.
    • @Negative : 음수를 허용합니다.
    • @NegativeOrZero : 0을 포함음수를 허용합니다.
  2. 시간에 대한 검증
    • Date, LocalDate, LocalDateTime 등의 타입을 지원합니다.
    • @Future : 현재보다 미래의 날짜를 허용합니다.
    • @FutureOrPresent : 현재를 포함미래의 날짜를 허용합니다.
    • @Past : 현재보다 과거의 날짜를 허용합니다.
    • @PastOrPresent : 현재를 포함과거의 날짜를 허용합니다.
  1. 이메일 검증
    • @Email : 이메일 형식을 검사합니다. ""는 허용합니다.
  1. 자릿수 범위 검증
    • BigDecimal, BigInteger, int ,long 등의 타입을 지원합니다.
    • @Digits(integer = $number1, fraction = $number2) : $number1의 정수 자릿수와 $number2의 소수 자릿수를 허용합니다.
  1. Boolean 검증
    • @AssertTrue : true인지 체크합니다. null 값은 체크하지 않습니다.
    • @AssertFalse : false인지 체크합니다. null 값은 체크하지 않습니다.
  1. 문자열 길이 검증
    • @Size(min = $number1, max = $number2) : $number1 이상 $number2 이하의 범위를 허용합니다.
  1. 정규식 검증
    • @Pattern(regexp = "$expression) : 정규식을 검사합니다. 정규식은 자바의 java.util.regex.Pattern 패키지의 컨벤션을 따릅니다.

2. ValidationController 생성

@RestController
@RequestMapping("/validation")
public class ValidationController {

    private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);

    @PostMapping("/valid")
    public ResponseEntity<String> checkValidationByValid(
            @Valid @RequestBody ValidRequestDto validRequestDto) {
        LOGGER.info(validRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
    }
}

동작 확인

image


설정한 값들이 유효성 검사를 통과할 수 있는 값들이므로 '200 OK'로 응답합니다.

유효성 설정 규칙을 벗어나는 경우 ( age를 -1로 설정하는 경우 결과 )

  • 응답 : "400 Bad Request" (400 에러 발생)
    [Field error in object 'validRequestDto' on field 'age': rejected value [-1]; codes [Min.validRequestDto.age,Min.age,Min.int,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validRequestDto.age,age]; arguments []; default message [age],20]; default message [20 이상이어야 합니다]] ]

2개 이상의 유효성 검사를 통과하지 못하는 경우 ( age = -1, count = 0 )

with 2 errors: [Field error in object 'validRequestDto' on field 'age': rejected value [-1]; codes [Min.validRequestDto.age,Min.age,Min.int,Min]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validRequestDto.age,age]; arguments []; default message [age],20]; default message [20 이상이어야 합니다]] [Field error in object 'validRequestDto' on field 'count': rejected value [0]; codes [Positive.validRequestDto.count,Positive.count,Positive.int,Positive]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validRequestDto.count,count]; arguments []; default message [count]]; default message [0보다 커야 합니다]] ]

3. @Validated 활용

@Valid어노테이션은 자바에서 지원하는 어노테이션이며, @Validated어노테이션은 스프링에서 지원하는 유효성 검사 어노테이션입니다.

@Validated@Valid의 기능을 포함하고 있기 때문에 @Validated로 변경할 수 있으며, 유효성 검사를 그룹으로 묶어 대상을
특정
할 수 있는 기능이 있습니다.

검증 그룹 사용하기

  • 검증 그룹은 별다른 내용이 없는 마커 인터페이스를 생성해서 사용합니다.

ValidationGroup1 인터페이스 생성

public interface ValidationGroup1 {
}

ValidationGroup2 인터페이스 생성

public interface ValidationGroup2 {
}

DTO 클래스에 검증 그룹 설정 - ValidatedRequestDto 클래스 생성

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidatedRequestDto {

    @NotBlank
    String name;

    @Email
    String email;

    @Pattern(regexp = "01(?:0|1|[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$")
    String phoneNumber;

    @Min(value = 20, groups = ValidationGroup1.class)
    @Max(value = 40, groups = ValidationGroup1.class)
    int age;


    @Size(min = 0, max = 40)
    String description;

    @Positive(groups = ValidationGroup2.class)
    int count;

    @AssertTrue
    boolean booleanCheck;

}
  • @Min, @Max 어노테이션의 groups 속성을 사용해 ValidationGroup1 그룹 설정
  • @Positive 어노테이션의 groups 속성을 사용해 ValidationGroup2 그룹 설정

ValidationController 클래스 수정

@RestController
@RequestMapping("/validation")
public class ValidationController {

    private final Logger LOGGER = LoggerFactory.getLogger(ValidationController.class);

    @PostMapping("/valid")
    public ResponseEntity<String> checkValidationByValid(
            @Valid @RequestBody ValidRequestDto validRequestDto) {
        LOGGER.info(validRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validRequestDto.toString());
    }


    @PostMapping("/validated")
    public ResponseEntity<String> checkValidation(
            @Validated @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }

    @PostMapping("/validated/group1")
    public ResponseEntity<String> checkValidation1(
            @Validated(ValidationGroup1.class) @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }

    @PostMapping("/validated/group2")
    public ResponseEntity<String> checkValidation2(
            @Validated(ValidationGroup2.class) @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }


    @PostMapping("/validated/all-group")
    public ResponseEntity<String> checkValidation3(
            @Validated({ValidationGroup1.class, ValidationGroup2.class}) @RequestBody ValidatedRequestDto validatedRequestDto) {
        LOGGER.info(validatedRequestDto.toString());
        return ResponseEntity.status(HttpStatus.OK).body(validatedRequestDto.toString());
    }


}
  • checkValidation() : group을 지정하지 않았습니다.
  • checkValidation1() : ValidationGroup1을 그룹으로 지정했습니다.
  • checkValidation2() : ValidationGroup2을 그룹으로 지정했습니다.
  • checkValidation3() : ValidationGroup1, ValidationGroup2을 그룹으로 지정했습니다.

유효성 검사 확인

{
    "age": -1,
    "booleanCheck" : true,
    "count" : -1,
    "description" : "Validation 실습 데이터입니다.",
    "email" : "flature@wikibooks.co.kr",
    "name" : "Falture",
    "phoneNumber" : "010-1234-5678"
}
  • 호출할 때 전달하는 데이터는 위와 같습니다.
  • age와 count 변수에 대한 유효성 검사를 통과하지 못하는 데이터입니다.
  1. @Validated 어노테이션에 특정 그룹을 지정하지 않은 경우 : checkValidation()
    • 정상 통과
    • @Validated 어노테이션에 특정 그룹을 지정하지 않는 경우 groups 속성을 설정하지 않은 필드에 대해서만 유효성 검사를 실시합니다.
  2. @Validated 어노테이션에 ValidationGroup1을 그룹으로 지정한 경우 : checkValidation1()
    • 검사 오류가 발생할 수 있는 두 변수 중에서 ValidationGroup1을 그룹으로 설정한 age에 대한 에러가 발생
  3. @Validated 어노테이션에 ValidationGroup2을 그룹으로 지정한 경우 : checkValidation2()
    • ValidationGroup2을 그룹으로 설정한 count에 대한 에러만 발생

유효성 검사 확인 데이터 변경

{
    "age": 30,
    "booleanCheck" : false,
    "count" : 30,
    "description" : "Validation 실습 데이터입니다.",
    "email" : "flature@wikibooks.co.kr",
    "name" : "Falture",
    "phoneNumber" : "010-1234-5678"
}
  • 위 데이터는 age와 count는 검사를 통과하고 booleanCheck 변수에서 검사를 실패하는 데이터입니다.
  • CheckValidation3() 호출 결과 정상적으로 응답

@Validated 정리

  • @Validated 어노테이션에 특정 그룹을 설정하지 않은 경우에는 groups가 설정되지 않은 필드에 대해 유효성 검사 수행
  • @Validated 어노테이션에 특정 그룹을 설정하는 경우에는 지정된 그룹으로 설정된 필드에 대해서만 유효성 검사를 수행

4. 커스텀 Validation 추가

  • 실무에서는 유효성 검사를 실시할 때 자바 또는 스프링의 유효 검사 어노테이션에서 제공하지 않는 기능을 써야할 때도 있습니다.
  • 이러한 경우 ConstraintValidator커스텀 어노테이션을 조합하여 별도의 유효성 검사 어노테이션을 생성할 수 있습니다.
  • 동일한 정규식을 계속 쓰는 @Pattern 어노테이션의 경우가 가장 흔한 사례입니다.

@Telephone어노테이션 생성하기

1. TelephoneValidator 클래스 생성

// ConstraintValidator 인터페이스를 구현하는 클래스 생성
// 어떤 어노테이션 인터페이스인지 타입 지정
public class TelephoneValidator implements ConstraintValidator<Telephone, String> {


   // 이 메서드를 구현하려면 직접 유효성 검사 로직을 작성해야 합니다.
   @Override
   public boolean isValid(String value, ConstraintValidatorContext context) {
       // null에 대한 허용 여부 로직 추가
      if (value == null) {
         return false;
      }
      // 지정한 정규식과 비교하는 로직 추가
      return value.matches("01(?:0:1:[6-9])[.-]?(\\d{3}|\\d{4})[.-]?(\\d{4})$");
   }
}
  • 이 로직에서 false가 리턴되면 MethodArgumentNotValidException 예외가 발생합니다.

2. Telephone 어노테이션 인터페이스 생성

@Target(ElementType.FIELD)  // 필드에 선언 가능한 어노테이션
@Retention(RetentionPolicy.RUNTIME) // 컴파일 이후에도 JVM에 의해 계속 참조
@Constraint(validatedBy = TelephoneValidator.class) // TelephoneValidator와 매핑
public @interface Telephone {
    String message() default "전화번호 형식이 일치하지 않습니다.";
    Class[] groups() default {};
    Class[] payload() default {};

}

@Target : 어노테이션을 어디서 선언할 수 있는지 정의

  • ElementType.PACKAGE
  • ElementType.TYPE
  • ElementType.COSTRUCTOR
  • ElementType.FIELD
  • ElementType.METHOD
  • ElementType.ANNOTATION_TYPE
  • ElementType.LOCAL_VARIABLE
  • ElementType.PARAMETER
  • ElementType.TYPE_PARAMETER
  • ElementType.TYPE_USE

@Retention : 어노테이션이 실제로 적용되고 유지되는 범위

  • RetentionPolicy.RUNTIME : 컴파일 이후에도 JVM에 의해 계속 참조합니다. 리플렉션이나 로깅에 많이 사용되는 정책입니다.
  • RetentionPolicy.CLASS : 컴파일러가 클래스를 참조할 때까지 유지합니다.
  • RetentionPolicy.SOURCE : 컴파일 전까지만 유지됩니다. 컴파일 이후에는 사라집니다.

인터페이스 내부 요소

  • message() : 유효성 검사가 실패할 경우 반환되는 메시지
  • groups() : 유효성 검사를 사용하는 그룹으로 설정
  • payload() : 사용자가 추가 정보를 위해 전달하는 값

3. ValidationRequestDto 수정

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
@Builder
public class ValidatedRequestDto {

    @NotBlank
    String name;

    @Email
    String email;

    // @Pattern 어노테이션을 @Telephone 어노테이션으로 변경
    @Telephone
    String phoneNumber;

    @Min(value = 20, groups = ValidationGroup1.class)
    @Max(value = 40, groups = ValidationGroup1.class)
    int age;


    @Size(min = 0, max = 40)
    String description;

    @Positive(groups = ValidationGroup2.class)
    int count;

    @AssertTrue
    boolean booleanCheck;

}

유효성 검사 확인

{
    "age": 30,
    "booleanCheck" : false,
    "count" : 30,
    "description" : "Validation 실습 데이터입니다.",
    "email" : "flature@wikibooks.co.kr",
    "name" : "Falture",
    "phoneNumber" : "12345678"
}

별도 그룹을 지정하지 않았기 때문에 checkValidation() 메서드를 호출했을 때 오류가 발생합니다.

[Field error in object 'validatedRequestDto' on field 'phoneNumber': rejected value [12345678]; codes [Telephone.validatedRequestDto.phoneNumber,Telephone.phoneNumber,Telephone.java.lang.String,Telephone]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [validatedRequestDto.phoneNumber,phoneNumber]; arguments []; default message [phoneNumber]]; default message [전화번호 형식이 일치하지 않습니다.]] ]

9.3 일대일 매핑


상품 테이블과 일대일로 매핑될 상품정보 테이블 생성

 

image

 

- 이처럼 하나의 상품에 하나의 상품정보만 매핑되는 구조는 일대일 관계라고 볼 수 있습니다.

9.3.1 일대일 단방향 매핑

@Entity
@Table(name = "product_detail")
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class ProductDetail extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String description;

    @OneToOne
    @JoinColumn(name = "product_number")
    private Product product;

}
  • @OneToOne : 다른 엔티티 객체를 필드로 정의했을 때 일대일 연관관계로 매핑하기 위해 사용
  • @JoinColumn
    • name : 매핑할 외래키의 이름 설정
    • referencedColumnName : 외래키가 참조할 상대 테이블의 칼럼명 지정
    • foreignKey : 외래키를 생성하면서 지정할 제약조건을 설정 (unique, nullable, insertable, updatable 등)


- @JoinColumn은 기본값이 설정돼 있어 자동으로 이름을 매핑하지만 의도한 이름이 들어가지 않기 떄문에
name 속성을 사용해 원하는 컬럼명을 지정하는 것이 좋습니다.

// ProductDetailRepository 생성
    public interface ProductDetailRepository extends JpaRepository<ProductDetail, Long> {

}

테스트 코드 작성

@SpringBootTest
class ProductDetailRepositoryTest {

    @Autowired
    ProductDetailRepository productDetailRepository;

    @Autowired
    ProductRepository productRepository;

    @Test
    public void saveAndReadTest1(){
        Product product = new Product();
        product.setName("스프링 부트 JPA");
        product.setPrice(5000);
        product.setStock(500);

        productRepository.save(product);

        ProductDetail productDetail = new ProductDetail();
        productDetail.setProduct(product);
        productDetail.setDescription("스프링 부트와 JPA를 함께 볼 수 있는 책");

        productDetailRepository.save(productDetail);

        // 생성한 데이터 조회
        // ProductDetail 객체에서 Product 객체를 일대일 단방향 연관관계를 설정했기 때문에 ProductDetailRepository에서 
        // ProductDetail 객체를 조회한 후 연관 매핑된 Product 객체를 조회할 수 있다.
        System.out.println("savedProduct: " + productDetailRepository.findById(productDetail.getId()).get().getProduct());

        System.out.println("savedProductDetail : " + productDetailRepository.findById(productDetail.getId()).get());
    }
}

쿼리 로그 확인

Hibernate: 
    select
        productdet0_.id as id1_1_0_,
        productdet0_.created_at as created_2_1_0_,
        productdet0_.updated_at as updated_3_1_0_,
        productdet0_.description as descript4_1_0_,
        productdet0_.product_number as product_5_1_0_,
        product1_.number as number1_0_1_,
        product1_.created_at as created_2_0_1_,
        product1_.updated_at as updated_3_0_1_,
        product1_.name as name4_0_1_,
        product1_.price as price5_0_1_,
        product1_.stock as stock6_0_1_ 
    from
        product_detail productdet0_ 
    left outer join
        product product1_ 
            on productdet0_.product_number=product1_.number 
    where
        productdet0_.id=?

select 구문을 보면 productDetail 객체와 Product 객체가 함께 조회되는 것을 볼 수 있습니다. 이처럼 엔티티를 조회할 때 연관된 엔티티도 함께
조회하는 것을 '즉시 로딩'이라고 합니다. 또한 @OneToOne 어노테이션으로 인해 left outer join이 생성되는 것을 볼 수 있습니다.

// @OneToOne 어노테이션 인터페이스
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface OneToOne {
    Class targetEntity() default void.class;

    CascadeType[] cascade() default {};

    FetchType fetch() default FetchType.EAGER;

    boolean optional() default true;

    String mappedBy() default "";

    boolean orphanRemoval() default false;
}
  • fetch() : 기본 전략으로 EAGER, 즉 즉시 로딩 전략이 채택되어 있음
  • optional() : 기본값이 true (매핑되는 값이 nullable)
public class ProductDetail extends BaseEntity {

@OneToOne(optional = false)
@JoinColumn(name = "product_number")
private Product product;
}

optional = false 속성 설정 시 테이블 생성 쿼리

Hibernate: 

    create table product_detail (
       id bigint not null auto_increment,
        created_at datetime(6),
        updated_at datetime(6),
        description varchar(255),
        product_number bigint not null,  // NOT NULL이 설정됨
        primary key (id)
    ) engine=InnoDB

Test 실행 쿼리

Hibernate: 
    select
        productdet0_.id as id1_1_0_,
        productdet0_.created_at as created_2_1_0_,
        productdet0_.updated_at as updated_3_1_0_,
        productdet0_.description as descript4_1_0_,
        productdet0_.product_number as product_5_1_0_,
        product1_.number as number1_0_1_,
        product1_.created_at as created_2_0_1_,
        product1_.updated_at as updated_3_0_1_,
        product1_.name as name4_0_1_,
        product1_.price as price5_0_1_,
        product1_.stock as stock6_0_1_ 
    from
        product_detail productdet0_ 
    inner join   // left outer join  ->  inner join
        product product1_ 
            on productdet0_.product_number=product1_.number 
    where
        productdet0_.id=?

9.3.2 일대일 양방향 매핑

양방향 매핑은 양쪽에서 단방향으로 서로를 매핑하는 것을 의미합니다.

Product 엔티티

@Entity
@Getter
@Setter
@NoArgsConstructor
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@Table(name = "product")
public class Product extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long number;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false)
    private Integer stock;

    // 단방향 관계를 추가함
    @OneToOne
    private ProductDetail productDetail;
}

다음과 같이 Product 테이블에 Product_detail_id 칼럼이 추가됩니다

image

쿼리 확인

Hibernate: 
    select
        productdet0_.id as id1_1_0_,
        productdet0_.created_at as created_2_1_0_,
        productdet0_.updated_at as updated_3_1_0_,
        productdet0_.description as descript4_1_0_,
        productdet0_.product_number as product_5_1_0_,
        product1_.number as number1_0_1_,
        product1_.created_at as created_2_0_1_,
        product1_.updated_at as updated_3_0_1_,
        product1_.name as name4_0_1_,
        product1_.price as price5_0_1_,
        product1_.product_detail_id as product_7_0_1_,
        product1_.stock as stock6_0_1_,
        productdet2_.id as id1_1_2_,
        productdet2_.created_at as created_2_1_2_,
        productdet2_.updated_at as updated_3_1_2_,
        productdet2_.description as descript4_1_2_,
        productdet2_.product_number as product_5_1_2_ 
    from
        product_detail productdet0_ 
    left outer join
        product product1_ 
            on productdet0_.product_number=product1_.number 
    left outer join
        product_detail productdet2_ 
            on product1_.product_detail_id=productdet2_.id 
    where
        productdet0_.id=?

쿼리를 보면 양쪽에서 외래키를 가지고 left outer join이 두 번이나 수행되어 효율성이 떨어집니다.

실제 데이터베이스에서도 테이블 간 연관관계를 맺으면 한쪽 테이블이 외래키를 가지는 구조로 이뤄집니다 ( '주인' 개념 )

JPA 에서도 실제 데이터베이스의 연관관계를 반영해서 한쪽의 테이블에서만 외래키를 바꿀 수 있도록 정하는 것이 좋습니다.


JPA 양방향 연관관계의 주인을 지정하는 방법

  • mappedBy 속성 사용
    : 엔티티는 양방향으로 매핑하되 한쪽에게만 외래키를 줘야 하는데, 이때 사용되는 속성 값이 mappedBy 입니다.
    mappedBy 속성은 어떤 객체가 주인인지 표시하는 속성입니다.
public class Product extends BaseEntity {

    // @OneToOne 어노테이션에 mappedBy 속성값을 사용합니다.
    // mappedBy에 들어가는 값은 연관관계를 갖고 있는 상대 엔티티에 있는 연관관계 필드의 이름입니다.
    @OneToOne(mappedBy = "product")
    private ProductDetail productDetail;

}

이 설정으로 ProductDetail 엔티티가 Product 엔티티의 주인이 되고,
다음과 같이 Product 테이블에 있던 외래키가 사라진 것을 볼 수 있습니다.

image

9.1 연관관계 매핑 종류와 방향


  • One To One : 일대일 (1:1)
  • One To Many : 일대다 (1:N)
  • Many To One : 다대일 (N:1)
  • Many To Many : 다대다 (N:M)

image

 

- 공급업체 입장에서 보면 한 가게에 납품하는 상품이 여러 개 있을 수 있으므로 상품 엔티티와 일대다 관계 ( 1 : N )
- 상품 입장에서는 하나의 공급업체에 속하게 되므로 다대일 관계 ( N : 1 )

 

단방향 : 두 엔티티의 관계에서 한쪽의 엔티티만 참조하는 형식

양방향 : 두 엔티티의 관계에서 각 엔티티가 서로의 엔티티를 참조하는 형식

주인(Owner) : 일반적으로 외래키를 가진 테이블이 관계의 주인이 되며, 주인은 외래키를 사용할 수 있으니 상대 엔티티는 읽는 작업만 수행할 수 있다.

+ Recent posts