비밀번호 찾기 UX 개선: 아이디 확인 → 이메일 자동 세팅 → 인증코드 발송(AJAX/Servlet)
[ 구현 배경 ]비로그인 상태에서 비밀번호 재설정을 만들 때,단순히 “이메일 입력 → 코드 발송”으로 끝내면위험 포인트가 많다. 그래서 이번 목표는 아래 흐름을안전하게 만드는 것이다.사용
lshfood2.tistory.com
1편에서 “비밀번호 찾기 UI”를 단순 폼 제출이 아니라
비동기(Ajax) 3단계로 나눠서 설계한 이유는 하나였다.
“이메일 인증이 끝나기 전에는
비밀번호 변경이 절대 일어나면 안 된다.”
이번 2편에서는
인증 완료 → 새 비밀번호 입력 → 최종 변경(Action)까지 흐름을 완성한다.
특히 핵심은 서버 검증을 2중으로 걸어,
프론트 조작으로 우회가 불가능하게 만드는 것이다.
1. 전체 흐름 요약 (최종 구조)
사용자 흐름 (프론트)
- 아이디 확인 버튼 → 회원 존재 + 이메일 조회 (Ajax)
- 인증번호 발송 버튼 → 메일 발송 + 세션에 code/expire 저장 (Ajax)
- 인증번호 확인 버튼 → 세션 code 검증 + verified=true 저장 (Ajax)
- 새 비밀번호 입력 → 정규식/일치 검사 통과하면 제출 버튼 활성화
- 비밀번호 변경(submit) → FindPasswordResetAction 으로 최종 변경
서버 흐름 (백엔드)
- FindPasswordMemberLookupServlet
: 아이디 조회 + 이메일 반환 - FindPasswordSendCodeServlet
: 코드 생성 + 메일 발송 + 세션 저장 + 재발송 5초 제한 - FindPasswordVerifyCodeServlet
: 코드/만료 검증 + verified=true - 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를
다 처리하는 것처럼 보여도,
최종 변경은 서버가 세션으로 검증하는 구조라
우회가 거의 불가능해진다.
'개주 훈련일지 > 🏋️ 전집중 호흡 훈련' 카테고리의 다른 글
| AniMale 서블릿 프로젝트를 Spring Boot(War)로 전환하는 과정 정리 (0) | 2026.02.03 |
|---|---|
| 카카오페이 결제 승인 응답 전달 방식 개선(PRG) (0) | 2026.01.06 |
| 비밀번호 찾기 UX 개선: 아이디 확인 → 이메일 자동 세팅 → 인증코드 발송(AJAX/Servlet) (0) | 2025.12.31 |
| 동기 검색/이동+비동기 페이지네이션 적용기 (0) | 2025.12.30 |
| 검색/이동은 동기, 데이터 로딩은 비동기 > 역할 분리 설계 (0) | 2025.12.28 |