이전에 동시성을 공부하며

낙관적 락은 실패 시 롤백 + 재시도로 인한 성능 저하

비관적 락은 동시 처리 성능 저하 및 데드락의 위험

이와 같은 단점이 있었고 이를 해결하기 위해 다른 방법을 찾아보다 분산락이란 것을 알게 되었고

이를 활용해 분산락을 통한 동시성을 관리해보고자 한다

 

[Spring] nGrinder를 활용해 동시성 테스트해보기

개요스터디에서 mini-pay를 주제로 개인 프로젝트를 진행하게 되었다. 중점은 동시성 처리 경험 쌓기..!구현을 어느정도 마치고 nGrinder를 통해 성능 분석 및 동시성에 대해 공부해보려고 한다.nGrind

kkyu99.tistory.com

 

 

분산락의 원리

분산락이란 여러 서버가 공유 데이터를 제어하기 위한 기술

락을 획득한 프로세스 또는 스레드만이 공유자원에 접근할 수 있도록 하는 것

분산락의 장점으로는 분산된 서버 환경에서도 프로세스들의 원자적 연산을 가능하게 해준다

 

 

분산락 구현 방법

- Zookeeper :
분산 서버 관리시스템으로 분산 서비스 내 설정 등을 공유해주는 시스템. 
추가적인 인프라 구성이 필요하고 성능 튜닝을 위한 러닝커브가 존재. 
- MySQL :
MySQL은 추가적인 인프라 구성없이  User Level Lock으로 분산락을 직접 구현할 수 있다.
하지만 락을 자동으로 반납할 수 없어 명시적으로 락을 release 시켜야 하며, DB에서 락을 관리하기 때문에 DB에 부담이 존재한다.
락 획득 시도는 스핀락으로 구현해야하기 때문에 WAS에도 부담이 존재한다.

💥스핀락?
대기 중인 스레드가 공유 자원의 상태를 무한 루프를 이용해 확인하는 방식. 무작정 Lock을 획득하기 까지 확인하며 대기하는 방식

- Redis :
Zookeeper와 마찬가지로 별도의 인프라를 구축하고 관리해야하지만, 인메모리 DB로 속도가 빠르며, 싱글스레드 방식으로 동시성 문제가 현저히 적다.캐시 저장소로 활용이 가능하다.(다양한 자료구조 지원)

 

 

이 중 redis 라이브러리를 통해 쉽게 사용가능하기에 redis를 활용해 구현해보고자 한다.

 

 

Lettuce, Redisson

redis를 활용해 분산락을 구현하는 방식으로 많이 사용되는 Lettuce와 Redisson에 대해 알아보자

 

Lettuce 

 

Lettuce는 공식적으로 분산락 기능을 제공하지 않기에, 직접 구현해서 사용해야 한다.

Lettuce의 락 획득 방식은 스핀락(spin lock) 방식으로 구성되어 있다. 이 스핀 락 방식은 계속해서 요청을 보내는 방식으로 인해 redis에 부하가 생길 수 있다는 단점이 있다.

 

Redisson

 

Redisson은 락 획득 시 스핀 락 방식이 아닌 pub/sub 방식으로 구성되어 있다.

pub/sub 방식은 락이 해제될 때, subscribe중인 클라이언트에게 알림을 보내기에 스핀락과 같이 redis에 지속적으로 락 획득 요청을 보내는 과정이 사라지고, 이에 따라 부하가 발생하지 않게 된다.

또한 Redisson은 RLock이라는 락을 위한 인터페이스를 제공한다. 이 인터페이스를 이용하여 비교적 손쉽게 락을 사용할 수 있다.

 

이러한 장점을 토대로 Redisson을 활용해 분산락을 구현해보자

 

구현 과정

 

해당 실습은 window, Spring Boot, Docker의 환경에서 진행되었다. 

 

- Redis 실행

1. redis 이미지 받기

$ docker pull redis

 

2. 도커 컨테이너 실행

$ docker run -p 6379:6379 --name (redis 컨테이너 이름) -d redis:latest 

 

 

- Spring 설정

 

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

application.yml

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379

 

 

RedissonConfig

package org.c4marathon.assignment.redisson.config;

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    private static final String REDISSON_HOST_PREFIX = "redis://";
    @Value(value="${spring.data.redis.host}")
    private String redisHost;
    @Value(value="${spring.data.redis.port}")
    private int redisPort;
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        return Redisson.create(config);
    }

}

 

 

 

