Spring Projcect/계좌 관리 시스템 프로젝트

Chapter 09. Account(계좌) 시스템 업그레이드

계란💕 2022. 8. 5. 20:29

9.1 일관성 있는 예외 처리

 

  에러 응답을 어떻게 줘야 Client(서버, FE, 앱 등)에서 처리하기 편리할까?

  • HTTP status code를 활용한다
  • 별도의 status code를 사용하는 방법 ex) 0000, -1001, -2001, -3001
  • errorCode(문자 코드)와 errorMessage를 사용하는 방법 - 직관적이고 편리하다.

 

 

  Ex) 

    - create userId (id = 111)

      -> 500번 서버 오류가 난다.

      -> 구체적인 이유를 알 수 없음

      - > AccountController에서 post mapping에서 create에 관한 예외를 던지지(try구문) 않으니까 컨트롤러 밖으로 예외를 던진다.

 

    - ErrorResponse 클래스를 만든다.

<hide/>
package com.example.account.dto;
import com.example.account.type.ErrorCode;
import lombok.*;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class ErrorResponse {

    private ErrorCode errorCode;
    private String errorMassage;

}

 

    - GlobalExceptionHandler클래스 만들기 - 에러메시지 아니고 에러코드 수정하기

<hide/>
package com.example.account.exception;
import com.example.account.dto.ErrorResponse;
import com.example.account.type.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import static com.example.account.type.ErrorCode.INVALID_REQUEST;
import static com.example.account.type.ErrorCode.INVALID_SERVER_ERROR;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(AccountException.class)
    public ErrorResponse handleAccountException(AccountException e){
        log.error("{} is occured." , e.getErrorMassage());
        return new ErrorResponse(e.getErrorCode(), e.getErrorMassage());
    }

    // UNIQUE 키가 중복되는 경우
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ErrorResponse handleDataIntegrityViolationException(DataIntegrityViolationException e){
        log.error("DataIntegrityViolationException is occurred." , e);
        return new ErrorResponse(INVALID_REQUEST, INVALID_REQUEST.getDescription());
    }

    // 최종적으로 발생하는 예외 처리
    @ExceptionHandler(AccountException.class)
    public ErrorResponse handleException(Exception e){
        log.error("Exception is occurred." , e);
        return new ErrorResponse(INVALID_SERVER_ERROR,
                                 INVALID_SERVER_ERROR.getDescription());
    }
}

 

 

     - 다시 createId를 하면 이렇게 나와야 하는데

 

<강사님 화면>

    - 계좌가 없기 때문에 UserNotFound Exc이 발생해야한다.

===============오류 =================

<내 화면>

     - 서버가 실행되지 않아서 인텔리제이를 껐다가 켰다.

     - getErrorMessage 부분이 null 이 뜬다.

 

 

  Ex)

<hide/>
### create account
POST http://localhost:8080/account
Content-Type: application/json

{
  "userId": 1,
  "initialBalance": -100
}

  Note) 실행 결과

  Ex)

    - 핸들러 추가한다.

<hide/>
package com.example.account.exception;


import com.example.account.dto.ErrorResponse;
import com.example.account.type.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import static com.example.account.type.ErrorCode.*;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(AccountException.class)
    public ErrorResponse handleAccountException(AccountException e){
        log.error("{} is occurred.", e.getErrorCode());
        return new ErrorResponse(e.getErrorCode(), e.getErrorMassage());
    }


    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e){
        log.error("MethodArgumentNotValidException is occurred.", e);
        return new ErrorResponse(INVALID_REQUEST,
                INVALID_REQUEST.getDescription());
    }

    // UNIQUE 키가 중복되는 경우
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ErrorResponse handleDataIntegrityViolationException(DataIntegrityViolationException e){
        log.error("DataIntegrityViolationException is occurred.", e);
        return new ErrorResponse(INVALID_REQUEST,
                                    INVALID_REQUEST.getDescription());
    }


    // 최종적으로 발생하는 예외 처리
    @ExceptionHandler(Exception.class)
    public ErrorResponse handleException(Exception e){
        log.error("Exception is occurred.", e);
        return new ErrorResponse(INTERNAL_SERVER_ERROR,
                                    INTERNAL_SERVER_ERROR.getDescription());
    }
}
<hide/>
### create account
POST http://localhost:8080/account
Content-Type: application/json

{
  "userId": 1,
  "initialBalance": -100
}

  Note) 실행 결과

 

 

    - failGetAccount() test를 추가한다.

====================== 오류 ====================

    - > 확인해보니까 errorMessage 철자를 잘못 써서 발생한 오류이다.

<hide/>

    @Test
    void failGetAccount() throws Exception {
        // given
        given(accountService.getAccount(anyLong()))
                .willThrow(new AccountException(ErrorCode.ACCOUNT_NOT_FOUND));

        // when
        // then
        mockMvc.perform(get("/account/876"))    // 테스트 하려는 url
                .andDo(print())
                .andExpect(jsonPath("$.errorCode").value("ACCOUNT_NOT_FOUND")) // 바디의 첫 번째 구조에 있는 값이
                .andExpect(jsonPath("$.errorMessage").value("계좌가 없습니다."))
                .andExpect(status().isOk());
    }

 

  Note) 실행 결과

 

 

    - ErrorResponse클래스의 내부의 에러메시지 철자를 수정한 다음 서버 켜고 아래 코드를 실행한다.

