수정하기 쉬운 코드가 좋은 코드다 — 프론트엔드·서버리스·백엔드 실전 가이드

"이 코드 건드렸다가 전체가 터졌어요." 개발자라면 한 번쯤 들어봤거나 직접 경험한 공포의 순간입니다. 기능은 돌아가지만 아무도 손대기 싫은 코드, 그것이 바로 나쁜 코드의 본질입니다. 좋은 코드란 단순히 버그 없이 동작하는 코드가 아니라, 6개월 뒤의 나 자신과 동료가 빠르게 읽고, 안심하고 수정할 수 있는 코드입니다. 이 글에서는 프론트엔드를 중심으로 서버리스·백엔드까지 아우르는 실전 원칙과 패턴을 구체적인 예시와 함께 소개합니다.

도입부

"이 코드 건드렸다가 전체가 터졌어요." 개발자라면 한 번쯤 들어봤거나 직접 경험한 공포의 순간입니다. 기능은 돌아가지만 아무도 손대기 싫은 코드, 그것이 바로 나쁜 코드의 본질입니다. 좋은 코드란 단순히 버그 없이 동작하는 코드가 아니라, 6개월 뒤의 나 자신과 동료가 빠르게 읽고, 안심하고 수정할 수 있는 코드입니다. 이 글에서는 프론트엔드를 중심으로 서버리스·백엔드까지 아우르는 실전 원칙과 패턴을 구체적인 예시와 함께 소개합니다.


왜 "수정하기 쉬운 코드"가 핵심인가

소프트웨어의 생애주기를 생각해보면 답이 명확해집니다. 코드를 처음 작성하는 시간보다 읽고, 디버깅하고, 수정하는 시간이 훨씬 깁니다. 마틴 파울러(Martin Fowler)의 표현을 빌리자면, "소프트웨어를 만드는 비용의 대부분은 초기 개발이 아니라 지속적인 수정에서 발생한다"고 할 수 있습니다.

수정하기 어려운 코드는 다음과 같은 공통점을 가집니다.

  • 의도를 알 수 없는 변수명·함수명 (data, temp, doStuff)
  • 한 함수가 너무 많은 일을 처리 (300줄짜리 컴포넌트)
  • 전역 상태 남용 및 예측 불가능한 사이드 이펙트
  • 테스트 없이 작성된 비즈니스 로직
  • 하드코딩된 값과 환경별 분기 처리 부재

이 다섯 가지 냄새(code smell)를 제거하는 것만으로도 코드 품질은 극적으로 높아집니다.


프론트엔드: 컴포넌트 설계와 상태 관리

단일 책임 원칙(SRP)을 컴포넌트에 적용하기

React, Vue, Svelte 등 어떤 프레임워크를 쓰더라도 컴포넌트는 하나의 역할만 가져야 합니다. UI 렌더링, 데이터 페칭, 비즈니스 로직이 하나의 파일에 뒤섞이면 수정할 때마다 연쇄 버그가 발생합니다.

// ❌ 수정하기 어려운 컴포넌트
export default function UserDashboard() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch('/api/user')
      .then(res => res.json())
      .then(data => {
        // 비즈니스 로직이 UI 안에 혼재
        if (data.role === 'admin') data.label = '관리자';
        setUser(data);
      });
  }, []);

  return <div>{user?.label} 대시보드</div>;
}
// ✅ 수정하기 쉬운 컴포넌트 — 역할 분리
// hooks/useUser.ts
export function useUser() {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => {
    fetchUser().then(setUser);
  }, []);
  return user;
}

// utils/userUtils.ts
export function formatUserLabel(user: RawUser): User {
  return { ...user, label: user.role === 'admin' ? '관리자' : '일반 사용자' };
}

// components/UserDashboard.tsx
export default function UserDashboard() {
  const user = useUser();
  return <div>{user?.label} 대시보드</div>;
}

커스텀 훅으로 데이터 페칭을 분리하고, 순수 함수로 비즈니스 로직을 격리하면 각 레이어를 독립적으로 수정·테스트할 수 있습니다.

상태는 가능한 한 아래로, 멀리 올리지 마라

전역 상태(Redux, Zustand, Pinia 등)는 정말 전역이어야 할 때만 사용합니다. 대부분의 UI 상태는 해당 컴포넌트 혹은 그 부모 컴포넌트 안에서 해결됩니다. 불필요하게 전역 스토어를 키우면 어떤 컴포넌트가 상태를 바꾸는지 추적하기 어려워집니다.

원칙: 상태를 필요로 하는 가장 가까운 공통 조상(Lowest Common Ancestor)에 배치하라.

타입스크립트로 계약을 명시하라

TypeScript의 타입은 단순한 문법 규칙이 아니라 코드 사용 방법에 대한 계약서입니다. 타입이 명확하면 수정 시 컴파일러가 실수를 바로 잡아줍니다.

// 타입을 명확히 정의하면 리팩터링이 안전해진다
interface Product {
  id: string;
  name: string;
  priceKRW: number;       // 단위를 이름에 포함
  isDiscounted: boolean;
}

function applyDiscount(product: Product, ratePercent: number): Product {
  return {
    ...product,
    priceKRW: product.priceKRW * (1 - ratePercent / 100),
    isDiscounted: true,
  };
}

서버리스: 함수는 작고, 순수하고, 명확하게

서버리스 함수는 단일 책임이 생존 전략이다

