아래와 같은 전화번호 인증을 구현해야하는데 문득 이런 생각이 들었다. 만약에 전화번호 인증을 동시에 요청하면 어떻게 하지?
img 태그 크기 조절해도 이미지가 안 줄어들어요
이런 동시 상황에서 적용할 수 있는 정책은 대략적으로 다음과 같다.
일단 2번 정책과 JPA의 낙관적 락을 사용해서 "5분 동안 5회만 전화번호 인증 코드 발급" 기능을 구현하기로 했다. 2번 정책을 사용한 이유는 다음과 같다.
이렇다보니 락이 필요한데 인증 코드 발급 요청 기능은 동시에 진행돼서 충돌날 경우가 매우 드물다고 생각하기 때문이었다. 처음에는 그냥 서버에서 Reentrantlock 쓸까 고민했다가, k8s에 배포한거 보면 서버가 n대 일텐데 비관락 or 낙관락이 맞겠다고 생각이 바뀌었다.

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




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]
일단 로컬에서 테스트를 했다. 부하테스트보다는 동시성 상황에 잘 대비가 되어있는지를 확인하고 싶었다.
Thread 10개 x 루프 2번으로 20번의 요청을 보냈다.



가로로 이미지 나열했는데 왜 세로로 되는거에요
Jmeter 돌려보니까 문자가 연달아서 주루룩 요청됐다. 동시성 상황에서 5분 동안 5회 요청 제한은 달성했는데, 이렇게 한 번에 문자가 오는거 보니까 1분 동안 1회 요청 제한 같은 기능을 추가해야할 것 같다. 1편으로 끝낼려했는데 2편으로 찾아뵙겠습니다
DB 트랜잭션이랑 외부 서비스 네트워크 요청을 같이 트랜잭션에 잡을 것인가? 에 대한 고민이 있었다. 분리하기로 결정했는데 Jmeter로 응답시간 확인해보니까 분리하는게 맞는 것 같다.
사실상 1번에서 평균 시간이 네이버 서비스와 연결하는 동안 소요되는 시간이라 봐도 무방하다. 전화번호 인증 기능이 아니더라도 저 시간 동안 DB 트랜잭션을 잡고 있으면 병목이 될 여지가 다분해보인다.
전화번호 인증에 대한 비즈니스 로직 자체는 사용자나 관리자 둘 다 똑같아서, domain-common 모듈에 구현하면 되겠다고 판단했다. 따라서 구현할 로직은 사용자 및 관리자에 동일하게 적용된다.
모노레포-멀티모듈-헥사고날 형태로 프로젝트를 구성해서 개발하고 있다. 따라서 도메인 모듈에다가 먼저 "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를 써도 문제가 없는 것으로 보아 괜찮은 것 같다. 나중에 한 번 꼭 체크해봐야겠다. 순수 도메인 객체는 아직 많이 어렵다ㅠㅠ
헥사고날 아키텍처로 모듈을 나눠서 개발하고 있어서, 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-user-jpa, outbound-employee-jpa 모듈 각각에 나눠서 Entity랑 Adpater를 따로 만들어줬다. 만약 물리적 위치가 똑같으면 다음과 같은 문제가 생긴다.
지금보니 PK를 INT auto increment로 하고 Unique를 (UserType, PhoneNumber)로 잡는 방법도 있긴 하겠다. 이 방법에 대해선 요즘 고민이 많다. 테이블을 분리하는게 맞나, 아니면 type이라는 컬럼을 두고 한 테이블에서 쓰는게 맞나...
사용자의 인증 코드를 담당하는 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();
}
}
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부터 제대로 합시다. 예?!
Spring Boot - MongoDB - Inheritance
[프로젝트3] 3. 관계형 DB에 맞춰 작성된 코드를 NoSQL에 알맞게 변경한다.