[Spring] 홍대 레거시 코드 분석

밀크야살빼자·2023년 7월 4일
0

Entity

  • 회원은 게시물과 댓글, 대댓글을 여러번 작성할 수 있기 때문에 회원과 게시물, 댓글, 대댓글 1:n 관계이다.
    • 게시물, 댓글, 대댓글이 연관관계 주인이 된다.
  • 회원이 댓글, 대댓글, 게시물 여러개에 좋아요를 누를 수 있기 때문에 게시물, 댓글, 대댓글과 게시물 좋아요, 댓글 좋아요, 대댓글 좋아요는 1:n관계이다.
    • 게시물 좋아요, 댓글 좋아요, 대댓글 좋아요가 연관관계 주인이 된다.
  • 여러 회원이 팀 하나에 속할 수 있기 때문에 팀과 회원의 관계는 1:n이다.
    • 회원이 연관관계 주인이 된다.
  • MainCategory, SubCategory, Role, LType는 enum 타입을 가지고 있다.

만약 코드 테이블로 카테고리를 관리하는 것은

  • 관련된 테이블은 조회를 할때마다 항상 join으로 code용 테이블에 있는 데이터를 가져와야 한다.
  • 컴파일 단계에서 체크할 수 있는 것이 없다.
  • 코드레벨에서 확인할 수 있는 것이 없다.
    - 코드 테이블이 여러개로 분리된 경우 작성된 쿼리를 통해 여러 테이블을 다 찾아서 확인 해야한다.

  • 비회원은 회원가입 및 로그인만 할 수 있다.
  • 회원은 게시물 작성, 수정, 삭제, 좋아요와 댓글 작성, 수정, 삭제 좋아요가 가능하다.
    • 위의 활동을 할 때에는 사용자 인증이 필요하다.
    • emial과 role을 가지고 JWT 토큰을 생성한다.
    • @AuthenticationPrincipal를 사용하여 현재 로그인한 사용자 정보를 가져온다.
  • 관리자는 회원, 게시물 및 댓글 관리를 한다.
    • 관리자는 회원 정보 수정, 조회 및 삭제, 게시물 조회, 수정 및 삭제와 댓글 조회, 수정 및 삭제 권한이 있다.

Admin

관리자가 회원 관리를 해야하기 때문에 User, Post, Comment 수정 및 삭제 권한이 있다.

Admin Controller

  • 요구사항
    • 회원목록 조회
    • 회원 정보 수정
    • 회원 삭제
    • 회원이 작성한 글&댓글 조회
    • 특정 회원이 작성한 게시글 전체 삭제
    • 특정 회원이 작성한 댓글 전체 삭제
    • 특정 회원이 작성한 게시글 1개 삭제
    • 특정 회원이 작성한 댓글 1개 삭제

AdminController.java


@Controller
@Slf4j
public class AdminController {

    private final AdminUserService adminUserService;
    private final AdminPostService adminPostService;
    private final AdminCommentService adminCommentService;

    public AdminController(AdminUserService adminUserService, AdminPostService adminPostService, AdminCommentService adminCommentService) {
        this.adminUserService = adminUserService;
        this.adminPostService = adminPostService;
        this.adminCommentService = adminCommentService;
    }
     @RequestMapping("/admin")
    private String main() {
        return "admin/main";
    }
    @GetMapping("admin/users")
    public String userList(Model model) {
        List<User> users = adminUserService.findUsers();
        model.addAttribute("users", users);

        return "admin/users";
    }
    @GetMapping("admin/users/{userId}/edit")
    public String userForm(@PathVariable("userId") Long userId, Model model) {
        User user = (User) adminUserService.findOne(userId);

        //UserAllDto form = new UserAllDto(id, username, nickname, profileImageSrc, role, major, part, studentId, team);
        UserAllDto form = UserAllDto.builder()
                .userId(user.getId())
                .username(user.getName())
                .nickname(user.getNickname())
                .major(user.getMajor())
                .part(user.getPart())
                .studentId(user.getStudentId())
                .role((user.getRole()))
                .build();

        model.addAttribute("form", form);

        return "admin/updateUserForm";
    }
    }
  • AdminUserService, AdminPostService, AdminCommentService와 의존관계에 있음
    -> 관리자가 User, Post, Comment 모두 관리하기 때문
  • url 요청을 받아서 service에 넘긴다. -> 핵심 로직은 service에서!
  • @RequestMapping("/admin")을 클래스 상단이 아닌 내부에 왜 썼을까..?
  • userList(Model model) : 요청을 서비스에 넘겨 DB에 저장된 모든 회원 조회 후 Model을 통해 데이터를 view로 넘겨준다.
  • (User)로 타입변환 시켜준다 -> 근데 서비스에서 .get()으로 하여 객체 리턴해도 되지 않았을까..?
  • Builder 패턴을 최상단인 controller에서 구현하면 보안상 좋지 않다. 왜냐면 DB 생명주기에 트랜잭션이랑 같이 DB조회하기 때문이다. -> servicedto에서 해주는게 좋으며 controller에서는 url 수신만 받는게 좋다.
  • controllerservice에 위임만 하면 된다.

