[Frontend] 세미나 신청폼 개발기 ① - UI 구조와 컴포넌트 설계

YuminPark·2026년 1월 25일

Frontend

목록 보기
6/18
post-thumbnail

DevTalk 세미나 신청에는 기존에 구글폼을 사용해왔지만, 이번에는 신청폼을 프론트엔드에서 직접 구현하게 되었다.

사실 꼭 내가 해야 했던 작업이라기보다는, 한번 직접 만들어보고 싶다는 생각도 있었고, 무엇보다 실사용자를 대상으로 한 페이지를 만들어볼 수 있다는 점이 매력적으로 느껴졌다. 실제 운영에 쓰이는 서비스인 만큼, 구현한 결과에 대한 피드백을 바로 받을 수 있고 그 경험이 앞으로의 개발에도 꽤 도움이 될 것 같다고 생각했다.

또 신청 정보와 상태를 다루는 구조상 전역 상태 관리가 필요해 보였고, Zustand를 사용해보기에도 적당한 케이스라고 판단한 것도 이유 중 하나였다.

기능 자체는 단순한 편이었지만, 실제로 세미나 운영에 바로 사용되는 페이지였기 때문에 입력값 처리, UX 흐름, 예외 상황 등 평소 개인 프로젝트보다 더 신경 써야 할 부분들이 많았다.

이 글과 다음 글은 세미나 신청폼을 구현하면서 프론트엔드에서 어떤 부분을 고려했고, 어떤 식으로 구현했는지를 정리한 개발 기록이다.
(현재 세미나 신청기간이 아니므로 신청폼에 들어갈 수 없어, Figma 사진으로 대체한다 🥲)


전체 UI 흐름


신청 플로우는 크게 두 개의 페이지로 나뉜다.

  • /seminar/apply-info
    → 기본 정보 입력 페이지
  • /seminar/apply-question
    → 사전 질문 작성 및 최종 신청 페이지

사실상 첫 번째 페이지에서 대부분의 정보를 입력하고,
두 번째 페이지에서는 질문이 있다면 질문을 남기고, 없다면 바로 신청하는 구조다.


공통 UI : SectionHeader

입력 항목이 많은 페이지인 만큼, 각 섹션의 제목 영역은 공통 컴포넌트로 분리했다.

type SectionHeaderProps = {
  title: string;
  required?: boolean;
  helperText?: string;
};

export const SectionHeader = ({ title, required, helperText }: SectionHeaderProps) => (
  <div className="flex flex-col gap-4">
    <div className="flex items-center gap-4">
      <p className="heading-3-semibold text-white">{title}</p>
      {required && <p className="heading-3-semibold text-gradient">*</p>}
    </div>
    {helperText && <p className="body-2-medium text-grey-300">{helperText}</p>}
  </div>
);
  • 필수 입력 항목은 * 표시
  • 섹션별 안내 문구(helperText)도 유연하게 넣을 수 있도록 구성했다.

첫 번째 페이지 : 기본 정보 입력

이 페이지에서는 신청자의 기본 정보를 입력받는다. 각 입력 영역은 섹션 단위 컴포넌트로 나누어 구성했다.
(페이지 상단의 텍스트 정보보다는 입력 UI 구조와 컴포넌트 설계에 초점을 맞춰 설명한다.)

텍스트 입력 섹션

이름, 연락처, 이메일, 학번과 같이 자유 입력이 필요한 항목들은 모두 동일한 스타일을 유지하도록 설계했다. 입력 필드마다 UX나 검증 방식은 다르지만, 사용자 입장에서는 하나의 흐름처럼 느껴지도록 맞추는 것을 목표로 했다.

<NameSection value={draft.name} onChange={(v) => draft.setField('name', v)} />
<PhoneSection value={draft.phone} onChange={(v) => draft.setField('phone', v)} />
<EmailSection value={draft.email} onChange={(v) => draft.setField('email', v)} />
<StudentIdSection
  value={draft.studentNum}
  onChange={(v) => draft.setField('studentNum', v)}
/>

연락처 입력의 경우, 입력 도중 자동으로 -가 삽입되도록 포맷팅하여 사용자가 별도로 형식을 신경 쓰지 않아도 되게 처리했다.

onChange={(e) => onChange(formatPhoneNumber(e.target.value))}

학번 입력은 조금 더 엄격하게 다뤘다. (기존 구글폼 신청 당시, 학번을 잘못 입력하는 학생들이 많아 곤란했다고 한다.)
우리 학교의 경우 학번은 알파벳 1자리 + 숫자 6자리 형식이 고정되어 있기 때문에, 입력 중 실시간으로 형식을 보정하고 형식이 맞지 않으면 즉시 에러 메시지를 노출하도록 했다.

const formatted = formatStudentId(e.target.value);
onChange(formatted);

