웹 개발/스프링

[스프링 부트 심화] AOP, Transaction

개발자명백 2023. 4. 27. 01:23

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

- 주니어 개발자와 시니어 개발자의 차이 : 없다 -> 구글신께 기도.

- 암기보단 -> 원리 파악 

- 반복해서 작업하다 보면 어느 순간 이해가 될 것이며 시간이 지나 또 잊혀질 것

- 원리를 파악하고 나면 다음에 구글링을 해서 프로그램에 적용하면 될 것.