[스프링 부트 심화] AOP, Transaction
0. 개요
- AOP 이해하기
- 스프링 예외처리
- DB 트랜잭션
1. 회원 별 사용 시간 측정하기
- 관리자만 사용시간을 조회
- 사용시간의 기준 설정 : 1) 페이지에 머문 시간 -> 동작이 없을 경우 X, 2) 서버 사용시간 -> API 수행시간을 총합
- 사용시간 측정 방법 : 응답을 보낸 시간 - 요청이 들어온 시간
- 구현 방법 :
(IntelliJ가 제공하는 임시 연습 코드 File > New > Scratch File)
class Scratch {
public static void main(String[] args) {
// 측정 시작 시간
long startTime = System.currentTimeMillis();
// 함수 수행
long output = sumFromOneTo(1_000_000_000);
// 측정 종료 시간
long endTime = System.currentTimeMillis();
long runTime = endTime - startTime;
System.out.println("소요시간: " + runTime); // ms 단위
}
// 수행할 함수
private static long sumFromOneTo(long input) {
long output = 0;
for (int i = 1; i < input; ++i) {
output = output + i;
}
return output;
}
}
2. 설계
테이블 :
- 테이블 명 : ApiUseTime
- PK : id, Long
- FK : user_id, Long
- column : totalTime, Long
관심 상품 저장에만 누적 시간 저장 기능 구현하기 (POST /api/products)
@PostMapping("/api/products")
public Product createProduct(@RequestBody ProductRequestDto requestDto,
@AuthenticationPrincipal UserDetailsImpl userDetails) {
long start = System.currentTimeMillis();
try {
User user = userDetails.getUser();
return productService.createProduct(requestDto, user.getId());
} finally {
long end = System.currentTimeMillis();
long runTime = end - start;
User user = userDetails.getUser();
ApiUseTime apiUseTime = apiUseTimeRepository.findByUser(user)
.orElse(null);
if (apiUseTime == null) {
apiUseTime = new ApiUseTime(user, runTime);
} else {
apiUseTime.addUseTime(runTime);
}
apiUseTimeRepository.save(apiUseTime);
System.out.println("[API Use Time] Username: " + user.getUsername() + ", Total Time : " + apiUseTime.getTotalTime());
}
}
테이블과 레포지토리(생략)
@Getter
@Setter
@Entity
@NoArgsConstructor
public class ApiUseTime {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@OneToOne
@JoinColumn(name = "USER_ID", nullable = false)
private User user;
@Column(nullable = false)
private Long totalTime;
public ApiUseTime(User user, long totalTime) {
this.user = user;
this.totalTime = totalTime;
}
public void addUseTime(long useTime) {
this.totalTime += useTime;
}
}
@Entity
@Table(name = "users")
public class User {
...
@OneToOne
private ApiUseTime apiUseTime;
...
}
- 관리자 권한 : 컨트롤러 위에 한 줄 추가
@Secured(UserRoleEnum.Authority.ADMIN)
3. 핵심기능과 부가기능
- 핵심기능 : 상품 키워드 검색, 관심상품 등록, 회원 가입, 관심상품 폴더 추가
- 부가 기능 : 회원 패턴 분석을 위한 로그 기록, API 수행시간 저장
문제점 :
- 한 함수 안에 섞여 있어 동료 개발자가 핵심 기능과 부가기능을 구분하기 어려움
- 핵심 기능의 숫자가 늘어날수록 비례적으로 부가기능이 증가.
- 핵심 기능이 수정된 경우 비례적으로 관리해야 할 부가 기능을 고려해야 함.
- 부가기능 추가를 깜박한다면 제대로된 수행 시간이 측정되지 못하며 신뢰성, 법적 이슈가 발생될 수 있음.
- 부가기능을 삭제할 경우에도 지우는 과정에서도 실수가 발생할 일이 큼
해결책 :
- AOP (Aspect Oriented Programming)을 통한 부가 기능의 모듈화
- 부가기능과 핵심기능은 사용 관점이 다르기 때문에 설계와 구현역시 달라져야 함.
- 스프링은 AOP를 제공하고 있음.
- 스프링의 AOP는 독립적으로 기능하는 핵심 기능과 부가 기능을 앞 뒤로 붙여주는 작업을 함.
- 이 때, 부가 기능을 어드바이스라고 하고 부가기능의 적용 위치를 포인트컷이라함.
4. 스프링 AOP 적용
- 먼저, 롤백(Rollback)을 진행 : ProductController에 추가했던 부가 기능을 제거
- AOP를 사용해 모든 Controller에 부가 기능 추가하기 :
package com.example.springcore.aop;
@Aspect
@Component
public class UseTimeAop {
private final ApiUseTimeRepository apiUseTimeRepository;
public UseTimeAop(ApiUseTimeRepository apiUseTimeRepository) {
this.apiUseTimeRepository = apiUseTimeRepository;
}
@Around("execution(public * com.example.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;
// 로그인 회원이 없는 경우, 수행시간 기록하지 않음
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal().getClass() == UserDetailsImpl.class) {
// 로그인 회원 정보
UserDetailsImpl userDetails = (UserDetailsImpl) auth.getPrincipal();
User loginUser = userDetails.getUser();
// API 사용시간 및 DB 에 기록
ApiUseTime apiUseTime = apiUseTimeRepository.findByUser(loginUser)
.orElse(null);
if (apiUseTime == null) {
// 로그인 회원의 기록이 없으면
apiUseTime = new ApiUseTime(loginUser, runTime);
} else {
// 로그인 회원의 기록이 이미 있으면
apiUseTime.addUseTime(runTime);
}
System.out.println("[API Use Time] Username: " + loginUser.getUsername() + ", Total Time: " + apiUseTime.getTotalTime() + " ms");
apiUseTimeRepository.save(apiUseTime);
}
}
}
}
AOP 동작 원리:
스프링 AOP 어노테이션 :
- @Aspect : 스프링 빈 (Bean) 클래스에만 적용할 수 있습니다.
- 어드바이스
- 포인트 컷
5. 중복 폴더명 예외 처리
- 폴더 추가 기능에서 중복된 폴더명을 추가할 시 에러를 발생시킵니다.
- 기존의 동작에서는 "AS-IS" 방식이었습니다. AS-IS 방식에서 "TO-BE" 방식으로 변경시켜 보도록 하겠습니다.
AS-IS) 중복된 폴더명을 제외한 나머지 폴더명만이 저장됩니다.
TO-BE) Alert창을 띄우며 사용자가 중복된 폴더명을 수정할 수 있도록함. 또한 저장되지 않도록 방지함.
기존 방식의 경우 사용자에게 폴더명을 수정하게할 요구사항을 충족시키지 못합니다.
이 경우 해결 방법은 두 가지가 있습니다.
- 예외가 발생할 시 그 동안 DB에 저장된 폴더를 삭제시킵니다. (.delete(), .deleteAll() )
- @Transactional 어노테이션을 추가해 함수가 종료되기 전 예외가 발생할 시 DB에 반영하지 않습니다.
6. 트랜잭션
트랜잭션이란?
- 데이터베이스에서 데이터에 대한 하나의 논리적 실행단계
- 데이터베이스 작업에서 더 이상 쪼갤 수 없는 최소 단위작업
- 모두 저장되거나 어느 하나도 저장되지 않거나(롤백) 하는 성질을 보장합니다.
- ACID (원자성, 일관성, 고립성, 지속성)은 데이터베이스 트랜잭션이 안전하게 수행되기위한 것을 보장하기 위한 성질을 가리키는 약어입니다.
@Transactional 이란?
- 해당 어노테이션을 추가할 시 AOP에의해 프록시가 생성됩니다.
7. DB운영 방식
- 웹 서비스에서 DB 데이터는 자산입니다.
- DB 훼손 가능성 : DB도 결국 물리적 하드 디스크에 존재하기 때문에 해당 하드 디스크가 고장나거나 컴퓨터가 고장이 날 수 있다.
- 따라서 DB는 2대 이상으로 운영한다.
문제점 : DB1과 DB2의 데이터를 어떻게 동기화를 하는가?
- 만약 A 회원의 계좌 잔고는 100원이며 70만원을 인출한 가운데 DB1에만 잔고가 30만원이 되었다. DB2의 잔고가 동기화 되기 전 A가 50만원을 인출 시도한다면 어떻게 되는가?
- DB1은 잔고가 30만원이므로 인출 불가 에러가 발생한다.
- DB2는 동기화가 되기 전이므로 잔고가 100만원이라 판단되어 50만원이 정상 인출이 될 수도 있다.
해결책 : Primary / Replica 운영
- 쓰기전용 DB (Primary)와 읽기전용 DB (Replica)를 구분한다.
- 쓰기 전용 DB (Primary) : @Transactional의 readOnly = false(default) 설정을 한다. write (Create, Update, Delete)된 데이터는 Replica로 Sync된다. (Replication)
- 읽기 전용 DB (Replica) : @Transactional(resdOnly=true) 설정을 한다. Primary DB에 반영된 최신 데이터를 동기화 한다.
- 위 개념은 Primay DB endpoint와 Replica DB endpoint를 설정해 주어야 한다.
- Primary는 Master를 대체하였고 Replica는 Slave를 대체한 단어이다.
- Primary DB에 문제가 발생할 시 Replica DB 중 하나가 승격 시켜 다시 정상적으로 운영한다.
8. 스프링 예외 처리
HTTP 에러 메시지의 전달 방법
- 서버와 클라이언트는 HTTP 프로토콜을 기반으로한 요청과 응답을 주고 받습니다.
- HTTP 메시지에는 HTTP Request와 HTTP Response가 있으며 각각은 start line, headers, empty line, body로 구성되어 있습니다.
- 상태줄 : start line은 API 요청 결과를 한 줄로서 보여줍니다. (상태코드, 상태 텍스트)
- 상태줄 예 ) HTTP/1.1 404 Not Found
- 상태 코드 종류 : 2XX (Success), 4XX (Client Error), 5XX(Server Error) ... 이며 이는 이미 스프링 내부적으로 명시되어 있습니다. (org.springframework.http > HttpStatus)
- 헤더 > Content-Type > Response 본문 내용에 따라 : HTML인 경우 - text/html, JSON인 경우 - application/json
- 바디 : HTML, JSON,...
에러 발생 시 HTTP 에러 메시지 확인 :
- 500 Internal Server Error : 서버가 요청을 처리하는 과정에서 "예상하지 못한 상황"을 나타냅니다.
- 우리가 발생시킨 에러는 예측 가능한 에러이며 에러의 원인이 클라이언트에 있습니다. 따라서 HTTP 500 -> HTTP 400 으로 변환해 주어야 하며 응답의 본문 역시 에러 내용을 프론트엔드 개발자와 공유해야 합니다.
- 에러 메시지를 담을 포맷을 정의 하겠습니다.
{
"errorMessage" : "중복된 폴더명을 작성했습니다.",
"httpStatus" : "BAD_REQUEST"
}
- 구현 : 스프링은 ResponseEntity 클래스를 제공하고 있습니다.
- ResponseEntity는 HTTP response object를 위한 Wrapper로서 HTTP status code, HTTP headers, HTTP body를 선언할 수 있습니다.
- 기본적으로 스프링은 controller까지 올라와 throw 된 error를 받아 500 에러로 보내줍니다.
@PostMapping("/api/folders")
public ResponseEntity createFolders(
@RequestBody FolderRequestDto folderRequestDto,
@AuthenticationPrincipal UserDetailsImpl userDetails
) {
try {
List<String> folderNames = folderRequestDto.getFolderNames();
User user = userDetails.getUser();
List<Folder> folderList = folderService.createFolder(folderNames, user);
return new ResponseEntity(folderList, HttpStatus.OK);
} catch (IllegalArgumentException ex) {
RestApiException restApiException = new RestApiException();
restApiException.setHttpStatus(HttpStatus.BAD_REQUEST);
restApiException.setErrorMessage(ex.getMessage());
return new ResponseEntity(restApiException, HttpStatus.BAD_REQUEST);
}
}
- 하지만 위와 같이 에러 처리를 해줄 경우 클라이언트로부터 넘겨져 온 값을 일일히 검사하기에 어려움이 있습니다.
- AOP를 활용하여 중복되고 부가적인 동작을 처리할 수 있습니다.
- @ExceptionHandler를 사용해 어떤 컨트롤러의 모든 함수에 예외 처리를 해줄 수 있습니다.
@ExceptionHandler({IllegalArgumentException.class, NullPointerException.class})
public ResponseEntity handleException(Exception ex) {
RestApiException restApiException = new RestApiException();
restApiException.setHttpStatus(HttpStatus.BAD_REQUEST);
restApiException.setErrorMessage(ex.getMessage());
return new ResponseEntity(restApiException, HttpStatus.BAD_REQUEST);
}
- 위 코드는 컨트롤러마다 작성해야 합니다.
스프링 Global 예외 처리 :
- @ExceptionHandler는 모든 컨트롤러에 작성해야 합니다. 모든 컨트롤러에 예외 처리 코드를 추가해줄 필요 없이 Global 예외 처리를 해주겠습니다.
- @ControllerAdvice는 클라이언트와 컨트롤러 사이 AOP 프록시로서 컨트롤러 내부적으로 예외가 발생한 경우 처리해줍니다.
- 여러 개의 ExceptionHandler로 정의되어 있습니다.
- @RestControllerAdvice == @ControllerAdvice + @ResponseBody
@RestControllerAdvice
// Global Exception으로서 모든 컨트롤러에 적용됩니다.
// 클라이언트와 컨트롤러 사이 AOP 입니다.
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
);
}
}
에러코드 :
- 서비스 내부의 전체적으로 사용할 에러 코드를 선언합니다.
- 예외가 발생할 시 서버, 클라이언트에서 선언한 ErrorCode를 사용합니다.
- 에러 코드 샘플 :
public enum ErrorCode {
// 400 Bad Request
DUPLICATED_FOLDER_NAME(HttpStatus.BAD_REQUEST, "400_1", "중복폴더명이 이미 존재합니다."),
BELOW_MIN_MY_PRICE(HttpStatus.BAD_REQUEST, "400_2", "최저 희망가는 최소 " + MIN_MY_PRICE + " 원 이상으로 설정해 주세요."),
// 404 Not Found
NOT_FOUND_PRODUCT(HttpStatus.NOT_FOUND, "404_1", "해당 관심상품 아이디가 존재하지 않습니다."),
NOT_FOUND_FOLDER(HttpStatus.NOT_FOUND, "404_2", "해당 폴더 아이디가 존재하지 않습니다."),
;
private final HttpStatus httpStatus;
private final String errorCode;
private final String errorMessage;
ErrorCode(HttpStatus httpStatus, String errorCode, String errorMessage) {
this.httpStatus = httpStatus;
this.errorCode = errorCode;
this.errorMessage = errorMessage;
}
}
- httpStatus는 HTTP 상태 코드입니다.
- errorCode는 Unique한 에러 코드로서 국제화에 사용 가능합니다. (클라이언트가 사용하는 용어에 따라 다른 에러 메시지를 보여줍니다.)
- errorMessage는 클라이언트에게 보여줄 에러 메시지를 보여줍니다.
9. 마무리
- OOP : 객체지향 프로그래밍은 하나의 파일에서 수행 가능한 기능을 관심사별로 코드를 분리하여 코드의 이해와 유지보수를 높이는 작업입니다. 크게 컨트롤러, 서비스, 레포지토리로 나뉩니다. 필요에 따라 더 작은 단위로 더 분리할 수 있습니다. 이를 통해 핵심 기능을 모듈화 합니다.
- AOP : AOP는 부가기능을 모듈화 합니다. API 시간 측정, 트랜잭션, 예외처리, 로깅 등 OOP가 관리하기 어려운 관심사들을 구현합니다.
10. Outro
- 주니어 개발자와 시니어 개발자의 차이 : 없다 -> 구글신께 기도.
- 암기보단 -> 원리 파악
- 반복해서 작업하다 보면 어느 순간 이해가 될 것이며 시간이 지나 또 잊혀질 것
- 원리를 파악하고 나면 다음에 구글링을 해서 프로그램에 적용하면 될 것.