Here - 익명 커뮤니티 플랫폼
익명 커뮤니티 앱은 사용자들이 자유롭게 의견을 나눌 수 있는 공간을 제공하지만, 이러한 익명성이 오히려 악용될 가능성도 존재한다.
잘못된 방향으로 사용될 경우 커뮤니티가 의도치 않게 부정적인 영향을 받을 수 있기 때문에, 이를 예방하고 건강한 커뮤니티 문화를 유지하기 위해 여러 기술적 장치를 도입을 했다.
이메일 OAuth2 등 기본적인 계정 생성은 계정차단을 회피 할 수 있기 때문에 핸드폰 인증시스템을 도입하게 되었다.
Simple & Easy Notification Service
가격
가격은 다른 플랫폼에 비해 비교적 싼 편이다.
월 50건을 제공하고 건당 9원의 가격이 과금된다.
준비사항
발신 번호 ( 전화 , 개인 핸드폰 )
듀얼넘버 X ( 안됌 ) 해봄...
결국 인터넷 전화를 개통하였다. 월 4400원 !!
https://www.ncloud.com/product/applicationService/sens
위 페이지에 들어가 이용신청을 눌러준다.
Service 탭에 들어가 프로젝트를 생성해준다.
서비스 -> SMS 를 선택해 주었다
SMS 를 찾고 Calling Number 탭에 들어가서 발신번호 등록 버튼을 누르면
신청 정보에 필요한 서류를 넣어 주어야 한다.
핸드폰 / 전화 - 개통 증명원을 발급받아서 서류를 첨부해 주면된다.
NCP 가 좋은 이유는 서비스 장애나 기타 여러 문의들의 답장이 매우매우 빠르다.
발신번호가 승인이 나면 코드를 구현해 준다.
코드 구현
//build.gradle
// WebFlux
implementation("org.springframework.boot:spring-boot-starter-webflux")
// Redis
implementation("org.springframework.boot:spring-boot-starter-data-redis")
외부에 요청을 위해 Webflux 를 추가하고 , 인증번호를 저장해줄 Redis 를 추가한다
//application.properties
#Naver SMS
NAVER_SMS_ID=ncp:sms:kr:서비스ID
NAVER_SMS_PHONE_NUMBER=발신번호
NAVER_ACCESS_KEY=ncp_iam_
NAVER_SECRET_KEY=ncp_iam_
application.yaml , application.properties 파일에 위 설정들을 넣어준다
서비스ID
프로젝트탭에 들어가서 생선한 프로젝트를 누르게 되면 아래에 서비스ID라는 항목을 복사해서 넣어주면 된다.
엑세스키 / 시크릿키
오른쪽 상단에 있는 계정을 누르고 -> 계정관리 -> 인증키 관리 탭에 들어가면 생성할 수 있다.
https://www.ncloud.com/mypage/manage/authkey
생성한 인증키는 레디스에 만료시간과 함께 저장하여 관리 한다.
** 중요
요청폼에 맞게 작성된 JSON 으로 요청을 해야 한다.
Service
@Service
@Transactional
class SmsCertificationService(
private val redisTemplate: RedisTemplate<String, String>,
private val authService: AuthService,
@Value("\${NAVER_ACCESS_KEY}")
private val accessKey: String? = null,
@Value("\${NAVER_SECRET_KEY}")
private val secretKey: String? = null,
@Value("\${NAVER_SMS_ID}")
private val serviceId: String? = null,
@Value("\${NAVER_SMS_PHONE_NUMBER}")
private val senderNumber: String? = null,
){
@Value("\${spring.profiles.active:local}")
private lateinit var profile: String
companion object {
const val VERIFICATION_PREFIX = "sms:"
const val VERIFICATION_TIME_LIMIT = 3L
}
fun sendVerificationMessage(to: String): SmsCertificationResponse {
val smsURL = "https://sens.apigw.ntruss.com/sms/v2/services/$serviceId/messages"
val verificationCode = generateVerificationCode()
val timeLimit = Duration.of(VERIFICATION_TIME_LIMIT, ChronoUnit.MINUTES)
val message = if (profile == "prd") {
handleRealSms(
to = to, code = verificationCode,
url = smsURL, timeLimit = timeLimit
)
} else {
handleFakeSms(to = to, code = verificationCode, timeLimit = timeLimit)
}
return SmsCertificationResponse(message = message, code = verificationCode)
}
private fun handleFakeSms(to: String, code: String, timeLimit: Duration): String {
val message = generateMessageWithCode(code)
println("Fake 메시지: $message")
redisTemplate.opsForValue().set("${VERIFICATION_PREFIX}$to", code, timeLimit)
return message
}
private fun handleRealSms(
to: String, code: String, url: String, timeLimit: Duration
): String {
return try {
val messageDto = SmsCodeValidateRequestDto(
to = to,
message = generateMessageWithCode(code)
)
val requestDto = createSmsRequestDto(messageDto)
val time = System.currentTimeMillis().toString()
val webClient = WebClient.builder()
.baseUrl(url)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("x-ncp-apigw-timestamp", time)
.defaultHeader("x-ncp-iam-access-key", accessKey)
.defaultHeader("x-ncp-apigw-signature-v2", makeSignature(time))
.build()
webClient.post()
.bodyValue(requestDto)
.retrieve()
.bodyToMono(SmsResponse::class.java)
.block()
redisTemplate.opsForValue().set("${VERIFICATION_PREFIX}$to", code, timeLimit)
"메시지 전송 성공"
} catch (e: Exception) {
e.printStackTrace()
throw InvalidRequestException("메시지 전송 실패")
}
}
private fun createSmsRequestDto(messageDto: SmsCodeValidateRequestDto): NaverSmsRequestDto {
return NaverSmsRequestDto(
type = "SMS",
contentType = "COMM",
countryCode = "82",
from = senderNumber,
request = messageDto
)
}
private fun generateVerificationCode(): String =
(111111..999999).random().toString()
private fun generateMessageWithCode(code: String): String = """
Here 인증번호 입니다.
[$code] 를 입력해주세요 :)
""".trimIndent()
private fun makeSignature(currentTime: String): String {
val space = " "
val newLine = "\n"
val method = "POST"
val url = "/sms/v2/services/${this.serviceId}/messages"
val accessKey = this.accessKey
val secretKey = this.secretKey
return try {
val message = "$method$space$url$newLine$currentTime$newLine$accessKey"
val signingKey = SecretKeySpec(secretKey!!.toByteArray(Charsets.UTF_8), "HmacSHA256")
val mac = Mac.getInstance("HmacSHA256")
mac.init(signingKey)
val rawHmac = mac.doFinal(message.toByteArray(Charsets.UTF_8))
Base64.getEncoder().encodeToString(rawHmac)
} catch (e: Exception) {
throw InvalidRequestException("서명 생성 실패")
}
}
fun verifyCode(req: SmsCodeValidateRequestDto): AuthDefaultResp {
val key = "${VERIFICATION_PREFIX}${req.to}"
val storedCode = redisTemplate.opsForValue().get(key)
?: throw DataException("인증이 만료된 코드입니다.")
if (storedCode != req.message) {
throw InvalidRequestException("인증번호가 일치하지 않습니다.")
}
redisTemplate.opsForValue().getAndDelete(key)
val loginRequestDto = LoginRequestDto(
phoneNumber = req.to,
channel = AuthType.LOCAL
)
return authService.login(loginRequestDto)
}
}
fun sendVerificationMessage(to: String): SmsCertificationResponse {
val smsURL = "https://sens.apigw.ntruss.com/sms/v2/services/$serviceId/messages"
val verificationCode = generateVerificationCode()
val timeLimit = Duration.of(VERIFICATION_TIME_LIMIT, ChronoUnit.MINUTES)
val message = if (profile == "prd") {
handleRealSms(
to = to, code = verificationCode,
url = smsURL, timeLimit = timeLimit
)
} else {
handleFakeSms(to = to, code = verificationCode, timeLimit = timeLimit)
}
return SmsCertificationResponse(message = message, code = verificationCode)
}
here 에서는 test / dev / prd 채널을 운영하고 있다
메세지 발신요청은 건당 과금이 되므로 dev 나 test 환경에서는 발신이 되지 않게 지정해주어야 한다
profile 을 받아와 현재 어떤 채널이 활성화 되어있는지 확인하여 실제 메세지를 보낼지 가상의 메세지를 만들어 보낼지 선택한다.
class SmsCodeValidateRequestDto(
var to: String, //수신자 전화번호
var message: String //전송할 메세지 본문
){
init {
validatePhoneNumber(to) //수신번호 유효성 검사
}
}
주의 !! 필드명을 바꾸지 말자
메세지 생성 / 처리
private fun handleRealSms(
to: String, code: String, url: String, timeLimit: Duration
): String {
return try {
val messageDto = SmsCodeValidateRequestDto(
to = to,
message = generateMessageWithCode(code)
)
val requestDto = createSmsRequestDto(messageDto)
val time = System.currentTimeMillis().toString()
val webClient = WebClient.builder()
.baseUrl(url)
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("x-ncp-apigw-timestamp", time)
.defaultHeader("x-ncp-iam-access-key", accessKey)
.defaultHeader("x-ncp-apigw-signature-v2", makeSignature(time))
.build()
webClient.post()
.bodyValue(requestDto)
.retrieve()
.bodyToMono(SmsResponse::class.java)
.block()
redisTemplate.opsForValue().set("${VERIFICATION_PREFIX}$to", code, timeLimit)
"메시지 전송 성공"
} catch (e: Exception) {
e.printStackTrace()
throw InvalidRequestException("메시지 전송 실패")
}
}
WebClient 로 Post 요청을 한다
아래 공식문서를 보면 더 자세한 설명들을 볼 수 있다.
주의 해야 할 사항은 이 글 마지막에 적어두었다.
https://api.ncloud-docs.com/docs/ai-application-service-sens-smsv2
오류노트
중요하게 봐야 할 사항은 아래와 같다 - 맞지 않으면 400 에러 BadRequest 발생
- 헤더의 TimeStamp 와 Signature 를 만들때 넣는 시간이 같아야 한다.
- Base64 로 인코딩된 값인지 확인
- 요청 필드값이 같은지 확인
val smsRequest = SmsRequest(
type = "SMS",
contentType = "COMM",
countryCode = "82",
from = "01012345678",
content = "테스트 메시지입니다.",
messages = listOf(
Message(to = "01098765432") // 수신 번호가 올바르게 포함되었는지 확인
)
)
'개-발 > Java + Spring + Kotlin' 카테고리의 다른 글
[Spring] ServletRequest 캐싱 (ContentCachingRequestWrapper) (0) | 2024.12.02 |
---|---|
[Spring] 상대방 채팅 읽음 감지 (1) | 2024.11.17 |
[spring] Cache 조회 성능을 최적화 Redis + Kotlin (0) | 2024.11.14 |
[WebSoket] Spring + SocketJs 사용하기 ( 테스트 Html코드 공유 ) (0) | 2024.11.02 |
[Spring Batch] 반복 오류 제어 (0) | 2024.09.01 |