본문으로 바로가기

프로젝트의 전체 소스 코드는 이곳에서 확인하실 수 있습니다.

 

이전에 진행했었던 프로젝트에서도 휴대폰 인증을 통한 회원가입 인증번호를 구현했었는데 당시에는 인증번호의 일치 불일치 여부를 프론트에서 처리했었다. 인증번호는 보안과 직결되는 중요한 요소이기 때문에 반드시 서버에서 처리해야 한다.

요구사항

대부분의 프로젝트에서 필수적을 포함되는 기능 중 하나가 바로 회원가입이다.

개발의 회원가입도 개발의 요구 사항에 따라서 구현이 복잡해질 수도 있고, 아주 간단하게 이루어질 수도 있다.

이번 프로젝트에서는 무분별한 회원가입을 방지하고 유령회원을 구분하기 위해 휴대폰 인증 또는 이메일 인증을 통한 회원가입을 진행하고 있다.

 

이렇게 휴대폰이나 이메일 인증을 통해 회원가입을 진행할때 고민해야할 가장 큰 부분은 인증번호 관리라고 생각한다.

인증번호는 어디에서 관리해야 할까?

1. 회원 테이블에서 관리하기

@Entity
public class User {
    @Id
    @GeneratedValue
    @Column(name = "USER_ID")
    private Long id;

    private String certificationNumber;

}

가장 원시적으로 생각해볼 수 있는 방법이다. 회원 테이블에CertificationNumber와 같은 컬럼을 추가하여 인증번호를 관리할 수 있다. 하지만 해당 방법에는 여러가지 문제점들이 존재한다.

인증번호는 회원 인증이 완료되면 더 이상 사용되지 않는다. 또한 대다수의 사이트들은 회원가입 시 입력하는 인증번호에 시간제한을 걸어두어 시간 내에 인증을 하지 못하면 올바른 인증번호를 입력했더라도 다시 발급받아 인증하도록 처리하고 있다. 이러한 점들을 고려해 보았을 때 인증번호를 회원 테이블에 저장하는 방법은 구현 과정도 상당히 복잡할 뿐만 아니라 매우 비효율적이다.

2. 세션에서 관리하기

public void sendSms(String phone) {
      String randomNumber = makeRandomNumber();
      session.setAttribute(phone, randomNumber);
  }

회원가입시 필요한 인증번호를 Session에 저장하여 관리하는 방법이다. 해당 방법도 많은 프로젝트에서 많이 사용되고 있는 방법이다. 회원가입시 생성된 인증번호를 세션에 저장하고, 사용자가 입력한 인증번호와 세션에 등록되어있는 인증번호를 비교하여 인증 여부를 판단할 수 있다.

또한 앞에서 언급한 인증번호의 유효시간도 Session의 time-out 기능을 사용하여 설정할 수 있다.

하지만 session또한 몇 가지 문제점이 존재한다.

세션의 time-out기능을 사용하여 인증번호의 유효시간을 3분으로 설정했다면 세션을 이용한 다른 비즈니스 로직에도 영향을 끼치게 된다. 물론 HttpSession을 여러개 생성하여 관리한다면 해결할 수 있지만 해당 문제 외에도 세션의 Key값이 중복되는 위험성이 존재한다.

3. Redis와 같은 In-memory에서 관리하기

마지막으로 생각해볼 수 있는 방법은 Redis에 인증번호를 저장하여 관리하는 방법이다.

Redis는 기본적으로 Map형태의 key,value값을 지원하며 인증번호의 유효시간 또한 TTL로 간편히 설정할 수 있다. 무엇보다도 Disk에 접근하지 않고 데이터를 바로 처리할 수 있기 때문에 성능적인 부분에서도 이점이 있다.

  1. Controller
@RestController
public class UserApiController {

    //인증번호 발송
    @PostMapping("/sms-certification/sends")
    public ResponseEntity<Void> sendSms(@RequestBody SmsCertificationRequest requestDto) {
        smsCertificationService.sendSms(requestDto.getPhone());
        return CREATED;
    }