BaseController.java

@ControllerAdvice
@RequiredArgsConstructor
public class BaseController {

    private final EntityManager em;

    @ModelAttribute("teams")
    public List<Team> teams(Model model){
        return em.createQuery("select t from Team t", Team.class).getResultList();
    }


}
  • @ControllerAdvice 어노테이션으로 모든 controller에서 발생할 수 있는 예외 처리해준다.
  • @ModelAttribute 어노테이션 사용으로 객체가 자동으로 Model 객체에 추가 후 view로 전달해준다.

Admin Service

  • 요구사항
    • 특정 회원이 작성한 댓글 조회
    • 댓글 전체 조회
    • 댓글 1개 조회
    • 댓글 1개 삭제
    • 특정 회원이 작성한 댓글 전체 삭제

AdminCommentService.java

@Service
@Transactional
@RequiredArgsConstructor
public class AdminCommentService {

    private final AdminCommentRepository adminCommentRepository;

    // 특정 User가 작성한 댓글 조회 (by. userId)
    @Transactional(readOnly = true)
    public List<Comment> findCommentsByUserId(Long userId) { return adminCommentRepository.findByUserId(userId); }

    // 댓글 전체 조회
    @Transactional(readOnly = true)
    public List<Comment> findComments() {
        return adminCommentRepository.findAll();
    }

    // 댓글 1개 조회
    @Transactional(readOnly = true)
    public Comment findOne(Long commentId) {
        return adminCommentRepository.findOne(commentId);
    }

    // 댓글 1개 삭제
    @Transactional
    public void deleteCommentOne(Long commentId) {

        Comment findComment = adminCommentRepository.findOne(commentId);

        adminCommentRepository.deleteComment(findComment);
    }


    // User가 작성한 댓글 전체 삭제
    // 전체 댓글 삭제 아님
    @Transactional
    public void deleteCommentByUser(Long userId) {
        List<Comment> findComments = adminCommentRepository.findByUserId(userId);

        adminCommentRepository.deleteComments(findComments);
    }

}
  • @Transactional을 사용하여 영속 상태로 있고, 다른 것들을 수정해야 한다고 할때 해당 메소드 안에서 수정 가능하다.
  • 조회는 데이터 변경이 없기 때문에 읽기 전용 메소드로 사용한다. 영속성 컨텍스트를 플러시 하지 않기 때문에 약간의 성능이 향상된다.

Admin Repository

AdminCommentRepository.java

@Repository
@RequiredArgsConstructor
public class AdminCommentRepository {

    private final EntityManager em;

    // comment ID로 조회
    public Comment findOne(Long id) { return em.find(Comment.class, id); }

    // 모든 댓글 조회
    public List<Comment> findAll() {
        List<Comment> result = em.createQuery("select c from Comment c", Comment.class)
                .getResultList();

        return result;
    }

    // 작성자의 댓글 조회
    public List<Comment> findByUserId(Long userId) {
        List<Comment> result = em.createQuery("select distinct c from Comment c where c.author.id = :userId", Comment.class)
        		// JPQL 쿼리의 매개변수 값을 설정 userId 매개변수에 전달받은 userId값이 설정됨
                .setParameter("userId", userId)
                //결과를 리스트 형태로 반환 결과가 없으면 빈 리스트가 반환된다.
                .getResultList();

        return result;
    }

    // 댓글 1개 삭제
    public void deleteComment(Comment comment) { em.remove(comment); }

    // 댓글 N개 삭제
    public void deleteComments(List<Comment> comments) {
        for(Comment comment : comments) {
            em.remove(comment);
        }
    }


}
  • 데이터 생성을 막기 위해서 distinct로 중복 제거

CommunityRepository.java

@Repository
@RequiredArgsConstructor
public class CommunityRepository  {

    private final EntityManager em;


