Coder Social home page Coder Social logo

2022-kkogkkog's Introduction

Hi~ I'm rookie 🤗

I’m a backend developer using Java and Spring


Hits

Anurag's GitHub stats

2022-kkogkkog's People

Contributors

wishoon avatar

Watchers

 avatar

2022-kkogkkog's Issues

[Feature] 테스트 환경에서 쿼리 요청시간 & 카운터를 측정하는 기능을 구현한다.

배경

로그를 통해 예상하지 못하는 쿼리가 발생하는지, 시간이 얼마나 소요되는지 수치적으로 확인하기 위함

구현

사전 지식

자바는 데이터베이스 종류에 상관없이 데이터베이스에 접속하고 쿼리를 실행하기 위해 JDBC API를 사용하고, JDBC API는 PrepareStatement 객체의 executeQuery, execute, executeUpdate 메서드를 사용하여 쿼리를 실행

즉, PrepareStatement의 메서드를 호출하는 시점에 "퍼포먼스 측정 부가기능"을 수행하도록 처리하도록 할 수 있으며, AOP를 사용하여 부가기능을 등록하면 됨

이를 위해서는 Connection과 PrepareStatement를 다이나믹 프록시로 생성하면 됨

구현 방법

[PreparedStatement 다이나믹 프록시]

쿼리 실행 시점에 카운트를 증가시키는 기능을 수행하는 프록시

public class ProxyPreparedStatementHandler implements InvocationHandler {

    private final Object preparedStatement;
    private final PerformanceMonitor performanceMonitor;

    public ProxyPreparedStatementHandler(final Object preparedStatement,
                                         final PerformanceMonitor performanceMonitor) {
        this.preparedStatement = preparedStatement;
        this.performanceMonitor = performanceMonitor;
    }

    @Override
    // (1) 
    public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable {
        // (2)
        if (isExecuteQuery(method) && isRequestScope()) {
            long startTime = System.currentTimeMillis();
            Object returnValue = method.invoke(preparedStatement, args);
            performanceMonitor.addQueryTime(System.currentTimeMillis() - startTime);
            performanceMonitor.addQueryCounts();
            return returnValue;
        }

        return method.invoke(preparedStatement, args);  // (3)
    }

    private boolean isExecuteQuery(final Method method) {
        String methodName = method.getName();
        return methodName.equals("executeQuery") || methodName.equals("execute") || methodName.equals("executeUpdate");
    }

    private boolean isRequestScope() {
        return Objects.nonNull(RequestContextHolder.getRequestAttributes());
    }
}

(1) - 다이나믹 프록시의 target 메서드 호출을 가로채서 실행. 실제 메서드의 구현 전/후의 부가기능을 구현할 수 있음
(2) - executeQuery, execute, executeUpdate 실행인지에 대한 분기
(3) - 실제 메서드를 실행하고 결과를 반환


[Connection 다이나믹 프록시]

Connection이 prepareStatement 메서드를 호출할 때, 실제 PreparedStatement 객체 대신 프록시를 반환해주는 역할을 수행

public class ProxyConnectionHandler implements InvocationHandler {

    private final Object connection;
    private final PerformanceMonitor performanceMonitor;

    public ProxyConnectionHandler(final Object connection, final PerformanceMonitor performanceMonitor) {
        this.connection = connection;
        this.performanceMonitor = performanceMonitor;
    }

    @Override
    public Object invoke(final Object proxy, final Method method, final Object[] args) throws Throwable {
        // (1)
        Object returnValue = method.invoke(connection, args);

        // (2)
        if (method.getName().equals("prepareStatement")) {
            return Proxy.newProxyInstance(
                returnValue.getClass().getClassLoader(),
                returnValue.getClass().getInterfaces(),
                new ProxyPreparedStatementHandler(returnValue, performanceMonitor));
        }

        return returnValue;
    }
}

(1) - 실제 Connection의 작업을 수행함
(2) - 커넥션의 요청이 preparedStatement 일 경우 프록시를 만들어 반환


[DataSoruce 어드바이저]

DataSource.getConnection 메서드 호출 시 프록시로 생성할 수 있도록 수행

@Aspect
@RequiredArgsConstructor
public class PerformanceAspect {