    //인증번호 확인
    @PostMapping("/sms-certification/confirms")
    public ResponseEntity<Void> SmsVerification(@RequestBody SmsCertificationRequest requestDto) {
        smsCertificationService.verifySms(requestDto);
        return OK;
    }
 }
  1. Service
@RequiredArgsConstructor
@Service
public class SmsCertificationService {

    private final SmsCertificationDao smsCertificationDao;
    private final AppProperties appProperties;

    // 인증 메세지 내용 생성
    public String makeSmsContent(String certificationNumber) {
        SmsMessageTemplate content = new SmsMessageTemplate();
        return content.builderCertificationContent(certificationNumber);
    }

    public HashMap<String, String> makeParams(String to, String text) {
        HashMap<String, String> params = new HashMap<>();
        params.put("from", appProperties.getCoolSmsFromPhoneNumber());
        params.put("type", SMS_TYPE);
        params.put("app_version", APP_VERSION);
        params.put("to", to);
        params.put("text", text);
        return params;
    }

    // coolSms API를 이용하여 인증번호 발송하고, 발송 정보를 Redis에 저장
    public void sendSms(String phone) {
        Message coolsms = new Message(appProperties.getCoolSmsKey(),
            appProperties.getCoolSmsSecret());
        String randomNumber = makeRandomNumber();
        String content = makeSmsContent(randomNumber);
        HashMap<String, String> params = makeParams(phone, content);

        try {
            JSONObject result = coolsms.send(params);
            if (result.get("success_count").toString().equals("0")) {
                throw new SmsSendFailedException();
            }
        } catch (CoolsmsException exception) {
            exception.printStackTrace();
        }

        smsCertificationDao.createSmsCertification(phone, randomNumber);
    }

    //사용자가 입력한 인증번호가 Redis에 저장된 인증번호와 동일한지 확인
    public void verifySms(SmsCertificationRequest requestDto) {
        if (isVerify(requestDto)) {
            throw new AuthenticationNumberMismatchException("인증번호가 일치하지 않습니다.");
        }
        smsCertificationDao.removeSmsCertification(requestDto.getPhone());
    }

    private boolean isVerify(SmsCertificationRequest requestDto) {
        return !(smsCertificationDao.hasKey(requestDto.getPhone()) &&
            smsCertificationDao.getSmsCertification(requestDto.getPhone())
                .equals(requestDto.getCertificationNumber()));
    }
}
  1. Redis
@RequiredArgsConstructor
@Repository
public class SmsCertificationDao {

    private final String PREFIX = "sms:";  // (1)
    private final int LIMIT_TIME = 3 * 60;  // (2)

    private final StringRedisTemplate stringRedisTemplate;

    public void createSmsCertification(String phone, String certificationNumber) { //(3)
        stringRedisTemplate.opsForValue()
            .set(PREFIX + phone, certificationNumber, Duration.ofSeconds(LIMIT_TIME));
    }

    public String getSmsCertification(String phone) { // (4)
        return stringRedisTemplate.opsForValue().get(PREFIX + phone);
    }

    public void removeSmsCertification(String phone) { // (5)
        stringRedisTemplate.delete(PREFIX + phone);
    }

    public boolean hasKey(String phone) {  //(6)
        return stringRedisTemplate.hasKey(PREFIX + phone);
    }
}

(1) Redis에 저장되는 Key값이 중복되지 않도록 상수 선언

(2) Redis에서 해당 데이터의 유효시간(TTL)을 설정

(3) 사용자가 입력한 휴대폰 번호와 인증번호를 저장하고 TTL을 180초로 설정

(4) Redis에서 휴대폰번호(KEY)에 해당하는 인증번호를 리턴

(5) 인증이 완료되었을 경우 메모리 관리를 위해 Redis에 저장된 인증번호 삭제

(6) Redis에 해당 휴대폰번호(KEY)로 저장된 인증번호(VALUE)가 존재하는지 확인

 

Redis에 저장된 결과 값