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

장성호·2024년 3월 27일

[Server]

목록 보기
3/6
post-thumbnail

문제 상황

아래와 같은 전화번호 인증을 구현해야하는데 문득 이런 생각이 들었다. 만약에 전화번호 인증을 동시에 요청하면 어떻게 하지?

Your Image

img 태그 크기 조절해도 이미지가 안 줄어들어요

이런 동시 상황에서 적용할 수 있는 정책은 대략적으로 다음과 같다.

  1. 동시에 오는 요청을 모두 승인하고 각 요청마다 인증 코드를 발급한다.
  2. 1개의 요청만 승인하고 나머지 요청은 전부 처리하지 않는다.
  3. 모든 요청을 처리하지 않는다.

일단 2번 정책과 JPA의 낙관적 락을 사용해서 "5분 동안 5회만 전화번호 인증 코드 발급" 기능을 구현하기로 했다. 2번 정책을 사용한 이유는 다음과 같다.

  1. 동시 요청이 왔을 때 현재 얼마나 많이 요청했는지를 저장하는 count를 제대로 업데이트 하기 위해서
  2. 네이버 SENS API라는 외부 SMS 서비스를 사용하다보니, 불필요한 건당 요청 요금을 줄이기 위해서
  3. 모든 요청을 처리하지 않으면 사용성이 떨어져서

이렇다보니 락이 필요한데 인증 코드 발급 요청 기능은 동시에 진행돼서 충돌날 경우가 매우 드물다고 생각하기 때문이었다. 처음에는 그냥 서버에서 Reentrantlock 쓸까 고민했다가, k8s에 배포한거 보면 서버가 n대 일텐데 비관락 or 낙관락이 맞겠다고 생각이 바뀌었다.

아무튼 이제 만들러 가보자.

결과 먼저 보고 가실게요

스웨거 테스트

  1. IntelliJ에서 디버그 중단점을 활용해 인증 코드를 생성하는 라인에서 잠시 멈추도록 한다.
  2. 2개 스웨거 탭에서 한 전화번호에 대해서 인증코드 발급 요청 API를 호출한다.

인증 코드 문자메세지

Swagger 동시 요청 1 - 성공

Swagger 동시 요청 2 - 실패

IntelliJ 디버그 중단점

동시 요청 2에 대한 Jpa 에러 로그

Caused by: org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.homfo.user.entity.JpaUserSmsCode#XXX-XXXX-XXXX]

Jmeter 동시성 테스트

일단 로컬에서 테스트를 했다. 부하테스트보다는 동시성 상황에 잘 대비가 되어있는지를 확인하고 싶었다.

Thread Group 설정

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

결과 요약

결과 테이블

해석

Image 1 Image 2

가로로 이미지 나열했는데 왜 세로로 되는거에요

Jmeter 돌려보니까 문자가 연달아서 주루룩 요청됐다. 동시성 상황에서 5분 동안 5회 요청 제한은 달성했는데, 이렇게 한 번에 문자가 오는거 보니까 1분 동안 1회 요청 제한 같은 기능을 추가해야할 것 같다. 1편으로 끝낼려했는데 2편으로 찾아뵙겠습니다

DB 트랜잭션이랑 외부 서비스 네트워크 요청을 같이 트랜잭션에 잡을 것인가? 에 대한 고민이 있었다. 분리하기로 결정했는데 Jmeter로 응답시간 확인해보니까 분리하는게 맞는 것 같다.

  1. 요청 성공한 건 네이버 SENS SMS API 요청 로직이 실행돼서, 응답이 돌아오는데 평균 약 731.6ms가 필요하다.
  2. 요청 실패한건 DB 1회 접근 이후 낙관적 락에 실패한거라 응답이 매우 빠르다.
  3. 지금 전부 로컬에 서버+DB를 띄운 상태라, 10~30ms로 응답이 매우 빠른 상태다.