    private ThreadLocal<PerformanceMonitor> performanceCounterThreadLocal;

    @Around("execution(* javax.sql.DataSource.getConnection())")
    public Object datasource(final ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        Object proceed = proceedingJoinPoint.proceed();
        
        return Proxy.newProxyInstance(
            proceed.getClass().getClassLoader(),
            proceed.getClass().getInterfaces(),
            new ProxyConnectionHandler(proceed, getPerformanceCounterThreadLocal()));
    }

    private PerformanceMonitor getPerformanceCounterThreadLocal() {
        if (performanceCounterThreadLocal == null) {
            performanceCounterThreadLocal = new ThreadLocal<>();
        }

        if (performanceCounterThreadLocal.get() == null) {
            performanceCounterThreadLocal.set(new PerformanceMonitor());
        }
        return performanceCounterThreadLocal.get();
    }
}

[Fix] 쿠폰의 상태 변경 시 발생할 수 있는 동시성 문제를 해결한다.

배경

발급된 쿠폰에 대해서 두 명의 사용자가 동시에 쿠폰의 상태를 변경할 경우 두 번의 갱신 분실 문제가 발생. 이를 데이터베이스에 락을 걸어서 해결하는 것이 아닌 어플리케이션 레벨에서 해결하는 코드를 설계 및 구현한다.

과정

사전 지식

낙관적 락

트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법이다. 실제 DB 단에서 락을 거는 것이 아니고, 버전 관리 기능을 통해 동시성 문제를 해결한다. 엔티티에 버전 관리용 필드를 추가하여 낙관적락을 구현할 수 있다.

엔티티의 수정이 발생하면 버전이 하나씩 자동으로 증가하게 되는데, 엔티티를 조회했을 때의 버전과 수정했을 때의 버전이 다르다면 다른 트랜잭션에서 이미 엔티티를 수정하였다고 볼 수 있기 때문에, 예외가 발생한다. 내부적으로는 update 쿼리문의 조건절에 version에 대한 조건을 붙여서 확인을 수행하게 된다.

낙관적 락은 총 3가지 옵션을 가지고 구현할 수 있다.

  • NONE

    • 용도 : 조회한 엔티티를 수정하는 시점에 다른 트랜잭션으로부터 변경되지 않음을 보장한다. (조회 시점 부터 수정 시점 까지를 보장)
    • 동작 : @Version 애노테이션만 적용하게 되면 기본적으로 적용. 엔티티를 수정할 때 버전을 체크한 후, 일치하면 버전을 증가한다. 만약 일치하지 않으면 예외가 발생한다.
    • 효과 : 두 번의 갱신 분실 문제를 방지한다
  • OPTIMISTIC

    • 용도 : NONE의 경우 엔티티를 수정해야 버전을 체크하지만, 엔티티를 조회만 해도 버전을 체크하는 기능을 제공한다. 즉, 한번 조회한 엔티티가 트랜잭션 동안 변경되지 않음을 보장한다.
    • 동작 : 트랜잭션을 커밋하는 시점에 버전 정보를 체크한다. 만약 일치하지 않으면 예외가 발생한다.
    • 효과 : 어플리케이션 레벨에서 DIRTY READ와 NONE-REPETABLE READ를 방지한다.
  • OPTIMISTIC_FORCE_INCREMENT

    • 용도 : 낙관적 락을 사용하면서 버전 정보를 강제로 증가한다. 엔티티가 물리적으로 변경되지 않았지만, 논리적으로는 변경되었을 경우 버전을 증가하고 싶을 때 사용한다.
    • 동작 : 엔티티가 직접적으로 수정되지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해 버전 정보를 강제로 증가시킨다.
    • 효과 : 강제로 버전을 변경하여 논리적인 단위의 엔티티 묶음을 버전으로 관리할 수 있다 (1 : N 관계)

해당 비즈니스 로직에서는 OPTIMISTIC을 활용하여 트랜잭션을 커밋하는 시점에서 일치여부를 확인하도록 설계한다.

Executors.newFixedThreadPool()

지정한 수만큼의 고정된 스레드 풀을 생성하는 기능

Future

비동기적 작업을 수행한 다음 도출된 결과를 메인 스레드에서 받아야 할 때 사용하는 객체

구현 과정

