시큐리티 - Spring AOP
🤔 Top5 회원을 찾아보자 (With. Timstamped)
👉 API 사용시간 측정 방법
Scratch File Java 로 생성 하고 코드 붙여넣기
class Scratch {
public static void main(String[] args) {
// 측정 시작 시간
long startTime = System.currentTimeMillis();
// 함수 수행
// sumFromOneTo() 함수 : 1 에서 "입력된 숫자" 까지의 합계를 구하는 함수
long output = sumFromOneTo(1_000_000_000);
// 측정 종료 시간
long endTime = System.currentTimeMillis();
long runTime = endTime - startTime;
System.out.println("소요시간: " + runTime);
}
private static long sumFromOneTo(long input) {
long output = 0;
for (int i = 1; i < input; ++i) {
output = output + i;
}
return output;
}
}
👉 일반 회원
- 회원별 사용시간 누적 저장
- 일단, 관심상품 저장하는 API (POST /api/products) 에만 적용
👉 관리자
- '관리자' 만 조회 가능
- API 위에 @Secured(UserRoleEnum.Authority.ADMIN) 추가
- API 동작 확인
- 테스트 회원 추가
- 일반 회원 2명 이상 추가
- 관리자 회원 추가
- 관심상품 등록시마다 API 사용시간이 잘 저장 되는지 확인
- H2 Console 로 DB 확인
- 테스트 회원 추가
오잉 갑자기 html이 하나도 적용되지 않는다,.. ㅁㅓ선 123,,,,,
이렇든 저렇든 일단 API는 잘 된다.
그럼 조금 전 ProductController에서만 API를 구현 해놨는데 , 사이트를 이용하는 TOP 5를 구해야하므로 모든 Controller에다가 아래의 코드를 넣어줘야한다.
@PostMapping("/products")
public ProductResponseDto createProduct(@RequestBody ProductRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
// 측정 시작 시간
long startTime = System.currentTimeMillis();
try {
return productService.createProduct(requestDto, userDetails.getUser());
} finally {
// 측정 종료 시간
long endTime = System.currentTimeMillis();
// 수행시간 = 종료 시간 - 시작 시간
long runTime = endTime - startTime;
// 로그인 회원 정보
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);
}
log.info("[API Use Time] Username: " + loginUser.getUsername() + ", Total Time: " + apiUseTime.getTotalTime() + " ms");
apiUseTimeRepository.save(apiUseTime);
}
}
와 매우 번거롭고 엄두도 안 난다. (물론 몇 개 안되지만!)
그래서 부가기능 모듈화의 필요성을 느낀다.
사진에서 보다시피 핵심 기능마다 같은 부가 기능을 붙여줘야만 할 때, 만약 핵심 기능을 수정해야한다면??
😱 핵심 기능이 뭔지 부터 알아야겠지? 그럼 나는 또 부가 기능까지 알아야 하는 사태가 발생하겠지? 오케이. 알았어. 이해했어. 이제 수정해보자! 하고 핵심 기능 수정하는데 부가 기능도 수정을 해야하네?? 그럼 또 일일이 다른 핵심기능 여기저기로 가서 하나하나 다 수정해줘야겠지?? 와 상상만 해도 아찔해.
😱 부가 기능 수정 요청이 들어왔다면??? 이것도 아찔하네.. 언제 여기저기 가서 다 고치지???
🤔 이거 어떻게 해결 할 수 있을까??
해서 나온게 AOP이다.
✅ AOP (Aspect Oriented Programming) 를 통해 부가 기능을 모듈화
- '부가 기능' 은 '핵심 기능' 과는 관점(Aspect), 관심이 다름
- 따라서 '핵심 기능'과 분리해서 '부가 기능' 중심으로 설계, 구현 가능
✅ 스프링이 제공하는 AOP
위의 코드로 부가 기능을 분석하면 아래의 사진과 같다.
시퀀스 다이어그램 (Sequence Diagram)
AOP 적용 전
AOP 적용 후
DispatcherServlet 과 ProductController 입장에서는 변화가 전혀 없음
- 호출되는 함수의 input, output 이 완전 동일
- "joinPoint.proceed()" 에 의해서 원래 호출하려고 했던 함수, 인수(argument) 가 전달됨
→ createProduct(requestDto)
✅ 스프링 AOP 어노테이션
(이 부분은 실제 구현과 관련된 내용이고, 굳이 외우지 말고 필요한 어노테이션을 그때 그때 찾아서 본다고 생각하면 된다)
1. @Aspect
- 스프링 빈 (Bean) 클래스에만 적용 가능
- @Component 어노테이션이랑 짝꿍이라고 생각하면 된다.
2. 어드바이스 종류
🙂 aop로 분류해서 모아둔 코드가 실행되는 시점이라고 생각하시면 편하다!
정확한 비유는 아닐 수 있지만, 스케줄러를 만들 때 해당 소스의 실행 시점을 정해주는데 그것처럼 aop 코드가 실행되는 시점을 지정해준다고 이해해봐도 좋다.
- @Around : '핵심기능' 수행 전과 후 (@Before + @After)
- @Before : '핵심기능' 호출 전 (ex. Client 의 입력값 Validation 수행)
- @After : '핵심기능' 수행 성공/실패 여부와 상관없이 언제나 동작 (try, catch 의 finally() 처럼 동작)
- @AfterReturning : '핵심기능' 호출 성공 시 (함수의 Return 값 사용 가능)
- @AfterThrowing : '핵심기능' 호출 실패 시. 즉, 예외 (Exception) 가 발생한 경우만 동작 (ex. 예외가 발생했을 때 개발자에게 email 이나 SMS 보냄)
3. 포인트컷
🙂 어드바이스가 “실행될 시점” 을 정해줬다면, 포인트컷은 “실행될 장소” 를 정해준다고 생각하면 된다.
- 포인트컷 Expression Language
- 포인트컷 Expression 형태
- ? 는 생략 가능
- 포인트컷 Expression 형태
execution(modifiers-pattern? return-type-pattern declaring-type-pattern? method-name-pattern(param-pattern) throws-pattern?)
-
-
- 포인트컷 Expression 예제
-
@Around("execution(public * com.sparta.springcore.controller..*(..))")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable { ... }
🙂 com.sparta.springcore.controller..
프로젝트 패키지 주소이다. 해당 주소 하위에 있는 소스들, 즉 컨트롤러들이 실행되는 장소라고 생각해보면 비유가 얼추 맞을 것 같다.
-
-
- modifiers-pattern
- public, private, *
- return-type-pattern
- void, String, List<String>, *
- declaring-type-pattern
- 클래스명 (패키지명 필요)
- com.sparta.springcore.controller.* - controller 패키지의 모든 클래스에 적용
- com.sparta.springcore.controller.. - controller 패키지 및 하위 패키지의 모든 클래
- method-name-pattern(param-pattern)
- 함수명
- addFolders : addFolders() 함수에만 적용
- add* : add 로 시작하는 모든 함수에 적용
- 파라미터 패턴 (param-pattern)
- (com.sparta.springcore.dto.FolderRequestDto) - FolderRequestDto 인수 (arguments) 만 적용
- () - 인수 없음
- (*) - 인수 1개 (타입 상관없음)
- (..) - 인수 0~N개 (타입 상관없음)
- 함수명
- @Pointcut
- 포인트컷 재사용 가능
- 포인트컷 결합 (combine) 가능
- modifiers-pattern
-
@Component
@Aspect
public class Aspect {
@Pointcut("execution(* com.sparta.springcore.controller.*.*(..))")
private void forAllController() {}
@Pointcut("execution(String com.sparta.springcore.controller.*.*())")
private void forAllViewController() {}
@Around("forAllContorller() && !forAllViewController")
public void saveRestApiLog() {
...
}
@Around("forAllContorller()")
public void saveAllApiLog() {
...
}
}
시큐리티 - Exception
🤔 예외 처리를 따로 다루는 이유?
1) 개발자인만큼 웹 어플리게이션에서의 “예외”에 대하여 다시 한 번 인지할 필요가 있다.
웹 어플리케이션에서의 에러를 프론트엔드와 백엔드 모두가 잘 알지 못하면, 서비스하는 환경에서 발생하는 에러에 대해서 제대로 대응 할 수 없다.
2) aop를 배웠던 만큼, 에러를 처리하는 것 역시 관심사를 분리해서 더 효율적으로 처리 할 수 있지 않을까 고민해보는 시간이 필요하다.
🤔 웹 어플리케이션의 에러
👉 HTTP 에러 메시지 전달 방법 이해
사실 서버가 응답을 보낼 때 한가지 더 보내주는것이 있는데, 그게 바로 start-line에 있는 응답 코드이다. 즉 응답 헤더에는 상태 코드가 있고, 세자리로 되어있는 해당 번호에는 각각의 의미가 있다.
✅ Response Message
1. 상태줄: API 요청 결과 (상태 코드, 상태 텍스트)
HTTP/1.1 404 Not Found
티켓팅 할 때 마다 보는 404 혹은 503,,,, 지긋지긋하다,,,,!
👉HTTP 상태 코드 종류
- 2xx Success → 200번대의 상태코드는 성공을 의미
- 4xx Client Error → 400번대의 상태코드는 클라이언트 에러, 즉 잘못된 요청을 의미
- 5xx Server Error → 500번대의 상태코드는 서버 에러, 즉 정확한 요청에 서버쪽 사유로 에러가 난 상황을 의미
👉 org.springframework.http > HttpStatus
public enum HttpStatus {
// 1xx Informational
CONTINUE(100, Series.INFORMATIONAL, "Continue"),
// ...
// 2xx Success
OK(200, Series.SUCCESSFUL, "OK"),
CREATED(201, Series.SUCCESSFUL, "Created"),
// ...
// 3xx Redirection
MULTIPLE_CHOICES(300, Series.REDIRECTION, "Multiple Choices"),
MOVED_PERMANENTLY(301, Series.REDIRECTION, "Moved Permanently"),
FOUND(302, Series.REDIRECTION, "Found"),
// ...
// --- 4xx Client Error ---
BAD_REQUEST(400, Series.CLIENT_ERROR, "Bad Request"),
UNAUTHORIZED(401, Series.CLIENT_ERROR, "Unauthorized"),
PAYMENT_REQUIRED(402, Series.CLIENT_ERROR, "Payment Required"),
FORBIDDEN(403, Series.CLIENT_ERROR, "Forbidden"),
// ...
// --- 5xx Server Error ---
INTERNAL_SERVER_ERROR(500, Series.SERVER_ERROR, "Internal Server Error"),
NOT_IMPLEMENTED(501, Series.SERVER_ERROR, "Not Implemented"),
BAD_GATEWAY(502, Series.SERVER_ERROR, "Bad Gateway"),
// ...
2. 헤더
👉 "Content type"
- 없음
- Response 본문 내용이 HTML 인 경우
Content type: text/html
- Response 본문 내용이 JSON 인 경우
Content type: application/json
3. 본문
👉 HTML
<!DOCTYPE html>
<html>
<head><title>By @ResponseBody</title></head>
<body>Hello, Spring 정적 웹 페이지!!</body>
</html>
👉 JSON
{
"name":"홍길동",
"age": 20
}
이렇게 다양한 상태 코드들이 있고, 백엔드 개발자가 제대로 예외 처리를 하지 않게 되면 500 에러가 떠서 프론트에서 헛고생을 할 수도 있다. 그러니까 잘 알아두고 꼭 예외 처리를 제대로 해서 다른 사람이 헛고생하지 않도록 하자!!
🤔 스프링의 예외처리
🚨 이제부터는 응답도 조금 더 신경써서 return 해주기 위해서, 스프링이 제공하는 ResponseEntity 클래스를
사용해 볼 것이다.
- Controller 코드 수정
- ResponseEntity는 HTTP response object 를 위한 Wrapper이다. 아래와 같은 것들을 담아서 response로 내려주면 아주 간편하게 처리가 가능하다.
- HTTP status code
- HTTP headers
- HTTP body
- ResponseEntity는 HTTP response object 를 위한 Wrapper이다. 아래와 같은 것들을 담아서 response로 내려주면 아주 간편하게 처리가 가능하다.
- @ExceptionHandler 사용
- FolderController 의 모든 함수에 예외처리 적용 (AOP)
(이것 또한 매 컨트롤러마다 추가해주는건 너무 번거로운 일 ….🥲)
- FolderController 의 모든 함수에 예외처리 적용 (AOP)
- 예외처리 적용 결과 확인
- HTTP 응답 (상태코드) HTTP 400 Bad Request
- HTTP 응답 본문 (Body)
🤔 스프링 Global 예외 처리 방법
이 앞 번에 단위 테스트를 하면서 코드를 이렇게 짰었다.
형광펜 칠한 것 처럼 해주는 것이 보통이다. (앞으로 주구장창 쓸 것,,,,,🥹)
- @ControllerAdvice 사용
- @RestControllerAdvice 사용
- @ControllerAdvice + @ResponseBody
시큐리티 - Transaction
👉 뭣도 모르고 썼던 트랜잭션
@Transactional <<< ???
public List<Folder> addFolders(List<String> folderNames, String name) {
User user = userRepository.findByUsername(name).orElseThrow(
() -> new IllegalArgumentException("사용자가 존재하지 않습니다.")
);
// 입력으로 들어온 폴더 이름을 기준으로, 회원이 이미 생성한 폴더들을 조회합니다.
List<Folder> existFolderList = folderRepository.findAllByUserAndNameIn(user, folderNames);
List<Folder> folderList = new ArrayList<>();
for (String folderName : folderNames) {
// 이미 생성한 폴더가 아닌 경우만 폴더 생성
if (isExistFolderName(folderName, existFolderList).equals("false")) {
Folder folder = new Folder(folderName, user);
folderList.add(folder);
} else {
throw new IllegalArgumentException("중복된 폴더명 ('" + isExistFolderName(folderName, existFolderList) + "')을 삭제하고 재시도해 주세요");
}
}
return folderRepository.saveAll(folderList);
}
👉 로직
- 폴더들과 이름을 인자로 넘겨받는다.
- 입력으로 온 폴더 이름을 기준으로 회원이 이미 생성한 폴더를 조회한다.
- 입력으로 온 폴더를 조회한 폴더들과 비교한다.
- 있는 폴더가 아니면 폴더를 생성한다.
- 이미 생성된 폴더를 만들지 않고 에러를 리턴합니다.
- 그런데 만약 5개의 폴더 생성 요청을 받아 처리하던 중 세 번째 폴더가 중복이면?
- 요청이 실패했으니, 해당 메서드의 실행 전과 데이터가 같아야 하긴 하는데… 이미 메서드가 있다.
- 다시 지워줘야 할까?(벌써 비효율적인 냄시가..)
- 이러한 것들을 위해서 트랜잭션을 활용한다!
✅트랜잭션이란?
데이터베이스에서 데이터에 대한 하나의 논리적 실행단계
ACID (원자성, 일관성, 고립성, 지속성)는 데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질을 가리키는 약어
특징 :
1. 더 이상 쪼갤 수 없는 최소 단위의 작업
2. 하나의 최소 단위의 작업에 여러가지 데이터 변경을 넣으면 모두 저장되거나, 아무 것도 저장되지 않거나 둘 중 하나이다.
- 비즈니스 로직에 트랜잭션 코드가 포함됨
- @Transactional 사용 시 폴더 생성 Flowchart
🤔 데이터베이스를 더 안전하게 관리하려면? (Primary, Replica)
✅ DB 운영방식
- 웹 서비스에서 DB 에 담겨있는 Data 는 가장 중요한 리소스 입니다.
- 회원 정보
- 서비스 이용 정보
- DB 훼손 가능성
- DB 도 결국 물리적인 HDD (하드 디스크) 에 존재
- DB 가 저장된 하드디스크 고장
- DB 가 저장된 컴퓨터 고장
- 일반적으로는 DB 를 2대 이상 운영
- 문제점: DB1 과 DB2 를 어떻게 데이터 Sync 를 할까요??
- 예제를 통한 이해
- 회원 A 계좌 잔고: 100만원
- DB1: 100만원
- DB2: 100만원
- 70만원 인출 시도
- DB1 에서 70만원 인출하여 잔고 30만원
- DB2 에서도 동일하게 잔고 30만원으로 데이터 Sync가 필요합니다.
- 50만원 인출 시도
- DB1 를 통해 잔고 확인 시
- 회원 A 의 "잔고 30만원" 이기 때문에, 인출불가 에러 발생
- DB2 를 통해 잔고 확인 시
- DB2 에 "첫번째 인출 시도한 데이터(70만원)" 가 Sync 적용되기 전이라고 가정
- 혹은 DB2 에 데이터 Sync 중 에러가 발생했다고 가정
- DB2 에서는 회원 A의 "잔고 100만원" 이 남아 있다고 판단
- 50만원이 정상 인출됨
- DB2 에 "첫번째 인출 시도한 데이터(70만원)" 가 Sync 적용되기 전이라고 가정
- DB1 를 통해 잔고 확인 시
- DB1 과 DB2 의 데이터 불일치 ⇒ 어느 정보를 믿어야하지?
- DB1 에서 회원 A 의 잔고: 30만원
- DB2 에서 회원 B 의 잔고: 50만원
- 70만원 인출 시도
- 회원 A 계좌 잔고: 100만원
🤔 보통은 읽기 전용 DB와 쓰기 전용 DB를 분리하고, 쓰기 전용 DB를 카피(사실 데이터 변경을 추적)하는 읽기 전용 DB들을 다수 두는 방식으로 해결한다. 이렇게하면 부하도 분산되고 안전하게 운영이 가능하다.
✅ Primary / Replica 운영방식
쓰기 전용 DB (Primary) 와 읽기 전용 DB (Replica) 를 구분
👉 Primary: 쓰기 전용
- @Transactional 의 readOnly 속성
@Transactional(readOnly = false)
- readOnly 를 코드에 적지 않으면, 기본값은 false
import org.springframework.transaction.annotation.Transactional;
@Transactional
public List<Folder> createFolders(List<String> folderNameList, User user) {
- Write 된 Data (Create, Update, Delete) 가 Replica 로 Sync 됨 (Replication)
👉 Replica (Secondary): 읽기 전용
하지만 이 개념은 스프링에 Primary DB endpoint, Replica DB endpoint 를 설정해야지만 가능하다.
@Transactional(readOnly = true)
👉 Primary 에 문제가 생겼을 때 Replica 중 1개가 Primary 가 됨