抽象化
今回の記事では、プログラミングにおける抽象化と、抽象化の観点から良いコードを書く方法について考えてみたい。
筆者はフロントエンド開発をする中で、「このロジックはどこまで分離すべきだろう?」「このコンポーネントはどの単位で分割すべきだろう?」と数え切れないほど悩んできた。最初は、単に共通部分を取り出すことが抽象化だと思っていた。繰り返されるコードを関数にし、似たコンポーネントの共通点を抽出して一つにまとめること。しかし、そうして作ったコードが時間とともにかえって手を入れにくい怪物へと変わっていく経験を何度かして、抽象化について改めて考えるようになった。
この記事では、抽象化の本質とは何か、そしてフロントエンド開発で抽象化をどう活用すれば良いコードを作れるのかについて、筆者が考えてきたことを整理したい。
抽象と抽象化
本題に入る前に、「抽象」と「抽象化」という言葉がプログラミングで正確に何を意味するのかを確認しておきたい。この二つの言葉は似ているように見えるが、性質はかなり異なる。
抽象(Abstract) は状態であり、属性である。「これは抽象的だ」と言うとき、それは具体的な詳細が省かれ、本質的な概念だけが残っている状態を意味する。JavaやTypeScriptでabstractキーワードが付いたクラスやメソッドが、まさにこの意味に当たる。具体的な実装がまだ埋められておらず、本質的な形だけが定義された未完成の設計図なのだ。
抽象化(Abstraction) はプロセスであり、行為である。複雑な対象から本質的な特徴だけを残し、不要な詳細を取り除いて単純化する、そのプロセス自体を指す。重要なのは、抽象化は「大雑把にひとまとめにすること」ではなく、各レベルで役割を正確に定義する行為だという点である。
日常では「抽象的」という言葉が、しばしば「曖昧だ」というニュアンスで使われる。しかし、プログラミングにおける抽象化はその正反対だ。抽象化の目的は曖昧になることではなく、絶対的に正確であり得る新たな意味のレベルを作ることである。与えられた文脈で関連する情報を保持し、関連しない情報を忘れる行為こそが、抽象化の本質となる。
結局、抽象と抽象化を区別して理解すると、こうなる。抽象は「本質だけが残った状態」であり、抽象化は「本質だけを残すプロセス」 である。コードを設計するときに私たちが行っているのは、まさにこの抽象化、つまり複雑な実装から本質的なインターフェースだけを残し、それ以外を隠すプロセスなのだ。
では、こうした抽象化はなぜプログラミングに必要なのだろうか。
なぜ抽象化が必要なのか
プログラミングに抽象化が必要な根本的な理由は、意外にも単純だ。より複雑なものを作るためである。より複雑なものを作ろうとしても、複雑な要素が多すぎると、そのすべてを覚えて扱うことが難しいからだ。そこで私たちは、複雑な要素をグループ化し、単純化された抽象的な概念にする。
フロントエンド開発者が毎日使うReactも同じだ。コンポーネントを一つレンダリングするために、内部ではVirtual DOMの生成、再調整(Reconciliation)、実際のDOM操作という複雑なプロセスが行われる。しかし私たちは、それらを気にせずJSXを書けばよい。Reactがこの複雑なプロセスを抽象化してくれているからだ。
次のコードを見てみよう。UserProfileコンポーネントを使うとき、内部で行われるVDOMの生成やdiffingといった複雑なプロセスを知らなくても、十分にUIを作り、扱うことができる。
<UserProfile name="jihoon" />以前はWebpackを自分で設定することがフロントエンド開発者の日常だったが、現在ではNext.jsやViteのようなフレームワークがバンドル設定を抽象化している。おかげで私たちは、バンドラーの内部動作を知らなくてもアプリケーションを開発できるようになり、その時間を使って、ビジネスロジックやユーザー体験といった、より高次の問題に集中できるようになった。(だからこそ筆者は、フロントエンド開発者の役割において抽象化の概念が非常に重要だと考えている。)
結局、抽象化の核心的な価値はこれだ。複雑なものを隠して単純に見せ、それぞれが自分の領域だけに集中できるようにすること。そのおかげで私たちは、一人ですべてを理解しなくても、ますます巨大で複雑なソフトウェアを作り出せるようになった。
では、抽象化がこれほど良いものなら、多ければ多いほど良いのだろうか。どのような目的で抽象化すべきなのかを考えてみよう。
文脈を減らす
多くの開発者は、抽象化を「共通部分を選び出すこと」だと捉えている。間違いではないが、これは抽象化を行う手法の一つにすぎず、抽象化の本質を説明するものではない。
筆者が考える抽象化の本質は、「コードを読む人が知るべき文脈を、適切なレベルまで減らすこと」 である。
簡単な例を見てみよう。
interface Order {
id: string;
status: "pending" | "completed";
amount: number;
}
let total = 0;
const orders: Order[] = [
{ id: "a", status: "pending", amount: 10000 },
{ id: "b", status: "completed", amount: 5000 },
{ id: "c", status: "completed", amount: 8000 },
];
for (let i = 0; i < orders.length; i++) {
if (orders[i].status === "completed") {
total += orders[i].amount;
}
}このコードを読む開発者は、ループの初期化・条件・更新を把握し、インデックスで要素にアクセスし、条件を確認して分岐し、外部の累積変数がどのように更新されるのかまで、すべて頭に入れなければならない。このコードが実際にやろうとしていることは、「完了した注文の合計を求める」 という一文にすぎないのに、その一文を理解するために四つの異なる文脈を同時に抱えているわけだ。
const total = orders
.filter((order) => order.status === "completed")
.reduce((sum, order) => sum + order.amount, 0);filterとreduceという抽象化のおかげで、開発者は「完了した注文だけを選ぶ」と「金額を累積する」という二つの意図だけを追えばよい。インデックスの管理、累積変数の宣言と更新という文脈は、コードの表面から消えた。
さらに一歩進めることもできる。
const total = sumCompletedOrders(orders);これでコードを読む人は、この計算が配列の走査によって行われるという事実すら知る必要がなくなる。「完了した注文の総額を求める」というビジネス上の意図だけが残る。コードがどのように(How)計算されるかではなく、何を(What)計算するかに集中できるようになったのだ。
この観点から見ると、私たちが日々書いているReactコードも、数多くの抽象化の組み合わせであることが分かる。
import { css } from "@emotion/css";
import { format } from "date-fns";
const TodayHeader = () => {
const now = new Date(); // Date 객체 생성이라는 복잡한 과정을 추상화
const formatted = format(now, "yyyy-MM-dd"); // 날짜 포맷팅 로직을 추상화
return (
<h1
className={css`
font-size: 1.8rem;
`}
>
{/* CSS-in-JS 처리 과정을 추상화 */}
Today is {formatted}
</h1> // React.createElement를 추상화
);
};
// 그리고 위 모든 것을 다시 추상화
<TodayHeader />;もしemotion、date-fns、reactの内部コードがすべてこのコンポーネントファイルに展開されていたら、どうなるだろう。どこから読めばよいのか、どの部分がビジネスロジックで、どの部分がライブラリのコードなのかを区別するのが難しいはずだ。抽象化が各領域の文脈を適切に隠してくれているからこそ、私たちは「今日の日付を表示する」という本質だけに集中できる。
では、実際にコードを設計するとき、抽象化にはどのような方向から取り組むべきだろうか。
抽象化レベルが高い、低いとはどういうことか
抽象化を語る上で欠かせない概念が、抽象化レベル(Level of Abstraction) である。コードの抽象化レベルが「高い」あるいは「低い」とは、一体どういう意味なのだろうか。
抽象化レベルが低いコードは、コンピューターが実行する具体的な手順に近いコードである。文字列を直接パースし、インデックスで配列を走査し、バイトを操作する。どのように(How) 動作するのかが、ありのままに表れている。
// 추상화 수준이 낮은 코드
const response = await fetch('/api/users');
const users = await response.json();
const filteredUsers = users.filter(user => user.status === 'active');
filteredUsers.forEach(user => {
const element = document.getElementById(`user-${user.id}`);
if (element) element.style.display = 'block';
});抽象化レベルが高いコードは、ビジネスドメインや問題領域の言葉で表現されたコードである。processPayment(order)、sendNotification(user, message)、validateUserInput(formData)などがそれに当たる。抽象化レベルが高いコードには、何を(What) するのかが表れ、どのようにするかは隠されている。
Robert C. MartinはClean Codeで、この概念を**「一つの関数につき一つの抽象化レベル(One Level of Abstraction per Function)」** という原則にまとめた。一つの関数内に高いレベルと低いレベルのコードが混在すると、読む人は「これは中心的なロジックなのか、それとも実装の詳細なのか」を一行ごとに判断しなければならないからだ。
実際のコードを見ると、この問題は明確になる。
// 추상화 수준이 뒤섞인 함수
async function registerUser(name: string, email: string, password: string) {
// ✅ 높은 수준: 비즈니스 규칙 검증
validateUserInput({ name, email, password });
await ensureEmailNotDuplicated(email);
// ❌ 낮은 수준: 비밀번호 해싱 직접 구현
const encoder = new TextEncoder();
const data = encoder.encode(password);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashedPassword = Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
// ✅ 높은 수준: 사용자 저장
const user = await userRepository.save({ name, email, password: hashedPassword });
// ❌ 낮은 수준: 이메일 전송 직접 구현
const verifyToken = crypto.randomBytes(32).toString("hex");
await db.execute(
"INSERT INTO email_tokens (user_id, token, expires_at) VALUES (?, ?, ?)",
[user.id, verifyToken, new Date(Date.now() + 86_400_000)]
);
await transporter.sendMail({
from: "noreply@example.com",
to: user.email,
subject: "Welcome! Please verify your email",
html: `<a href="/verify?token=${verifyToken}">Verify your account</a>`,
});
// ✅ 높은 수준: 환영 이메일 발송
await sendWelcomeEmail(user);
}この関数を読む人は、「ユーザー登録の流れ」というビジネスルールに基づく高いレベルの文脈を追っていたのに、突然、ハッシュバッファの操作、SQLクエリ、メールテンプレート文字列という低いレベルの文脈へと引きずり下ろされる。そして再び、sendWelcomeEmailという高いレベルへ飛ぶ。このように抽象化レベルが上がったり下がったりすると、読む人の頭の中も一緒に上下することになる。
同じ関数を、抽象化レベルを揃えて書き直すと次のようになる。
// 추상화 수준이 일관된 함수
async function registerUser(name: string, email: string, password: string) {
validateUserInput({ name, email, password });
await ensureEmailNotDuplicated(email);
const hashedPassword = await hashPassword(password);
const user = await userRepository.save({ name, email, password: hashedPassword });
await sendVerificationEmail(user);
await sendWelcomeEmail(user);
}すべての文が同じ高さの抽象化レベルで語られている。メール送信がどのように実装されているのか、パスワードのハッシュ化にどのアルゴリズムを使うのかは、それぞれ下位の関数が担う。この関数を読む人は、「ユーザー登録の全体的な流れ」という一つの文脈だけに集中すればよい。
Martinはこれを、「ステップダウン・ルール(The Stepdown Rule)」 とも呼んでいる。コードを上から下へ読むとき、新聞記事のように、上には全体像があり、下へ進むほど詳細が現れるべきだという考え方だ。
Kent BeckもSmalltalk Best Practice Patternsで、同じ原則をComposed Methodパターンとして提示した。一つのメソッドは同じ抽象化レベルの処理だけで構成され、各ステップは一行のメソッド呼び出しで表現されるべきだというものだ。
結局、これらの話はすべて一つに集約される。一つの関数は、一つの抽象化レベルだけで語るべきである。 これを守るだけでも、コードの可読性は目に見えて変わる。
では、抽象化の方向性はどう定めればよいのだろう。具体的なものから始めるべきか、それとも抽象的なものから始めるべきか。
共通点の抽出ではなく、部品の組み立てとして考える
OOPでは、「具体的なものの共通点を抜き出して、抽象的なものを定義せよ」とよく言われる。このアプローチ自体が間違っているわけではないが、筆者は、この方法にこだわりすぎると、現在の要件に縛られた設計を作る危険があると考えている。
例を挙げよう。要件にA、B、Cというボタンがあり、三つとも青く丸い形で、違いはラベルのテキストだけだとする。共通点だけを抜き出して設計すると、次のように表現できる。
const BlueRoundButton = ({ label }: { label: string }) => {
return <button className="blue round">{label}</button>;
};現在の要件は完璧に満たされる。ところが数日後、企画担当者がこう言う。
「Bボタンの色を変更できるようにしてください。」
この瞬間、BlueRoundButtonという名前からして不自然になる。色のpropを追加すれば対応はできるが、そもそも「青く丸いボタン」という具体的な共通点から出発した設計が、変更に弱かったのだ。(この程度ならまだかわいいものだ。現実には、ボタンの形やサイズなど、数え切れないほどの要望が飛んでくる。)
こうした状況が繰り返されると、自然と気づくことがある。具体的な要件から共通点を抜き出す方法では、抽象化された成果物さえも現在の要件だけを反映したものになりやすいということだ。
そこで筆者が好むのは、反対方向のアプローチである。具体的なものから抽象を抜き出すのではなく、まず抽象的な部品を考え、それらを組み立てて具体的なものを作る方法だ。
トースト通知コンポーネントを作るとしよう。最初の要件は単純だ。「保存が完了したら、画面下部に短い案内メッセージを表示してください。」共通点を抽出するアプローチなら、次のようになる。
// 현재 요구사항의 공통 특성에서 출발한 설계
type ToastProps = {
message: string;
hasAction?: boolean;
actionLabel?: string;
onAction?: () => void;
};
const Toast = ({ message, hasAction, actionLabel, onAction }: ToastProps) => (
<div className="toast">
<span>{message}</span>
{hasAction && <button onClick={onAction}>{actionLabel}</button>}
</div>
);現在の要件には完璧に対応できる。ところが数日後、企画担当者が「成功/警告に応じて左側にアイコンを入れてください」と言う。hasIconとiconNameが追加される。続いて、「アップロードの進捗バーが付いたトーストも必要です」という要望が飛んでくる。progressというpropがまた一つ増える。このプロセスを何度か繰り返すと、Toastは十個を超えるpropsを持ち、この組み合わせは使えるが、あの組み合わせは使えないという隠れたルールまで暗記しなければならないコンポーネントになる。(そしてたいてい、そのルールはコメントにも残されない。)
そもそも、「トーストはこういう形であるべきだ」という現時点の具体的なイメージから出発した設計が、変更に弱かったのだ。
部品を組み立てるアプローチなら、話は変わる。
// 부품을 조립하는 설계
const Toast = ({ children }: PropsWithChildren) => (
<div className="toast">{children}</div>
);
Toast.Icon = ({ name }: { name: "check" | "warn" | "info" }) => {
/* ... */
};
Toast.Message = ({ children }: PropsWithChildren) => <span>{children}</span>;
Toast.Action = ({
children,
onClick,
}: PropsWithChildren<{ onClick: () => void }>) => (
<button onClick={onClick}>{children}</button>
);
Toast.Progress = ({ value }: { value: number }) => {
/* ... */
};
// 필요한 부품만 골라 조립한다
<Toast>
<Toast.Message>저장되었습니다</Toast.Message>
</Toast>;
<Toast>
<Toast.Icon name="check" />
<Toast.Message>업로드 완료</Toast.Message>
<Toast.Action onClick={undo}>실행 취소</Toast.Action>
</Toast>;
<Toast>
<Toast.Progress value={0.4} />
<Toast.Message>파일 전송 중...</Toast.Message>
</Toast>;「トーストは何かを収める浅いコンテナである」という変わらない本質と、「その中に何が入るか」という変わりやすい具体が分離された。これでToastの内部に手を加えなくても、新しい部品をいくらでも追加でき、既存の部品を新たな組み合わせで配置できる。propの組み合わせに関する有効性のルールもなくなる。単純に、入れたいものを入れればよいのだ。
もちろん、経験豊富な開発者なら「最初からIoC(制御の反転)で設計すればいいのでは」と言うかもしれない。その通りだ。しかし、その判断ができるのは、過去に数多くの試行錯誤を重ね、「どこが変更されやすい部分なのか」を見抜く感覚が培われているからである。
こうした感覚がまだ十分でないなら、「この機能はどのような部品で構成され、それぞれの部品をどう組み立てるべきか」という問いから始めるほうが、変更に開かれた設計をはるかに作りやすい。
ここまで読むと、自然に一つの疑問が浮かぶ。では、部品をどのような基準で分け、外部にどう表現すればよいのだろうか。
良い抽象化のための三つのこと
表現について考える
抽象化されたモジュールにとって最も重要なのは、ソースコードを開かなくても動作を推測できることである。Kent Beckはこれを**「意図が伝わる名前(Intention-Revealing Name)」** パターンと呼び、簡潔な名前を付けられないなら、抽象化そのものを見直すべきだと述べた。
そのために使える道具は、大きく二つある。名前と型だ。
// 도대체 뭘 하는 건지 알 수 없는 함수
function calculate(price: number, rate: number): number;
// 이름과 타입만으로 동작을 유추할 수 있는 함수
function calculateDiscountedPrice(originalPrice: number, discountRate: number): number;calculateDiscountedPriceは、名前を見るだけで、元の価格と割引率を受け取り、割引後の価格を計算することが分かる。numberを受け取ってnumberを返すという型情報も、それを裏付けている。内部でどのような計算ロジックが適用されるかは、知らなくてもよい。
一方、calculate(price: number, rate: number): numberは、何を計算するのか分からないため、結果を予想できない。結局、ソースコードを開かなければ使えず、抽象化の利点を失ってしまう。
ここで、名前の付け方そのものが抽象化レベルを反映する点に注目したい。プログラミングにおける関数名は、一般に動詞+名詞の組み合わせで構成されるが、どの動詞を選ぶかによって、その関数がどの抽象化レベルで動作するかが表れる。
ただし、動詞だけで抽象化レベルが決まるわけではない。一緒に使われる名詞(ドメインの文脈)が最終的なレベルを決める
抽象化レベルが低い側でよく使われる動詞がある。parse、encode、decode、serialize、read、write、push、pop、convert、transformなどだ。これらの言葉は、データの物理的な変換やデータ構造の直接的な操作を示唆する。
中間レベルでは、get、save、load、validateといった動詞が登場する。技術的な動作ではあるものの、その動作の意図がある程度伝わるレベルだ。
抽象化レベルが高い側では、register、refund、confirm、cancel、submitといった動詞が使われる。これらはビジネスドメインの言葉である。内部でどのような技術的手順が行われるかは一切表れず、ユーザーの行為やビジネスプロセスだけが表現される。
// 낮은 수준: 기술적 동작이 드러남
function parseJSON(text: string): object;
function encodeBase64(data: Uint8Array): string;
// 중간 수준: 의도가 드러나되 기술적 맥락이 남아있음
function getUserById(id: string): Promise<User>;
function validateEmail(email: string): boolean;
// 높은 수준: 비즈니스 의도만 드러남
function registerUser(form: RegistrationForm): Promise<User>;
function refundPayment(orderId: OrderId, amount: Money): Promise<Refund>;Robert C. MartinはClean Codeで、これについて**「短く謎めいた名前より、長く説明的な名前のほうがよい」** と述べている。また、「一つの概念には一つの単語を使う」 という原則も提示した。同じ文脈の動作にfetch、retrieve、getを混在させると、読む人が「この三つは異なる動作なのか」と混乱するからだ。
この原則は、Reactコンポーネントやフックの命名にもそのまま当てはまる。
<Button />
<SearchInput />
<SubmitOrderButton />コンポーネントは、抽象化レベルに応じて名前の具体性が異なる。Buttonは低いレベルの汎用UIプリミティブとして使われるが、SubmitOrderButtonは高いレベルでビジネス上の意図が明確に表れる。
const handleSubmit = async(data: FormData) => {
await registerUser(data);
};
<Form onSubmit={handleSubmit} /> on*は、コンポーネントが外部に公開するpropの名前である。コンポーネントを使う側で「どのイベントに反応するか」を宣言する。handle*は、そのpropに実際に渡される実装関数の名前だ。
const user = useAuth();
const [items, setItems] = useCartItems();
const { isOpen, toggle } = useModal(); カスタムフックはuse接頭辞を使ってReactのルールに従い、フックが提供する状態や動作をコンポーネントで利用できるようにする。
Coding Horrorを運営するJeff Atwoodは、
Managerという接尾辞の問題を指摘したことがある。UrlManagerという名前では、URLをプールするのか、検証するのか、生成するのかがまったく分からない。UrlBuilder、UrlValidator、UrlPoolのように、具体的な役割が伝わる名前のほうがはるかに良い。名前が曖昧であることは、そのモジュールの責務自体が曖昧である兆候かもしれないのだ。
結局、良い名前とは、そのコードがどの抽象化レベルで動作するのかを、読む人に即座に伝える名前である。
入力の自由度を意図的に設計する
抽象化されたモジュールを設計するとき、よく直面する悩みがある。「機能をどこまで開放するか」だ。この決定によって、モジュールを使う開発者の体験は大きく変わる。
// 기능이 닫힌 컴포넌트
const Button = ({ children }: {children?: React.ReactNode}) => {
return <button>{children}</button>;
};
// 기능이 완전히 열린 컴포넌트
const Button = (props: ComponentProps<"button">) => {
return <button {...props} />;
};最初のボタンはchildrenしか受け取れない。onClickもtypeもdisabledも設定できない。その代わり、使う人が迷うことはない。
二つ目のボタンは、button要素のすべての属性を受け取れる。自由度は高いが、使う側は数十個のpropのうち何を使うべきか悩むことになる。筆者はこの状況を、「コンポーネントが開発者に判断を強いる」 と表現している。
正解はない。ただし、モジュールの目的と利用者に応じて、適切なレベルを見つける必要がある。デザインシステムの基本ボタンなら、制限されたPropsで一貫性を保つほうがよいかもしれないし、汎用ユーティリティコンポーネントなら、柔軟に開放するほうがよいかもしれない。
// 지나치게 닫힌 인터페이스 — 다양한 상황에 대응 불가
<Button onClick={handleSubmit}>제출</Button>
// onClick 외의 이벤트, className, disabled 등을 전달할 방법이 없다
// 지나치게 열린 인터페이스 — 의도가 사라짐
<Button {...anyProps} />
// 무엇을 전달해야 하는지 사용자가 직접 파악해야 한다抽象化の幅は、利用者が誰なのかに応じて決めるべきだ。内部実装を理解して細かく制御する必要がある利用者には、低いレベルのインターフェースが適している。一方、詳細を知らなくてもよい利用者に、開きすぎたインターフェースを提供すれば、混乱が増すだけだ。反対に、さまざまな状況に対応しなければならない利用者に対して入力を過度に制限すれば、そもそも利用できなくなってしまう。
抽象化の単位を適切に保つ
抽象化の単位、つまり「どこまでを一つのモジュールとしてまとめるか」も重要な論点である。
フロントエンドでよく見られるアンチパターンの一つが、カスタムフックの過剰な抽出だ。
// 이 훅은 단 하나의 컴포넌트에서만 사용된다
const useUserProfileData = () => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUser()
.then(setUser)
.finally(() => setLoading(false));
}, []);
return { user, loading };
};一つのコンポーネントでしか使われないロジックを、あえてフックとして分離すると、コードを読む人は二つのファイルを行き来しながら文脈を把握しなければならない。抽象化が文脈を減らすどころか、増やしてしまったのだ。
反対に、一つのフックにあまりに多くのものを詰め込むことも問題だ。
// 관련 없는 관심사가 하나의 훅에 뒤섞여 있다
const useEverything = () => {
const auth = useAuth();
const theme = useTheme();
const analytics = useAnalytics();
const toast = useToast();
return { auth, theme, analytics, toast };
};こうした「God Hook」はテストが難しく、一つを変更すると無関係な部分にまで影響が及ぶことがある。
抽象化の適切な単位を判断する基準は、「この分離によって、コードを読む人の文脈が実際に減るか」 である。分離した結果、かえって文脈が分散して把握しにくくなるなら、その抽象化にはまだ早いということだ。
早すぎる抽象化に注意せよ
ここまで読むと、「では、いつ抽象化すればよいのか」という疑問が残るかもしれない。筆者の考えはこうだ。基本的な前提として、抽象化を急ぐべきではない。
明確な抽象化の兆候が見えない限り、コードをそのままにしておくほうが、誤った抽象化を作って後から解きほぐすよりも、悪い状態になりにくい。誤った抽象化が作られる過程は、おおよそ次のようなものだ。
- コードAとコードBに似たパターンが見つかる。
- 「DRY原則だから共通関数に切り出そう!」と抽象化する。
- コードCにも似たパターンが見つかり、同じ関数を使う。ただし、少し違う動作に対応するため、引数を一つ追加する。
- コードD、Eでも使うようになり、条件文と引数が増え続ける。
- こうして、この関数はあらゆる場所で使われているのに、誰も触れるのを恐れるコードになる。
// 처음에는 단순했던 함수가...
const formatUserName = (user: User) => `${user.firstName} ${user.lastName}`;
// 요구사항이 추가될 때마다 매개변수가 늘어나고...
const formatUserName = (
user: User,
includeMiddleName?: boolean,
format?: "full" | "short" | "initials",
locale?: string,
honorific?: boolean,
) => {
if (format === "initials") {
/* ... */
}
if (includeMiddleName && user.middleName) {
/* ... */
}
if (honorific && locale === "ko") {
/* ... */
}
// ...끝없는 분기
};この状況に陥ったなら、解決策は明確だ。抽象化されたコードを各利用箇所にもう一度インライン化し、それぞれの箇所で不要なコードを取り除く。そしてきれいになった状態で本当の共通点が見えたら、そのとき改めて抽象化する。「最速で前に進む方法は、引き返すことだ。」
では、抽象化すべきなのはどのようなときか。筆者が感じる抽象化の兆候は、おおよそ次のようなものだ。
- 一貫性が崩れている。 同じロジックなのに、あるコンポーネントではインラインで書かれ、別のコンポーネントでは別関数に分離されている。同じ計算ロジックがあちこちに散らばっている。
- 内部構造が外部に不必要に公開されている。 外部が知る必要のない実装の詳細を、呼び出し側が一つずつ扱っている。
- 自身の動作が露出し続けている。 モジュールが内部の手順を隠せず、使う側がその手順をそのまま追わなければならない。
問題は、こうした兆候を検知すること自体はたいてい単純なのに、実際にはその兆候を無視し、より「重要な」要件を満たすことに集中しがちだという点である。スケジュールに追われたり、機能実装に没頭したりすると、「ひとまず動くから、後で整理しよう」となり、その「後で」はなかなか訪れない。
ここでもう一つ重要なのは、一貫した抽象化の基準を保つことである。コードベース内で、同じ種類のロジックが、ある場所ではインライン、ある場所では関数、また別の場所ではカスタムフックとして分離されていれば、新しくコードを読む人は「この違いには意図があるのか」と混乱する。抽象化するにせよ、しないにせよ、チーム内の基準は一貫していなければならない。
Joel Spolskyが2002年に提唱した**「漏れのある抽象化の法則(The Law of Leaky Abstractions)」** も、この文脈で覚えておく価値がある。漏れのある抽象化の法則とは、抽象化は複雑な実装を隠そうとするが、その実装の詳細は結局、外へ漏れ出す(leak)というものだ。つまり、抽象化を使う人は内部実装を知らなくてもよいように設計したはずなのに、実際には、それを知らなければ正しく使えない状況が生じることを指す。
TCPは不安定なネットワークを安定した接続のように見せる形で抽象化するが、ケーブルが切れれば、その抽象化は崩れる。ReactはUIの更新を宣言的に抽象化するが、再レンダリングを最適化するには、結局その内部動作を理解しなければならない。完璧な抽象化は存在しないからこそ、抽象化するときには、「この抽象化が崩れたとき、利用者は対処できるか」 まで考える必要がある。
結局、「抽象化は作業時間を節約してくれるが、学習時間を節約してはくれない。」
抽象化は体得するものだ
以前、同僚と抽象化について話したことがある。そのときに出た話が印象深かった。抽象化の兆候を捉え、適切なタイミングで適切なレベルに分離することは、結局のところ感覚の領域だという話だ。
もちろん、ここまで述べてきた原則、つまり抽象化レベルを揃え、適切な名前を付け、入力の自由度を設計することは、間違いなく重要である。しかし、実際にコードを書く瞬間にこれらの原則を一つひとつ思い浮かべ、「これを分離すべきか、しないべきか」と考えるやり方は、かえって流れを妨げることがある。試合中にジャブを打つとき、肘の角度を意識すると逆にタイミングを逃してしまうように、コードを書くときも、抽象化は意識的な判断ではなく、自然な感覚から生まれるべきなのだ。
コードを書き進める中で、何かに抵抗を感じる瞬間がある。「このロジックはここにあるべきではない気がする」「このコンポーネントはあまりに多くのことを知りすぎている気がする」という感覚だ。その感覚こそが抽象化の兆候であり、それを自然に捉えて反応できることが、体得するということである。
ただし、この感覚は一朝一夕には身につかない。数多くのパターンを学び、多様なコードを読み、自ら試行錯誤を重ねて初めて、「これは分離したほうがよさそうだ」 という勘が自然に働き始める。後から同僚に「なぜこれを分離したの?」と聞かれたとき、「これは〇〇だから分離した」と自然に説明できるなら、それは体得できているということだ。
どの分野でも同じなのだと思う。暗記して上手くなろうとすると、かえって判断が難しくなる。結局は大きな流れだけを押さえ、細部は自然に埋まるようにしなければならない。そして、その自然さは、日頃から積み重ねてきた多様なパターンと経験から生まれるのだ。
おわりに
プログラミングにおける抽象化とは、複雑なものを隠して単純に見せ、コードを読む人が必要な文脈だけに集中できるようにする行為である。
良い抽象化のために覚えておきたいことを、もう一度確認すると次のようになる。
- 基本的な前提として抽象化を急がず、明確な兆候が見えたときに初めて分離しよう。
- 一つの関数は、一つの抽象化レベルだけで語ろう。
- 名前と型で動作を十分に表現し、ソースコードを開かなくても使えるようにしよう。
- 入力の自由度を、モジュールの目的と利用者に合わせて意図的に設計しよう。
- 誤った抽象化の上に付け足すより、解きほぐしてやり直す勇気を持とう。
- そして、これらすべてを意識しなくても自然にできるよう、多様なパターンを体得しよう。
もちろん、この記事で筆者が述べたことが正解というわけではない。適切な抽象化レベルは、ビジネスの状況、チームの構成、プロジェクトの性質によって変わらざるを得ない。ただし、一つだけ変わらないものがあるとすれば、抽象化の究極の目的は、人にとって理解しやすいコードを作ることだという点である。
この記事を読んだ方にも、それぞれのコードベースで「この抽象化は本当に文脈を減らしているだろうか」と、一度問いかけてみてほしい。その問い一つだけでも、コードを見る視点が少し変わるはずだ。
参考資料
この記事は、複数の公式ドキュメントや先行記事から多くの着想を得た。直接引用した箇所の出典と、思考の枠組みを作る上で役立った記事を併せて記載する。
📚関連記事
ドメインモデル
2026/4/18 · 50 min read
今回の記事では、ドメイン(Domain) について考えてみたい。 筆者は開発を続けるなかで、「ドメイン(Domain)」 という言葉をかなり頻繁に耳にしてきた。しかし、いざ「ドメインとは正確には何か?」と聞かれると、明快に答えるのは簡単ではない。(正直なところ、開発を始めたばかりの頃は、ドメインとはwwwのことだと思っていた。) ドメインについて調べると、自然と ドメインモデル、ドメインオブジェク...
Toss Frontend Fundamentals 模擬試験 第2回のリファクタリングを終えて
2026/3/28 · 24 min read
今回の記事では、Toss Frontend Fundamentals 模擬試験の第2回に参加し、取り組んだリファクタリングについて振り返ってみたい。 以前からコードレビューやリファクタリングに関心があった筆者は、Tossが公開した「Frontend Fundamentals 模擬試験」という興味深い形式の課題に取り組むことになった。課題は、与えられた会議室予約アプリをリファクタリングするというもの...
AIフロントエンドエンジニア
2026/3/2 · 45 min read
今回の記事では、AI時代にエンジニアがどのように成長し、生き残れるのかについて、個人的な視点から考えてみたい。 筆者がジュニアだった頃、特に印象深く読んだ記事の一つに、ペ・フィドンさんの「フロントエンドエンジニアのキャリアロードマップ、ジュニアのための3つの専門性トラック」がある。フロントエンドエンジニアのキャリアを、Web特化(Software Engineer)/プロダクト特化(Product...