본문 바로가기

프로젝트/멋사 개인 프로젝트 (mutsa-SNS)

[07] mutsa-SNS 3일차 - (2) 글쓰기 기능

글쓰기 기능을 추가해 보았다.

글쓰는 기능은 유효한 토큰을 가지고 있는 유저만 사용 가능하게 하였다.

1. Configuration

JwtTokenUtil

public class JwtTokenUtil {

    private static Claims extractClaims(String token, String key) {
        return Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody();
    }

    public static boolean isExpired(String token, String key) {
        Date expiredDate = extractClaims(token, key).getExpiration();
        return expiredDate.before(new Date()); // 현재보다 전인지 check. true : 만료됨
    }

    public static String getUserName(String token, String key) {
        return extractClaims(token, key).get("userName").toString();
    }

}

토큰의 만료여부와, 토큰의 헤더에 저장되어 있는 userName을 불러 올 수 있는 method를 추가하였다.

 

2. Configuration

JwtTokenFilter

@RequiredArgsConstructor
public class JwtTokenFilter extends OncePerRequestFilter {
    private final UserService userService;
    private final String secretKey;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        final String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); //헤더에 토큰을 넘긴 것을 가져옴.


        //토큰이 없는 경우 or Bearer로 시작하는 토큰이 아닌 경우
        if(authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;

        }

        //bearer 이후 문자열 token 분리 성공 실패
        String token;
        try {
            token = authorizationHeader.split(" ")[1];
        } catch (Exception e) {
            filterChain.doFilter(request, response);
            return;
        }

        //만료된 토큰일 경우
        if(JwtTokenUtil.isExpired(token, secretKey)) {
            filterChain.doFilter(request, response);
            return;
        };

        // Token에서 UserName꺼내기 (JwtTokenUtil에서 Claim에서 꺼냄)
        String userName = JwtTokenUtil.getUserName(token, secretKey);
        User user = userService.tokenGetUserByUserName(userName);

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), null
                , List.of(new SimpleGrantedAuthority(user.getRole().name())));
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); //권한 부여
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request,response);

    }

}

밑의 경우를 제외한 경우를 제외한 경우 인가를 내렸다.

  • 토큰을 보유하지 않은 경우
  • Bearer로 시작하는 토큰이 아닌 경우
  • Bearer이후의 토큰 내용 분리가 실패하는 경우
  • 만료 시간이 지난 토큰인 경우

 

SecurityConfig

@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
//WebSecurityConfigurerAdapter 는 이후 SpringBoot에서 잘 안쓰게 됨
public class SecurityConfig {

    private final UserService userService;

    @Value("${jwt.token.secret}")
    private String secretKey;

    private static final String[] PERMIT_URL_ARRAY = {
            /* swagger v2 */
            "/swagger-resources",
            "/swagger-resources/**",
            "/configuration/ui",
            "/configuration/security",
            "/swagger-ui.html",
            "/webjars/**",
            /* swagger v3 */
            "/v3/api-docs/**",
            "/swagger-ui/**",
            /* login and join*/
            "/api/v1/users/login",
            "/api/v1/users/join"

    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .httpBasic().disable()
                .csrf().disable()
                .cors().and()
                .authorizeRequests()
                .antMatchers(PERMIT_URL_ARRAY).permitAll()
                .antMatchers(HttpMethod.POST,"/api/**").authenticated() // post는 인가자만 허용
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt사용하는 경우 씀
                .and()
                .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class) //UserNamePasswordAuthenticationFilter적용하기 전에 JWTTokenFilter를 적용
                .build();
    }

}

모든 유저가 접근 가능한 URL을 PERMIT_URL_ARRAY에 담았다.

  • swagger의 경우 filter를 걸면 접근 오류가 생겨 관련 URL을 모두 추가해 주었다.
  • 로그인과 회원가입은 모든 유저가 가능해야 하므로 추가해 주었다.

그 후, Post와 관련된 모든 내용은 권한이 있는 자만 허락해 주었다.

 

<Reference>

https://devfunny.tistory.com/692

 

SpringBoot + SpringSecurity 프로젝트에 Swagger 3.0 적용하기

