LanguageBuilding
주니어 개발자들을 위한 패턴 언어 - 문제 영역의 언어를 만들어 프로그래밍하는 방법
Contents
The Story: The Evolving Vocabulary
한 주니어 개발자가 전자상거래 시스템의 주문 처리 코드를 작성했다. 함수 이름은 processOrder()였고, 내부는 if-else로 가득했다.
시니어가 코드를 보며 물었다. "이 함수가 정확히 뭘 하지?"
"주문을 처리해요. 재고를 확인하고, 결제를 하고, 배송을 준비하고..."
"그런데," 시니어가 화면을 가리키며 말했다. "이 코드를 읽으면 그게 보이나? 아니면 if문과 for문만 보이나?"
주니어는 잠시 생각했다. "코드는... 복잡해 보이네요."
시니어가 다른 파일을 열었다:
order .validate_inventory() .process_payment() .prepare_shipment() .notify_customer()
"같은 기능이야. 하지만 이건 이야기처럼 읽혀. 주문이 무엇을 하는지가 바로 보여."
"하지만 이 메서드들은 어디서...?"
"우리가 만든 거야. 프로그래밍은 단순히 기존 함수를 호출하는 게 아니라, 새로운 단어를 만들어내는 것이기도 해. 문제 영역의 언어를 만드는 거지."
주니어의 눈이 커졌다. "언어를... 만든다고요?"
"그래. Lisp 프로그래머들이 오래전부터 해온 방식이야. Paul Graham이 말했지: '프로그램을 언어를 향해 작성하는 동시에 언어를 프로그램을 향해 만든다.' 좋은 프로그램은 문제를 표현하는 언어를 먼저 만들고, 그 언어로 해결책을 쓰는 거야."
Context
복잡한 문제 영역을 다루는 프로그램을 작성하고 있다. 코드가 길어질수록 이해하기 어려워지고, 새로운 기능을 추가하기가 점점 더 힘들어진다. 함수와 변수를 만들지만, 전체적으로 무엇을 하는지 점점 불명확해진다.
당신은 DataAsFoundation을 통해 데이터 구조의 중요성을 알고 있고, NamesAsDesign을 통해 좋은 이름의 가치를 인식하고 있다. 하지만 개별 함수와 데이터 구조를 넘어서, 문제 영역 전체를 표현하는 더 높은 수준의 무언가가 필요하다는 느낌을 받는다.
일상적인 상황:
- 코드가 "어떻게" 하는지만 보이고 "무엇을" 하는지 불명확하다
- 같은 개념을 여러 곳에서 다르게 표현하고 있다
- 도메인 전문가와 프로그래머 사이의 대화가 코드에 반영되지 않는다
- 새로운 팀원이 코드를 이해하는 데 오랜 시간이 걸린다
Problem
범용 언어의 원시 요소만으로는 문제 영역을 자연스럽게 표현하기 어렵다. Python, Java, JavaScript - 이들은 강력하고 범용적이다. 하지만 이 언어들은 모든 문제를 해결하도록 설계되었기에, 특정 문제를 표현하기에는 너무 일반적이다.
The Generic Code Problem
일반적인 프로그래밍 구조만 사용하면:
def process_data(items):
result = []
for item in items:
if item['status'] == 'pending' and item['date'] < today():
if item['priority'] > 5:
result.append({
'id': item['id'],
'name': item['name'],
'urgent': True
})
return result이 코드가 무엇을 하는가? 코드를 전부 읽어야만 안다.
문제: 비즈니스 로직이 구현 세부사항 속에 묻혀있다. for, if, append - 이것들은 프로그래밍 언어의 단어지, 문제 영역의 단어가 아니다.
The Vocabulary Gap
도메인 전문가가 이야기하는 방식:
"우리는 긴급한 초과 작업들을 먼저 처리해야 합니다."
코드가 표현하는 방식:
for item in items:
if item['status'] == 'pending' and item['date'] < today():
if item['priority'] > 5:
...간극이 보이는가? 대화에서 사용하는 단어들(긴급한, 초과, 작업)이 코드에 없다. 코드는 다른 언어로 쓰여있다.
이로 인해:
의사소통 비용 - 도메인 지식이 코드로 번역되는 과정에서 왜곡된다
이해 비용 - 새로운 개발자가 코드를 읽고 비즈니스 로직을 이해하기 어렵다
변경 비용 - 요구사항이 바뀌면 코드 전체를 뒤져야 한다
일관성 부족 - 같은 개념을 여러 방식으로 표현한다
The Language Construction Barrier
"새로운 언어를 만든다고? 그건 너무 큰 일 아닌가?"
이것이 많은 프로그래머들이 가진 오해다. 언어를 만드는 것은:
- 컴파일러를 작성하는 것이 아니다
- 새로운 문법을 만드는 것이 아니다
- 거창한 프레임워크를 구축하는 것이 아니다
언어를 만드는 것은 단순히 문제 영역의 개념에 이름을 붙이고, 그것들을 조합할 수 있게 만드는 것이다.
Solution
프로그래밍을 "문제를 표현하는 언어를 만드는 과정"으로 접근하라. Lisp 프로그래머들이 수십 년간 해온 방식이다: 먼저 문제를 잘 표현할 수 있는 언어를 만들고, 그 언어로 해결책을 작성한다.
Principle 1: Programming as Theory Building
Peter Naur는 1985년 논문 "Programming as Theory Building"에서 핵심을 짚었다: 프로그래밍은 단순히 코드를 작성하는 것이 아니라, 문제 영역에 대한 이론(theory)을 구축하는 것이다.
이 이론은:
- 문제 영역의 개념들을 이해하는 것
- 개념들 간의 관계를 파악하는 것
- 그것을 표현할 수 있는 어휘를 만드는 것
코드는 단지 이 이론의 구현일 뿐이다. 진짜 가치는 이론에 있다.
Principle 2: Bottom-Up Language Construction
Lisp 전통에서 배우는 것: 아래에서 위로 언어를 만들어 올라간다.
# Level 0: 원시 연산
items.filter(...).map(...)
# Level 1: 도메인 개념
def is_overdue(task):
return task.status == 'pending' and task.date < today()
def is_urgent(task):
return task.priority > 5
# Level 2: 도메인 언어
def urgent_overdue_tasks(tasks):
return tasks.filter(is_overdue).filter(is_urgent)
# Level 3: 문제 해결
priority_list = urgent_overdue_tasks(all_tasks)각 레벨은 이전 레벨 위에 구축된다. 점진적으로 문제 영역의 추상화를 만들어간다.
Paul Graham이 표현했듯: "좋은 Lisp 프로그래머는 프로그램을 언어를 향해 작성하는 동시에 언어를 프로그램을 향해 만들어간다."
Principle 3: Domain Vocabulary as Functions
문제 영역의 단어를 함수로 만들어라:
Before: 일반적 프로그래밍 용어
def process_data(items):
result = []
for item in items:
if item['status'] == 'pending' and item['date'] < today():
result.append(item)
return resultAfter: 도메인 어휘
def overdue_tasks(tasks):
return tasks.that_are(pending).and_past_due()
# 또는 더 간단히
def overdue_tasks(tasks):
return [t for t in tasks if t.is_pending() and t.is_past_due()]차이점: 두 번째는 도메인 전문가가 이해할 수 있는 단어로 쓰여있다.
Principle 4: Fluent Interfaces (Method Chaining)
연결 가능한 메서드로 문장처럼 읽히는 코드를 만들어라:
# 명령형 스타일
order = Order()
order.set_customer(customer)
order.add_item("Book", 2)
order.set_shipping("Express")
total = order.calculate_total()
# Fluent 인터페이스 - 문장처럼
total = Order()
.for_customer(customer)
.add_item("Book", quantity=2)
.with_shipping("Express")
.calculate_total()두 번째는 읽는 것만으로 무엇을 하는지 알 수 있다.
Principle 5: Two Languages - Problem and Solution
TwoWorlds를 기억하라. 두 종류의 언어가 필요하다:
문제 공간의 언어 - 도메인 개념
order.validate_inventory() order.process_payment() order.prepare_shipment()
해결 공간의 언어 - 기술적 추상화
repository.save(order) cache.invalidate(order.id) queue.publish(OrderCreatedEvent(order))
둘 다 필요하지만, 명확히 분리되어야 한다.
Principle 6: Representation as Language
DataAsFoundation에서 보았듯이, 데이터 구조도 언어다.
Peter Norvig와 Paul Graham이 강조한 것: 표현 언어(representation language)의 선택이 문제 해결의 절반이다.
같은 개념, 다른 표현:
# XML - 계층적이고 명시적
<order>
<customer>John</customer>
<items>
<item quantity="2">Book</item>
</items>
</order>
# S-expression - 간결하고 프로그래밍 가능
(order
(customer "John")
(items (item "Book" :quantity 2)))
# Method chain DSL - 유창한 인터페이스
order()
.for_customer("John")
.add_item("Book", quantity=2)
# JSON - 데이터 교환
{
"customer": "John",
"items": [{"name": "Book", "quantity": 2}]
}각각은 서로 다른 언어다. 어떤 언어로 문제를 표현하느냐가 해결의 명확성을 결정한다.
Principle 7: Grow the Language Organically
처음부터 완벽한 언어를 만들려 하지 마라. OrganicGrowth 방식:
1단계: 작동하는 코드 먼저
# 먼저 직접 작성
if task.status == 'pending' and task.date < today():
process(task)2단계: 패턴을 발견하면 이름 붙이기
# 같은 패턴이 여러 번 보이면
def is_overdue(task):
return task.status == 'pending' and task.date < today()
if is_overdue(task):
process(task)3단계: 조합 가능하게 만들기
# 다른 개념과 조합 overdue_and_urgent = tasks.filter(is_overdue).filter(is_urgent)
4단계: 언어로 진화
# 이제 이것은 "언어"다
priority_tasks = tasks
.that_are(overdue)
.and_are(urgent)
.sorted_by(priority)
.take(10)언어는 사용하면서 발견된다. 미리 설계하지 마라.
Real Examples
Example 1: Test DSL - RSpec
Before: 일반적인 테스트
def test_user_authentication():
user = User("john@example.com", "password123")
assert user.authenticate("password123") == True
assert user.authenticate("wrong") == FalseAfter: RSpec의 언어
describe User do
it "authenticates with correct password" do
user = User.new("john@example.com", "password123")
expect(user.authenticate("password123")).to be_true
end
it "rejects incorrect password" do
user = User.new("john@example.com", "password123")
expect(user.authenticate("wrong")).to be_false
end
endRSpec은 테스트를 명세(specification)로 읽히게 만들었다: "User는... correct password로 인증한다."
이것은 새로운 언어다. 테스트가 아니라 명세를 쓰는 언어.
Example 2: Build DSL - Rake vs Make
Makefile - Shell 명령 나열
output.txt: input.txt
cat input.txt | sort | uniq > output.txtRakefile - Ruby의 풍부한 언어
file "output.txt" => "input.txt" do
lines = File.readlines("input.txt")
unique_lines = lines.sort.uniq
File.write("output.txt", unique_lines.join)
endRake는 빌드를 Ruby 언어로 표현할 수 있게 했다. 프로그래밍 언어의 모든 힘을 빌드 스크립트에 가져왔다.
Example 3: Rails - Convention over Configuration
Rails는 웹 개발을 위한 언어를 만들었다:
class User < ApplicationRecord has_many :posts validates :email, presence: true, uniqueness: true end
이 짧은 코드가 표현하는 것:
- User는 데이터베이스 테이블이다
- User는 여러 Post를 가진다
- email은 필수이고 유일해야 한다
- 자동으로 finder 메서드들이 생긴다
- 자동으로 관계 메서드들이 생긴다
선언적 언어로 의도를 표현하면, 프레임워크가 "어떻게"를 처리한다.
Example 4: Query Languages - 관점의 차이
같은 쿼리, 다른 언어:
# SQL - 테이블과 조인의 언어
SELECT users.name, COUNT(posts.id) as post_count
FROM users
LEFT JOIN posts ON posts.user_id = users.id
GROUP BY users.id
HAVING post_count > 10
# ActiveRecord - 객체의 언어
User.joins(:posts)
.group(:id)
.having('COUNT(posts.id) > 10')
.select('users.name, COUNT(posts.id) as post_count')
# 더 나아가 - 도메인 언어
User.prolific_writers # has_many :posts, -> { where('posts.count > 10') }각 언어는 다른 사고 방식을 표현한다.
Example 5: 실제 경험 - Invoice Generator
실제 프로젝트에서의 진화:
Version 1: 절차적 코드
def generate_invoice(data):
pdf = PDF()
pdf.add_text(data['customer_name'])
pdf.add_text(data['date'])
for item in data['items']:
pdf.add_row(item['name'], item['price'])
total = sum(item['price'] for item in data['items'])
pdf.add_text(f"Total: {total}")
return pdfVersion 2: 송장의 언어
invoice = Invoice()
.for_customer(customer)
.dated(today())
.add_line_item(description="Consulting", amount=1000)
.add_line_item(description="Development", amount=2000)
.with_payment_terms("Net 30")
.with_notes("Thank you for your business")
.generate_pdf()차이점:
- 도메인 전문가(회계사)가 읽고 이해할 수 있다
- 의도가 코드에서 즉시 보인다
- 새로운 기능 추가가 자연스럽다
- 테스트하기 쉽다
결과: 코드가 송장을 만드는 언어가 되었다.
Example 6: Configuration as DSL
설정도 언어가 될 수 있다:
Before: 중첩된 딕셔너리
config = {
'database': {
'host': 'localhost',
'port': 5432,
'name': 'mydb'
},
'cache': {
'type': 'redis',
'ttl': 3600
}
}After: Fluent Configuration
config = Configuration()
.database(host='localhost', port=5432, name='mydb')
.cache(type='redis', ttl=3600)
.logging(level='INFO', output='file')설정이 문장처럼 읽힌다.
The Pattern in Practice
Start Small: Single Function
언어 구축은 단일 함수에서 시작한다:
# 패턴 발견
if user.role == 'admin' or user.id == resource.owner_id:
allow_access()
# 이름 붙이기
def can_access(user, resource):
return user.role == 'admin' or user.id == resource.owner_id
# 사용
if can_access(user, resource):
allow_access()작은 시작이지만, 이미 도메인 언어가 생기기 시작했다.
Compose: Build on Building Blocks
작은 조각들을 조합한다:
def can_edit(user, post):
return can_access(user, post) and not post.is_locked
def can_delete(user, post):
return user.role == 'admin' or user.id == post.author_id
# 조합
if can_edit(current_user, post):
show_edit_button()각 함수는 단어다. 조합하면 문장이 된다.
Abstract: Create Higher-Level Concepts
패턴이 보이면 추상화한다:
class Permission:
def __init__(self, user, resource):
self.user = user
self.resource = resource
def can_read(self):
return True # Everyone can read
def can_edit(self):
return self.can_access() and not self.resource.is_locked
def can_delete(self):
return self.user.is_admin() or self.user.owns(self.resource)
# 사용
permission = Permission(current_user, post)
if permission.can_edit():
show_edit_button()이제 권한의 언어가 생겼다.
Evolve: Let the Language Grow
사용하면서 언어를 발전시킨다:
# 새로운 요구사항: 시간 기반 권한
class Permission:
def can_edit(self):
return (self.can_access()
and not self.resource.is_locked
and self.within_edit_window())
def within_edit_window(self):
return datetime.now() < self.resource.created_at + timedelta(hours=24)언어가 살아있고 진화한다.
Common Pitfalls
"Let's Build a Framework First!"
언어를 만든다는 말을 듣고, 거대한 프레임워크를 구축하려 한다.
문제: 문제를 충분히 이해하기 전에 추상화를 만든다. 결과는 사용되지 않는 복잡한 코드.
해결: WorkingFirst - 먼저 작동하는 코드를 만들고, 패턴이 보일 때 추상화하라. 3번 반복되면 추상화하라(Rule of Three).
"Every Concept Needs a Class!"
모든 도메인 개념을 클래스로 만든다.
class OverdueChecker:
def check(self, task):
return task.status == 'pending' and task.date < today()
class UrgentChecker:
def check(self, task):
return task.priority > 5문제: 과도한 추상화. 간단한 것을 복잡하게 만든다.
해결: 간단한 함수로 충분하다:
def is_overdue(task):
return task.status == 'pending' and task.date < today()
def is_urgent(task):
return task.priority > 5규칙: 함수로 시작하라. 상태가 필요하거나 여러 관련 함수가 생기면 클래스로 만들어라.
"DSL for Everything!"
모든 것에 DSL을 만들려 한다.
문제: 학습 곡선과 유지보수 부담. 팀원들이 프로젝트 고유 언어를 배워야 한다.
해결: The 95% Rule - 완벽한 DSL보다 95%의 경우를 커버하는 간단한 추상화가 낫다.
# ❌ 과도한 DSL
query().select('users').where(field('age').gt(18)).and(field('active').eq(true))
# ✅ 간단한 추상화
users.filter(age__gt=18, active=True)
"Premature Linguistic Abstraction"
한두 번 본 패턴을 즉시 추상화한다.
문제: 실제 패턴인지 확신할 수 없다. 나중에 변경이 어렵다.
해결: Rule of Three - 같은 패턴이 3번 나타날 때 추상화하라. 그 전까지는 복사를 허용하라.
"Ignoring the Solution Space"
문제 공간의 언어만 만들고 기술적 추상화를 무시한다.
문제: 성능, 보안, 확장성 같은 기술적 관심사가 누락된다.
해결: TwoWorlds - 문제 공간과 해결 공간, 두 언어 모두 필요하다. 명확히 분리하되, 둘 다 만들어라.
Connection to Other Patterns
DataAsFoundation - 데이터 구조가 언어의 명사(nouns)다. 좋은 데이터 구조는 좋은 언어의 시작이다. 기반
NamesAsDesign - 좋은 이름이 어휘(vocabulary)를 만든다. 언어 구축은 본질적으로 네이밍이다. 핵심 기술
TwoWorlds - 문제 공간과 해결 공간을 위한 각각의 언어가 필요하다. 명확한 분리
LivingVocabulary - 언어는 살아있고 진화한다. 사용하면서 계속 개선된다. 자연스러운 발전
PatternHunting - 언어는 패턴의 인식과 명명에서 나온다. 패턴을 보면 단어가 된다. 발견의 도구
WorkingFirst - 추상화 전에 먼저 작동하게 만들어라. 언어는 필요에서 자라난다. 순서
OrganicGrowth - 언어는 한 번에 만들어지지 않고 점진적으로 성장한다. 진화적 접근
ComplexityTaming - 좋은 언어는 복잡성을 관리하는 강력한 도구다. 복잡성 해결
TechnicalCommunity - 공유된 언어는 팀 협업의 기초다. 팀의 공통어
Signs of Success
LanguageBuilding을 효과적으로 사용하고 있다는 신호:
코드가 읽힌다 - 코드를 읽으면 이야기처럼 흘러간다. "무엇을 하는지"가 즉시 보인다.
도메인 대화가 코드에 반영된다 - 도메인 전문가와의 대화에서 나온 단어들이 코드에 그대로 있다.
일관된 어휘 - 같은 개념을 항상 같은 단어로 표현한다. 코드베이스 전체에 일관성이 있다.
자연스러운 확장 - 새로운 기능을 추가할 때 언어를 확장하는 형태로 자연스럽게 추가된다.
자체 문서화 - 주석이 거의 필요없다. 코드 자체가 무엇을 하는지 말해준다.
빠른 온보딩 - 새로운 팀원이 도메인 언어를 배우면 코드를 빠르게 이해한다.
재사용 가능한 조각 - 작은 함수들을 조합해서 새로운 기능을 만들 수 있다.
For Teachers and Mentors
주니어에게 언어 구축을 가르칠 때:
패턴을 가리켜라: "이 코드 블록이 여러 곳에서 반복되지? 이것에 이름을 붙이면 어떨까?"
질문하라:
- "이 코드가 무엇을 하는지 한 문장으로 설명할 수 있어?"
- "그 문장이 코드에 보이나?"
- "도메인 전문가가 이 코드를 읽고 이해할 수 있을까?"
리팩토링을 보여줘라:
# Before
for task in tasks:
if task.status == 'pending' and task.date < today():
process(task)
# After
for task in overdue_tasks(tasks):
process(task)
# 더 나아가
overdue_tasks(tasks).each(process)Lisp 전통을 소개하라:
- Paul Graham의 "On Lisp"에서 bottom-up 프로그래밍
- SICP에서 언어 중심 프로그래밍
- Domain-Specific Languages (Martin Fowler)
작게 시작하게 하라: "거대한 프레임워크 말고, 하나의 함수에 이름을 붙이는 것부터 시작해보자."
The Ultimate Insight
Alan Perlis가 말했다: 언어는 생각을 형성한다(Language shapes thought).
Peter Naur가 말했다: 프로그래밍은 프로그래머의 마음속에 이론을 구축하는 것이다.
Paul Graham이 말했다: 좋은 프로그래머는 프로그램을 언어를 향해 작성하는 동시에 언어를 프로그램을 향해 만든다.
이 모든 지혜가 같은 진리를 가리킨다:
프로그래밍 = 언어 만들기 + 그 언어로 표현하기
범용 프로그래밍 언어는 재료다. Python, Java, JavaScript - 이들은 도구다. 하지만 진짜 예술은 이 재료로 문제 영역의 언어를 만드는 것이다.
좋은 프로그램은:
- 도메인 개념을 명확한 단어로 표현한다
- 그 단어들을 조합해서 의미 있는 문장을 만든다
- 문장들이 모여 이야기가 된다
- 이야기를 읽으면 문제와 해결책이 보인다
언어를 만들어라. 문제를 잘 표현하는 언어를 만들면, 해결책은 자연스럽게 따라온다. 그리고 그 언어는 팀의 공통어가 되고, 코드는 살아있는 문서가 되며, 프로그래밍은 예술이 된다.
이것이 LanguageBuilding이다. 단순히 코드를 작성하는 것이 아니라, 문제를 표현할 언어를 만들고, 그 언어로 해결책을 말하는 것이다.
CategoryPatternLanguage CategoryProgramming CategoryDesign CategoryLanguage CategoryDSL
