Where The Streets Have No Name

이중 submit막기 본문

Developement/Java

이중 submit막기

highheat 2006. 5. 1. 10:36
Database를 다루다 보면 종종 이중 중복되어 insert되는 경우가 있다. 아니면 쇼핑몰에서 이중으로 주문이 되는 경우가 있다. 이는 사이트에서 전체 프로세스를 개발자가 100% 컨트롤할 수 없고 사용자가 interrupt할 수 있기때문에 발생하는 문제이다.

즉 사용자가 F5를 누르는 것을 우리는 컨트롤할 수 없다.


그러나 이를 극복하기 위해서는 사용자가 F5를 누르거나 Back 버튼을 눌렀을 때 이중으로 처리되지 않게 처리하면 된다. 그 방법은 간단하다. 데이터를 submit하기 전에 간단한 유일한 문자열 token을 심어주고 성공적인 처리 후 이 token을 다른 값으로 바꾼다. 그리고 submit을 처리하는 순간 두 값을 비교하여 다른 경우 처리를 하지 않게 하는 것이다.

여기서는 하나의 문자열을 서블릿으로 submit하고 서블릿에서 jsp 문서로 forward하여 "Hello~ 문자열"을 출력할 것이다. 만일 F5를 눌러 재시도시 "Hello~ 문자열" 대신 "Hellow~ theclub"을 출력할 것이다.

파일은 모두 5개이다.
1. TokenFormjava : Token을 설정하고 TokenForm.jsp으로 forward한다.
1. TokenForm.jsp : 문자열을 넘길 form을 가진 jsp 문서
2. Hellow.java : 문자열을 받아 Token의 유효 여부에 따라 name 변수를 request에 설정한다.
3. Hellow.jsp : "Hellow~ 문자열" 또는 "Hellow~ theclub"을 출력한다.
4. Token.java : Token을 셋팅하고 두개의 token이 일치하는지 여부를 결정하는 메쏘드가 있다.

우선 TokenForm.java은 다음과 같다.

===== TokenForm.java =====
import javax.servlet.*;
import javax.servlet.http.*;

import TokenTool.Token;

public class TokenForm extends HttpServlet{

public void doGet(HttpServletRequest request, HttpServletResponse response)
  throws ServletException{

  Token.set(request);

  String target="/TokenForm.jsp";

  RequestDispatcher dispatcher;
  dispatcher = getServletContext().getRequestDispatcher(target);
  try {
      dispatcher.forward(request, response);
  }catch(Exception e){
      System.err.println(e);
  }
}

public void doPost(HttpServletRequest request, HttpServletResponse response)
  throws ServletException{

  doGet(request, response);
}
}
=========================

일단 TokenTool.Token 이라는 핵심 클래스를 import한다. 이 클래스는 나중에 자세히 다룰 것이다.

  Token.set(request);

위의 줄은 Token 클래스의 set 매소드를 실행시키는 것으로 SESSIONID와 시간을 이용해서 유일한 문자열을 생성한 후 request와 session에 각각 token이라는 변수명으로 저장한다.

아래의 코드 중 target이라는 String 변수는 TokenForm.jsp라는 form을 통한 문자열을 넘기기 위해서 마련된 파일이다. 이 파일로 RequestDispatcher를 사용하여 forward시킨다. 이 forward는 redirect와 달라 브라우저의 url이 바뀌지 않는다. 그러므로 inclue와 비슷한 효과를 낼 수 있다. RequestDispatcher를 사용한 이유는 request와 response를 넘기기 위해서 이다. RequestDispatcher를 이용하면 request와 response를 넘길 수 있다.

  String target="/TokenForm.jsp";

  RequestDispatcher dispatcher;
  dispatcher = getServletContext().getRequestDispatcher(target);
  try {
      dispatcher.forward(request, response);
  }catch(Exception e){
      System.err.println(e);
  }

이렇게 하면 브라우저의 url은 TokenForm이면서 화면에는 name을 입력하는 화면이 나올 것이다.

===== TokenForm.jsp =====
<html>
<head>
<title>JspGeek.com</title>
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
</head>

<body>

<form method="post" action="Hellow">
name : <input type="text" name="name">
<input type="hidden" name="token" value="<%=request.getAttribute("token")%>">
<input type="submit" value="submit">
<%=request.getAttribute("token")%>
</form>
</body>
</html>
=========================
여기서는 별 게 없고 단지 TokenForm.java의 Token.set(request)을 통하여 request에 저장한 token의 값을 <%=request.getAttribute("token")%>으로 가져와, Token이라는 hidden 필드에 값을 넣는다. <%=request.getAttribute("token")%>는 현재 설정된 token의 값을 알아 보기 위한 것이다. 이 화면에서 F5를 누르게 되면 이 token 값이 변하는 것을 알 수 있는데 그 이유는 token의 값을 생성할 경우 시간을 이용하므로 시간이 변하는 만큼 값도 변하게 된다.

여기서 이름을 넣고 submit버튼을 눌러 보자. 그러면 action="Hellow"에 따라 Hellow.java 파일로 데이터가 넘어가게 된다.

===== Hellow.java =====
import javax.servlet.*;
import javax.servlet.http.*;

import TokenTool.Token;

