CoolSMS, Redis, React(Next.js)
End-to-End

ํ์ฌ ์งํํ๋ ์๋น์ค์์ ์์ ํ์๊ฐ์ (์นด์นด์ค)๊ณผ ์ผ๋ฐ ํ์๊ฐ์ ์ ๋ชจ๋ ์ง์ํ๋ค๋ณด๋, ์ด๋ฉ์ผ์ด๋ ์์ ID๋ก๋ง์ผ๋ก๋ ๋์ผ ์ฌ์ฉ์๊ฐ ์ฌ๋ฌ ๋ฒ ํ์๊ฐ์ ํ ์ ์๋ ๋ฌธ์ ๊ฐ ๋ฐ์ํ๋ค. ์ค์ ๋ก ์ด์ํ๋ค ๋ณด๋ฉด ์ค๋ณต ๊ฐ์ ์ผ๋ก ์ธํ ๊ด๋ฆฌ ์ด๋ ค์, ์ด๋ฒคํธ ๋จ์ฉ, ๋ฐ์ดํฐ ๋ญ๋น ๋ฑ ๋ฌธ์ ๊ฐ ๋ฐ์ํ๋ค.
์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๊ธฐ ์ํด, ํ์๋ค๊ณผ ๋ ผ์ ๋์ "ํด๋ํฐ ๋ฒํธ"๋ฅผ ํต์ฌ ์ค๋ณต ๋ฐฉ์ง ์๋ณ์๋ก ์ ํํ๋ค.
ํ์๊ฐ์ ์ ๋ฐ๋์ ํด๋ํฐ ์ธ์ฆ์ ๊ฑฐ์ณ ์ด๋ฏธ ์ฌ์ฉ๋ ๋ฒํธ๋ก๋ ๊ฐ์ ํ ์ ์๊ฒ ๋ง๋ ๋ฐฉ์์ ๋์ ํ๋ค.
์ผ๋จ SMS ์ธ์ฆ ๊ตฌํ ๊ณผ์ ์ ์ ํด๋ํฐ ๋ฒํธ ์ปฌ๋ผ์ผ๋ก ํด๋ํฐ ๋ฒํธ ์ค๋ณต์ ์ฒดํฌํ๋ ๊ณผ์ ๋ถํฐ ์จ๋ด๋ ค ๊ฐ๊ฒ ์ !
๐ฑํด๋ํฐ ๋ฒํธ ์ค๋ณต์ฒดํฌ ๊ตฌํ
1. User Entity(VO)์ Phone ์ปฌ๋ผ์ ์ ๋ํฌ ์ ์ฝ์ ์ถ๊ฐ
@Column(unique = true, nullable = false)
private String phone;
ALTER TABLE user ADD CONSTRAINT uq_user_phone UNIQUE(phone);
- JPA๋ฅผ ์ฌ์ฉํ๊ธฐ๋๋ฌธ์ VO์๋ค ์ ๋ํฌ ์ ์ฝ๋ง ๊ฑธ์ด์ฃผ๋ฉด ๋จ.
2. UserRepository(DAO)
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByPhone(String phone);
}
- Optinal<User> findByPhone ๋ฉ์๋ ์ถ๊ฐ
select * from user where phone=?
- JPA๊ฐ ๋ง๋ค์ด์ฃผ์ง๋ง ์ค์ ์ฟผ๋ฆฌ๋ ์์ ๊ฐ๋ค.
3. UserService ์๋น์ค๋จ ์ค๋ณต ์ฒดํฌ ๋ก์ง
public void validateDuplicatePhone(String phone) {
if (userRepository.findByPhone(phone).isPresent()) {
throw new CustomException(ErrorType.ALREADY_EXISTS_PHONE);
}
}
- ์ฌ์ฉ์๊ฐ ๊ฐ์ ์ ์๋ํ ๋๋ง๋ค ํธ์ถ
public enum ErrorType implements ErrorCode {
ALREADY_EXISTS_PHONE(HttpStatus.CONFLICT, "์ฌ์ฉ์ค์ธ ํด๋ํฐ๋ฒํธ์
๋๋ค."),
}
- ์คํจ ์ ์์ธ ๋ฉ์ธ์ง ๋ฐํ (Custom Exception)
4. KakoAuthService(kakaoRegister Method) ํด๋ํฐ ๋ฒํธ ์ค๋ณต์ฒดํฌ ๋ก์ง
private final UserService userService;
@Transactional
public String kakaoRegister(KakaoSocialSignUpdto signUpdto) {
log.info("[์นด์นด์ค ํ์๊ฐ์
] ์นด์นด์ค ํ์๊ฐ์
์๋: kakaoId={}, email={}, name={}", signUpdto.getKakaoId(), signUpdto.getEmail(), signUpdto.getName());
// ํด๋ํฐ ๋ฒํธ ์ค๋ณต ์ฒดํฌ
userService.validateDuplicatePhone(signUpdto.getPhone());
// ๊ธฐํ ์นด์นด์ค ํ์๊ฐ์
๋ก์ง
...
}
- KakaoAuthService์์ UserService๋ฅผ ์ฃผ์ ๋ฐ์ validateDuplicatePhone() ๋ฉ์๋ ํธ์ถ.
- UserRepository๋ฅผ ํตํด DB์ ๊ฐ์ ํด๋ํฐ ๋ฒํธ๊ฐ ์กด์ฌํ๋์ง ์ฒดํฌํ๊ณ , ์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ๋ฒํธ์ผ ๊ฒฝ์ฐ ์์ธ๋ฅผ ๋ฐ์์์ผ ์ค๋ณต ๊ฐ์ ์ ๋ฐฉ์งํ๋ค.
5. AuthController(์นด์นด์ค ํ์๊ฐ์ ์๋ํฌ์ธํธ)
@PostMapping("/kakao/register")
public ResponseEntity<?> kakaoRegister(@RequestBody KakaoSocialSignUpdto signUpdto) {
try {
String jwt = kakaoAuthService.kakaoRegister(signUpdto);
return ResponseEntity.ok().body(
new KakaoRegisterResponseDto(jwt, "ํ์๊ฐ์
์ฑ๊ณต")
);
} catch (CustomException e) {
log.warn("[์นด์นด์ค ํ์๊ฐ์
] ์ปค์คํ
์๋ฌ(ํด๋ํฐ๋ฒํธ ์ค๋ณต): {}", e.getMessage());
return ResponseEntity.status(e.getErrorType().getStatus()).body(
new KakaoRegisterResponseDto(null, e.getErrorType().getDesc())
);
} catch (IllegalArgumentException e) {
log.warn("[์นด์นด์ค ํ์๊ฐ์
] ์ค๋ณต ๊ฐ์
์๋: {}", e.getMessage());
return ResponseEntity.badRequest().body(
new KakaoRegisterResponseDto(null, e.getMessage())
);
} catch (Exception e) {
log.error("[์นด์นด์ค ํ์๊ฐ์
] ์๋ฒ ์ค๋ฅ: {}", e.getMessage(), e);
return ResponseEntity.internalServerError().body(
new KakaoRegisterResponseDto(null, "์๋ฒ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.")
);
}
}
- KakaoAuthService๋ฅผ ์ฃผ์ ๋ฐ์ HTTP ์์ฒญ/์๋ต ๊ด๋ฆฌ์ ์ง์คํ๋๋ก ์ค๊ณ
- ํ์๊ฐ์ ์ด ์ ์์ ์ผ๋ก ์ด๋ฃจ์ด์ง ๊ฒฝ์ฐ JWTํ ํฐ๊ณผ ํจ๊ป ํ์๊ฐ์ ์ฑ๊ณต ๋ฉ์ธ์ง ๋ฐํ
- ์คํจ ์, ๊ฐ๊ฐ์ ์์ธ๋ฅผ ๋์ง
- CustomException: ํด๋ํฐ ์ค๋ณต
- IllegalArgumentException: ์์ ๊ณ์ ์ค๋ณต
- ๊ธฐํ ์์ธ: 500 Internal Server Error
๐ CoolSMS ํด๋ํฐ ์ธ์ฆ ๋์ ๊ธฐ
๐ค ๋์ ๊ณ๊ธฐ
์๋ก ์์ ์ธ๊ธํ์ง๋ง ๊ธฐ์กด ์์ /์ผ๋ฐ ํ์๊ฐ์ ์ ๋ชจ๋ ์ง์ํ๋ค ๋ณด๋ ์ด๋ฉ์ผ์ด๋ ์์ ID๋ง์ผ๋ก๋ ์ค๋ณต ํ์๊ฐ์ ๋ฌธ์ ๊ฐ ๋ฐ์ํ๋ค.
๐ ์ ์ฒด ์ธ์ฆ Flow
- ์ฌ์ฉ์๊ฐ ํ์๊ฐ์ ํผ์์ ํด๋ํฐ ๋ฒํธ ์ ๋ ฅ
- "์ธ์ฆ๋ฒํธ ๋ฐ๊ธฐ" ํด๋ฆญ ์, ์๋ฒ(๋ฐฑ์๋)์์ CoolSMS API๋ฅผ ํตํด ์ธ์ฆ๋ฒํธ ์ ์ก
- ์ธ์ฆ๋ฒํธ๋ฅผ Redis์ 3๋ถ๊ฐ ์์ ์ ์ฅ
- ์ฌ์ฉ์๋ ์ธ์ฆ๋ฒํธ ์ ๋ ฅ ํ, "์ธ์ฆ๋ฒํธ ํ์ธ" ๋ฒํผ์ผ๋ก ๊ฒ์ฆ
- ์ธ์ฆ ์ฑ๊ณต ์, ์ธ์ฆ ์๋ฃ ํ๋๊ทธ/์ฝ๋๋ก ํ์๊ฐ์ ์งํ
1. Cool SMS ํ์๊ฐ์ ๋ฐ API Key / Secret ๋ฐ๊ธ

- CoolSMS์์ ํ์๊ฐ์ ํ API Key์ Secret Key๋ฅผ ๋ฐ๊ธ๋ฐ๋๋ค.
2. ํ๊ฒฝ ์ค์ (yml, env, gradle)
2-1. build.gradle (dependencies ์์กด์ฑ ์ถ๊ฐ)

// CoolSMS ์์กด์ฑ ์ถ๊ฐ (๋ฌธ์์ธ์ฆ)
implementation 'net.nurigo:sdk:4.3.2'
2-2. application.yml (local)

coolsms:
api-key: ${COOLSMS_API_KEY}
api-secret: ${COOLSMS_API_SECRET}
sender: "๋ฐ์ ๋ฒํธ"
- ์์ ๊ฐ์ด ymlํ์ผ์ ์์ ํด์ค๋ค.
- sender์๋ CoolSMS์ ๋ฑ๋กํ ๋ฐ์ ๋ฒํธ (ex:"010-1234-5678")๋ฅผ ์ ๋ ฅํ๋ฉด๋จ.
2-3. .env

- CoolSMS์์ ๋ฐ๊ธ๋ฐ์ API Key์ SECRET Key ์ ๋ ฅ
3. SmsConfig ๋ฐ SmsUitl ํด๋์ค
3-1. SmsConfig(์ธ๋ถ ๋ผ์ด๋ธ๋ฌ๋ฆฌ, API Key, ๊ธฐํ ์ค์ ๊ด๋ จ Bean ๋ฑ๋ก)

import net.nurigo.sdk.message.service.DefaultMessageService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SmsConfig {
@Value("${coolsms.api-key}")
private String apiKey;
@Value("${coolsms.api-secret}")
private String apiSecret;
// CoolSMS Message Bean ๋ฑ๋ก
@Bean
public DefaultMessageService messageService() {
return new DefaultMessageService(apiKey, apiSecret, "https://api.coolsms.co.kr");
}
}
- ์ธ๋ถ SMS ์๋น์ค ์ฐ๋์ ์ข ์์ฑ์ ํ ๊ณณ์์ ๊ด๋ฆฌํ์ฌ ์ ์ง๋ณด์์ ๋ณด์์ฑ UP
- ์ค์ ์ด์ํ๊ฒฝ/๊ฐ๋ฐํ๊ฒฝ์ ๋ฐ๋ผ Key๋ฅผ ๋ค๋ฅด๊ฒ ๊ด๋ฆฌํ ์ ์์. ๋ณ๋์ Mock ์๋น์ค๋ก๋ ๋์ฒด ๊ฐ๋ฅ
- Bean ๋ฑ๋ก ๋ฐฉ์์ด๋ฏ๋ก, ์ถํ ์๋น์ค ๋จ์์ ์ง์ ๊ฐ์ฒด๋ฅผ ์์ฑํ์ง ์๊ณ DI๋ก ๊ด๋ฆฌ.
3-2. SmsUtil

import java.util.Random;
import org.springframework.stereotype.Component;
@Component
public class SmsUtil {
public static String generateAuthCode() {
Random random = new Random();
int code = 100000 + random.nextInt(900000); // 6์๋ฆฌ ์ซ์
return String.valueOf(code);
}
public String makeAuthMessage(String authCode) {
return "[IssueMate ์ธ์ฆ๋ฒํธ] " + authCode + "\n๋ณธ์ธ ํ์ธ์ ์ํด ์ธ์ฆ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.";
}
}
- ์ธ์ฆ๋ฒํธ(OTP) ์์ฑ ๋ฐ ๋ฉ์ธ์ง ํฌ๋งท์ ํ ๊ณณ์์ ๊ด๋ฆฌ.
- ์ธ์ฆ๋ฒํธ ์์ฑ(`generateAuthCode`)์ 6์๋ฆฌ ๋๋ค ์ซ์๋ฅผ ๋ฆฌํดํ๋ฉฐ, ๋ฉ์ธ์ง ์์ฑ (`makeAuthMessage`)์ ์ฌ์ฉ์์๊ฒ ์ ์ก๋ ์๋ด๋ฌธ๊ณผ ํจ๊ป ์ธ์ฆ๋ฒํธ๋ฅผ ์กฐํฉํ๋ค.
4. SmsService (๋ฌธ์๋ฉ์ธ์ง ์ธ์ฆ ๊ด๋ จ ๋น์ฆ๋์ค ๋ก์ง)
4-1. sendAuthCode(String phoneNumber)

public void sendAuthCode(String phoneNumber) {
String authCode = SmsUtil.generateAuthCode(); // ์ธ์ฆ๋ฒํธ ์์ฑ
String messageText = smsUtil.makeAuthMessage(authCode); // ๋ฉ์ธ์ง ํฌ๋งทํ
Message message = new Message();
message.setFrom("010-9205-9228");
message.setTo(phoneNumber);
message.setText(messageText);
// ์์ฒญ ๋ํ
SingleMessageSendingRequest request = new SingleMessageSendingRequest(message);
// CoolSMS ๋ฐ์ก ๋ฐ ๋ก๊ทธ
try {
SingleMessageSentResponse response = messageService.sendOne(request);
log.info("[SMS] ์ธ์ฆ๋ฒํธ ๋ฐ์ก - to: {}, code: {}, response: {}", phoneNumber, authCode, response);
} catch (Exception e) {
log.error("[SMS] ์ธ์ฆ๋ฒํธ ๋ฐ์ก ์คํจ - to: {}, ์๋ฌ: {}", phoneNumber, e.getMessage(), e);
throw new RuntimeException("SMS ๋ฐ์ก์ ์คํจํ์ต๋๋ค.");
}
// Redis์ ์ธ์ฆ๋ฒํธ ์ ์ฅ (3๋ถ ์ ํจ)
String redisKey = "SMS:AUTH:" + phoneNumber;
redisTemplate.opsForValue().set(redisKey, authCode, 3, TimeUnit.MINUTES);
log.info("[SMS] ์ธ์ฆ๋ฒํธ Redis ์ ์ฅ - key: {}, code: {}", redisKey, authCode);
}
- ์ ๋ฌ๋ฐ์ ํด๋ํฐ ๋ฒํธ๋ก 6์๋ฆฌ ์ธ์ฆ๋ฒํธ๋ฅผ ์์ฑํด์ ๋ฌธ์๋ก ๋ฐ์ก.
- ์ธ์ฆ๋ฒํธ๋ฅผ Redis์ 3๋ถ(180์ด) ๋์ ์ ์ฅํจ.
๋์ ํ๋ฆ
- SmsUtil.generateAuthCode() ํธ์ถ โ 6์๋ฆฌ ์ธ์ฆ๋ฒํธ ์์ฑ
- SmsUtil.makeAuthMessage(authCode) ํธ์ถ โ ์ฌ์ฉ์์๊ฒ ๋ณด๋ผ ์๋ด ๋ฉ์ธ์ง ํฌ๋งทํ
- CoolSMS(Message, SingleMessageSendingRequest)๋ก ์ค์ SMS ์ ์ก
- ๋ฐ์ก ์ฑ๊ณต ์, `SMS:AUTH: <phoneNumber>` ๋ผ๋ ํค๋ก ์ธ์ฆ๋ฒํธ๋ฅผ Redis์ ์ ์ฅ (3๋ถ ํ์์์)
4-2. verifyAuthCode(String phoneNumber, String inputCode)

public boolean verifyAuthCode(String phoneNumber, String inputCode) {
String redisKey = "SMS:AUTH:" + phoneNumber;
String storedCode = redisTemplate.opsForValue().get(redisKey);
log.info("[SMS] ์ธ์ฆ๋ฒํธ ๊ฒ์ฆ ์์ฒญ - key: {}, ์
๋ ฅ๊ฐ: {}, ์ ์ฅ๊ฐ: {}", redisKey, inputCode, storedCode);
if (storedCode != null && storedCode.equals(inputCode)) {
redisTemplate.delete(redisKey); // 1ํ ๊ฒ์ฆ ํ ์ญ์
log.info("[SMS] ์ธ์ฆ๋ฒํธ ๊ฒ์ฆ ์ฑ๊ณต - key: {}", redisKey);
return true;
}
log.warn("[SMS] ์ธ์ฆ๋ฒํธ ๊ฒ์ฆ ์คํจ - key: {}, ์
๋ ฅ๊ฐ: {}", redisKey, inputCode);
return false;
}
- ์ ๋ฌ๋ฐ์ ํด๋ํฐ ๋ฒํธ์ ์ ๋ ฅ๋ฐ์ ์ธ์ฆ๋ฒํธ๋ฅผ Redis์ ์ ์ฅ๋ ๊ฐ๊ณผ ๋น๊ตํ์ฌ ์ผ์น ์ฌ๋ถ ๊ฒ์ฆ
- 1ํ ๊ฒ์ฆ ์ Redis์์ ์ธ์ฆ๋ฒํธ ์ฆ์ ์ญ์ (์ฌ์ฌ์ฉ ๋ถ๊ฐ)
๋์ ํ๋ฆ
- `SMS:AUTH:<phoneNumber>` ํค๋ก Redis์์ ์ธ์ฆ๋ฒํธ ์กฐํ
- ์ ์ฅ๋ ๊ฐ์ด ์๊ณ , ์ ๋ ฅ๊ฐ๊ณผ ์ผ์นํ๋ฉด ์ธ์ฆ๋ฒํธ Redis์์ ์ญ์ , ์ฑ๊ณต ๋ก๊ทธ ์ถ๋ ฅ ๋ฐ true ๋ฐํ
- ๊ฐ์ด ์๊ฑฐ๋, ์ ๋ ฅ๊ฐ ๋ถ์ผ์น ์ ์คํจ ๋ก๊ทธ ์ถ๋ ฅ ๋ฐ false ๋ฐํ
5. SmsController (API EndPoint)
5-1. ์ธ์ฆ๋ฒํธ ๋ฐ์ก ์๋ํฌ์ธํธ @PostMapping("/sms/send")

private final SmsService smsService;
// ์ธ์ฆ๋ฒํธ ๋ฐ์ก
@PostMapping("/send")
public ResponseEntity<?> sendAuthCode(@RequestParam String phone) {
try {
smsService.sendAuthCode(phone);
log.info("[SMS] ์ธ์ฆ๋ฒํธ ๋ฐ์ก ์์ฒญ ์๋ฃ - to: {}", phone);
return ResponseEntity.ok().body(Map.of("message", "์ธ์ฆ๋ฒํธ๊ฐ ์ ์ก๋์์ต๋๋ค."));
} catch (Exception e) {
log.error("[SMS] ์ธ์ฆ๋ฒํธ ๋ฐ์ก ์์ฒญ ์คํจ - to: {}, ์๋ฌ: {}", phone, e.getMessage(), e);
return ResponseEntity.internalServerError().body("SMS ๋ฐ์ก์ ์คํจํ์ต๋๋ค: " + e.getMessage());
}
}
- ํ๋ก ํธ์๋์์ ํด๋ํฐ ๋ฒํธ๋ฅผ ์ ๋ฌ๋ฐ์, ํด๋น ๋ฒํธ๋ก ์ธ์ฆ๋ฒํธ(SMS)๋ฅผ ๋ฐ์ก ์์ฒญํ๋ ์๋ํฌ์ธํธ
๋์ ํ๋ฆ
- smsService.sendAuthCode(phone) ํธ์ถ๋ก ์ธ์ฆ๋ฒํธ ์์ฑ ๋ฐ ์ ์ก
- ๋ฐ์ก ์ฑ๊ณต ์, {"message": "์ธ์ฆ๋ฒํธ๊ฐ ์ ์ก๋์์ต๋๋ค."} ํ์์ JSON ์๋ต ๋ฐํ (200 OK)
- ์์ธ ๋ฐ์ ์, ์๋ฒ ์๋ฌ ๋ฉ์์ง์ ํจ๊ป 500 ์๋ต
5-2. ์ธ์ฆ๋ฒํธ ๊ฒ์ฆ ์๋ํฌ์ธํธ @PostMapping("/sms/verify")

// ์ธ์ฆ๋ฒํธ ๊ฒ์ฆ
@PostMapping("/verify")
public ResponseEntity<?> verifyAuthCode(
@RequestParam String phone,
@RequestParam String code
) {
boolean result = false;
try {
result = smsService.verifyAuthCode(phone, code);
} catch (Exception e) {
log.error("[SMS] ์ธ์ฆ๋ฒํธ ๊ฒ์ฆ ์ค ์์ธ - to: {}, ์๋ฌ: {}", phone, e.getMessage());
return ResponseEntity.internalServerError().body("์ธ์ฆ ์ค ์๋ฒ ์ค๋ฅ: " + e.getMessage());
}
if (result) {
log.info("[SMS] ์ธ์ฆ๋ฒํธ ๊ฒ์ฆ ์ฑ๊ณต - to: {}", phone);
return ResponseEntity.ok().body(Map.of("message","์ธ์ฆ๋์์ต๋๋ค."));
} else {
log.warn("[SMS] ์ธ์ฆ๋ฒํธ ๊ฒ์ฆ ์คํจ - to: {}", phone);
return ResponseEntity.badRequest().body(Map.of("message", "์ธ์ฆ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค"));
}
}
- ํ๋ก ํธ์๋์์ ์ฌ์ฉ์๊ฐ ์ ๋ ฅํ ์ธ์ฆ๋ฒํธ์ ์๋ฒ(Redis)์ ์ ์ฅ๋ ์ธ์ฆ๋ฒํธ๋ฅผ ๋น๊ตํ์ฌ ์ผ์น ์ฌ๋ถ๋ฅผ ํ์ธํ๋ ์๋ํฌ์ธํธ.
๋์ ํ๋ฆ
- sms.Service.verifyAuthCode(phone, code) ํธ์ถ๋ก ์ค์ ๊ฒ์ฆ ์ํ
- ์ฑ๊ณต ์, {"message": "์ธ์ฆ๋์์ต๋๋ค."} ํ์์ JSON ์๋ต ๋ฐํ (200 OK)
- ์คํจ ์, {"message": "์ธ์ฆ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค."} ํ์์ JSON ๋ฐํ(400 BadRequest)
- ์๋ฒ ์์ธ ์, 500 ์๋ต ๋ฐ ์๋ฌ ๋ฉ์ธ์ง ๋ฐํ
5. BE: ๋ฌธ์๋ฉ์ธ์ง ์ธ์ฆ ํ ์คํธ ํ๋ฉด (with Swagger)
1. ์ ๋ ฅํ ๋ฒํธ๋ก ์ธ์ฆ์ฝ๋ ๋ฐ์ก

2. ๋ฉ์ธ์ง ์ ์กํ๋ฉด

3. Redis ์ ์ฅ

4. ์ธ์ฆ ์ฝ๋ ๊ฒ์ฆ
- ์คํจ ์

- ์ฑ๊ณต ์

- ์ธ์ฆ ์ฑ๊ณต ์ Redis์์ ํด๋น ํค ๊ฐ ์ญ์ (์ผํ์ฑ)

๐จ FE: SMS ๋ฌธ์๋ฉ์ธ์ง ์ธ์ฆ API ์ฐ๋ ๋ฐ ํ๋ฉด UI/UX (Next.js)
ํ๋ก ํธ์๋ ํ์ผ/์ฝ๋ ๋ณ ์ ๋ฆฌ
1. feature/phone-auth/api/sms.ts
import {
SmsSendRequest,
SmsVerifyRequest,
} from "@/feature/phone-auth/types/sms.types";
export async function sendSmsAuthCode({ phone }: SmsSendRequest) {
const res = await fetch(
`http://localhost:8080/sms/send?phone=${encodeURIComponent(phone)}`,
{
method: "POST",
}
);
const data = await res.json();
if (!res.ok) throw new Error(data.message || "์ธ์ฆ๋ฒํธ ์ ์ก ์คํจ");
return data;
}
export async function verifySmsAuthCode({ phone, code }: SmsVerifyRequest) {
const res = await fetch(
`http://localhost:8080/sms/verify?phone=${encodeURIComponent(phone)}&code=${encodeURIComponent(code)}`,
{
method: "POST",
}
);
const data = await res.json();
if (!res.ok) throw new Error(data.message || "์ธ์ฆ๋ฒํธ ๊ฒ์ฆ ์คํจ");
return data;
}
- ์ญํ : ๋ฐฑ์๋ ์ธ์ฆ API(/sms/send, /sms,verify) ํธ์ถ ํจ์ ์ ์
์ฃผ์ ๋ด์ฉ
- ์ธ์ฆ๋ฒํธ ์ ์ก: sendSmsAuthCode({ phone })
- ์ธ์ฆ๋ฒํธ ๊ฒ์ฆ: verifySmsAuthCode({ phone, code })
- fetch/์๋ฌ ์ฒ๋ฆฌ ํฌํจ, ์ฌ์ฌ์ฉ ๊ฐ๋ฅ
2. feature/phone-auth/api/sms.ts
"use client";
import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { CheckCircle, Clock, RotateCcw, Send } from "lucide-react";
import { sendSmsAuthCode, verifySmsAuthCode } from "../api/sms";
type Props = {
phone: string;
disabled: boolean;
onVerified: (code: string) => void;
};
export default function PhoneAuthSection({
phone,
disabled,
onVerified,
}: Props) {
const [code, setCode] = useState("");
const [codeSent, setCodeSent] = useState(false);
const [sendLoading, setSendLoading] = useState(false);
const [verifyLoading, setVerifyLoading] = useState(false);
const [verified, setVerified] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [timeLeft, setTimeLeft] = useState(0);
// ์นด์ดํธ๋ค์ด ํ์ด๋จธ
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (timeLeft > 0 && !verified) {
interval = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1) {
return 0;
}
return prev - 1;
});
}, 1000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [timeLeft, verified]);
// ์๊ฐ์ MM:SS ํํ๋ก ํฌ๋งทํ
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
};
const handleSend = async () => {
setError("");
setSuccess("");
setSendLoading(true);
try {
await sendSmsAuthCode({ phone });
setCodeSent(true);
setTimeLeft(180); // 3๋ถ = 180์ด
setCode(""); // ์ธ์ฆ๋ฒํธ ์
๋ ฅ ํ๋ ์ด๊ธฐํ
setSuccess("์ธ์ฆ๋ฒํธ๊ฐ ์ ์ก๋์์ต๋๋ค.");
} catch (err) {
setError((err as Error).message || "์ธ์ฆ๋ฒํธ ์ ์ก ์คํจ");
} finally {
setSendLoading(false);
}
};
const handleVerify = async () => {
setError("");
setSuccess("");
setVerifyLoading(true);
try {
await verifySmsAuthCode({ phone, code });
setVerified(true);
setTimeLeft(0); // ํ์ด๋จธ ์ ์ง
setSuccess("์ธ์ฆ ์ฑ๊ณต!");
onVerified(code);
} catch (err) {
setError((err as Error).message || "์ธ์ฆ ์คํจ");
} finally {
setVerifyLoading(false);
}
};
// ๋ฒํผ ํ
์คํธ์ ์์ด์ฝ์ ๊ฒฐ์ ํ๋ ํจ์
const getButtonContent = () => {
if (sendLoading) {
return (
<>
<Clock className="w-4 h-4 mr-2 animate-spin" />
์ ์ก ์ค...
</>
);
}
if (codeSent && timeLeft > 0) {
return (
<>
<Clock className="w-4 h-4 mr-2" />
์ฌ์ ์ก ({formatTime(timeLeft)})
</>
);
}
if (codeSent && timeLeft === 0) {
return (
<>
<RotateCcw className="w-4 h-4 mr-2" />
์ธ์ฆ๋ฒํธ ์ฌ์ ์ก
</>
);
}
return (
<>
<Send className="w-4 h-4 mr-2" />
์ธ์ฆ๋ฒํธ ๋ฐ๊ธฐ
</>
);
};
if (verified) {
return (
<div className="flex items-center gap-2 p-3 bg-green-900/20 border border-green-800 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-400" />
<span className="text-green-400 font-medium">์ธ์ฆ ์๋ฃ</span>
</div>
);
}
return (
<div className="space-y-3">
{/* ์ธ์ฆ๋ฒํธ ์ ์ก ๋ฒํผ */}
<Button
onClick={handleSend}
disabled={disabled || sendLoading || (codeSent && timeLeft > 0)}
type="button"
className="w-full bg-gray-700 hover:bg-gray-600 text-white border border-gray-600 disabled:opacity-50"
variant="outline"
>
{getButtonContent()}
</Button>
{/* ์ธ์ฆ๋ฒํธ ์
๋ ฅ ๋ฐ ํ์ธ */}
{codeSent && (
<div className="space-y-3">
<div className="flex gap-2">
<div className="flex-1 relative">
<Input
placeholder="์ธ์ฆ๋ฒํธ 6์๋ฆฌ ์
๋ ฅ"
value={code}
onChange={(e) =>
setCode(e.target.value.replace(/[^\d]/g, "").slice(0, 6))
}
maxLength={6}
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-gray-600 focus:ring-gray-600 pr-16"
/>
{/* ํ์ด๋จธ ํ์ */}
{timeLeft > 0 && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<span
className={`text-sm font-mono ${timeLeft <= 30 ? "text-red-400" : "text-gray-400"}`}
>
{formatTime(timeLeft)}
</span>
</div>
)}
{timeLeft === 0 && codeSent && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<span className="text-sm text-red-400 font-medium">
๋ง๋ฃ๋จ
</span>
</div>
)}
</div>
<Button
onClick={handleVerify}
disabled={
verifyLoading || !code || code.length !== 6 || timeLeft === 0
}
type="button"
className="bg-white hover:bg-gray-100 text-black font-medium px-6"
>
{verifyLoading ? "ํ์ธ ์ค..." : "ํ์ธ"}
</Button>
</div>
{/* ํ์ด๋จธ ์๋ด ๋ฉ์์ง */}
{timeLeft > 0 && (
<p className="text-xs text-gray-500 text-center">
์ธ์ฆ๋ฒํธ๋ {formatTime(timeLeft)} ํ ๋ง๋ฃ๋ฉ๋๋ค.
</p>
)}
{timeLeft === 0 && (
<p className="text-xs text-red-400 text-center">
์ธ์ฆ๋ฒํธ๊ฐ ๋ง๋ฃ๋์์ต๋๋ค. ์๋ก์ด ์ธ์ฆ๋ฒํธ๋ฅผ ์์ฒญํด์ฃผ์ธ์.
</p>
)}
</div>
)}
{/* ์๋ฌ ๋ฉ์์ง */}
{error && (
<Alert
variant="destructive"
className="bg-red-900/20 border-red-800 text-red-200"
>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* ์ฑ๊ณต ๋ฉ์์ง */}
{success && !verified && (
<Alert className="bg-green-900/20 border-green-800 text-green-200">
<AlertDescription>{success}</AlertDescription>
</Alert>
)}
</div>
);
}
- ์ญํ
- ํด๋ํฐ ๋ฒํธ ์ธ์ฆ UI ๋ฐ ์ํ ๊ด๋ฆฌ(์ ๋ ฅ, ์ ์ก, ๊ฒ์ฆ, ํ์ด๋ฒ, ์๋ฌ/์ฑ๊ณต ์๋ด ๋ฑ)
- ํ์ด๋จธ/์ฌ์ ์ก/๋ง๋ฃ ๊ธฐ๋ฅ ๊น์ง ํฌํจ. ์ธํฐ๋์ ๊ฐ์
- ์ฃผ์ ๋ด์ฉ
- ํด๋ํฐ ๋ฒํธ ์ธ์ฆ (๋ฌธ์ ๋ฐ์ก โ ์ธ์ฆ๋ฒํธ ์ ๋ ฅ โ ๊ฒ์ฆ โ ์ฑ๊ณต/์คํจ ์๋ฆผ) UI + ๋ก์ง
- ์ธ์ฆ ์ฑ๊ณต ์ onVerified(code) ์ฝ๋ฐฑ์ผ๋ก ์์์์ ์ธ์ฆ ์ํ ๊ด๋ฆฌ
- ์ผ๋ฐ ํ์๊ฐ์ , ์์ ํ์๊ฐ์ ๋ชจ๋์์ ์ฌ์ฌ์ฉ ๊ฐ๋ฅํ๊ฒ ์ค๊ผ
- ์ธ์ฆ๋ฒํธ ์ ํจ์๊ฐ ํ์ด๋จธ(์นด์ดํธ๋ค์ด) ์ถ๊ฐ (timeLeft ์ํ, MM:SS ํ์)
- ๋ง๋ฃ ์ ์ธ์ฆ๋ฒํธ ์ ๋ ฅ / ํ์ธ ๋ฒํผ ๋นํ์ฑํ ๋ฐ "๋ง๋ฃ๋จ" ๋ฉ์ธ์ง ๊ฐ์กฐ
- ์ฌ์ ์ก / ์ฌ์ธ์ฆ UX ๊ฐ์
- ์ธ์ฆ๋ฒํธ ๋ง๋ฃ ์ "์ธ์ฆ๋ฒํธ ์ฌ์ ์ก ๋ฒํผ"
- ์ธ์ฆ๋ฒํธ ์ฌ์ ์ก ์, ํ์ด๋จธ, ์ ๋ ฅ๋ ๋ชจ๋ ๋ฆฌ์
3. feature/phone-auth/types/sms.types.ts
export interface SmsSendRequest {
phone: string;
}
export interface SmsVerifyRequest {
phone: string;
code: string;
}
- ์ญํ : ํด๋ํฐ ์ธ์ฆ API ํ๋ผ๋ฏธํฐ/์๋ต ํ์ ์ ์(TypeScript)
- ์ฃผ์ ํ์ : SmsSendRequest, SmsVerifyRequest
4. feature/social-signup/compnents/SocialSignupForm.tsx ์์
"use client";
import type React from "react";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import type { KakaoSocialSignUpRequest } from "@/feature/social-signup/types/signup.types";
import registerKakaoUser from "@/feature/social-signup/api/register";
import PhoneAuthSection from "@/feature/phone-auth/components/PhoneAuthSection";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { User, Phone, Shield, CheckCircle } from "lucide-react";
import type { CheckedState } from "@radix-ui/react-checkbox";
export default function SocialSignupForm() {
const router = useRouter();
const searchParams = useSearchParams();
// ์ฟผ๋ฆฌ์์ provider/kakaoId/email ๋ฑ ๊ฐ์ ธ์ด
const provider = searchParams.get("provider");
const kakaoId = searchParams.get("kakaoId");
const email = searchParams.get("email");
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [termsAgreed, setTermsAgreed] = useState(false);
const [phoneVerified, setPhoneVerified] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
// ํด๋ํฐ ๋ฒํธ ํฌ๋งทํ
ํจ์
const formatPhoneNumber = (value: string) => {
// ์ซ์๋ง ์ถ์ถ
const numbers = value.replace(/[^\d]/g, "");
// ์ต๋ 11์๋ฆฌ๊น์ง๋ง ํ์ฉ
const truncated = numbers.slice(0, 11);
// ๊ธธ์ด์ ๋ฐ๋ผ ํฌ๋งทํ
if (truncated.length <= 3) {
return truncated;
}
if (truncated.length <= 7) {
return `${truncated.slice(0, 3)}-${truncated.slice(3)}`;
}
return `${truncated.slice(0, 3)}-${truncated.slice(3, 7)}-${truncated.slice(7)}`;
};
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatPhoneNumber(e.target.value);
setPhone(formatted);
if (phoneVerified) {
setPhoneVerified(false);
}
};
const handlePhoneVerified = () => {
setPhoneVerified(true);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (!name || !phone || !termsAgreed) {
setError("์ค๋ช
, ํด๋ํฐ๋ฒํธ, ์ฝ๊ด๋์ ๋ชจ๋ ํ์์
๋๋ค.");
return;
}
if (!phoneVerified) {
setError("ํด๋ํฐ ์ธ์ฆ์ ์๋ฃํด์ฃผ์ธ์.");
return;
}
// ํด๋ํฐ ๋ฒํธ ์ ํจ์ฑ ๊ฒ์ฌ
const phoneNumbers = phone.replace(/[^\d]/g, "");
if (phoneNumbers.length !== 11) {
setError("์ฌ๋ฐ๋ฅธ ํด๋ํฐ ๋ฒํธ๋ฅผ ์
๋ ฅํด์ฃผ์ธ์.");
return;
}
setLoading(true);
try {
const payload: KakaoSocialSignUpRequest = {
provider: provider ?? "kakao",
kakaoId: kakaoId ?? undefined,
email: email ?? undefined,
name,
phone,
termsAgreed,
};
const res = await registerKakaoUser(payload);
localStorage.setItem("jwt_token", res.token);
router.replace("/dashboard");
} catch (err) {
setError((err as Error).message || "ํ์๊ฐ์
์ ์คํจํ์ต๋๋ค.");
} finally {
setLoading(false);
}
};
// ํด๋ํฐ ์ธ์ฆ ๋นํ์ฑํ ์กฐ๊ฑด
const isPhoneAuthDisabled = phone.replace(/[^\d]/g, "").length !== 11;
return (
<div className="min-h-screen flex items-center justify-center bg-black p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-white mb-2">ํ์๊ฐ์
</h1>
<p className="text-gray-400">์ถ๊ฐ ์ ๋ณด๋ฅผ ์
๋ ฅํด์ฃผ์ธ์</p>
</div>
<form
className="space-y-6 p-8 bg-gray-900 rounded-xl shadow-2xl border border-gray-800"
onSubmit={handleSubmit}
>
{error && (
<Alert
variant="destructive"
className="bg-red-900/20 border-red-800 text-red-200"
>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* ์ค๋ช
์
๋ ฅ */}
<div className="space-y-2">
<Label
htmlFor="name"
className="text-gray-200 font-medium flex items-center gap-2"
>
<User className="w-4 h-4" />
์ค๋ช
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="ํ๊ธธ๋"
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-gray-600 focus:ring-gray-600"
autoComplete="name"
/>
</div>
{/* ํด๋ํฐ ๋ฒํธ ์
๋ ฅ ๋ฐ ์ธ์ฆ */}
<div className="space-y-3">
<Label
htmlFor="phone"
className="text-gray-200 font-medium flex items-center gap-2"
>
<Phone className="w-4 h-4" />
ํด๋ํฐ๋ฒํธ
{phoneVerified && (
<CheckCircle className="w-4 h-4 text-green-400 ml-auto" />
)}
</Label>
<Input
id="phone"
value={phone}
onChange={handlePhoneChange}
placeholder="010-1234-5678"
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-gray-600 focus:ring-gray-600"
autoComplete="tel"
maxLength={13}
disabled={phoneVerified}
/>
<PhoneAuthSection
phone={phone}
disabled={isPhoneAuthDisabled}
onVerified={handlePhoneVerified}
/>
</div>
{/* ์ฝ๊ด ๋์ */}
<div className="space-y-4">
<div className="flex items-start gap-3 p-4 bg-gray-800/50 rounded-lg border border-gray-700">
<Checkbox
id="terms"
checked={termsAgreed}
onCheckedChange={(checked: CheckedState) =>
setTermsAgreed(checked === true)
}
className="mt-0.5 border-2 border-gray-500 bg-gray-700 data-[state=checked]:bg-white data-[state=checked]:text-black data-[state=checked]:border-white"
/>
<div className="flex-1">
<Label
htmlFor="terms"
className="text-gray-300 cursor-pointer flex items-start gap-2 leading-relaxed"
>
<Shield className="w-4 h-4 mt-0.5 text-gray-400" />
<span>
<span className="text-red-400 font-medium">(ํ์)</span>{" "}
์๋น์ค ์ด์ฉ์ฝ๊ด์ ๋์ํฉ๋๋ค.
</span>
</Label>
</div>
</div>
</div>
<Button
type="submit"
className="w-full bg-white hover:bg-gray-100 text-black font-semibold py-3 transition-colors"
disabled={loading}
>
{loading ? "๊ฐ์
์ค..." : "๊ฐ์
ํ๊ธฐ"}
</Button>
</form>
</div>
</div>
);
}
- ์ญํ
- ์์ ํ์๊ฐ์ ํผ
- ํด๋ํฐ ์ธ์ฆ ์ปดํฌ๋ํธ ํตํฉ
- ํ์๊ฐ์ ์ ํ์ ์ ๋ ฅ/์ธ์ฆ/์ฝ๊ด ์ฒดํฌ ๋ฑ
- ๋ณ๊ฒฝ ์ฌํญ
- PhoneAuthSection ์ปดํฌ๋ํธ ์ถ๊ฐ
- ์ธ์ฆ ํ๋๊ทธ/์ฝ๋ ๊ด๋ฆฌ
- ์ธ์ฆ ์ฑ๊ณต ์๋ง ํ์๊ฐ์ ๊ฐ๋ฅ
- ์ ๋ ฅ๊ฐ, ์๋ฌ, ์ํ ๋ฑ ๊ฐ์
ํ๋ฉด ์ด๋ฏธ์ง
1. ํด๋ํฐ ๋ฒํธ ์ ํจ์ฑ ๊ฒ์ฆ
[๊ฒ์ฆ ์๋ฃ ์, ์ธ์ฆ๋ฒํธ ๋ฐ๊ธฐ ๋ฒํผ ํ์ฑํ ]

[๊ฒ์ฆ ๋ฏธ์๋ฃ ์, ์ธ์ฆ๋ฒํธ ๋ฐ๊ธฐ ๋นํ์ฑํ]

2. ์ธ์ฆ๋ฒํธ ๋ฐ๊ธฐ ๋ฒํผ ํด๋ฆญ ์
[์ธ์ฆ๋ฒํธ 6์๋ฆฌ๊ฐ ๋ชจ๋ ์
๋ ฅ ์๋์์ผ๋ฉด, ํ์ธ ๋ฒํผ ๋นํ์ฑํ]

[6์๋ฆฌ ๋ชจ๋ ์
๋ ฅ๋๋ฉด, ํ์ธ๋ฒํผ ํ์ฑํ]

3. ์ธ์ฆ๋ฒํธ ๋น๊ต ํ๋ฉด
[์ธ์ฆ๋ฒํธ ์ผ์น]

[์ธ์ฆ๋ฒํธ ๋ถ์ผ์น]

5. ์ธ์ฆ์ํ์ง ์๊ณ ๊ฐ์
ํ๊ธฐ ๋ฒํผ ํด๋ฆญ ์

6. ์ธ์ฆ๋ฒํธ ์๊ฐ ๋ง๋ฃ

์ด๋ฒ ํฌ์คํ ์ ํ์๊ฐ์ ์ ํด๋ํฐ ์ธ์ฆ์ ๋ถ์ด๋ ์ ์ฒด ํ๋ก์ฐ ๋ฐ ๊ณผ์ ์ ๋ํด ์ ๋ฆฌํด๋ณด์๋ค.
์ง์ ๋ถ์ฌ๋ณด๊ณ ๋๋ ์ง์ง ์๋น์ค์ ํ๊ฑธ์ ๋ ๋ค๊ฐ๊ฐ ๋๋์ด ์ฒด๊ฐ๋๋ค.
์ค์๋น์ค์์ ์ค์ ์ธ์ฆ/์ค๋ณต์ฒดํฌ์ ์ค์์ฑ์ ์ฒด๊ฐํ๋ค. ๋จ์ํ "์์ ๋ก๊ทธ์ธ๋ง ๋ถ์ด๋ฉด ๋!" ์ด ์๋, ์ค์ ์ด์ ํ๊ฒฝ์์๋ ๋์ผ์ธ์ด ์ฌ๋ฌ ๋ฒ ๊ฐ์ ํ๋ ๋ฌธ์ ๋ฅผ ๋ฐ๋์ ์ฒดํฌํด์ผ ํ๋ค๋ ์ , ํต์ฌ ์๋ณ์(์ด๋ฉ์ผ, ์์ ID, ํด๋ํฐ ๋ฒํธ ๋ฑ)์ ์๋ฏธ์ ํ๊ณ์ ๋ํด ๊ณ ๋ฏผํ๊ฒ ๋์๊ณ , ๊ทธ ์ค์์๋ ํด๋ํฐ ์ธ์ฆ์ด ๊ฐ์ฅ ํ์ค์ ์์ ์ง์ ๊ฒฝํํด๋ณด์๋ค.
์ ์ ๊ฒฝํ(UX)์์ ๋จ์ ์ธ์ฆ๋ง์ด ์๋๋ผ ์ค์๊ฐ ํ์ด๋จธ, ์ฌ์ ์ก, ๋ง๋ฃ ์๋ด, ๋ฐ์ํ ์ฆ๊ฐ ํผ๋๋ฐฑ ๋ฑ ์ฌ์ฉ์ ๊ฒฝํ์ ์ธ์ฌํ๊ฒ ๊ณ ๋ คํด์ผํ๋ค๋ ์ ์ ๋ค์ ํ๋ฒ ๋๋ผ๊ฒ ๋์๋ค.
โน๏ธ ์ฐธ๊ณ
[ - ]
'๐ง๐ปโ๐ป Project' ์นดํ ๊ณ ๋ฆฌ์ ๋ค๋ฅธ ๊ธ
[์นด์นด์ค ์์ ๋ก๊ทธ์ธ] OAuth 2.0์ ํ์ฉํ์ฌ ์นด์นด์ค ์์ ๋ก๊ทธ์ธ ์ ์ฉ ํด๋ณด๊ธฐ ๊ทผ๋ฐ ์ด์ ์คํ๋ง์ ๊ณ๋ค์ธ.. (0) | 2025.07.02 |
---|