Skip to content

현재 사용법 (2026-09-10, 6fbaee7f): 직원 내 정보·서류·전자계약 · 기능 마스터. 아래는 작성 당시의 변경 설명이며 현재 전체 동작·재측정 결과를 뜻하지 않습니다.

내 정보 저장 400 — 비어 있는 섹션의 null을 서버가 거부함 (CS-LOC-260909-0011) ​

사용자가 보는 변화 ​

직원 앱(hereby Works)의 「내 정보」에서 주소나 비상연락처를 고치고 저장을 누르면 「저장 중 오류가 발생했어요」만 뜨고 아무것도 저장되지 않던 것을, 정상적으로 저장되게 고칩니다. 비상연락처나 계좌를 등록하지 않은 직원에게 일어나던 문제입니다.

배경 / 지금 동작 ​

「내 정보」 화면은 여러 앱이 공유하는 컴포넌트 MyInfoForm(packages/components/src/components/MyInfoForm.ts)이 그립니다. 직원 앱은 전화·주소·비상연락처·계좌를 한 화면에서 항상 편집 상태로 보여 주고, 저장 버튼 하나로 전부 보내는 saveAll 경로를 씁니다. admin의 「내 정보」 설정 탭은 항목별로 편집 모달을 열어 한 섹션씩 저장하는 saveSection 경로를 씁니다. 두 경로 모두 서버의 employee.update 프로시저를 호출하고, 입력은 updateEmployeeSchema(packages/core/src/schemas/employee.schema.ts)가 Zod로 검증합니다.

saveAll은 2026년 7월(커밋 39e9ed10)부터 편집 가능한 모든 섹션을 매번 보냅니다. 이때 섹션이 비어 있으면 「컬럼을 비워라」는 뜻으로 null을 보냅니다. 예를 들어 비상연락처를 등록하지 않은 직원은 주소만 고쳐도 emergencyContact: null이 함께 전송됩니다.

문제는 updateEmployeeSchema가 null을 허용하는 필드가 address 하나뿐이었다는 점입니다. address는 2026년 7월(커밋 8492e0a2)에 「수정에서 명시적으로 비울 수 있어야 한다」며 nullable()이 추가됐지만, 같은 방식으로 전송되는 emergencyContact와 bankAccount는 그대로 optional()만 남았습니다. Zod는 optional()에 null이 오면 Expected object, received null로 거부하므로, 비상연락처가 없는 직원의 저장 요청은 항상 400으로 실패했습니다. 클라이언트는 오류 종류를 구분하지 않고 showError("저장 중 오류")만 띄우므로 사용자에게는 원인이 보이지 않았습니다.

같은 스키마에서 함께 걸리는 빈 문자열이 두 가지 더 있었습니다. 우편번호는 ^\d{5}$ 정규식이라 직원 앱의 우편번호 입력을 비워 두면 ""가 거부되고, 비상연락처의 관계(relationship)는 min(1)이라 이름·전화만 넣고 관계를 비우면 거부됐습니다. 직원 앱 UI는 두 값 모두 선택 입력으로 다루고 있어, UI 검증은 통과하고 서버 검증에서 실패하는 불일치였습니다.

변경 전 ​

변경 후 ​

개념 흐름별 변경 ​

비어 있는 섹션을 null로 지울 수 있게 서버 스키마를 맞춥니다. updateEmployeeSchema에서 emergencyContact와 bankAccount를 address와 동일하게 nullable().optional()로 바꿉니다. 두 컬럼은 이미 nullable: true이고, 계좌의 암호화 트랜스포머(encryptedJsonbColumn)도 null을 그대로 통과시키므로 서버 저장 로직은 손대지 않습니다. 이 한 줄이 CS 증상의 직접 원인을 제거합니다. 비상연락처의 관계는 optional()로 낮춰 직원 앱 UI의 검증 기준(이름·전화만 필수)과 일치시키고, 응답 스키마의 relationship도 같이 optional로 맞춥니다. 재사용을 위해 두 객체를 employeeEmergencyContactSchema / employeeBankAccountSchema로 분리했습니다. utils/validation.utils.ts에 이미 다른 용도의 bankAccountSchema가 있어 이름을 구분했습니다.

클라이언트 직렬화를 한 곳으로 모으고 빈 문자열을 뺍니다. saveSection과 saveAll이 각자 갖고 있던 섹션 직렬화 코드를 _serializeSection으로 통합합니다. 주소는 우편번호와 상세주소가 빈 문자열이면 키를 빼고, 비상연락처는 관계가 비어 있으면 키를 뺍니다. 스키마가 optional만 허용하는 자리에 ""가 가지 않도록 하는 것입니다. 섹션 자체가 비어 있을 때 null을 보내는 규칙은 그대로입니다.

바뀐 섹션만 전송합니다. saveAll은 로드 시점 스냅샷(editBaseline, 저장 버튼 활성화 판정에 이미 쓰던 JSON)과 비교해 실제로 달라진 섹션만 페이로드에 넣습니다(_sectionChanged). 손대지 않은 섹션까지 매번 재전송하면, 예를 들어 임포트로 들어온 옛 6자리 우편번호처럼 현재 스키마에 맞지 않는 기존 값이 전체 저장을 막습니다. 스키마만 고치는 단순한 수정으로는 이 경우를 막지 못하므로 함께 바꿨습니다.

회귀 테스트를 추가합니다. employee.schema.test.ts는 세 섹션의 null 허용과 관계 없는 비상연락처 허용을 못 박고, MyInfoForm.test.ts는 직렬화가 빈 문자열을 빼고 비어 있는 섹션을 null로 만드는 것과 바뀐 섹션만 감지하는 것을 검증합니다.

한계 ​

CS 리포트의 스크린샷과 오류 스냅샷은 이 세션에서 확인하지 못했습니다(운영 DB 조회가 차단됨). 원인은 코드와 스키마 검증 재현으로 확정했고, 스키마 테스트는 수정 전 코드에서 실패하는 것을 확인했습니다.