이노베이션 캠프 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");
}
}
- UsernamePasswordAuthenticationToken객체를 SecurityContextHolder를 통해 저장하는 코드이다
- https://hou27.tistory.com/entry/Spring-Security-JWT
- jwt 필터 관련 코드는 위 링크와 아래 링크를 적절히 혼합하여 만들었다
- https://velog.io/@shinmj1207/Spring-Spring-Security-JWT-%EB%A1%9C%EA%B7%B8%EC%9D%B8
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() 어노테이션으로 순서 정해서 할 수 있는 것 같다
'이노베이션 캠프' 카테고리의 다른 글
[TIL] 27일차 (0) | 2022.08.28 |
---|---|
[TIL] 26일차 (0) | 2022.08.27 |
[TIL] 24일차 (0) | 2022.08.24 |
[TIL] 18일차 (0) | 2022.08.18 |
[TIL] 17일차 (0) | 2022.08.18 |