1. 낙관적 락을 설정하고자 하는 엔티티에 @Version을 사용하여 낙관적 락 적용
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class Coupon extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // ...

    @Enumerated(EnumType.STRING)
    private Condition condition;

    @Version
    private Long version;
}
2. OPTIMISTIC 적용을 위해서 xxxRepository의 조회 메서드에 @lock 애노테이션을 통해서 속성을 적용
@Lock(LockModeType.OPTIMISTIC)
@Query("select c from Coupon c where c.id = :couponId")
Optional<Coupon> findWithOptimisticLockById(@Param("couponId") Long couponId);
3. 낙관적 락을 적용할 비즈니스 로직 작성
@Transactional
public void updateCondition(final Long couponId,
                            final Long invokeMemberId,
                            final CouponConditionUpdateRequest request) {
    validateExistsMember(invokeMemberId);
    Coupon coupon = couponRepository.getWithOptimisticLockById(couponId);

    coupon.updateCondition(request.getCondition(), invokeMemberId);
}
4. 테스트 코드를 통해 낙관적 락 검증 (OptimisticLockingFailureException 이 발생하는지 검증)
Long senderId = memberRepository.save(발신자_회원()).getId();
Long receiverId = memberRepository.save(수신자_회원()).getId();
Long couponId = couponRepository.save(IN_PROGRESS_쿠폰(senderId, receiverId)).getId();

ExecutorService executor = Executors.newFixedThreadPool(2);
Future<?> future1 = executor.submit(() -> couponService.updateCondition(
	couponId, receiverId, new CouponConditionUpdateRequest(Condition.READY.getValue())));
Future<?> future2 = executor.submit(() -> couponService.updateCondition(
	couponId, receiverId, new CouponConditionUpdateRequest(Condition.FINISH.getValue())));

Exception result = new Exception();
try {
	future1.get();
	future2.get();
} catch (final ExecutionException e) {
	result = (Exception) e.getCause();
}

assertTrue(result instanceof OptimisticLockingFailureException);

[Feature] Github OAuth를 활용한 인증/인가를 구현한다.

배경

서비스에서 OAuth를 활용한 기능을 이용해서 인증/인가를 수행해야 한다. 이때 한가지의 OAuth Provider만 이용하는 것이 아니라 다양한 Provider(Github, Google, Naver, Kakao)를 이용해서 로직을 구성해야 한다. 새로운 Provider를 도입하더라도 기존의 코드에는 영향이 가지 않는 코드를 설계한다.

또한 외부 환경과 연결되는 부분은 롱 트랜잭션을 가지게 될 가능성이 높다. 외부 리소스를 사용하는 부분은 트랜잭션을 분리하여 커넥션이 부족하지 않도록 코드를 설계 및 구현한다.

과정

사전 지식

Spring DI

Spring에서의 DI란, 객체간 의존성을 개발자가 객체 내부에서 직접 호출하는 것이 아니라, 외부에서 객체를 생성해서 넣어주는 방식. 인터페이스를 사이에 두어 클래스 레벨에서는 의존관계가 고정되지 않도록 하고, 런타임 시 관계를 동적으로 주입하여 유연성을 확보하고 결합도를 낮출 수 있는 방법

조회한 빈이 모두 필요한 경우

Map과 List 같은 자료구조를 활용하고 Value를 인터페이스로 설정하면 모두 빈으로 등록할 수 있다.

구현 방법

1. 제공하고자 하는 기능을 추상화하는 인터페이스를 구현
public interface OAuthClient {

    OAuthAccessTokenResponse getAccessToken(final String code);

    OAuthProfileResponse getProfile(final String accessToken);
}
2. 추상화된 인터페이스를 재정의하여 각 OAuth Provider 구현체를 구현하고 컴포넌트 스캔 대상이 되도록 @component를 설정해 Bean으로 등록
@Component
public class GithubOAuthClient implements OAuthClient {

    private final String clientId;
    private final String clientSecret;
    private final String accessTokenURL;
    private final String profileURL;
    private final RestTemplate restTemplate;