RedissonLock

package org.c4marathon.assignment.redisson;


import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonLock {

    String value(); // Lock의 이름 (고유값)
    long waitTime() default 5000L; // Lock획득을 시도하는 최대 시간 (ms)
    long leaseTime() default 2000L; // 락을 획득한 후, 점유하는 최대 시간 (ms)

}

 

RedissonLcokAspect

그냥 Lock이 필요한 로직마다 Lock 획득, 반납 로직을 추가해주어도 괜찮지만

공통 로직이 발생하기에 해당 로직에 aop를 통해 수행할 수 있도록 해주었다!

package org.c4marathon.assignment.redisson.aspect;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.c4marathon.assignment.redisson.RedissonLock;
import org.c4marathon.assignment.redisson.parser.CustomSpringELParser;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

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

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(org.c4marathon.assignment.redisson.RedissonLock)")
    public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RedissonLock annotation = method.getAnnotation(RedissonLock.class);
        String lockKey = method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.value());

        RLock lock = redissonClient.getLock(lockKey);

        try {
            boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
            if (!lockable) {
                log.info("Lock 획득 실패={}", lockKey);
                return false;
            }
            log.info("로직 수행");
            return aopForTransaction.proceed(joinPoint); // 해당로직을 Trasaction으로 설정해주기 위함
        } catch (InterruptedException e) {
            log.info("에러 발생");
            throw e;
        } finally {
            log.info("락 해제");
            lock.unlock();
        }

    }

}

AopForTransaction

package org.c4marathon.assignment.redisson.aspect;

import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Component
public class AopForTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

CustomSpringELParser

package org.c4marathon.assignment.redisson.parser;

import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

public class CustomSpringELParser {

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        SpelExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        return parser.parseExpression(key).getValue(context, Object.class);
    }

}

ConcurrencyServiceImpl

@RedissonLock(value="#id")
public void transfer2(Map<String, String> map, String id) throws RuntimeException{

    long amount = Long.parseLong(map.get("amount"));

    Account sender = accountRepository.findById(map.get("sender"))
              .orElseThrow(() -> new RuntimeException("본인 계좌 없음"));

    if (sender.getBalance() < amount) {
            throw new RuntimeException("잔액 부족");
        }

    Account receiver = accountRepository.findById(map.get("receiver"))
             .orElseThrow(() -> new RuntimeException("상대 계좌 없음"));

    sender.updateBalance(sender.getBalance() - amount);
    receiver.updateBalance(receiver.getBalance() + amount);
}

 

동시성 테스트

해당 테스트는 이전과 동일하게 ngrinder를 활용해 16번 계좌에서 17번 계좌로 1원씩 100번 송금을 하는 것으로 했다.

 

- 첫번째 시도

(테스트 사진은 캡쳐를 못했다)

그라인더를 통해 요청을 100번 보냈는데 실패한 경우가 발생하였다.

서버쪽 로그를 확인하니 다음과 같은 오류가 발생했다.

 

락이 없는 스레드가 unlock 시도하여 에러 발생한 것이다.

생각에는 leaseTime 옵션이 획득한 락을 최대로 점유할 수 있는 시간인데 로직이 끝나기 전 leaseTime에 도달하고 lock을 반환하고 다시 aop 마지막 부분에서 unlock을 하며 생기는 문제로 생각했다.

 

처음 설정한 2초 정도면 충분할 것이라고 생각하나, 현재 내가 사용하는 노트북의 성능이 매우 안좋아서 그런 것 같다.

package org.c4marathon.assignment.redisson;


import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RedissonLock {

    String value(); // Lock의 이름 (고유값)
    long waitTime() default 10000L; // Lock획득을 시도하는 최대 시간 (ms)
    long leaseTime() default 100000L; // 락을 획득한 후, 점유하는 최대 시간 (ms)

}

다음과 같이 많이 늘려보았다.

 

-두번째 시도

테스트 전 상태

 

테스트 후

동시성 처리가 잘 된 것 같다.

 

++) 

RedissonLcokAspect

