✨회고

자바스크립트 리팩토링 회고🔍 - 폼 유효성 검사

2025. 5. 1. 13:54

내용이 좀 많이 길다..

 

💡 주요 리뷰 포인트

  • 요소는 명확하게 선택하기
  • 폼 유효성 검사는 폼에 위임하지 말자
  • 유효성 검사 함수는 유효성 검사만. (UI 업데이트와 분리하기)
  • closest 사용 지양
  • 클로저가 이 상황에 맞을까?
  • 적절한 함수 분리로 응집도 높이기
  • 태그 자체에 스타일 주지 말기 (reset.css로 보내거나, 클래스 줘서 스타일하기)
  • 기능들 init 함수로 초기화

 

이번에 로그인, 회원가입 폼을 자바스크립트로 구현하는 미션을 수행하면서 깨달은 점이 많다.

코드를 어떻게 써야 하는지, 어떻게 쓰면 안되는지 등...

특히 멘토님과 주강사님의 코드리뷰에서 내가 어떤 점을 놓치고 있었는지, 

코딩의 핵심은 무엇인지를 알게 되어서 굉장히 뜻깊은 시간이었다.

참고로 제목에 '자바스크립트 리팩토링'이라고 적어놓았지만 css 리팩토링도 살짝 다룰 예정이다. ('태그 자체에 스타일 주지 말기')

 

각설하고 바로 정리 들어가자.

 


✅ 요소는 명확하게 선택하기

[ bad case ]

const form = document.querySelector(".form");

 

[ refactored ]

const form = document.querySelector("#loginForm");
const form = document.querySelector("#signupForm");

 

=> DOM 요소를 선택할 때는 최대한 구체적으로 선택해야 한다.

 

나는  하나의 폼을 선택해서 이벤트 위임을 이용해 폼 안의 각 필드를 누르면 이벤트 리스너가 동작하도록 구현했었는데,

폼의 경우 얼마든지 규모가 더 커질 수도 있고, 더 다양한 페이지에서 쓰일 수 있기 때문에

반드시 폼마다 id를 줘서 id로 정확하게 해당 폼을 선택해주는 것이 좋다고 한다.

 


 

✅ 폼 유효성 검사는 폼에 위임하지 말자

위에서 다룬 내용과 이어진다.

폼에 버블링 형태로 이벤트리스너를 등록하면, 폼이 커질수록 form 하나가 다뤄야 할 이벤트리스너들도 많아진다.

그러면 개발자는 모든 폼들의 정보를 이해하고 있어야 필드들의 동작을 구분할 수 있기 때문에, 유지보수가 어려워진다.

 

=> 폼에 이벤트 위임을 하지 말고, 각 필드들에 이벤트 리스너를 따로 붙여주자.

 

[ bad case ]

form.addEventListener("focusout", createFormValidator());

 

[ refactored ]

// 폼 필드에 이벤트 리스너 등록
function attachFieldsValidation() {
  const targetFields = validatorKey.map((key) =>
    form.querySelector(`#${key}`)
  );

  targetFields.forEach((field) => {
    field.addEventListener("focusout", handleFieldValidation);
  });
}

 

 


 

✅ 유효성 검사 함수는 유효성 검사만 하자

필드 유효성 검사 결과에 따라 필드의 UI가 업데이트 되니까,

별 생각 없이 유효성 검사 함수 안에 UI 업데이트 기능까지 같이 포함했었다. 

하지만 UI 업데이트 기능은 여기서 분리될 수 있다. 

유효성 검사 함수가 결과를 리턴해주면, UI 업데이트 함수는 그 결과를 따로 받아서 처리해주면 되는 것이다.

 

=> 함수는 가급적 하나의 기능만 하도록 만드는 것이 유지보수, 확장성, 응집도 등 여러 측면에서 바람직하다.

 

또한 UI 업데이트 함수를 'validators.js'에서 'updateValidationUI.js'로 따로 모듈화하여 분리시켰다.

 

 

[ bad case ]

// validators.js
/* 유효성에 따른 UI 업데이트 함수 */
function setValid(input, valid = true, msg) {
  const inputHintEl = input
    .closest(".form-control")
    .querySelector(".form-input-hint");

  if (!valid) {
    input.classList.add("invalid");
    inputHintEl.textContent = msg;
    return;
  }

  input.classList.remove("invalid");
  inputHintEl.textContent = "";
}

/* 이메일 유효성 */
function validateEmail(input) {
  if (input.value.trim().length === 0) {
    setValid(input, false, "이메일을 입력해주세요.");  // ❗ 유효성 검사 함수에서 UI 업데이트까지 하고 있음
    return false;
  }

  if (!input.checkValidity()) {
    setValid(input, false, "잘못된 이메일 형식입니다.");
    return false;
  }

  setValid(input);
  return true;
}

 

 

[ refactored ]