<hide/>
### create account
POST http://localhost:8080/account
Content-Type: application/json

{
  "userId": 1,
  "initialBalance": -100
}

  Note) 실행 결과 - 해결 완료

 

===============================================

 

9.2 중복 거래 방지

 

9.2-1 중복거래 방지 (1)

 

  동시성 이슈란?

  • 여러 요청이 동일한 자원에 접근하며 발생하는 문제들을 통칭하여 주로 데이터베이스에서 동일한 레코드를 동시 접근하며 문제가 발생한다.

 

1번, 2번 요청이 거의 동시에 요청된 케이스 / 잔액이 9000원 인데 1000원 짜리 거래가 두 번 일어났다.

 

 

  해결 방법

  • DB 의존적인 방법
  • 기타 인프라를 활용하는 방법
  • 비즈니스 로직으로 해결하는 방법

 

  Ex)

 

    - AOP 패키지 만들고 AccountLock이라는 어노테이션 만든다.

<hide/>
package com.example.account.aop;
import javax.persistence.Inheritance;
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface AccountLock {

    long tryLockTime() default 5000L;
}

 

    - 트랜잭션 컨트롤러에 어노테이션을  추가한다.

      -> AOP를 붙여줘야 어노테이션이 동작한다.

 

 

    - LockAopAspect를 만든다.

 

    - AccountController 클래스 수정한다. (Redis 모두 지운다.)

 

 

 

     -그런데 여기서 아까랑 같은 오류가 또 발생한다.

<hide/>
    @Test
    void failGetAccount() throws Exception {
        // given
        given(accountService.getAccount(anyLong()))
                .willThrow(new AccountException(ErrorCode.ACCOUNT_NOT_FOUND));

        // when
        // then
        mockMvc.perform(get("/account/876"))    // 테스트 하려는 url
                .andDo(print())
                .andExpect(jsonPath("$.errorCode").value("ACCOUNT_NOT_FOUND")) // 바디의 첫 번째 구조에 있는 값이
                .andExpect(jsonPath("$.errorMessage").value("계좌가 없습니다."))
                .andExpect(status().isOk());
    }

 

 

    - Radis 클래스명을 LockService로 바꾼다.

<hide/>
package com.example.account.service;
import com.example.account.exception.AccountException;
import com.example.account.type.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor
public class LockService {
    private final RedissonClient redissonClient;
    public void lock(String accountNumber){
        RLock lock = redissonClient.getLock(getLockKey(accountNumber));// 락 이름이 샘플락, 자물쇠를 받는다.
        log.debug("Trying lock for accountNumber : " + accountNumber);

        try{
            boolean isLock = lock.tryLock(1, 5, TimeUnit.SECONDS);
            // 5초 동안 작업이 없으면 락이 풀린다.
            // 최대 1초 동안 기다리면서 자물쇠 찾는다.

            if(!isLock){
                log.error("============Lock acquisition failed ==============");
                throw new AccountException(ErrorCode.ACCOUNT_NOT_FOUND);
            }
        }catch (Exception e){
            log.error("Redis lock failed");
        }
    }

    public void unlock(String accountNumber){
        log.debug("Unlock for accountNumber : {}", accountNumber);
        redissonClient.getLock(getLockKey(accountNumber)).unlock();
    }
    private String getLockKey(String accountNumber) {

        return "ACLK: " + accountNumber;
    }
}

 

    - AccountLockIdInterface를 만든다.

<hide/>
package com.example.account.aop;
public interface AccountLockIdInterface {
    String getAccountNumber();
}

 

    - UseBalance, CancelBalance의 Request가 둘다 위 AccountLockIdInterface 구현하도록 한다.

     -> 두 클래스 타입이 다르기 때문에 인터페이스를 이용하면 공통화 가능하다. 

 

 

9.2-2 중복거래 방지 (2)

 

 

  Ex)

    - 

 

 

    - create Account 세 번 한다.

<hide/>
### create account
POST http://localhost:8080/account
Content-Type: application/json

{
  "userId": 1,
  "initialBalance": 10000
}

 

 

    - use balance 100원 자리 거래  =>오류

<hide/>
### use balance
POST http://localhost:8080/transaction/use
Content-Type: application/json

{
  "userId": 1,
  "accountNumber": "1000000002",
  "amount": 100
}

 

    - use balance2 =>오류

<hide/>

### use balance2
POST http://localhost:8080/transaction/use
Content-Type: application/json

{
  "userId": 1,
  "accountNumber": "1000000002",
  "amount": 100
}

 

  - 시간이 너무 길어서 짧게 다시 수정한다. 3000L, leaseTime: 15

 

 

===================오류 =================================

    - useBalance1 실행하면 아래와 같이 또 nullPointerException 발생한다.

 

 

    - create Account 를 해준다.

    - 다시 useBalance

 

 

    - DB를 확인해보면

 

 

 

  Ex) useBalance1, useBalance2를 동시에 실행하면? 1번은 잘 되고 2번은 내부 서버 오류가 난다.

    - lockService 클래스의 catch구문을 하나 더 만든다.