이전 버전 Swagger 2.0 적용 방법 https://devfunny.tistory.com/313 SpringBoot에 Swagger을 빠르게 적용해보기 Swagger 요즘 백엔드 개발은 ModelAndView 방식보다는 API 위주의 어플리케이션을 권장하고 있다. API 개발

devfunny.tistory.com

 

3. Domain

Post

@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Table(name = "post")
@Entity
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
public class Post extends BaseEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;
    private String body;
    private String title;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "user_id")
    private User user;
}

글에 대한 정보를 담고 있는 Entity를 생성해 주었다.

유저가 다수의 글을 쓸 수 있으므로 @ManyToOne 매칭을 해주었다. 이 때, Post table이 user 의 key(id)를 foreign key로 갖게 된다.

fetch를 EAGER로 설정하면 예기치 못한 오류가 자주 발생한다 하니, LAZY로 설정하였다.

 

<Reference>

https://ict-nroo.tistory.com/132

 

[JPA] 즉시 로딩과 지연 로딩(FetchType.LAZY or EAGER)

즉시 로딩과 지연 로딩프록시 학습 처음에 했던 질문. Member를 조회할 때 Team도 함께 조회 해야 할까?비즈니스 로직에서 단순히 멤버 로직만 사용하는데 함께 조회하면, 아무리 연관관계가 걸려

ict-nroo.tistory.com

 

PostCreateRequest (domain - dto directiory)

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class PostCreateRequest {
    private String title;
    private String body;
}

글 작성시 사용자에게 제목과 본문을 받아온다.

 

PostCreateResponse (domain - dto directiory)

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class PostCreateResponse {
    private String message;
    private int id;
}

글 작성 성공 시, 성공 메세지와 post의 id가 출력된다.

 

PostDto (domain - dto directiory)

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class PostDto {

    private int id;
    private String userName;
    private String title;
    private String body;
    private LocalDateTime lastModifiedAt;
    private LocalDateTime createdAt;

}

PostService에서 PostController로 정보를 옮길 때 사용하는 dto이다.

 

4. Repository

PostRepository

public interface PostRepository extends JpaRepository<Post, Long> {
}

JpaRepository를 extends 해주었다.

 

5. Service

PostService

@Service
@RequiredArgsConstructor
@Slf4j
public class PostService {

    private final PostRepository postRepository;
    private final UserRepository userRepository;

    public PostDto createPost(PostCreateRequest req, String userName) {

        log.info("userName:{}", userName);

        User user = userRepository.findByUserName(userName)
                .orElseThrow(()-> new AppException(ErrorCode.NOT_FOUNDED_USER_NAME, ""));

        log.info("user info:{}", user.getUserName());

        Post post = Post.builder()
                .user(user)
                .title(req.getTitle())
                .body(req.getBody())
                .build();

        postRepository.save(post);

        return PostDto.builder()
        		.id(post.getId())
                .title(post.getTitle())
                .body(post.getBody())
                .userName(post.getUser().getUserName())
                .build();
    }
}

로그인한 유저의 토큰에서 가져온 userName과 유저가 입력한 글 제목, 글 내용을 controller에게 받는다.

db에서 userName을 통해 검색한 결과 없을 때 exception을 발생한다. (후에 토큰을 발행받은 후 유저가 삭제되는 경우 이 경우에 걸릴 듯 하다.)

그 후, post Entity에 입력받은 정보를 저장하고, PostDto에 정보를 담아 controller에 return해준다.

 

6. Controller

PostController

@RestController
@RequestMapping("/api/v1/posts")
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @PostMapping
    public Response<PostCreateResponse> createPost(@RequestBody PostCreateRequest req, Authentication authentication) {
        String userName = authentication.getName();
        PostDto postDto = postService.createPost(req, userName);
        return Response.success(new PostCreateResponse("포스트 등록 완료", postDto.getId()));
    }



}

유저로부터 글 제목과 글 내용을 받고, 인증내용을 토대로 userName을 가져온다.

그 후 PostService에서 로직을 실행한 후 받은 postDto을 토대로 Response를 view에 return한다.

 

7. TestCode

PostControllerTest

@WebMvcTest(PostController.class)
class PostControllerTest {
    @Autowired
    MockMvc mockMvc;

