낙관적 락으로 전화번호 인증 구현하기 - 2

장성호·2024년 3월 28일

[Server]

목록 보기
4/6

문제 상황

지난번 포스팅에서 Jmeter로 실제 테스트 해보니까 "5분 내로 5회까지만 인증 코드 생성"은 성공했는데, 동시성 테스트에서 한 번에 문자가 5번 오는 이슈가 발생했다. 그래서 "인증 코드 생성 이후 1분 동안은 새로운 인증 코드 생성 불가"라는 정책을 추가로 도입할 필요가 생겼다.

근데 두 가지 정책이 충돌나는 지점이 있었다.

위 그림처럼 마지막 5번째 인증 코드 발급을 4분 30초 쯤에 했다면, 5분 30초까지 발급이 제한된다. 그런데 "5분 내로 5회까지만 인증 코드 생성"은 5분 00초에 제한이 끝난다. 이렇게 정책이 충돌나는 시점을 개선해야하니까 고민하다가 다음처럼 정책을 변경하기로 했다.

대부분의 서비스가 이런식으로 사이클을 돌려가며 인증코드를 생성하길래, 사람들도 이런 사용성에 적응되어 있을 것 같아서 정책을 변경했다.

결과

이번에는 Jmeter로만 검증을 했다.

Thread Group 설정

저번처럼 Thread 10개 x 루프 2번으로 20번의 요청을 보냈다.

결과 요약

결과 테이블

해석


동시성 테스트는 오전 11시 11분에 진행해서 아래에 있는 문자 1개만 도착했다.

저번 포스팅에서 테스트 했을 때 문자가 주루룩 오던 현상은 해결되었다. 프론트 쪽에서 버튼이 의도치 않게 여러 번 눌리는 "따닥"이 발생해도, 정확하게 1번 처리할 수 있게 되었다. 해결 방법에 대해서 백엔드 지인들이랑 이야기해보던 도중, 멱등성 API라는 것을 소개받았다. 멱등성이 뭔가요? 라는 토스페이먼츠의 포스팅을 보면 멱등성 API에 대해 자세히 알 수 있다. 되게 신기하면서도 궁금한 점이 생겼다.

  1. "따닥" 문제처럼 동시 요청에 대해서는 멱등성 DB랑 도메인 서버 쪽 둘 다 락을 잡는건지? 아니면 멱등성 DB를 믿고 도메인 서버는 잡지 않는지?
  2. 멱등성 서버랑 도메인 서버를 따로 만들어서 처리하는 거 같은데 DB를 따로 관리하는건지? 같이 쓰는지?

이런 것들이 좀 궁금해졌다. 파면 팔수록 더 뭔가 나오는 개발 세계 🫨

Domain 레이어

저번 포스팅에서는 영속성 레이어까지 다루었다. 이번에는 Entity의 비즈니스 로직만 수정되어서 관련된 내용만 다룬다.

Entity 비즈니스 로직

  1. "인증 코드 생성 이후 1분 동안은 새로운 인증 코드 생성 불가" 적용
  2. 5회에 도달했으면 5분 동안 생성 제한
  3. 5분 이후에는 다시 1번으로 돌아감

위 반영하기 위해서 createCode 함수가 수정되었고 createLimited 함수가 추가되었다.

/**
 * Sms 코드 인증을 담당하는 Entity 입니다.
 */
@MappedSuperclass
public abstract class SmsCode {
    /**
     * 인증 코드 길이입니다.
     */
    public static final int CODE_LENGTH = 6;

    /**
     * 인증 코드가 만료되는 시각입니다.
     */
    public static final int EXPIRED_MINUTES = 5;

    /**
     * {@value REQUEST_LIMIT}에 도달하지 않았을 때 생성 제한 시간입니다.
     */
    public static final int LIMIT_MINUTES_WITHIN_REQUEST_LIMIT = 1;

    /**
     * {@value REQUEST_LIMIT}에 도달했을 때 생성 제한 시간입니다.
     */
    public static final int LIMIT_MINUTES_REACHED_REQUEST_LIMIT = 5;

    /**
     * 인증 코드를 최대로 요청할 수 있는 횟수입니다.
     */
    public static final int REQUEST_LIMIT = 5;

    /**
     * {@value CODE_LENGTH} 길이에 해당되는 10진수 숫자로 이루어진 문자열입니다.
     */
    protected String code;

    /**
     * 인증 코드의 상태입니다.
     */
    protected SmsCodeStatus status;

    /**
     * 인증 코드가 생성된 횟수를 저장합니다.
     */
    protected Integer count;

    /**
     * 인증 코드가 생성된 시각을 저장합니다.
     */
    protected LocalDateTime createdAt;

    /**
     * Sms 코드를 보낸 전화번호입니다. 유일한 값이어야 합니다.
     */
    public abstract String getPhoneNumber();

    /**
     * Sms 인증 코드입니다.
     */
    public String getCode() {
        return code;
    }

    /**
     * Entity 전화번호에 Sms 코드를 보낸 횟수입니다.
     */
    public Integer getCount() {
        return count;
    }

    /**
     * 인증 코드 상태입니다.
     */
    public SmsCodeStatus getStatus() {
        return status;
    }