    public List<AdminPost> findAll(String category){

        return em.createQuery(
                "select new Likelion.Recruiting.admin.AdminPost(p.id,p.title,u.name,p.createdTime,p.body,u.part) " +
                " from Post p join p.author u" +
                " where p.mainCategory = :main",AdminPost.class)
                .setParameter("main",MainCategory.valueOf(category))
                .getResultList();
    }
    }
  • select 절에 new 명령어를 사용하면 반환받을 클래스를 지정할 수 있다.
    • new는 객체를 생성하라는 의미가 아니라 JPQL에서 지원하는 new 키워드이다.
  • 생성자에 JPQL 조회 결과를 넘길 수 있다.
  • getResultList() : 결과를 반환, 없으면 빈 컬렉션
  • getSingleResult() : 결과가 정확히 하나일 경우 사용

Comment

  • 요구사항
    • 게시글에 대한 댓글 & 대댓글 조회
    • 댓글 저장
    • 댓글 수정
    • 댓글 삭제
    • 댓글 좋아요(이미 있으면 삭제, 없으면 저장)

Comment Controller

CommentController.java

    @CrossOrigin("*")
	@RestController
	@RequiredArgsConstructor
	public class CommentController {
    private final CommentService commentService;
    private final CommentLikeService commentLikeService;
    private final UserService userService;
    private final PostRepository postRepository;

    private final CommentRepository commentRepository;

    private final CommentLikeRepository commentLikeRepository;
       @GetMapping("/community/post/{postId}/comments")//게시글에 따른 댓글 & 대댓글 불러오기
    public DataResponseDto getSimplePosts(@AuthenticationPrincipal CustomOauthUserImpl customOauthUser, @RequestHeader("HEADER") String header, @PathVariable("postId") Long postId) {
        List<Comment> comments = commentRepository.findByPostId(postId);
        String email = customOauthUser.getUser().getEmail();
        User user = userService.findUser(email);// 옵셔널이므로 id없을시 예외처리할때 예외코드날아감 -->try catch쓰기
        List<CommentDto> result = comments.stream()
                .map(comment -> new CommentDto(comment,user))
                .collect(Collectors.toList());
        return new DataResponseDto(result.size(), result);
    }
    @PostMapping("/community/post/{postId}")//댓글 저장 api
    public CreatePostResponseDto saveComment(@AuthenticationPrincipal CustomOauthUserImpl customOauthUser, @RequestBody CreateCommentRequestDto request, @PathVariable("postId") Long postId) {
        Comment createdComment = Comment.builder()
                .body(request.getBody())
                .build();
        String email = customOauthUser.getUser().getEmail();
        User user = userService.findUser(email);
        Post post = postRepository.findById(postId).get();//역시 예외처리는 예외코드로
        Comment savedComment = commentService.createComment(createdComment,post,user);
        return new CreatePostResponseDto(savedComment.getId(), "댓글 작성 성공");
    }
    }
  • CommentService, CommentLikeService, UserService, PostRepository, CommentRepository, CommentLikeRepository 의존관계에 있다.
  • @AuthenticationPrincipalUserDetails 타입을 가지고 있다. -> UserDetails 타입을 구현한 PrincipalDetails 클래스를 받아 User object를 얻는다.
  • 소셜을 통해 회원가입 및 로그인을 하기 때문에 OAuth2User 타입 객체를 사용한다.
    • SecurityContext에는 Authentication 객체 타입만 들어갈 수 있다.
    • Authentication 안에도 저장할 수 있는 객체 타입은 UserDetailsOAuth2User 이다.
    • 회원가입-로그인 프로세스를 직접 구현한 경우 UserDetails 타입 객체를 저장하고, 소셜 로그인 구현시에는 OAuth2User 타입 객체를 저장한다.
    • 조회 할 때도 소셜로그인 사용자 정보 조회 기능에서는 OAuth2User 타입 객체를, 일반 로그인 사용자 정보 조회 기능에서는 UserDetails 타입 객체를 사용한다.
    • 하지만 UserDetails와 OAuth2User를 둘 다 implement하는 클래스를 만들면 동시 조회 가능하다.
  • @AuthenticationPrincipal을 통해 현재 로그인한 회원의 email 정보를 가져온다.
  • email 가지고 회원정보 조회한다.

