한컴AI 2기

한컴AI 2기[스나이퍼팩토리] 한컴AI 2기 - AI개발자 교육 4주차

싱커 2025. 7. 24. 17:37

미니 팀프로젝트


핀볼을 타서 랜덤 조 배정이 이뤄졌다.

버블팝 맵

나는 3조에 배치됐다.


우리 조의 주제는 [설문 폼 제작 플랫폼]으로 결정됐다.

https://github.com/XinKer-Kim/surveyForm

 

GitHub - XinKer-Kim/surveyForm: 설문 폼 제작 플랫폼 @ 한컴AI2기 프론트 팀플 3조

설문 폼 제작 플랫폼 @ 한컴AI2기 프론트 팀플 3조. Contribute to XinKer-Kim/surveyForm development by creating an account on GitHub.

github.com

[프로젝트 기간] 7월 22일 (화) ~ 7월 31일 (목)

기술 스택

  • 프레임워크: React (Vite)
  • 언어: TypeScript
  • CSS 프레임워크: Tailwind CSS
  • UI 라이브러리: shadcn/ui
  • 백엔드 (예정): Supabase

프로젝트 구조

구조 보기
├── src
│   ├── App.tsx
│   ├── main.tsx
│   ├── components/
│   │   ├── ui/       // shadcn/ui 컴포넌트
│   │   ├── form/     // 폼 관련 컴포넌트
│   │   └── layouts/  // 레이아웃 관련 컴포넌트
│   ├── hooks/        // 커스텀 훅
│   ├── pages/        // 페이지 컴포넌트
│   ├── types/        // 타입 정의
│   ├── utils/        // 유틸리티 함수
│   ├── supabaseClient.ts // Supabase 클라이언트 설정
│   └── index.css
├── tailwind.config.js
├── postcss.config.js
├── vite.config.ts
├── tsconfig.json
├── tsconfig.node.json
├── package.json
├── package-lock.json
└── README.md

기획

질문 컴포넌트 유형 분류

각 설문에는 여러개의 질문이 들어갈 수 있으며, 각 질문들은 특정한 유형을 갖는다.

유형별 질문에 따른 컴포넌트를 만들고자했고, 함수로 만들 기능들을 특정지었다.


프로젝트

https://github.com/users/XinKer-Kim/projects/3

프로젝트에 칸반보드로 해야할사항/진행중인 사항들을 관리하기로 했다. 관리가 쉽지는 않다.

1일차 종료.


풀 리퀘스트

레포지토리의 주인으로서, 다른 사람이 브랜치에서 작업한 커밋을 PR로 주면 이를 병합하는 작업을 수행했다.


폼 - 컴포넌트 - 함수 구조

객관식 및 주관식

폼 빌더 페이지에서 퀘스천 파일을 불러오고, 각 유형별 질문들을 대응하도록 만들었다.

질문 함수 & 컴포 관리방식

- 상태와 로직은 Question.tsx에서 통합 관리(props로 전달)
- 유형별 UI (단답형, 객관식 등) 렌더링은 유형별 컴포넌트로 분리

컴포 구조

components/
└── form/
    ├── Question.tsx              ← 상태 관리 중심
    ├── QuestionShortAnswer.tsx   ← 단답형
    ├── QuestionLongAnswer.tsx    ← 서술형
    └── QuestionMultipleChoice.tsx ← 객관식 (복수선택 포함)
 

 

별점

별점 유형도 뒤이어 개발했다. 단위 드롭다운은 0.5와 1 중에 고르도록 했다.


가 데이터 테스트

SQL 에디터로 테이블에 행 데이터를 임의로 채워넣었다.

내 설문

state 초기값으로 하드코드 uuid를 할당했고, 그 uuid에 맞는 설문도 임의로 3개 작성해뒀다.

`/list`에서 목록들이 정상적으로 조회된다!


특수 기능

설문 공유 기능

연필 아이콘 대신 공유 아이콘을 넣었고, 이를 눌렀을 때 설문공유가 되게 했다. 링크 복사를 누르면 클립보드에 링크가 복사된다.

세로 더보기

세로 더보기 메뉴에는 편집, 삭제 기능을 위한 UI를 준비해뒀다. 현재는 편집만이 실질적으로 기능한다.

2일차 종료.


PR 수용

전날 마지막으로 들어온 2개의 풀 리퀘스트를 수용했다.


폼 업데이트와 트랜잭션

슈퍼베이스 DB에 폼을 업데이트 하는 로직이 필요하여 구현했다. 또한 폼과 질문들이 FK로 연결되어있는데 수정 및 갱신 시 삭제와 삽입이 필요했기때문에 이를 트랜잭션으로 처리했다.

킹받는 GPT

업설트와 트랜잭션 중 고민을 했는데, 향후 확장성, 정합성, 관계형 무결성을 고려하여 트랜잭션을 사용하기로 했다.

트랜잭션은 Supabase에선 PostgreSQL의 stored procedure (함수) 형태로 정의하고, 프론트엔드에서는 그것을 RPC (supabase.rpc) 방식으로 호출한다고 한다.

