[스파르타코딩클럽] Spring 심화반 - 5주차

hyeseungS·2022년 2월 23일
0
post-thumbnail

[수업 목표]

  1. AOP 개념을 이해한다.
  2. '나만의 셀렉샵'에 스프링 AOP를 적용한다.
    • Top5 회원 찾기
    • 중복 폴더명 저장 시 에러 처리
    • 스프링 예외 처리방법 이해
  3. DB 트랙잭션을 이해한다.

01. Top5 회원 찾기 요구사항

1) 요구사항

  1. '고객 감사 이벤트'
    • 2주간 '나만의 셀렉샵'을 가장 많이 사용하는 Top5 회원들에게 선물 증정
    • 회원별 '나만의 셀렉샵' 사용시간 저장
  2. 회원별 '나만의 셀렉샵' 사용시간 조회 가능
    • 일반 회원은 조회 불가능
    • '관리자'만 가능

Top5 회원을 어떤 기준으로 선정?

  • '나만의 셀렉샵' 서버 사용시간으로 하자
    • 서버 사용시간 : '나만의 셀렉샵' 모든 API 수행시간의 총합
      1. 상품 조회 API ("GET /api/search") 수행시간
      2. 관심상품 등록 API ("POST /api/products") 수행시간
      3. 폴더 저장 API ("POST /api/folders") 수행시간
      4. ...
        을 다 합치자!

2) API 사용시간 측정 방법

  • API 사용 시간
    = Controller 에 요청이 들어온 시간 ~ 응답이 나간 시간

    • Ex)

      • Controller에 요청이 들어온 시간 : 9시 10분 30초
      • Controller에 응답이 나간 시간 : 9시 10분 33초
      • API 사용시간?
        : Controller에 응답이 나간 시간 - Controller에 요청이 들어온 시간 = 3초
  • 수행시간 측정 코드 구현

  1. Intellij 메뉴에서 File > New > Scratch File

  2. 수행시간 측정 코드 구현

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);
    }

    private static long sumFromOneTo(long input) {
        long output = 0;

        for (int i = 1; i < input; ++i) {
            output = output + i;
        }

        return output;
    }
}
  • sumFromOneTo() 함수(1에서 "입력된 숫자"까지의 합계를 구하는 함수)의 수행 시간

02. Top5 회원 찾기 설계 및 구현

1) 회원별 총 API 사용시간 저장

  1. API 사용시간을 저장할 테이블 설계

ApiUseTime 테이블

컬럼명컬럼타입중복허용설명
idLongX테이블 ID (PK)
user_idLongX회원 ID (FK)
totalTimeLongOAPI 총 사용시간
- user_id : User 테이블의 id
  (하나의 회원은 하나의 row만 가질 수 있음, 따라서 @OneToOne)
  1. 회원별 사용시간 누적 저장
  • 일단, 관심상품 저장하는 API (POST /api/products)에만 적용해보자

  • model > ApiUseTime

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;

@Setter
@Getter // get 함수를 일괄적으로 만들어줍니다.
@NoArgsConstructor // 기본 생성자를 만들어줍니다.
@Entity // DB 테이블 역할을 합니다.
public class ApiUseTime {
    // ID가 자동으로 생성 및 증가합니다.
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Id
    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;
    }
}
  • repository > ApiUseTimeRepository
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface ApiUseTimeRepository extends JpaRepository<ApiUseTime, Long> {
    Optional<ApiUseTime> findByUser(User user);
}
- model 하나 만들면 repository 만들기
  • controller > ProductController
    : 01에서 sumFromOneTo 함수의 수행시간 측정 코드 이용
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@RestController // JSON으로 데이터를 주고받음을 선언합니다.
public class ProductController {

    private final ProductService productService;
    private final ApiUseTimeRepository apiUseTimeRepository;

    @Autowired
    public ProductController(
            ProductService productService,
            ApiUseTimeRepository apiUseTimeRepository
    ) {
        this.productService = productService;
        this.apiUseTimeRepository = apiUseTimeRepository;
    }