    public GithubOAuthClient(@Value("${oauth2.github.client-id}") final String clientId,
                             @Value("${oauth2.github.client-secret}") final String clientSecret,
                             @Value("${oauth2.github.access-token-url}") final String accessTokenURL,
                             @Value("${oauth2.github.profile-url}") final String profileURL,
                             final RestTemplate restTemplate) {
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.accessTokenURL = accessTokenURL;
        this.profileURL = profileURL;
        this.restTemplate = restTemplate;
    }

    @Override
    public OAuthAccessTokenResponse getAccessToken(final String code) {
        // ... 생략
        return // ... 생략
    }

    @Override
    public OAuthProfileResponse getProfile(final String accessToken) {
	// ... 생략
        return // ... 생략
    }
}
3. 추상화된 기능을 구현한 구현체들의 Interface 타입을 Map을 통해 주입받아 InMemory로 등록. Key로는 구현체의 빈 이름을 저장하고, Value로는 실제 구현체의 인스턴스를 저장
@Getter
@Component
public class InMemoryOAuthClientRepository {

    private final Map<String, OAuthClient> clients;

    public InMemoryOAuthClientRepository(final Map<String, OAuthClient> clients) {
        this.clients = clients;
    }
} 
4. 필요한 구현체를 찾는 메서드를 제공하여 각 구현체를 제공
@Getter
@Component
public class InMemoryOAuthClientRepository {

    private final Map<String, OAuthClient> clients;

    public InMemoryOAuthClientRepository(final Map<String, OAuthClient> clients) {
        this.clients = clients;
    }

    public OAuthClient findByClientName(final String oauthClientName) {
        return clients.entrySet().stream()
            .filter(entry -> entry.getKey().contains(oauthClientName))
            .map(Entry::getValue)
            .findAny()
            .orElseThrow(IllegalArgumentException::new);
    }
}

[Feature] 수량을 가지는 쿠폰 엔티티를 구현하고 갯수 증가, 갯수 감소 기능을 구현한다.

배경

Spring은 멀티 스레드 환경에서 구동된다. 따라서 여러 스레드가 함께 접근할 수 있는 공유자원(수량 쿠폰)에 대해서 동시성 문제가 발생할 수 있기 때문에 동시성 처리가 가능한 설계와 로직을 구현한다.

과정

사전 지식

비관적 락

트랜잭션 대부분은 충돌이 발생한다고 가정하고 비관적으로 가정하는 방법이다. 실제 DB 단에 락을 걸어 동시성 문제를 해결한다. 비관적 락은 2가지 옵션을 가지고 구현할 수 있다. 데이터 무결성을 보장하는 수준이 높지만, 데이터 자체에 락을 걸어버리기 때문에 동시성이 떨어져 성능에 손해를 보게 되며, 자원 경쟁으로 인해 데드락이 발생할 가능성이 높다.

  • 공유 락

    • Shared Lock으로 불리며, 다른 트랜잭션에서 읽기만 가능하다. 쓰는 작업은 불가능하며 공유 락이 적용된 경우 다른 트랜잭션에서 베타 락은 적용이 불가능하다.
  • 베타 락

    • Exclusive Lock으로 불리며, 다른 트랜잭션에서 읽기, 쓰기 둘다 불가능하다. 또한 다른 트랜잭션에서 공유 락, 베타 락 둘다 적용이 불가능하다.

해당 비즈니스에서는 공유 락을 걸게 된다면 다른 트랜잭션에서 값을 읽을 수 있기 때문에 동시성 문제를 해결할 수 없다. 따라서 베타락을 통해 읽는 것도 불가능하도록 로직을 구현해야 하지만 언급했듯이 성능저하 + 데드락 문제 때문에 고려 대상에서 제외

추가적으로 분산 환경(Replication)에서의 로직을 구현하고 있기 때문에 해당 방법으로는 동시성 제어가 불가능 함.


분산 락

여러 프로세스에서 하나의 자원을 공유해야 할 때, 공통된 저장소를 이용해서 자원이 사용중인지를 체크하고 이를 통해 동시성 문제를 해결한다. 직접 데이터베이스에 락을 설정하지 않으며, 공통된 저장소를 이용하기 때문에 분산 환경에서도 사용이 가능하다.

1. RDBMS Named Lock