create or replace function save_form_with_questions(payload jsonb)
returns void
language plpgsql
as $$
declare
  p_form_id uuid;
  p_title text;
  p_description text;
  p_questions jsonb;
begin
  p_form_id := (payload->>'form_id')::uuid;
  p_title := payload->>'title';
  p_description := payload->>'description';
  p_questions := payload->'questions';

  update forms
  set title = p_title,
      description = p_description,
      updated_at = now()
  where id = p_form_id;

  delete from questions where form_id = p_form_id;

  insert into questions (id, form_id, text, type, order_number, required)
  select
    (q->>'id')::uuid,
    p_form_id,
    q->>'text',
    q->>'type',
    (q->>'order_number')::int,
    (q->>'required')::boolean
  from jsonb_array_elements(p_questions) as q;
end;
$$;

고급문법이 필요했고, GPT의 도움을 받아 트랜잭션 함수를 적용했다.

그리고 RPC를 호출할 때에는 페이로드에 담는 모양을 지켜야한다.

await supabase.rpc("save_form_with_questions", {
  payload: {
    form_id,
    title,
    description,
    questions: formElements.map((q, i) => ({
      id: q.id,
      text: q.text,
      type: q.type,
      order_number: i + 1,
      required: q.required ?? false,
    })),
  },
});

 

트랜잭션이 구현되고 나니, 폼과 퀘스천간의 연결 업데이트가 잘 되는 것 같다. (title에 대해서는 차후에 대응하기로 했다. 다른 개발자와 작업공간이 겹칠 수 있어서 보류)

로컬 시연

내 설문과 참여한 설문을 각각 조회하는 데에 문제가 없다.

3일차 종료.


객관식(옵션) 대응 로직

객관식에서 폼을 수정하고 항목 내용을 추가하는 등 수정해도, 그걸 유효하게 DB로 보내고 받아오는 로직이 없기때문에, 슈퍼베이스 DB SQL 함수를 업데이트 해야했다.

create or replace function save_form_with_questions(payload jsonb)
returns void
language plpgsql
as $$
declare
  p_form_id uuid;
  p_title text;
  p_description text;
  p_questions jsonb;
  q jsonb;
  o jsonb;
begin
  p_form_id := (payload->>'form_id')::uuid;
  p_title := payload->>'title';
  p_description := payload->>'description';
  p_questions := payload->'questions';

  -- update form metadata
  update forms
  set title = p_title,
      description = p_description,
      updated_at = now()
  where id = p_form_id;

  -- delete existing questions (cascade will delete options too if FK on delete cascade is set)
  delete from questions where form_id = p_form_id;

  -- insert new questions + options
  for q in select * from jsonb_array_elements(p_questions)
  loop
    insert into questions (id, form_id, text, type, order_number, required)
    values (
      (q->>'id')::uuid,
      p_form_id,
      q->>'text',
      q->>'type',
      (q->>'order_number')::int,
      (q->>'required')::boolean
    );

    -- delete existing options (defensive - in case you later allow partial deletes)
    delete from options where question_id = (q->>'id')::uuid;

    -- insert options if present
    if q ? 'options' then
      for o in select * from jsonb_array_elements(q->'options')
      loop
        insert into options (id, question_id, label, value, order_number)
        values (
          (o->>'id')::uuid,
          (q->>'id')::uuid,
          o->>'label',
          o->>'value',
          (o->>'order_number')::int
        );
      end loop;
    end if;
  end loop;
end;
$$;
더보기

✅ 전체 기능 흐름

[1] payload에서 필요한 값 추출

p_form_id := (payload->>'form_id')::uuid;
p_title := payload->>'title';
p_description := payload->>'description';
p_questions := payload->'questions';

 

  • JSON으로 들어온 payload에서 필요한 값을 변수로 꺼내 저장함
  • form_id, title, description, questions 배열을 각각 별도 변수로 추출

 

[2] 폼 메타데이터 업데이트

update forms
set title = p_title,
    description = p_description,
    updated_at = now()
where id = p_form_id;

 

  • 이미 존재하는 설문폼(forms)의 제목과 설명을 업데이트함
  • 이미 저장된 폼이라면 수정 / 신규일 경우 앞단에서 insert한 ID를 사용

 

[3] 기존 질문(과 옵션) 전부 삭제

delete from questions where form_id = p_form_id;

 

  • form_id에 연결된 기존 질문들을 몽땅 삭제함
  • 이 때 questions → options가 on delete cascade로 설정되어 있다면 옵션도 같이 삭제됨
  • 이 구조 덕분에 따로 options 삭제 안 해도 됨 → 다만 명시적으로도 처리해줌 (6번 참고)

 

[4] 질문 반복 루프

for q in select * from jsonb_array_elements(p_questions)
loop
  ...
end loop;

 

  • questions 배열을 루프 돌면서 각 질문 하나씩 처리함
  • q는 JSON 객체 하나 (질문 하나)로 사용됨

 

[5] 질문 INSERT