    // 신규 상품 등록
    @PostMapping("/api/products")
    public Product createProduct(@RequestBody ProductRequestDto requestDto,
                                 @AuthenticationPrincipal UserDetailsImpl userDetails) {
        // 측정 시작 시간
        long startTime = System.currentTimeMillis();

        try {
            // 로그인 되어 있는 회원 테이블의 ID
            Long userId = userDetails.getUser().getId();

            Product product = productService.createProduct(requestDto, userId);

            // 응답 보내기
            return product;
        } 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);
            }

            System.out.println("[API Use Time] Username: " + loginUser.getUsername() + ", Total Time: " + apiUseTime.getTotalTime() + " ms");
            apiUseTimeRepository.save(apiUseTime);
        }
    }

    ...
}
- try-catch 문 사용, (finally는 예외 있던 없던 항상 수행)
- 시간 누적을 어떻게 할까?
  : 이미 이 User에 대해서 ApiUseTime에 존재하면 그냥 더해주기
  (Optional -> .orElse(null) -> Optional에 대한 처리(nullPointerException 방지)

2) 회원별 API 총 사용시간 조회 (관리자용)

회원별 API 총 사용시간 조회 API

NameMethodURL설명
API 총 사용시간 조회GET/api/use/time회원별 API 총 사용시간 조회
(관리자용)
  • '관리자'만 조회 가능
    • API 위에 @Secured("ROLE_ADMIN") 추가
    • controller > ApiUseTimeController
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.annotation.Secured;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
public class ApiUseTimeController {
    private ApiUseTimeRepository apiUseTimeRepository;

    @Autowired
    public ApiUseTimeController(ApiUseTimeRepository apiUseTimeRepository) {
        this.apiUseTimeRepository = apiUseTimeRepository;
    }

    @Secured(UserRoleEnum.Authority.ADMIN)
    @GetMapping("/api/use/time")
    public List<ApiUseTime> getAllApiUseTime() {
        return apiUseTimeRepository.findAll();
    }
}
  • API 동작 확인
  1. 테스트 회원 추가
    • 일반 회원 2명 이상 추가
    • 관리자 회원 추가
    • testdata > TestDataRunner
...
@Override
public void run(ApplicationArguments args) throws Exception {
	// 테스트 User 생성
    User testUser1 = new User("정국", passwordEncoder.encode("123"), "jg@sparta.com", UserRoleEnum.USER);
    User testUser2 = new User("제이홉", passwordEncoder.encode("123"), "hope@sparta.com", UserRoleEnum.USER);
    User testAdminUser1 = new User("아미", passwordEncoder.encode("123"), "army@sparta.com", UserRoleEnum.ADMIN);
    testUser1 = userRepository.save(testUser1);
    testUser2 = userRepository.save(testUser2);
    testAdminUser1 = userRepository.save(testAdminUser1);

    // 테스트 User 의 관심상품 등록
    // 검색어 당 관심상품 10개 등록
    createTestData(testUser1, "신발");
    createTestData(testUser1, "과자");
    createTestData(testUser1, "키보드");
    createTestData(testUser1, "휴지");
    createTestData(testUser1, "휴대폰");
    createTestData(testUser1, "앨범");
    createTestData(testUser1, "헤드폰");
    createTestData(testUser1, "이어폰");
    createTestData(testUser1, "노트북");
    createTestData(testUser1, "무선 이어폰");
    createTestData(testUser1, "모니터");
}
...
  1. 관심상품 등록시마다 API 사용시간이 잘 저장되는지 확인

    • H2 Console로 DB 확인
  2. 회원별 총 사용시간 조회 API 동작 검증

    • 일반회원 : 조회 불가
      • API 요청 시 "사용자 접근 불가 페이지" (HTTP 403) 뜨는지 확인
    • 관리자 : 조회 가능

03. AOP 란?

1) 부가기능 모듈화의 필요성

'Top5 회원 찾기' 기능이 추가된 것을 '나만의 셀렉샵' 회원들이 알 수 있을까? 혹은 알 필요가 있을까?

  • '핵심기능' : 각 API 별 수행해야 할 비즈니스 로직
    ex) 상품 키워드 검색, 관심상품 등록, 회원 가입, 관심상품에 폴더 추가, ...
  • '부가기능' : 핵심기능을 보조하는 기능
    ex) 회원 패턴 분석을 위한 로그 기록, API 수행시간 저장
    - 02에서는 부가기능 사이에 핵심기능을 넣어서 했었음
  • 문제점
    • 모든 '핵심기능'의 Controller에 '부가기능' 코드를 추가했을 때..

      • '핵심기능'이 100면?

      • '핵심기능'이 나중에 추가되면?

        • 항상 '부가기능' 추가 신경써야함
        • '부가기능' 추가를 깜박하면 신뢰성 이슈
    • '핵심기능' 수정 시

      • 같은 함수 내에 '핵심기능'과 '부가기능'이 섞여있음
      • '핵심기능' 이해를 위해 '부가기능'까지 이해 필요
    • '부가기능' 수정 시

      • '핵심기능' 개수만큼 '부가기능' 수정
    • '부가기능' 삭제

