Light Blue Pointer
본문 바로가기
Developing/Journal

[내일배움캠프][Spring Team Project]뉴스피드 프로젝트 개발일지

by Greedy 2023. 11. 27.

프로젝트 링크 :

 

GitHub - minisundev/Gamelog: 게이머들을 위한 news feed project

게이머들을 위한 news feed project. Contribute to minisundev/Gamelog development by creating an account on GitHub.

github.com

느낀 점 : 

강의가 너무 많아서 듣는데만도 힘들어서 복습도 못 한채로 팀 프로젝트에 들어갔는데

팀원들이 내가 모르는 부분 많이 알려주기도 했지만 일단 과제는 완성해야하니까 모여서 코딩하는 시간에 강의자료 들여다보니까 실전 압축 경험으로 이해가 쏙쏙 되어서 많이 배우게 되었다

사실 강의 듣느라 개인과제는 못했는데 과제를 하면서, 실제로 코딩을 하면서 익히는게 제일 머릿속에 잘 남는 거 같다고 생각했다

이번 팀 프로젝트로 배우는 점도 많았고 정말 좋은 경험이었다!!

잘 하는 팀원은 내가 뭘 물어봤을때 잘 알려줘서 나한테 도움이 되고 마감이 가까워오는데 내가 맡은 부분 이외의 요구사항 구현이 안 되어 있다면 내가 할 일이 많아져서 뭐라도 더 해보려고 하다가 배우는 점이 많기 때문에 도움이 되는 것 같다

그리고 개발하면서 왜 이런 문제가 생겼는지 고민하고 문제를 해결해가는 과정은 항상 추리게임 같아서 너무 재미있다

알고있는 지식을 활용해서 적용해보는 것도 너무 재미있는 것 같다

취업도 빨리 하고싶고 개발자로 가능한 한 오래 일하고 싶다

개발 과정

나는 게시글 수정,삭제 를 맡았는데

하다 보니

프로필 조회, 작성글 조회랑

로그인 기능을 HttpHeader에다가 저장했던 토큰을 쿠키에 저장하는 방식으로 바꾸는 것까지 맡게 되었다

 

처음에는 CUD 기능을 로그인 정보 없이 개발했다

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/feed")
public class FeedController {
    private final FeedService feedService;

    @PostMapping("/add")
    public ResponseEntity<FeedResponseDto> addPost(
            @RequestBody FeedResponseDto requestDto
    ) {
        FeedResponseDto responseDto = feedService.addPost(requestDto);
        return ResponseEntity.status(HttpStatus.CREATED).body(responseDto);
    }
    @PatchMapping("/{postId}")
    public ResponseEntity<FeedResponseDto> updatePost(
            @PathVariable Long postId,
            @RequestBody FeedUpdateRequestDto requestDto
    ) {
        FeedResponseDto responseDto = feedService.updatePost(postId, requestDto);
        return ResponseEntity.ok(responseDto);
    }

    @DeleteMapping("/{postId}")
    public ResponseEntity<Void> deletePost(
            @PathVariable Long postId
    ) {
        feedService.deletePost(postId);
        return ResponseEntity.noContent().build();
    }
@Getter
@Entity
@Table(name = "feeds")
@NoArgsConstructor
public class Post extends Timestamped {

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

    @Column
    private String title;

    @Column
    private String author;

    @Column
    private String content;

    public Post(PostRequestDto resquestDto, String author) {
        this.title = resquestDto.getTitle();
        this.author = author;
        this.content = resquestDto.getContent();
    }

    public void update(PostUpdateRequestDto requestDto) {
        this.title = requestDto.getTitle();
        this.content = requestDto.getContent();
    }
}
package feed.repository;

import feed.entity.Feed;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface FeedJpaRepository extends JpaRepository<Feed, Long> {
    List<Feed> findAllByOrderByCreatedAtDesc();
}

이렇게 되어있었는데

다른 팀원이 User쪽 개발하고 나서 인증 정보 받아서 처리하게 바꿨다

@JoinToColumn 으로 다른 테이블의 Foreign key를 끌어올 수 있다는 것 알게 되었다

@ManyToOne으로 n대 1 관계를 표현한다는 것도 알게 되었다

@Getter
@Entity
@Table(name = "feeds")
@NoArgsConstructor
public class Post extends Timestamped {

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

    @Column
    private String title;

    @Column
    private String author;

    @Column
    private String content;

    @Setter
    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;

    public Post(PostRequestDto resquestDto, String author) {
        this.title = resquestDto.getTitle();
        this.author = author;
        this.content = resquestDto.getContent();
    }

