본문 바로가기

Spring

비밀번호 없이 회원가입, 로그인 구현 (kotlin + spring boot)

벨로그(https://velog.io/) 서비스를 이용하다 회원가입, 로그인에서 독특한 플로우를 발견했다. 우선 로그인 안 한 상태로 벨로그에 접속하면 우측 상단에 로그인 버튼이 보인다.

 

로그인 버튼을 클릭하면 이런 로그인 모달이 뜬다.

 

이메일을 입력 후 로그인 버튼을 누르면, 벨로그에 가입된 회원인 경우 이메일로 로그인 링크가 발송되며, 가입된 회원이 아닌 경우에는 이메일로 회원가입 링크가 발송된다. (혹은 사용자가 회원가입을 하지 않은 것이 확실하다면 모달 우측 하단의 ‘회원가입’ 텍스트를 틀릭하면 모달창에서 ‘로그인’ 부분이 ‘회원가입’으로 텍스트가 변경된다.)

이메일로 로그인 링크를 받은 경우 링크 클릭 시 로그인한 상태로 벨로그 메인 페이지로 이동한다. 이메일로 회원가입 링크를 받은 경우 링크를 클릭하면 벨로그 회원가입 페이지로 이동한다. 회원가입 시 입력하는 데이터 중 이메일은 이미 내가 입력한 이메일로 수정 불가 상태로 표시되어 있다. 

 

이런 플로우를 사용하면 db에 비밀번호 저장 없이도 회원가입과 로그인을 구현할 수 있겠구나! 라는 생각이 들어서 진행해본 클론 코딩(회원가입, 로그인 플로우만…)

 

 

1. 이메일 전송

yml 파일 설정

spring:
  mail:
    host: smtp.gmail.com
    port: 587
    username: ${emailUsername}
    password: ${emailPassword}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true
          connectiontimeout: 5000
          timeout: 5000
          writetimeout: 5000
    auth-code-expiration-millis: 86400000 # 24 hour
  • password에는 구글 계정에서 앱 비밀번호를 생성 한 뒤 생성되는 16자리 알파벳을 적는다.

의존성

	implementation("org.springframework.boot:spring-boot-starter-mail")
  • 위의 의존성을 추가해야 한다.

JavaMailSender 빈 등록

설정파일에 적어놓은 프로퍼티들을 불러와서 JavaMailSender의 구현체를 빈으로 등록시켜주는 코드를 다음과 같이 작성했다.

@Configuration
@ConfigurationProperties(prefix = "spring.mail")
class EmailProperties {
    var host: String? = null
    var port: Int? = 0
    var username: String? = null
    var password: String? = null
    var properties: Properties = Properties()
    var authCodeExpirationMillis: Long = 0

    class Properties {
        var mail: Mail = Mail()

        class Mail {
            var smtp: Smtp = Smtp()

            class Smtp {
                var auth: Boolean = false
                var starttls: Starttls = Starttls()
                var connectionTimeout: Int = 0
                var timeout: Int = 0
                var writeTimeout: Int = 0

                class Starttls {
                    var enable: Boolean = false
                    var required: Boolean = false
                }
            }
        }
    }

    @Bean
    fun mailSender(): JavaMailSender {
        val mailsender = JavaMailSenderImpl()
        mailsender.host = host
        mailsender.port = port!!
        mailsender.username = username
        mailsender.password = password
        mailsender.defaultEncoding = "UTF-8"
        mailsender.javaMailProperties = getMailProperties()

        return mailsender
    }

    private fun getMailProperties(): java.util.Properties {
        val properties = java.util.Properties()
        properties.put("mail.smtp.auth", this.properties.mail.smtp.auth);
        properties.put("mail.smtp.starttls.enable", this.properties.mail.smtp.starttls.enable);
        properties.put("mail.smtp.starttls.required", this.properties.mail.smtp.starttls.required);
        properties.put("mail.smtp.connectiontimeout", this.properties.mail.smtp.connectionTimeout);
        properties.put("mail.smtp.timeout", this.properties.mail.smtp.timeout);
        properties.put("mail.smtp.writetimeout", this.properties.mail.smtp.writeTimeout);

        return properties
    }
}

메일 보내는 곳 코드는 위에서 스프링 빈으로 등록한 javaMailSender를 이용한다.

@Component
class EmailTool(
    private val javaMailSender: JavaMailSender,
) {
    fun sendEmail(email: String, title: String, text: String) {
        val emailForm = createEmailForm(email, title, text)
        javaMailSender.send(emailForm)
    }

    private fun createEmailForm(
        toEmail: String,
        title: String,
        text: String,
    ): SimpleMailMessage {
        val message = SimpleMailMessage()
        message.setTo(toEmail)
        message.subject = title
        message.text = text

        return message
    }
}

위와 같이 SimpleMailMessage 객체를 생성해서 메일 전송 시, 다음과 같은 텍스트 기반 메시지만 전송할 수 있다.

 

 

MimeMessage 타입을 사용하면 HTML 기반의 이메일을 보낼 수 있다.

💡MIME (Multipurpose Internet Mail Extensions) is an extension of the original Simple Mail Transport Protocol (SMTP) email protocol. It lets users exchange different kinds of data files, including audio, video, images and application programs, over email.

 

 

    fun sendEmail(email: String, title: String, text: String) {
        try {
            val message: MimeMessage = javaMailSender.createMimeMessage()
            val helper = MimeMessageHelper(message, true)
            helper.setTo(email)
            helper.setSubject(title)
            helper.setText(text, true) // true는 html 형식을 의미
            javaMailSender.send(message)
        } catch (e: MessagingException) {
            e.printStackTrace()
        }
    }

 

 

위의 모달에서 내 이메일을 입력 후 [이메일로 로그인/회원가입] 버튼을 누르면 이메일 인증 API가 호출된다. 로직 내부에서는 인증 코드를 생성 후 email을 검사해서 이미 가입된 회원이면 로그인 링크가 포함된 이메일, 가입된 회원이 아니면 회원가입 링크가 포함된 메일을 발송한다. 이메일 인증에 사용하는 코드는 영속화한다. 인증에 사용되는 코드는 생성시간+24시간 후에 만료된다.

 

    override fun sendVerificationEmail(email: String): Boolean {
        // 인증 코드 생성
        val code = EmailUtil.generateCode(emailConstant.CODE_LENGTH)

        val user = userRepository.getByEmail(email)

        if (user != null) {
            // 이미 가입된 회원이면 로그인 링크가 포함된 이메일 발송
            sendLoginEmail(email, code)
        } else {
            // 가입된 회원이 아니면 회원가입 링크가 포함된 이메일 발송
            sendRegistrationEmail(email, code)
        }

        // 영속화
        val newEntity = EmailVerificationEntity().apply {
            this.email = email
            this.code = code
            this.expiredAt = LocalDateTime.now().plus(Duration.ofMillis(emailProperties.authCodeExpirationMillis))
        }
        emailVerificationJpaRepository.save(newEntity)
        return true
    }

2. 회원가입

가입되지 않은 메일을 입력하고 버튼을 누르면 회원가입 메일이 다음과 같이 온다.

 

 

링크를 클릭하면 회원가입 페이지로 연결된다.

 

이때 이메일은 코드를 통해 서버에서 조회하고, 수정 불가 상태로 페이지에 보여진다.

 

회원가입을 성공적으로 진행하면 사용한 코드는 삭제되어, 해당 코드는 다시 사용할 수 없다.

 

3. 로그인

회원가입 완료 한 이메일로 모달에서 로그인 요청 시 다음과 같이 메일이 온다.

 

링크를 클릭하면 로그인 API를 호출하고, 코드로 이메일을 찾아 해당 유저의 jwt 토큰을 발급하여 로그인 처리한다. 로그인에 사용한 코드는 삭제한다.


후기

흥미 위주로 플로우만 가볍게 만들어 보느라 부족한 점이 많다(예외 처리 등). 처음에는 타임리프 템플릿 엔진을 사용하여 클라이언트를 구성했으나 OAuth2를 붙이기 위해 프론트도 분리했다. (리액트나 타입스크립트에 대한 깊이 없이 지피티 활용하여 구성) 이렇게 포스팅하기에는 아직 손봐야 할 부분이 많다 😭

 

비밀번호 없이 회원가입/로그인을 관리하는 방식에 대한 단상.

  • 장점
    • 보안 : 비밀번호 자체를 저장하지 않기 때문에, 데이터가 유출되더라도 사용자 인증 정보가 노출될 위험이 줄어든다.
    • 사용자 편의 : 비밀번호를 기억하거나, 재설정하거나, 강제 변경(비밀번호 분실 시)하는 등의 관리 이슈가 줄어든다. 회원가입 시 별도의 비밀번호 입력이 필요없어서 쉽게 서비스를 시작 가능하다.
  • 단점 
    • 이메일 계정 의존성 : 만약 사용자의 이메일 계정이 탈취당하면 해당 서비스의 계정까지도 탈취당할 수 있다.
    • 기억력 이슈 : 사용자 입장에서 서비스에 내 수많은 이메일 중에 어떤 이메일로 가입했는지 기억이 안 난다면…? 수많은 이메일들을 다 확인해봐야 한다.
    • 번거로움 : 사용자 입장에서, 로그인할 때마다 본인의 이메일을 확인해야 하면 귀찮을 것 같다. 로그인 정책상으로 액세스, 리프레시 토큰의 기한을 적절히 설정하여서 사용성을 개선할 수 있다.

[깃허브 레포지토리]

https://github.com/yeonsuoh/vlog-api

 

GitHub - yeonsuoh/vlog-api

Contribute to yeonsuoh/vlog-api development by creating an account on GitHub.

github.com

 

 

https://github.com/yeonsuoh/vlog-client

 

GitHub - yeonsuoh/vlog-client

Contribute to yeonsuoh/vlog-client development by creating an account on GitHub.

github.com

 


[참고]

https://velog.io/@kyungmin/Spring-이메일-인증Google

 

[Spring] 이메일 인증(Google)

블로그를 찾아보면서 다른 분들이 구글을 많이 이용하셨길래 나도 구글 이메일인증을 사용해보기로 하였다.먼저 구글에 로그인을 진행한다. 1 ) 구글 홈페이지 오른쪽 상단에 본인 프로필 클릭2

velog.io