GET_LOCK(), RELEASE_LOCK()을 통해 Named Lock을 통해 분산 서버에서도 분산 락을 구현할 수 있다. MySQL을 메인 RDMBS로 사용중이므로 Redis와 같은 추가 인프라 구축 비용 없이 분산락을 구현할 수 있는 장점이 있지만 Replication을 통해 DB가 분산되어 있어 고려 대상에서 제외

2. Redis 스핀 락

레디스는 싱글 스레드 기반으로 동작하는 메모리 기반 Key-Value 저장소이다. 기존 RDMBS와는 다르게 디스크에 데이터를 저장하는 것이 아니기 때문에 보다 빠르게 데이터를 관리할 수 있다.

이러한 레디스을 사용해서 스핀 락을 구현할 수 있다. 스핀 락이란, 락을 사용할 수 있을 때 까지 지속적으로 확인하며 기다리는 방식을 말한다. 레디스의 SETNX 명령을 통해 특정 Key에 Value가 존재하지 않을 때만 값을 설정할 수 있도록 구현을 할 수 있다. 이를 통해 지속적으로 SETNX 명령을 보내어 임계 영역 진입 여부를 확인하도록 구현이 가능하다.

하지만 지속적으로 레디스 서버에 락 획득 여부를 요청하기 때문에 많은 부하를 줄 수 있다는 단점이 있다.

3. Redis Message Broker

레디스에서 Message Broker 기능을 사용해서 락을 구현할 수도 있다. SUBSCRIBE 명령으로 특정 채널을 구독할 수 있으며, PUBLISH 명령으로 특정 채널에 메시지를 발행할 수 있다. 해당 방식을 이용해 락을 해제하는 프로세스에서 락을 대기하는 프로세스에 “락 획득 시도 요청 메시지”를 발행하여 동시성 문제를 해결할 수 있다.

해당 방식을 이용하면 스핀 락을 통해 매번 레디스 서버에 요청하지 않더라도 동시성 문제를 해결할 수 있다.


Message Queue + Event

이번 이슈에서는 고려하지 않고 추후 개선 사항으로 이슈를 발행한다.

구현 과정

해당 Issue에서는 분산 DB에서 적용할 수 있고, 레디스 서버에 부하를 적게 줄 수 있는 Message Broker 방식을 통해 분산 락을 구현한다. 이후 Message Queue 방식을 통해서 락을 사용하지 않고 구현하는 방법으로 추후 변경한다.

[Refactor] 접근 제어자 규칙을 수정한다.

배경

  • Spring은 자바 객체와 JSON 형식 간의 변환 작업을 위해서 Jackson 라이브러리를 기본적으로 사용한다. 이때 ObjectMapper를 사용한다.
  • ObjectMapper를 사용해서 자바 객체를 JSON 형식으로 변경하기 위해서 getter를 사용해서 이를 해결한다.
  • 반대로 JSON 형식을 자바 객체로 변경해주기 위해서는 기본 생성자가 필요하다. 이는 리플렉션을 통해서 작업이 이루어지기 때문이라고 할 수 있다. 따라서 접근 제어자를 private으로 설정해도 무방하다.

추가사항

  • 자바 객체 -> JSON 경우 기본 생성자가 필요 없지만, RestAssured API의 as 메서드를 사용하게 되면 동일한 문제가 발생한다. 따라서 동일하게 기본 생성자를 사용한다.

[Feature] 수량을 가지는 쿠폰의 수량이 감소할 경우, 쿠폰을 발급하는 기능을 구현한다.

배경

수량 쿠폰(QuantityCoupon)과 쿠폰(Coupon)은 의존성을 가진다. 수량 쿠폰을 발급한 뒤 재고를 감소시키면 쿠폰의 제고가 감소하는 비즈니스 로직을 구현해야 한다. 이를 구현하면서 예상되는 문제는 다음 항목들로 예상됨

  1. 수량 쿠폰을 관리하는 패키지에서 쿠폰 생성까지 관리해야 하는 불필요한 책임을 가진다.

  2. 핵심기능과 부가기능이 섞이게 되어 추후 유지보수를 어렵게 한다.

  3. 수량 쿠폰 제고감소와 쿠폰 생성이 하나의 트랜잭션에서 묶일 경우, 한쪽에서 요청이 오래 걸리게 되면 다른 쪽도 영향을 받게 된다.

이러한 3가지 문제들을 해결하는 코드를 설계하고 구현한다.