AWS Lambda, Vercel Functions, Cloudflare Workers 같은 서버리스 환경에서는 함수 하나가 너무 많은 일을 하면 디버깅, 비용 최적화, 콜드 스타트 문제가 모두 악화됩니다. 함수는 작고 명확해야 합니다.

// ✅ Vercel API Route — 단일 책임
// /api/send-welcome-email.ts
import { sendEmail } from '@/lib/mailer';
import { getUserById } from '@/lib/db';

export default async function handler(req, res) {
  if (req.method !== 'POST') return res.status(405).end();

  const { userId } = req.body;
  const user = await getUserById(userId);

  await sendEmail({
    to: user.email,
    subject: '가입을 환영합니다!',
    template: 'welcome',
    data: { name: user.name },
  });

  return res.status(200).json({ success: true });
}

이메일 전송 로직을 mailer, DB 접근을 db 레이어로 분리했습니다. 나중에 이메일 서비스를 SendGrid에서 Resend로 교체해도 mailer.ts만 수정하면 됩니다.

환경 변수와 설정은 반드시 분리하라

하드코딩된 URL, API 키, 상수값은 수정할 때마다 전체 파일을 뒤져야 합니다. 환경 변수와 설정 파일을 활용하면 배포 환경 변경이 코드 수정 없이 가능합니다.

// ❌ 하드코딩
const BASE_URL = 'https://api.myservice.com/v2';

// ✅ 환경 변수 분리
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL;

.env.local, .env.production 파일을 구분해 관리하면 스테이징·프로덕션 전환이 깔끔해집니다.


백엔드: 계층 구조와 의존성 역전

레이어드 아키텍처로 변경 범위를 제한하라

Node.js(Express/Fastify), Python(FastAPI), Go 등 어떤 백엔드 스택이든 라우터 → 서비스 → 리포지토리 3계층 구조는 수정 범위를 명확히 제한합니다.

📁 src/
├── routes/         ← HTTP 요청/응답만 처리
├── services/       ← 비즈니스 로직
├── repositories/   ← DB 접근
└── models/         ← 타입·스키마 정의

예를 들어 DB를 PostgreSQL에서 MongoDB로 바꾼다면 repositories/ 폴더만 수정하면 됩니다. 서비스 레이어나 라우터는 전혀 건드릴 필요가 없습니다.

의존성 주입(DI)으로 테스트 가능한 코드 만들기

함수나 클래스가 의존하는 모듈을 내부에서 직접 생성하지 않고 외부에서 주입받으면, 테스트 시 Mock으로 쉽게 교체할 수 있습니다.

// ❌ 테스트하기 어려운 코드
class OrderService {
  private db = new PostgresClient(); // 내부에서 직접 생성
  async createOrder(data) { ... }
}

// ✅ 의존성 주입 — 테스트와 교체가 쉬워진다
class OrderService {
  constructor(private db: DatabaseClient) {}
  async createOrder(data) { ... }
}

// 프로덕션
const service = new OrderService(new PostgresClient());
// 테스트
const service = new OrderService(new MockDatabaseClient());

에러 처리는 명시적으로, 전파 경로를 예측 가능하게

에러를 조용히 삼키거나 (catch 블록 비워두기) 무분별하게 console.log만 찍으면 디버깅이 극도로 어려워집니다. 에러 타입을 정의하고, 계층별로 명확한 책임을 부여합니다.

// 커스텀 에러 클래스로 에러 유형을 명시
export class NotFoundError extends Error {
  constructor(resource: string, id: string) {
    super(`${resource} (id: ${id})를 찾을 수 없습니다.`);
    this.name = 'NotFoundError';
  }
}

// 서비스 레이어
async function getUserById(id: string) {
  const user = await userRepository.findById(id);
  if (!user) throw new NotFoundError('User', id);
  return user;
}

// 라우터 레이어 — 에러 종류에 따라 HTTP 코드 분기
app.use((err, req, res, next) => {
  if (err instanceof NotFoundError) return res.status(404).json({ message: err.message });
  return res.status(500).json({ message: '서버 오류가 발생했습니다.' });
});

수정하기 쉬운 코드를 위한 공통 원칙 정리

프론트엔드·서버리스·백엔드를 관통하는 핵심 원칙을 다시 정리합니다.


결론: 미래의 나를 위한 코드를 작성하라

좋은 코드란 천재만이 이해하는 영리한 코드가 아닙니다. 6개월 뒤 야근 중인 동료, 혹은 컨텍스트를 잊어버린 나 자신이 10분 안에 파악하고 안전하게 수정할 수 있는 코드입니다.

오늘 당장 모든 코드를 완벽하게 리팩터링할 필요는 없습니다. 다음 번 기능을 추가하거나 버그를 고칠 때, **보이스카우트 원칙(캠프를 처음보다 깨끗하게 떠나라)**처럼 코드를 조금씩 더 나은 상태로 남기는 것부터 시작해보세요.

  • 긴 함수를 발견하면 → 작은 함수로 분리
  • any 타입을 발견하면 → 명확한 타입 정의
  • 하드코딩된 값을 발견하면 → 상수나 환경 변수로 이동
  • 테스트 없는 비즈니스 로직을 발견하면 → 테스트 한 줄 추가

수정하기 쉬운 코드는 팀의 속도를 높이고, 버그를 줄이고, 개발자의 번아웃을 예방합니다. 지금 작성하는 코드가 내일의 팀 생산성을 결정합니다. 오늘부터 미래의 나를 위한 코드를 작성해보시겠어요?

댓글 0개

댓글

댓글을 불러오는 중…

지원 문의