CommentService.java

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CommentService {

    private final CommentRepository commentRepository;

    @Transactional
    public Comment createComment(Comment comment, Post post, User user){
        comment.setAuthor(user);
        comment.setPost(post);
        Comment createdComment = commentRepository.save(comment);
        return createdComment;
    }

    @Transactional
    public CreateResponseMessage updateComment(Long commentId, Long userId, CreateCommentRequestDto reqeust){
        Comment comment = commentRepository.findById(commentId).get();
        if(userId == comment.getAuthor().getId()){
            comment.update(reqeust.getBody());
            commentRepository.save(comment);
            return new CreateResponseMessage((long)200, "업데이트 성공");
        }
        else return new CreateResponseMessage((long)403, "본인의 댓글이 아닙니다.");

    }

    @Transactional
    public CreateResponseMessage deleteComment (Long commentId, Long userId){
        Comment comment = commentRepository.findById(commentId).get();
        if(userId.equals(comment.getAuthor().getId())) {
            comment.delete();
            if (comment.getIsDeleted() == true)
                return new CreateResponseMessage((long) 200, "삭제 성공");
            else return new CreateResponseMessage((long) 404, "이미 삭제된 댓글입니다.");
        }
        else return new CreateResponseMessage((long)403, "본인의 댓글이 아닙니다.");
    }

    public List<Comment> findUser_Comment(User user) {

        return commentRepository.findAllDesc(user);
    }
}

Post

  • 요구사항
    • 카테고리별로 게시글 가져오기
    • 카테고리별로 게시글 저장
    • 게실글 상세보기
    • 게시글 수정
    • 게시글 삭제
    • 게시글 좋아요(이미 있으면 삭제, 없으면 저장)
    • 게시글 검색

Reply

  • 요구사항
    • 대댓글 저장
    • 대댓글 수정
    • 대댓글 삭제
    • 대댓글 좋아요(이미 있으면 삭제, 없으면 저장)

User

  • 요구사항
    • 로그아웃
    • 로그인
    • 닉네임 중복 확인
    • 유저 정보 상세 페이지
    • 유저 정보 수정
    • 마이페이지에서 유저 정보 조회
    • 마이페이지 수정
    • 유저가 작성한 게시글 조회
    • 유저가 작성한 댓글 조회
    • 유저가 좋아요한 게시글 조회

인증 인가

  1. 클라이언트한테 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되며, AuthenticationFilter에서 인증을 처리한다.
  2. AuthenticationFilter는 HttpServletRequest에서 email과 role을 추출해서 토큰을 생성한다.
  3. AuthenticationManagaer(구현체는 ProviderManager)에게 토큰을 전달한다.
  4. ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달한다.
  5. AuthenticationProvider는 토큰의 정보를 CustomOAuth2UserService에 전달한다.
  6. CustomOAuth2UserService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 CustomOAuthUser 객체를 생성한다.
  7. 생성된 CumstomOAuthUser 객체는 AuthenticationProvider로 전달되고, 해당 Provider 인증을 수행하고 성공하게 되면 ProviderManager로 권한을 담은 토큰을 전달한다.
  8. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다.
  9. AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장한다.

TokenService.java

  • 요구사항
    • 로그인
    • JWT 생성
    • Refresh Token 생성
    • Refresh Token 삭제
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class TokenService {

    private final UserRepository userRepository;
    private final JwtTokenProvider jwtTokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;


    public User getUser(String token){
        String email = jwtTokenProvider.getUserEmail(token);
        User user = userRepository.findByEmail(email).get();

        return user;
    }
    public NavbarDto login(Long uid){
        // 해당 유저 찾기
        User user = userRepository.findById(uid).get();

        String email = user.getEmail();
        Role role = user.getRole();

        // JWT 만들기
        String token = jwtTokenProvider.createToken(email, role);

        return NavbarDto.builder()
                .id(user.getId())
                .name(user.getName())
                .profileImage(user.getProfileImage())
                .isJoined(user.isJoind())
                .role(user.getRole())
                .JWT(token)
                .build();
    }

    public JWTDto makeJwt(Long uid){

        // 해당 유저 찾기
        User user = userRepository.findById(uid).get();

        String email = user.getEmail();
        Role role = user.getRole();

        // JWT 만들기
        String token = jwtTokenProvider.createToken(email, role);

        return new JWTDto(token);
    }

    @Transactional
    public String makeRt(Long uid){
        // 해당 유저 찾기
        User user = userRepository.findById(uid).get();

        String email = user.getEmail();
        Role role = user.getRole();

        // Refresh Token 만들기
        String RT = jwtTokenProvider.createRT(email, role);

        // RT를 저장하기 위헤 객체로 만들기
        RefreshToken refreshToken = RefreshToken.builder()
                .user_id(user.getId())
                .refreshToken(RT)
                .build();

        // RT DB에 저장하기
        refreshTokenRepository.save(refreshToken);

        return RT;
    }

    @Transactional
    public void deleteRt(String rt){
        RefreshToken refreshToken = refreshTokenRepository.findByRefreshToken(rt);
        if(refreshToken != null){ // DB에 Refresh Token이 있는 경우
            refreshTokenRepository.delete(refreshToken);
            System.out.println("rt 삭제 완료!");
        }
        else { // DB에 Refresh Token이 없는 경우 == 이미 삭제 되었거나, 올바르지 않은 RT이거나
            throw new RefreshException(ErrorCode.NOT_VALIDATE_RT.getErrorCode(), "refresh_token이 올바르지 않습니다.");
        }

    }
}
  • email과 role을 가지고 JWT 토큰을 만들어 로그인한다.
  • email과 role을 가지고 Refresh Token을 만든다.

