개주 훈련일지/🏋️ 전집중 호흡 훈련

이메일 인증 완료 후 비밀번호 변경까지 - Ajax 3단계 + 최종 ResetAction 적용기

lshfood2 2025. 12. 31. 23:31

 

 

비밀번호 찾기 UX 개선: 아이디 확인 → 이메일 자동 세팅 → 인증코드 발송(AJAX/Servlet)

[ 구현 배경 ]비로그인 상태에서 비밀번호 재설정을 만들 때,단순히 “이메일 입력 → 코드 발송”으로 끝내면위험 포인트가 많다. 그래서 이번 목표는 아래 흐름을안전하게 만드는 것이다.사용

lshfood2.tistory.com

 

1편에서 “비밀번호 찾기 UI”를 단순 폼 제출이 아니라

비동기(Ajax) 3단계로 나눠서 설계한 이유는 하나였다.

“이메일 인증이 끝나기 전에는
비밀번호 변경이 절대 일어나면 안 된다.”

 

이번 2편에서는

인증 완료 → 새 비밀번호 입력 → 최종 변경(Action)까지 흐름을 완성한다.

 

특히 핵심은 서버 검증을 2중으로 걸어,

프론트 조작으로 우회가 불가능하게 만드는 것이다.


1. 전체 흐름 요약 (최종 구조)

 

사용자 흐름 (프론트)

  1. 아이디 확인 버튼 → 회원 존재 + 이메일 조회 (Ajax)
  2. 인증번호 발송 버튼 → 메일 발송 + 세션에 code/expire 저장 (Ajax)
  3. 인증번호 확인 버튼 → 세션 code 검증 + verified=true 저장 (Ajax)
  4. 새 비밀번호 입력 → 정규식/일치 검사 통과하면 제출 버튼 활성화
  5. 비밀번호 변경(submit) → FindPasswordResetAction 으로 최종 변경

서버 흐름 (백엔드)

  1. FindPasswordMemberLookupServlet
    : 아이디 조회 + 이메일 반환
  2. FindPasswordSendCodeServlet
    : 코드 생성 + 메일 발송 + 세션 저장 + 재발송 5초 제한
  3. FindPasswordVerifyCodeServlet
    : 코드/만료 검증 + verified=true
  4. FindPasswordResetAction :
    verified/만료/세션값 검증 후 DB 비밀번호 update

2. (중요) 최종 변경은 무조건 Action에서만 처리한다

프론트에서 “verified=true” 같은 변수를 바꿔도

의미 없게 만들려면 최종 비밀번호 변경은

서버 세션 검증을 통과해야만 실행되도록 해야 한다.

 

즉, Ajax는 “인증을 도와주는 수단”이고,
DB 업데이트는 Action 한 곳에서만 하게 만든다.


3. 핵심 구현 포인트 3가지

 

핵심 1)

이메일은 사용자가 입력한 값을 믿지 않는다

메일 발송할 때 JSP에서 memberEmail이 보이지만,
서버는 memberName으로 DB에서 이메일을 다시 조회해서 발송한다.

  • 이유: 사용자가 개발자도구로 이메일 바꿔치기 가능
  • 해결: memberName만 받고 DB에서 이메일 로딩

핵심 2)

인증 유효시간(3분)은 “프론트 타이머”가

아니라 “서버 만료 시간”이 기준이다

프론트 타이머는 UX용이고,
진짜 보안 기준은 서버 세션의 expireAt 비교다.

  • expireAt = System.currentTimeMillis() + 180000
  • verify/reset 둘 다 expireAt 체크 통과해야 함

핵심 3)

재발송은 “5초 뒤에 가능”하도록

UX + 서버 모두 제한한다

메일 발송 버튼은 서버 호출 자체가 느릴 수 있고, 연타하면 문제 생긴다.

그래서

  • 프론트에서 재발송 버튼 클릭 후 5초 비활성화 + 카운트 표시
  • 서버에서도 lastSendAt 저장해 5초 이내 재요청 거절

4. 최종 코드