- 따라서 AOP 등장!!

2) 부가기능을 모듈화

  • AOP (Aspect Oriented Programming) 를 통해 부가기능을 모듈화
    • '부가기능'은 '핵심기능'과는 관점 (Aspect), 관심이 다름
    • 따라서 '핵심기능'과 분리해서 '부가기능' 중심으로 설계, 구현 가능

3) 스프링이 제공하는 AOP

: 스프링이 부가기능과 핵심기능을 붙여주는 것처럼 해준다
1. 어드바이스 : 부가기능
2. 포인트컷 : 부가기능 적용 위치

- 부가기능 적용위치에만 넣어달라!
- ex) Controller에만 넣어줘, Service & Repository 말고

04. 스프링 AOP 적용

스프링 AOP 적용

  • ProductController(01에서)에 추가했던 부가기능 제거(Rollback)
    - controller > ProductController
  • AOP 사용해 모든 Controller에 부가기능 추가
    - aop > UseTimeAop
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

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

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

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

        try {
            // 핵심기능 수행
            Object output = joinPoint.proceed(); // 결과가 ouput으로 들어옴
            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);
            }
        }
    }
}

05. 스프링 AOP 이해

1) 스프링 AOP 동작 이해

  • 개념적 이해
  • 스프링 실제 동작
- 핵심 기능1 바로 호출하지 않고 프록시라는 객체가 생성되어
  이것이 핵심기능1을 호출 (프록시가 부가기능 역할)
  • 시퀀스 다이어그램 (Sequence Diagram)
    • AOP 적용 전
    • AOP 적용 후
      • DispatcherServlet과 ProductController 입장에서는 변화가 전혀 없음
        • 호출되는 함수의 input, output이 완전 동일
        • "joinPoint.proceed()" 에 의해서 원래 호출하려고 했던 함수, 인수(argument) 가 전달됨
          -> createProduct(requestDto)
  • 스프링 서버가 기동될 때
    • 핵심 기능 DI 시
    • 프록시 객체를 중간에 삽입
      (스프링이 제공해주는 빈들만 사용 가능)

2) 스프링 AOP 어노테이션

  1. @Aspect
  • 스프링 빈 (Bean) 클래스에만 적용 가능
  1. 어드바이스 종류
  • @Around : '핵심기능' 수행 전과 후 (@Before + @After)
  • @Before : '핵심기능' 호출 전 (ex. Client의 입력값 Validation 수행)
  • @After : '핵심기능' 수행 성공/실패 여부와 상관 없이 언제나 동작 (try, catch의 finally() 처럼 동작)
  • @AfterReturning : '핵심기능' 호출 성공 시 (함수의 Return 값 사용 가능)
  • @AfterThrowing : '핵심기능' 호출 실패 시. 즉, 예외 (Exception) 가 발생한 경우만 동작 (ex. 예외가 발생했을 때 개발자에게 email 이나 SMS 보냄)
  1. 포인트컷

    • 포인트컷 Expression Language
      • 포인트컷 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 { ... }
    • modifiers-pattern

      • public, private, *
    • return-type-pattern

      • void, String, List, *(return type 상관없이)
    • 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

      • 포인트컷 재사용 가능(execution을 하나의 함수로 만들어줌)
      • 포인트컷 결합 (combine) 가능
  @Component // 빈 등록해야 @Aspect 적용 가능 (AOP로 사용 가능)
  @Aspect
  public class Aspect {
  	@Pointcut("execution(* com.sparta.springcore.controller.*.*(..))")
  	private void forAllController() {}

  	// View를 return 하는 것은 String이라고 가정(위에랑 String 만 다름)
  	@Pointcut("execution(String com.sparta.springcore.controller.*.*())")
  	private void forAllViewController() {}

  	@Around("forAllContorller() && !forAllViewController")
  	public void saveRestApiLog() {
  		...

  	}
  	@Around("forAllContorller()")
  	public void saveAllApiLog() {
  		...
  	}	
}
  • Controller - Service - Repository 3계층에 맞춰 구현을 해야 하는 다른 이유
    • Controller에 비즈니스 로직을 추가한다면?
    • Controller에서 Repository를 바로 호출한다면?
      -> 문제가 될 수 있음!

