1.god-object
God Object: 모든 걸 아는 신(神) 클래스
시스템 전체를 지배하는 만능 클래스의 최후
이게 뭔데
God Object. 이름부터 거창하다. 시스템 전체에 대해 너무 많은 걸 알고, 너무 많은 걸 처리하는 클래스를 말한다. "갓 오브젝트"라고 읽으면 됨. 신(神)처럼 전지전능한 클래스라는 뜻이다.
비유를 하나 들어보자. 회사에 한 명 있다. 경리도 하고, 영업도 하고, 개발도 하고, 디자인도 하고, 심지어 사무실 화분에 물도 주는 슈퍼맨 직원. 처음엔 편하다. 뭐든 그 사람한테 물어보면 되니까. 근데 그 사람이 연차 쓰면? 회사가 멈춘다. 아프면? 재앙이다. 퇴사하면? 회사가 망한다.
God Object가 정확히 그거임. 처음엔 "일단 여기에 넣자"로 시작해서, 나중엔 "이거 건드리면 뭐가 터질지 모른다"가 되는 클래스. OOP의 가장 기본적인 원칙인 단일 책임 원칙(SRP)을 정면으로 위반하는 대표적인 안티패턴이다.
한줄요약
하나의 클래스가 너무 많은 책임을 가지면, 그 클래스는 모든 변경의 원인이 되고 모든 버그의 온상이 된다.
이런 코드 본 적 있을 거임
솔직히 말해보자. 아래 코드를 보고 "아... 우리 회사에도 이거 있는데"라는 생각이 들면 정상이다.
class AppManager {
private db: Database;
private cache: Map<string, any> = new Map();
private mailer: SMTPClient;
private stripe: StripeClient;
private s3: S3Client;
private logger: Logger;
constructor() {
this.db = new Database(process.env.DB_URL!);
this.mailer = new SMTPClient(process.env.SMTP_HOST!);
this.stripe = new StripeClient(process.env.STRIPE_KEY!);
this.s3 = new S3Client({ region: "ap-northeast-2" });
this.logger = new Logger("AppManager");
}
// ---- 유저 관련 ----
async createUser(name: string, email: string): Promise<User> {
const user = await this.db.query("INSERT INTO users ...", [name, email]);
this.cache.set(`user:${user.id}`, user);
await this.sendWelcomeEmail(user.email);
this.logger.info(`User created: ${user.id}`);
return user;
}
async getUser(id: string): Promise<User | null> {
if (this.cache.has(`user:${id}`)) return this.cache.get(`user:${id}`);
const user = await this.db.query("SELECT * FROM users WHERE id = $1", [id]);
if (user) this.cache.set(`user:${id}`, user);
return user;
}
async deleteUser(id: string): Promise<void> {
await this.db.query("DELETE FROM users WHERE id = $1", [id]);
this.cache.delete(`user:${id}`);
await this.cancelAllSubscriptions(id);
await this.deleteUserFiles(id);
this.logger.info(`User deleted: ${id}`);
}
// ---- 이메일 관련 ----
async sendWelcomeEmail(to: string): Promise<void> {
const html = this.renderEmailTemplate("welcome", { to });
await this.mailer.send({ to, subject: "환영합니다!", html });
this.logger.info(`Welcome email sent to ${to}`);
}
async sendPasswordResetEmail(to: string, token: string): Promise<void> {
const html = this.renderEmailTemplate("reset", { to, token });
await this.mailer.send({ to, subject: "비밀번호 재설정", html });
}
private renderEmailTemplate(name: string, data: Record<string, any>): string {
// 템플릿 엔진 로직이 여기에... 50줄짜리...
return `<html>...</html>`;
}
// ---- 결제 관련 ----
async processPayment(userId: string, amount: number): Promise<PaymentResult> {
const user = await this.getUser(userId);
if (!user) throw new Error("User not found");
const charge = await this.stripe.charges.create({
amount: amount * 100,
currency: "krw",
customer: user.stripeCustomerId,
});
await this.db.query("INSERT INTO payments ...", [userId, amount, charge.id]);
await this.sendPaymentReceipt(user.email, amount);
this.logger.info(`Payment processed: ${charge.id}`);
return { success: true, chargeId: charge.id };
}
async cancelAllSubscriptions(userId: string): Promise<void> {
const subs = await this.stripe.subscriptions.list({ customer: userId });
for (const sub of subs.data) {
await this.stripe.subscriptions.cancel(sub.id);
}
}
private async sendPaymentReceipt(to: string, amount: number): Promise<void> {
const html = this.renderEmailTemplate("receipt", { amount });
await this.mailer.send({ to, subject: `${amount}원 결제 완료`, html });
}
// ---- 파일 업로드 관련 ----
async uploadAvatar(userId: string, file: Buffer): Promise<string> {
const key = `avatars/${userId}/${Date.now()}.png`;
await this.s3.putObject({ Bucket: "my-bucket", Key: key, Body: file });
await this.db.query("UPDATE users SET avatar_url = $1 WHERE id = $2", [key, userId]);
this.cache.delete(`user:${userId}`);
this.logger.info(`Avatar uploaded: ${key}`);
return key;
}
async deleteUserFiles(userId: string): Promise<void> {
const files = await this.s3.listObjects({ Bucket: "my-bucket", Prefix: `avatars/${userId}/` });
for (const file of files.Contents ?? []) {
await this.s3.deleteObject({ Bucket: "my-bucket", Key: file.Key! });
}
}
// ---- 알림 관련 ----
async sendPushNotification(userId: string, message: string): Promise<void> {
const user = await this.getUser(userId);
if (!user?.pushToken) return;
await fetch("https://fcm.googleapis.com/fcm/send", {
method: "POST",
headers: { Authorization: `key=${process.env.FCM_KEY}` },
body: JSON.stringify({ to: user.pushToken, notification: { body: message } }),
});
this.logger.info(`Push sent to ${userId}`);
}
// ---- 캐시 관련 ----
clearCache(): void {
this.cache.clear();
this.logger.info("Cache cleared");
}
getCacheStats(): { size: number; keys: string[] } {
return { size: this.cache.size, keys: [...this.cache.keys()] };
}
}
// 사용하는 쪽
const app = new AppManager();
await app.createUser("김철수", "chulsoo@example.com");
await app.processPayment("user-123", 49900);
await app.uploadAvatar("user-123", avatarBuffer);
await app.sendPushNotification("user-123", "결제가 완료되었습니다");
이 클래스 하나가 유저 관리, 이메일 발송, 결제 처리, 파일 업로드, 푸시 알림, 캐싱까지 전부 담당하고 있다. 실제 프로덕션 코드에서는 이런 클래스가 2000줄을 넘기는 경우도 흔함. 진짜로.
뭐가 문제냐면
- SRP 위반: 하나의 클래스가 유저, 이메일, 결제, 파일, 알림, 캐시 등 10개 이상의 책임을 가지고 있다
- 테스트 불가능:
createUser를 테스트하려면 DB, SMTP, S3, Stripe를 전부 모킹해야 한다 - 변경 충돌: 팀원 5명이 유저 기능, 결제 기능, 이메일 기능을 동시에 수정하면 전부 같은 파일에서 충돌이 난다
- 순환 의존: 시스템의 거의 모든 모듈이 이 클래스를 import하게 되어 의존성 그래프가 별 모양(star topology)이 된다
- 재사용 불가: 이메일 발송 로직만 다른 서비스에서 쓰고 싶어도,
AppManager전체를 가져와야 한다
이렇게 고치면 됨
핵심은 간단하다. 책임별로 클래스를 쪼개고, 조합(composition)으로 연결한다. 각 클래스가 딱 하나의 도메인만 담당하게 만들면 된다.
// 1. 유저 저장소 — 유저 CRUD만 담당
class UserRepository {
constructor(private db: Database) {}
async create(name: string, email: string): Promise<User> {
return this.db.query("INSERT INTO users ...", [name, email]);
}
async findById(id: string): Promise<User | null> {
return this.db.query("SELECT * FROM users WHERE id = $1", [id]);
}
async delete(id: string): Promise<void> {
await this.db.query("DELETE FROM users WHERE id = $1", [id]);
}
async updateAvatarUrl(id: string, url: string): Promise<void> {
await this.db.query("UPDATE users SET avatar_url = $1 WHERE id = $2", [url, id]);
}
}
// 2. 이메일 서비스 — 이메일 발송만 담당
class EmailService {
constructor(private mailer: SMTPClient) {}
async sendWelcome(to: string): Promise<void> {
const html = this.renderTemplate("welcome", { to });
await this.mailer.send({ to, subject: "환영합니다!", html });
}
async sendPasswordReset(to: string, token: string): Promise<void> {
const html = this.renderTemplate("reset", { to, token });
await this.mailer.send({ to, subject: "비밀번호 재설정", html });
}
async sendPaymentReceipt(to: string, amount: number): Promise<void> {
const html = this.renderTemplate("receipt", { amount });
await this.mailer.send({ to, subject: `${amount}원 결제 완료`, html });
}
private renderTemplate(name: string, data: Record<string, any>): string {
return `<html>...</html>`;
}
}
// 3. 결제 서비스 — 결제 처리만 담당
class PaymentService {
constructor(
private stripe: StripeClient,
private db: Database,
) {}
async charge(customerId: string, amount: number): Promise<PaymentResult> {
const charge = await this.stripe.charges.create({
amount: amount * 100,
currency: "krw",
customer: customerId,
});
await this.db.query("INSERT INTO payments ...", [customerId, amount, charge.id]);
return { success: true, chargeId: charge.id };
}
async cancelAll(customerId: string): Promise<void> {
const subs = await this.stripe.subscriptions.list({ customer: customerId });
for (const sub of subs.data) {
await this.stripe.subscriptions.cancel(sub.id);
}
}
}
// 4. 파일 업로드 서비스 — S3 파일 관리만 담당
class FileUploadService {
constructor(private s3: S3Client, private bucket: string) {}
async upload(path: string, file: Buffer): Promise<string> {
const key = `${path}/${Date.now()}.png`;
await this.s3.putObject({ Bucket: this.bucket, Key: key, Body: file });
return key;
}
async deleteByPrefix(prefix: string): Promise<void> {
const files = await this.s3.listObjects({ Bucket: this.bucket, Prefix: prefix });
for (const file of files.Contents ?? []) {
await this.s3.deleteObject({ Bucket: this.bucket, Key: file.Key! });
}
}
}
// 5. 알림 서비스 — 푸시 알림만 담당
class NotificationService {
constructor(private fcmKey: string) {}
async sendPush(token: string, message: string): Promise<void> {
await fetch("https://fcm.googleapis.com/fcm/send", {
method: "POST",
headers: { Authorization: `key=${this.fcmKey}` },
body: JSON.stringify({ to: token, notification: { body: message } }),
});
}
}
// 6. 유저 유스케이스 — 서비스들을 조합해서 비즈니스 흐름을 처리
class UserService {
constructor(
private userRepo: UserRepository,
private emailService: EmailService,
private paymentService: PaymentService,
private fileService: FileUploadService,
private notificationService: NotificationService,
private logger: Logger,
) {}
async register(name: string, email: string): Promise<User> {
const user = await this.userRepo.create(name, email);
await this.emailService.sendWelcome(email);
this.logger.info(`User registered: ${user.id}`);
return user;
}
async purchase(userId: string, amount: number): Promise<PaymentResult> {
const user = await this.userRepo.findById(userId);
if (!user) throw new Error("User not found");
const result = await this.paymentService.charge(user.stripeCustomerId, amount);
await this.emailService.sendPaymentReceipt(user.email, amount);
this.logger.info(`Payment processed for user ${userId}`);
return result;
}
async uploadAvatar(userId: string, file: Buffer): Promise<string> {
const key = await this.fileService.upload(`avatars/${userId}`, file);
await this.userRepo.updateAvatarUrl(userId, key);
return key;
}
async remove(userId: string): Promise<void> {
await this.paymentService.cancelAll(userId);
await this.fileService.deleteByPrefix(`avatars/${userId}/`);
await this.userRepo.delete(userId);
this.logger.info(`User removed: ${userId}`);
}
}
// 의존성 주입으로 조립
const db = new Database(process.env.DB_URL!);
const userRepo = new UserRepository(db);
const emailService = new EmailService(new SMTPClient(process.env.SMTP_HOST!));
const paymentService = new PaymentService(new StripeClient(process.env.STRIPE_KEY!), db);
const fileService = new FileUploadService(new S3Client({ region: "ap-northeast-2" }), "my-bucket");
const notificationService = new NotificationService(process.env.FCM_KEY!);
const logger = new Logger("UserService");
const userService = new UserService(
userRepo, emailService, paymentService, fileService, notificationService, logger,
);
// 깔끔하게 사용
await userService.register("김철수", "chulsoo@example.com");
await userService.purchase("user-123", 49900);
코드가 좀 길어진 것 같다고? 맞다. 파일 수도 늘었다. 근데 이게 핵심이 아님. 중요한 건 각 파일이 300줄을 넘지 않는다는 것, 그리고 한 파일을 수정해도 다른 파일에 영향이 없다는 것이다.
뭐가 좋아졌냐면
- 단일 책임: 각 클래스가 딱 하나의 도메인만 담당한다.
EmailService는 이메일만,PaymentService는 결제만. - 독립 테스트 가능:
EmailService를 테스트할 때 DB나 Stripe는 필요 없다. SMTP 모킹만 하면 됨. - 충돌 없는 협업: 결제 로직 수정하는 사람과 이메일 템플릿 수정하는 사람이 서로 다른 파일에서 작업한다.
- 의존성 명확: 생성자를 보면 이 클래스가 뭘 필요로 하는지 한눈에 파악 가능. 교체도 쉽다.
- 재사용 가능:
EmailService를 다른 마이크로서비스에서 그대로 가져다 쓸 수 있다.
실무에서는
God Object는 악의적으로 만들어지지 않는다. 자연스럽게 태어남. 그 과정은 대충 이렇다.
1단계: "일단 여기에 넣자"
스타트업 초기에 빠르게 MVP를 만들다 보면 자연스럽게 생긴다. UserService 하나에 유저 관련 로직을 넣고, 이메일 보내는 것도 유저 가입 플로우니까 여기에 넣고, 결제도 유저가 하는 거니까 여기에 넣고... "어차피 나중에 리팩토링하면 되지"라는 생각으로 계속 추가한다. 그 "나중"은 안 온다.
2단계: "건드리기 무섭다"
6개월이 지나면 그 클래스는 800줄이 넘고, 메서드가 40개가 넘는다. 새로 들어온 팀원이 "이거 좀 나누면 안 될까요?"라고 물어보면 시니어가 쓴웃음을 지으며 말한다. "그거 건드리면 어디서 터질지 몰라요." 이제 리팩토링 비용이 기능 개발 비용보다 커져버린 거다.
3단계: "merge conflict 지옥"
팀이 5명으로 늘어나면 진짜 재앙이 시작된다. 유저 기능 수정하는 사람, 결제 기능 수정하는 사람, 이메일 템플릿 바꾸는 사람이 전부 같은 파일을 건드린다. PR 올릴 때마다 conflict가 나고, conflict 해결하다가 버그가 생기고, 그 버그 고치다가 또 conflict가 나는 무한루프.
4단계: "2000줄의 벽"
어느 순간 IDE에서 파일을 열면 스크롤바가 점처럼 작아져 있다. 파일 내에서 Ctrl+F로 메서드를 찾아다녀야 하고, 클래스의 전체 구조를 파악하는 게 불가능해진다. 이 시점에서 보통 두 가지 선택지가 있다: 지금 고통을 감수하고 리팩토링하든가, 아니면 이직하든가.
왜 이렇게 되는 걸까?
근본적인 원인은 "편의성 중독"이다. 하나의 클래스에 모든 걸 넣으면 당장은 편하다. import 하나, 인스턴스 하나로 모든 걸 할 수 있으니까. 하지만 소프트웨어의 복잡성은 선형이 아니라 지수적으로 증가한다. 메서드가 10개일 때는 괜찮지만, 50개가 되면 메서드 간의 잠재적 상호작용은 1225개(50C2)가 된다. 인간의 뇌가 감당할 수 있는 수준이 아님.
God Object 감지법
내 코드에 God Object가 있는지 확인하는 간단한 휴리스틱:
- 라인 수 기준: 클래스가 500줄을 넘으면 의심, 1000줄을 넘으면 확신
- 의존성 개수: 생성자에서 주입받는 의존성이 5개를 넘으면 빨간불
- 파일명 확인:
Manager,Helper,Util,Handler,Processor같은 이름이 붙어 있으면 높은 확률로 God Object - 도메인 혼합: 하나의 클래스 안에서 서로 다른 도메인(유저, 결제, 이메일 등)을 건드리는 메서드가 공존하면 100% God Object
- git log 확인: 한 파일의 커밋 빈도가 비정상적으로 높으면 여러 사람이 여러 이유로 수정하고 있다는 뜻 → 책임이 너무 많다는 신호
정리
God Object는 안티패턴 중에서도 가장 흔하고, 가장 자연스럽게 생기고, 가장 고치기 어렵다. 하지만 원칙은 단순하다: 하나의 클래스는 하나의 이유로만 변경되어야 한다. 클래스를 열었는데 "이 클래스가 하는 일을 한 문장으로 설명할 수 없다"면, 그건 이미 너무 많은 걸 하고 있는 거다.
다음 글에서는 God Object와는 결이 다른 구조적 혼돈, 파스타 코드 3형제를 살펴본다. 스파게티 코드는 들어봤을 텐데, 라자냐 코드와 라비올리 코드는 들어봤는지?
← 시리즈 개요 | 다음 글: 파스타 코드 3형제 →