if (formatted && !validateStudentId(formatted)) {
  setError('학번은 알파벳 1자리 + 숫자 6자리여야 합니다.');
}

이렇게 함으로써 제출 이후가 아니라 입력 단계에서 바로 잘못된 값을 인지할 수 있게 했다.

선택형 섹션

선택형 입력 섹션은 항목 성격에 따라 선택 방식이 다르도록 설계했다.

① 학과 : 복수 선택 + 기타 입력 가능

학과는 복수전공, 부전공 등을 고려해 여러 개 선택이 가능하도록 체크박스 형태로 구성했다.
또, 목록에 없는 학과를 입력할 수 있도록 ‘기타’ 항목을 함께 제공했다.

<DepartmentSection
  options={DEPARTMENTS}
  selected={draft.departments}
  etcValue={draft.departmentEtc}
/>

여기서 잠깐 .. 🖐🏻


'기타' 입력은 어떻게 구현했나?

핵심은 “기타 체크 여부 = etcValue가 null이냐 아니냐”로 상태를 단순하게 관리한 것!

  • etcValue === null → 기타를 선택하지 않은 상태
  • etcValue !== null → 기타를 선택한 상태(입력 가능)
    즉, 체크박스 자체를 별도의 boolean 상태로 두지 않고 입력값 상태 하나로 체크 여부까지 표현하도록 설계했다.
<input
  id="dept-other"
  type="checkbox"
  className="sr-only peer"
  checked={etcValue !== null}
  onChange={(e) => onToggleEtc(e.target.checked)}
/>

그리고 체크박스를 켜는 순간 바로 입력할 수 있도록, onToggleEtc(true)가 들어오면 etcValue''(빈 문자열)로 열어준다.

onToggleEtc={(checked) => {
  draft.setField('departmentEtc', checked ? '' : null);
}}

이렇게 해두면 UX가 자연스럽다. 체크하면 입력창이 열리고(빈 값이라도 입력 가능), 체크를 풀면 입력값 자체가 null로 정리되면서 “기타 선택” 상태도 사라지기 때문!

입력창은 "체크했을 때만 보이게"

UI적으로는 peer를 사용해서 기타 체크박스가 선택된 경우에만 입력창이 활성화되도록 만들었다.

<input
  type="text"
  className="
    opacity-0 pointer-events-none transition-opacity
    peer-checked:opacity-100 peer-checked:pointer-events-auto
  "
/>
  • 체크 전: 입력창이 안 보이고(opacity-0), 클릭/포커스도 불가능(pointer-events-none)
  • 체크 후: 자연스럽게 나타나고 입력 가능(peer-checked:*)
    즉 “그냥 안 보이는 input”이 아니라,
    실제로 접근 자체를 막아버려서 실수 입력도 방지할 수 있었다.

② 학년 / 유입 경로 : 단일 선택 + 기타 입력 가능

반면 학년과 “이번 세미나를 어떻게 알게 되었나요?” 항목은 하나만 선택하는 것이 자연스럽다고 판단해 라디오 버튼으로 구성했다.

<GradeSection
  options={GRADES}
  selected={draft.gradeEtc !== null ? '기타' : `${draft.grade}학년`}
  etcValue={draft.gradeEtc}
/>

<HowToKnowSection
  options={HOWTOKNOW}
  selected={draft.inflowPathEtc !== null ? '기타' : draft.inflowPath}
  etcValue={draft.inflowPathEtc}
/>

이 두 섹션 역시 ‘기타’를 선택한 경우에만 직접 입력 필드가 노출되도록 동일한 UX 패턴을 유지했다.
선택 방식은 다르지만, 사용자 경험은 최대한 일관되게 느껴지도록 맞추는 데 집중했다!

원래는 오프라인 / 온라인 참여 방식을 선택하는 섹션도 구현해두었다.
다만 개발 중 연사님 일정 이슈로 인해 온라인 세미나만 운영하는 것으로 변경되면서, 현재는 해당 섹션을 페이지에 연결하지 않았다.
대신 기본 참여 방식은 내부적으로 온라인으로 고정해두었고, 추후 오프라인 세미나가 다시 열릴 경우 쉽게 복구할 수 있도록 컴포넌트는 유지했다.


두 번째 페이지 : 사전 질문 및 최종 신청

두 번째 페이지는 UI적으로 두 가지 역할을 가진다.

  • 연사 및 세션 정보를 명확히 보여주는 영역
  • 질문이 있다면 입력할 수 있는 영역 (선택 사항)

여기서 중요한 포인트는, 질문이 선택이라는 점이다. 질문을 남기는 사람은 편하게 길게 작성할 수 있어야 하고, 질문이 없는 사람은 불필요한 스크롤/동선 없이 바로 신청할 수 있어야 했다.
그래서 이 페이지는 “입력 유도”보다 맥락 제공 + 동선 최소화를 목표로 UI를 짰다.