06. 중복 폴더명 예외처리 설계 및 구현

1) 요구사항

폴더들을 저장 시, "중복 폴더명 저장 시" 에러를 발생시킴

요구사항에서 사용되는 용어 중 AS-IS, TO-BE 가 있음.
AS-IS: 기존 동작
TO-BE: 변경 동작

AS-IS)

  • 중복을 제외한 모든 폴더명이 저장됨

TO-BE)

  • Alert 팝업을 띄워서 사용자가 폴더명을 수정할 수 있도록 유도!
    • 중복된 폴더명 표시
  • 저장 시도한 폴더 모두 저장되지 않음

2) 현재 동작 확인 (with 디버깅)

  1. 테스트 Folder 추가
  • testdata > TestDataRunner
    : createTestData() 함수에서 관심상품 등록 코드 아래 추가
Folder folder = new Folder(searchWord, user);
folderRepository.save(folder);
  1. service > FolderService 클래스에서 addFolders() 함수
    : Exception 발생 코드 추가 시 문제 발생!
// 로그인한 회원에 폴더들 등록
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;
}
문제점
  - ex) 중복되지 않는 폴더 2개와 중복된 폴더명을 추가하면?
        3개 다 저장 안되어야 하는데
  		중복되지 않는 폴더 2개가 DB에 저장됨

3) 해결방법 (1)

  • 예외 발생 시, 그동안 DB에 저장된 폴더들을 삭제
  • service > FolderService 클래스에서 addFolders() 함수
    : Exception 발생 위에 추가
folderRepository.deleteAll(savedFolderList);

4) 해결방법 (2)

  • 트랜잭션 (@Transactional) 이용
  • service > FolderService 클래스에서 addFolders() 함수
    : 함수 위에 @Transactional 추가

07. 트랜잭션의 이해

1) 트랜잭션이란?

트랜잭션 : 데이터베이스에서 데이터에 대한 하나의 논리적 실행단계
(DB 입장에서 더이상 쪼갤 수 없는 최소 단위의 작업)

ACID (원자성, 일관성, 고립성, 지속성)는 데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질을 가리키는 약어

출처 : 위키백과

  • 트랜잭션의 특징
    • 더 이상 쪼갤 수 없는 최소 단위의 작업
    • 모두 저장되거나, 아무 것도 저장되지 않거나를 보장!
    • 모! 아니면 도!

2) 데이터 1개 저장 시

  1. 회원 등록 요청
User user = new User(username, password, email, role);
user = userRepository.save(user);
  1. 만약, DB 저장 시 에러로 다음과 같이 저장되면?
  • 저장 요청 DATA
    • USERNAME: "삼식이", PASSWORD: "$2a^A...", EMAIL: "sameat@sparta.com", ROLE: "USER"
  • 실제 DB에 저장된 DATA
문제점 : PASSWORD 저장 안됨, ROLE이 ADMIN으로 저장
-> DB가 아예 저장 안되도록 함. 이것을 지켜주기 위한 것이 ACID다!!
  1. 회원 저장 요청이 1개의 트랜잭션
  • DB는 1개의 회원 정보가 안전하게 저장됨을 보장

3) 데이터 2개 이상 저장 시

A 계좌 → B계좌로 200,000 원 이체 시

  • A 계좌 잔고: 1,000,000 원
  • B 계좌 잔고: 1,000,000 원

정상 케이스

  1. A 계좌 잔고 200,000 원 이상 확인
    • A 계좌 잔고: 1,000,000 원
  2. A 계좌 잔고 200,000 원 금액 감소
    • A 계좌 잔고: 800,000 원 (1,000,000 원 - 200,000 원)
  3. B 계좌 잔고 200,000 원 금액 증가
    • B 계좌 잔고: 1,200,000 원 (1,000,000 원 + 200,000 원)