// validators.js
/* 이메일 유효성 */
export function validateEmail(input) {
  if (input.value.trim().length === 0) {
    return { isValid: false, message: "이메일을 입력해주세요." };
  }
  if (!input.checkValidity()) {
    return { isValid: false, message: "잘못된 이메일 형식입니다." };
  }
  return { isValid: true, message: "" };
}
// updateValidationUI.js
export function updateValidationUI(input, validationResult) {
  const inputHint = input
    .closest(".form-control")
    .querySelector(".form-input-hint");

  if (!inputHint) return;

  !validationResult.isValid
    ? input.classList.add("invalid")
    : input.classList.remove("invalid");
  inputHint.textContent = validationResult.message;
}

 

참고로 두 모듈은 이렇게 연동된다.

// validateForm.js (새로 추가한 폼 전용 유효성 검사 모듈)
function handleFieldValidation(e) {
  // 필드별 유효성 검사 함수 가져오기
  const input = e.target;
  const validationFunc = inputValidatorMap[input.id];  // validators.js에 있는 필드별 유효성 검사 메서드 가져오기
  if (!validationFunc) return;

  // 유효성 검사 UI 업데이트 --- ✅ 두 모듈 연결 부분
  const validationResult = validationFunc(input);  // validators.js에서 유효성 결과 받아오기
  updateValidationUI(input, validationResult);  // 결과 기반 UI 업데이트

  // 모든 필드가 유효할 경우, 폼 제출 버튼 활성화
  validStateMap.set(input.id, validationResult.isValid);
  updateSubmitButtonState();  
}

validators.js의 각 필드별 유효성 검사기에서 리턴한 결과를 updateValidationUI에서 받아야 하기 때문에, 

'validateForm.js'라는 폼 유효성 검사 전용 모듈을 새로 만들어서 두 모듈을 연결했다.

 

 

 


 

✅ closest 사용 지양

디자인을 하다보면 html 구조가 변경될 때가 많아서,

closest를 통해 부모가 있다고 가정해서 요소를 선택하는 것보다는 따로 querySelector를 줘서 이벤트를 주는 것이 안정적이다.

 

[ bad case ]

export default function togglePasswordHandler(area = document.body) {
  area.addEventListener("click", (e) => {
    const toggleBtn = e.target.closest(".btn-password-visible");  // ❗ 비밀번호 표시/숨김 토글 버튼을 closest로 선택함
    if (!toggleBtn) return;

    const targetInput = toggleBtn.parentNode.querySelector(".form-input");
    if (!targetInput) return;

    const isVisible = toggleBtn.classList.toggle("on");
    targetInput.type = isVisible ? "text" : "password";
    targetInput.setAttribute("aria-pressed", `${isVisible}`);
  });
}

 

이 코드는 리팩토링 하기 전의 '비밀번호 표시/숨김 토글' 모듈인데,

사실 closest문제 뿐만 아니라 한 가지 문제점이 더 있다. 

파라미터의 범위가 매우 추상적(document.body)이라는 점이다. ('요소 명확하게 선택하기' 문제와 비슷)

이렇게 작성하면 문서 아무데나 클릭해도 이벤트 리스너가 실행되므로 성능상으로도 좋지 않다.

그래서 파라미터도 더 좁고 명확한 범위로 바꿔주었다.

 

 

[ refactored ]

export default function togglePasswordVisible(form) {
  const btns = form.querySelectorAll(".btn-password-visible");
  if (!btns) return;

  btns.forEach((btn) => {
    btn.addEventListener("click", () => {
      const targetInput = btn.parentNode.querySelector(".form-input");
      if (!targetInput) return;

      // 이하 동일...
    });
  });
}

 

 


 

✅ 클로저가 이 상황에 맞을까?

멘토님과 주강사님께 공통적으로 받은 리뷰였다.

 

클로저를 사용법에 맞게 쓰긴 했으나, 폼 유효성 검사를 구현하기 적절한 특성은 아니었다. (이를 '구현 명세와 맞지 않다' 라고도 하는 것 같다)

사실 클로저에 대한 사용법만 익힌 상태였기 때문에, 맞는 용도로 사용했는지까지는 고려하지 못했다.

 

클로저는 주로 내부 함수가 외부 스코프의 변수에 접근해야 할 때 사용된다고 하는데, 나는 이 점을 이용해서 '폼 전체 유효성 통과 여부 체크' 함수를 클로저로 구현했었다.

 

 

[ bad case 1 ]

// login.js, signup.js 공통
function createFormValidator() {
  let emailValid = false;  // 클로저: 자식 함수인 formValidate가 외부 변수로 참조
  let passwordValid = false;  // 클로저: 자식 함수인 formValidate가 외부 변수로 참조

  return function formValidate(e) {
    switch (e.target.id) {
      case "userEmail":
        emailValid = validateEmail(emailInput);
        break;
      case "userPassword":
        passwordValid = validatePassword(passwordInput);
        break;
    }

    loginBtn.disabled = !(emailValid && passwordValid);
  };
}