package org.c4marathon.assignment.redisson.aspect;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.c4marathon.assignment.redisson.RedissonLock;
import org.c4marathon.assignment.redisson.parser.CustomSpringELParser;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;

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

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction;

    @Around("@annotation(org.c4marathon.assignment.redisson.RedissonLock)")
    public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RedissonLock annotation = method.getAnnotation(RedissonLock.class);
        String lockKey = method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.value());

        RLock lock = redissonClient.getLock(lockKey);

        try {
            boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
            if (!lockable) {
                log.info("Lock 획득 실패={}", lockKey);
                return false;
            }
            log.info("로직 수행");
            return joinPoint.proceed();
            //return aopForTransaction.proceed(joinPoint); // 해당로직을 Trasaction으로 설정해주기 위함
        } catch (InterruptedException e) {
            log.info("에러 발생");
            throw e;
        } finally {
            log.info("락 해제");
            lock.unlock();
        }

    }

}

 

처음에 aop를 구성할 때 앞선 내용과 같이 joinPoint.proceed()를 따로 빼 @Transactional로 만들어주지 않고 그대로

일반 AOP와 같이 joinPoint.proceed()를 사용해 구현했더니, 실제 테스트에서 DB에 update가 반영되지 않았다.

 

그 이유를 스터디원들과 공부하는 과정에서 @Transactional이 붙은 클래스는 Proxy 객체가 만들어지고, 이를 통해 트랜잭션이 처리된다는 것을 알게 되었다(@Transactional 역시 AOP를 통해 구현되어 있기에...). 

 

처음했던 방식에선 joinPoint.proceed()를 통해 호출되는 메서드는 Proxy 객체로 만들어진 클래스에 포함되어 있기에, Proxy 객체를 통해 호출해주어야 정상 작동할 수 있다. 그렇지 않으면 내가 경험한 것과 같이 DB에 적용되지 않는다.

 

자세한 내용은 아래에서 확인 해보자

 

프록시로 동작하는 @Transactional의 사용시 주의할 점

이 포스팅은 2024.06.27에 작성되었습니다. (평소보다 조금 긴)서론 (좀 길 수도 있으니, 개념만 필요하신 분은 아래로 스킵해주세요!) 이 부분을 너무 이해하고 싶어서 AOP 프록시를 공부하고 왔다.

velog.io

 

 

 

마무리하며...

개인 프로젝트를 처음 해보았다.

3주 정도간 진행한 프로젝트인 것 같은데

요구사항을 보며 생각할 것도 많았고 처음 고민해보는 동시성에 대해서도 공부했어야 했다.

아직은 간단하게만 구현해보았지만 진행하며 마주한 오류들을 보며 실제 서비스에 있어서 동시성 처리는 상당히 중요할 것 같다는 생각을 했다.

추가적으로, 노트북 성능이 너무 안좋아 nGrinder를 위해 docker를 사용하는 도중 docker가 내려가는 현상을 너무 많이 마주하였고 그때마다 노트북을 껐다 키기를 반복하여 작업 능률이 많이 떨어진 것 같다. 또한 성능이 안좋으니 사실상 tps에 관해서 lock 구현 방식에 따라 얼마나 큰차이가 나는지 눈을 보지 못한 것이 아쉽다.

기회가 된다면 aws를 통해 테스트 환경을 구성한다면 더 좋지 않을까 라는 생각을 했다.

(노트북 바꾸는 것이 먼저일듯,,)

 

참고

 

[Redis] Redisson 분산 락을 간단하게 적용해보기

문제 상황 어떤 데이터에 대해 매우 빠르게 수정이 일어날 때 동시성 문제가 발생할 수 있다. 예를 들어 A라는 데이터를 수정하는 로직이 0.1초 소요되는데, 0.001초 간격으로 A라는 데이터를 수정

innovation123.tistory.com

 

풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson

어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.

helloworld.kurly.com

https://velog.io/@a01021039107/%EB%B6%84%EC%82%B0%EB%9D%BD%EC%9C%BC%EB%A1%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EB%8A%94-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C%EC%9D%B4%EB%A1%A0%ED%8E%B8

'Spring' 카테고리의 다른 글

[Spring] nGrinder를 활용해 동시성 테스트해보기  (3) 2024.06.20

개요

스터디에서 mini-pay를 주제로 개인 프로젝트를 진행하게 되었다. 중점은 동시성 처리 경험 쌓기..!

구현을 어느정도 마치고 nGrinder를 통해 성능 분석 및 동시성에 대해 공부해보려고 한다.

nGrinder

nGrinder는 네이버에서 제공하는  오픈소스 성능 테스트 솔루션이다. 스크립트 생성, 테스트 실행, 모니터링 및 결과 확인을 통합된 UI를 통해 사용할 수 있게 해준다.

 

