이노베이션 캠프

[TIL] 25일차

hjkim0502 2022. 8. 26. 00:49

이노베이션 캠프 4주차를 수행하면서 배운 것 중 유용한 것을 정리한다.

스프링 어노테이션이 얼마나 큰 역할을 하는지 더욱 알게된 한 주였다.

 

1. MySql

  • 먼저 오늘 배포하기 전에 H2에서 RDS MySql로 바꾸었을 때 
JPA - Field 'id' doesn't have a default value
  • 이런 에러가 자꾸나서 찾아본 결과 JPA와 DB의 save() 관련 sql문 차이가 있어 따로 설정해주어야 되는 부분이있었다.
// User
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long id;
  • 유저 엔티티의 DB id값 설정에서 원래 GenerationType.AUTO에서 GenerationType.IDENTITY로 바꿨었는데, 여기서 문제가 되었던 것이다
  • User의 id값에 Auto Increment(AI) 설정을 해주었더니 해결되었다
  • https://kounjeong.tistory.com/20

2. @AuthenticationPrincipal

  • 컨트롤러가 클라이언트의 요청을 매핑하는 메소드에 인자로 이 어노테이션이 붙은 UserDetailsImpl 객체를 받아와 해당 사용자의 로그인 정보를 활용해 요청을 처리할 수 있다
  • Jwt 인증을 담당하는 필터에서 검증이 완료되면 SecurityContext에 로그인을 성공한 사용자 정보를 담아 이것을 사용하는 것이다.
// JwtProvider
    public Authentication getAuthentication(String token) {
        String username = Jwts.parserBuilder().setSigningKey(JWT_SECRET).build().parseClaimsJws(token).getBody().getSubject();
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);

        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

// JwtFilter
    private void validateToken(HttpServletRequest request, String accessToken, String refreshToken) {
        if (accessToken != null && jwtProvider.validateToken(accessToken) && jwtProvider.validateToken(refreshToken)) {
            Authentication auth = jwtProvider.getAuthentication(accessToken);
            // 정상 토큰이면 토큰을 통해 생성한 Authentication 객체를 SecurityContext에 저장 -> Controller에서 @AuthenticationPrincipal로 받아올 수 있음
            SecurityContextHolder.getContext().setAuthentication(auth);
        } else {
            request.setAttribute("INVALID_JWT", "INVALID_JWT");
        }
    }

3. HttpServletResponse

  • 해당 객체에는 addHeader, setHeader 등 헤더에는 이미 구현된 메소드로 쉽게 설정할 수 있지만 바디에는 직접 구현해주어야 하는 것 같았다
private final ObjectMapper objectMapper = new ObjectMapper();

// HttpServletResponse response

String result = objectMapper.writeValueAsString(msg);
response.getWriter().write(result);
  • ObjectMapper를 이용해 내가 만든 msg를 result 변수에 담고 getWriter() 메소드를 이용해 응답 바디에 원하는 내용을 JSON 형식으로 보낼 수 있었다

4. DB 연관관계

  • JPA의 어노테이션을 활용하여 간편하게 연관관계 설정을 할 수 있었다
    • @OneToMany
    • @ManyToOne
    • @OneToOne
    • @ManyToMany
  • 과제에서는 댓글과 게시글에 적용해보았다 -> 게시글 하나에 댓글 여러개 가능
// 댓글 엔티티
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity // DB 테이블 역할
@Getter
public class Comment extends Timestamped {
    @Id // primary key
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String author;

    @Column(nullable = false)
    private String content;

    @ManyToOne
    @JoinColumn(name = "article_id", nullable = false)
    @JsonIgnore
    private Article article;

 

// 게시글 엔티티
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity // DB 테이블 역할
@Getter
public class Article extends Timestamped {

    @Id // primary key
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(nullable = false) // 컬럼 값이고 반드시 값이 존재해야 함을 나타냅니다.
    private String title;
    @Column(nullable = false)
    private String content;

    @Column(nullable = false)
    private String author;

    @OneToMany(mappedBy = "article", cascade = CascadeType.REMOVE) // cascade 설정으로 게시글 삭제 시, 댓글 모두 삭제
    private List<Comment> comments = new ArrayList<Comment>();
  • 댓글 객체가 article_id라는 외래키를 갖게 되며, 게시글 id가 아닌 게시글 객체 자체를 저장한다
  • Comment 클래스의 게시글 객체에 @ManyToOne이 붙어 댓글에서 게시글 정보를 불러올 수 있게되며
  • Article 클래스의 댓글 목록 객체에 @OneToMany가 붙어 게시글에서 댓글 정보를 불러올 수 있게 된다
  • 과제에서는 게시글을 조회했을 때 댓글 정보들이 따라나오면 되는 상황이었어서 Comment 클래스의 게시글 객체에 @JSONIgnore 설정을 해주었다
  • @OneToMany의 mappedBy 값에 Comment 클래스가 저장할 인스턴스명을 써주고, @JoinColumn의 name 값에 '댓글이 저장할 게시글의 클래스명_그 클래스의 DB id 이름' 형식으로 지정한다
  • https://victorydntmd.tistory.com/208
  • https://dev-coco.tistory.com/132

5. 사용자의 편의를 위한 pagination: https://tecoble.techcourse.co.kr/post/2021-08-15-pageable/

 

6. AOP

