폼 빌더 조건부 로직 구현: 동적 질문 표시와 상태 관리 전략
폼 빌더 조건부 로직 구현: 동적 질문 표시와 상태 관리 전략
들어가며
폼 빌더 서비스를 개발하면서 사용자들로부터 가장 많이 받은 요청 중 하나가 바로 조건부 로직 기능이었습니다. "특정 답변을 선택하면 다음 질문이 나타나거나 사라지게 하고 싶다"는 니즈는 생각보다 흔했고, 실제로 많은 폼 서비스에서 제공하는 핵심 기능이기도 했습니다.
처음에는 단순해 보였던 이 기능이 실제 구현 과정에서는 생각보다 복잡한 문제들을 마주하게 했습니다. 특히 조건이 연쇄적으로 적용되는 경우나, 사용자가 답변을 변경할 때 발생하는 상태 관리 문제는 꽤나 까다로웠습니다.
이번 글에서는 조건부 로직이 어떻게 데이터로 저장되고, 폼 작성 시 어떻게 활용되는지, 그리고 구현 과정에서 마주한 문제들을 어떻게 해결했는지 그 과정을 공유하려고 합니다.
조건부 로직의 기본 구조
조건부 로직은 데이터베이스에 다음과 같은 형태로 저장됩니다.
"conditionalLogicList": [ { "createdAt": ISODate("2025-12-01T06:40:11.185Z"), "updatedAt": ISODate("2025-12-01T06:40:11.185Z"), "__v": 0, "id": "submission_conditional_logic_692d367b130acaa9d067e9d4", "questionId": "submission_question_692e76e1cb3a00e2eb6ead59", "condition": { "type": "is", "valueList": [ "submission_option_692e76e1cb3a00e2eb6ead5a" ] }, "action": { "type": "hide_step", "valueList": [ "submission_step_692e76e1cb3a00e2eb6ead62", "submission_step_692e76e1cb3a00e2eb6ead8a", "submission_step_692e76e1cb3a00e2eb6ead47" ] }, "_id": ObjectId("692d384bd92618185ad97265") } ]
이 데이터를 해석하면 다음과 같습니다. 특정 문항에서 특정 옵션을 선택하면 스텝 2, 4, 5를 숨기는 규칙입니다. 이러한 조건이 설정되어 있다면, 사용자가 답변을 조회하든 새로 생성하든, 어떤 액션이나 상황에서든 해당 조건을 만족하는 한 사용자는 해당 스텝들이 숨겨진 상태로 폼을 사용해야 합니다.
이를 위해서는 매번 조건을 동적으로 평가하여 어떤 스텝을 숨기고 어떤 스텝을 보여줄지 실시간으로 계산해야 합니다. 이러한 실시간 계산을 담당하는 것이 바로 핸들러와 서비스 로직입니다.
답변 기반 조건부 로직 평가 및 액션 누적 전략
간단한 흐름
flowchart TD A[입력: submission, <br/>submissionAnswer] --> B[totalSteps 계산<br/>submission.stepList.length] B --> C[기본 결과 생성<br/>visibleStepList = 모든 스텝<br/>hiddenStepList] C --> D{conditionalLogicList 존재?} D -- No --> R[기본 결과 반환] D -- Yes --> E[조건 평가<br/>evaluateConditions] E --> F{satisfiedActionList<br/>비어 있음?} F -- Yes --> R F -- No --> G[액션 실행 및 누적<br/>executeActions] G --> H[각 핸들러 execute/accumulate로<br/>숨길/보여줄 스텝 누적] H --> I[최종 EvaluationResult<br/>visibleStepList / hiddenStepList]
가장 최신 상태의 응답 데이터와 폼 데이터를 입력으로 받아 계산을 시작합니다.
1단계: 디폴트 결과 생성
처음에는 모든 스텝이 노출된 상태를 기본값으로 가집니다.
{ "visibleStepList": [1, 2, 3, 4, 5, 6, 7], "hiddenStepList": [] }
2단계: 조건부 로직 순회 및 조건 평가
조건부 로직을 순회하면서 현재 응답값과 각 조건을 비교합니다. 이를 통해 어떤 스텝을 숨기고 보여야 할지 판단하기 위한 기초 데이터를 만듭니다.
각 조건에 대해 현재 답변이 해당 조건을 만족하는지 평가합니다. IS, IS_NOT 등의 비교 연산자를 기준으로 옵션이 올바르게 선택되었는지 판단합니다. 그 결과, 조건을 만족하는 조건과 액션 세트만 필터링하여 satisfiedActionList를 만듭니다.
3단계: 액션 실행 및 결과 누적
필터링된 액션이 하나라도 있다면 실제로 수행해야 하는 액션이 존재한다는 의미입니다. 여러 개의 액션을 순서대로 적용하면서, 액션 타입에 따라 서로 다른 방식으로 visibleStepList와 hiddenStepList를 갱신하고 누적합니다.
예시
조건부 로직 배열에 두 개의 조건부 로직이 설정되어 있다고 가정하겠습니다.
첫 번째 액션이 hide_step이고 스텝 1, 2를 숨기라고 되어 있다면, 누적 결과는 다음과 같습니다.
{ "visibleStepList": [3, 4, 5, 6, 7], "hiddenStepList": [1, 2] }
다음 액션이 show_step이고 스텝 2, 3을 보여주라고 되어 있다면, 그다음 누적 결과는 다음과 같습니다.
{ "visibleStepList": [2, 3, 4, 5, 6, 7], "hiddenStepList": [1] }
이처럼 각 액션을 순차적으로 적용하고 누적한 뒤, 마지막까지 반영된 visibleStepList와 hiddenStepList를 최종 결과로 반환합니다.
상세 버전
sequenceDiagram participant C as Client (Controller 등) participant S as ConditionalLogicService participant F as ConditionalLogicHandlerFactory participant H as Handler(들)
C->>S: evaluateConditionalLogic(submission, submissionAnswer)
Note over S: totalSteps = submission.stepList.length<br/>createDefaultResult(totalSteps)<br/>(모든 스텝 visible로 초기화)
alt submission.conditionalLogicList 없음
S-->>C: defaultResult 반환
else 조건부 로직 존재
S->>S: evaluateConditions()
Note over S: ConditionEvaluationContext 생성<br/>(currentAnswers = submissionAnswer.answer)
loop 각 conditionalLogic
S->>F: getHandler(type = logic.action.type)
F-->>S: handler or null
alt handler 존재
S->>H: evaluateCondition(questionId, condition, context)
H-->>S: 조건 만족 여부
Note over S: 조건 만족 시 해당 action을<br/>satisfiedActionList에 포함
else handler 없음
Note over S: 해당 로직은 스킵
end
end
alt satisfiedActionList 비어 있음
S-->>C: defaultResult 반환
else 하나 이상 존재
S->>S: executeActions()
Note over S: initialResult = defaultResult
loop 각 action in satisfiedActionList
S->>F: getHandler(type = action.type)
F-->>S: handler or null
alt handler 존재
S->>H: execute({ context: { action, submission } })
H-->>S: actionResult
S->>H: accumulate(previousResult, actionResult, context)
H-->>S: 새로운 accumulatedResult
else handler 없음
Note over S: accumulatedResult 변경 없이 유지
end
end
S-->>C: 최종 EvaluationResult<br/>(visibleStepList / hiddenStepList 반영)
end
end
전체 흐름을 정리하면 다음과 같습니다.
입력: submission, submissionAnswer
1단계 – 기본 결과 생성
totalSteps를 submission.stepList.length로 계산하고, createDefaultResult 함수로 모든 스텝을 visibleStepList에 넣고 hiddenStepList는 빈 배열로 초기화합니다.
2단계 – 조건 평가 (evaluateConditions)
ConditionEvaluationContext를 생성하여 currentAnswers를 submissionAnswer.answer로 설정합니다.
각 conditionalLogic에 대해 다음 작업을 진행합니다. ConditionalLogicHandlerFactory에서 logic.action.type에 해당하는 핸들러를 조회하고, handler.evaluateCondition을 통해 조건을 평가합니다. 조건을 만족한 로직의 action만 모아서 satisfiedActionList를 생성합니다.
3단계 – 액션 실행 및 누적 (executeActions)
satisfiedActionList를 순회하면서 다음 작업을 진행합니다.
매 액션마다 핸들러를 조회하고, executionContext를 생성하여 handler.execute로 액션 결과를 계산합니다. 그 결과를 handler.accumulate로 누적하여 최종 EvaluationResult를 만듭니다.
조건부 로직이 없거나 만족하는 액션이 하나도 없으면 그대로 기본 결과를 반환합니다.
폼 응답 시 해당 값 활용
{ "visibleStepList": [2, 3, 4, 5, 6, 7], "hiddenStepList": [1] }
위와 같이 평가된 조건부 로직의 결과는 이후 어떤 방식으로 활용될까요?
지원서 답변 스텝 제출 검증
API: PUT api/submission-answers/:id/submissions/:submissionId/steps/:step
이 API는 스텝을 넘어갈 때마다 호출됩니다. 이제는 다음 버튼뿐만 아니라 이전 버튼에도 해당 API를 호출합니다. body 값으로 direction을 받아서 이전 버튼인지 다음 버튼의 액션인지를 판단합니다.
Validation
canSubmitCurrentStep({ answerRecord, submission, currentStep, hiddenStepList, }: { answerRecord: SubmissionAnswer['answer']; submission: Submission; currentStep: number; hiddenStepList: number[]; }) { this.validateCanSubmitHiddenStep({ currentStep, hiddenStepList, }); this.validateStepsCompleteUpToCurrent({ answerRecord, submission, currentStep, hiddenStepList, }); }
해당 스텝을 제출해도 되는지 검증하는 validator에서 사용됩니다.
우선 제출하려는 스텝이 숨겨진 스텝이라면 해당 API는 오류를 던지게 되어있습니다.
submission.stepList ?.filter( (step) => step.step <= currentStep && !hiddenStepList.includes(step.step), ) ... });
또한 현재 스텝까지 작성한 모든 값들을 순회하며 필수값들을 잘 작성했는지 확인합니다. 이때 숨겨진 스텝은 작성할 필요가 없으므로 필터로 제거하여 검증합니다.
이동해야 할 스텝 지정
const targetStep = await this.getTargetStep({ submission, step, direction, hiddenStepList, }); private async getTargetStep({ submission, step, direction, hiddenStepList, }: { submission: Submission; step: number; direction: 'forward' | 'backward'; hiddenStepList: number[]; }) { let targetStep = direction === 'forward' ? step + 1 : step - 1; const maxStep = submission.stepList?.length ?? 0; while ( targetStep >= 1 && targetStep <= maxStep && hiddenStepList.includes(targetStep) ) { targetStep = direction === 'forward' ? targetStep + 1 : targetStep - 1; } if (targetStep < 1 || targetStep > maxStep) { throw new BadRequestException( SUBMISSION_ANSWER_ERROR.INVALID_TARGET_STEP.message, { cause: SUBMISSION_ANSWER_ERROR.INVALID_TARGET_STEP.code, }, ); } return targetStep; }
숨겨진 스텝을 건너뛰는 이동 규칙이 필요합니다. 이동하려는 다음 스텝 또는 이전 스텝이 hiddenStepList에 포함되어 있다면, 해당 스텝은 건너뛰고 보이는 스텝이 나올 때까지 계속 이동해야 합니다. 이때 이동 방향(이전/다음)이 매우 중요합니다.
{ "visibleStepList": [1, 3, 5, 6, 7], "hiddenStepList": [2, 4, 5] }
위와 같은 상태에서 사용자가 현재 3번 스텝에 있다고 가정해보겠습니다.
이전 버튼을 눌렀을 때, 2번 스텝은 숨겨져 있으므로 2번으로는 이동할 수 없습니다. 따라서 사용자는 1번 스텝으로 이동해야 합니다.
다음 버튼을 눌렀을 때, 4번과 5번 스텝이 숨겨져 있으므로 이 둘을 모두 건너뛰고 6번 스텝으로 이동해야 합니다.
서버는 버튼이 이전으로 가는지 다음으로 가는지 같은 이동 방향 정보를 직접 알 수 없기 때문에, 프론트엔드가 버튼 액션에 따라 이동 방향을 서버에 명시적으로 전달합니다. 서버는 이 방향 정보를 기반으로 그 방향으로 한 스텝씩 이동해 가며 숨겨지지 않은 첫 번째 스텝을 찾습니다.
이렇게 서버가 계산한 결과(visibleStepList, hiddenStepList 및 이동 대상 스텝)를 응답으로 내려주면, 프론트는 이를 바탕으로 history 조작을 통해 실제 화면 스텝을 이동시키며, 전체 플로우는 서버 주도(step server-driven) 방식으로 동작하게 됩니다.
지원서 답변 조회
API: GET api/submission-answers/:id/submissions/:submissionId/steps/:step
이 API는 스��을 조회할 때마다 호출됩니다.
Validation
canAccessCurrentStep({ answerRecord, submission, currentStep, hiddenStepList, visibleStepList, }: { answerRecord: SubmissionAnswer['answer']; submission: Submission; currentStep: number; hiddenStepList: number[]; visibleStepList: number[]; }) { this.validateStepsCompleteUpToPrevious({ answerRecord, submission, currentStep, hiddenStepList, }); this.validateStepNotHidden({ currentStep, hiddenStepList, visibleStepList, totalSteps: submission.stepList?.length, }); }
해당 스텝을 접근해도 되는지 검증하는 validator에서 사용됩니다.
submission.stepList ?.filter( (step) => step.step < currentStep && !hiddenStepList.includes(step.step), ) ... });
여기서도 마찬가지로 현재 스텝 이전까지 작성한 모든 값들을 순회하며 필수값들을 잘 작성했는지 확인합니다. 이때 숨겨진 스텝은 작성할 필요가 없으므로 필터로 제거하여 검증합니다.
private validateStepNotHidden({ currentStep, hiddenStepList, visibleStepList, totalSteps, }: { currentStep: number; hiddenStepList: number[]; visibleStepList: number[]; totalSteps?: number; }) { if (hiddenStepList.includes(currentStep)) { const nextStep = visibleStepList.filter((step) => step > currentStep)[0] ?? totalSteps; throw new ForbiddenException( SUBMISSION_ANSWER_ERROR.STEP_SKIPPED_BY_CONDITIONAL_LOGIC.message, { cause: { code: SUBMISSION_ANSWER_ERROR .STEP_SKIPPED_BY_CONDITIONAL_LOGIC.code, maxAccessStep: nextStep, }, }, ); } }
모든 필수 문항을 포함해 사용자가 입력을 마쳤다고 가정하겠습니다. 이 시점부터는 검증(validation) 단계로 진입합니다. 이 검증은 사용자가 정상적인 흐름(이전/다음 버튼)을 통해 이동한 경우가 아니라, URL 직접 접근 등으로 숨겨진 스텝에 강제로 진입하려 할 때 동작합니다.
이때 서버는 지금 요청한 스텝보다 번호가 큰 스텝들 중에서, 실제로 보여질 수 있는 가장 앞선 스텝으로 사용자를 이동시킵니다.
{ "visibleStepList": [1, 3, 5, 6, 7], "hiddenStepList": [2, 4, 5] }
위와 같이 설정된 상태에서 사용자가 4번 스텝에 직접 접근했다고 가정해보겠습니다.
4번 스텝은 숨겨져 있으므로 접근이 허용되지 않습니다. 4번보다 번호가 크고 동시에 보이는 스텝 중 가장 먼저 올 수 있는 스텝은 6번입니다. 따라서 사용자는 6번 스텝으로 리다이렉션됩니다.
예를 들어 3번 스텝에 작성하지 않은 필수 문항이 남아 있는 상태에서 사용자가 다른 스텝에 접근하면, 서버는 이를 감지하고 해당 필수 문항이 있는 3번 스텝으로 즉시 이동시킵니다.
이때 서버는 STEP_SKIPPED_BY_CONDITIONAL_LOGIC.code를 함께 내려주고, 프론트는 이 코드를 받아 내려준 스텝으로 리다이렉션 처리를 수행합니다.
위와 같은 예외 상황이 없더라도, 숨겨진 스텝에 직접 접근하는 요청은 모두 이 검증 로직에 의해 걸러지며, 사용자는 항상 조건을 만족하는 보이는 스텝으로만 이동할 수 있도록 강제됩니다.
지원서 최종 제출
API: PATCH api/submission-answers/:id/status/submitted
이 API는 최종 제출 시에 호출됩니다.
Validation
this.validationService.canSubmitFinal({ answerRecord: submissionAnswer.answer, submission, hiddenStepList, });
canSubmitFinal({ answerRecord, submission, hiddenStepList, }: { answerRecord: SubmissionAnswer['answer']; submission: Submission; hiddenStepList: number[]; }) { this.validateStepsCompleteUpToFinal({ answerRecord, submission, hiddenStepList, }); }
최종까지의 스텝에 모든 답변을 검증하는 validator에서 사용됩니다.
private validateStepsCompleteUpToFinal({ answerRecord, submission, hiddenStepList, }: { answerRecord: SubmissionAnswer['answer']; submission: Submission; hiddenStepList: number[]; }) { submission.stepList ?.filter((step) => !hiddenStepList.includes(step.step)) .forEach((step) => { ... }); }
여기서도 마찬가지로 마지막 스텝까지 작성한 모든 값들을 순회하며 필수값들을 잘 작성했는지 확인합니다. 이때 숨겨진 스텝은 작성할 필요가 없으므로 필터로 제거하여 검증합니다.
트러블 슈팅: 연쇄적으로 적용된 스텝 가리기가 의도대로 동작하지 않음
구현 과정에서 조건부 로직 간의 우선순위 결정 문제가 있었습니다. 현재는 뒤에 있는 조건부 로직이 앞선 조건을 덮어쓰는 방식으로 동작합니다. 이는 액션을 순차적으로 누적하는 구조에서 자연스럽게 발생하는 결과입니다.
문제 상황
다음과 같은 조건부 로직이 설정되어 있다고 가정하겠습니다.
재현 시나리오
핵심 문제
결론은 명확했습니다. 가려진 스텝에 적용된 조건부 로직은 평가되지 말아야 합니다.
해결 방안 비교
방법 1: 프론트엔드에서 상태 관리
프론트엔드에서 서버로부터 받은 hiddenStepList, visibleStepList를 실시간으로 관리하고, API 호출 시 함께 전송하여 백엔드가 가려진 스텝의 조건부 로직을 평가하지 않도록 합니다.
장점은 사용자의 답변 데이터가 보존되고, 답변 히스토리 추적이 가능하다는 점입니다.
단점은 신뢰할 수 없는 클라이언트 문제입니다. 프론트엔드 상태는 조작 가능하며, 개발자 도구로 hiddenStepList를 변조하면 조건부 로직을 우회할 수 있습니다. 또한 프론트-백 간 상태 동기화 로직이 필요하고, 여러 탭이나 디바이스에서 동시 작업 시 상태 불일치가 발생할 수 있습니다.
방법 2: 백엔드에서 답변 무효화
백엔드에서 가려진 스텝에 대한 submission_answer를 null로 변경합니다. 답변이 없어지면 해당 조건부 로직이 작동하지 않게 됩니다.
장점은 Single Source of Truth로 DB가 유일한 진실 공급원이 되고, 백엔드에서 제어하므로 클라이언트 조작이 불가능하며, 조건부 로직 평가 시 답변 유무만 체크하면 된다는 단순함입니다.
단점은 데이터 손실입니다. 사용자가 공들여 작성한 답변이 사라지며, UX 문제가 발생합니다.
둘 다 장단점이 명확하고 완벽한 방법은 아니라고 판단했습니다.
새로운 방법
submission_answer를 삭제하는 것은 UX적으로 말이 되지 않습니다. 또한 soft-delete를 한다고 해도, 매번 요청마다 이를 삭제하거나 복구를 반복해야 하기 때문에 데이터 정합성 오류가 발생할 우려가 있습니다. disk I/O와 network 비용도 꽤 많이 들어갈 것으로 보였습니다.
따라서 백엔드 단에서 조건을 바탕으로 이러한 문제를 넘어설 방법으로 해결해야 했습니다. 핵심 명제는 다음과 같습니다.
조건부 로직 평가 시, 가려진 스텝의 조건은 무시
해결 흐름
예시
undefined
이렇게 구현함으로써, 가려진 스텝의 답변이 DB에 남아있더라도 해당 조건은 평가되지 않아 의도한 대로 동작하게 됩니다.
적용 결과
실제 서비스에 조건부 로직을 적용한 결과, 사용자 답변에 따라 폼이 동적으로 변하는 것을 확인할 수 있었습니다.
예를 들어 아래에 실제 적용된 폼을 확인할 수 있습니다. 트랙 선택 질문에서 "트랙 1"을 선택하면, 해당 트랙과 관련없는 특정 스텝들이 자동으로 숨겨지고 "트랙 1"과 관련된 질문들만 표시됩니다. 반대로 "트랙 2"를 선택하면 "트랙 1"과 관련된 질문들이 숨겨집니다.