만약, 3번 과정에서 에러가 발생 시

  • A 계좌 잔고: 800,000 원 ??
  • B 계좌 잔고: 1,000,000 원 ??
-> 이를 해결하기 위해 트랜잭션이라는 개념 추가

2) 데이터 2개 이상 저장 시 (with 트랜잭션)

트랜잭션 시작

  1. A 계좌 잔고 200,000 원 이상 확인
    • A 계좌 잔고: 1,000,000 원
  2. A 계좌 잔고 200,000 원 금액 감소
    • A 계좌 잔고: 800,000 원 (1,000,000 원 - 200,000 원)
  3. B 계좌 잔고 200,000 원 금액 증가
    • B 계좌 잔고: 1,200,000 원 (1,000,000 원 + 200,000 원)

모두 성공 시 ⇒트랜잭션 Commit

중간에 하나라도 실패 시 ⇒ 트랜잭션 Rollback
(트랜잭션 시작 전으로 돌아감)


08. @Transactional의 정체

1) 트랜잭션을 사용한 폴더 생성 코드

public List<Folder> addFolders(List<String> folderNames, User user) {
	// 트랜잭션의 시작
  	// transactionManager 만들어서 이를 가지고 Transaction 가저와서 트랜잭션 시작됐다라는 것을 DB에게 알려줌
    TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition());
	try {
		// 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);
	        }
	    }

		// 트랜잭션 commit (정상)
        transactionManager.commit(status);

	    return savedFolderList;
	} catch (Exception ex) {
    	// 트랜잭션 rollback (Exception 발생)
    	transactionManager.rollback(status);
        throw ex;
    }
}

2) 트랜잭션을 사용한 폴더 생성 Flowchart

  • 비즈니스 로직에 트랜잭션 코드가 포함됨
- 트랜잭션 시작을 DB에게 알려줌.
- DB가 트랜잭션 아이디를 발급해줌
- 그 아이디로 commit 또는 rollback 해준다.

3) @Transactional 사용 시 폴더 생성 Flowchart

@Transactional
public List<Folder> addFolders(List<String> folderNames, User user) {
  // ...
}

-> @Transactional 하면 AOP(핵심 기능, 부가 기능 나눈 것과 비슷)가 동작하는 것. 
  (AOP에 의해서 Transaction 부가기능이 나타남 -> 프록시가 생긴 것)

4) @Transactional 관련 리팩토링

다음과 같이 코드를 리팩토링하면 문제가 생김.
어떤 문제일까?

  • 리팩토링 코드

    • repository > FolderRepository

      public interface FolderRepository extends JpaRepository<Folder, Long> {
        List<Folder> findAllByUser(User user);
      	// User 명과 Folder 이름을 가지고 존재하면 true
        boolean existsByUserAndName(User user, String name);
      }
    • service > FolderService

      // 로그인한 회원에 폴더들 등록
       public List<Folder> addFolders(List<String> folderNames, User user) {
            List<Folder> savedFolderList = new ArrayList<>();
            for (String folderName : folderNames) {
                Folder folder = createFolderOrThrow(folderName, user);
                savedFolderList.add(folder);
            }
      
            return savedFolderList;
        }
      
        @Transactional
        public Folder createFolderOrThrow(String folderName, User user) {
            // 입력으로 들어온 폴더 이름이 이미 존재하는 경우, Exception 발생
            boolean isExistFolder = folderRepository.existsByUserAndName(user, folderName);
            if (isExistFolder) {
                throw new IllegalArgumentException("중복된 폴더명을 제거해 주세요! 폴더명: " + folderName);
            }
      
            // 폴더명 저장
            Folder folder = new Folder(folderName, user);
            return folderRepository.save(folder);
        }
-> DB에 이미 저장된 폴더명들이 Rollback 되지 않음
  - 이유 : 하나의 folder 생성마다 하나의 트랜잭션으로 처리되어서
  • 수정
// 로그인한 회원에 폴더들 등록
@Transactional
public List<Folder> addFolders(List<String> folderNames, User user) {
	List<Folder> savedFolderList = new ArrayList<>();
    for (String folderName : folderNames) {
		Folder folder = createFolderOrThrow(folderName, user);
        savedFolderList.add(folder);
    }

    return savedFolderList;
}