<hide/>
package com.example.account.service;
import com.example.account.exception.AccountException;
import com.example.account.type.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
@RequiredArgsConstructor
public class LockService {
    private final RedissonClient redissonClient;
    public void lock(String accountNumber){
        RLock lock = redissonClient.getLock(getLockKey(accountNumber));// 락 이름이 샘플락, 자물쇠를 받는다.
        log.debug("Trying lock for accountNumber : {}" + accountNumber);

        try{
            boolean isLock = lock.tryLock(1, 15, TimeUnit.SECONDS);
            // 5초 동안 작업이 없으면 락이 풀린다.
            // 최대 1초 동안 기다리면서 자물쇠 찾는다.

            if(!isLock){
                log.error("============Lock acquisition failed ==============");
                throw new AccountException(ErrorCode.ACCOUNT_NOT_FOUND);
            }
        }catch (AccountException e){
            throw e;
        }catch (Exception e){
            log.error("Redis lock failed", e); // 다시 한 번 던진다.
        }
    }

    public void unlock(String accountNumber){
        log.debug("Unlock for accountNumber : {}", accountNumber);
        redissonClient.getLock(getLockKey(accountNumber)).unlock();
    }

    private String getLockKey(String accountNumber) {

        return "ACLK: " + accountNumber;
    }
}

 

    - 다시 계좌 생성한다.

    - useBalacne  두 개 동시에 실행한다.

 

 

  Note) 실행 결과

 

 

 

================================null 에러 발생 ===========================

    

    -> AccountException클래스에서 생성자에 errorCode값을 추가한다.

<hide/>
package com.example.account.exception;
import com.example.account.type.ErrorCode;
import lombok.*;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class AccountException extends RuntimeException{

    private ErrorCode errorCode;
    private String errorMessage;
   
    public AccountException(ErrorCode errorCode) {
        this.errorCode = errorCode;
        this.errorMessage = errorCode.getDescription(); // 추가한 부분
    }
}

 

 

 

 

 

 

 

  Ex) 

    - LockAopAspectTest 클래스를 만든다.

=========================오류 ===============================

<hide/>

  
    @Test
    void lockAndUnlock_evenIfThrow() throws Throwable {
        // given

        ArgumentCaptor<String> lockArgumentCaptor =
                ArgumentCaptor.forClass(String.class);
        ArgumentCaptor<String> unLockArgumentCaptor =
                ArgumentCaptor.forClass(String.class);
        UseBalance.Request request =
                new UseBalance.Request(123L, "54321", 1000L);

        given(proceedingJoinPoint.proceed())
                .willThrow(new AccountException(ErrorCode.ACCOUNT_NOT_FOUND));

        // when
        assertThrows(AccountException.class, () ->
            lockAopAspect.aroundMethod(proceedingJoinPoint, request));


        // then
        //?????????????????
        verify(lockService, times(1))
                .lock(lockArgumentCaptor.capture());
        verify(lockService, times(1))
                .unlock(unLockArgumentCaptor.capture());

        assertEquals("54321", lockArgumentCaptor.getValue());
        assertEquals("54321", unLockArgumentCaptor.getValue());
    }

 

============오류 ===========

  Note) 실행 결과

    - 클래스 위에 어노테이션 @ExtendsWith을 붙이니까 해결된다.

 

 

 

  Ex) LockServiceTest 클래스에 successGetLock 메서드를 만든다.

<hide/>
   @Test
    void successGetLock() throws InterruptedException{
        // given
        given(redissonClient.getLock(anyString()))
                .willReturn(rLock);
        given(rLock.tryLock(anyLong(), anyLong(), any()))
                .willReturn(true);

        // when
        // then
        assertDoesNotThrow( () -> lockService.lock("123"));
    }

 

 

 

  Ex) failGetLock()

<hide/>
   @Test
    void failGetLock() throws InterruptedException{
        // given
        given(redissonClient.getLock(anyString()))
                .willReturn(rLock);
        given(rLock.tryLock(anyLong(), anyLong(), any()))
                .willReturn(false);
        // when
        AccountException exception = assertThrows(AccountException.class,
                () -> lockService.lock("123"));
        // then
        assertEquals(ErrorCode.ACCOUNT_TRANSACTION_LOCK, exception.getErrorCode());
    }

 

 

9.3 리팩토링(refactoring)

 

  리팩토링(refactoring)

  • 동작은 변경하지 않으면서 내부 구조를 개선하는 작업을 '리팩토링'이라고 한다.
  • 가독성 향상
  • 유지 보수성 향상(중복 코드 제거, 복잡한 구조의 개선)
  • 안티 패턴 제거, 개발 능력의 향상 및 역량의 향상

 

  Ex)

    - BaseEntity를 만든다.

      -> AccountUser와 Transaction, Account클래스들이 BaseEntity를 구현하도록 한다. 두 클려랴래스 모두 id와 필요없는 필드는 삭제한다.

 

 

  Note) 전체 테스트 결과