사실상 1번에서 평균 시간이 네이버 서비스와 연결하는 동안 소요되는 시간이라 봐도 무방하다. 전화번호 인증 기능이 아니더라도 저 시간 동안 DB 트랜잭션을 잡고 있으면 병목이 될 여지가 다분해보인다.

Domain 레이어

전화번호 인증에 대한 비즈니스 로직 자체는 사용자나 관리자 둘 다 똑같아서, domain-common 모듈에 구현하면 되겠다고 판단했다. 따라서 구현할 로직은 사용자 및 관리자에 동일하게 적용된다.

Entity 비즈니스 로직

모노레포-멀티모듈-헥사고날 형태로 프로젝트를 구성해서 개발하고 있다. 따라서 도메인 모듈에다가 먼저 "5분 동안 5회만 전화번호 인증 코드 발급"에 대한 비즈니스 로직을 작성해보자. 핵심으로 구현해야할 것은 2개이다.

  1. 새로운 인증 코드 생성
    • 5분 동안 5회 제한 정책에 따라 새로운 인증 코드 생성이 불가능한지?
    • 5분 내로 새로운 인증 코드를 생성할 때마다 생성 횟수 체크하기
  2. 발급된 인증 코드랑 실제 인증 요청의 내용이 서로 맞는지?
    • 내용은 맞더라도 이미 만료된 인증 코드인지?
/**
 * Sms 코드 인증을 담당하는 Entity 입니다.
 */
@MappedSuperclass
public abstract class SmsCode {
    /**
     * 인증 코드 길이입니다.
     */
    public static final int CODE_LENGTH = 6;

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

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

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

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

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

    /**
     * {@value EXPIRED_MINUTES} 분 내로 첫번째 요청됐던 시각을 저장합니다.
     */
    protected LocalDateTime firstCreatedAt;

    /**
     * 인증 코드가 생성된 시각을 저장합니다.
     */
    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;
    }

    /**
     * {@value EXPIRED_MINUTES}분 내로 첫번째 문자 메세지를 보낸 시각입니다.
     */
    public LocalDateTime getFirstCreatedAt() {
        return firstCreatedAt;
    }

    /**
     * 문자 메세지를 보낸 시각입니다.
     */
    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>
     * {@value EXPIRED_MINUTES}분 내에 {@value REQUEST_LIMIT}번을 더이상 문자를 보낼 수 없습니다.
     * {@value EXPIRED_MINUTES}분이 지난 뒤에 문자를 보내면 1회로 초기화 됩니다.
     *
     * @throws RequestLimitException
     */
    public void createCode() {
        boolean limit = !isCreateTimeLimitExpired() && getCount() >= REQUEST_LIMIT;

        if (limit) {
            throw new RequestLimitException(SmsErrorCode.LIMITED_SEND_SMS);
        }

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

        if (getCount() == 0 || isCreateTimeLimitExpired()) {
            count = 1;
            firstCreatedAt = LocalDateTime.now();
        } else {
            count++;
        }
    }

    /**
     * {@value EXPIRED_MINUTES}분 내 {@value REQUEST_LIMIT}회 생성 제한 정책이 만료되었는지 확인합니다.
     */
    public boolean isCreateTimeLimitExpired() {
        long minutesSinceFirstCreate = Duration.between(getFirstCreatedAt(), LocalDateTime.now()).toMinutes();

        return minutesSinceFirstCreate >= EXPIRED_MINUTES;
    }
}

순수 도메인 객체로 SmsCode 추상 클래스를 풀어내는 과정에서 변수가 몇 개 필요했다. DB랑 상관없이 풀어내고 싶어서 그랬던건데, 이렇게 하니까 JPA에서 컬럼을 못 찾는 이슈가 생겼다. @MappedSuperclass 어노테이션을 붙이면 해결된다는데, 도메인 모듈이 JPA에 의존한다는게 좀 마음에 걸린다.