JwtTokenProvider.java


@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private final CustomOAuth2UserService customOAuth2UserService;
    private final RefreshTokenRepository refreshTokenRepository;
    private final UserRepository userRepository;

	// JWT 토큰 유효시간 설정
	//    private Long tokenValidTime = 10 * 1000L; // 10초
    private Long tokenValidTime = 60 * 60 * 1000L; // 1시간
    // Refresh Token 유효시간 설정
	//    private Long RTValidTime = 10 * 1000L; // 1초
    private Long RTValidTime = 14 * 24 * 60 * 60 * 1000L; // 2주
    
    //---------------------- JWT 토큰 생성 ---------------------- //
    public String createToken(String email, Role role) {

        //payload 설정
        //registered claims
        Date now = new Date();
        Claims claims = Jwts.claims()
                .setSubject("access_token") //토큰제목
                .setIssuedAt(now) //발행시간
                .setExpiration(new Date(now.getTime() + tokenValidTime)); // 토큰 만료기한

        //private claims
        claims.put("email", email); // 정보는 key - value 쌍으로 저장.
        claims.put("role", role);

        return Jwts.builder()
                .setHeaderParam("typ", "JWT") //헤더
                .setClaims(claims) // 페이로드
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)  // 서명. 사용할 암호화 알고리즘과 signature 에 들어갈 secretKey 세팅
                .compact();
    }

//---------------------- RT 토큰 생성 ---------------------- //
    public String createRT(String email, Role role) {

        //payload 설정
        //registered claims
        Date now = new Date();
        Claims claims = Jwts.claims()
                .setSubject("refresh_token") //토큰제목
                .setIssuedAt(now) //발행시간
                .setExpiration(new Date(now.getTime() + RTValidTime)); // 토큰 만료기한

        claims.put("email", email); // 정보는 key - value 쌍으로 저장.
        claims.put("role", role);

        return Jwts.builder()
                .setHeaderParam("typ", "refreshToken") //헤더
                .setClaims(claims) // 페이로드
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)  // 서명. 사용할 암호화 알고리즘과 signature 에 들어갈 secretKey 세팅
                .compact();
    }
    }
  • JWT 토큰 유효시간은 1시간, Refresh Token 유효시간은 2주로 하여 시간 지나면 로그아웃 된다.
  • access 토큰과 refresh 토큰을 함께 발행한다.
  • access 토큰은 만료기간을 짧게 설정한다. -> access 토큰이 넘어가더라도, 금방 만료된다.
  • access 토큰이 만료되었더라도, refresh 토큰이 유효하면 다시 access token을 발행한다 -> refresh 토큰의 유효기간은 길게 설정한다.
  • refresh 토큰도 탈취 당하는 문제 때문에 서버의 DB에도 함께 저장한다. 만약 refresh 토큰이 탈취된 것이 확인되면, 서버의 DB에서 해당 refresh 토큰을 제거하면 되기 때문이다.
    • refresh 토큰 유효기간과 별개로 DB를 통해서도 유효성을 한번 더 검사하는 것이다.
  • 로그인 실패시 성공했을 때 남아있던 에러 제거한다.

  • OAuth 2.0는 클라이언트가 resource server에 사용자 권한으로 접근할 수 있는 프로토콜이다.
    • resource server에 사용자 정보 요청 -> 사용자 정보를 바탕으로 별도의 회원가입 및 로그인 처리

@AuthenticationPrincipal을 통해 현재 로그인 중인 유저 정보를 가져와서 Post, Comment, Reply에 대한 CRUD를 한다.

profile
기록기록기록기록기록

0개의 댓글