form.addEventListener("focusout", createFormValidator());

내가 해놓고도 뭔가 이상하다 싶었지만, 내가 이렇게 코드를 짠 데에는 나름의 이유가 있었다.

사실 클로저가 되기 전의 코드는 이러했다.

 

[ bad case 2 ]

// 클로저 사용 전
let emailValid = false;
let passwordValid = false;

function formValidate(e) {
  switch (e.target.id) {
    case "userEmail":
      emailValid = validateEmail(emailInput);
      break;
    case "userPassword":
      passwordValid = validatePassword(passwordInput);
      break;
  }

  loginBtn.disabled = !(emailValid && passwordValid);
};

form.addEventListener("focusout", formValidate);

이 경우, let으로 선언된 두 변수가 '전역' 범위에 있으므로 언제든지 다른 함수에서 같은 이름의 변수를 사용한다면 영향(side-effect, 부작용)을 받게 된다.

그래서 원래는 formValidate 함수 안에 선언하여 사용하려고 했으나, 그러면 두 변수에 상태 저장이 잘 되지 않았다.

그 이유는 switch문을 잘못 사용했기 때문이다.

 

위 코드는 switch의 break 특성에 따라서,

  • emailValid에 유효성 검사 결과를 받으면 break로 switch를 빠져나오기 때문에 passwordValid에 값을 마저 담을 수 없고,
  • 반대로 passwordValid에 유효성 검사 결과를 받아도 역시 break로 switch를 빠져나오기 때문에 emailValid에 값을 담지 못한다.

이 상태에서 다음 구문인 loginBtn.disabled 제어문으로 넘어가면 결과는 반드시 둘 중 하나의 값이 undefined가 된다.

아래는 당시 console.log(emailValid, passwordValid)를 했을 때의 결과다.

true undefined
undefined true

 

이러면 영원히 폼 유효성 검사 완료를 할 수 없다.😨

 

그래서 switch문의 영향을 받지 않도록 전역변수로 옮기게 되었는데, (여기서 switch문을 쓸 이유도 없어짐)

그러면 또 let 변수를 전역으로 설정했다는 문제가 발생하게 되어서 

어쩔 수 없이 let 변수들을 또 하나의 함수로 감싸서 데이터를 보호하려다 보니 클로저가 구현된 것이었다.

 

대충 클로저의 목적과 사용법에는 맞으나.. '폼 유효성 검사'를 구현하는 데 있어서는 굳이 이럴 필요가 없었던 것이다.

 

따라서 아예 기능 구현 방법을 다시 작성했다.

이번 미션에서는 이렇게 login.js, signup.js에 불편하게 뭉쳐있던 클로저를 풀어헤치고, 다시 정리하는 데에 가장 많은 시간을 보낸 것 같다.

 

 

[ refactored ]

코드 위치도 login.js, signup.js → validateForm 모듈로 옮겼다.

// validateForm.js
// 폼 필드 유효성 검사
function handleFieldValidation(e) {
  const input = e.target;
  const validationFunc = inputValidatorMap[input.id];
  if (!validationFunc) return;

  // 유효성 검사 UI 업데이트
  const validationResult = validationFunc(input);
  updateValidationUI(input, validationResult);

  // 유효성 상태 업데이트
  validStateMap.set(input.id, validationResult.isValid);
  updateSubmitButtonState();
}

// 제출 버튼 상태 변경
function updateSubmitButtonState() {
  const isAllValid = [...validStateMap.values()].every(Boolean);
  formButton.disabled = !isAllValid;
}

 

 

이렇게 클로저를 풀어낸 내용을 validateForm으로 모듈화함으로써 login.js, signup.js 코드는 매우 간결해지고, 핵심 내용만 갖게 되었다.

코드만 봐도 여기서 어떤 일들이 일어나는지 바로 알 수 있다.

// login.js
import { validateEmail, validatePassword } from "../util/validators.js";
import togglePasswordVisible from "./togglePasswordVisible.js";
import validateForm from "./validateForm.js";
import focusFirstField from "../util/focusFirstField.js";
import { REDIRECT_MAP } from "../constants.js";

const form = document.querySelector("#loginForm");
const formButton = document.querySelector("#loginBtn");

function init() {
  validateForm({
    form,
    formButton,
    inputValidatorMap: {
      userEmail: validateEmail,
      userPassword: validatePassword,
    },
    onSubmitRedirectUrl: REDIRECT_MAP[window.location.pathname],
  });
  togglePasswordVisible(form);
  focusFirstField(form);
}