    @MockBean
    PostService postService;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    @DisplayName("포스트 작성 성공")
    @WithMockUser
    void post_success() throws Exception{
        PostCreateRequest postCreateRequest = PostCreateRequest.builder()
                .title("title")
                .body("body")
                .build();

        when(postService.createPost(any(),any())).thenReturn(mock(PostDto.class));

        mockMvc.perform(post("/api/v1/posts")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postCreateRequest)))
                .andDo(print())
                .andExpect(status().isOk());

    }

    @Test
    @DisplayName("포스트 작성 실패 - 로그인 하지 않은 상태(토큰 x)")
    @WithAnonymousUser
    void post_fail() throws Exception{

        PostCreateRequest postCreateRequest = PostCreateRequest.builder()
                .title("title")
                .body("body")
                .build();

        mockMvc.perform(post("/api/v1/posts")
                        .with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(postCreateRequest)))
                .andDo(print())
                .andExpect(status().isUnauthorized());

    }
    
}

포스트 작성 성공과 실패 총 두가지 경우를 나눠 테스트 코드를 작성하였다.

@WithMockUser(인증된 상태) , @WithAnonymousUser(인증되지 않은 상태)를 사용하였다.

 

PostServiceTest

public class PostServiceTest {

    PostService postService;

    PostRepository postRepository = mock(PostRepository.class);
    UserRepository userRepository = mock(UserRepository.class);

    @BeforeEach
    void setUp() {
        postService = new PostService(postRepository, userRepository);

    }

    @Test
    @DisplayName("포스트 작성 성공")
    void post_success() {

        Post mockPostEntity = mock(Post.class);
        User mockUserEntity = mock(User.class);

        when(userRepository.findByUserName(mockUserEntity.getUserName()))
                .thenReturn(Optional.of(mockUserEntity));

        when(postRepository.save(any()))
                .thenReturn(mockPostEntity);

        Assertions.assertDoesNotThrow(() -> postService.createPost(new PostCreateRequest(mockPostEntity.getTitle(), mockPostEntity.getBody()), mockUserEntity.getUserName()));

    }

    @Test
    @DisplayName("포스트 작성 실패 - 유저가 존재하지 않을 때")
    void post_fail() {

        Post mockPostEntity = mock(Post.class);
        User mockUserEntity = mock(User.class);

        when(userRepository.findByUserName(mockUserEntity.getUserName()))
                .thenReturn(Optional.empty());

        when(postRepository.save(any()))
                .thenReturn(mockPostEntity);


        AppException exception = assertThrows(AppException.class,
                ()-> {
                    postService.createPost(new PostCreateRequest(mockPostEntity.getTitle(), mockPostEntity.getBody()), mockUserEntity.getUserName());
                });

        Assertions.assertEquals("NOT_FOUNDED_USER_NAME", exception.getErrorCode().name());

    }
}

SpringBoot Dependency가 없이 Pojo만으로 테스트 가능하도록 작성하였다.

User와 Post에 대한 Mock 객체를 임의로 설정하였다.

포스트 실패의 경우, userName을 db에서 찾았을 때, 빈 객체를 Optional.empty()를 통해 return하도록 하였다.

그 경우 발생한 exception이 "NOT_FOUNDED_USER_NAME"과 동일한지 검증하였다.

 

<reference>

https://covenant.tistory.com/256

 

완벽정리! Junit5로 예외 테스트하는 방법

환경 구성 testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeClasspath - Runtime classpath of source set 'test'. +--- org.springframework.boot:spring-boot-starter-web -> 2.5.6 \--- org.springframework.boot:spring-boot-sta

covenant.tistory.com

https://www.daleseo.com/java8-optional-after/

 

자바8 Optional 2부: null을 대하는 새로운 방법

Engineering Blog by Dale Seo

www.daleseo.com

 

발생 이슈들

1. JwtTokenFilter에서의 예외 처리 시도

 

JwtTokenFilter에서 해당 토큰이 유효하지 않은 경우 throw new exception을 통해 예외 처리를 하고 싶었으나,

return하지 않을 시 error가 났다. 만약 인증/인가에서 exception 처리를 하고 싶다면 아래의 방법을 이용해야 할 듯 하다.

 

<Reference>

https://velog.io/@seongwon97/Spring-Security-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80%EC%9D%98-Exception

 

[Spring Security] 인증/인가의 Exception