만약에 MongoDB로 구현체를 바꾸면 저 어노테이션이 문제가 될까 싶어서 좀 찾아봤는데, 저 어노테이션은 JPA에서만 사용하는 것 같아서 일단 사용 중이다. 다른 분의 포스팅도 봤는데, 저 어노테이션을 사용한채 MongoDB를 써도 문제가 없는 것으로 보아 괜찮은 것 같다. 나중에 한 번 꼭 체크해봐야겠다. 순수 도메인 객체는 아직 많이 어렵다ㅠㅠ

Service 로직

헥사고날 아키텍처로 모듈을 나눠서 개발하고 있어서, Inbound에 해당하는 Usecase Interface랑 Outbound에 해당하는 Port Interface를 활용해야한다. 이 사이를 매핑시켜줄 Service 클래스로 인증 메세지 요청이랑 인증 코드 확인 로직을 구현한다.

@Service
@Slf4j
public class ValidateUserService implements ValidateSmsCodeUsecase, RequestSmsCodeUsecase {
    private final ManageSmsCodePort manageSmsCodePort;

    private final SendSmsPort sendSmsPort;

    @Autowired
    public ValidateUserService(
            @Qualifier("userSmsCodePersistenceAdapter") ManageSmsCodePort manageSmsCodePort,
            SendSmsPort sendSmsPort
    ) {
        this.manageSmsCodePort = manageSmsCodePort;
        this.sendSmsPort = sendSmsPort;
    }

	@Override
    public boolean validateSmsCode(ValidateSmsCodeCommand command) {
        return manageSmsCodePort.verifySmsCode(command);
    }

    @Override
    public boolean requestSmsCode(String phoneNumber) {
        Pattern pattern = Pattern.compile(User.PHONE_NUMBER_REGEXP);

        if (!pattern.matcher(phoneNumber).matches()) {
            throw new BadRequestException(CommonErrorCode.BAD_REQUEST);
        }

        SmsCodeTransactionDto smsCodeTransactionDto = manageSmsCodePort.createSmsCode(phoneNumber);
        String message = "[홈포] 인증번호: " + smsCodeTransactionDto.after().code() + "\n타인 유출로 인한 피해 주의";
        SmsSendDto smsSendDto = new SmsSendDto(smsCodeTransactionDto.phoneNumber(), message);

        try {
            sendSmsPort.sendSms(smsSendDto);
            return true;
        } catch (ThirdPartyUnavailableException e) {
            log.warn("SendSmsPort error " + e);
            manageSmsCodePort.rollbackSmsCode(smsCodeTransactionDto);
            throw e;
        }
    }
}

requestSmsCode 메소드에서 아래에 해당하는 부분은 서로 다른 Outbound에 해당하는 요청이다.

SmsCodeTransactionDto smsCodeTransactionDto = manageSmsCodePort.createSmsCode(phoneNumber);

sendSmsPort.sendSms(smsSendDto);

그래서 하나의 트랜잭션으로 묶으면 각 Outbound가 자기 자신의 트랜잭션이 아닌 다른 Outbound의 트랜잭션 때문에 병목이 생길거라고 생각했다. 근데 이걸 분리시키자니 롤백을 구현해야하는데, @Transactional 어노테이션이 그리워졌다. Spring이 평소에 정말 개발 편하게 해주는구나 싶었다 🥲

검증

비즈니스 로직에서 나타날 수 있는 경우의 수를 테스트 코드로 검증했다. 만들다보니까 로직이 생각외로 잘못된 경우가 종종 있어서, 나올 수 있는 경우의 수를 최대한 다 검증하도록 빡세게 작성했다.

Outbound 레이어

전화번호 인증 로직 자체는 공통이어도 사용자 및 관리자의 인증 코드가 저장되는 물리적 위치는 달라야 된다고 생각했다. 그래서 outbound-user-jpa, outbound-employee-jpa 모듈 각각에 나눠서 Entity랑 Adpater를 따로 만들어줬다. 만약 물리적 위치가 똑같으면 다음과 같은 문제가 생긴다.

  1. 사용자 앱에서 999-9999-9999 전화번호로 인증 코드 발급
  2. 관리자 웹페이지 접속해서 999-9999-9999로 계정 정보 입력한 뒤, 1번에서 발급받은 인증 코드를 입력하면 인증 성공
  3. 한 테이블에서 UserType으로 구분하자니, PK가 전화번호여서 Duplicate Key 에러 발생

