다크 모드
Chapter 07. 게시판 DTO와 컴포넌트 경계선의 타입 계약
"백엔드 Spring Boot 개발자가 API 응답 필드명을 슬그머니 바꿨을 때, 우리 프론트엔드는 운영 환경 배포 전에 그 사실을 눈치챌 수 있을까?
any타입을 완전히 몰아내고 엄격한 타입 계약서와 모달 컴포넌트 경계선을 구축하자."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
| Spring Boot DTO ↔ TS Interface | 백엔드 데이터 전송 객체와 프론트엔드 모델을 1:1로 맞추는 계약 | 7.1 |
| Java → JSON → TS 타입 매핑 | Long, LocalDateTime 등을 직렬화 결과 기준으로 TS에 옮기는 규칙 | 7.1 표 |
제네릭 인터페이스 PageResponse<T> | 내용물 배열의 타입을 사용하는 시점에 결정하는 페이징 응답 틀 | 7.2 🆕 박스 |
유틸리티 타입 (Pick, Omit) | 기존 인터페이스에서 필요한 필드만 추출하거나 제외하는 TypeScript 문법 | 7.2 |
런타임 타입 가드 (isPageResponse) | unknown 응답 데이터가 실제 계약 구조와 일치하는지 실행 시점에 검증 | 7.2 [코드 7-1] |
판별된 유니온 & never 완전성 검사 | 종류 필드로 타입을 좁히고 빠뜨린 분기를 컴파일 타임에 잡기 | 7.3 |
input.required<T>() & output<T>() | 모달 컴포넌트의 입출력 경계선을 신호 기반으로 엄격히 잠그기 | 7.4 |
TIP
🏁 이 장의 시작 파일frontend/src/app/board-app/board-app.component.ts가 snapshots/board-app.ch06.component.ts와 같은 상태에서 시작합니다(막혔다면 스냅샷 파일을 복사해 오십시오).
이 장에서 작성·수정하는 파일:
frontend/src/app/models/post.model.ts(도메인 DTO 계약서 및 런타임 타입 가드)frontend/src/app/components/post-create-modal.component.ts(글 작성 모달 컴포넌트)frontend/src/app/components/post-detail-modal.component.ts(글 상세 조회 모달 컴포넌트)frontend/src/app/board-app/board-app.component.ts(계약서 및 모달 연동)
7.1 백엔드 DTO와 프론트엔드의 1:1 타입 계약
우리 사내 백엔드(backend/src/main/java/com/company/board/post/PostDto.java)는 다음과 같이 명확한 DTO 클래스로 클라이언트에 내려줄 데이터를 통제하고 있습니다:
java
// Spring Boot 3 백엔드 (PostDto.java)
package com.company.board.post;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.List;
public class PostDto {
@Getter
@Builder
@AllArgsConstructor
public static class PostResponse {
private Long id;
private String title;
private String author;
private String content;
private String category; // "DEV", "NOTICE", "FREE"
private LocalDateTime createdAt;
private int viewCount;
private int likeCount;
}
@Getter
@Builder
@AllArgsConstructor
public static class PageResponse<T> {
private List<T> content;
private long totalElements;
private int totalPages;
private int pageNumber;
private int pageSize;
}
}🆕 처음 보는 문법: Java DTO와 Jackson 직렬화
- DTO(Data Transfer Object): 서버와 클라이언트가 데이터를 주고받을 때 사용하는 전용 클래스입니다. 프론트엔드의
interface PostResponse가 이 클래스의 1:1 짝꿍입니다. - Lombok (
@Getter,@Builder,@AllArgsConstructor): 반복적인 getter 메서드와 빌더 패턴 코드를 컴파일 시점에 자동 생성해 주는 Java 라이브러리입니다. - JSON이 되는 과정: Spring Boot에 내장된 Jackson 라이브러리는 getter 이름(
getTitle()→"title")을 기준으로 객체를 JSON 문자열로 변환합니다. 따라서 프론트엔드 인터페이스 속성명은 Java 필드명과 정확히 일치해야 합니다.
프론트엔드에서 이 API를 호출할 때 귀찮다고 this.http.get<any>('/api/posts')로 받는 순간 모든 재앙이 시작됩니다.
📦 비유: 깐깐한 공항 세관(Java DTO) vs 라벨 없는 미확인 밀수 택배(
any)
백엔드 Spring Boot는@NotNull,@Size를 붙여 공항 세관원처럼 데이터를 샅샅이 검사해서 보냅니다. 그런데 프론트엔드에서 귀찮다고any로 받는 순간, "아 몰라, 아무거나 상자에 담아 와라!" 하고 눈을 질끈 감아버리는 것과 같습니다.
배송된 상자에 진짜 데이터가 들었는지 폭탄이 들었는지 까보기 전까지 모르다가, 결국 운영 서버 브라우저에서Cannot read properties of undefined로 화면이 폭발하고 맙니다! 프론트엔드도 백엔드 DTO와 토씨 하나 틀리지 않는 **1:1 타입 계약서(interface PostResponse)**를 써야 두 다리 뻗고 잘 수 있습니다.
Java 타입 → JSON → TypeScript 6.0 타입 매핑표
Java 클래스를 TypeScript로 옮길 때는 **Java 타입이 아니라 "JSON으로 직렬화된 결과"**를 기준으로 매핑해야 합니다.
Java (PostDto) | JSON 직렬화 값 | TypeScript | 실무 주의사항 |
|---|---|---|---|
Long id | 101 | number | JS number는 2^53 - 1(Number.MAX_SAFE_INTEGER)까지만 정밀도를 보장합니다. Snowflake ID(64비트 정수)를 쓴다면 백엔드에서 문자열로 직렬화하고 TS는 string으로 받아야 끝자리가 깨지지 않습니다. |
String title | "Angular 22 도입기" | string | DB 컬럼이 Nullable이라면 string | null로 정직하게 선언해 Null 참조 에러를 막습니다. |
String category | "DEV" | 'DEV' | 'NOTICE' | 'FREE' | Java에서는 일반 String이라도 허용 값이 정해져 있다면 TS에서는 문자열 리터럴 유니온으로 좁혀 오타를 컴파일 타임에 차단합니다. |
LocalDateTime createdAt | "2026-11-01T14:30:00" | string | JSON에는 날짜 타입이 없습니다. Spring Boot는 ISO-8601 문자열로 내려보내므로 TS에서도 Date가 아닌 string으로 선언합니다. |
int viewCount | 42 | number | Java 원시 타입(int, long)은 null이 될 수 없으므로 옵셔널(?)을 붙이지 않습니다. |
List<T> content | [...] | T[] | 제네릭 타입 매개변수 T를 그대로 배열 요소 타입으로 연결합니다. |
7.2 [실습 1] 도메인 계약서와 런타임 타입 가드 (post.model.ts)
6장까지 컴포넌트 내부에 임시로 적어 두었던 느슨한 인터페이스를 제거하고, 백엔드 DTO와 1:1로 맞물리는 공식 도메인 계약서 파일을 작성합니다. 특히 TypeScript의 타입은 빌드 후 사라지므로(Type Erasure), 외부 API 응답이 정말 PageResponse<T> 모양인지 실행 시점에 확인하는 **런타임 타입 가드(isPageResponse)**도 함께 구축합니다.
[코드 7-1] 프론트엔드 모델 계약서 (frontend/src/app/models/post.model.ts)
typescript
// frontend/src/app/models/post.model.ts
export type PostCategory = 'DEV' | 'NOTICE' | 'FREE';
// 검색 필터 드롭다운용 타입 ('ALL'은 화면 전용 상태이므로 PostCategory와 분리)
export type CategoryFilter = PostCategory | 'ALL';
// 1. 백엔드 PostDto.PostResponse와 1:1 매핑되는 모델
export interface PostResponse {
id: number;
title: string;
author: string;
content: string;
category: PostCategory;
createdAt: string;
viewCount: number;
likeCount: number;
}
// 기존 코드와의 호환을 위한 타입 별칭
export type Post = PostResponse;
// 2. 글 등록 요청용 DTO: 서버가 자동 생성하는 필드를 Omit으로 제외
export type CreatePostRequest = Omit<
PostResponse,
'id' | 'createdAt' | 'viewCount' | 'likeCount'
>;
// 3. 글 수정 요청용 DTO: 수정 가능한 필드만 Pick으로 추출
export type UpdatePostRequest = Pick<PostResponse, 'title' | 'content' | 'category'>;
// 4. Spring Boot PageResponse<T>와 1:1 매핑되는 제네릭 페이징 계약
export interface PageResponse<T> {
content: T[];
totalElements: number;
totalPages: number;
pageNumber: number;
pageSize: number;
}
// 5. 🌟 런타임 타입 가드(User-Defined Type Guard)
// 백엔드 응답(unknown)이 실제로 PageResponse 규격을 지켰는지 런타임에 검증합니다.
export function isPageResponse<T>(
data: unknown,
itemGuard?: (item: unknown) => item is T
): data is PageResponse<T> {
if (typeof data !== 'object' || data === null) {
return false;
}
const candidate = data as Record<string, unknown>;
const hasValidMeta =
Array.isArray(candidate['content']) &&
typeof candidate['totalElements'] === 'number' &&
typeof candidate['totalPages'] === 'number' &&
typeof candidate['pageNumber'] === 'number' &&
typeof candidate['pageSize'] === 'number';
if (!hasValidMeta) {
return false;
}
if (itemGuard) {
return (candidate['content'] as unknown[]).every(itemGuard);
}
return true;
}
// 개별 게시글 객체 검증용 런타임 가드
export function isPostResponse(item: unknown): item is PostResponse {
if (typeof item !== 'object' || item === null) return false;
const p = item as Record<string, unknown>;
return (
typeof p['id'] === 'number' &&
typeof p['title'] === 'string' &&
typeof p['author'] === 'string' &&
typeof p['content'] === 'string' &&
(p['category'] === 'DEV' || p['category'] === 'NOTICE' || p['category'] === 'FREE') &&
typeof p['createdAt'] === 'string' &&
typeof p['viewCount'] === 'number' &&
typeof p['likeCount'] === 'number'
);
}🆕 처음 보는 문법: 유틸리티 타입과 사용자 정의 타입 가드
Omit<T, Keys>/Pick<T, Keys>: 이미 선언된PostResponse에서 특정 속성을 빼거나(Omit) 골라내어(Pick) 새로운 타입을 만듭니다. 필드 정의를 중복 작성하지 않아PostResponse하나만 수정해도 요청 DTO 타입들이 자동으로 함께 갱신됩니다.data is PageResponse<T>(타입 술어, Type Predicate): 함수의 반환 타입 자리에인자명 is 타입을 적으면, 이 함수가true를 반환한if (isPageResponse(res))블록 안에서 TypeScript가res의 타입을unknown에서PageResponse<T>로 안전하게 좁혀(Narrowing) 줍니다.
7.3 판별된 유니온(Discriminated Unions)으로 비즈니스 다형성 모델링
게시판이 고도화되면서 게시글 종류에 따라 필수 필드가 달라지는 요구사항이 들어왔다고 가정해 봅시다:
- 일반글(
GENERAL): 본문과 댓글 허용 - 공지글(
NOTICE): 상단 고정 만료일(pinnedUntil) 필수 - 비밀글(
SECRET): 비밀번호 힌트(passwordHint) 필수
이 필드들을 하나의 인터페이스에 pinnedUntil?: string처럼 옵셔널(?)로 뭉뚱그리면, 공지글인데 만료일이 누락되는 버그를 막을 수 없습니다. 이때 **판별된 유니온(Discriminated Unions)**과 never 완전성 검사를 사용합니다.
typescript
// frontend/src/app/models/post.model.ts 하단에 추가 가능한 다형성 모델
export interface BasePost {
id: number;
title: string;
author: string;
createdAt: string;
}
export interface GeneralPost extends BasePost {
postType: 'GENERAL';
content: string;
}
export interface NoticePost extends BasePost {
postType: 'NOTICE';
content: string;
pinnedUntil: string; // 공지글에는 만료일이 필수!
}
export interface SecretPost extends BasePost {
postType: 'SECRET';
passwordHint: string; // 비밀글에는 힌트가 필수!
}
export type CommunityPost = GeneralPost | NoticePost | SecretPost;
export function formatPostBadge(post: CommunityPost): string {
switch (post.postType) {
case 'GENERAL':
return '일반 게시글';
case 'NOTICE':
// ✅ 여기서 post는 자동으로 NoticePost로 좁혀져 pinnedUntil 자동완성!
return `📌 공지 (~${post.pinnedUntil}까지 고정)`;
case 'SECRET':
// ✅ 여기서 post는 자동으로 SecretPost로 좁혀져 passwordHint 자동완성!
return `🔒 비밀글 (힌트: ${post.passwordHint})`;
default: {
// 🌟 완전성 검사(Exhaustiveness Check):
// 나중에 누군가 'EVENT' 게시글 타입을 추가하고 switch 분기를 빠뜨리면 즉시 컴파일 에러 발생!
const _exhaustiveCheck: never = post;
return _exhaustiveCheck;
}
}
}Angular 22의 템플릿 컴파일러(ngtsc)는 @if와 @switch 제어 흐름 블록 안에서도 동일한 타입 좁히기를 수행합니다. 즉 템플릿에서 @if (post.postType === 'NOTICE')로 감싸면 그 블록 안에서만 {{ post.pinnedUntil }} 접근이 허용되고, 블록 밖에서 접근하면 빌드 즉시 에러를 띄워 줍니다.
🔬 심화(건너뛰어도 됨): OpenAPI 코드 생성기와 Zod 스키마 검증
규모가 큰 엔터프라이즈 환경에서는 수십 개의 DTO를 수기로 옮기는 과정에서도 휴먼 에러가 발생할 수 있습니다.
- OpenAPI 기반 타입 자동 생성 (
springdoc-openapi+openapi-typescript): Spring Boot 서버가 생성하는 Swagger(v3/api-docs) JSON 명세를 읽어npm run generate:types명령 한 번으로 프론트엔드 TypeScript 인터페이스를 자동 생성합니다. - Zod / Valibot 결합: 7.2에서 직접 작성한
isPageResponse같은 타입 가드를 복잡한 중첩 DTO마다 손으로 짜기 힘들 때,Zod스키마를 정의하면 런타임 파싱(Schema.parse(data))과 정적 타입 추출(z.infer<typeof Schema>)을 한 벌로 해결할 수 있습니다.
7.4 [실습 2] 컴포넌트 경계선 엄격화: 글 작성·상세 모달 분리
이제 BoardAppComponent 안에 엉켜 있던 글 작성 폼과 글 상세 보기 영역을 독립된 모달 컴포넌트로 분리하고, 부모-자식 컴포넌트 사이의 입출력 경계선을 input.required<T>()와 output<T>()로 엄격하게 잠급니다.
[코드 7-2] 글 작성 모달 (frontend/src/app/components/post-create-modal.component.ts)
typescript
// frontend/src/app/components/post-create-modal.component.ts
import { Component, ChangeDetectionStrategy, input, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CreatePostRequest, PostCategory } from '../models/post.model';
@Component({
selector: 'app-post-create-modal',
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (isOpen()) {
<div class="modal-backdrop" (click)="handleClose()">
<div class="modal-panel" (click)="$event.stopPropagation()">
<header class="modal-header">
<h3>✏️ 새 기술 질문 등록</h3>
<button type="button" class="btn-close" (click)="handleClose()">✕</button>
</header>
<form (ngSubmit)="onSubmit()" class="modal-body">
<div class="form-row">
<label for="category">카테고리</label>
<select
id="category"
name="category"
[ngModel]="category()"
(ngModelChange)="category.set($event)"
>
<option value="DEV">개발(DEV)</option>
<option value="NOTICE">공지(NOTICE)</option>
<option value="FREE">자유(FREE)</option>
</select>
</div>
<div class="form-row">
<label for="title">제목</label>
<input
id="title"
name="title"
type="text"
[ngModel]="title()"
(ngModelChange)="title.set($event)"
placeholder="질문 제목을 입력하세요 (2자 이상)"
required
/>
</div>
<div class="form-row">
<label for="author">작성자</label>
<input
id="author"
name="author"
type="text"
[ngModel]="author()"
(ngModelChange)="author.set($event)"
placeholder="작성자 이름"
required
/>
</div>
<div class="form-row">
<label for="content">본문</label>
<textarea
id="content"
name="content"
rows="5"
[ngModel]="content()"
(ngModelChange)="content.set($event)"
placeholder="질문 내용을 상세히 적어주세요 (10자 이상)"
required
></textarea>
</div>
@if (validationError()) {
<p class="error-msg">⚠️ {{ validationError() }}</p>
}
<footer class="modal-footer">
<button type="button" class="btn-cancel" (click)="handleClose()">취소</button>
<button type="submit" class="btn-submit" [disabled]="submitting()">
{{ submitting() ? '등록 중...' : '등록하기' }}
</button>
</footer>
</form>
</div>
</div>
}
`,
styles: [`
.modal-backdrop {
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-panel {
background: #fff; width: 100%; max-width: 520px; border-radius: 12px;
padding: 24px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15);
}
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.form-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.form-row input, .form-row select, .form-row textarea {
padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 14px;
}
.error-msg { color: #dc2626; font-size: 13px; margin: 4px 0 12px; }
.modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.btn-submit { background: #2563eb; color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; }
.btn-cancel { background: #f1f5f9; color: #334155; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; }
`]
})
export class PostCreateModalComponent {
// 1. 입력 경계선: 부모로부터 열림 여부와 전송 중 상태를 신호로 전달받음
readonly isOpen = input.required<boolean>();
readonly submitting = input<boolean>(false);
// 2. 출력 경계선: CreatePostRequest 계약에 맞는 페이로드만 부모로 방출!
readonly closed = output<void>();
readonly submitted = output<CreatePostRequest>();
// 모달 내부 로컬 폼 신호
readonly category = signal<PostCategory>('DEV');
readonly title = signal('');
readonly author = signal('');
readonly content = signal('');
readonly validationError = signal('');
onSubmit(): void {
const trimmedTitle = this.title().trim();
const trimmedAuthor = this.author().trim();
const trimmedContent = this.content().trim();
if (trimmedTitle.length < 2) {
this.validationError.set('제목은 최소 2자 이상 입력해야 합니다.');
return;
}
if (!trimmedAuthor) {
this.validationError.set('작성자 이름을 입력해 주세요.');
return;
}
if (trimmedContent.length < 10) {
this.validationError.set('본문은 최소 10자 이상 입력해야 합니다.');
return;
}
this.validationError.set('');
// 🌟 CreatePostRequest 타입 계약을 완벽히 준수하는 객체 생성 후 방출
const payload: CreatePostRequest = {
title: trimmedTitle,
author: trimmedAuthor,
content: trimmedContent,
category: this.category()
};
this.submitted.emit(payload);
}
resetForm(): void {
this.category.set('DEV');
this.title.set('');
this.author.set('');
this.content.set('');
this.validationError.set('');
}
handleClose(): void {
this.validationError.set('');
this.closed.emit();
}
}[코드 7-3] 글 상세 조회 모달 (frontend/src/app/components/post-detail-modal.component.ts)
typescript
// frontend/src/app/components/post-detail-modal.component.ts
import { Component, ChangeDetectionStrategy, input, output } from '@angular/core';
import { PostResponse } from '../models/post.model';
@Component({
selector: 'app-post-detail-modal',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (post(); as currentPost) {
<div class="modal-backdrop" (click)="closed.emit()">
<article class="modal-panel" (click)="$event.stopPropagation()">
<header class="detail-header">
<span class="category-badge">{{ currentPost.category }}</span>
<h3>{{ currentPost.title }}</h3>
<div class="meta">
<span>작성자: <strong>{{ currentPost.author }}</strong></span>
<span>작성일: {{ currentPost.createdAt }}</span>
<span>조회 {{ currentPost.viewCount }} · ❤️ {{ currentPost.likeCount }}</span>
</div>
</header>
<div class="detail-content">
{{ currentPost.content }}
</div>
<footer class="detail-footer">
<button type="button" class="btn-delete" (click)="deleted.emit(currentPost.id)">
🗑️ 삭제
</button>
<button type="button" class="btn-close" (click)="closed.emit()">
닫기
</button>
</footer>
</article>
</div>
}
`,
styles: [`
.modal-backdrop {
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-panel {
background: #fff; width: 100%; max-width: 560px; border-radius: 12px;
padding: 24px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.15);
}
.category-badge {
display: inline-block; background: #dbeafe; color: #1d4ed8;
font-size: 12px; font-weight: 700; padding: 2px 8px; border-radius: 999px;
}
.meta { display: flex; gap: 12px; color: #64748b; font-size: 13px; margin-top: 8px; }
.detail-content {
margin: 20px 0; padding: 16px; background: #f8fafc;
border-radius: 8px; white-space: pre-wrap; line-height: 1.6;
}
.detail-footer { display: flex; justify-content: space-between; }
.btn-delete { background: #fee2e2; color: #dc2626; border: none; padding: 8px 14px; border-radius: 6px; cursor: pointer; }
.btn-close { background: #e2e8f0; color: #1e293b; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; }
`]
})
export class PostDetailModalComponent {
// 선택된 게시글이 없으면 null을 받아 모달을 닫음
readonly post = input.required<PostResponse | null>();
readonly closed = output<void>();
readonly deleted = output<number>();
}[코드 7-4] BoardAppComponent에 타입 계약과 모달 연결하기
typescript
// frontend/src/app/board-app/board-app.component.ts (핵심 연동부)
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, viewChild } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {
PostResponse,
CreatePostRequest,
isPageResponse,
isPostResponse
} from '../models/post.model';
import { PostCreateModalComponent } from '../components/post-create-modal.component';
import { PostDetailModalComponent } from '../components/post-detail-modal.component';
@Component({
selector: 'app-board-app',
standalone: true,
imports: [PostCreateModalComponent, PostDetailModalComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="board-container">
<header class="board-header">
<h2>사내 기술 Q&A 게시판 (타입 계약 적용)</h2>
<button type="button" class="btn-primary" (click)="isCreateModalOpen.set(true)">
✏️ 새 글 쓰기
</button>
</header>
<table class="board-table">
<thead>
<tr><th>ID</th><th>카테고리</th><th>제목</th><th>작성자</th><th>작성일</th></tr>
</thead>
<tbody>
@for (post of posts(); track post.id) {
<tr (click)="selectedPost.set(post)" class="clickable-row">
<td>{{ post.id }}</td>
<td>{{ post.category }}</td>
<td>{{ post.title }}</td>
<td>{{ post.author }}</td>
<td>{{ post.createdAt }}</td>
</tr>
}
</tbody>
</table>
<!-- 글 작성 모달: 입출력 타입 계약으로 연결 -->
<app-post-create-modal
[isOpen]="isCreateModalOpen()"
[submitting]="isSubmitting()"
(closed)="isCreateModalOpen.set(false)"
(submitted)="handleCreatePost($event)"
/>
<!-- 글 상세 모달: PostResponse | null 계약으로 연결 -->
<app-post-detail-modal
[post]="selectedPost()"
(closed)="selectedPost.set(null)"
(deleted)="handleDeletePost($event)"
/>
</div>
`
})
export class BoardAppComponent implements OnInit {
private readonly http = inject(HttpClient);
private readonly createModal = viewChild(PostCreateModalComponent);
readonly posts = signal<PostResponse[]>([]);
readonly isCreateModalOpen = signal(false);
readonly isSubmitting = signal(false);
readonly selectedPost = signal<PostResponse | null>(null);
ngOnInit(): void {
this.loadPosts();
}
loadPosts(): void {
this.http.get<unknown>('/api/posts?page=1&size=10').subscribe({
next: (raw) => {
// 🌟 런타임 타입 가드로 백엔드 응답 검증 후 안전하게 상태 반영
if (isPageResponse(raw, isPostResponse)) {
this.posts.set(raw.content);
} else if (Array.isArray(raw) && raw.every(isPostResponse)) {
this.posts.set(raw);
} else {
console.error('🚨 백엔드 응답이 PageResponse<PostResponse> 계약과 다릅니다:', raw);
}
}
});
}
handleCreatePost(payload: CreatePostRequest): void {
this.isSubmitting.set(true);
this.http.post<PostResponse>('/api/posts', payload).subscribe({
next: () => {
this.isSubmitting.set(false);
this.isCreateModalOpen.set(false);
this.createModal()?.resetForm();
this.loadPosts();
},
error: () => this.isSubmitting.set(false)
});
}
handleDeletePost(postId: number): void {
this.http.delete(`/api/posts/${postId}`).subscribe({
next: () => {
this.selectedPost.set(null);
this.posts.update((list) => list.filter((p) => p.id !== postId));
}
});
}
}💡 [확인하기]: 템플릿의
{{ post.title }}을 일부러{{ post.titel }}로 바꾸고npx ng build를 실행해 보십시오. Angular 컴파일러가 즉시Property 'titel' does not exist on type 'PostResponse'에러를 잡아냅니다. 확인 후 원래대로 되돌리십시오.
7.5 [직접 해보기] 실습 미션 💪
🎯 미션: 댓글 DTO(CommentResponse)와 생성 요청 타입 계약서 작성하기
백엔드의 CommentDto.Response 클래스가 다음과 같을 때, post.model.ts 하단에 댓글용 인터페이스와 Omit을 활용한 등록 요청 타입, 그리고 런타임 타입 가드 함수 isCommentResponse를 작성해 보세요.
- 백엔드 필드:
Long id,Long postId,String author,String content,LocalDateTime createdAt
💡 미션 정답 코드 확인하기
typescript
// frontend/src/app/models/post.model.ts 하단에 추가
export interface CommentResponse {
id: number;
postId: number;
author: string;
content: string;
createdAt: string;
}
// 등록 시에는 id와 createdAt을 서버가 생성하므로 Omit으로 제외
export type CreateCommentRequest = Omit<CommentResponse, 'id' | 'createdAt'>;
export function isCommentResponse(data: unknown): data is CommentResponse {
if (typeof data !== 'object' || data === null) return false;
const c = data as Record<string, unknown>;
return (
typeof c['id'] === 'number' &&
typeof c['postId'] === 'number' &&
typeof c['author'] === 'string' &&
typeof c['content'] === 'string' &&
typeof c['createdAt'] === 'string'
);
}7.6 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄
- 프론트엔드 인터페이스는 Java 클래스가 아니라 JSON으로 직렬화된 결과(
Long→number,LocalDateTime→string)를 기준으로 1:1 매핑합니다. Omit과Pick으로 파생 요청 DTO를 만들고, 외부 API 경계선에서는 사용자 정의 타입 가드(isPageResponse)로 런타임 안전성까지 확보합니다.- 모달 컴포넌트는
input.required<T>()와output<CreatePostRequest>()로 입출력 타입을 엄격히 잠가 부모-자식 간 데이터 계약을 강제합니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
src/app/models/post.model.ts | PostResponse, CreatePostRequest, PageResponse<T>, isPageResponse 작성 |
src/app/components/post-create-modal.component.ts | 글 작성 모달 컴포넌트 분리 및 output<CreatePostRequest>() 적용 |
src/app/components/post-detail-modal.component.ts | 글 상세 조회 모달 컴포넌트 분리 |
src/app/board-app/board-app.component.ts | 도메인 계약서 import 및 모달 컴포넌트 연동 |
자주 하는 실수
| 증상 | 원인 | 해결 |
|---|---|---|
Property 'titel' does not exist on type 'PostResponse' | 템플릿 또는 스크립트의 필드명 오타 | post.model.ts의 속성명과 정확히 일치시키기 |
Type '"ALL"' is not assignable to type 'PostCategory' | 검색 필터 초기값 'ALL'을 PostCategory 신호에 넣음 | 필터 상태에는 CategoryFilter(PostCategory | 'ALL') 사용 |
@if (post()) 안에서 post().title에 null 경고가 남 | 신호 호출 결과를 별칭으로 고정하지 않음 | @if (post(); as currentPost) 문법으로 안전하게 언래핑 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
- 백엔드의
LocalDateTime createdAt을 TypeScript에서Date가 아닌string으로 선언하는 이유는 무엇인가요? CreatePostRequest를 별도 인터페이스로 복붙하지 않고Omit<PostResponse, ...>으로 정의하면 어떤 장점이 있나요?- 컴파일 타임 타입(
interface)만으로는 막을 수 없어isPageResponse같은 런타임 타입 가드가 필요한 상황은 언제인가요?
셀프 체크 정답
- JSON 명세에는 날짜 타입이 없으며, Spring Boot(Jackson)가
LocalDateTime을 ISO-8601 문자열(예:"2026-11-01T14:30:00")로 직렬화하여 전송하기 때문입니다. PostResponse의 필드 타입(예:category: PostCategory)이 변경될 때CreatePostRequest에도 자동으로 반영되어 단일 진실 공급원(SSOT)이 유지됩니다.- TypeScript 타입은 빌드 시점에 모두 제거(Type Erasure)되므로, 운영 환경에서 백엔드 API 스펙이 예고 없이 바뀌거나 프록시 오류로 엉뚱한 JSON이 내려올 때 이를 런타임에 감지하려면 타입 가드 함수가 필요합니다.
다음 챕터로 이어지는 질문
모달은 깔끔하게 분리되었지만, BoardAppComponent 템플릿에는 여전히 <table class="board-table"> 마크업이 통째로 박혀 있습니다. 사내 인트라넷의 공지사항(Notice), 직원 목록(Employee) 화면에서도 똑같은 테이블 디자인을 쓰려면 어떻게 해야 할까요?
이어지는 Chapter 08에서는 어떤 데이터 타입 T든 받아들이고 커스텀 셀 렌더링까지 지원하는 **제네릭 BoardTableComponent<T>**를 제작합니다.