유효성 검사 중에는 백엔드 API나 서드 파티 서비스로 데이터가 적합한지 검사해야 하는 경우가 있습니다.
시그널 폼은 이런 경우를 위해 비동기 유효성 검사를 2가지 방법으로 지원합니다.
HTTP 기반으로 유효성 검사를 해야 하는 경우는 validateHttp()를 사용하고, 커스텀 리소스로 유효성 검사를 해야 하는 경우는 validateAsync()를 사용하면 됩니다.
비동기 유효성 검사는 언제 사용하나요
비동기 유효성 검사는 유효성 검사에 외부 데이터가 필요할 때 사용합니다. 보통은 이런 경우입니다:
- 중복 검사 - 사용자 이름이나 이메일이 사용 중인지 확인할 때
- DB 탐색 - 서버 데이터를 확인해야 할 때
- 외부 API 유효성 검사 - 주소, 주민번호 등 서드 파티 서비스를 활용해서 데이터를 확인해야 할 때
- 서버의 비즈니스 규칙을 확인할 때 - 서버에서만 실행할 수 있는 유효성 검사가 필요할 때
클라이언트에서 동기 방식으로 실행할 수 있는 유효성 검사에 비동기 유효성 검사를 적용하지 마세요.
pattern(), email(), validate()와 같이 형식을 검사하거나 정적인 규칙을 확인하는 경우는 동기 유효성 검사를 사용하는 것이 맞습니다.
비동기 유효성 검사는 어떻게 동작하나요
비동기 유효성 검사는 동기 유효성 검사를 모두 통과한 후에 실행됩니다.
그리고 비동기 검사가 실행 중일 때는 pending() 시그널이 true를 반환합니다.
유효성 검사 결과를 활용하면 특정 필드에 오류가 발생했다는 것을 표시할 수 있으며, 필드 값이 변경되면 진행 중인 비동기 유효성 검사를 취소할 수도 있습니다.
사용자 이름을 사용할 수 있는지 검사하는 예제 코드를 살펴봅시다:
import {Component, signal} from '@angular/core';
import {form, validateHttp, FormField} from '@angular/forms/signals';
@Component({
selector: 'app-registration',
imports: [FormField],
template: `
<form>
<label>
Username:
<input [formField]="registrationForm.username" />
</label>
@if (registrationForm.username().pending()) {
<span class="checking">Checking availability...</span>
}
@if (registrationForm.username().invalid()) {
@for (error of registrationForm.username().errors(); track $index) {
<span class="error">{{ error.message }}</span>
}
}
</form>
`,
})
export class Registration {
registrationModel = signal({username: ''});
registrationForm = form(this.registrationModel, (schemaPath) => {
validateHttp(schemaPath.username, {
request: ({value}) => {
const username = value();
return username ? `/api/users/check?username=${username}` : undefined;
},
onSuccess: (response: {available: boolean}) => {
return response.available
? null
: {
kind: 'usernameTaken',
message: 'Username is already taken',
};
},
onError: (error) => {
console.error('Validation request failed:', error);
return {
kind: 'serverError',
message: 'Could not verify username availability',
};
},
});
});
}
유효성 검사가 진행되는 과정은 이렇습니다:
- 사용자가 값을 입력합니다.
- 동기 유효성 검사가 먼저 실행됩니다.
- 동기 유효성 검사가 실패하면 비동기 유효성 검사는 실행되지 않습니다.
- 동기 유효성 검사가 성공하면, 비동기 유효성 검사가 실행되며
pending()시그널이true를 반환합니다. - 비동기 유효성 검사 요청이 성공하면
pending()시그널이false를 반환합니다. - 유효성 검사 응답을 확인해서 오류를 표시합니다.
HTTP 유효성 검사: validateHttp()
validateHttp() 함수는 일반적인 비동기 유효성 검사로 가장 많이 사용됩니다.
REST API나 HTTP 엔드포인트로 유효성 검사를 진행할 때 사용하세요.
요청 함수
request() 함수는 URL 문자열이나 HttpResourceRequest 객체를 반환합니다.
undefined를 반환하면 유효성 검사를 건너뜁니다:
import {Component, signal} from '@angular/core';
import {form, validateHttp, FormField} from '@angular/forms/signals';
@Component({
selector: 'app-registration',
imports: [FormField],
template: `...`,
})
export class Registration {
registrationModel = signal({username: ''});
// 유효성 검사에 통과한 사용자명을 캐싱합니다.
private validatedUsernames = new Set<string>();
registrationForm = form(this.registrationModel, (schemaPath) => {
validateHttp(schemaPath.username, {
request: ({value}) => {
const username = value();
// 이미 유효성 검사가 끝난 HTTP 요청은 건너뜁니다.
if (this.validatedUsernames.has(username)) return undefined;
return `/api/users/check?username=${username}`;
},
onSuccess: (response: {available: boolean}, {value}) => {
if (response.available) {
// 유효성 검사에 성공하면 캐시에 저장합니다.
this.validatedUsernames.add(value());
return null;
}
return {
kind: 'usernameTaken',
message: 'Username is already taken',
};
},
onError: () => ({
kind: 'serverError',
message: 'Could not verify username',
}),
});
});
}
POST 요청이나 커스텀 헤더를 사용해야 한다면 HttpResourceRequest 객체를 반환하세요:
request: ({value}) => ({
url: '/api/validate',
method: 'POST',
body: {username: value()},
}) // prettier-ignore
비동기 유효성 검사 성공/실패 처리하기
onSuccess 함수는 HTTP 응답을 인자로 받아, 값이 유효하지 않으면 유효성 검사 오류를 반환하고 유효하면 undefined를 반환합니다.
onSuccess: (response: { valid: boolean; message?: string }) => {
if (response.valid) return undefined;
return {
kind: 'invalid',
message: response.message || 'Validation failed',
};
} // prettier-ignore
오류는 여러 개를 한 번에 반환할 수도 있습니다:
onSuccess: (response: { usernameTaken: boolean; profanity: boolean }) => {
const errors = [];
if (response.usernameTaken) {
errors.push({
kind: 'usernameTaken',
message: 'Username taken',
});
}
if (response.profanity) {
errors.push({
kind: 'profanity',
message: 'Username contains inappropriate content',
});
}
return errors.length > 0 ? errors : undefined;
} // prettier-ignore
onSuccess 함수의 타입은 인자에서 바로 지정하거나 validateHttp의 options으로 parse 프로퍼티를 활용하면 됩니다.
onSuccess: (response: { usernameTaken: boolean; profanity: boolean }) => {
// ...
} // prettier-ignore
// 또는
options: {
parse: (response) => response as {usernameTaken: boolean; profanity: boolean};
}
onSuccess: (response) => {
// ...
} // prettier-ignore
onError 함수는 네트워크 오류나 HTTP 오류를 반환받았을 때 실행됩니다:
onError: (error) => {
console.error('Validation request failed:', error);
return {
kind: 'serverError',
message: 'Could not verify. Please try again later.',
};
} // prettier-ignore
HTTP 옵션
request 함수에서 HttpResourceRequest 객체를 반환하면 HTTP 요청을 커스터마이징할 수 있습니다:
import {HttpHeaders} from '@angular/common/http';
import {validateHttp} from '@angular/forms/signals';
validateHttp(schemaPath.field, {
request: ({value}) => ({
url: `/api/validate?value=${value()}`,
headers: new HttpHeaders({
Authorization: 'Bearer token',
}),
timeout: 5000,
}),
onSuccess: (response: {valid: boolean}) =>
response.valid
? null
: {
kind: 'invalid',
message: 'Invalid value',
},
onError: () => ({
kind: 'requestFailed',
message: 'Unable to reach server to validate.',
}),
});
팁: 사용할 수 있는 옵션을 확인하려면 httpResource API 문서를 참고하세요.
커스텀 비동기 유효성 검사: validateAsync()
비동기 유효성 검사는 대부분 validateHttp()를 사용하면 됩니다.
이 함수는 최소 옵션으로 HTTP 요청을 처리하며, 대부분의 경우에 활용할 수 있습니다.
validateAsync()는 하위 계층 API이기 때문에 Angular의 리소스 자원을 그대로 노출합니다.
이 함수를 활용하면 HTTP 요청을 원하는 대로 활용할 수 있지만, Angular 리소스 API에 대해 자세하게 알아야 합니다.
validateHttp()로 해결할 수 없다면 validateAsync() 활용을 검토하세요.
이런 경우에 활용하면 됩니다:
- HTTP가 아닌 유효성 검사 - WebSocket 연결이나, IndexedDB 탐색, Web Worker 연산인 경우
- 커스텀 캐싱 정책 - 단순한 메모이제이션이 아니라 애플리케이션에 특화된 캐싱을 해야 하는 경우
- 재시도 로직이 복잡한 경우 - 커스텀 재시도 정책이나 조건부 재시도를 활용하는 경우
- resource에 직접 접근하는 경우 - resource 라이프사이클에 적극적으로 개입해야 하는 경우
커스텀 유효성 검사 규칙 만들기
validateAsync() 함수는 params, factory, onSuccess, onError로 프로퍼티가 4개 필요합니다.
이때 params 함수는 리소스에 전달할 인자를 반환하며, factory는 리소스를 생성합니다:
import {Component, inject, signal, resource, Signal} from '@angular/core';
import {form, validateAsync, FormField} from '@angular/forms/signals';
import {UsernameValidator} from './username-validator';
@Component({
selector: 'app-registration',
imports: [FormField],
template: `...`,
})
export class Registration {
registrationModel = signal({username: ''});
private usernameValidator = inject(UsernameValidator);
private cache = new Map<string, {available: boolean}>();
// 캐싱을 관리하는 커스텀 리소스 팩토리
createUsernameResource = (usernameSignal: Signal<string | undefined>) => {
return resource({
params: () => usernameSignal(),
loader: async ({params: username}) => {
if (!username) return undefined;
// 캐싱되었는지 확인합니다
const cached = this.cache.get(username);
if (cached !== undefined) return cached;
// 유효성 검사를 위해 서비스를 의존성으로 주입합니다.
const result = await this.usernameValidator.checkAvailability(username);
// 캐싱하고 결과를 반환합니다.
this.cache.set(username, result);
return result;
},
});
};
registrationForm = form(this.registrationModel, (schemaPath) => {
validateAsync(schemaPath.username, {
params: ({value}) => {
const username = value();
return username.length >= 3 ? username : undefined!;
},
factory: this.createUsernameResource,
onSuccess: (result) => {
return result?.available
? null
: {
kind: 'usernameTaken',
message: 'Username taken',
};
},
onError: (error) => {
console.error('Validation failed:', error);
return {
kind: 'serverError',
message: 'Could not verify username',
};
},
});
});
}
params 함수는 값이 변경될 때마다 실행됩니다.
그리고 유효성 검사를 건너뛰려면 undefined를 반환하면 됩니다.
factory 함수는 초기 설정 단계에 한 번 실행되며 시그널을 인자로 받습니다.
리소스는 params 함수가 반환하는 값이 변경될 때마다 자동으로 갱신됩니다.
옵저버블 기반 서비스 활용하기
애플리케이션에서 옵저버블 기반 서비스를 활용하고 있다면 @angular/core/rxjs-interop 패키지로 제공되는 rxResource를 활용하세요:
import {Component, inject, signal, Signal} from '@angular/core';
import {rxResource} from '@angular/core/rxjs-interop';
import {form, validateAsync, FormField} from '@angular/forms/signals';
import {UsernameService} from './username-service';
@Component({
selector: 'app-registration',
imports: [FormField],
template: `...`,
})
export class Registration {
registrationModel = signal({username: ''});
private usernameService = inject(UsernameService);
private createUsernameResource = (usernameSignal: Signal<string | undefined>) => {
return rxResource({
params: () => usernameSignal(),
stream: ({params: username}) => this.usernameService.checkUsername(username),
});
};
registrationForm = form(this.registrationModel, (schemaPath) => {
validateAsync(schemaPath.username, {
params: ({value}) => value(),
factory: this.createUsernameResource,
onSuccess: (result) =>
result?.available ? null : {kind: 'usernameTaken', message: 'Username taken'},
onError: () => ({
kind: 'serverError',
message: 'Could not verify username',
}),
});
});
}
rxResource 함수는 옵저버블을 직접 처리합니다.
필드 값이 변경되었을 때 기존에 구독한 옵저버블을 정리하는 기능도 자동으로 합니다.
디바운싱(debouncing)
debounce는 사용자가 폼 모델에 입력하는 값을 디바운싱(debouncing)합니다.
디바운싱은 사용자의 값 입력을 일정 시간 동안 기다린 후에 폼 모델에 반영하는 것을 의미합니다.
이 방식은 모든 키 눌림에 반응하지 않기 때문에 연산 자원을 아낄 수 있으며, 값을 입력하는 중에는 오류 메시지를 표시하지 않을 수 있고, 개별 글자마다 반응하지 않을 수 있습니다.
스키마 안에서 debounce를 사용하면 폼 필드의 UI 변화를 어떻게 지연시킬지 지정해야 합니다.
가장 간단하게는 debounce(path, ms)라고 작성하면 사용자의 입력이 해당 밀리초만큼 지난 후에 모델에 값을 반영합니다.
중간에 값이 변경되는 동안은 타이머가 계속 초기화됩니다.
아래 예제는 username 필드에 사용자가 값을 충분히 입력할 수 있도록 debounce와 validateHttp를 사용한 예제 코드입니다:
import {Component, signal} from '@angular/core';
import {form, debounce, validateHttp, FormField} from '@angular/forms/signals';
@Component({
selector: 'app-registration',
imports: [FormField],
template: `
<label>
Username:
<input [formField]="registrationForm.username" />
</label>
@if (registrationForm.username().pending()) {
<span class="checking">Checking availability...</span>
}
`,
})
export class Registration {
registrationModel = signal({username: ''});
registrationForm = form(this.registrationModel, (schemaPath) => {
// 폼 모델에 값을 전달하기까지 300 ms 기다립니다.
debounce(schemaPath.username, 300);
// 모든 키 입력에 반응하지 않고 디바운싱을 기다립니다.
validateHttp(schemaPath.username, {
request: ({value}) => {
const username = value();
// 빈 값이 입력되면 요청을 보내지 않습니다.
return username ? `/api/users/check?username=${username}` : undefined;
},
onSuccess: (response: {available: boolean}) =>
response.available ? null : {kind: 'usernameTaken', message: 'Username is already taken'},
onError: () => ({
kind: 'serverError',
message: 'Could not verify username availability',
}),
});
});
}
300ms 라고 디바운싱을 지정하면, 폼 모델은 사용자가 해당 시간만큼 입력이 멈추기를 기다린 후에 유효성 검사를 실행합니다. 예를 들면, "signal forms"라고 빠르게 입력하면 유효성 검사 요청은 12번이 아니라 1번만 실행합니다.
touched 상태면 바로 반영하기
디바운싱 딜레이와 관계없이, 필드가 touched 상태가 되면 프레임워크는 필드의 controlValue() 값을 모델에 즉시 전달합니다.
네이티브 입력 필드는 포커스가 이동하면 터치된 것으로 간주되므로, 사용자가 값을 입력하고 탭을 누르면 디바운싱 타이머가 만료되는 것을 기다릴 필요가 없습니다.
커스텀 컨트롤이라면 원하는 이벤트에 응답해서 터치된 것으로 간주할 수 있습니다.
일반적으로는 폼 제출 시에 중요합니다. 사용자가 제출 버튼을 클릭하면 포커스를 가진 입력 필드에서 blur가 발생합니다. 이때 필드가 touched 상태가 되면서 대기 중인 디바운스 값을 모델에 즉시 반영하므로, 제출 핸들러가 실행되기 전에 최신 값이 동기화됩니다.
blur일 때 반응하기
어떤 필드는 사용자 입력 도중에 반응하지 않고, 입력을 모두 끝낸 후에야 값을 반영해야 하는 경우가 있습니다. 예를 들어 검색 필터라면 사용자가 값을 입력하는 동안 모든 값 입력에 반응하거나 상태 변경을 유발하는 동작은 사용자가 값을 완전히 입력하기 전까지는 제한하는 것이 좋습니다.
이런 경우에는 시간 대신 'blur'를 전달하여 필드가 touched 상태가 될 때까지 모든 갱신을 미루면 됩니다:
form(this.registrationModel, (schemaPath) => {
debounce(schemaPath.username, 'blur');
});
'blur' 옵션을 지정하면 사용자가 값을 입력하는 동안 폼 모델은 이전 값을 유지합니다.
그리고 동기, 비동기 유효성 검사와 연관 시그널, 모든 반응형 규칙이 이전 값을 기준으로 동작합니다.
이런 경우는 사용자가 입력 필드에서 포커스를 옮기거나, 커스텀 컨트롤이 자체 터치 시그널을 보낼 때 발생합니다.
커스텀 타이밍 로직
딜레이가 필요하거나 'blur'로는 해결되지 않으면, Debouncer 함수를 사용하면 됩니다.
이 함수는 필드 컨텍스트와 AbortSignal을 인자로 받으며, 모델이 업데이트해야 할 시점에 완료되는 Promise<void>를 반환합니다:
import {debounce, type Debouncer, form} from '@angular/forms/signals';
const shorterWhenLonger: Debouncer<string> = ({value}, abortSignal) => {
// 사용자가 입력 중일 가능성이 있기 때문에 쿼리가 짧으면 딜레이를 길게 합니다.
const ms = value().length < 3 ? 500 : 200;
return new Promise((resolve) => {
const timeoutId = setTimeout(resolve, ms);
// 필드가 터치 상태로 되거나 값이 변경되면, 중단 신호가 발생하여 대기 중인 타이머가 취소됩니다.
abortSignal.addEventListener(
'abort',
() => {
clearTimeout(timeoutId);
resolve();
},
{once: true},
);
});
};
const registrationForm = form(registrationModel, (schemaPath) => {
debounce(schemaPath.username, shorterWhenLonger);
});
abortSignal은 필드가 조작되거나 디바운스가 완료되기 전에 값이 변경되면 발생합니다.
중단 신호가 발생하면 Promise를 완료(resolve) 상태로 만들어서 디바운서가 대기 중인 타이머를 정리할 수 있도록 하세요.
Angular는 필드 조작 시 대기 중인 값을 모델에 반영하고 새로운 값이 들어오면 기존 값을 폐기합니다.
전체 Debouncer 구성을 알아보려면 debounce API 참조 문서를 참고하세요.
단일 비동기 유효성 검사의 디바운싱
debounce 규칙은 동기 유효성 검사, 비동기 유효성 검사, 시그널 반응 등 모든 연쇄 반응을 지연시킵니다.
하지만 그 반대의 경우가 필요한 경우도 있습니다.
required나 email과 같이 간단한 동기 유효성 검사는 그대로 실행하고, 비용이 들어가는 비동기 함수는 사용자 입력이 끝나고 동작하는 것입니다.
validateHttp()와 validateAsync()는 모두 유효성 검사를 지연시키기 위해 debounce 옵션을 지원합니다:
form(this.registrationModel, (schemaPath) => {
validateHttp(schemaPath.username, {
// HTTP 요청을 지연시킵니다.
debounce: 300,
request: ({value}) => {
const username = value();
// 빈 값이면 요청을 생략합니다.
return username ? `/api/users/check?username=${username}` : undefined;
},
onSuccess: (response: {available: boolean}) =>
response.available ? null : {kind: 'usernameTaken', message: 'Username is already taken'},
onError: () => ({
kind: 'serverError',
message: 'Could not verify username availability',
}),
});
});
그러면 모델의 값은 매 키입력마다 갱신되지만, 해당 필드에 연결된 다른 규칙들도 즉시 실행됩니다. HTTP 요청만 디바운싱됩니다. 필드 값이 변경되면 300ms 동안 기다리며, 사용자가 동작을 멈췄을 때 HTTP 요청이 진행됩니다.
두 계층이 동작하는 것을 비교해 보세요.
| 옵션 | 언제 사용하나요 |
|---|---|
debounce() 규칙 |
동기 유효성 검사, 상태 연계 반응, 필드 값이 모두 확정되어야 폼 제출이 가능할 때, 입력 중에는 반응하지 않습니다. |
validateHttp({ debounce }) 또는 validateAsync({ debounce }) |
부하가 적은 간단한 유효성 검사는 즉시 실행합니다. 하지만 연산이 필요한 검사는 사용자가 입력을 멈출 때까지 기다립니다. |
두 옵션 모두 인자를 밀리초 단위로 받습니다.
커스텀 타이밍 콜백은 조금 다른데, 폼 계층의 규칙은 Debouncer를 사용하며, 유효성 계층의 옵션은 @angular/core 패키지로 제공되는 DebounceTimer를 받습니다.
두 방식은 서로 호환되지 않습니다.
팩토리로 비동기 유효성 검사와 resource 조합하기
Angular가 제공하는 debounce 옵션은 스로틀링으로 사용할 수도 있지만, validateAsync()는 더 유용하게 조합할 수 있는 factory 함수를 제공합니다.
팩토리 함수는 시그널을 인자로 받고 리소스를 반환합니다.
두 지점 사이에는 필요한 로직을 자유롭게 구현할 수 있습니다.
리소스 하나를 랩핑하는 간단한 폼 하나를 살펴봅시다.
사용자명이 사용 가능한지는 컴포넌트 클래스 메서드로도 검사할 수 있지만 validateAsync를 활용하면 이렇게 구현할 수 있습니다:
export class Registration {
registrationModel = signal({username: ''});
private usernameValidator = inject(UsernameValidator);
// 팩토리 함수
checkUsernameAvailable = (username: Signal<string | undefined>) =>
resource({
params: () => username(),
loader: async ({params: name}) => this.usernameValidator.checkAvailability(name),
});
registrationForm = form(this.registrationModel, (schemaPath) => {
validateAsync(schemaPath.username, {
params: ({value}) => {
const username = value();
// 너무 짧은 문자열은 검사하지 않습니다.
return username.length >= 3 ? username : undefined!;
},
debounce: 300,
// 위에서 선언한 팩토리 함수를 연결합니다.
factory: this.checkUsernameAvailable,
onSuccess: (result) =>
result?.available ? null : {kind: 'usernameTaken', message: 'Username taken'},
onError: () => ({kind: 'serverError', message: 'Could not verify'}),
});
});
}
params 콜백 함수는 사용자명이 짧은 경우 undefined를 반환하기 때문에 유효성 검사를 건너뜁니다.
그리고 debounce: 300을 사용하면 사용자가 입력을 300ms 멈추기 전까지는 값이 변경된 것을 처리하지 않고 기다립니다.
이후 유효한 사용자명에는 로더를 실행하고, 디바운스된 값이 undefined로 확정되면 대기 상태를 유지합니다.
디바운스에 로직 더하기
시간 기반 디바운스보다 복잡한 로직이 필요하다면, 커스텀 팩토리를 조합하면 됩니다. 서버 응답을 캐싱하는 경우에 이 방식을 활용합니다. 예를 들어, 서버가 특정 사용자 이름을 확인했다면, 동일한 값을 다시 입력해도 요청을 보낼 필요가 없습니다.
export class Registration {
registrationModel = signal({username: ''});
private usernameValidator = inject(UsernameValidator);
registrationForm = form(this.registrationModel, (schemaPath) => {
validateAsync(schemaPath.username, {
params: ({value}) => {
const username = value();
return username.length >= 3 ? username : undefined;
},
factory: (username) => {
// 핵심 디바운스: 값이 변경되기까지 300 ms를 기다립니다.
const debouncedUsername = debounced(username, 300);
// 팩토리 클로저에 캐싱하며 해당 필드의 라이프싸이클 동안 유지됩니다.
const cache = new Map<string, {available: boolean}>();
return resource({
// 원본 시그널이 아니라 디바운스된 시그널을 읽습니다.
params: () => debouncedUsername.value(),
loader: async ({params: name}) => {
const cached = cache.get(name);
if (cached) return cached;
const result = await this.usernameValidator.checkAvailability(name);
cache.set(name, result);
return result;
},
});
},
onSuccess: (result) =>
result?.available ? null : {kind: 'usernameTaken', message: 'Username taken'},
onError: () => ({
kind: 'serverError',
message: 'Could not verify username',
}),
});
});
}
cache는 팩토리 클로저 안에 위치하기 때문에, 이 캐시는 필드의 라이프사이클 동안 유지됩니다.
사용자가 사용자명을 입력해서 서버로 한 번 검사하고 나면, 이후에는 새로운 요청을 보내지 않고 로더가 캐시를 확인합니다.
pending 상태 이해하기
비동기 유효성 검사가 실행되면, 필드의 pending() 시그널은 true를 반환합니다.
이때:
valid()는false를 반환합니다.invalid()는false를 반환합니다.errors()는 빈 배열을 반환합니다.submit()는 유효성 검사가 끝날 때까지 기다리지 않습니다. 기본적으로action은 그대로 실행됩니다(ignoreValidators참고).
pending 상태라면 템플릿에 관련 메시지를 표시하는 방식으로 활용할 수 있습니다:
<input [formField]="loginForm.username" />
@if (loginForm.username().pending()) {
<span class="loading">Checking availability...</span>
}
@if (loginForm.username().touched() && loginForm.username().invalid()) {
@for (error of loginForm.username().errors(); track $index) {
<span class="error">{{ error.message }}</span>
}
}
유효성 검사가 진행 중인 경우는 폼 제출을 막으세요:
<button type="submit" [disabled]="loginForm().pending()">
@if (loginForm().pending()) {
Validating...
} @else {
Submit
}
</button>
팁: pending(), valid(), invalid() 시그널 활용에 대해 더 알아보려면 필드 상태 관리를 참고하세요.
유효성 검사 실행 순서
비동기 유효성 검사는 동기 유효성 검사를 모두 통과한 이후에만 실행됩니다. 그래서 입력값이 잘못된 경우에는 불필요하게 서버로 요청을 보내지 않을 수 있습니다:
import {form, required, minLength, validateHttp} from '@angular/forms/signals';
import {model} from '@angular/core';
form(model, (schemaPath) => {
// 1. 동기 유효성 검사 규칙이 먼저 실행됩니다.
required(schemaPath.username);
minLength(schemaPath.username, 3);
// 2. 동기 유효성 검사를 모두 통과하면 비동기 유효성 검사 규칙이 실행됩니다.
validateHttp(schemaPath.username, {
request: ({value}) => `/api/check?username=${value()}`,
onSuccess: (result: {valid: boolean}) =>
result.valid
? null
: {
kind: 'usernameTaken',
message: 'Username taken',
},
onError: () => ({
kind: 'serverError',
message: 'Validation failed',
}),
});
});
실행 순서가 이렇다는 것을 활용하면 형식에 맞지 않는 입력값을 즉시 확인하거나 서버의 검사 부하를 줄일 수 있습니다.
요청 취소
필드 값이 변경되면 시그널 폼은 아직 완료되지 않은 비동기 유효성 검사를 모두 취소합니다. 그렇기 때문에 응답 순서가 뒤바뀌는 일 없이 현재 필드 값으로만 유효성 검사가 동작합니다. 수동으로 로직을 취소할 필요는 없습니다.
모범사례
동기 유효성 검사와 조합하기
입력값 형식 검사는 언제나 비동기 유효성 검사보다 먼저 실행하세요. 이렇게 하면 오류를 즉시 잡을 수 있고 불필요한 서버 요청을 방지할 수 있습니다:
import {form, required, email, validateHttp} from '@angular/forms/signals';
import {model} from '@angular/core';
form(model, (schemaPath) => {
// 형식을 먼저 검사합니다.
required(schemaPath.email);
email(schemaPath.email);
// 이후에 사용 가능한지 검사합니다.
validateHttp(schemaPath.email, {
request: ({value}) => `/api/emails/check?email=${value()}`,
onSuccess: (result: {available: boolean}) =>
result.available
? null
: {
kind: 'emailInUse',
message: 'Email already in use',
},
onError: () => ({
kind: 'serverError',
message: 'Could not verify email',
}),
});
});
근사값으로 유효성 검사 생략하기
request 함수가 undefined를 반환하면 유효성 검사를 생략합니다.
이것을 활용하면 필드 입력값이 비어있거나 최소 길이보다 모자란 경우 유효성 검사를 생략할 수 있습니다:
import {validateHttp} from '@angular/forms/signals';
validateHttp(schemaPath.username, {
request: ({value}) => {
const username = value();
// 빈 값이나 최소 길이보다 짧으면 유효성 검사를 생략합니다.
if (!username || username.length < 3) return undefined;
return `/api/users/check?username=${username}`;
},
onSuccess: (result: {valid: boolean}) =>
result.valid
? null
: {
kind: 'usernameTaken',
message: 'Username taken',
},
onError: () => ({
kind: 'serverError',
message: 'Validation failed',
}),
});
오류 처리하기
오류 메시지는 명확하게, 사용자가 이해하기 쉽게 제공하세요. 디버깅을 위한 세부사항은 사용자에게 표시할 필요 없이 로그로 남겨주세요:
import {validateHttp} from '@angular/forms/signals';
validateHttp(schemaPath.field, {
request: ({value}) => `/api/validate?field=${value()}`,
onSuccess: (result: {valid: boolean; message?: string}) => {
if (result.valid) return null;
// 서버와 통신할 수 있으면 서버에서 보내는 메시지를 활용합니다.
return {
kind: 'serverError',
message: result.message || 'Validation failed',
};
},
onError: (error) => {
// 디버깅 로그를 출력합니다.
console.error('Validation request failed:', error);
// 사용자가 이해하기 쉬운 메시지를 제공합니다.
return {
kind: 'serverError',
message: 'Unable to validate. Please try again later.',
};
},
});
피드백을 명확하게 제공하세요
유효성 검사가 진행 중인 것을 사용자에게 표시하려면 pending() 시그널을 활용하면 됩니다.
그러면 사용자는 왜 앱이 느려졌는지 확인할 수 있으며, 체감 성능도 향상됩니다:
@if (field().pending()) {
<span class="checking">
<span class="spinner"></span>
Checking...
</span>
}
@if (field().valid() && !field().pending()) {
<span class="success">Available</span>
}
@if (field().invalid()) {
<span class="error">{{ field().errors()[0]?.message }}</span>
}
다음 단계
이 문서에서는 validateHttp()와 validateAsync()를 활용하는 비동기 유효성 검사를 다뤘습니다.
시그널 폼에 대해 더 알아보세요:
API 문서도 확인해 보세요:
validateHttp()- HTTP 기반의 비동기 유효성 검사validateAsync()- 커스텀 리소스 기반 유효성 검사httpResource()- Angular HTTP 리소스 APIresource()- Angular 리소스 타입