window.addEventListener("DOMContentLoaded", init);
// signup.js
import {
  validateEmail,
  validatePassword,
  validatePasswordCheck,
  validateNickname,
} from "../util/validators.js";
import togglePasswordVisible from "./togglePasswordVisible.js";
import validateForm from "./validateForm.js";
import focusFirstField from "../util/focusFirstField.js";
import { REDIRECT_MAP } from "../constants.js";

const form = document.querySelector("#signupForm");
const formButton = document.querySelector("#signupBtn");

function init() {
  validateForm({
    form,
    formButton,
    inputValidatorMap: {
      userEmail: validateEmail,
      userPassword: validatePassword,
      userPasswordChk: validatePasswordCheck,
      userNickname: validateNickname,
    },
    onSubmitRedirectUrl: REDIRECT_MAP[window.location.pathname],
  });
  togglePasswordVisible(form);
  focusFirstField(form);
}

window.addEventListener("DOMContentLoaded", init);

(이렇게 되기까지 멘토님의 정말 많은 도움이 있었다)

 

+ 클로저는 그럼 어떤 경우에 사용하는 게 적절한 지는 좀 더 조사가 필요할 것 같다. 아직 감이 잘 안 온다.

+ useState, useRef가 클로저를 사용하는 방식이라고 한다. 이쪽으로 알아보면 좋을 것 같다.

 

 

 


 

✅ 적절한 함수 분리로 응집도 높이기

위에서 이미 여러 함수들을 분리하면서 응집도가 높아졌지만, 아직 분리(모듈화)가 필요한 작은 함수들이 남아있어서 추가로 진행했다.

 

[ bad case ]

// login.js, signups.js 하단 공통 코드
/* 비밀번호 토글 */
togglePasswordHandler(form);

/* UX: 첫번째 input focus 처리 */
window.addEventListener("DOMContentLoaded", () => {
  form.querySelector(".form-input").focus();
});

/* 로그인 버튼 클릭 시 'items'로 이동 */
loginBtn.addEventListener("click", (e) => {
  e.preventDefault();
  location.href = "/items.html";
});

 

 

[ refactored ]

// login.js, signup.js 공통 코드
function init() {
  validateForm({
    {
      // 각 페이지에 따른 폼 유효성 검사용 arguments...
    },
    onSubmitRedirectUrl: REDIRECT_MAP[window.location.pathname],
  });
  togglePasswordVisible(form);
  focusFirstField(form);
}

window.addEventListener("DOMContentLoaded", init);
  • 부가 기능들 (패스워드 표시/숨김 토글, 폼 첫번째 필드 자동 포커스)은 모듈화하여 함수 한 줄만 추가하도록 수정.
  • 활성화된 폼 제출 버튼 클릭하면 다른 페이지로 이동하는 함수: validateForm에 통합하여 인자로만 전달

 

위와 같이 로그인, 회원가입의 validateForm()을 통해 전달된 인자들은 validateForm 모듈에서 이렇게 처리되어 사용된다.

// validateForm.js
export default function validateForm({
  form,
  formButton,
  inputValidatorMap,
  onSubmitRedirectUrl,
}) {
  const validatorKey = Object.keys(inputValidatorMap);  // inputValidatorMap: 각 페이지별 필드들을 {id: validate함수} 형태로 전달받음
  const validStateMap = new Map(validatorKey.map((id) => [id, false]));  // 필드별 유효성 검사값 초기화: [input.id, false]
  
  // 폼 필드에 이벤트 리스너 등록...
}

 

이 모듈 아래에는 '폼 필드 유효성 검사' 함수가 있는데, 여기서

  • const validationFunc = inputValidatorMap[input.id];
    이 코드로 미리 매핑해놓은 필드 id별 유효성 검사 메서드를 가져온 것,
  • validStateMap.set(input.id, validationResult.isValid);
    여기서 Map의 set 메서드를 활용하여 각 필드별 유효성 상태를 업데이트한 것

이 부분들이 특히 기억에 남는다.

왜냐면 그동안 객체에 메서드를 매핑해서 해당 프로퍼티의 메서드를 불러온 적도 없었고,

Map을 사용해본 적은 아예 없었기 때문이다.

// validateForm.js
// 폼 필드 유효성 검사
  function handleFieldValidation(e) {
    const input = e.target;
    const validationFunc = inputValidatorMap[input.id];
    if (!validationFunc) return;

    // 유효성 검사 UI 업데이트
    const validationResult = validationFunc(input);
    updateValidationUI(input, validationResult);

    // 유효성 상태 업데이트
    validStateMap.set(input.id, validationResult.isValid);
    updateSubmitButtonState();

    // 비밀번호, 비밀번호 확인 필드 유효성 검사 연동
    if (e.target.id !== "userPassword") return;
    const inputPasswordCheck = form.querySelector("#userPasswordChk");

    if (inputPasswordCheck?.value) {
      const result = validatePasswordCheck(inputPasswordCheck);
      updateValidationUI(inputPasswordCheck, result);

      // 비밀번호 확인 필드 유효성 상태도 업데이트
      validStateMap.set(inputPasswordCheck.id, result.isValid);
    }
  }

 

 

 


 