지금보니 PK를 INT auto increment로 하고 Unique를 (UserType, PhoneNumber)로 잡는 방법도 있긴 하겠다. 이 방법에 대해선 요즘 고민이 많다. 테이블을 분리하는게 맞나, 아니면 type이라는 컬럼을 두고 한 테이블에서 쓰는게 맞나...

Jpa Entity

사용자의 인증 코드를 담당하는 Entity로 DB 롤백까지 구현한다. DB 구현체니까 낙관적 락을 위한 @Version까지 사용했다.

@Table(name = "USER_SMS_CODES")
@Entity
@NoArgsConstructor
@Getter
public class JpaUserSmsCode extends SmsCode {
    @Id
    private String phoneNumber;

    @Version
    private Long version;

    @UpdateTimestamp
    private LocalDateTime updatedAt;

    public JpaUserSmsCode(String phoneNumber) {
        this.phoneNumber = phoneNumber;
        this.count = 0;
        this.status = SmsCodeStatus.REQUESTED;
    }

    public void rollback(SmsCodeTransactionDto smsCodeTransactionDto) {
        boolean isValid = Objects.equals(this.phoneNumber, smsCodeTransactionDto.phoneNumber()) &&
                Objects.equals(this.code, smsCodeTransactionDto.after().code()) &&
                Objects.equals(this.status, smsCodeTransactionDto.after().status()) &&
                Objects.equals(this.firstCreatedAt, smsCodeTransactionDto.after().firstCreatedAt()) &&
                Objects.equals(this.createdAt, smsCodeTransactionDto.after().createdAt());

        if(isValid) {
            this.code = smsCodeTransactionDto.before().code();
            this.status = smsCodeTransactionDto.before().status();
            this.firstCreatedAt = smsCodeTransactionDto.before().firstCreatedAt();
            this.createdAt = smsCodeTransactionDto.before().createdAt();
            return;
        }

        throw new IllegalArgumentException();
    }
}

Adapter

ManageSmsCodePort를 구현하는 클래스이다. 아까 Service에서 트랜잭션을 분리하면서 create랑 rollback을 따로따로 직접 구현해야했다. @Transactional 그립읍니다...

@RequiredArgsConstructor
@Repository
public class UserSmsCodePersistenceAdapter implements ManageSmsCodePort {
    private final UserSmsCodeRepository repository;

    @Override
    @Transactional
    public SmsCodeTransactionDto createSmsCode(@NonNull String phoneNumber) {
        SmsCodeDto before;
        SmsCodeDto after;
        JpaUserSmsCode smsCode = repository.findById(phoneNumber).orElse(new JpaUserSmsCode(phoneNumber));

        before = new SmsCodeDto(smsCode.getPhoneNumber(), smsCode.getCode(), smsCode.getStatus(),  smsCode.getFirstCreatedAt(), smsCode.getCreatedAt());

        try {
            smsCode.createCode();

            after = new SmsCodeDto(smsCode.getPhoneNumber(), smsCode.getCode(), smsCode.getStatus(), smsCode.getFirstCreatedAt(), smsCode.getCreatedAt());

            repository.save(smsCode);
        } catch (ObjectOptimisticLockingFailureException e) {
            throw new DuplicateRequestException(SmsErrorCode.DUPLICATE_REQUEST_SMS);
        }

        return new SmsCodeTransactionDto(smsCode.getPhoneNumber(), before, after);
    }

    @Override
    @Transactional
    public boolean verifySmsCode(@NonNull ValidateSmsCodeCommand command) {
        JpaUserSmsCode smsCode = repository.findByPhoneNumberAndCode(command.phoneNumber(), command.code()).orElseThrow(() -> new ResourceNotFoundException(SmsErrorCode.NOT_EXIST_SMS));
        boolean isValid = smsCode.verifyCode(command);

        if (isValid) {
            repository.save(smsCode);
            return true;
        }

        return false;
    }