nGrinder는 많은 성능 테스트 도구 중 하나일 뿐이지 다른 도구들로  JMeter, Gatling 등이 있다.

다양한 선택지들 중에서 선택해서 사용하자.

 

 

nGrinder 구조

 

- Controller의 역할은  Agent에 부하 발생 명령을 통해 테스트 할 수 있도록 해주는 웹 애플리케이션 서버이다. 성능 테스트를 위해서 UI를 제공하고 테스트 구조를 설정할 수 있도록 하며, 실행 중인 테스트를 모니터링하거나, 테스트 결과를 수집해서 시각화해 주는 역할을 한다.

 

- Agent는 부하를 발생시키는 서버이다. 프로세스와 스레드 수를 조정하여 vUser(가상 사용자)를 생성하고, vUser는 Controller에서 작성한 테스트 스크립트에 따라 동작하여 Target Server에 요청을 보내게 된다. 이를 통해 성능을 측정하게 된다.

 

- Target  Server는 테스트하고자 하는 대상 서버이다.  nGrinder Monitor를 설치하면 테스트 중 Target Server에서 발생한 오류들 혹은 실시간 CPU, Memory 상태 등 조금 더 다양한 정보를 확인 할 수 있다.

 

이들이 하나의 서버에 존재한다면 서버는 자원을 나누어 사용하게 되고, 그만큼 Context Switching 이 발생하는 등 테스트에 있어서 불필요한 자원이 소모되기에 정확한 성능을 도출하기 어려워지기에 다른 서버를 두는 것을 권장

보통 Controller 서버를 하나 두고, Agent 서버는 부하를 많이 생성하기 위해 (vUser를 늘리기 위해) 추가적으로 여러 대로 설치할 수 있다.

 

nGrinder 설치 및 설정

nGrinder DockerHub

해당 페이지에서 수동으로 로컬환경에 설치하여 사용하는 방법도 있지만, 나는 docker를 활용하여 설치하고 사용해보고자 한다.

 

Docker Controller 설치 및 실행

docker pull ngrinder/controller

controller 실행하기
default port 번호는 80이다

docker run -d -v ~/ngrinder-controller:/opt/ngrinder-controller --name controller -p 80:80 -p 16001:16001 -p 12000-12009:12000-12009 ngrinder/controller

 다른 port를 사용하고 싶다면 아래와 같이 바꿔주면 된다 ex) 7070

docker run -d -v ~/ngrinder-controller:/opt/ngrinder-controller --name controller -p 7070:80 -p 16001:16001 -p 12000-12009:12000-12009 ngrinder/controller

Controller 웹 접근 및 Agent 실행

웹서버 실행 후 localhost:port 에 접속하면 아래와 같은 화면을 볼 수 있다.

  • 첫 아이디와 패스워드는 모두 admin이다.

Docker를 통한 Agent 실행

docker pull ngrinder/agent

 

 

controller 서버 주소에 설정해준 값을 토대로 실행해주기

docker run -d -v ~/ngrinder-agent:/opt/ngrinder-agent --name agent ngrinder/agent 192.168.0.3:7070

 

계정 -> 에이전트 관리에 들어가면 실행 중인 에이전트 확인 가능하다

 

Script & Test 만들기

스크립트 메뉴에 들어가 새로 만들기를 누르면 아래와 같은 창을 볼 수 있다.

테스트 url은 Target Server에서 테스트 할 도메인을 적어주면 된다(안에 들어가서도 바꿀 수 있긴 해)

넘어간 다음 창에서 다음과 같이 스크립트를 구성할 수 있다.

검증 버튼을 통해 스크립트가 잘 작성된지 확인하고 저장하고 넘어가자.

import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

	public static GTest test
	public static HTTPRequest request
	public static Map<String, String> headers = [:]
	public static Map<String, Object> params = ["amount":"1","sender":"16","receiver":"17"]
	public static List<Cookie> cookies = []

	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		test = new GTest(1, "192.168.0.3")
		request = new HTTPRequest()
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		test.record(this, "test")
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}

	@Before
	public void before() {
		request.setHeaders(headers)
		CookieManager.addCookies(cookies)
		grinder.logger.info("before. init headers and cookies")
	}

	@Test
	public void test() {
		HTTPResponse response = request.GET("http://192.168.0.3/con/send", params)

		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
}

 

 