public Folder createFolderOrThrow(String folderName, User user) {
	// 입력으로 들어온 폴더 이름이 이미 존재하는 경우, Exception 발생
	boolean isExistFolder = folderRepository.existsByUserAndName(user, folderName);
	if (isExistFolder) {
		throw new IllegalArgumentException("중복된 폴더명을 제거해 주세요! 폴더명: " + folderName);
	}
  	
  	// 폴더명 저장
    Folder folder = new Folder(folderName, user);
    return folderRepository.save(folder);
}

09. 현업에서 DB 운영 방식 (Primary, Replica)

1) 현업에서 DB 운영 방식

  • DB 훼손 가능성 때문에 현업에서는 DB를 2대 이상 운영
    • 문제점 : DB1과 DB2를 어떻게 데이터 Sync를 하지?
	Ex)
        - 회원 A 계좌 잔고: 100만원
        - DB1: 100만원
        - DB2: 100만원

      1. 70만원 인출 시도
          1. DB1 에서 70만원 인출하여 잔고 30만원
          2. DB2 에서도 동일하게 잔고 30만원으로 데이터 Sync 필요!

      2. 50만원 인출 시도
          1. DB1 를 통해 잔고 확인 시
              - 회원 A 의 "잔고 30만원"이기 때문에, 인출불가 에러 발생
          2. DB2 를 통해 잔고 확인 시 
              - DB2 에 "첫번째 인출 시도한 데이터(70만원)"가 Sync 적용되기 전이라고 가정
                  - 혹은 DB2 에 데이터 Sync 중 에러가 발생했다고 가정
              - DB2 에서는 회원 A의 "잔고 100만원" 이 남아 있다고 판단
              - 50만원이 정상 인출됨

      3. DB1 과 DB2 의 데이터 불일치 ⇒ 어느 정보를 믿어야하지?
          1. DB1 에서 회원 A 의 잔고: 30만원
          2. DB2 에서 회원 B 의 잔고: 50만원

2) Primary / Replica 운영 방식

  • 쓰기 전용 DB (Primary) 와 읽기 전용 DB (Replica)를 구분
    • Primary : 쓰기 전용
      • @Transactional 의 readOnly 속성
      @Transactional(readOnly = false)
      • readOnly를 코드에 적지 않으면, 기본값은 false
    • Replica (Secondary) : 읽기 전용
      @Transactional(readOnly = true)
      • DB Primary를 통해 인출하니까 항상 최신상태가 반영되어있다고 가정
    • 하지만, 위 개념은 스프링에 Primary DB endpoint, Replica DB endpoint를 설정해야지만 가능!

수십년 동안 통용되던 용어 대체됨
마스터 (Master) -> Primary
슬레이브 (Slave) -> Replica, Secondary

출처 : 위키백과

3) Primary 에 문제가 생겼을 때

  • Replica 중 1개가 Primary가 됨
  • 다시 Primary - Replica 로 정상 운영

10. 스프링 예외 처리 방법

1) 스프링 기본 에러 처리

HTTP 에러 메시지 전달 방법 이해

참고 : HTTP 메시지 설명 (MDN Web Docs)

Response 메시지

  1. 상태줄 : API 요청 결과 (상태 코드, 상태 텍스트)
HTTP/1.1 404 Not Found

HTTP 상태 코드 종류
<1> 2xx Success
<2> 4xx Client Error
<3> 5xx Server Error

  • org.springframework.http > HttpStatus
    : 스프링에서 http status 정리해놓은 파일
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"),
	// ...
}
  1. 헤더
  • "Content type"
    <1> 없음
    <2> Response 본문 내용이 HTML인 경우
    <3> Response 본문 내용이 JSON인 경우
  1. 본문
    <1> HTML
    <2> JSON

현재 에러 발생 시 HTTP 에러 메시지 확인

: 어떻게 throw로 클라이언트에게 Http 코드로 어떻게 내려가는지 확인
  1. DB에 중복된 폴더명 존재 시 Exception 발생
  • service > FolderService