    public void update(PostUpdateRequestDto requestDto) {
        this.title = requestDto.getTitle();
        this.content = requestDto.getContent();
    }
}

처음에는 PostUpdateRequestDto랑 PostRequestDto랑 다른 DTO로 구현이 되어있었는데 팀원이 안에 필드가 다 똑같은데 왜 두개를 만들었냐고 물어서 그냥 PostRequestDto만 쓰게 바꿨었다

근데 더 구현하면서 경험해보니 기능별로 RequestDto가 다 따로 있는게 해당 기능 수정할때 요구되는 입력 필드가 살짝이라도 달라진다면 클래스부터 다시 만들고 코드 다 뜯어고치는것보다 코드 수정이 훨씬 간편하고 용이할 것 같았다

Q. 그래서 처음부터 기능별로 RequestDto다 따로 만드는게 더 바람직한가 싶었다

인증처리하게 만든 Update, Delete

PostController

@PatchMapping("/{postId}")
    public ResponseEntity<PostResponseDto> updatePost(
            @PathVariable Long postId,
            @RequestBody PostUpdateRequestDto requestDto,
            @AuthenticationPrincipal UserDetailsImpl userDetails
    ) {
        try {
            PostResponseDto responseDto = postService.updatePost(postId, requestDto, userDetails.getUser());
            return ResponseEntity.ok(responseDto);
        }catch (RejectedExecutionException | IllegalArgumentException ex){
            return ResponseEntity.badRequest().body(new PostResponseDto(ex.getMessage(), HttpStatus.BAD_REQUEST.value()));

        }
    }

    @DeleteMapping("/{postId}")
    public ResponseEntity<CommonResponseDto> deletePost(
            @PathVariable Long postId,
            @AuthenticationPrincipal UserDetailsImpl userDetails
    ) {
        try {
            postService.deletePost(postId, userDetails.getUser());
            // 성공 메시지 반환
            return ResponseEntity.status(HttpStatus.OK.value()).body(new CommonResponseDto("삭제 성공", HttpStatus.OK.value()));
        } catch (RejectedExecutionException | IllegalArgumentException ex) {
            return ResponseEntity.badRequest().body(new CommonResponseDto(ex.getMessage(), HttpStatus.BAD_REQUEST.value()));
        }
    }

PostService

@Transactional
    public PostResponseDto updatePost(Long postId, PostUpdateRequestDto requestDto, User user) {
        Post postEntity = getUserPost(postId,user);
        postEntity.update(requestDto);
        return new PostResponseDto(postEntity);
    }

    public void deletePost(Long postId, User user) {
        Post postEntity = getUserPost(postId,user);
        feedJpaRepository.delete(postEntity);
    }

    private Post getPostEntity(Long postId) {
        return feedJpaRepository.findById(postId)
                .orElseThrow(() -> new IllegalArgumentException("Post를 찾을 수 없음"));
    }

    private Post getUserPost(Long postId, User user){
        Post post = getPostEntity(postId);
        if(!user.getId().equals(post.getUser().getId())) {
            throw new RejectedExecutionException("작성자만 수정할 수 있습니다.");
        }
        return post;
    }

FeedJpaRepository

public interface FeedJpaRepository extends JpaRepository<Post, Long> {

}

Controller에 Delete 메서드에서 에러 메시지를 출력하는 데에는 내가 원래 돌려주던 리턴값이 없어서 팀원이 미리 만들어둔 CommonResponse를 사용하는데 문제가 없었다

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CommonResponseDto {

    private String msg;
    private Integer statusCode;
}

그런데 Controller에 Update 메서드에서 에러메시지를 출력할 때에는 문제가 있었다

🚩문제 1

Return 타입이 원래 PostResponseDto고 어떻게 업데이트 되었는지 전송해주고 있었는데 CommonResponseDto로 돌려보내게 되면 어떻게 업데이트 되었는지 전송할 수가 없는 것이다

⛳해결 1

PostResponseDto가 CommonResponseDto를 상속하게 해서 PostResponseDto타입을 리턴하지만 상태메시지도 이용할 수 있게 했다

@Getter
@RequiredArgsConstructor
public class PostResponseDto extends CommonResponseDto {
    private String title;
    private String author;
    private String content;
    private LocalDateTime created_at;
    private LocalDateTime updated_at;

    public PostResponseDto(Post post) {
        this.title = post.getTitle();
        this.author = post.getAuthor();
        this.content = post.getContent();
        this.created_at = post.getCreatedAt();
        this.updated_at = post.getUpdatedAt();
    }