test 생성하기

성능 테스트에 들어가 새 테스트를 만들자. 그러면 아래와 같은 화면을 볼 수 있다.

프로세스 수와 쓰레드 수를 지정해주고, 스크립트에 아까 작성한 파일로 골라 설정해주자

이로써 준비는 끝났고 저장 후 시작을 누르면 테스트가 실행된다.

 

 

 

동시성 테스트

1. 아무 처리도 하지않고 확인해보기

아래는 테스트를 통해 확인 할 로직이다.

package org.c4marathon.assignment.concurrency.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.c4marathon.assignment.accounts.entity.Account;
import org.c4marathon.assignment.accounts.repository.AccountRepository;
import org.c4marathon.assignment.transfer.entity.Transfer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Map;

@Service
@RequiredArgsConstructor
@Slf4j
public class ConcurrencyServiceImpl {

    private final AccountRepository accountRepository;

    @Transactional
    public void transfer(Map<String, String> map) throws RuntimeException{
        
        long amount = Long.parseLong(map.get("amount"));
//        Account sender = accountRepository.findByAccountWithLock(map.get("sender"))
//                .orElseThrow(() -> new RuntimeException("본인 계좌 없음"));
        Account sender = accountRepository.findById(map.get("sender"))
                .orElseThrow(() -> new RuntimeException("본인 계좌 없음"));

        if (sender.getBalance() < amount) {
            throw new RuntimeException("잔액 부족");
        }

//        Account receiver = accountRepository.findByAccountWithLock(map.get("receiver"))
//                .orElseThrow(() -> new RuntimeException("상대 계좌 없음"));
        Account receiver = accountRepository.findById(map.get("receiver"))
                .orElseThrow(() -> new RuntimeException("상대 계좌 없음"));

        sender.updateBalance(sender.getBalance() - amount);
        receiver.updateBalance(receiver.getBalance() + amount);
    }


}

 

계좌번호 16 -> 17로 1만큼의 돈을 송금하는 요청을 300번 보내도록 설정하였다. 

실행 전 DB에 저장 된 값이다. 300번 요청하기에 216914 -> 21661가 되어야 정상적으로 작동하는 것이다.

하지만 아무런 처리를 하지 않았기에 문제가 발생해야 정상적이다.

오류없이 테스트는 잘 진행 되었다
300이 줄어 614가 되어야하는데 2만 줄어든 모습

생각과 동일하게 300만큼 줄어야하는 것에 반해 2만 줄었다.

 

 

2. Serializable

가장 강력한 isolation level이기에 아무런 문제가 발생하지 않아야 정상이다.

@Transactional 어노테이션에 아래와 같이 격리수준을 설정해주고 진행해보자.

package org.c4marathon.assignment.concurrency.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.c4marathon.assignment.accounts.entity.Account;
import org.c4marathon.assignment.accounts.repository.AccountRepository;
import org.c4marathon.assignment.transfer.entity.Transfer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

import java.util.Map;

@Service
@RequiredArgsConstructor
@Slf4j
public class ConcurrencyServiceImpl {

    private final AccountRepository accountRepository;

    @Transactional(isolation = Isolation.SERIALIZABLE)
    public void transfer(Map<String, String> map) throws RuntimeException{
        
        long amount = Long.parseLong(map.get("amount"));
//        Account sender = accountRepository.findByAccountWithLock(map.get("sender"))
//                .orElseThrow(() -> new RuntimeException("본인 계좌 없음"));
        Account sender = accountRepository.findById(map.get("sender"))
                .orElseThrow(() -> new RuntimeException("본인 계좌 없음"));

        if (sender.getBalance() < amount) {
            throw new RuntimeException("잔액 부족");
        }

//        Account receiver = accountRepository.findByAccountWithLock(map.get("receiver"))
//                .orElseThrow(() -> new RuntimeException("상대 계좌 없음"));
        Account receiver = accountRepository.findById(map.get("receiver"))
                .orElseThrow(() -> new RuntimeException("상대 계좌 없음"));

        sender.updateBalance(sender.getBalance() - amount);
        receiver.updateBalance(receiver.getBalance() + amount);
    }


}

 

테스트 수행 전

(노트북이 안좋아서 그런지 자꾸 docker가 내려가 가상유저를 100개로 낮추고 진행했다ㅜ)