아래 코드들은 “2편 완성 기준”으로

바로 적용 가능한 형태로 정리했다.

 

4-1) FindPasswordVerifyCodeServlet (인증번호 확인)

package controller;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

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

import com.google.gson.Gson;

@WebServlet("/FindPasswordVerifyCode")
public class FindPasswordVerifyCodeServlet extends HttpServlet {
    private static final long serialVersionUID = 1L;

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {

        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/json; charset=UTF-8");

        Gson gson = new Gson();
        Map<String, Object> result = new HashMap<>();

        String code = request.getParameter("code");
        if (code == null || code.trim().isEmpty()) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            result.put("success", false);
            result.put("message", "인증번호를 입력해주세요.");
            response.getWriter().print(gson.toJson(result));
            return;
        }
        code = code.trim();

        HttpSession session = request.getSession(false);
        if (session == null) {
            result.put("success", false);
            result.put("message", "인증 세션이 없습니다. 다시 인증을 진행해주세요.");
            response.getWriter().print(gson.toJson(result));
            return;
        }

        String savedCode = (String) session.getAttribute("findPasswordCode");
        Long expireAt = (Long) session.getAttribute("findPasswordExpireAt");

        if (savedCode == null || expireAt == null) {
            result.put("success", false);
            result.put("message", "인증 정보가 없습니다. 다시 인증을 진행해주세요.");
            response.getWriter().print(gson.toJson(result));
            return;
        }

        // 만료 체크
        if (System.currentTimeMillis() > expireAt) {
            result.put("success", false);
            result.put("message", "인증번호가 만료되었습니다. 재발송 후 다시 시도해주세요.");
            response.getWriter().print(gson.toJson(result));
            return;
        }

        // 코드 불일치
        if (!savedCode.equals(code)) {
            result.put("success", false);
            result.put("message", "인증번호가 일치하지 않습니다.");
            response.getWriter().print(gson.toJson(result));
            return;
        }

        // 인증 성공
        session.setAttribute("findPasswordVerified", true);

        result.put("success", true);
        response.getWriter().print(gson.toJson(result));
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        doPost(request, response);
    }
}

 

4-2) FindPasswordResetAction (최종 비밀번호 변경)

이 Action은 DB UPDATE를 하는 유일한 장소다.

package controller.member;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import controller.common.Action;
import controller.common.ActionForward;
import model.dao.MemberDAO;
import model.dto.MemberDTO;

public class FindPasswordResetAction implements Action {

    @Override
    public ActionForward execute(HttpServletRequest request, HttpServletResponse response) {

        ActionForward forward = new ActionForward();
        forward.setRedirect(false);

        // 1) 세션 확인
        HttpSession session = request.getSession(false);
        if (session == null) {
            request.setAttribute("msg", "인증 정보가 없습니다. 다시 진행해주세요.");
            request.setAttribute("location", "findPasswordPage.do");
            forward.setPath("message.jsp");
            return forward;
        }

        // 2) 인증 완료 여부
        Boolean verified = (Boolean) session.getAttribute("findPasswordVerified");
        if (verified == null || verified == false) {
            request.setAttribute("msg", "이메일 인증이 필요합니다.");
            request.setAttribute("location", "findPasswordPage.do");
            forward.setPath("message.jsp");
            return forward;
        }

        // 3) 만료 체크
        Long expireAt = (Long) session.getAttribute("findPasswordExpireAt");
        if (expireAt == null || System.currentTimeMillis() > expireAt) {
            request.setAttribute("msg", "인증 시간이 만료되었습니다. 다시 인증해주세요.");
            request.setAttribute("location", "findPasswordPage.do");
            forward.setPath("message.jsp");
            return forward;
        }

        // 4) 대상 회원 ID 확인
        Integer memberId = (Integer) session.getAttribute("findPasswordMemberId");
        if (memberId == null) {
            request.setAttribute("msg", "대상 회원 정보가 없습니다.");
            request.setAttribute("location", "findPasswordPage.do");
            forward.setPath("message.jsp");
            return forward;
        }

        // 5) 새 비밀번호 수신
        String newPw = request.getParameter("memberPassword");
        if (newPw == null || newPw.trim().isEmpty()) {
            request.setAttribute("msg", "새 비밀번호를 입력해주세요.");
            request.setAttribute("location", "findPasswordPage.do");
            forward.setPath("message.jsp");
            return forward;
        }
        newPw = newPw.trim();

        // 6) DB 업데이트
        MemberDAO dao = new MemberDAO();
        MemberDTO dto = new MemberDTO();
        dto.setCondition("MEMBER_PASSWORD_UPDATE");
        dto.setMemberId(memberId);
        dto.setMemberPassword(newPw);

        boolean ok = dao.update(dto);

        if (!ok) {
            request.setAttribute("msg", "비밀번호 변경에 실패했습니다. 다시 시도해주세요.");
            request.setAttribute("location", "findPasswordPage.do");
            forward.setPath("message.jsp");
            return forward;
        }

        // 7) 인증 세션 정리 (재사용 방지)
        session.removeAttribute("findPasswordMemberId");
        session.removeAttribute("findPasswordEmail");
        session.removeAttribute("findPasswordCode");
        session.removeAttribute("findPasswordExpireAt");
        session.removeAttribute("findPasswordVerified");
        session.removeAttribute("findPasswordLastSendAt");

        request.setAttribute("msg", "비밀번호가 변경되었습니다. 로그인 해주세요.");
        request.setAttribute("location", "loginPage.do");
        forward.setPath("message.jsp");
        return forward;
    }
}

 

