배경

얼마 전 기획팀에서 쿠폰 기능에 개수 제한 기능을 추가하고 싶다는 요청이 들어왔다. 로직 설계는 심플했다. 

갯수에 대한 상태 프로퍼티를 추가한 뒤 요청이 올 때마다 상태 변경한 뒤 쿠폰을 지급 또는 exception 처리를 하면 되었다. 

비즈니스 로직 설계

하지만 이 경우, 동시성 이슈가 발생해서 간헐적으로 설정한 갯수보다 더 많은 쿠폰을 발급할 가능성이 있었다. 

결국 이 요청사항의 핵심은 동시성 이슈를 해결하는 것이었고 이에 대한 방법을 찾아보고 도입해 보기로 했다. 

 

동시성 이슈를 해결하는 방법

1. Synchronized 키워드 

synchronized 키워드

가장 먼저 떠오른 방법이었다. 멀티스레드 환경에서 동기화 처리를 위해 자바에서 제공하는 키워드로서 synchronized 블록 단위로 Lock을 걸기 때문에 동시성을 보장할 수 있었다. 

하지만, 어노테이션이 적용되는 범위가 메서드이다보니 불필요한 로직들도 lock이 잡히고, 성능상 오버헤드가 심하다고 한다.
또한 단일서버가 아닌 멀티서버 환경에서는 동시성을 보장받지 못하는 점
때문에 이 방법은 적용해보지 않았다.

 

2. Optimistic Lock(낙관적 잠금)