✅ 태그 자체에 스타일 주지 말기

css 리팩토링이 필요한 부분도 있었는데, 혼자 편하게 코딩하던 습관이 남아있어 태그 자체에 스타일을 추가한 게 몇 개 있었다.

이런 경우에는

  • 해당 태그에 클래스 주고, 그 클래스에 스타일을 적용하거나 
  • 태그 자체에 줘야만 한다면 reset.css로 위치를 옮겨야 한다.

당시 common.css에 해당 코드가 있었는데, 클래스를 줘야될 만큼 구체적이고 복잡한 스타일은 아니라서 reset.css로 옮겼다.

 

 

 


✅ init 함수로 초기화

위에서 이미 bad case와 리팩토링된 예시로 보여주었지만, 한 번 명확히 짚고 넘어갈 필요가 있는 것 같다.

 

웬만하면 기능들을 풀어헤쳐놓지 않고, init() 이라는 초기화 함수에 넣어서 

페이지가 처음 로드될 때 실행되어야 하는 함수들이 init() 한 번만 호출되면 모두 실행되도록 만드는 것이 추후 유지보수와 트러블 슈팅에 좋으며,

보기에도 명확하다. (한 페이지에 들어갈 기능들을 init 안에서 모두 확인할 수 있으므로)

 

 

[ bad case ]

function createFormValidator() {
  let emailValid = false;
  let passwordValid = false;

  return function formValidate(e) {
    switch (e.target.id) {
      case "userEmail":
        emailValid = validateEmail(emailInput);
        break;
      case "userPassword":
        passwordValid = validatePassword(passwordInput);
        break;
    }

    loginBtn.disabled = !(emailValid && passwordValid);
  };
}

form.addEventListener("focusout", createFormValidator());

/* 비밀번호 토글 */
togglePasswordHandler(form);

/* UX: 첫번째 input focus 처리 */
window.addEventListener("DOMContentLoaded", () => {
  form.querySelector(".form-input").focus();
});

/* 로그인 버튼 클릭 시 'items'로 이동 */
loginBtn.addEventListener("click", (e) => {
  e.preventDefault();
  location.href = "/items.html";
});

 

 

[ refactored ]

function init() {
  validateForm({
    form,
    formButton,
    inputValidatorMap: {
      userEmail: validateEmail,
      userPassword: validatePassword,
    },
    onSubmitRedirectUrl: REDIRECT_MAP[window.location.pathname],
  });
  togglePasswordVisible(form);
  focusFirstField(form);
}

window.addEventListener("DOMContentLoaded", init);

 

 

 

 


 

+ 자동완성된 필드도 포함해서 폼 유효성 검사하기

DOMContentLoaded로 init 함수를 불러오면서 자연스럽게 해결된 문제다.

 

리팩토링 전에는 함수들이 흩어져있는 채로 각자의 조건에 따라 실행되었기 때문에, 

페이지가 로드될 때 자동완성된 입력값들의 유효성이 반영되지 않는 문제가 발생했었다.

 

=> 모든 함수가 DOM이 렌더링 된 이후에 실행되도록 init으로 묶어 호출했더니, 

DOM 요소 렌더 → 자동완성 입력 → 자동으로 focusout 처리 → 유효성 상태가 잘 업데이트된 것 같다.

 

 

 


 

+ 추가로 했던 작업들

  • 폼 첫번째 필드 자동 포커스 (사용자 입력 편의성)
  • 페이지 이동 map 추가(REDIRECT_MAP)
  • 비밀번호 필드, 비밀번호 확인 필드 유효성 검사 연동 (비밀번호값 변경  비밀번호 확인 유효성 갱신)

 

 

 


 

🎯 결론

그동안은 기능 구현만 되면 괜찮다 생각하며 안일하게 코딩했었는데,

  • 유지보수성 (안정성)
  • 확장성
  • 명확성
  • 함수 역할 분리
  • 구조 설계 (모듈화)

이 5가지 요소가 코딩의 핵심이라는 것을 이번에 확실히 깨달은 것 같다.

또한 객체, Map의 사용법도 이번 리팩토링을 진행하면서 (예전보단) 훨씬 능숙해진 것 같다.

정말 얻은 게 많은 과제였다. ⭐ ⭐ ⭐ ⭐ ⭐

 

 

 

+ js 코드 before, after 전체 보기

더보기

[ before ]
- 폴더 구조: 

script
├── util
│   ├── togglePassword.js
│   └── validators.js
├── login.js
└── signup.js

// login.js
import { validateEmail, validatePassword } from "./util/validators.js";
import togglePasswordHandler from "./util/togglePassword.js";