insert into questions (id, form_id, text, type, order_number, required)
values (
  (q->>'id')::uuid,
  p_form_id,
  q->>'text',
  q->>'type',
  (q->>'order_number')::int,
  (q->>'required')::boolean
);

 

  • 루프 안에서 각 질문을 questions 테이블에 저장
  • 이미 삭제된 상태라 중복 걱정 없음 → 순수 INSERT

 

[6] 옵션 삭제 (방어적 처리)

delete from options where question_id = (q->>'id')::uuid;

 

  • 혹시 on delete cascade가 안 걸려있을 수도 있으므로,
  • 해당 질문의 옵션은 명시적으로 삭제

[7] 옵션 삽입 (객관식 처럼 옵션이 있다면)

if q ? 'options' then
  for o in select * from jsonb_array_elements(q->'options')
  loop
    insert into options (id, question_id, label, value, order_number)
    values (
      (o->>'id')::uuid,
      (q->>'id')::uuid,
      o->>'label',
      o->>'value',
      (o->>'order_number')::int
    );
  end loop;
end if;

 

 

  • 질문 객체에 "options" 키가 있다면
  • options[] 배열을 loop 돌면서 각각 insert함
  • 옵션에 대한 전체 재삽입이 이 루프에서 처리됨

 

이 함수는 설문 → 질문 → 옵션까지 트리플로 묶인 구조를, 기존 데이터는 전부 삭제하고
새로운 구조로 재삽입하는 트랜잭션-safe 방식으로 처리한다.

 

탬플릿 기능

홈 화면 탬플릿 기능

네이버의 그것처럼, 홈 화면에서 탬플릿 버튼을 눌러 시작할 수 있게 만들었다.

프론트 UI보다는 컴포넌트 구조 및 백엔드 로직이 조금 어려웠다.

state를 사용하여 해결!

탬플릿 모드에서는 formId가 존재하지 않는다. 따라서 insert를 할 때 로직 상 문제가 있었다. formId는 파람스이기 때문에 new로 임의 할당을 할 수가 없다. 따라서 별도의 state를 사용하여 이 문제를 해결했다.


삭제 기능

const handleDeleteForm = async () => {
    const confirm = window.confirm("정말로 이 설문을 삭제하시겠습니까?");
    if (!confirm) return;

    const { error } = await supabase
      .from("forms")
      .delete()
      .eq("id", formId);

    if (error) {
      console.error("삭제 실패:", error);
      alert("설문 삭제 중 오류가 발생했습니다.");
      return;
    }

    alert("설문이 삭제되었습니다.");
    navigate("/list");
  };

기능 자체는 평범한 delete문으로 구현하였다. 이것으로 충분히 기능할 수 있었던 이유는, 각 테이블에 ON DELETE CASCADE 설정이 잘 걸려있었기 때문이다.

constraint options_question_id_fkey foreign KEY (question_id) references questions (id) on delete CASCADE

예를들어, 옵션 테이블은 question_id를 FK로 참조하고있는데, 이 외래키가 삭제되면, 옵션 테이블의 해당 행이 같이 삭제된다.

questions 테이블도 마찬가지로, form_id가 지워지면 그 외래키를 쓰는 행이 같이 삭제된다. (즉, 연쇄삭제가 이뤄진다!)

삭제 확인

삭제 확인창 UI는 향후 alert창 대신 토스트UI로 바꿀 생각이다.

4일차 종료.


우선순위 높은 다음 개발 후보

더보기

1. 🔍 설문 응답 페이지 (실제 설문 제출) 구현

  • 현재 응답 결과는 있지만, 참여자가 직접 설문에 응답하는 UI는 없음
  • 즉, /take/:formId 형태의 응답 입력 폼 페이지 필요

✅ 해야 할 일:

  • URL로 formId 받아서 해당 질문들 로드 (questions + options)
  • 각 질문 유형에 맞는 입력 UI 렌더링 (text, radio, dropdown, star 등)
  • Submit 버튼 누르면 answers 테이블에 저장 (insert)
  • 중복 제출 방지 고려 (responses에 user_id + form_id 유니크 체크?)

2. 💬 설문 미리보기 기능 (/preview/:formId)

  • FormActionMenu에 있는 "미리보기" 버튼을 실제로 작동시킴
  • 위 응답 페이지와 거의 같지만 disabled 상태로 렌더링

3. 📊 응답 결과 분석/시각화 개선

  • /results/:formId 페이지 개선
    • 객관식 → 막대 그래프
    • 별점/점수형 → 평균점수 시각화
    • 주관식 → 답변 리스트 + 키워드 태그 정리 (선택)

📦 보너스: 확장 기능 아이디어

기능설명
폼 복제 기존 폼 + 질문을 그대로 복제하여 새 폼 생성
공개/비공개 설정 is_public 필드로 외부 공개 여부 지정
응답 기간 지난 설문 자동 종료 end_time 기반으로 상태 표시 (미응답 차단 포함)
모바일 UI 최적화 반응형 tailwind 개선
설문 통계 내보내기 응답 결과 CSV 다운로드