throw new IllegalArgumentException("중복된 폴더명을 제거해 주세요! 폴더명: " + folderName);
  1. 클라이언트에서 에러 메시지 확인
  • 상태줄
  • 헤더
  • HTTP 500

    HTTP 500 Internal Server Error
    참고: MDN Web Docs

  • To Do
    <1> HTTP 500 -> 400 Client Error
    : 예측가능한 에러이므로 400이어야 한다.
    <2> 응답 본문 (Body) 내용 정의
    • 에러 시 전달되는 본문 정의 -> FE와 공유(에러 메시지 포맷)
      1. errorMessage : 에러 내용
      2. httpStatus : 스프링에 선언된 HttpStatus 값
    • Ex
		{
  		"errorMessage":"중복된 폴더명을 제거해 주세요! 폴더명: 신발",
		"httpStatus":"BAD_REQUEST"
  		}

2) 스프링 예외처리 방법

  1. Controller 코드 수정

    a. 프론트엔드 : 에러메시지 팝업 띄우는 작업 적용

    • basic.js
      : ajax에서 POST /api/folders 요청 후, 
         성공했을 때 처리 말고 error에 대한 처리가 들어옴

    b. 백엔드 : 예외 처리

    • 스프링이 제공하는 ResponseEntity 클래스 사용
    • ResponseEntity: HTTP response object 를 위한 Wrapper
    • 선언 가능
      • HTTP status code
      • HTTP headers
      • HTTP body
  • exception > RestApiException
: 본문에 담아서 보내줄 JSON 내리는 형태
import lombok.Getter;
import lombok.Setter;
import org.springframework.http.HttpStatus;

@Getter
@Setter
public class RestApiException {
    private String errorMessage;
    private HttpStatus httpStatus;
}
  • controller > FolderController
: Controller에서 에러 처리 안해주면 스프링으로 올라옴
  (이를 스프링이 500에러로 내려준다)
@PostMapping("api/folders")
public ResponseEntity addFolders(
	@RequestBody FolderRequestDto folderRequestDto,
    @AuthenticationPrincipal UserDetailsImpl userDetails
) {
	try {
		List<String> folderNames = folderRequestDto.getFolderNames();
        User user = userDetails.getUser();

        List<Folder> folders = folderService.addFolders(folderNames, user);
        return new ResponseEntity(folders, HttpStatus.OK); // 정상 케이스
    } catch(IllegalArgumentException ex) {
		RestApiException restApiException = new RestApiException();
		restApiException.setHttpStatus(HttpStatus.BAD_REQUEST); // 400 에러
		restApiException.setErrorMessage(ex.getMessage()); // 에러 메시지 넘겨줌
		return new ResponseEntity(
			// HTTP body
            restApiException,
            // HTTP status code
            HttpStatus.BAD_REQUEST);
    }
}
  1. @ExceptionHandler 사용
  • FolderController의 모든 함수에 예외처리 적용 (AOP)
  • @ExceptionHandler 적용하여 1. 같이 반복적인 것 처리
@PostMapping("api/folders")
public List<Folder> addFolders(
	@RequestBody FolderRequestDto folderRequestDto,
	@AuthenticationPrincipal UserDetailsImpl userDetails
) {
	List<String> folderNames = folderRequestDto.getFolderNames();
	User user = userDetails.getUser();

    return folderService.addFolders(folderNames, user);
}
  
@ExceptionHandler({ IllegalArgumentException.class }) // 어떤 종류 에러 받을 것인지 (여러 개 가능)
public ResponseEntity handleException(IllegalArgumentException ex) {
	RestApiException restApiException = new RestApiException();
	restApiException.setHttpStatus(HttpStatus.BAD_REQUEST);
	restApiException.setErrorMessage(ex.getMessage());
	return new ResponseEntity(
		// HTTP body
		restApiException,
		// HTTP status code
		HttpStatus.BAD_REQUEST
	);
}
  • 예외 처리 함수
  1. 예외처리 적용 결과 확인
  • HTTP 응답 (상태코드)

    HTTP 400 Bad Request
    참고 : MDN Web Docs

  • HTTP 응답 본문 (Body)

11. 스프링 Global 예외 처리 방법

1) Global 예외처리 방법

  • 관심상품 희망최저가 등록 시
    (service > ProductService)