낙관적 잠금의 프로세스 ( https://sabarada.tistory.com/175 블로그 참고)

데이터의 버전을 체크하면서 데이터의 정합성을 유지하는 방법이다. 실제로 lock을 잡는 방식이 아니기 때문에 성능적으로도 좋다고 한다.

하지만, 정합성 체크를 커밋 시점에 하기 때문에 충돌이 발생한 경우 해당 트렌젝션에 대한 롤백 처리가 필요하다.

여러 트렌젝션으로 묶인 로직이라면 롤백 처리가 복잡해질 수 있고 충돌이 자주 발생한다면 롤백으로 인한 오버헤드가 크게 발생할 수 도 있다고 한다. 

쿠폰 발급 처리의 경우 보통 이벤트로 인해 요청이 몰리게 되는 경우가 많다. 그렇기 때문에 해당 방식도 적합하지 않다고 판단하였다.

 

3. Pessimistic Lock(비관적 잠금)

비관적 잠금의 프로세스 ( https://sabarada.tistory.com/175 블로그 참고)

비관적 잠금은 트렌젝션이 시작할때 lock을 거는 방식이다.

배타락(Exclusive Lock), 공유락(Shared Lock) 두 가지 옵션이 있다.

  • 배타락은 하나의 쓰레드에서 lock을 획득했으면 해제되기 전까지 다른 스레드에서 읽기/쓰기가 모두 불가능한 방식.  (쿼리로 표현하면 select for update)
  • 공유락은 하나의 쓰레드에서 lock을 획득했으면 해제되기 전까지 다른 스레드에서 읽기는 가능 쓰기는 lock을 획득해야 가능한 방식.

비관적 잠금은 작업 처리 과정 중 데드락이 발생할 가능성이 있다는 단점이 있고 처리시간이 길 경우 대기시간에 따른 성능이 저하될 수 있다고 한다. 또한, JPA와는 달리 R2DBC에서는 낙관적 락만 지원, pessimistic lock은 지원하지 않았기 때문에 다른 방식을 찾아보았다.
 

4. DB Internal Lock

MySQL internal Lock은 데이터의 동시성 처리를 위해 MySQL 서버에서 제공하는 방법이다. 총 3가지로 제공하고 있다.

  • Row-Level Locking
  • Table-Level Locking
  • User-Level Locking

Row-Level Locking은 Row 수준으로 Locking을 하는 것이고, Table-Level Locking은 개별 테이블 단위로 설정되는 Locking이다.
User-Level Locking은 사용자가 지정한 문자열에 대해 키를 생성하고 해당 키로 잠금을 거는 방식이다.  

보통 서비스에서 db 연동은 해두고 쓰기 때문에 초기구축이나 작업 비용이 적다는 장점이 있었다. 하지만 동시에 여러 프로세스가 접근한다면 lock을 얻기위한 시도로 인해 부하 발생이 가능했고 트랜잭션에 대한 connection pool 관리가 필요했다.

 

5. Redis를 활용한 방법

  • lettuce를 활용한 방식

set when not exists(setnx) 방식으로 cache setting을 통해 lock을 거는 방법이다.

만약 spring framework에서 이미 redis를 사용한다면 라이브러리 추가 없이 사용가능하다.

lettuce는 spring-data-redis 라이브러리에서 제공하는 클라이언트이다. 또한 트랜잭션에 대한 connection pool 관리 필요 없다. 

하지만 스핀락 방식이기 때문에 동시에 많은 스레드가 대기 상태라면 redis에 부하를 줄 수 있다고 한다. 

 

  • redisson을 활용한 방식

Pub-Sub 기반이라서 thread의 재시도에 따른 redis 부하가 발생하지 않는 방식이었다.

별도의 라이브러리를 사용해야 한다는 점이 걸리긴 했지만 생각보다 구현이 간단했다.

DB와 redis의 부하를 신경 쓰지 않으면서 비즈니스 로직에 좀 더 집중할 수 있는 이 방식을 선택해서 서비스에 적용해 보았다.

 

Redisson 도입기

Redisson dependency 설정(gradle)

implementation("org.redisson:redisson:3.16.3")

redission client 객체를 생성을 위한 설정 로직

    fun redissonClient(): RedissonClient {
        val config = Config()
        config.useSingleServer().address = "redis://${redisWriteProperties.host}:${redisWriteProperties.port}"
        return Redisson.create(config)
    }

lock 관리 로직

    suspend fun receiveLimitCoupon(userId: Long, couponId: Long): List<CouponUser?> {
        val context = newSingleThreadContext("couponId")
        return withContext(CoroutineScope(context).coroutineContext) {
            val lock = redissonClient.getLock("limitCoupon")
            val maxRetry = 3
            var initRetry = 0

            while (!lock.tryLock(10, 3, TimeUnit.SECONDS)) {
                if (++initRetry == maxRetry) {
                    logger.info("threadID: ${Thread.currentThread().id} lock 획득 실패 Timeout")
                    return@withContext emptyList()
                }
            }

            try {
                logger.info("threadID: ${Thread.currentThread().id} lock 획득 성공")
                receiveCouponWithCouponId(couponId = couponId, userId = userId)
            } finally {
                lock.unlock()
                logger.info("threadID: ${Thread.currentThread().id} lock 반환 성공")
            }
        }
    }

1. "limitCoupon"이라는 name으로 lock을 생성한다.
2. tryLock method를 이용해서 락 획득을 시도하다. 

3. 락을 획득한 상태에서 비즈니스 로직(쿠폰발급)을 수행한다. 

4. 명시적으로 lock을 해제한다.

 

락을 요청하는 횟수를 정할 수 있다. 소스에서는 총 3번 요청하고 그 사이에 획득을 하지 못한다면 emptyList를 return 하도록 했다.


요청할 때 파라미터를 지정할 수 있다. tryLock method의 파라미터를 살펴보면 첫 번째는 waitTime(락을 기다리는 시간)이다. 두 번째는 leaseTime(획득한 락을 자동으로 해제하는 시간)이다. 세 번째는 시간단위를 나타낸다. 

waitTime동안 lock을 획득하지 못한다면 tryLock 함수는 false를 리턴하게 된다. 참고로 waitTime을 명시하지 않으면 즉시 값을 return 하게 되고, leaseTime이 명시되어 있지 않으면 watchDogTimeout값이라는 것을 이용하는데 default로 30초이다.

 

waitTime과 leaseTime을 적절하게 setting 하는 것은 중요하다.

만약 비즈니스 로직이 leaseTime보다 오래 걸린다면 처리과정 중 lock이 해제되고 해당 락은 다른 thread에 의해 선점될 수 있다. 그리고 락이 해제되었기 때문에 unlock() 메서드를 실행할 때 예외가 발생하게 된다.

 

동시성 테스트

 

1. Test1: 동시성을 고려하지 않은 로직에 coroutine을 이용해서 쿠폰 발급받기

쿠폰 제한 수량은 100개로 등록, 유저 100명의 요청을 10개의 worker로 나눠서 동시에 발급 요청했음. 

    test("case1 동시성 고려하지 않은 쿠폰 발급(10개 thread 동시에 처리)") {
        val headerMap = mutableMapOf<String, String>()
        headerMap["x-balcony-id"] = "BOOMTOON_COM"
        val reactorContext = Context.of(
            "BALCONY_CONTEXT",
            HttpHeaders.readOnlyHttpHeaders(LinkedMultiValueMap<String, String>().apply { setAll(headerMap) })
        ).asCoroutineContext()
        val userIds = List(100) { i -> i + 1 }
        
        withContext(reactorContext) {
            serviceOperator.execute {
                userIds.chunked(10).forEach { userIds ->
                    launch {
                        userIds.forEach {
                            couponRedisService.notConcurrentlyReceiveLimitNormalCoupon(it.toLong(), 96)
                        }
                    }
                }
            }
        }
    }

테스트 결과 로그는 위와 같았다.
해당 값은 발급 전 남은 쿠폰의 개수를 출력한 것이다. 
실제로 DB에 저장된 데이터를 확인해 보면 100명 모두 쿠폰을 발급받았지만 남아있는 쿠폰은 81개였다. 

데이터 불일치 문제가 발생한 것이다.

 

 

2. Test2: redisson을 이용한 로직으로 동시에 쿠폰 발급받기

조건은 test1과 동일했다. 단지 발급로직에 redisson을 이용해서 lock 처리가 추가되었다. 

    test("Redis coupon 100번 동시 호출") {
        val headerMap = mutableMapOf<String, String>()
        headerMap["x-balcony-id"] = "BOOMTOON_COM"
        val reactorContext = Context.of(
            "BALCONY_CONTEXT",
            HttpHeaders.readOnlyHttpHeaders(LinkedMultiValueMap<String, String>().apply { setAll(headerMap) })
        ).asCoroutineContext()
        val userIds = List(100) { i -> i + 1 }
        withContext(reactorContext) {
            serviceOperator.execute {
                userIds.chunked(10).forEach { userIds ->
                    launch {
                        userIds.forEach {
                            couponRedisService.receiveLimitCoupon(it.toLong(), 96)
                        }
                    }
                }
            }
        }
    }

테스트 결과는 다음과 같았다.

2개의 쿠폰이 남았길래 확인해 보니까 2명이 lock try timeout으로 받지 못한 것을 확인할 수 있었다.
하지만, 테스트 결과는 정상이었다. 총 98명이 받았다.
테스트를 로컬환경에서 진행 중이었는데 서버의 성능에 영향을 많이 받는 것을 알 수 있었다.

 

waitTime을 약간 늘려서 다시 테스트해 보았을 땐 정상으로 전부 처리된 것을 볼 수 있었다. 
위에서 언급한 'waitTime과 leaseTime을 적절하게 setting 하는 것은 중요하다.'를 다시 한번 느꼈다.

 

100명의 유저가 80개의 쿠폰을 발급받는 테스트도 진행해 보았다.

테스트 결과, 쿠폰 발급 수는 80개였고 20명은 발급받지 못한 것을 확인할 수 있었다.

 

마무리

한 가지 문제를 해결하는 데는 다양한 솔루션이 있다.

동시성 이슈를 해결하는 방법도 위에서 언급한 것처럼 다양하게 있다. 

어떤 솔루션이 자신의 상황에 가장 적절한지 충분히 고민해 본 다음에 적용하고 테스트해 보는 습관을 들이자.

 

참고자료

https://sabarada.tistory.com/175

https://dealicious-inc.github.io/2021/04/05/distributed-locking.html

https://happy-coding-day.tistory.com/entry/%EB%B9%84%EA%B4%80%EC%A0%81-%EC%9E%A0%EA%B8%88-%EB%82%99%EA%B4%80%EC%A0%81-%EC%9E%A0%EA%B8%88-%EA%B7%B8%EB%9F%B0-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0

기억이란 지나고 나면 휘발되기 때문에 과정을 기록하는 것은 매우 중요하다.
기록은 다양한 기회가, 무대가 나를 찾아오게 한다.

 

이번주에 진행했던 개발자 퍼스널브랜딩 특강 중 기억에 남는 문장이다.

 

모르고 있던 내용이 아니다. 

기록이 중요성은 직, 간접적으로 배웠지만 다양한 이유(게으름, 귀찮음, 시간 없음, 능력 없음)를 핑계로 거의 하지 않았다.

중요성은 알았지만 필요성을 느끼지 못했던 것이 아닐까 싶다.

 

최근 개발자 퍼스널브랜딩 교육을 5주째 듣고 있다. 

단언컨데 본인이 개발자로서 다음 스텝이 막막하다면 매우 추천한다.
이에 대한 내용은 곧 써볼 예정이다. 혹시 너무 궁금하다면 https://brunch.co.kr/@moon-sky/27 구경해보는걸 추천!
벌써 한 회 차 밖에 남지 않았다는 것이 매우 아쉽다.

 

 

개발자를 위한 퍼스널 브랜딩 워크숍

나만의 강점을 찾고, 이를 기반으로 퍼스널 브랜딩을 시작하는 방법 | 개발자들 위해 디자인된 교육 플랫폼 NextStep에서 개발자들의 퍼스널 브랜딩을 위한 강의를 오픈하였습니다. 개발자를 위한

brunch.co.kr

이 과정에서 ‘기록’은 중요하다고 느껴졌던 몇 가지 포인트가 있다.

첫 번째로는 글은 본인을 나타낼 수 있는 가장 쉬운 방법 중 하나인 점이다. 특히, 말로 표현하기를 어려워하는 사람들에게 글은 충분한 시간을 가지고 작성할 수 있으니 스스로를 표현할 수 있는 최선의 방식이라 느껴졌다.

두 번째로는 언제 어디로 갈지 모르는 개발자의 숙명(?)에 도움이 된다는 점이다. 진행했던 업무들의 히스토리를 기억하는 것은 필수이지 않나 싶다. 과거 면접에서 분명히 내가 했던 일이었지만 기억이 나지 않아 얼레벌레 이야기하면서 화끈거렸던 적이 있다. 기록이 있었다면 기억을 떠올리는데 훨씬 도움이 되었을 것이다.

그래서 휘발되는 기억에 의존하기보단 기록으로 남겨둬야겠다고 생각했다.

 

과거에도 다양한 방식으로 글쓰기를 시도했던 적이 있다. 하지만 꾸준함으로 이어지진 못했다. 이유가 뭘까 곰곰이 생각해 봤다.

기록을 어렵게 생각했던 점이 가장 큰 원인이 아니었을까 싶다.

잘못된 것을 쓰면 안 된다는 강박은 글을 쓰는데 허들이 되었고

잘 읽히는, 재밌는 글을 써야 한다는 생각은 고민만 키우고 기록으로 이어지지 않았다.

 

최근에 느낀 것은 이런저런 걱정들은 차치하고 ‘JUST DO IT’ 이 중요하다는 것이다.

고민만 하다가 시도 조차 하지 못한 것보다 가볍게 생각해서 일단 시도하는 것이 더 가치가 있다.

시도를 해야지 잘못된 것을 고칠 수도 있고, 피드백을 통해 나도 발전할 수 있다.

지금 이 글도 기록에 대해 어려워할 때마다 나의 마음을 다시 상기시킬 수 있도록 작성 중이다.

 

마음가짐을 바꾸고 나니 쓰고 싶은 글감이 많아졌다.

나를 브랜딩 할 수 있는 여러 가지 방법 중 올해 처음으로 시도하는 행동으로 기록을 선택했다. 

당장의 넘치는 열정을 금방 소진시키지 않기 위해 일주일에 1회 작성을 목표로 가져가려고 한다. 

 

벌써 다음글을 쓸 생각에 두근두근 거리는 중이다. 

'MyStory > 에세이' 카테고리의 다른 글

2023년! 아직 반이나 남았다.  (0) 2023.07.24
2023년 나의 목표와 계획에 대한 고찰  (0) 2023.03.11

5주 차 강의 내용 정리

  • 단위 테스트
  • 통합 테스트

단위 테스트

 - 특정 단위(테스트 대상)가 의도한 대로 작동하는지 검증

 

단위란? 

 - 사람마다 정의가 다를 수 있음. 작은부분이라는 점에 초점을 맞추면 됨.

 - 단일 메서드에서 전체 클래스까지 될 수 있음.

 

통합과 고립(Sociable and Solitary)

 - 단위 테스트 작성 시 관계를 맺고 있는 대상(협력 객체)이 있는 경우를 고려해야 함.

 - 협력 객체를 실제 객체로 사용할지, Mock 객체로 사용할지에 따라 테스트 구현이 달라짐.

 

단위의 정의를 논하기 앞서 테스트하는 단위가 통합(Sociable)되어야 하는지 고립(Solitary)되어야 하는지 고려해야 .


Test Double

 - 테스트 목적으로 실제 객체 대신 사용되는 모든 종류의 객체를 표현하는 일반 용어.

 - 즉 실제(클래스, 모듈, 매서드)를 가짜 버전으로 대체한다는 의미.

Stubbing

 - 테스트 동안 호출이 되면 미리 지정된 답변으로 응답.

 - 미리 지정된 것 외의 것에 대해서는 응답 하지 않음.

 

Mockito, MockitoExtension, Spring 등을 활용한 Stubbing 방법이 있다.

 

MockitoExtension을 활용한 예시

@ExtendWith(MockitoExtension.class)
public class AuthServiceTest {
    public static final String EMAIL = "email@email.com";
    public static final String PASSWORD = "password";
    public static final int AGE = 10;

    private AuthService authService;

    @Mock
    private MemberRepository memberRepository;
    @Mock
    private JwtTokenProvider jwtTokenProvider;

    @BeforeEach
    void setUp() {
        authService = new AuthService(memberRepository, jwtTokenProvider);
    }

    @Test
    void login() {
        when(memberRepository.findByEmail(anyString())).thenReturn(Optional.of(new Member(EMAIL, PASSWORD, AGE)));
        when(jwtTokenProvider.createToken(anyString())).thenReturn("TOKEN");

        TokenResponse token = authService.login(new TokenRequest(EMAIL, PASSWORD));

        assertThat(token.getAccessToken()).isNotBlank();
    }
}

 

Test Double은 언제 쓰는가?

 - 테스트 대상이 협력 객체를 가질 때 사용한다.

 

실제 객체를 사용하면 협력 객체의 행위를 협력 객체 스스로가 정의

가짜 객체를 사용하면 협력 객체의 행위를 테스트가 정의


가짜 객체 특징

  • 가짜 객체를 사용 할 경우 테스트 대상을 검증할 때 외부 요인(협력 객체)으로 부터 철저히 격리
  • 하지만 테스트가 협력 객체의 상세 구현을 알아야 함

실제 객체 특징

  • 실제 객체를 사용 할 경우 협력 객체의 상세 구현에 대해서 알 필요가 없음
  • 하지만 협력 객체의 정상 동작 여부에 영향을 받음

테스트 코드를 작성 할 때

  • 가짜 객체를 활용하면 실제 객체를 사용할 때 보다 조금 더 편하게 테스트를 작성할 수 있음
  • 하지만 상세 구현에 의존하는 테스트가 될 수 있음

 


추천하는 방법

  • 우선 TDD를 연습할 때 가급적이면 실제 객체를 활용하는 것을 우선으로 진행
  • 테스트 작성이 어렵거나 흐름이 이어지지 않는다면 테스트 더블을 활용하는 방법으로 접근하시는 것을 추천

통합 테스트

 - 우리가 만드는 대부분의 애플리케이션은 데이터베이스, 파일 시스템, 외부 라이브러리 등과 같이 다른 애플리케이션과 통합되어 개발하는 경우가 많음

 - 적은 비용과 빠른 테스트를 위해 단위 테스트를 주로 사용하지만 실제로 상호 작용하는 내용에 대한 검증도 반드시 필요함

 - 예를 들면 데이터베이스와의 통합을 테스트하는 경우 테스트를 실행할 데이터베이스를 실행해야함

 

외부 라이브러리

테스트의 필요성

 - 라이브러리 기능에 대한 검증은 필요 없음.

 - 단, 그 부분을 사용하는 부분에 대한 검증이 필요.

 

변경할 수 없는 코드 검증 시 실제 객체 사용

 - 작동 원리를 깊이 이해하기 어려움

 - Mock 객체와 실제 객체의 행위를 일치시키기 어려움

@Test
void findPath() {
    // graphService에서 활용하는 Jgrpah라이브러리의 객체를 실제 객체로 사용한다.
    List<Long> stationIds = graphService.findPath(
            Lists.list(line1, line2), 
            station3.getId(), 
            station5.getId(), 
            PathType.DISTANCE
    );

    assertThat(stationIds).hasSize(5);
    assertThat(stationIds.get(0)).isEqualTo(3L);
    assertThat(stationIds.get(1)).isEqualTo(2L);
    assertThat(stationIds.get(2)).isEqualTo(1L);
    assertThat(stationIds.get(3)).isEqualTo(4L);
    assertThat(stationIds.get(4)).isEqualTo(5L);
}

데이터 베이스

 - 테스트를 쉽게 하기 위해 메모리 내 H2 데이터베이스에 연결해서 한다.

@DataJdbcTest
public class StationRepositoryTest {
    @Autowired
    private StationRepository stationRepository;

    @Test
    void saveStation() {
        String stationName = "강남역";
        stationRepository.save(new Station(stationName));

        assertThrows(DbActionExecutionException.class, () -> 
            stationRepository.save(new Station(stationName))
        );
    }
}

 

통합 테스트 vs 단위 테스트

  • 통합 테스트는 통합하고 있는 부분을 실제와 가까운 환경에서 검증하여 기능이 정상적으로 여부를 검증
  • 단위 테스트는 통합하고 있는 부분이 정상적으로 동작한다고 가정하고 단일 기능에 대해서만 검증

참고자료

+ Recent posts