과정

사전 지식

Spring Event

Spring Event란 Spring의 Bean과 Bean 사이의 데이터를 전달하는 방법 중 하나다. 일반적으로 데이터를 전달하는 방법은 DI를 통해서 데이터를 전달하는 방식으로 이루어진다. 하지만 이벤트는 “이벤트를 발생시키는 publisher”, “이벤트를 받아들이는 listener”, “이벤트 데이터를 담는 event model”을 통해서 데이터를 전달하는 방식으로 이루어지게 된다. 이러한 방법을 통해 각 코드의 관심사를 분리할 수 있다.

이벤트를 사용함으로서 얻을 수 있는 이점은 다음과 같다.

  • XXXService는 하고자하는 핵심 로직만 수행을 처리함
  • 부가적인 로직들은 Event Listener를 통해서 이벤트를 발행하고 처리의 책임을 넘겨줌

@Async

@Async Annotation은 Spring에서 제공하는 Thread Pool을 활용하는 비동기 메소드 자원 Annotation이다. 비동기 방식이기 때문에 작업의 결과를 기다리지 않고 작업이 수행되며, 별도의 스레드를 만들어서 동작을 하게 된다. @Async는 AOP를 이용해서 실행된다. 즉, Proxy 방법을 통해서 부가기능을 실행시켜 비동기 방식을 지원한다고 할 수 있다.

Spring에서 @Async를 사용하기 위해서는 Application 클래스에 @EnableAsync만 적용하게 되면 사용이 가능하다. 하지만 SimpleAsyncTaskExecutor를 사용하는 기본값으로 설정되기 때문에 요청이 올 때마다 스레드를 만들게 된다. 요청이 올 때마다 스레드를 만드는 문제를 해결하기 위해 Configuration을 통한 ThreadPoolTaskExectuor를 사용해주는 방식으로 변경한다.

  • 기본 설정에 대한 설명

    • corePoolSize : ThreadPool에 항상 살아있는 Thread의 최소 갯수
    • queueCapacity: ThreadPool의 대기 큐. 설정한 값을 초과하게 될 경우 Thread의 갯수를 maxPoolSize 까지 증가시킴.
    • maxPoolSize : ThreadPool에서 사용할 수 있는 최대 Thread의 갯수
    • keepAliveSeconds : maxPoolSize의 설정 값까지 Thread가 생성되어 모두 사용되다가 IDLE 상태가 되어 다시 필요 없어졌을 때, Thread를 종료하기까지 대기하는 시간
  • @Async 기본 값

    • corePoolSize : 1
    • maxPoolSize : Integer.MAX_VALUE
    • keepAliveSeconds : 60(second)
    • QueueCapacity : Integer.MAX_VALUE
    • AllowCoreThreadTimeOut : false
    • WaitForTasksToCompleteOnShutdown : false
    • RejectedExecutionHandler : AbortPolicy
  • 변경한 ThreadPool 설정 값

    • corePoolSize : 10
    • maxPoolSize : 20
    • keepAliveSeconds : 60(second)
    • QueueCapacity : 10
    • AllowCoreThreadTimeOut : false
    • WaitForTasksToCompleteOnShutdown : false
    • RejectedExecutionHandler : AbortPolicy

트랜잭션과 이벤트 처리

이벤트를 처리할 때, 주 관심사의 비즈니스 로직이 커밋된 이후 부가적인 코드가 실행되어야 한다. 이를 해결하기 위해 TransactionEventListener를 사용한다. 제공되는 속성 값 중 AFTER_COMMIT을 이용하여 트랜잭션이 성공적으로 COMMIT 된 이후 이벤트 로직을 처리하도록 할 수 있다.

주의해야 할점으로 주 관심사의 트랜잭션이 이미 커밋 되었기 때문에 AFTER_COMMIT 이후에 새로운 트랜잭션을 수행하면 해당 데이터 소스 상에서는 트랜잭션을 커밋을 하지 못한다는 점이다. 따라서 @Transactional어노테이션을 적용한 코드에서 REQUIRES_NEW 옵션을 지정하지 않는다면 이벤트 리스너에서 트랜잭션에 의존한 로직을 실행했을 경우 이 트랜잭션은 커밋되지 않는다.

