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)
동시성 이슈란?
- 여러 요청이 동일한 자원에 접근하며 발생하는 문제들을 통칭하여 주로 데이터베이스에서 동일한 레코드를 동시 접근하며 문제가 발생한다.
해결 방법
- 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) 전체 테스트 결과
'Spring Projcect > 계좌 관리 시스템 프로젝트' 카테고리의 다른 글
Chapter 08. Account(계좌) 시스템 개발 (0) | 2022.08.02 |
---|---|
Chapter 07. 사전 준비 (0) | 2022.08.02 |
Chapter 06. 스프링 MVC(Model-View-Controller) (0) | 2022.07.27 |
Chapter 03. 자바에서 스프링으로 (0) | 2022.07.19 |
Chapter 02. OOP(Object Oriented Pragramming)와 스프링 프레임워크 (0) | 2022.07.19 |