    public PostResponseDto(String msg, Integer statuscode){
        super(msg,statuscode);

    }}

그리고 프로젝트 마지막 날에 모여서 보니까 원래 하기로 한 프로필 보기, 프로필 정보 수정 삭제, 프로필에서 해당 유저가 작성한 글 다 보는 기능이 없어서

그 중에 프로필 보기랑 작성글 모아보기 기능을 추가로 맡아서 구현했다

처음에는 이렇게 개발했다

UserController

@GetMapping("/profile")
    public ResponseEntity<UserProfileDto> getUserProfile(
            @AuthenticationPrincipal UserDetailsImpl userDetails
    ) {
        UserProfileDto responseDto = userService.getProfile(userDetails.getUser());
        return ResponseEntity.ok(responseDto);
    }

    @GetMapping("/posts")
    public ResponseEntity<List<PostResponseDto>> getPosts(
            @AuthenticationPrincipal UserDetailsImpl userDetails
    ) {
        List<PostResponseDto> responseDto = userService.getPosts(userDetails.getUser());
        return ResponseEntity.ok(responseDto);
    }

UserController

public UserProfileDto getProfile(User user) {
        return new UserProfileDto(user);
    }

    public List<PostResponseDto> getPosts(User user){
        return feedJpaRepository.findAllByUser_id(user.getId()).stream()
                .map(PostResponseDto::new)
               .collect(Collectors.toList());
    }

FeedJpaRepository

public interface FeedJpaRepository extends JpaRepository<Post, Long> {
    List<Post> findAllByOrderByCreatedAtDesc();

    List<Post> findAllByUser_id(Long user_id);
}

🚩문제 2

근데 개발해놓고 팀원들한테 말하니까 프로필정보 보기랑 작성글 보기가 한 페이지에서 조회되어야 한다고 했다

⛳해결 2

UserProfileDto이랑 List<PostResponseDto>를 합친 형태인 ResponseDto를 하나 더 만들어서 통채로 반환했다

UserProfileDto

@Getter
@Setter
public class UserProfileDto {
    private String userName;
    private String description;
    private List<PostResponseDto> posts;

    public UserProfileDto(User user){
        this.userName = user.getUsername();
        this.description = user.getDescription();
    }
}

UserController

@GetMapping("/profile")
    public ResponseEntity<UserProfileDto> getUserProfile(
            @AuthenticationPrincipal UserDetailsImpl userDetails
    ) {
        User user = userDetails.getUser();
        UserProfileDto responseDto = userService.getProfile(user);
        responseDto.setPosts(userService.getPosts(user));
        return ResponseEntity.ok(responseDto);
    }

Q. 지금보니 기능별로 함수를 따로 분리해서 꺼내고 저기 안에 함수로 연결되게 하는 방식이 더 나은가 싶다

팀원이 전체 게시글 조회 기능을 개발해서 기존에

List<Post> findAllByOrderByCreatedAtDesc();

이 코드를 사용해 놓았는데 쿼리명을 그냥 메소드명으로 쓰면 되는 방식이니 Post에서 조회할때에 user_id로 조회하면 될 것 같았음

@Getter
@Entity
@Table(name = "feeds")
@NoArgsConstructor
public class Post extends Timestamped {

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

    @Column
    private String title;

    @Column
    private String author;

    @Column
    private String content;

