한 사람이 코드를 읽을 때 동시에 고려할 수 있는 총 맥락의 숫자는 제한되어 있다고 한다. 그렇기 때문에 우리는 추상화를 통해서 불필요한 맥락을 줄여 코드를 읽기 쉽게 만듦으로써 좋은 코드를 만들 수 있다. 근데 이 프로그래밍에서 빠질 수 없는 추상화는 대체 무엇일까?
추상화란?
추상화는 객체 지향 프로그래밍의 개념 중 하나이다. “추상” 이라는 용어의 사전적 의미는 “사물이나 표상을 어떤 성질, 공통성, 본질에 착안하여 그것을 추출하여 파악하는 것” 이라 정의한다.
소프트웨어적으로 말하자면 복잡한 시스템의 세부 구현을 숨기고, 필요한 핵심적인 개념만을 드러내어 다룰 수 있도록 하는 개념이라고 말할 수 있다. 즉, 불필요한 세부사항을 감추고 중요한 정보만을 제공하는 방식인 것이다.
그렇다면 추상화를 통해 우리가 얻는 것은 무엇일까?
- 복잡성 감소
- 불필요한 내부 동작을 숨기고 중요한 부분만 노출하여 코드의 가독성을 높이고 유지보수를 용이하게 함
- 모듈화와 재사용성 향상
- 여러 기능을 추상화하여 모듈화하면 코드가 독립적으로 동작할 수 있어 재사용성이 증가
- 유지보수성 향상
- 세부 구현을 감추고 인터페이스만 제공하면 내부 구현이 변경되어도 외부 코드에 미치는 영향을 최소화
아래 예시를 보자.
function fetchUserData(userId: string) {
return fetch(`https://api.example.com/users/${userId}`)
.then((res) => res.json());
}
// 사용자는 내부 요청 방식(Fetch API 등)을 몰라도 데이터를 가져올 수 있음
fetchUserData("123").then((data) => console.log(data));
fetchUserData()는 유저의 정보를 가져오는 api라는 것을 우리는 함수의 명을 통해 알 수 있다.
하지만 이 api는 그냥 부른다고 띡 되는게 아니다.
이 안에는 Fetch문을 통해 Promise 기반의 http 요청을 보내야 하고, 이렇게 받은 요청에 대해서 체이닝을 통해 json 데이터로 변환하는 작업까지 담겨 있다.
위에서 말하는 불필요한 세부사항은 바로 위에서 말한 것들이다. 우리는 요청을 보내는 과정에서 일어나는 것들을 굳이 그대로 쓰지 않고 한 단계 fetchUserData라는 함수로 추상화 함으로써 위와 같은 단계들을 하나의 함수명(중요한 정보) 을 통해서 알 수 있도록 하였다.
이를 통해서 아 우리가 인식하지 않고 하고 있던 것들이 다 추상화였구나!를 깨달을 수 있었다. 함수를 나눠서 쓰는 것도 해당 함수에 꼭 필요한 내부 기능을 담게 코드를 작성하고 있었기 때문에 이 또한 추상화이기 때문이다.
내가 쓴 코드의 추상화에 대하여
이번 Neo 프로젝트를 하면서 운이 좋게도 지인에게 코드 리뷰를 받을 수 있게 되었다. 나는 이 코드 리뷰에서 집중적으로 봐줬으면 좋을 것 같은 부분 중 ‘추상화’를 꼽았다.
추상화는 물론 많은 작업들을 보다 간단하게 이해할 수 있게끔 해주는 좋은 역할을 가지고 있지만, 그렇다고 무조건 해야만 좋은 것도 아니다. 내가 쓰는 하나하나의 로직들을 전부 추상화하게 된다면, 프론트엔드에서는 컴포넌트를 계속 링크를 파고파고 들어가는 형태가 되어버린다. 결국 우리가 추상화를 하게 된 근본적인 이유는 보다 코드를 쉽게 이해하고 잘 재사용하기 위해서인데, 코드를 쉽게 이해하지도 못하고 재사용도 애매해지는 결과를 낳기도 한다. 이번에 직접 ‘섣부른 추상화’를 행하면서 깨달은 점이기도 하다.
원래 코드는 아래와 같았다.
// app/login/page.tsx
export default function Page() {
const loginFormSchema = z.object({
email: z.string().email({ message: "유효한 이메일 주소가 아닙니다." }),
password: z
.string()
.regex(
/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*#?&])[A-Za-z\d@$!%*#?&]{8,}$/,
{
message:
"비밀번호는 영문 대/소문자, 숫자, 특수문자를 포함하여 8자 이상이어야 합니다.",
}
),
});
const form = useForm<z.infer<typeof loginFormSchema>>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
email: "",
password: "",
},
});
function onSubmit(values: z.infer<typeof loginFormSchema>) {
console.log(values);
}
return (
<div className="h-screen grid px-8">
<div className="flex flex-col items-center justify-center">
<Image src="/neo_emblem.svg" alt="logo" width={100} height={100} />
<span className="text-xl font-bold">NEO</span>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="flex justify-between">
<span className="text-sm font-medium">이메일 주소</span>
</FormLabel>
<FormControl>
<Input
placeholder="example@gmail.com"
className="p-6 bg-gray-100 rounded-lg"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="flex justify-between">
<span className="text-sm font-medium">비밀번호</span>
</FormLabel>
<FormControl>
<Input className="p-6 bg-gray-100 rounded-lg" {...field} />
</FormControl>
<div className="flex justify-between">
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
className="data-[state=checked]:bg-primary border-muted-foreground"
/>
<Label
htmlFor="remember"
className="text-sm font-medium text-muted-foreground leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
비밀번호 기억하기
</Label>
</div>
<Button
variant="link"
className="text-sm text-primary"
asChild
>
<Link href="/forgot-password">
계정을 잃어버리셨나요?
</Link>
</Button>
</div>
</FormItem>
)}
/>
</div>
<div className="flex flex-col gap-2">
<Button
type="submit"
variant="default"
className="w-full flex items-center justify-center gap-2 p-6 rounded-lg hover:opacity-90 hover:bg-primary"
>
<span className="text-lg font-base">다음</span>
<ChevronRight className="w-6 h-6" />
</Button>
<div className="flex justify-center items-center gap-2">
<span className="text-sm text-muted-foreground">
아직 회원이 아니신가요?
</span>
<Button variant="link" className="text-sm text-primary" asChild>
<Link href="/signup">회원가입하기</Link>
</Button>
</div>
</div>
</form>
</Form>
<div className="flex flex-col gap-8">
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
<span className="relative z-10 bg-background px-2 text-muted-foreground">
또는 10초만에 로그인 · 회원가입 하기
</span>
</div>
<Button variant="outline" className="w-full p-6 rounded-lg">
<Image
src="/auth/google_emblem.svg"
alt="google"
width={20}
height={20}
/>
<span className="text-base font-light">구글 로그인</span>
</Button>
</div>
</div>
);
}나는 처음에 거의 150줄이 넘어가는 코드의 개수를 보자마자 ‘아 이건 추상화를 해야겠다’라고 생각했다. 일단 나는 100줄이 넘어가는 하나의 페이지는 무조건 지양해야 한다고 생각했기 때문이다. 그래서 내가 생각했던 추상화는
- 자체 로그인 부분만 loginForm으로 추상화
- loginForm에서 공통으로 쓰이는 FormField내의 컴포넌트 구성을 loginFormField로 추상화
- 소셜 로그인 버튼을 socialLoginButton으로 추상화
- 로그인에 필요한 스키마를 해당 파일에서 선언하는 것이 아닌 다른 곳으로 분리
- login에 필요한 server action을 추상화
- react-hook-form에서 쓰이는 훅을 추상화 정도로 나눠지게 되었다.
import { LoginForm } from "@/app/(auth)/login/loginForm";
import SocialLoginButton from "@/components/ui/socialLoginButton";
import Image from "next/image";
export default function Page() {
return (
<div className="h-screen grid px-8 relative">
<div className="flex flex-col items-center justify-center">
<Image src="/neo_emblem.svg" alt="logo" width={100} height={100} />
<span className="text-xl font-bold">NEO</span>
</div>
<LoginForm />
<div className="flex flex-col gap-8">
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t after:border-border">
<span className="relative z-10 bg-background px-2 text-muted-foreground">
또는 10초만에 로그인 · 회원가입 하기
</span>
</div>
<div className="w-full flex flex-col justify-center items-center gap-5">
<SocialLoginButton type="kakao" />
<SocialLoginButton type="google" />
</div>
</div>
</div>
);
}그렇게 추상화한 결과 page는 이렇게 확실히 코드가 줄어지게 되었다. 하지만 해당 추상화가 정말 잘된 추상화일까?
이번에 내가 했던 코드 리뷰에서 이런 이야기가 나왔다.