정상적으로 실행되기를 기대했지만 에러가 87개나 났다

서버쪽 로그를 확인해보니 

데드락이 발생했다

이 경우에서 데드락은 왜 발생하는 걸까

 

DeadLock

왜 데드락이 발생할까?

Serializable은 그 트랜잭션 안에서 조회되는 모든 row에 대해서 s-lock을 획득하고 읽어온다. 그리고 그 row를 수정할 땐 x-lock을 획득하고 수정한다. 그리고 s-lock은 여러 트랜잭션이 동시에 획득할 수 있고, 다른 트랜잭션이 s-lock을 획득해 놓은 상태에선 x-lock을 획득하지 못하고 대기해야 한다. 그럼 두 트랜잭션이 동시에 한 row를 읽어오고 수정하려 한다면..?

 

이런 플로우를 타게 된다. A트랜잭션과 B트랜잭션이 서로 잡아놓은 s-lock 때문에 무한대기가 된다. 보통 데드락은 여러 자원에 락을 걸 경우 발생하는 것이 보편적인데, Serializable같은 경우는 하나의 자원에 락을 걸어도 s-lock과 x-lock이라는 특성때문에 데드락이 발생한다.

[참고 : https://alexander96.tistory.com/55?category=1065003]

 

위와 같이 Serializable을 통해 동시성을 잡을려고 하는 것은 너무 위험한 것 같다...

3. 비관적 락(Pessimistic Lock)

무엇인지 궁금하다면 아래를 확인하자

 

[Database] 낙관적 Lock & 비관적 Lock

왜 필요할까?여러개의 트랜잭션이 같은 데이터에 접근하여 동시에 읽고 변경하는 과정이 일어나게 되면서여러 문제점이 발생할 수 있다. 그러한 문제들은 아래에서 확인하자.이를 해결하기 위

kkyu99.tistory.com

package org.c4marathon.assignment.concurrency.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.c4marathon.assignment.accounts.entity.Account;
import org.c4marathon.assignment.accounts.repository.AccountRepository;
import org.c4marathon.assignment.transfer.entity.Transfer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

import java.util.Map;

@Service
@RequiredArgsConstructor
@Slf4j
public class ConcurrencyServiceImpl {

    private final AccountRepository accountRepository;

    @Transactional
    public void transfer(Map<String, String> map) throws RuntimeException{
        
        long amount = Long.parseLong(map.get("amount"));
        Account sender = accountRepository.findByAccountWithLock(map.get("sender"))
                .orElseThrow(() -> new RuntimeException("본인 계좌 없음"));
//        Account sender = accountRepository.findById(map.get("sender"))
//                .orElseThrow(() -> new RuntimeException("본인 계좌 없음"));

        if (sender.getBalance() < amount) {
            throw new RuntimeException("잔액 부족");
        }

        Account receiver = accountRepository.findByAccountWithLock(map.get("receiver"))
                .orElseThrow(() -> new RuntimeException("상대 계좌 없음"));
//        Account receiver = accountRepository.findById(map.get("receiver"))
//                .orElseThrow(() -> new RuntimeException("상대 계좌 없음"));

        sender.updateBalance(sender.getBalance() - amount);
        receiver.updateBalance(receiver.getBalance() + amount);
    }


}
package org.c4marathon.assignment.accounts.repository;

import jakarta.persistence.LockModeType;
import org.c4marathon.assignment.accounts.entity.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account,String> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(value="SELECT a FROM Account a where a.account = :account")
    Optional<Account> findByAccountWithLock(@Param("account") String account);


}

이어서 진행하였기에 898 -> 798로 정상적으로 작동되는 것을 확인할 수 있었다.

Throughput과 연관된 것은 TPS (transcations per second)다

TPS (transcations per second)는 초당 수용 가능한 트랜잭션으로 serializable의 경우에 비해 많이 좋아졌다.

 

 

 

그럼 비관적 Lock만 있으면 되는거 아닐까?

비관적 Lock을 많이 사용하게 되면 동시성이 보장될지라도 처리량이 떨어지게 된다.

로직을 파악하며 Lock을 아예 쓰지 않을지, 낙관적 Lock을 사용할지, 비관적  Lock을 사용할지 판단할 수 있는 능력이 중요해보인다. 

 

'Spring' 카테고리의 다른 글

[Spring] Redisson을 활용한 분산락 구현하기  (0) 2024.06.27

+ Recent posts