    @Setter
    @ManyToOne
    @JoinColumn(name = "user_id")
    private User user;

}

그래서 대충 쿼리를 앞글자 대문자로 바꿔서 메소드 만들면 자동으로 되지 않을까, 싶어서 해봤다

List<Post> findAllByUser_id(Long user_id);

잘 됐다

그러고 나서는 기존에 HttpHeader에다 토큰을 저장하던 것을 쿠키 방식으로 바꾸는 것을 구현했다

프론트 개발을 하지 않았기 때문에 Postman으로 작동하고 있었는데 HttpHeader에다 토큰을 저장할 경우에 login시 발급된 토큰을 복사해서 Post,Patch,Get뭘 하든지 항상 Header에다 붙여넣고 요청을 보내야하는 불편함이 있었고, 과제 요구사항에 로그아웃 기능이 있는데 가뜩이나 프론트도 없는데 수동으로 Postman에서 헤더에 있는 토큰을 삭제하는게 로그아웃이라고 하기에는 좀 그랬다

Cookies.remove()함수를 프론트를 구현했다 치면 js에서 사용한다고 가정하고 쿠키 방식으로 바꾸기로 했다

쿠키랑 세션 강의는 듣긴 들었는데 복습을 못 해서 머리에 없었지만 그냥 코드를 일단 보기 시작했다

// JWT Cookie 에 저장
    public void addJwtToCookie(String token, HttpServletResponse res) {
        try {
            token = URLEncoder.encode(token, "UTF-8").replaceAll("\\\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행

            Cookie cookie = new Cookie(AUTHORIZATION_HEADER, token); // Name-Value
            cookie.setPath("/");

            // Response 객체에 Cookie 추가
            res.addCookie(cookie);
        } catch (UnsupportedEncodingException e) {
            log.error(e.getMessage());
        }
    }

팀원이 만든 쿠키를 저장하는 부분

팀원이 쿠키를 저장하는 것까지는 성공했는데 쿠키에서 토큰이 안 나온다고 해서 나도 도우려고 코드를 살펴보기 시작했다

Response객체에 Cookie를 저장했다면 Request객체에서 쿠키를 뽑아오면 되는 거 아냐? 이러고 Request 객체가 어디에 있는지 파일들 안에서 검색하기 시작했다

그리고 JwtAuthorizationFilter에서 HttpServletRequest 를 받아오는게 보이길래 코드를 찬찬히 살펴보기 시작했다

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

        String token = jwtUtil.resolveToken(request);

        if(Objects.nonNull(token)) {
            if(jwtUtil.validateToken(token)) {
                Claims info = jwtUtil.getUserInfoFromToken(token);

                // 인증정보에 유저정보(username) 넣기
                // username -> user 조회
                String username = info.getSubject();
                SecurityContext context = SecurityContextHolder.createEmptyContext();
                // -> userDetails 에 담고
                UserDetailsImpl userDetails = userDetailsService.getUserDetails(username);
                // -> authentication의 principal 에 담고
                Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                // -> securityContent 에 담고
                context.setAuthentication(authentication);
                // -> SecurityContextHolder 에 담고
                SecurityContextHolder.setContext(context);
                // -> 이제 @AuthenticationPrincipal 로 조회할 수 있음
            } else {
                // 인증정보가 존재하지 않을때
                CommonResponseDto commonResponseDto = new CommonResponseDto("토큰이 유효하지 않습니다.", HttpStatus.BAD_REQUEST.value());
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.setContentType("application/json; charset=UTF-8");
                response.getWriter().write(objectMapper.writeValueAsString(commonResponseDto));
                return;
            }
        }

        filterChain.doFilter(request, response);
    }

String token = jwtUtil.resolveToken(request); 이 부분 따라가서 읽다 보니까

public String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
            return bearerToken.substring(7);
        }
        return null;
    }

토큰을 헤더에서 뽑아오는 부분이 보였다

저 부분을 쿠키에서 뽑아오게 바꾸면 될 것 같았다

그래서 저 부분을 이렇게 바꿨다

// HttpServletRequest 에서 Cookie Value : JWT 가져오기
    public String getTokenFromRequest(HttpServletRequest req) {
        Cookie[] cookies = req.getCookies();
        if(cookies != null) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals(AUTHORIZATION_HEADER)) {
                    try {//FU//
                        return URLDecoder.decode(cookie.getValue(), "UTF-8"); // Encode 되어 넘어간 Value 다시 Decode
                    } catch (UnsupportedEncodingException e) {
                        return null;
                    }
                }
            }
        }
        return null;
    }

🚩문제 3

근데 자꾸 decode할 때 공백을 포함할 수 없다고 떴다

나는 팀원이 만든 코드에

token = URLEncoder.encode(token, "UTF-8").replaceAll("\\\\+", "%20"); // Cookie Value 에는 공백이 불가능해서 encoding 진행

이 부분이 있는데 왜 공백이 있어서 안된다고 하는지 이해할 수 없었다

저 부분에서 공백을 뺀 것이 아닌가?

아무튼 자꾸 공백이 있다고 하니까

cookie.getValue().trim(); 등으로 공백을 제거하려고 노력해 보았으나 전혀 통하지 않았다

그리고 Postman에 뜨는 쿠키값을 직접 가져다가

jwt.io에 넣다가 꺠달았다

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJpZDEyMzQiLCJleHAiOjE3MDEwMDg3NjcsImlhdCI6MTcwMTAwNTE2N30.uU0uz-T36p6KISvO0AlNk5RjzLRTTEjrWO3HvoaO7W4

거기 예시에는 ey이런식으로 암호문만으로 시작하는데 내가 복사한 값에는 Bearer%20이 붙어있었음

Bearer%20eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJpZDEyMzQiLCJleHAiOjE3MDEwMDg3NjcsImlhdCI6MTcwMTAwNTE2N30.uU0uz-T36p6KISvO0AlNk5RjzLRTTEjrWO3HvoaO7W4

팀원이 암호문 만들때 공백이 있으면 안 돌아간다는 말은 Bearer뒤에 공백을 %20으로 만들었을 뿐이고 내가 가져와서 바로 decode 시키기에는 뭐가 더 붙어있단 걸 깨달았다

⛳해결 3

replaceAll("Bearer%20", "") 로 Bearer%20부분을 제거하니까 잘 동작했다