public Product updateProduct(Long id, ProductMypriceRequestDto requestDto) {
    int myprice = requestDto.getMyprice();
    if (myprice < MIN_MY_PRICE) {
        throw new IllegalArgumentException("유효하지 않은 관심 가격입니다. 최소 " + MIN_MY_PRICE + " 원 이상으로 설정해 주세요.");
    }
}
  • Controller 마다 예외처리 코드를 추가해줘야 할까?
  • Global 예외 처리
    • @ControllerAdvice 사용
      : Client랑 Controller 사이에서 ControllerAdvice가 
         Controller인 척하면서 try-catch 다 처리해줌,
         (에러 발생해서 throw 되면 Global 하게 Exceptionn 처리해줌)
         ExceptionHandler 여러 개 처리 O
    • @RestControllerAdvice
      : @ControllerAdvice + @ResponseBody
      : JSON 형태로 Global로 적용됨
  • @RestControllerAdvice 적용
    (exception > RestApiExceptionHandler)
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@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
        );
    }
}
  • FolderController의 예외처리 함수 제거
    (controller > FolderController)
    • handleException() 함수 제거

2) ErrorCode 선언

  • 서비스 전체에 사용할 에러코드를 (ErrorCode)을 선언
    (Global 하게 사용)
    • 예외발생 시 서버 및 클라이언트에서 선언한 ErrorCode 사용
  • 에러코드 샘플
    (exception > ErrorCode)
import org.springframework.http.HttpStatus;
import static com.sparta.springcore.service.ProductService.MIN_MY_PRICE;

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;
    }
}

<1> httpStatus : HTTP 상태코드
<2> errorCode : 에러코드

  • 에러 종류별로 Unique한 에러코드를 소유
  • 국제화에 사용 가능
    : 클라이언트가 사용하는 언어 (한국어, 영어, 중국어 등)에 따라 에러메시지를 다르게 보여줌

<3> errorMessage

  • 대표 에러 메시지

12. OOP VS AOP

  • OOP : 핵심기능을 모듈화
  • AOP : 부가기능을 모듈화
    • 부가기능 Ex : API시간 측정, 트랜잭션, 예외 처리, 로깅 등
  • AOP 는 ..
    • OOP를 "대체" X
    • OOP를 "보완" O

13. 회원별 총 API 호출 횟수 추가

1) 요구사항

AS-IS

  • 회원별 총 API 수행시간 -> DB에 저장

ApiUseTime 테이블

컬럼명컬럼타입중복허용설명
idLongX테이블 ID (PK)
user_idLongX회원 ID (FK)
totalTimeLongOAPI 총 사용시간

TO-BE

  • 회원별 총 API 호출 횟수 -> DB에 저장

ApiUseTime 테이블

컬럼명컬럼타입중복허용설명
idLongX테이블 ID (PK)
user_idLongX회원 ID (FK)
totalTimeLongOAPI 총 사용시간
totalCountLongOAPI 총 호출횟수

2) 구현

  • model > ApiUseTime
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import javax.persistence.*;

@Setter
@Getter // get 함수를 일괄적으로 만들어줍니다.
@NoArgsConstructor // 기본 생성자를 만들어줍니다.
@Entity // DB 테이블 역할을 합니다.
public class ApiUseTime {
    // ID가 자동으로 생성 및 증가합니다.
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Id
    private Long id;

    @OneToOne
    @JoinColumn(name = "USER_ID", nullable = false)
    private User user;

    @Column(nullable = false)
    private Long totalTime;

    @Column(nullable = false, columnDefinition = "bigint default 0")
    private Long totalCount;

    public ApiUseTime(User user, long totalTime) {
        this.user = user;
        this.totalTime = totalTime;
        this.totalCount = 1L;
    }

    public void addUseTime(long useTime) {
        this.totalTime += useTime;
        this.totalCount += 1L;
    }
}

결과

Top5 회원 찾기 조회 (관리자용)

중복 폴더명 저장 시 에러 처리


마치며

5주차에서는 AOP라는 새로운 개념을 배웠다. 데이터베이스 수업에서 배우던 트랜잭션 개념도 나와 재미있게 들었다. 모든 강의를 다 듣고 정리까지 마쳤으니 이제 복습하면서 프로젝트에 써먹을 날이 왔으면 좋겠다. '나만의 셀렉샵' 코드는 내 깃허브에 올려두었다. 앞으로 차근차근 계속 공부해야지!! 아즈아😬
출처: 스파르타 코딩 클럽


1주차

[스파르타코딩클럽] Spring 심화반 - 1주차


profile
Studying!!

0개의 댓글