개발환경

  • MacBook Air (M1, 2020)
  • OpenJDK 8
  • Eclipse 2021-12
  • tomcat 8.5
  • MySQL Workbench 8.0.19


기간

  • 2022.3.4 ~ 2022.4.6


주제

  • 웹 백엔드 수업 중 중간 과제로 개인 프로젝트를 진행하게 되었다.
  • 회원가입/로그인/탈퇴 등 기본적인 회원관리 시스템을 가진 웹 사이트를 만드는 것이다. 주어진 기한은 한 달
  • 나는 다음 카페를 소규모로 만들어 보기로 했다. 평소 자주 이용하기도 했고 과제의 평가 기준에서 요구하는 기능들을 다 담고 있기도 했기 때문에 이번 기회에 구현해 보면 그동안 배운 것들을 활용하기에 좋을 거 같았다.
  • 평가 기준에 사이트의 디자인 구현(HTML/CSS 등 프론트엔드)은 포함되지 않기 때문에 본인이 쓰고 싶은 HTML/CSS 템플릿을 구한 뒤 회원 관리 기능을 구현하면 된다.


진행상황

  • openAPI 사용법을 익히고 싶어서 휴대폰 번호 인증하기 기능을 만들어 보았다.
  • 그동안 openAPI 쓰면 쉽게 만들 수 있다, 개발이 편해진다 하는 말을 참 많이 들었는데 직접 해 보니까 쉽지 않아서 많이 헤멨다… 😔 처음 해 보는 것이라 그런걸까?
  • 그리고 아직 Spring framework는 배우지 않은 상태라서 JavaServlet을 이용해 MVC Model2 패턴으로 구현해야 하는데 대부분의 자료는 Spring framework를 이용한 것이라 더 어렵게 느껴졌던 것 같다. 하지만 지금까지는 코드로 일일이 구현해 왔던 것을 Spring에서는 어노테이션으로 처리할 수 있는 것을 보니까 빨리 Spring을 배워 보고 싶어졌다.
  • 그래도 이번 구현을 통해 휴대폰 번호 인증 로직이 어떻게 돌아가는 지 알게 되었고 openAPI도 어떤 방식으로 사용하는지 어느 정도 알게 되어서 좋은 경험이었다고 생각한다.

1. 네이버 클라우드 회원가입 하기

  • 대부분의 휴대폰 번호 인증 API는 유료였는데 네이버 클라우드의 API는 한 달에 50건까지 무료라서 사용했다.

  • NAVER CLOUD PlATFORM[SENS API] - 네이버 문자 인증
  • 기본적인 프로젝트 생성 방법은 이 블로그 글을 참고했다.
  • 처음엔 Products & Services 탭에서 활성화 된 메뉴가 아주 적을 수 있는데 결제 수단을 등록해 주면 모든 메뉴를 볼 수 있다.

2. join.jsp에 휴대폰 번호 인증란 추가

<!-- join.jsp -->

...
생략

<div class="formRow">
    <label for="MOD_TEXTFORM_TelField">휴대폰 번호 </label>
    <label class="phone">
    <input id="phone1" type="tel" name="phone1" size="1" maxlength="3" oninput="tabCursor(1)"> - 
    <input id="phone2" type="tel" name="phone2" size="3" maxlength="4" oninput="tabCursor(2)"> - 
    <input id="phone3" type="tel" name="phone3" size="3" maxlength="4">
    <button id="requestBtn" class="btn" type="submit" name="requestBtn">휴대폰인증</button>
    </label>
</div>
<div id="phoneMsg"></div>
<div class="formRow">
    <label for="MOD_TEXTFORM_EmailField">인증번호 </label>
    <label class="phone">
    <input type="text" name="validateNum" id="validateNum">
    <button id="validateBtn" class="btn" type="submit" name="validateBtn">인증번호 확인</button>
    </label>
</div>
<div id="validateMsg"></div>

...
생략

3. 휴대폰 번호 인증을 처리하는 클래스 만들기

// SmsService.java

import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Random;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.json.simple.JSONArray;
import org.json.simple.JSONObject;