트랙 1 선택 화면
트랙 1을 선택했을 때

트랙 1 선택 후 다음 화면
트랙 1을 선택하고 다음 버튼을 눌렀을 때
사용자가 다음 버튼을 눌러 이동할 때도 숨겨진 스텝들은 자동으로 건너뛰어집니다. 서버는 이동 방향(이전/다음)을 판단하여 보이는 다음 스텝으로 정확하게 안내합니다.

트랙 2 선택 화면
트랙 2를 선택했을 때

트랙 2 선택 후 다음 화면
트랙 2를 선택하고 다음 버튼을 눌렀을 때
조건부 로직이 적용되면서 사용자는 본인과 관련 없는 질문들을 자연스럽게 건너뛰고, 필요한 질문에만 집중할 수 있게 되었습니다. 무엇보다 폼 구성 자체를 어색하게 나누지 않고도 좋은 사용자 경험을 제공할 수 있다는 점이 좋았습니다.
실제로 여러 모집 공고에서 선발 과정이나 교육 과정에 따라 다른 질문을 받아야 하는 경우가 많았습니다. 이전에는 폼을 여러 개로 나누거나 모든 질문을 보여주는 방식밖에 없었는데, 이제는 하나의 폼으로 깔끔하게 처리할 수 있게 되었습니다.
그래서 비즈니스적으로도 많은 기여를 할 수 있었던 의미 있는 프로젝트라고 생각합니다.
마치며
복잡한 기술이나 화려한 아키텍처가 필요한 작업은 아니었습니다. 하지만 개발자로서 가장 중요한 능력인 비즈니스 로직을 정확하게 작성하고, 다양한 엣지 케이스를 미리 발견하고 대응하는 과정을 충분히 경험할 수 있었습니다.
사용자가 스텝을 앞뒤로 이동하며 답변을 변경할 때, 가려진 스텝의 답변을 어떻게 처리할지, 연쇄적으로 적용되는 조건들을 어떤 순서로 평가할지, URL 직접 접근은 어떻게 막을지 같은 문제들을 하나씩 해결하면서 많은 것을 배웠습니다. 결국 이런 꼼꼼함이 안정적인 서비스를 만드는 기반이 되며, 이를 지속적으로 연습해야 한다는 것을 느낄 수 있었습니다.