const form = document.querySelector(".form");
const emailInput = document.querySelector("#userEmail");
const passwordInput = document.querySelector("#userPassword");
const loginBtn = document.querySelector("#loginBtn");

function createFormValidator() {
  let emailValid = false;
  let passwordValid = false;

  return function formValidate(e) {
    switch (e.target.id) {
      case "userEmail":
        emailValid = validateEmail(emailInput);
        break;
      case "userPassword":
        passwordValid = validatePassword(passwordInput);
        break;
    }

    loginBtn.disabled = !(emailValid && passwordValid);
  };
}

form.addEventListener("focusout", createFormValidator());

/* 비밀번호 토글 */
togglePasswordHandler(form);

/* UX: 첫번째 input focus 처리 */
window.addEventListener("DOMContentLoaded", () => {
  form.querySelector(".form-input").focus();
});

/* 로그인 버튼 클릭 시 'items'로 이동 */
loginBtn.addEventListener("click", (e) => {
  e.preventDefault();
  location.href = "/items.html";
});
// signup.js
import {
  validateEmail,
  validatePassword,
  validatePasswordChk,
  validateNickname,
} from "./util/validators.js";
import togglePasswordHandler from "./util/togglePassword.js";

const form = document.querySelector(".form");
const emailInput = document.querySelector("#userEmail");
const nicknameInput = document.querySelector("#userNickname");
const passwordInput = document.querySelector("#userPassword");
const passwordChkInput = document.querySelector("#userPasswordChk");
const signupBtn = document.querySelector("#signupBtn");

function createFormValidator() {
  let emailValid = false;
  let nicknameValid = false;
  let passwordValid = false;
  let passwordChkValid = false;

  return function formValidate(e) {
    switch (e.target.id) {
      case "userEmail":
        emailValid = validateEmail(emailInput);
        break;
      case "userNickname":
        nicknameValid = validateNickname(nicknameInput);
        break;
      case "userPassword":
        passwordValid = validatePassword(passwordInput, passwordChkInput);
        break;
      case "userPasswordChk":
        passwordChkValid = validatePasswordChk(passwordInput, passwordChkInput);
        break;
    }

    signupBtn.disabled = !(
      emailValid &&
      nicknameValid &&
      passwordValid &&
      passwordChkValid
    );
  };
}

form.addEventListener("focusout", createFormValidator());

/* 비밀번호 토글 */
togglePasswordHandler(form);

/* UX: 첫번째 input focus 처리 */
window.addEventListener("DOMContentLoaded", () => {
  form.querySelector(".form-input").focus();
});

/* 회원가입 버튼 클릭 시 'login'으로 이동 */
signupBtn.addEventListener("click", (e) => {
  e.preventDefault();
  location.href = "/login.html";
});
// util/togglePasswordHandler.js
export default function togglePasswordHandler(area = document.body) {
  area.addEventListener("click", (e) => {
    const toggleBtn = e.target.closest(".btn-password-visible");
    if (!toggleBtn) return;

    const targetInput = toggleBtn.parentNode.querySelector(".form-input");
    if (!targetInput) return;

    const isVisible = toggleBtn.classList.toggle("on");
    targetInput.type = isVisible ? "text" : "password";
    targetInput.setAttribute("aria-pressed", `${isVisible}`);
  });
}
// util/validators.js
/* 유효성 상태 설정 */
function setValid(input, valid = true, msg) {
  const inputHintEl = input
    .closest(".form-control")
    .querySelector(".form-input-hint");

  if (!valid) {
    input.classList.add("invalid");
    inputHintEl.textContent = msg;
    return;
  }

  input.classList.remove("invalid");
  inputHintEl.textContent = "";
}

/* 이메일 유효성 */
function validateEmail(input) {
  if (input.value.trim().length === 0) {
    setValid(input, false, "이메일을 입력해주세요.");
    return false;
  }

  if (!input.checkValidity()) {
    setValid(input, false, "잘못된 이메일 형식입니다.");
    return false;
  }

  setValid(input);
  return true;
}

/* 비밀번호 유효성 */
function validatePassword(input, chkInput) {
  // 비밀번호 체크 연동
  if (chkInput) {
    if (input.value !== chkInput.value) {
      validatePasswordChk(input, chkInput);
    } else {
      setValid(chkInput);
    }
  }

  // 비밀번호 유효성 검사
  if (input.value.trim().length === 0) {
    setValid(input, false, "비밀번호를 입력해주세요.");
    return false;
  }

  if (input.value.trim().length < 8) {
    setValid(input, false, "비밀번호를 8자 이상 입력해주세요.");
    return false;
  }

  setValid(input);
  return true;
}

/* 비밀번호 확인 유효성 */
function validatePasswordChk(input, chkInput) {
  if (input.value !== chkInput.value) {
    setValid(chkInput, false, "비밀번호가 일치하지 않습니다.");
    return false;
  }

  setValid(chkInput);
  return true;
}