    /**
     * 문자 메세지를 보낸 시각입니다.
     */
    public LocalDateTime getCreatedAt() {
        return createdAt;
    }

    /**
     * 데이터 수정 시각입니다.
     */
    public abstract LocalDateTime getUpdatedAt();


    /**
     * 인증 코드가 만료되었는지 확인합니다.
     */
    public boolean isExpired() {
        long minutesSinceLastUpdate = Duration.between(getCreatedAt(), LocalDateTime.now()).toMinutes();

        return minutesSinceLastUpdate >= EXPIRED_MINUTES;
    }

    /**
     * 전화번호랑 코드가 맞는지 확인합니다.
     */
    public boolean verifyCode(ValidateSmsCodeCommand command) {
        if (isExpired() || getCount() > REQUEST_LIMIT) {
            return false;
        }

        boolean isValid = Objects.equals(command.phoneNumber(), getPhoneNumber()) && Objects.equals(command.code(), code);

        if (isValid) {
            status = SmsCodeStatus.SUCCESS;
            return true;
        }

        return false;
    }

    /**
     * Entity 전화번호에 Sms 코드를 보냅니다.
     * <p />
     * <p />
     * {@value LIMIT_MINUTES_WITHIN_REQUEST_LIMIT}분마다 문자를 보낼 수 있습니다.
     * <p />
     * {@value EXPIRED_MINUTES}분 내에 {@value REQUEST_LIMIT}번까지 문자를 보낼 수 있습니다.
     * <p>
     * {@value EXPIRED_MINUTES}분이 지난 뒤에 문자를 보내면 1회로 초기화 됩니다.
     *
     * @throws RequestLimitException
     */
    public void createCode() {
        if (createLimited()) {
            throw new RequestLimitException(SmsErrorCode.LIMITED_REQUEST_SMS);
        }

        Integer count = getCount();
        boolean reset = count == null || count == 0 || getCount() >= REQUEST_LIMIT;

        if (reset) {
            this.count = 1;
        } else {
            this.count++;
        }

        status = SmsCodeStatus.REQUESTED;
        code = RandomNumberUtil.random(CODE_LENGTH);
        createdAt = LocalDateTime.now();
    }

    /**
     * 인증 코드가 {@value EXPIRED_MINUTES}분 내로 요청된 것인지 확인합니다.
     */
    public boolean createLimited() {
        LocalDateTime createdAt = getCreatedAt();

        if(createdAt == null) {
            return false;
        }

        long minutesSinceCreate = Duration.between(createdAt, LocalDateTime.now()).toMinutes();

        if(getCount() >= REQUEST_LIMIT) {
            return minutesSinceCreate < LIMIT_MINUTES_REACHED_REQUEST_LIMIT;
        }
        return minutesSinceCreate < LIMIT_MINUTES_WITHIN_REQUEST_LIMIT;
    }
}

검증

정책이 여러 가지라 테스트 코드를 추가했다. 정책이 추가되면서 기존 테스트 코드도 몇가지 수정했다.

책임 분리의 장점

새로운 정책을 추가할 때 Entity 로직만 건드리면 되니까 많이 편했다. 처음에 기능 만들 때는 멀티모듈이랑 헥사고날에 적응이 잘 안돼서 되게 헷갈리고 굳이 도입했나 싶었다가도, 이번에 Entity만 건드려서 빠르게 수정하니까 좀 뿌듯했다. 쉴 새 없이 머리 박았던 시간들

그리고 테스트 코드로 미리 검증하니까 Entity 로직 수정할 때도 큰 부담이 없었다. 로직이 웬만하면 문제 없을거라는 신뢰도도 좀 높아졌다. 스웨거 하나로 계속 테스트 해볼 때는 프론트한테 "스웨거로는 일단 돼!" 이렇게 불안하게 말했었는데ㅋㅋ.. 근데 진짜 테스트 코드 커버리지 80% 이런건 어떻게 달성하는 건지 궁금하다. 진짜 열심히 짜서 70% 간신히 찍는거 보고 80~90%가 경이로웠다 ㄷㄷ

분명 테스트 코드 열심히 짜는데 가면 갈수록 떡락하는 커버리지

외부 서비스 테스트에 대한 고민

이번에 Jmeter로 동시성 테스트 해보면서 외부 서비스를 실제로 연결 지어서 테스트 할 것인지, 아니면 Mocking 할 것인지에 대한 고민이 생겼다. 실제로 연결 지어서 하자니 동시성 테스트나 부하 테스트 할 때 수천~수만 단위 트래픽 부하를 줄텐데, 그게 외부 서비스까지 영향을 끼치면 디도스로 밴 당할 것 같았다. 그렇다고 프로덕션 코드에다가 외부 서비스를 Mocking해서 넣자니... 프로덕션 코드에? 이런 생각이 자꾸 들었다.

옛날에 테스트 코드 공부 할 때 @Profile 어노테이션 활용해서, 상황에 맞게 빈을 주입하는 것을 본 적이 있었다. QA나 Stage 환경이면 Mocking한 외부 서비스로 빈을 생성하고, Prod 환경이면 실제 외부 서비스로 빈을 생성하는 그런 느낌이려나..?

profile
일벌리기 좋아하는 사람

0개의 댓글