    @Override
    @Transactional
    public SmsCodeDto rollbackSmsCode(@NonNull SmsCodeTransactionDto smsCodeTransactionDto) {
        JpaUserSmsCode smsCode = repository.findById(smsCodeTransactionDto.phoneNumber()).orElseThrow(() -> new ResourceNotFoundException(SmsErrorCode.NOT_EXIST_SMS));

        smsCode.rollback(smsCodeTransactionDto);

        repository.save(smsCode);

        return new SmsCodeDto(smsCode.getPhoneNumber(), smsCode.getCode(), smsCode.getStatus(), smsCode.getFirstCreatedAt(),  smsCode.getCreatedAt());
    }
}

생성할 때 낙관적 락이 실패하면 발생하는 ObjectOptimisticLockingFailureException를 잡아내서, Conflict 에러로 명시적으로 변경했다. 한편 롤백을 서로 다른 트랜잭션으로 직접 구현하려니까 전/후 정보를 전부 알아야했다. 그래서 전/후 정보를 가지는 SmsCodeTransactionDto를 정의해서 사용했다. 일단 롤백 시도는 1회만 하게 해놨는데, 롤백을 여러 번 시도해야하나 그것도 고민 중이다.

네이버 SENS에서 SMS를 전송하는 API이다. SendSmsPort를 구현하여 받은 문자 내용을 그대로 네이버에 전송해달라고 요청하는 역할을 담당한다. 네이버 serviceId에 :가 포함되어있는데, 이거 때문에 자꾸 Uri 쪽에서 문자열 해석이 잘못되어가지구 죽는 줄 알았다. 문자열 더하기 연산으로 하면 잘만 되는데, 파라미터 형식으로 넣는다거나 그러면 자꾸 인코딩되어서 요청 URI가 이상해지는 문제가 터졌었다.

@Service
@Slf4j
public class NaverSmsClient implements SendSmsPort {
    private String accessKey;

    private String secretKey;

    private String serviceId;

    private String senderPhone;

    private static final String NCP_TIMESTAMP_HEADER = "x-ncp-apigw-timestamp";

    private static final String NCP_ACCESS_KEY_HEADER = "x-ncp-iam-access-key";

    private static final String NCP_SIGNATURE_HEADER = "x-ncp-apigw-signature-v2";

    private final WebClient webClient;

    @Autowired
    public NaverSmsClient(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder.baseUrl("https://sens.apigw.ntruss.com/sms/v2/services").build();
    }


    public void sendSms(@NonNull SmsSendDto smsSendDto) {
        try {
            NaverSmsResponse response = sendSmsToNcp(smsSendDto);

            if (!Objects.equals(response.getStatusCode(), "202")) {
                throw new ThirdPartyUnavailableException(SmsErrorCode.FAILED_SEND_SMS);
            }
        } catch (Exception e) {
            log.warn("NaverSmsClient sendSms error " + e);
            throw new ThirdPartyUnavailableException(SmsErrorCode.FAILED_SEND_SMS);
        }
    }

    private NaverSmsResponse sendSmsToNcp(SmsSendDto smsSendDto) throws JsonProcessingException, RestClientException, InvalidKeyException, NoSuchAlgorithmException, URISyntaxException {
        HttpHeaders headers = getNcpHttpHeaders();
        List<NaverSmsPayload> messages = new ArrayList<>();
        NaverSmsPayload payload = new NaverSmsPayload(smsSendDto.phoneNumber().replaceAll("-", ""), "", smsSendDto.message());
        messages.add(payload);

        NaverSmsRequest request = new NaverSmsRequest(
                "SMS",
                "COMM",
                "82",
                senderPhone,
                smsSendDto.message(),
                messages
        );
        ObjectMapper objectMapper = new ObjectMapper();
        String body = objectMapper.writeValueAsString(request);

        return webClient.post()
                .uri(uriBuilder -> uriBuilder.path("/"+ serviceId + "/messages").build())
                .headers(httpHeaders -> httpHeaders.addAll(headers))
                .bodyValue(body)
                .retrieve()
                .bodyToMono(NaverSmsResponse.class)
                .block();
    }