  • '핵심기능': 각 API 별 수행해야 할 비즈니스 로직
    • ex) 상품 키워드 검색, 관심상품 등록, 회원 가입, 관심상품에 폴더 추가, ....
  • '부가기능': 핵심기능을 보조하는 기능
    • ex) 회원 패턴 분석을 위한 로그 기록, API 수행시간 저장

 

  • 프록시를 사용하여 AOP가 사용되는 원리이며 컨트롤러의 입장에서는 joinPoint.proceed()로 인해 원래 요청인 createProduct(requestDto)로 전달받게 된다

 

@Aspect
@Component
public class UseTimeAop {
    private final ApiUseTimeRepository apiUseTimeRepository;

    public UseTimeAop(ApiUseTimeRepository apiUseTimeRepository) {
        this.apiUseTimeRepository = apiUseTimeRepository;
    }

    @Around("execution(public * com.sparta.springcore.controller..*(..))")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        // 측정 시작 시간
        long startTime = System.currentTimeMillis();

        try {
            // 핵심기능 수행
            Object output = joinPoint.proceed();
            return output;
        } finally {
            // 측정 종료 시간
            long endTime = System.currentTimeMillis();
            // 수행시간 = 종료 시간 - 시작 시간
            long runTime = endTime - startTime;
  • 스프링 빈으로 등록된 클래스에 @Aspect 어노테이션을 활용하여 AOP로 쓰일 모듈임을 알려주고
  • 어드바이스 설정
    • @Around: '핵심기능' 수행 전과 후 (@Before + @After)
    • @Before: '핵심기능' 호출 전 (ex. Client 의 입력값 Validation 수행)
    • @After: '핵심기능' 수행 성공/실패 여부와 상관없이 언제나 동작 (try, catch 의 finally() 처럼 동작)
    • @AfterReturning: '핵심기능' 호출 성공 시 (함수의 Return 값 사용 가능)
    • @AfterThrowing: '핵심기능' 호출 실패 시. 즉, 예외 (Exception) 가 발생한 경우만 동작 (ex. 예외가 발생했을 때 개발자에게 email 이나 SMS 보냄)
  • 어드바이스에 포인트컷 설정을 통해 적용 범위를 지정한다

7. @Transactional

  • 전에도 TIL에 작성한 적 있었지만 오늘 학습한 내용으로 좀 더 알게 된 것 같다
  • 쪼개질 수 없는 DB연산 단위라고 알고는 있었는데 좀 더 명확해졌다
  • 이 어노테이션이 AOP의 활용 중 하나이다
  • 사용 예시 1)
    @Transactional
    public User updateUser2() {
        // 테스트 회원 "user1" 생성
        // 회원 "user1" 객체 추가
        User user = new User("user1", "진", "꽃등심");
        // 회원 "user1" 객체를 영속화
        User savedUser = userRepository.save(user);

        // 회원의 nickname 변경
        savedUser.setNickname("월드와이드핸섬 진");
        // 회원의 favoriteFood 변경
        savedUser.setFavoriteFood("까르보나라");

        return savedUser;
    }
  • 영속성 컨텍스트에만 반영되고 DB까지는 반영되지 않는 코드에 @Transactional을 붙혀 메소드 종료 시점에 자동으로 업데이트한 정보를 DB에 저장해줌
  • 사용 예시 2)
    // 로그인한 회원에 폴더들 등록
    @Transactional
    public List<Folder> addFolders(List<String> folderNames, User user) {
        // 1) 입력으로 들어온 폴더 이름을 기준으로, 회원이 이미 생성한 폴더들을 조회합니다.
        List<Folder> existFolderList = folderRepository.findAllByUserAndNameIn(user, folderNames);

        List<Folder> savedFolderList = new ArrayList<>();
        for (String folderName : folderNames) {
            // 2) 이미 생성한 폴더가 아닌 경우만 폴더 생성
            if (isExistFolderName(folderName, existFolderList)) {
                // Exception 발생!
                throw new IllegalArgumentException("중복된 폴더명을 제거해 주세요! 폴더명: " + folderName);
            } else {
                Folder folder = new Folder(folderName, user);
                // 폴더명 저장
                folder = folderRepository.save(folder);
                savedFolderList.add(folder);
            }
        }

        return savedFolderList;
    }
  • 중복 폴더명이 포함된 폴더 생성 요청이 들어올 경우 해당 요청으로 들어온 모든 폴더의 생성을 수행하지 않는 상황
  • @Transactional이 DB의 상태를 예외 발생시 해당 메소드 수행 전으로 rollback, 성공 시 commit하게 해준다

8. 예외 처리

@RestControllerAdvice
public class RestApiExceptionHandler {

    @ExceptionHandler(value = { IllegalArgumentException.class })
    public ResponseEntity<Object> handleApiRequestException(IllegalArgumentException ex) {
        RestApiException restApiException = new RestApiException();
        restApiException.setHttpStatus(HttpStatus.BAD_REQUEST);
        restApiException.setErrorMessage(ex.getMessage());

        return new ResponseEntity(
                restApiException,
                HttpStatus.BAD_REQUEST
        );
    }
}
  • 일일히 컨트롤러에 @ExceptionHandler를 달아서 예외 처리를 할 수도 있지만 AOP가 적용된 @RestControllerAdvice를 활용하여 전역적으로 예외 처리할 수 있는 클래스를 따로 만들어 손쉽게 관리할 수 있다
  • 과제 진행중에 @RestControllerAdvice가 컨트롤러에서 발생한 예외만 처리해준다는 것을 나중에 알아서 많이 고생했었다

++ 회원가입 ID, PW 형식 지정할 때 User 클래스의 nickname, password 필드에 @Pattern으로 쉽게 처리 가능한 것 같다

++ 테스트 코드 작성 시 메소드에 @Order() 어노테이션으로 순서 정해서 할 수 있는 것 같다