4-3) ActionFactory 등록

비동기가 아닌 액션 클래스는 팩토리 등록이 필요하다.

map.put("/findPasswordReset.do", new FindPasswordResetAction());

 

4-4) findpassword.jsp (폼 action + 재발송 5초 UX 포함)

핵심: submit은 findPasswordReset.do 로 간다.

<form action="<%=request.getContextPath()%>/findPasswordReset.do" method="post">
    ...
</form>

 

그리고 재발송 5초 UX는 JS에서

이렇게 처리하는 게 “완성” 느낌이 난다.

function lockResendButton5s() {
  let left = 5;
  $("#resendBtn").prop("disabled", true).text("재발송 (" + left + "s)");
  const t = setInterval(() => {
    left--;
    $("#resendBtn").text("재발송 (" + left + "s)");
    if (left <= 0) {
      clearInterval(t);
      $("#resendBtn").prop("disabled", false).text("재발송");
    }
  }, 1000);
}

 

그리고 재발송 성공 직후에 추가한다.

lockResendButton5s();

 

4-5) 서버에서도 5초 재발송 제한 넣기 (SendCodeServlet)

프론트만 막으면 개발자도구로 무한 호출 가능해서,

서버에서도 한 번 막아주는 게 안전하다.

Long lastSendAt = (Long) session.getAttribute("findPasswordLastSendAt");
long now = System.currentTimeMillis();

if (lastSendAt != null && (now - lastSendAt) < 5000) {
    result.put("success", false);
    result.put("message", "재발송은 5초 후 가능합니다.");
    response.getWriter().print(gson.toJson(result));
    return;
}

session.setAttribute("findPasswordLastSendAt", now);

5. 최종 파일 목록 정리 (2편 결과물)

 

JSP

findpassword.jsp

- Ajax 3단계 연결

- 인증 성공 후 비밀번호 입력 활성화

- submit → findPasswordReset.do

 

Servlet (Ajax)

FindPasswordMemberLookupServlet

FindPasswordSendCodeServlet(+ 재발송 5초 제한 optional)

FindPasswordVerifyCodeServlet

 

Action (최종 변경)

FindPasswordResetAction

ActionFactory에 /findPasswordReset.do 등록

 

Util

EmailService (메일 발송 담당)

 

이렇게 구성하면 프론트에서 인증 UI를

다 처리하는 것처럼 보여도,

 

최종 변경은 서버가 세션으로 검증하는 구조라

우회가 거의 불가능해진다.