해당 과정에서는 @Async를 통해 별도의 스레드를 사용하는 방법으로 문제를 해결한다.

결과

image

향후 고려할 점

  • Domain Event(AbstractAggregateRoot)와 Application Event에 대한 Trade Off
  • Async 사용시 Thread Pool과 DB Connection Pool 관리에 고민
  • 이벤트 리스너 로직의 예외처리 및 재처리

[Feature] 예외 발생시 Slack으로 알림을 전송하는 기능을 구현한다.

배경

500 에러 발생시 업무에서 주로 사용하는 Slack, Discord 와 같은 시스템을 통해 예외 알림 메시지를 전송 받는 기능을 구성한다.

구현

사전 지식

[Spring AOP]

  • JoinPoint 인터페이스
    • 클라이언트가 호출한 비즈니스 메서드의 정보를 가져올 수 있는 기능을 제공한다.
    • 해당 구현에서는 Object[] getArgs()를 통해 인자 목록을 Object 배열로 전달받도록 기능을 구현한다.

[Slack Incoming Webhooks]

  • 링크를 통해 설명을 대신한다 -> 링크

구현 방법

해당 구현은 외부 인프라를 사용한다. 이 때문에 예외 메시지를 보내는 메서드를 추상화하여 외부 시스템이 변경되더라도 로직을 수정하지 않도록 설계하는 것을 구현의 목표로 한다.

1. 알림 부가 기능을 수행할 타겟 선정

모든 예외는 GlobalControllerAdvice를 통해 처리된다. 요구사항은 500에러 상황시 예외 알림을 전송시키는 것이기 때문에 500 에러를 핸들링하는 메서드에 AOP를 적용하여 부가기능을 수행하도록 한다.

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> unHandledExceptionHandler(final Exception e) {
    log.error("Not Expected Exception is occurred", e);
    return ResponseEntity.internalServerError().body(new ErrorResponse(COMMON_UNHANDLED.getCode(), COMMON_UNHANDLED.getMessage()));
}

2. 예외 알림 기능을 위한 추상화 작업

알림을 보낼 기능을 추상화 하기 위해 인터페이스를 구현하고 실제 구현할 구현체를 빈으로 등록한다.

public interface AlarmSender {

    void send(final String message);
}

@Component
public class SlackAlarmSender implements AlarmSender {

    @Override
    public void send(final String message) {
        // ...
    }
}

3. 알림 부가 기능 적용 (AOP)

부가 기능 적용을 위해 커스텀 애노테이션을 활용하여 포인트 컷을 등록한다.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface SlackLogger {

}

이후 위의 부가기능(알림)을 적용하기 위한 어드바이스를 구현한다.

@RequiredArgsConstructor
@Slf4j
@Aspect
@Component
public class SlackLoggerAspect {

    @Before("@annotation(com.woowacourse.kkogkkog.common.alarm.SlackLogger)")
    public void sendErrorLog(final JoinPoint joinPoint) {
        
    }
}

4. 예외 메시지를 전송하기 위해 Exception Extracting

Exception 객체에서 필요한 부분들을 추출하여 전송할 데이터 객체를 구현한다.

@AllArgsConstructor
@Getter
public class ExceptionAlarmData {

    private final String className;
    private final String methodName;
    private final int lineNumber;
    private final String message;

    public static ExceptionAlarmData create(final Exception exception) {
        StackTraceElement[] stackTrace = exception.getStackTrace();
        String className = stackTrace[0].getClassName();
        String methodName = stackTrace[0].getMethodName();
        int lineNumber = stackTrace[0].getLineNumber();
        String message = exception.getMessage();

        return new ExceptionAlarmData(className, methodName, lineNumber, message);
    }
}

5. 구현한 부가기능을 모두 Aspect에 구현하고, Slack 구현체를 통해 알림을 전송

@RequiredArgsConstructor
@Slf4j
@Aspect
@Component
public class SlackLoggerAspect {

    private final AlarmSender alarmSender;

    @Before("@annotation(com.woowacourse.kkogkkog.common.alarm.SlackLogger)")
    public void sendErrorLog(final JoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();
        if (args.length != 1) {
            log.warn("Slack Logger Failed : Invalid Used");
            return;
        }

        ExceptionAlarmData response = ExceptionAlarmData.create((Exception) args[0]);
        alarmSender.send(MessageGenerator.generate(response));
    }
}

