16.abstraction-failures
추상화 실패 3종: 샘, 뒤집힘, 오염
추상화는 마법이 아니다 — 잘못하면 오히려 독
추상화는 소프트웨어 개발의 핵심 도구다. 복잡한 것을 감추고 단순한 인터페이스만 노출한다. 잘 만든 추상화는 투명하다 — 사용자가 내부를 몰라도 된다. 하지만 추상화가 잘못되면? 내부를 알아야 하거나, 추상화 때문에 오히려 더 복잡해지거나, 추상화가 오염되어 버그를 퍼뜨린다.
이번 글에서 다루는 세 가지는 추상화가 실패하는 세 가지 방식이다. Leaky Abstraction(새는 추상화), Abstraction Inversion(추상화 역전), Object Cesspool(객체 오물 웅덩이). 각각 "추상화가 너무 얇음", "추상화가 거꾸로 됨", "추상화가 오염됨"에 대응한다.
1. Leaky Abstraction (새는 추상화)
이게 뭔데
정의
추상화가 내부 구현의 세부사항을 외부로 노출(leak)하여, 사용자가 내부 구현을 알아야만 올바르게 사용할 수 있는 상태. Joel Spolsky의 "The Law of Leaky Abstractions" (2002)에서 체계화.
Spolsky의 법칙은 간단하다:
"모든 non-trivial 추상화는 어느 정도 새어나간다."
모든 추상화는 새다. 완벽한 추상화는 없다. 문제는 정도의 차이다. 약간 새는 건 괜찮다. 폭포처럼 쏟아지면 추상화의 의미가 없다.
비유를 들어보자. 자동차의 엑셀 페달은 엔진의 추상화다. 우리는 연료 분사량, 점화 타이밍, 밸브 개폐를 몰라도 엑셀을 밟으면 차가 가속한다. 이게 좋은 추상화다. 하지만 고속도로에서 갑자기 가속이 안 된다면? 터보 랙인지, 연료 펌프 문제인지, 산소 센서 이상인지 알아야 한다. 추상화가 샌 거다.
실제 예시들
ORM (Object-Relational Mapping):
ORM은 SQL을 추상화한다. 객체를 다루듯 DB를 다룰 수 있게 해준다. 근데 실제로 써보면:
// 순수하게 ORM만 쓴 코드 — 겉보기엔 깔끔함
class OrderService {
async getOrdersWithDetails(userId: string) {
// ORM의 추상화: "DB? 몰라도 됨. 그냥 객체처럼 써"
const orders = await this.orderRepo.find({
where: { userId },
relations: ["items", "items.product"],
});
return orders;
}
}
// 실행되는 SQL:
// SELECT * FROM orders WHERE user_id = '...'
// SELECT * FROM order_items WHERE order_id IN (...)
// SELECT * FROM products WHERE id IN (...)
// → 3번의 쿼리. 주문이 1000개면? 여전히 3번 (다행히 IN절)
여기까지는 괜찮다. 근데 성능 문제가 생기면?
// 성능 문제 발생 → ORM 추상화를 뚫고 SQL 지식이 필요해짐
class OrderService {
async getOrdersWithDetails(userId: string) {
// ORM이 생성하는 SQL이 비효율적 → 직접 쿼리 작성
const orders = await this.orderRepo
.createQueryBuilder("order")
.leftJoinAndSelect("order.items", "item")
.leftJoinAndSelect("item.product", "product")
.where("order.userId = :userId", { userId })
.addSelect("SUM(item.quantity * item.price)", "order_total")
.groupBy("order.id")
.orderBy("order.createdAt", "DESC")
.limit(50)
.cache(60000) // 1분 캐시 — 이것도 알아야 함
.getMany();
return orders;
}
}
// 이 시점에서 개발자가 알아야 하는 것:
// - SQL JOIN 종류와 성능 차이
// - 인덱스 설계
// - 쿼리 실행 계획 (EXPLAIN)
// - 커넥션 풀 관리
// - N+1 문제와 Eager/Lazy Loading
// → ORM이 추상화하겠다던 것들을 전부 알아야 함
ORM은 "SQL을 몰라도 돼"라고 약속했지만, 실제로는 SQL을 알아야 제대로 쓸 수 있다. 추상화가 새고 있는 거다.
TCP/IP:
TCP는 "신뢰할 수 있는 바이트 스트림"을 추상화한다. 패킷이 유실되면 자동으로 재전송해준다. 하지만:
- 네트워크가 불안정하면 재전송 지연이 발생한다
- 대양 횡단 통신에서 RTT(Round Trip Time)가 100ms면, TCP 핸드셰이크만 300ms
- 패킷 유실률이 높으면 TCP 윈도우가 줄어들면서 처리량이 급감
"신뢰할 수 있는 스트림"이라고 했는데, 실제로는 네트워크 물리적 특성을 알아야 성능을 낼 수 있다.
React:
React는 DOM을 추상화한다. 상태를 바꾸면 UI가 알아서 업데이트된다. 하지만:
useMemo,useCallback없이 대규모 리스트를 렌더링하면 느려짐- Virtual DOM의 reconciliation 알고리즘을 알아야
keyprop을 올바르게 씀 - 상태 업데이트가 배치로 처리되는 타이밍을 알아야
useEffect를 제대로 씀 - Concurrent Mode에서 렌더링이 중단될 수 있다는 걸 알아야 side effect를 안전하게 처리
DOM을 몰라도 된다고 했는데, React 자체의 내부 동작을 알아야 한다. 추상화 레이어가 바뀌었을 뿐, "내부를 알아야 한다"는 본질은 같다.
Spolsky의 법칙의 함의
"모든 non-trivial 추상화는 어느 정도 새어나간다." 이 법칙의 진짜 의미는 이거다: 추상화 아래 레이어를 무시하면 안 된다. ORM을 쓰더라도 SQL을 알아야 한다. React를 쓰더라도 DOM을 이해해야 한다. TCP를 쓰더라도 네트워크 기초를 알아야 한다. 추상화는 "몰라도 되는 마법"이 아니라 "보통은 신경 안 써도 되지만 가끔은 알아야 하는 레이어"다.
새는 추상화와 공존하는 법
완벽한 추상화는 없으므로, 우리가 할 수 있는 건 새는 정도를 관리하는 거다:
- 추상화 아래 레이어를 공부하라. ORM 뒤의 SQL, HTTP 뒤의 TCP, React 뒤의 DOM
- 추상화가 새는 지점을 문서화하라. "이 ORM은 N+1에 취약하니 이런 패턴을 써라"
- 추상화를 너무 많이 쌓지 마라. 레이어가 많을수록 새는 지점도 많아진다
- 성능 문제가 생기면 추상화를 의심하라. 추상화 없이 직접 호출했을 때 차이가 나는지 확인
2. Abstraction Inversion (추상화 역전)
이게 뭔데
정의
하위 레이어의 기능이 상위 레이어에 의해 숨겨져, 사용자가 필요한 저수준 기능을 고수준 API로 우회 구현해야 하는 상태. 추상화의 방향이 뒤집힌 것.
새는 추상화가 "추상화가 너무 얇아서 내부가 보인다"면, 추상화 역전은 "추상화가 너무 두꺼워서 내부에 접근할 수 없다"이다. 정반대의 문제.
필요한 저수준 기능이 있는데, 프레임워크/언어가 그걸 숨기고 있다. 그래서 고수준 기능을 조합해서 저수준 작업을 억지로 수행한다. 마치 망치가 필요한데 못이 없어서, 렌치로 너트를 풀고 볼트를 빼서 쇠막대를 꺼내 망치 대신 쓰는 것과 같다.
이런 코드
// 추상화 역전 예시 1: 프레임워크가 저수준 접근을 막음
// Next.js에서 HTTP 헤더의 raw bytes에 접근하고 싶은데,
// Request 객체가 이미 파싱된 형태로만 제공됨
export async function POST(request: Request) {
// 필요한 것: raw request body의 바이트 단위 처리
// (예: webhook signature 검증에 필요)
// 하지만 Request.json()은 이미 파싱해버림
// Request.text()는 문자열로 변환해버림
// 추상화 역전: 고수준 API로 저수준 작업을 우회
const text = await request.text();
const rawBytes = new TextEncoder().encode(text);
// ⚠️ 원본과 다를 수 있음! encoding 차이, BOM 등
const signature = request.headers.get("x-webhook-signature");
const isValid = verifySignature(rawBytes, signature);
// 원본 raw bytes가 아니라 text → bytes 변환이라 서명 불일치 가능
}
// 추상화 역전 예시 2: ORM이 raw SQL을 숨김
// 필요한 것: PostgreSQL의 UPSERT (ON CONFLICT DO UPDATE)
// ORM이 이 기능을 지원하지 않음
class ProductRepository {
// 추상화 역전: ORM의 고수준 API로 UPSERT를 흉내냄
async upsertProduct(product: Product): Promise<void> {
const existing = await this.repo.findOne({
where: { sku: product.sku }
});
if (existing) {
// 별도 조회 + 업데이트 = 2번의 DB 호출
await this.repo.update(existing.id, product);
} else {
await this.repo.save(product);
}
// 문제점:
// 1. 조회와 저장 사이에 다른 트랜잭션이 끼어들 수 있음 (race condition)
// 2. 2번의 쿼리 vs 원래 1번이면 되는 UPSERT
// 3. DB가 제공하는 원자성을 활용 못함
}
// 진짜 해결: raw SQL 사용 (추상화를 뚫는 게 차라리 나음)
async upsertProductCorrectly(product: Product): Promise<void> {
await this.dataSource.query(`
INSERT INTO products (sku, name, price)
VALUES ($1, $2, $3)
ON CONFLICT (sku) DO UPDATE
SET name = EXCLUDED.name, price = EXCLUDED.price
`, [product.sku, product.name, product.price]);
}
}
// 추상화 역전 예시 3: Set이 필요한데 Array로 구현
// 상황: 중복 없는 컬렉션에서 특정 요소의 존재 여부를 빠르게 확인하고 싶음
// 문제: 코드베이스 전체가 Array 기반이라 Set으로 전환이 어려움
class TagManager {
private tags: string[] = []; // Set이어야 하는데 Array
// O(n) — Set이면 O(1)
hasTag(tag: string): boolean {
return this.tags.indexOf(tag) !== -1;
}
// O(n) — 중복 체크해야 하니까
addTag(tag: string): void {
if (!this.hasTag(tag)) { // indexOf 한 번
this.tags.push(tag);
}
}
// O(n) — splice가 필요
removeTag(tag: string): void {
const idx = this.tags.indexOf(tag); // indexOf 한 번
if (idx !== -1) {
this.tags.splice(idx, 1); // 뒤의 요소 전부 이동
}
}
// 이 모든 메서드가 O(1)이면 되는데, Array의 "추상화"가
// 해시 테이블의 저수준 성능을 가림
}
// 올바른 해결
class TagManager {
private tags: Set<string> = new Set();
hasTag(tag: string): boolean { return this.tags.has(tag); } // O(1)
addTag(tag: string): void { this.tags.add(tag); } // O(1)
removeTag(tag: string): void { this.tags.delete(tag); } // O(1)
}
추상화 역전을 피하는 법
역전 방지
- 추상화 아래로 접근할 수 있는 escape hatch를 남겨둬라. ORM이라면 raw SQL 실행 메서드, UI 라이브러리라면 DOM 직접 접근법
- 프레임워크 선택 시 저수준 접근 가능성을 확인하라. "이 프레임워크에서 X를 할 수 없으면 어떻게 하지?"를 미리 물어라
- 추상화가 필요한 기능을 숨기면, 추상화를 우회하는 것이 차라리 나을 때가 있다. 고수준 API로 저수준 작업을 흉내내는 것보다 추상화를 뚫고 직접 하는 게 더 깔끔하고 안전할 수 있다
3. Object Cesspool (객체 오물 웅덩이)
이게 뭔데
정의
객체 풀(Object Pool)에 반환된 객체가 재사용 가능한 상태로 초기화되지 않아, 이전 사용의 데이터가 오염된 채로 다음 사용자에게 전달되는 상태.
Object Pool 패턴은 객체 생성 비용이 높을 때 사용한다. DB 커넥션, 스레드, HTTP 클라이언트 등을 미리 만들어두고 재사용하는 거다. 좋은 패턴인데, 재사용할 때 상태 초기화를 깜빡하면? 이전 사용자의 데이터가 다음 사용자에게 보이는 보안 사고가 난다.
이름이 "cesspool(오물 웅덩이)"인 이유가 있다. 깨끗해야 할 풀에 더러운 객체가 섞여있으니까.
이런 코드
// 요청 컨텍스트 객체 풀 — 초기화 안 한 예시
class RequestContext {
userId: string = "";
role: string = "guest";
permissions: string[] = [];
sessionData: Record<string, any> = {};
requestId: string = "";
}
class RequestContextPool {
private pool: RequestContext[] = [];
private maxSize = 100;
acquire(): RequestContext {
// 풀에 있으면 꺼내서 반환
if (this.pool.length > 0) {
return this.pool.pop()!; // 🚨 초기화 없이 반환!
}
return new RequestContext();
}
release(ctx: RequestContext): void {
if (this.pool.length < this.maxSize) {
this.pool.push(ctx); // 🚨 초기화 없이 풀에 반환!
}
}
}
// 사용 시나리오
const pool = new RequestContextPool();
// 요청 1: 관리자 로그인
async function handleAdminRequest() {
const ctx = pool.acquire();
ctx.userId = "admin-001";
ctx.role = "admin";
ctx.permissions = ["read", "write", "delete", "admin"];
ctx.sessionData = { lastAccess: Date.now(), secretKey: "abc123" };
await processRequest(ctx);
pool.release(ctx); // 관리자 데이터가 담긴 채로 반환
}
// 요청 2: 일반 사용자 — 관리자의 컨텍스트를 받음!
async function handleUserRequest() {
const ctx = pool.acquire(); // 🚨 관리자의 데이터가 남아있음!
// ctx.userId = "admin-001" ← 이전 사용자의 ID
// ctx.role = "admin" ← 관리자 권한!
// ctx.permissions = ["read", "write", "delete", "admin"]
// ctx.sessionData = { secretKey: "abc123" } ← 민감 데이터 노출!
ctx.userId = "user-042"; // 유저 ID는 덮어썼지만...
// role, permissions, sessionData는 안 바꿈 — 관리자 권한 유지!
if (ctx.permissions.includes("admin")) {
// 일반 사용자가 관리자 기능에 접근 가능!!
await deleteAllUsers(); // 보안 사고
}
}
이건 보안 취약점이다. 이전 사용자의 데이터가 다음 사용자에게 노출되면 권한 상승(privilege escalation), 데이터 유출, 세션 하이재킹이 발생할 수 있다.
실제 사례로, 2023년에 ChatGPT에서 다른 사용자의 대화 제목이 보이는 버그가 있었는데, 이것도 유사한 캐시/풀 오염 문제였다.
고친 코드
class RequestContext {
userId: string = "";
role: string = "guest";
permissions: string[] = [];
sessionData: Record<string, any> = {};
requestId: string = "";
// 핵심: 재사용 전 초기화 메서드
reset(): void {
this.userId = "";
this.role = "guest";
this.permissions = [];
this.sessionData = {};
this.requestId = "";
}
}
class SafeRequestContextPool {
private pool: RequestContext[] = [];
private maxSize = 100;
acquire(): RequestContext {
if (this.pool.length > 0) {
const ctx = this.pool.pop()!;
ctx.reset(); // 반드시 초기화 후 반환
return ctx;
}
return new RequestContext();
}
release(ctx: RequestContext): void {
ctx.reset(); // 반환 시에도 초기화 (이중 안전장치)
if (this.pool.length < this.maxSize) {
this.pool.push(ctx);
}
}
}
차이는 딱 한 줄이다: ctx.reset(). 하지만 이 한 줄이 보안 사고를 막는다.
더 안전한 패턴
사실 Object Cesspool 문제를 근본적으로 방지하는 방법은 불변 객체를 사용하는 거다:
// 불변 컨텍스트 — 오염 자체가 불가능
interface RequestContext {
readonly userId: string;
readonly role: string;
readonly permissions: readonly string[];
readonly requestId: string;
}
// 매번 새로 생성 (Object.freeze로 불변 보장)
function createContext(userId: string, role: string, permissions: string[]): RequestContext {
return Object.freeze({
userId,
role,
permissions: Object.freeze([...permissions]),
requestId: crypto.randomUUID(),
});
}
// 이전 컨텍스트의 데이터가 절대 다음에 영향을 줄 수 없음
객체 생성 비용이 충분히 낮다면 (대부분의 경우 그렇다), 풀링 대신 매번 새로 만드는 게 더 안전하다. "성능 최적화"랍시고 풀링을 도입했다가 보안 사고를 만드는 것보다, 약간의 GC 오버헤드를 감수하는 게 낫다.
객체 풀링이 정말 필요한 경우
- DB 커넥션: 생성에 수십~수백 ms. 풀링 필수
- 스레드: OS 레벨 자원. 풀링 권장
- 대형 버퍼: 메모리 할당/해제 비용이 큰 경우
- 위 경우가 아니면? 그냥 새로 만들어라. 현대 GC는 충분히 빠르다
추상화에 대한 건강한 마인드셋
세 가지 안티패턴을 종합하면, 추상화에 대한 건강한 태도는 이렇다:
새는 추상화 (Leaky Abstraction):
- 모든 추상화는 새다. 이건 결함이 아니라 본질이다
- 추상화 아래 레이어를 이해하라. 몰라도 되는 마법은 없다
- 추상화가 새는 지점을 팀 내에서 공유하라
추상화 역전 (Abstraction Inversion):
- 프레임워크가 저수준 접근을 막으면 escape hatch를 찾아라
- 고수준 API로 저수준 작업을 우회하지 말고, 추상화를 뚫는 게 나을 때가 있다
- 프레임워크/라이브러리 선택 시 "이것이 숨기는 것"을 확인하라
객체 오물 웅덩이 (Object Cesspool):
- 객체 풀에서 꺼낸 건 반드시 초기화하라
- 가능하면 불변 객체를 사용하라
- 풀링이 정말 필요한지 먼저 측정하라. 대부분은 필요 없다
그리고 가장 중요한 한 가지:
추상화의 수준을 고르는 건 기술적 결정이 아니라 설계 결정이다. 너무 얇으면 새고, 너무 두꺼우면 역전되고, 관리를 안 하면 오염된다. 추상화는 "한 번 만들고 끝"이 아니라 지속적으로 관리해야 하는 살아있는 구조다.