Spring Security에서 인증/인가에 대한 예외처리는 FilterSecurityFilter와 ExceptionTranslationFilter가 처리를 하게 됩니다.

velog.io

https://velog.io/@dltkdgns3435/%EC%8A%A4%ED%94%84%EB%A7%81%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-JWT-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC

 

스프링시큐리티 JWT 예외처리

🎈시작하며 > 이 포스트는 정답이 아니며, 주관적인 생각에 의해 작성한 글입니다. 해당 포스트를 보고 제가 잘못 알고 있는 점이나, 지적사항이 있으시면 댓글로 남겨주시면 정말 감사하겠습

velog.io

 

2. Docker images가 계속 누적됨

 

1일차에 설정한 CI/CD로 자동 배포를 실행하면서 container는 제거했지만, image는 제거하지 않아 계속 쌓이고 있었다.

deploy-docker.sh에서 이미지를 pull 받을 때, 새 image가 받아지면 기존 image를 제거하도록 코드를 추가하였다.

image의 구분은 tag를 이용하여 하였으며, tag가 latest가 아니면 삭제하도록 하였다.

docker rmi $(docker images -f "dangling=true" -q)

<Reference>

https://jhkimmm.tistory.com/9

 

[Docker] none 이미지 삭제하기

도커 이미지를 빌드하다보면 위와 같은 이미지들이 쌓이곤 하는데 일일히 ID입력해서 지우기는 너무 번거롭습니다. 이미지들을 일괄적으로 한번에 삭제하는 방법을 알아보겠습니다. docker rmi $(d

jhkimmm.tistory.com

 

<전체 Reference>

https://devfunny.tistory.com/692

 

SpringBoot + SpringSecurity 프로젝트에 Swagger 3.0 적용하기

이전 버전 Swagger 2.0 적용 방법 https://devfunny.tistory.com/313 SpringBoot에 Swagger을 빠르게 적용해보기 Swagger 요즘 백엔드 개발은 ModelAndView 방식보다는 API 위주의 어플리케이션을 권장하고 있다. API 개발

devfunny.tistory.com

https://ict-nroo.tistory.com/132

 

[JPA] 즉시 로딩과 지연 로딩(FetchType.LAZY or EAGER)

즉시 로딩과 지연 로딩프록시 학습 처음에 했던 질문. Member를 조회할 때 Team도 함께 조회 해야 할까?비즈니스 로직에서 단순히 멤버 로직만 사용하는데 함께 조회하면, 아무리 연관관계가 걸려

ict-nroo.tistory.com

https://covenant.tistory.com/256

 

완벽정리! Junit5로 예외 테스트하는 방법

환경 구성 testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeClasspath - Runtime classpath of source set 'test'. +--- org.springframework.boot:spring-boot-starter-web -> 2.5.6 \--- org.springframework.boot:spring-boot-sta

covenant.tistory.com

https://www.daleseo.com/java8-optional-after/

 

자바8 Optional 2부: null을 대하는 새로운 방법

Engineering Blog by Dale Seo

www.daleseo.com

https://velog.io/@seongwon97/Spring-Security-%EC%9D%B8%EC%A6%9D%EC%9D%B8%EA%B0%80%EC%9D%98-Exception

 

[Spring Security] 인증/인가의 Exception

Spring Security에서 인증/인가에 대한 예외처리는 FilterSecurityFilter와 ExceptionTranslationFilter가 처리를 하게 됩니다.

velog.io

https://velog.io/@dltkdgns3435/%EC%8A%A4%ED%94%84%EB%A7%81%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0-JWT-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC

 

스프링시큐리티 JWT 예외처리

🎈시작하며 > 이 포스트는 정답이 아니며, 주관적인 생각에 의해 작성한 글입니다. 해당 포스트를 보고 제가 잘못 알고 있는 점이나, 지적사항이 있으시면 댓글로 남겨주시면 정말 감사하겠습

velog.io

https://jhkimmm.tistory.com/9

 

[Docker] none 이미지 삭제하기

도커 이미지를 빌드하다보면 위와 같은 이미지들이 쌓이곤 하는데 일일히 ID입력해서 지우기는 너무 번거롭습니다. 이미지들을 일괄적으로 한번에 삭제하는 방법을 알아보겠습니다. docker rmi $(d

jhkimmm.tistory.com