public class SmsService 
{
    private String makeSignature(String method, String url, String timestamp, String accessKey, String secretKey) throws UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException
    {
        String space = " ";					// one space
        String newLine = "\n";					// new line
		
        String message = new StringBuilder()
            .append(method)
            .append(space)
            .append(url)
            .append(newLine)
            .append(timestamp)
            .append(newLine)
            .append(accessKey)
            .toString();
		
        SecretKeySpec signingKey;
        String encodeBase64String;
        try {
            signingKey = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256");
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(signingKey);
            byte[] rawHmac = mac.doFinal(message.getBytes("UTF-8"));
            encodeBase64String = Base64.getEncoder().encodeToString(rawHmac);
        }
        catch (Exception e) {
            encodeBase64String = e.toString();
        }
		
    return encodeBase64String;
}
	
//	{
//    "type":"(SMS | LMS | MMS)", // sms 타입
//    "contentType":"(COMM | AD)", // 메세지 타입
//    "countryCode":"string", // 국가번호
//    "from":"string",		// 발신번호
//    "subject":"string",		// 기본 메시지 제목
//    "content":"string",		// 기본 메시지 내용
//    "messages":[			// 메시지 정보(Object) - 최대 1000개
//        {
//            "to":"string",	// 수신번호(String)
//            "subject":"string", // 개별 메시지 제목(String)
//            "content":"string"  // 개별 메시지 내용(String)
//        }
//    ]
//}
	
    @SuppressWarnings("unchecked")
    public int sendSms(String dstPhoneNumber)
    {
        System.out.println("sendSms() 호출");
		
        String method = "POST";					// method
        String smsUrl = "https://sens.apigw.ntruss.com";	// url (include query string)
        String requestUrl = "/sms/v2/services/";
        String requestUrlType = "/messages";
        String accessKey = "{user access key id}";			// access key id (from portal or Sub Account)
        String secretKey = "{user secret key}";
        String serviceId = "{user service id}";
        String timestamp = Long.toString(System.currentTimeMillis());			// current timestamp (epoch)
		
        requestUrl += serviceId + requestUrlType;
        String apiUrl = smsUrl + requestUrl;
		
        JSONObject bodyJson = new JSONObject();
        JSONObject toJson = new JSONObject();
        JSONArray toArr = new JSONArray();
		
        toJson.put("to", dstPhoneNumber);
        toArr.add(toJson);
		
        bodyJson.put("type", "SMS");
        bodyJson.put("contentType", "COMM");
        bodyJson.put("countryCode", "82");
        bodyJson.put("from", "발신번호로 등록한 폰번호");
        bodyJson.put("subject", "[Web 발신]");

        // 난수 생성
        int min = 1000;
        int max = 9999;
        int validateNum = (int) (Math.random() * (max - min + 1)) + min;
        bodyJson.put("content", "인증번호 : " + Integer.toString(validateNum));
        System.out.println("validateNum : " + validateNum);
		
        bodyJson.put("messages", toArr);
		
        String body = bodyJson.toString();
		
        System.out.println("body : " + body);
		
        try {
            URL url = new URL(apiUrl);
			
            HttpURLConnection con = (HttpURLConnection) url.openConnection();
            con.setUseCaches(false);
            con.setDoOutput(true);
            con.setDoInput(true);
            con.setRequestProperty("Content-Type", "application/json");
            con.setRequestProperty("x-ncp-apigw-timestamp", timestamp);
            con.setRequestProperty("x-ncp-iam-access-key", accessKey);
            con.setRequestProperty("x-ncp-apigw-signature-v2", makeSignature(method, requestUrl, timestamp, accessKey, secretKey));
            con.setRequestMethod(method);
            con.setDoOutput(true);
            DataOutputStream dos = new DataOutputStream(con.getOutputStream());
			
            dos.write(body.getBytes());
            dos.flush();
            dos.close();
			
            int responseCode = con.getResponseCode();
            BufferedReader br;
            System.out.println("responseCode : " + responseCode);
            if (202 == responseCode)
            {
                // 정상호출
                br = new BufferedReader(new InputStreamReader(con.getInputStream()));
            }
            else 
            {
                // 에러발생
                br = new BufferedReader(new InputStreamReader(con.getErrorStream()));
            }
			
            String inputLine;
            StringBuffer response = new StringBuffer();
            while (null != (inputLine = br.readLine()))
                response.append(inputLine);
			
            br.close();
			
            System.out.println(response.toString());
        }
        catch (Exception e) {
            System.out.println(e);
        }
		
        System.out.println("sendSms() 끝");
		
        return validateNum;
    }
}
  • 난수를 발생시켜서 인증번호를 만들었으며 사용자가 입력한 번호와 비교하기 위해서 생성된 난수를 리턴하도록 했다.
  • 난수는 4자리 숫자를 만들도록 범위를 지정해 주었다.

