블로그 이미지

suddiyo


꾸준히 기록하고 성장하는 백엔드 개발자 💻
Today
-
Yesterday
-
Total
-

ABOUT ME

-

  • [Spring] Spring Security + JWT 로그인 구현하기 - 4
    Spring 2023. 6. 16. 14:36

    📝 지난 포스팅

    ➡︎ Spring Security + JWT 로그인 구현하기 - 3

     

    [Spring] Spring Security + JWT 로그인 구현하기 - 3

    📝 지난 포스팅 ➡︎ Spring Security + JWT 로그인 구현하기 - 2 [Spring] Spring Security + JWT 로그인 구현하기 - 2 Spring Security + JWT 로그인 구현 1️⃣ 라이브러리 설정 Spring Security와 JWT를 사용하기 위해 다

    suddiyo.tistory.com


    지난 포스팅에서 Postman을 사용해서 로그인 기능이 잘 작동하는지 테스트를 해보았다.
    이번 포스팅에서는 SpringBootTestTestRestTemplate을 이용하여 test code를 직접 작성해보는 방식으로 테스트를 진행해보자!


    1️⃣ 회원가입 Service 구현

    DB에 회원을 직접 넣는 방식 대신 회원가입 로직을 구현해준다.

    • 모든 객체의 생성은 Builder Pattern 사용
    • DB에 password 저장한 후 UserDeatils를 생성하며 encoding을 하는 방식 대신, DB 자체에 encoding된 password를 저장
    • Entity를 직접 사용하는 것은 매우 좋지 않은 방법 ➡︎ Dto 사용!

     

    💡 회원가입 로직

    1. SignUpDto로 들어온 정보를 toEntity 메서드를 통해 Member 엔티티로 변환한다.
    2. 변환한 엔티티를 DB에 저장한다. 이때, 반환값으로 저장된 Member 엔티티를 받는다.
    3. 반환받은 엔티티를 MemberDto의 static method인 toDto를 호출하여 MemberDto로 변환하여 반환한다.

     

    SignUpDto.java

    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    public class SignUpDto {
    
        private String username;
        private String password;
        private String nickname;
        private String address;
        private String phone;
        private String profileImg;
        private List<String> roles = new ArrayList<>();
    
        public Member toEntity(String encodedPassword, List<String> roles) {
    
            return Member.builder()
                    .username(username)
                    .password(encodedPassword)
                    .nickname(nickname)
                    .address(address)
                    .phone(phone)
                    .profileImg(profileImg)
                    .roles(roles)
                    .build();
        }
    }

     

    MemberDto.java

    @Getter
    @ToString
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public class MemberDto {
    
        private Long id;
        private String username;
        private String nickname;
        private String address;
        private String phone;
        private String profileImg;
    
        static public MemberDto toDto(Member member) {
            return MemberDto.builder()
                    .id(member.getId())
                    .username(member.getUsername())
                    .nickname(member.getNickname())
                    .address(member.getAddress())
                    .phone(member.getPhone())
                    .profileImg(member.getProfileImg()).build();
        }
    
        public Member toEntity() {
            return Member.builder()
                    .id(id)
                    .username(username)
                    .nickname(nickname)
                    .address(address)
                    .phone(phone)
                    .profileImg(profileImg).build();
        }
    }

     

    MemberServiceImpl.java

    ...
    
    @Transactional
    @Override
    public MemberDto signUp(SignUpDto signUpDto) {
        if (memberRepository.existsByUsername(signUpDto.getUsername())) {
            throw new IllegalArgumentException("이미 사용 중인 사용자 이름입니다.");
        }
        // Password 암호화
        String encodedPassword = passwordEncoder.encode(signUpDto.getPassword());
        List<String> roles = new ArrayList<>();
        roles.add("USER");  // USER 권한 부여
        return MemberDto.toDto(memberRepository.save(signUpDto.toEntity(encodedPassword, roles)));
    }

    2️⃣ SecurityConfig 설정

    지난 포스팅에서 구현한 SecurityConfig에 .requestMatchers("/members/sign-up").permitAll() 을 추가함으로써 회원가입 API에 대한 모든 요청을 허가한다.

    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig {
        private final JwtTokenProvider jwtTokenProvider;
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
            return httpSecurity
                    // REST API이므로 basic auth 및 csrf 보안을 사용하지 않음
                    .httpBasic().disable()
                    .csrf().disable()
                    // JWT를 사용하기 때문에 세션을 사용하지 않음
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                    .authorizeHttpRequests()
                    // 해당 API에 대해서는 모든 요청을 허가
                    .requestMatchers("/members/sign-up").permitAll()	// ⭐️
                    .requestMatchers("/members/sign-in").permitAll()
                    // USER 권한이 있어야 요청할 수 있음
                    .requestMatchers("/members/test").hasRole("USER")
                    // 이 밖에 모든 요청에 대해서 인증을 필요로 한다는 설정
                    .anyRequest().authenticated()
                    .and()
                    // JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행
                    .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class).build();
        }
    
        @Bean
        public PasswordEncoder passwordEncoder() {
            // BCrypt Encoder 사용
            return PasswordEncoderFactories.createDelegatingPasswordEncoder();
        }
    
    
    }

    3️⃣ 회원가입 Controller 구현

    memberService.signUp()를 호출하여 반환된 MemberDto 객체를 ResponseEntity에 넣어서 반환한다.

    MemberController.java

    @PostMapping("/sign-up")
    public ResponseEntity<MemberDto> signUp(@RequestBody SignUpDto signUpDto) {
        MemberDto savedMemberDto = memberService.signUp(signUpDto);
        return ResponseEntity.ok(savedMemberDto);
    }

     


    4️⃣ 회원가입 Controller 테스트

    @SpringBootTestTestRestTemplate을 사용하여 Controller 테스트를 한다.

    💡 @SpringBootTest

    • @SpringBootTest는 통합 테스트를 제공하는 기본적인 스프링 부트 테스트 어노테이션
    • 이 포스팅에서는 webEnvironment 프로퍼티를 RANDOM_PORT로 지정
      ➡︎ EmbeddedWebApplicationContex를 로드하여 실제 서블릿 활경 구성, 임의의 port listen

    💡 TestRestTemplate

    • HTTP 요청 후 JSON, xml, String과 같은 응답을 받을 수 있는 HTTP 통신 템플릿
    • ResponseEntity와 Server to Server 통신하는데 자주 사용
    • Header, Content-Type 등을 설정하여 외부 API 호출
    • webEnvironment 설정시 그에 맞춰 자동으로 설정되어 빈 생성
    • RestTemplate의 테스트 처리 가능

     

    MemberControllerTest.java

    @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    @Slf4j
    class MemberControllerTest {
    
        @Autowired
        DatabaseCleanUp databaseCleanUp;
        @Autowired
        MemberService memberService;
        @Autowired
        TestRestTemplate testRestTemplate;
        @LocalServerPort
        int randomServerPort;
    
        private SignUpDto signUpDto;
    
        @BeforeEach
        void beforeEach() {
            // Member 회원가입
            signUpDto = SignUpDto.builder()
                    .username("member")
                    .password("12345678")
                    .nickname("닉네임")
                    .address("서울시 광진구")
                    .phone("010-1234-5678")
                    .build();
        }
    
        @AfterEach
        void afterEach() {
            databaseCleanUp.truncateAllEntity();
        }
    
        @Test
        public void signUpTest() {
    
            // API 요청 설정
            String url = "http://localhost:" + randomServerPort + "/members/sign-up";
            ResponseEntity<MemberDto> responseEntity = testRestTemplate.postForEntity(url, signUpDto, MemberDto.class);
    
            // 응답 검증
            assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
            MemberDto savedMemberDto = responseEntity.getBody();
            assertThat(savedMemberDto.getUsername()).isEqualTo(signUpDto.getUsername());
            assertThat(savedMemberDto.getNickname()).isEqualTo(signUpDto.getNickname());
        }
    
    }

    🖤 beforeEach()

    • 각 테스트 케이스 전에 실행되어 회원 등록을 위한 SignUpDto 객체 생성

     

    🖤 afterEach()

    • 각 테스트 케이스 후에 실행되어 DB를 정리하기 위해 모든 엔티티 삭제

     

    🖤 signUpTest()

    • 회원을 등록하고 저장된 회원 객체의 username, nickname이 예상값과 일치하는지 확인

     

    DatabaseCleanUp.java

    @Service
    @RequiredArgsConstructor
    @Slf4j
    public class DatabaseCleanUp implements InitializingBean {
    
        private final EntityManager entityManager;
        private List<String> tableNames = new ArrayList<>();
    
        @Override
        public void afterPropertiesSet() throws Exception {
            tableNames = entityManager.getMetamodel().getEntities().stream()
                    .filter(entityType -> entityType.getJavaType().getAnnotation(Entity.class) != null)
                    .map(entityType -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entityType.getName()))
                    .collect(Collectors.toList());
        }
    
        @Transactional
        public void truncateAllEntity() {
            entityManager.flush();
    
    
            entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
            for (String tableName : tableNames) {
                entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
    
            }
            entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
        }
    
    }

    5️⃣ 로그인 Controller 테스트

    MemberControllerTest.java

     @Test
        public void signInTest() {
            memberService.signUp(signUpDto);
    
            SignInDto signInDto = SignInDto.builder()
                    .username("member")
                    .password("12345678").build();
    
            // 로그인 요청
            JwtToken jwtToken = memberService.signIn(signInDto);
    
            // HttpHeaders 객체 생성 및 토큰 추가
            HttpHeaders httpHeaders = new HttpHeaders();
            httpHeaders.setBearerAuth(jwtToken.getAccessToken());
            httpHeaders.setContentType(MediaType.APPLICATION_JSON);
    
            log.info("httpHeaders = {}", httpHeaders);
    
            // API 요청 설정
            String url = "http://localhost:" + randomServerPort + "/members/test";
            ResponseEntity<String> responseEntity = testRestTemplate.postForEntity(url, new HttpEntity<>(httpHeaders), String.class);
            assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
            assertThat(responseEntity.getBody()).isEqualTo(signInDto.getUsername());
    
    //        assertThat(SecurityUtil.getCurrentUsername()).isEqualTo(signInDto.getUsername()); // -> 테스트 코드에서는 인증을 위한 절차를 거치지 X. SecurityContextHolder 에 인증 정보가 존재하지 않는다.
    
    
        }

    🖤 signInTest()

    • 회원가입 성공한 username, password를 입력하여 로그인 진행
    • 로그인 성공하면 JwtToken 정상적으로 발급
    • httpHeader에 발급받은 JwtToken 객체의 accessToken 넣어주고 MediaType JSON으로 지정
    • HttpEntity에 위에서 설정한 httpHeader 등록한 후, testRestTemplate의 postForEntity 메서드 실행
    • responseEntitiy의 상태값과 담겨온 객체 내용이 예상값과 일치하는지 확인

     

    💡 테스트 코드에서의 SecurityUtil 사용

    테스트 코드에서는 인증을 위한 절차를 거치지 않는다.
    따라서 SecurityContextHolder에 인증 정보가 존재하지 않기 때문에, 직접적으로 SecurityUtil을 사용하는 것이 불가능하다.


    MemberControllerTest


    📋 참고 자료

     

    [스프링부트 (9)] SpringBoot Test(2) - @SpringBootTest로 통합테스트 하기

    [스프링부트 (9)] SpringBoot Test(2) - @SpringBootTest로 통합테스트 하기 안녕하세요. 갓대희 입니다. 이번 포스팅은 [ 스프링 부트 통합 테스트 하기 (@SpringBootTest)] 입니다. : ) 0. 들어가기 앞서 이번 포스

    goddaehee.tistory.com

     

    728x90

    댓글

Designed by Tistory.