/* 닉네임 유효성 */
function validateNickname(input) {
  if (input.value.trim().length === 0) {
    setValid(input, false, "닉네임을 입력해주세요.");
    return false;
  }

  setValid(input);
  return true;
}

export {
  validateEmail,
  validatePassword,
  validatePasswordChk,
  validateNickname,
};

 


 

[ after ]
- 폴더 구조:

script
├── auth
│   ├── login.js
│   ├── signup.js
│   ├── togglePasswordVisible.js
│   └── validateForm.js
├── util
│   ├── focusFirstField.js
│   ├── updateValidationUI.js
│   └── validators.js
└── constants.js

// auth/login.js
import { validateEmail, validatePassword } from "../util/validators.js";
import togglePasswordVisible from "./togglePasswordVisible.js";
import validateForm from "./validateForm.js";
import focusFirstField from "../util/focusFirstField.js";
import { REDIRECT_MAP } from "../constants.js";

const form = document.querySelector("#loginForm");
const formButton = document.querySelector("#loginBtn");

function init() {
  validateForm({
    form,
    formButton,
    inputValidatorMap: {
      userEmail: validateEmail,
      userPassword: validatePassword,
    },
    onSubmitRedirectUrl: REDIRECT_MAP[window.location.pathname],
  });
  togglePasswordVisible(form);
  focusFirstField(form);
}

window.addEventListener("DOMContentLoaded", init);
// auth/signup.js
import {
  validateEmail,
  validatePassword,
  validatePasswordCheck,
  validateNickname,
} from "../util/validators.js";
import togglePasswordVisible from "./togglePasswordVisible.js";
import validateForm from "./validateForm.js";
import focusFirstField from "../util/focusFirstField.js";
import { REDIRECT_MAP } from "../constants.js";

const form = document.querySelector("#signupForm");
const formButton = document.querySelector("#signupBtn");

function init() {
  validateForm({
    form,
    formButton,
    inputValidatorMap: {
      userEmail: validateEmail,
      userPassword: validatePassword,
      userPasswordChk: validatePasswordCheck,
      userNickname: validateNickname,
    },
    onSubmitRedirectUrl: REDIRECT_MAP[window.location.pathname],
  });
  togglePasswordVisible(form);
  focusFirstField(form);
}

window.addEventListener("DOMContentLoaded", init);
// auth/validateForm.js
import { updateValidationUI } from "../util/updateValidationUI.js";
import { validatePasswordCheck } from "../util/validators.js";

export default function validateForm({
  form,
  formButton,
  inputValidatorMap,
  onSubmitRedirectUrl,
}) {
  // 각 키의 유효성 검사값 초기화: [input.id, false]
  const validatorKey = Object.keys(inputValidatorMap);
  const validStateMap = new Map(validatorKey.map((id) => [id, false]));

  // 폼 필드에 이벤트 리스너 등록
  function attachFieldsValidation() {
    const targetFields = validatorKey.map((key) =>
      form.querySelector(`#${key}`)
    );

    targetFields.forEach((field) => {
      field.addEventListener("focusout", handleFieldValidation);
    });
  }

  // 폼 필드 유효성 검사
  function handleFieldValidation(e) {
    const input = e.target;
    const validationFunc = inputValidatorMap[input.id];
    if (!validationFunc) return;

    // 유효성 검사 UI 업데이트
    const validationResult = validationFunc(input);
    updateValidationUI(input, validationResult);

    // 유효성 상태 업데이트
    validStateMap.set(input.id, validationResult.isValid);
    updateSubmitButtonState();

    // 비밀번호, 비밀번호 확인 필드 유효성 검사 연동
    if (e.target.id !== "userPassword") return;
    const inputPasswordCheck = form.querySelector("#userPasswordChk");

    if (inputPasswordCheck?.value) {
      const result = validatePasswordCheck(inputPasswordCheck);
      updateValidationUI(inputPasswordCheck, result);

      // 비밀번호 확인 필드 유효성 상태도 업데이트
      validStateMap.set(inputPasswordCheck.id, result.isValid);
    }
  }

  // 제출 버튼 상태 변경
  function updateSubmitButtonState() {
    const isAllValid = [...validStateMap.values()].every(Boolean);
    formButton.disabled = !isAllValid;
  }

  // 모두 유효할 경우, 폼 제출
  function handleSubmit(e) {
    e.preventDefault();
    location.href = onSubmitRedirectUrl;
  }

  function init() {
    attachFieldsValidation();
    formButton.addEventListener("click", handleSubmit);
  }

  init();
}
// auth/togglePasswordVisible.js
export default function togglePasswordVisible(form) {
  const btns = form.querySelectorAll(".btn-password-visible");
  if (!btns) return;

  btns.forEach((btn) => {
    btn.addEventListener("click", () => {
      const targetInput = btn.parentNode.querySelector(".form-input");
      if (!targetInput) return;

      const isVisible = btn.classList.toggle("on");
      targetInput.type = isVisible ? "text" : "password";
      targetInput.setAttribute("aria-pressed", `${isVisible}`);
    });
  });
}
// util/validators.js
/* 이메일 유효성 */
export function validateEmail(input) {
  if (input.value.trim().length === 0) {
    return { isValid: false, message: "이메일을 입력해주세요." };
  }
  if (!input.checkValidity()) {
    return { isValid: false, message: "잘못된 이메일 형식입니다." };
  }
  return { isValid: true, message: "" };
}