연사 정보 카드

사전 질문을 쓰는 화면에서 내가 지금 누구에게 질문을 쓰는지가 애매해지면 UX가 바로 무너진다. 그래서 연사 카드 + 질문 입력을 한 세트로 묶어서 반복 렌더링했다.

<div className="flex flex-col gap-16">
  <SpeakerCard
    name={s.speaker.name}
    title={s.title}
    organization={s.speaker.organization}
    description={s.description}
    profileUrl={s.speaker.profileUrl}
  />
  <AutoResizeTextarea
    value={draft.questions[s.sessionId] ?? ''}
    placeholder={`[${s.speaker.name}] 연사님께 드리고 싶은 질문을\n자유롭게 남겨주세요.`}
  />
</div>

그리고 연사가 여러 명일 수 있으니, 세트 사이에는 구분선을 넣어 “지금부터 다른 연사”임이 명확히 느껴지도록 했다.

{idx < sessions.length - 1 && (
  <hr className="border-t border-grey-700 w-[335px] mx-auto my-32" />
)}

강조 문법 처리

당시 별도의 텍스트 에디터(리치 텍스트)를 붙이지 않았기 때문에, 어드민에서 입력한 설명 텍스트 중 특정 부분만 강조할 필요가 있었다.
그래서 설명 텍스트에서 %강조% 문법을 사용하면 프론트엔드에서 자동으로 text-gradient 스타일이 적용되도록 처리했다.

// %~% 텍스트 <span>으로 변환
const highlightDescription = (text: string) => {
  return text.replace(/%([^%]+)%/g, '<span class="text-gradient">$1</span>');
};

%정보 보안%은 누가해야할까요? 와 같이 입력하면 아래처럼 보이게 된다!

당시 기획 요구사항을 맞추기 위해 프론트에서 무리하게 편집 기능을 붙이기보다는, 운영자가 입력하기 쉬운 규칙 + 프론트에서 가볍게 변환하는 방식이 더 현실적이었다.

질문 입력칸

사전 질문은 선택 사항이다. 질문이 없는 경우에는 아무 것도 입력하지 않고 바로 신청할 수 있다. 다만 질문을 작성하는 경우에는 입력 UX가 중요하다고 생각해, 높이가 자동으로 늘어나는 textarea를 사용했고 질문은 공백 포함 최대 500자 제한을 두었다.

<AutoResizeTextarea
  value={draft.questions[sessionId] ?? ''}
  placeholder="[연사님께 드리고 싶은 질문을 자유롭게 남겨주세요]"
/>

AutoResizeTextarea에서 내가 잡은 UI 포인트는 다음과 같다.

  • 입력이 늘어나면 textarea가 자연스럽게 확장 (너무 작은 입력창으로 인한 불편함 제거 목적)
  • 대신 무한정 커지진 않게 상/하한 두기 (레이아웃 붕괴 방지)
  • 하단에 “최대 500자(공백 포함)” 안내를 고정해 사용자가 제한을 한눈에 인지
<div className="w-full max-w-[680px] rounded-8 bg-grey-800 p-4
                flex flex-col gap-8
                focus-within:ring-1 focus-within:ring-grey-600">
  <textarea
    maxLength={500}
    className="w-full bg-transparent outline-none resize-none
               px-16 py-16 body-1-medium text-grey-50 placeholder-grey-300"
  />
  <div className="px-16 py-16 flex justify-end">
    <span className="caption-medium text-grey-400">최대 500(공백 포함)</span>
  </div>
</div>

하단 고정 CTA

질문이 없는 사람은 보통 입력 자체를 안 하고 바로 신청하고 싶어한다. 그래서 이 페이지에서 CTA 버튼은 스크롤 끝에 두기보다, 화면 하단에 고정하는 게 더 자연스럽다고 판단했다.

<Button
  className="fixed bottom-[64px] left-1/2 -translate-x-1/2 z-50"
  text="신청하기"
/>

이 덕분에

  • 질문을 안 쓰는 사람: 들어오자마자 바로 신청 가능
  • 질문을 쓰는 사람: 길게 써도 버튼 위치가 항상 같아서 흐름이 끊기지 않음

두 케이스 모두 불필요한 동선 없이 신청을 완료할 수 있었다.


마무리

이번 글에서는 세미나 신청폼의 전체 UI 흐름과, 각 입력 섹션을 어떤 기준으로 컴포넌트화했는지를 중심으로 정리해봤다.
다음 편에서는 이 UI 위에서 신청 정보가 어떻게 전역 상태로 관리되고, 페이지 이동 간에도 입력값을 유지할 수 있었는지, 그리고 최종 제출 로직은 어떤 구조로 설계했는지를 정리해보려고 한다.

0개의 댓글