[항해 플러스] 1주차 - 1 : TDD를 통해 포인트의 동시성 락 개선기

백동민·2025년 10월 26일

문제

이번 주엔
1. TDD를 활용 하기 (RED-GREEN-REFATOR)
2. 포인트에서의 동시성 락을 어떻게 해결할까?
(non-blocking, single-thread 기반의 nest.js)

TDD와의 첫 만남... (시험 2일 전)

사실 첫만남은 매우 비호감입니다; 중간고사와 겹치는 불상사!어쩔 수 없습니다.
해내는게 저이고 말고요.

일단 TDD의 기본 베이스는 이렇습니다.

Red : 실패하는 테스트 코드를 먼저 작성
Green : 테스트 코드를 성공시키기 위한 실제 코드를 작성
Refactor : 테스트 결과에 맞추어 (커버리지나 중복 코드 및 읽기 쉬운 코드 등) 리팩토링을 수행하는 것

개인적으로 느낀 장점은 이렇습니다.

한 번에 조금씩 커밋하고, 메시지는 Claude로 자동화

이렇게 개발하는 방법은 처음 겪어 봤기에 테스트 코드를 작성을 먼저 해봤습니다.

// 동시에 충전과 사용 요청
            const promises = [
                service.chargePoint(userId, chargeAmount), // +3000
                service.usePoint(userId, useAmount),       // -2000  
                service.chargePoint(userId, chargeAmount)  // +3000
            ];

            const results = await Promise.all(promises);

            // 최종 잔고는 15000 + 3000 - 2000 + 3000 = 19000이어야 함
            const expectedFinalBalance = 
            	initialBalance + chargeAmount - useAmount + chargeAmount;

            // 모든 연산이 순차적으로 적용되었는지 확인
            // 현재는 동시성 제어가 없어서 예상과 다를 것
            const balances = results.map(result => result.point);
            const maxBalance = Math.max(...balances);

            expect(maxBalance).toBe(expectedFinalBalance);
        }, 10000);

이렇게 spec.ts를 기준으로 가설을 세우고, 동시에 충전과 사용 요청을 만들어 봤습니다.


초기값과 최종값에만 의존하는 상태함수 개념 (간단한 아이디어)

그래도 아직 테스트 코드 자체의 문법이 익숙하지 않아 학습할 필요가 있어
숙달해야할 듯 합니다.

사실 제대로 했을까?

현재 테스트는 최종 상태를 보지 않고 중간 응답의 최댓값으로 판정하므로 신뢰할 수 없움

nest.js의 튜토리얼을 참고해보기!

그렇게 커버리지 100퍼 달성은 refactoring 과정에서 달성을 할 수 있었다? 좋은거 맞아?

마치 문제를 정의하고 틀리게 해결하면 무슨 소용일까요.

가치있는 테스트를 위한 전략과 구현

위의 테스트 코드의 전략과 구현의 글을 보고 좋은 테스트 코드는 무엇인지 잘 숙지를 해야한다는 점입니다.

Fast: 테스트는 빠르게 동작하여 자주 돌릴 수 있어야 함
Independent: 각각의 테스트는 독립적이며 서로 의존해서는 안 됨
Repeatable: 어느 환경에서도 반복 가능해야 함
Self-Validating: 테스트는 성공 또는 실패로 bool 값으로 결과를 내어 자체적으로 검증되어야 함
Timely: 테스트는 적시에 즉, 테스트하려는 실제 코드를 구현하기 직전에 구현해야 함

✨ 핵심 테스트 코드를 목적에 맞게 적절히 구현하는 것이 중요


동시성 락, 싸우지마 애들아!

위 그림을 본다면 덮어쓰기가 2번 동작하네??? 어라,,, 그러면 115달러가 아니고 105달러가 최종 답이네...? John과 Alice는 계좌 금액에 대해 동시 접근을 해...

경쟁 상태(race condition)란?

여러 개의 프로세스가 공유 자원에 동시 접근할 때 실행 순서에 따라 결과값이 달라질 수 있는 현상

그렇다면 어떻게 해결하는 것이 좋을까?

누가 먼저 할래? 그럼 줄 서

  • 선택한 접근 방식: Promise 기반 Lock 시스템

왜 이 방식을 선택했는가?

    1. Node.js 싱글 스레드 특성 활용하기
// 전통적인 Mutex 대신 Promise 기반 접근
private readonly locks = new Map<number, Promise<any>>();
Node.js는 싱글 스레드 이벤트 루프 기반
메모리 내 Map을 통한 간단하고 효율적인 구현
별도 라이브러리 의존성 없음
    1. 사용자별 독립적 Lock 관리
async withLock<T>(userId: number, operation: () => Promise<T>): Promise<T> {
    const existingLock = this.locks.get(userId);
    // 사용자별 개별 Lock 처리
}

다음과 같은 플로우 차트를 기반으로 기획을 했었다.

사실 이건 거짓 락에 불과한 건데, 제출하고 나서 스스로 분석도 하고 피드백을 참고해보니까...

  • 1. 허들(Thundering herd)문제 : 이벤트가 발생하면 모두 동시에 깨어나 하나의 자원을 차지하려고 경쟁하는 현상

if (existingLock) await Promise.race([existingLock, timeout]);
const newLock = operation();
this.locks.set(userId, newLock);

여러 호출이 같은 기존 락이 끝나길 기다린 뒤, 동시에 operation()을 시작할 수 있습니다. 마치 오픈런...

  • 2. 해제 제대로 안된다...

    		operation().finally(() => this.locks.delete(userId));
    		```

나보다 나중에 들어온 작업이 this.locks.set(userId, next)로 새 작업(tail)을 세팅해도, 먼저 끝난 작업의 finally가 무조건 delete를 호출하면 현재 진행 중인 끝 부분도 같이 지워진다.

  • 3. 이전 작업 실패 전파

existingLock이 reject 되면 다음 호출도 await에서 같이 터짐
-> 연쇄 실패. (보통 직렬화 큐는 “이전 실패와 무관하게 다음
작업은 진행”이 자연스러움)

  • 4. 공정성/대기열 없음

FIFO 보장 없음. “먼저 도착한 요청이 먼저 실행”이 깨지기 쉽습니다.



가차없는 피드백을 받았습니다,,, 사실 map말고 queue를 썼다면 좋을텐데~


다음 목표

시간에 쫓기지 않고 좀 더 CS기반으로 판단해서 구현을 하는 것을 목적으로!

플로우 차트를 그리면서 고민 또 고민을 했어야했고, 왜 맵을 썼는지 이유를 근거해서 구현을 하면 좋을 것 같아.

내가 맞게 했는지 의심하고 또 의심해라

구현을 하면서 테스트코드 자체가 맞다고 생각하면서 구현을 하니 아무것도 성공할 수 없었습니다. 잘못된 테스트는 잘못된 리팩토링을 불러옵니다

profile
Developer보단 Engineer로 될래

0개의 댓글