/* 비밀번호 유효성 */
export function validatePassword(input) {
  if (input.value.trim().length === 0) {
    return { isValid: false, message: "비밀번호를 입력해주세요." };
  }
  if (input.value.trim().length < 8) {
    return { isValid: false, message: "비밀번호를 8자 이상 입력해주세요." };
  }
  return { isValid: true, message: "" };
}

/* 비밀번호 확인 유효성 */
export function validatePasswordCheck(input) {
  const inputPassword = document.querySelector("#userPassword");
  if (inputPassword.value !== input.value) {
    return { isValid: false, message: "비밀번호가 일치하지 않습니다." };
  }
  return { isValid: true, message: "" };
}

/* 닉네임 유효성 */
export function validateNickname(input) {
  if (input.value.trim().length === 0) {
    return { isValid: false, message: "닉네임을 입력해주세요." };
  }
  return { isValid: true, message: "" };
}
// util/updateValidationUI.js
export function updateValidationUI(input, validationResult) {
  const inputHint = input
    .closest(".form-control")
    .querySelector(".form-input-hint");

  if (!inputHint) return;

  !validationResult.isValid
    ? input.classList.add("invalid")
    : input.classList.remove("invalid");
  inputHint.textContent = validationResult.message;
}
// util/focusFirstField.js
export default function focusFirstField(form) {
  const firstInput = form.querySelector(".form-input");
  firstInput?.focus();
}
// constants.js (상수 모음)
export const REDIRECT_MAP = {
  "/login.html": "/items.html",
  "/signup.html": "/login.html",
};

 

 

 

 

'✨회고' 카테고리의 다른 글

9/1 회고 - zustand와 'use client'  (0) 2025.09.12
8/29 회고 - headless ui의 Menu vs ListBox | sharp 라이브러리의 용도에 관해  (0) 2025.09.12
8/26 회고 - next.js에서의 초기 데이터 fetch + 무한 스크롤  (0) 2025.09.12
8/23 회고 - debounce.ts (with TypeScript)  (0) 2025.09.12
'✨회고' 카테고리의 다른 글
  • 9/1 회고 - zustand와 'use client'
  • 8/29 회고 - headless ui의 Menu vs ListBox | sharp 라이브러리의 용도에 관해
  • 8/26 회고 - next.js에서의 초기 데이터 fetch + 무한 스크롤
  • 8/23 회고 - debounce.ts (with TypeScript)
쥬피썬더의노예
쥬피썬더의노예
오히려 좋아
  • 쥬피썬더의노예
    d.log
    쥬피썬더의노예
    글쓰기 관리
    • 분류 전체보기 (112)
      • JS (37)
      • TS (3)
      • WEB (10)
      • React.js (20)
      • Next.js (4)
      • tanstack query (2)
      • Node.js (2)
      • HTML (5)
      • CSS (13)
      • CS (1)
      • 에이전트 (1)
      • Git (4)
      • JAVA (0)
      • SQL (0)
      • db (0)
      • GSAP (0)
      • 자료구조 (1)
      • 알고리즘 (0)
      • ✨회고 (5)
      • 포꾸 (0)
      • 인터뷰 (0)
      • 개발일지 (0)
      • 일기 (1)
      • etc (3)
      • 정처기 실기 (0)
        • C (0)
        • Java (0)
        • Python (0)
      • fonts (0)
      • articles (0)
      • 도서 (0)
  • 인기 글

  • 태그

    zustand
    css
    아키텍처
    안티그래비티
    조합 패턴
    React
    Til
    CSR
    HTML
    javascript
    슬라이딩 윈도우
    WEB
    클로저
    SSG
    React.JS
    useState
    GIT
    state
    Next.js
    상태 관리
    SSR
    유효성 검사
    리액트
    폼
    useEffect
    자바스크립트
    리팩토링
    프론트엔드
    React Query
    TypeScript
  • 최근 글

  • 전체
    오늘
    어제
  • hELLO· Designed By정상우.v4.10.3
쥬피썬더의노예
자바스크립트 리팩토링 회고🔍 - 폼 유효성 검사
상단으로

티스토리툴바