@Component
public class SlackAlarmSender implements AlarmSender {

    private static final String REQUEST_URI = "https://hooks.slack.com/services/";

    @Value("${alarm.slack.exception.hook-url}")
    private String hookUri;

    @Override
    public void send(final String message) {
        WebClient.create(REQUEST_URI)
            .post()
            .uri(hookUri)
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(new MessageRequest(message))
            .retrieve()
            .bodyToMono(Void.class)
            .subscribe();
    }

    @AllArgsConstructor
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    @Getter
    public static class MessageRequest {

        private String text;
    }
}

결과

image

[Feature] 인증된 회원의 정보를 가지는 커스텀 애노테이션을 구현한다.

배경

커스텀 애노테이션을 통해서 토큰을 통해 인증된 회원의 정보를 사용하기 위함

구현

사전 지식

[Spring Argument Resolver]

API 엔드포인트로부터 들어온 데이터를 가공하여 필요한 데이터로 만들어내는 작업이 필요할 때 사용할 수 있음. 요청은 다음 순으로 이루어짐.

1. 사용자의 요청을 DispatcherServlet이 받음
2. DispatcherServlet은 해당 요청에 맞는 URI를 HandlerMapping에서 검색
3. 찾은 Handler을 수행하기 전에, Intercepter를 실행
4. Argument Resolver를 처리

Argument Resolver를 구현하기 위해서는 HandlerMethodArgumentResolver를 구현해야 하며, 총 2개의 메서드에 대한 구현이 필요.

/** 요청받은 메서드의 인자에 원하는 어노테이션이 붙어있는지 확인하고 원하는 어노테이션을 포함하고 있으면 true를 반환 **/

@Override
public boolean supportsParameter(MethodParameter parameter) {
    return parameter.getParameterAnnotation();
}


/** supportsParameter()에서 true를 받은 경우, 즉, 특정 어노테이션이 붙어있는 메서드가 있는 경우 parameter가 원하는 형태로 정보를 바인딩하여 반환하는 메서드 **/

@Override
public Object resolveArgument(final MethodParameter parameter,
                              final ModelAndViewContainer mavContainer,
                              final NativeWebRequest webRequest,
                              final WebDataBinderFactory binderFactory) {
    return authContext.getMemberId();
}

[Spring Interceptor]

Handler의 실행을 가로채어 그 사이에 추가적으로 공통된 로직을 처리하는 작업이 필요할 때 사용할 수 있음.

@Override
public boolean preHandle(final HttpServletRequest request,
                         final HttpServletResponse response,
                         final Object handler) throws Exception {
     if (CorsUtils.isPreFlightRequest(request)) {
        return true;
     }
     return true;
}

토큰의 유효성 검사, 해당 토큰의 Payload를 추출하는 과정을 Spring Interceptor에서 수행함. 추가적으로 RequestScope를 활용하여 Payload로 가져온 값을 할당함. 이를 통해서 Argument Resolver에서는 필요한 객체로만 만들어주는 작업만 수행하도록 구현을 진행

구현 방법

다음 방법을 통해 Interceptor 에서 토큰의 유효성 검사, 토큰의 Payload 추출 작업을 진행. 성공적으로 수행되었으면 true를 반환. RequestScope로 등록한 AuthContext를 활용해 ArgumentResolver에서 값을 최종적으로 원하는 값으로 처리

@RequiredArgsConstructor
@Component
public class AuthInterceptor implements HandlerInterceptor {

    private final TokenExtractor tokenExtractor;
    private final TokenProvider tokenProvider;
    private final AuthContext authContext;

    @Override
    public boolean preHandle(final HttpServletRequest request,
                             final HttpServletResponse response,
                             final Object handler) throws Exception {
        if (CorsUtils.isPreFlightRequest(request)) {
            return true;
        }

        String accessToken = tokenExtractor.extractToken(request.getHeader(HttpHeaders.AUTHORIZATION), "Bearer");
        MemberPayload memberPayLoad = tokenProvider.getPayload(accessToken);

        authContext.setMemberId(memberPayLoad.getMemberId());
        return true;
    }
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.