4. 문자 전송 결과를 받아올 서블릿 생성

import java.io.IOException;
import java.io.PrintWriter;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@WebServlet("/validatePhone.me")
public class ValidatePhone extends HttpServlet 
{
	protected void doProcess(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException 
	{
		resp.setContentType("text/html; charset=utf-8");
		
		SmsService smsService = new SmsService();
		PrintWriter out = resp.getWriter();
		
		int validateNum = smsService.sendSms(req.getParameter("phone"));
		out.print(validateNum);
		
		out.close();
	}
	
	@Override
	protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException 
	{
		doProcess(req, resp);
	}

	@Override
	protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException 
	{
		doProcess(req, resp);
	}
}
  • 문자 전송 후 발생된 난수를 받아와 출력 스트림을 이용해 response 객체로 전달하도록 했다.

5. join.js에서 join.jsp와 인증 페이지를 연결하는 코드 작성

...
생략

var $validateNum;
var $checkValidate = false;
 
$(document).ready(function()
{
    ...
    중략
    
    getToken();
    checkValidateNum();
});

function getToken()
{
    $('#requestBtn').click(function()
    {
        phoneCheck();
        if ($phone)
        {
            var phoneNum = $('#phone1').val() + $('#phone2').val() + $('#phone3').val();
            $.ajax({
                type: 'POST',
                async: false,
                url: './validatePhone.me',
                data: {
                    'phone': phoneNum
                },
                dataType: 'text',
                success: function(data)
                {
                    alert('인증번호 전송 완료!');
                    $validateNum = data;
                }
            });					
        }
    });
}

function checkValidateNum()
{
    $('#validateBtn').click(function()
    {
        var userInput = $("#validateNum").val();
        if ($validateNum === userInput)
        {
            $checkValidate = true;
            $("#validateMsg").text("인증이 완료되었습니다!");
            $("#validateMsg").css("color", "green");
            $("#validateBtn").prop("disabled", true);
            $("#requestBtn").prop("disabled", true);
            $("#validateBtn").css("background", "gray");
            $("#requestBtn").css("background", "gray");
        }
        else 
        {
            $checkValidate = false;
            $("#validateMsg").text("인증번호가 일치하지 않습니다.");
            $("#validateMsg").css("color", "red");
        }
    });
}

// 마지막 제출 전에 유효성 검사하는 함수
function finalCheck() 
{
    var join = document.join;
    if (!$id)
    {
        join.id.focus();
        return false;
    }
		
    if (!$pass)
    {
        join.pass.focus();
        return false;
    }

    if (!$passConfirm)
    {
        join.confirm.focus();
        return false;
    }

    if (!$name)
    {
        join.name.focus();
        return false;
    }

    if (!$birth)
    {
        join.birth.focus();
        return false;
    }
	
    genderCheck();
    if (!$gender)
        return false;

    cityCheck();
    if (!$city)
        return false;
	
    phoneCheck();
    if (!$phone)
    {
        join.phone1.focus();
        return false;
    }
	
    if (!$checkValidate)
    {
        join.validateNum.focus();
        return false;
    }
	
    if (!$email)
    {
        join.email.focus();
        return false;
    }
} 

...
생략
  • 서블릿을 이용해 문자 전송 요청을 보내고 발생된 난수를 받아올 것이기 때문에 Ajax를 이용했다.
  • 인증번호 확인 버튼을 누르면 서블릿에서 받아온 번호와 비교해서 일치하는지 아닌지 확인 후 결과를 알려준다.



오늘 얻은 팁

  • 만약 css 변경 사항이 적용되지 않을 때에는 css 파일 경로 설정하는 부분의 맨 뒤에 ?를 붙여주면 된다.
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/modules.css?after">
  • 이런 식으로 맨 뒤에 ?를 붙이고 다음에는 아무 말이나 입력해도 되고 입력하지 않아도 된다.

  • 이제 로그인 기능 만들어야지…


마감까지

  • D-24