public class Hellow extends HttpServlet{

public void service(HttpServletRequest request, HttpServletResponse response)
  throws ServletException{

  if(Token.isValid(request)){
    request.setAttribute("name", request.getParameter("name"));

    Token.set(request);
  }
  else {
    request.setAttribute("name", "theclub");
  }

  String target="/Hellow.jsp";

  RequestDispatcher dispatcher;
  dispatcher = getServletContext().getRequestDispatcher(target);
  try {
      dispatcher.forward(request, response);
  }catch(Exception e){
      System.out.println(e);
  }
}
}
=======================
여기서도 역시 TokenTool.Token를 import한다.
Token.isValid(request)의 유효성에 따라 request에 name 변수의 값을 다르게 지정한다. 즉 Token.isValid(request)가 true인 경우 위 form에서 입력한 문자열을 request에 저장한다. 다음 코드를 사용하면 된다.

    request.setAttribute("name", request.getParameter("name"));

그리고 Token.isValid(request)가 false인 경우, 즉 F5를 눌러 submit된 정보를 다시 submit하려고 할 경우에 request의 name 변수에 값으로 theclub을 저장해서, 한번 submit된 이후에는 항상 "Hellow~ theclub"이 나오도록 한다. 그러므로 Token.isValid(request)는 처음 submit일 경우 true를 두 번째 submit 부터는 false를 반환한다.

그 이후 부분은 위에서 설명한 것처럼 "Hellow~ 문자열"을 보여 줄 Hellow.jsp로 RequestDispatcher한다.



* 핵심 포인트 *

이 코드에서 아주 중요한 줄이 있는데 다음과 같다.

    Token.set(request);

Token.isValid(request)는 첫번째 submit인지 두번째부터 submit인지를 판단한다고 하였다. 그 원리는 간단하다. TokenForm.java에서 Token.set(request);에 의해서 token을 만들고 이 token을 TokenForm.jsp에서 hidden으로 넘긴다. 그러므로 여기까지는 session에 있는 token 값이나 hidden으로 넘어가는 token값이 같게 된다. 이럴 경우는 첫번째 submit으로 판단한다. 그러므로 이 두개의 값이 다를 경우 이 submit을 첫 번째 submit으로 판단하지 않는 것이다. 그러므로 다시 submit을 하려고 할 경우 즉 form의 hidden에 의해서 token을 넘어 가고 있을 경우 session에 있는 token 값이 다르다면 첫 번째 submit이 아님을 알 수 있다. 그러므로 첫번째 submit 순간에는 session에 있는 token 값이나 hidden에 있는 token 값이나 같을지라도, request에 name이라는 변수에 값을 넣는 바로 다음에(아래 코드를 이용해서)

    request.setAttribute("name", request.getParameter("name"));

session에 있는 token 값을 Update한다. 그 코드가 바로 다음 코드이다.

    Token.set(request);

database에 insert할 경우 성공한 바로 다음에 이 작업을 해 주어야 한다.

만일 이 작업이 이루어지지 않을 경우 hidden에 있는 token 값이나 session에 있는 token 값이나 같으므로 계속 submit 할 수 있게 된다.


===== Hellow.jsp =====
<html>
<head>
<title>JspGeek.com</title>
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
</head>

<body>
<%
if(request.getAttribute("name")!=null){
out.println("Hello~ "+request.getAttribute("name")+"<br>");
}
else {
out.println("No Names");
}
%>
</body>
</html>
======================

if(request.getAttribute("name")!=null)는 request에 name이라는 변수가 있는지 확인 한다. 즉 !=null로 null이 아닌 경우를 말한다. 그 경우 아래 코들 통하여 "Hello~ 문자열"로 인사를 하게 된다.

out.println("Hello~ "+request.getAttribute("name")+"<br>");

그러나 name이라는 문자열이 없는 경우 out.println("No Names");로 No Names 문자열을 출력한다.

아래의 Token.java를 덧붙인다. 이 클래스에 대해서는 다음 시간에 자세히 다루도록 할 것이다.

==== Token.java =====
package TokenTool;

import javax.servlet.http.*;
import java.security.*;

public class Token {
public static void set(HttpServletRequest req) {
HttpSession session = req.getSession(true);

long systime = System.currentTimeMillis();
byte[] time  = new Long(systime).toString().getBytes();
byte[] id = session.getId().getBytes();
try {
MessageDigest md5 = MessageDigest.getInstance("MD5");
md5.update(id);
md5.update(time);
String token = toHex(md5.digest());
req.setAttribute("token", token);
session.setAttribute("token", token);
}
catch (Exception e) {
System.err.println("Unable to calculate MD5 Digests");
}
}

public static boolean isValid(HttpServletRequest req) {
HttpSession session = req.getSession(true);
String requestToken = req.getParameter("token");
String sessionToken = (String)session.getAttribute("token");
if (requestToken == null || sessionToken == null)
return false;
else
return requestToken.equals(sessionToken);
}

private static String toHex(byte[] digest) {
StringBuffer buf = new StringBuffer();
for (int i=0; i < digest.length; i++)
buf.append(Integer.toHexString((int)digest[i] & 0x00ff));
return buf.toString();
}
}
=====================