url은 비단 링크 정도의 기능만을 하는 것이 아니다. 각각의 페이지 모두 html 문서, js, css를 불러오는 get api를 불러오기 때문에 routing 또한 RESTFUL하게 이루어져야 한다.
Next.js에서 app 라우터는 page라는 파일에 라우팅된다. 그러므로 app/login은 로그인이라는 자원을 불러온다는 의미기도 한데, 이미 route를 통해서 login이라는 추상화를 이루어냈음에도 불구하고 여기서 form을 따로 나누는 것은 불필요한 추상화가 아니었나 생각한다.
또한 loginForm을 나누고, 이 form에서 가지고 있는 hook까지 커스텀 훅으로 빼게 되니 코드의 수가 더 늘어났다.
물론 이게 나와 협업하는 다른 개발자들이 보기에 조금 더 편할 수 있다면 LOC가 늘어나는 것은 괜찮다고 생각하지만 문제는 평탄하게 뿌려진 폴더 구조였다. 나는 어찌보면 route 하나하나가 각 도메인을 의미한다고 생각했기에 여기에 필요한 훅, 컴포넌트, 스키마 등을 나누었다.

원래대로라면 page.tsx가 하나만 있어야 할텐데 너무 늘어나 이에 대한 배경지식이 없다면 코드의 가독성이 더 떨어져 보이는 듯한 효과를 주는 것을 느끼게 되었다.
또한 Login쪽에서 관리하는 Hook을 보자
export function useLogin() {
const { supabase } = useAuth();
const router = useRouter();
const {
open: openErrorModal,
switchModal,
setMessage: setErrorMessage,
message: errorMessage,
} = useModal();
const form = useForm<loginFormSchemaType>({
resolver: zodResolver(loginFormSchema),
defaultValues: {
email: "",
password: "",
},
});
const { mutate, isPending } = useMutation({
mutationFn: handleEmailLogin,
onSuccess: () => router.push("/"),
onError: (error) => {
if (error instanceof Error) {
setErrorMessage(error.message);
switchModal();
} else {
setErrorMessage(
"서버 상의 이유로 실패하였습니다.\n 잠시 후 다시 시도해 주세요."
);
switchModal();
}
},
});
function submit(values: loginFormSchemaType) {
const { email, password } = values;
mutate({ email, password, supabase });
}
return {
form,
submit,
openErrorModal,
switchModal,
setErrorMessage,
isPending,
errorMessage,
};
}
useLoginForm이라는 비즈니스 로직을 추상화한 하나의 커스텀 훅에 너무 많은 역할들이 혼재하고 있다.
서버 상태 관리, 에러 상태 관리, 폼 관리를 하나의 훅으로 추상화하면서 어찌보면 이렇게 많은 역할들을 한번에 이해하기란 쉽지 않을 것 같다고 다시 코드를 봤을 때 느끼게 되었다.
개선하기 - 컴포넌트를 조금 더 잘 만들어보기
const TextInputWithIcon = React.forwardRef<
HTMLInputElement,
TextInputWithIconProps
>(({ className, IconComponent, ...props }, ref) => {
return (
<div
className={cn(
"flex h-10 w-full items-center rounded-md border border-input bg-background px-3 py-2 md:text-sm ",
className
)}
>
<input
type="text"
className="flex-1 inset-1 bg-transparent focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background focus-visible:outline-none disabled:opacity-50 disabled:cursor-not-allowed text-base"
ref={ref}
{...props}
/>
{IconComponent && (
<span className="ml-4 w-6 text-muted-foreground">{IconComponent}</span>
)}
</div>
);
});
TextInputWithIcon.displayName = "TextInputWithIcon";이전에 리뷰를 부탁했을 때 리뷰를 받은 것들 중에서 TextInputwithIcon이 있다. 맨 처음에 해당 컴포넌트는 아이콘이 들어있는 input의 형태를 가진 컴포넌트를 미리 만들어 놓으면 좋지 않을까? 하는 생각을 가져 만들었었다.
해당 리뷰에서 받았던 점은
- 아이콘에 대한 책임이 input 옆에만 뚫어주는 정도인 것 같은데 불필요한 추상화인 것 같음
- TextInputWithIcon이란 이름을 사용할 거라면 아이콘 라이브러리에서 아이콘 이름 타입을 떙겨와 아이콘 명만 적도록 하는 것이 import문도 적어질 것 같음
- control 로직을 포함한 폼 필드로 추상화를 시켜 설계해보는건 어떤지? 였다.
나 또한 뭔가 이런 식으로 굳이 아이콘 하나때문에?라는 생각이 컴포넌트를 만들면서 했었는데 리뷰를 받고 나 또한 여기에 대해서 다시 생각해볼 필요성이 있다고 판단하여 control 로직을 포함한 폼 필드에서 에러 메세지나 아이콘과 같은 부분들을 옵션으로 주입할 수 있는 형태로 개선해 보고 싶었다.
const InputFormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
control,
name,
label,
changeEffect,
className,
icon,
placeHolder,
type = "text",
children,
}: {
control: Control<TFieldValues>;
name: TName;
label?: React.ReactNode;
changeEffect?: (value: string) => void;
className?: React.HTMLAttributes<HTMLInputElement>["className"];
icon?: React.ReactNode;
placeHolder?: string;
type?: React.HTMLInputTypeAttribute;
children?: React.ReactNode;
}) => {
const validTypes = ["text", "checkbox", "email", "default"];
const variantType = validTypes.includes(type as string) ? type : "default";
return (
<FormField
control={control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<div
className={cn(
inputVariants({ type: variantType as any }),
className
)}
>
<Input
type={type}
{...field}
placeholder={placeHolder ?? ""}
onChange={(e) => {
field.onChange(e);
changeEffect?.(e.target.value);
}}
className="flex-1 inset-1 disabled:opacity-50 disabled:cursor-not-allowed bg-transparent text-base border-none focus-visible:ring-offset-0 focus-visible:ring-0 p-0"
/>
{children}
{icon && (
<p className=" w-7 text-muted-foreground flex justify-center">
{icon}
</p>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
};formLabel + Control + Input을 모두 가진 하나의 공통 컴포넌트로 개선했다. 해당 컴포넌트에서는 react-hook-form에서 제공하는 control만 연결하면 해당 control이 가진 필드값 타입을 가져와서 해당 타입의 키값들을 name에 대해 주입해주어 타입 안정성을 전보다 높였고, 아이콘의 경우 직접 우리가 만든 아이콘과 Lucide-React의 아이콘도 함께 사용했으면 해서 icon 컴포넌트 자체를 주입해주는 방식으로 만들어 보았다.
const { control } = useFormContext<SettingFormType>();
...
<InputFormField
control={control}
name="name"
placeHolder="이름"
icon={<User />}
/>해당 컴포넌트를 사용할 때는 위와 같이 useFormContext에서 control 함수를 가져와 연결시켜 주었다.나는 해당 폼을 애초에 context API 로 감싸는 컴포넌트를 만들었기 때문에 위와 같이 더 편리하게 context를 가져와서 사용할 수 있었다.
이렇게 되니 이전보다 훨씬 사용성도 좋아졌을 뿐만 아니라 다양한 상황에서도 범용적으로 사용할 수 있는 공용컴포넌트라고 생각한다.
<InputFormField
control={control}
name="marketingAgreement"
type="checkbox"
>
<p className="w-full flex items-center ml-2">
<span
className="text-primary"
onClick={() => handleAgreementModal("marketingAgreement")}
>
마케팅 이용 약관
</span>
에 동의합니다.
</p>
</InputFormField>해당 용례는 input의 타입을 checkBox로 만들었을 때인데, checkBox 타입이 있더라도 ui와 이를 값의 변경에 대해서도 다 대처할 수 있는 사용성을 가지게 되었다.
이번 코드 리뷰를 통해 ‘추상화’ 라는 것이 무조건 내 코드의 품질을 높여주는 silver bullet이 아닌, ‘잘’ 추상화 해야지만 추상화의 이점을 가질 수 있음을 깨달았다. 따라서 이번에 조금 더 추상화를 어떻게 더 ‘좋은 코드’ 로 만들 수 있을까에 대해서 계속해서 고민하고 시도해가면서 감을 익혀나가야겠다.