    private String makeSignature(Long time) throws NoSuchAlgorithmException, InvalidKeyException {
        String space = " ";
        String newLine = "\n";
        String method = "POST";
        String url = "/sms/v2/services/" + this.serviceId + "/messages";
        String timestamp = time.toString();
        String message = method +
                space +
                url +
                newLine +
                timestamp +
                newLine +
                accessKey;

        SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(signingKey);

        byte[] rawHmac = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));

        return Base64.encodeBase64String(rawHmac);
    }

    private HttpHeaders getNcpHttpHeaders() throws NoSuchAlgorithmException, InvalidKeyException {
        Long time = System.currentTimeMillis();
        HttpHeaders headers = new HttpHeaders();

        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set(NCP_TIMESTAMP_HEADER, time.toString());
        headers.set(NCP_ACCESS_KEY_HEADER, accessKey);
        headers.set(NCP_SIGNATURE_HEADER, makeSignature(time));

        return headers;
    }
}

네이버랑 통신 도중 실패하거나 아님 다른 에러가 발생하면 ThirdPartyUnavailableException로 전환해, 외부 서비스에서의 장애가 발생했다고 에러를 변경시켜 클라이언트에게 보내줄 수 있도록 했다. 어디서 봤던건데 외부 서비스의 장애를 명시하지 않으면, 해당 장애가 내부 서비스의 장애라고 생각해 브랜드 이미지에 타격을 준다는 내용이었다. 이런 것도 세심하게 신경쓰면서 개발해야겠다.

검증

아직 테스트코드 개발 진행 중

기본기를 챙기자

이번에 처음으로 로그인 & 회원가입 기능에서 동시성을 고려해봤다. 솔직히 일어날까 싶다가도 Flutter 개발하면서 일명 "따닥"이 종종 있었던거 생각해보면, 생각보다 있을 법한 일인 것 같다. JPA에서 지원되는 기능이 워낙 많아서 쉬울거라 생각했는데, 직접 해보니까 만만치 않다는 것을 깨달았다. 일단 JPA를 떠나서 "외부 서비스를 향한 네트워크 I/O랑 DB I/O를 같은 트랜잭션으로 묶는게 충분히 가치가 있는 일인지?" 이런 것부터 고민이 많았다. 결론은 분리했고 그에 따라 롤백을 수동으로 구현하는 과정까지 넓게 고민해야됐었다.

같이 백엔드 준비하는 지인들과 토론도 해보고 자료도 많이 이래저래 찾아봤는데, 입장이 서로 다른게 이해가 가서 참 고민이 많았다. 내가 내린 결론은 상황마다 다르겠지만 최대한 분리하는 것이 맞다고 결론지었다. 외부 서비스 장애로 타임아웃까지 기다리는 상황이라면, DB 트랜잭션이 너무 지연되는 상황이 발생하기 때문이다.

여러 프로젝트 하다보면 전화번호, 이메일 인증 같은 건 거의 필수 기능이라 기본기에 가깝다. 그동안 잘 됐어서 별생각 없었는데 이렇게 개발하고 나니까, 기본기부터 제대로 만들 줄 알아야겠다고 뼈저리게 느꼈다ㅋㅋ.. Jmeter한테 컷당함 1분 동안 1회 요청 제한 같은 기능을 추가 개발해서 2편으로 찾아와야겠다.

백엔드 어려워요 CRUD부터 제대로 합시다. 예?!

Reference

Spring Boot - MongoDB - Inheritance
[프로젝트3] 3. 관계형 DB에 맞춰 작성된 코드를 NoSQL에 알맞게 변경한다.

profile
일벌리기 좋아하는 사람

0개의 댓글