파이프라인으로 생각하기: Go 시스템을 구조화하는 더 나은 방법
I’m happy to translate the article for you, but I’ll need the full text of the post (the paragraphs you’d like translated). Could you please paste the article’s content here? I’ll keep the source link at the top exactly as you requested and translate the rest into Korean while preserving the original formatting.
최근 개인 프로젝트인 잡 스크래퍼를 작업하면서
백엔드 시스템을 Go로 구조화하는 방식에 진짜로 변화를 가져온 패턴을 발견했습니다.
그것은 Pipeline Pattern이며, 결제, 분석, API 등 다양한 곳에서 나타납니다.
이 글에서는 잡‑스크래퍼 프로젝트를 예시로 들어 이 패턴을 단계별로 설명하겠습니다.
우리가 피하고 싶은 혼란
패턴을 보여주기 전에, 패턴 없이 코드를 어떻게 작성할 수 있는지 살펴보겠습니다.
제 스크래퍼는 네 가지 일을 합니다.
- 여러 소스에서 잡 리스트를 스크랩한다
- 잡 데이터를 정규화한다(즉, 정리한다)
- 키워드 기반으로 점수를 매긴다(내 스킬셋에 가장 관련 있는 것이 높은 점수를 받는다)
- 데이터베이스에 저장한다
아무런 구조 없이 구현한다면 다음과 같이 될 수 있습니다(단순화).
for _, raw := range rawJobs {
// normalize
raw.Title = strings.TrimSpace(raw.Title)
raw.Location = strings.ReplaceAll(raw.Location, "NYC", "New York")
// score
score := 0
for _, keyword := range keywords {
if strings.Contains(raw.Title, keyword) {
score++
}
}
// save
s.Repo.Create(raw.Title, raw.Location, score)
}
기술적으로는 동작하지만 여러 문제가 생깁니다.
- 단계가 명확하지 않다. 정규화가 끝나는 시점과 점수 계산이 시작되는 시점이 어디인지? 전체 코드를 다 읽어야 이해할 수 있습니다.
- 테스트가 어렵다. 점수 로직만 따로 테스트하려면? 루프 안에 다른 로직이 얽혀 있어 불가능합니다.
- 변경이 어렵다. 새로운 점수 규칙을 추가하고 싶다면? 루프 안을 뒤져 정규화 로직까지 건드려 저장 로직을 깨뜨릴 수도 있습니다. 모든 것이 결합돼 있습니다.
- 재사용이 어렵다. 정규화 로직을 다른 곳에서도 쓰고 싶다면? 복사‑붙여넣기로 중복 코드를 만들게 됩니다.
- 동시 처리 구현이 힘들다. 잡을 병렬로 처리하고 싶다면? 루프가 뒤얽혀 있어 어떤 부분을 안전하게 병렬화할 수 있을지 알기 어렵습니다.
깨달음
위 루프를 보면 실제로는 하나의 문제라기보다 매 잡마다 반복되는 동일한 단계 집합이라는 것을 알 수 있습니다.
Scrape → Clean → Evaluate → Save
그래서 질문이 바뀝니다 – 그 단계들을 명시적으로 만들면 어떨까?
파이프라인
하나의 거대한 루프가 모든 일을 처리하는 대신, 작업을 명확한 단계로 나눕니다.
Scrape → Normalize → Score → Store
각 단계는 한 가지 일만 수행합니다. 데이터는 흐르면서 변환되고 다음 단계로 넘어갑니다.
아래는 제 스크래퍼에서 실제로 사용되는 파이프라인 구현입니다.
type Pipeline struct {
scorer scoring.Scorer
jobService JobService
companyService CompanyService
logger *slog.Logger
}
func NewPipeline(
scorer scoring.Scorer,
jobService JobService,
companyService CompanyService,
logger *slog.Logger,
) *Pipeline {
return &Pipeline{
scorer: scorer,
jobService: jobService,
companyService: companyService,
logger: logger,
}
}
Notice what’s happening in
NewPipeline. We’re not hard‑coding any specific scraper or store; we pass them in. We’ll see why that matters shortly.
파이프라인 실행
func (p *Pipeline) Run(ctx context.Context, scraper Scraper) error {
// 1. Scrape
rawJobs, err := scraper.Scrape(ctx)
if err != nil {
return fmt.Errorf("scraping %s: %w", scraper.Source(), err)
}
var saved, failed int
for _, rawJob := range rawJobs {
// 2. Normalize
normalizedJob, err := normalize.Normalize(rawJob)
if err != nil {
failed++
continue
}
// 3. Score
normalizedJob.Score = p.scorer.Score(normalizedJob)
// 4. Save
if err := p.jobService.Save(ctx, normalizedJob); err != nil {
failed++
continue
}
saved++
}
p.logger.Info("pipeline finished", "saved", saved, "failed", failed)
return nil
}
논리는 어지러운 버전과 동일하지만, 이제
Source: …
각 단계는 자체 함수에 존재합니다. Run 메서드를 위‑에서 아래로 읽어보면 시스템이 무엇을 하는지 즉시 파악할 수 있습니다: 스크래핑 → 정규화 → 점수 매기기 → 저장. 별도의 탐색이나 추측이 필요 없습니다. 구조 자체가 이야기를 전달합니다.
교체 가능성
각 단계가 별도로 존재하고 인터페이스를 통해 연결되기 때문에 파이프라인의 어느 부분이든 나머지를 건드리지 않고 교체할 수 있습니다.
스크래퍼 교체
// testing
scraper := &FakeScraper{}
// production
scraper := &RemotiveScraper{}
파이프라인 코드는 그대로 유지됩니다.
스토어 교체
// development
store := NewInMemoryStore()
// production
store := NewPostgresStore()
마찬가지로 파이프라인은 변함이 없습니다.
스코어러 교체
// simple keyword scorer
scorer := &KeywordScorer{keywords: []string{"Go", "backend", "remote"}}
// later, a smarter scorer
scorer := &MLScorer{}
파이프라인은 수정 없이 그대로 동작합니다.
왜 이것이 중요한가
- 관심사의 명확한 분리 덕분에 코드베이스를 읽고 이해하기가 쉬워집니다.
- 테스트 용이성이 크게 향상됩니다; 각 단계를 독립적으로 단위 테스트할 수 있습니다.
- 확장성은 새로운 인터페이스를 구현하고 연결하기만 하면 됩니다.
- 동시성이 간단해집니다 – 상태를 갖지 않는 단계(예: 정규화나 점수 매기기)를 병렬화해도 다른 단계에 레이스 컨디션 위험이 없습니다.
TL;DR
파이프라인 패턴은 얽힌 단일 루프를 깔끔하고 조합 가능한 단계 시리즈로 바꿔줍니다. 스크래핑, 정규화, 점수 매기기, 저장이라는 명시적인 경계를 정의함으로써 가독성, 테스트 용이성, 교체 가능성, 그리고 시스템 일부를 동시에 실행할 수 있는 능력을 얻습니다.
다음 Go 백엔드 프로젝트에서 한 번 시도해 보세요; 없었으면 어쩔 수 없었을지 모릅니다.
동시성 – 워커 풀
이제 시스템이 깔끔하고 유연해졌습니다. 하지만 성능은 어떨까요?
현재는 모든 작업이 한 번에 하나씩 실행됩니다: 하나를 스크랩하고, 정규화하고, 점수를 매기고, 저장한 다음 다음 작업으로 넘어갑니다. 작은 데이터셋에는 괜찮지만, 천 개의 작업을 처리하려면 느립니다.
레벨업
아이디어는 간단합니다: 한 번에 하나의 작업을 처리하는 대신, 워커 풀(고루틴) 을 띄워 작업을 동시에 처리하도록 합니다. 고루틴을 사용해 본 적이 없다면, Go에서 기본적으로 제공하는 가벼운 스레드라고 생각하면 됩니다. 큰 오버헤드 없이 많은 고루틴을 띄울 수 있습니다. 여기서 핵심 키워드는 많은 고루틴이지 무제한은 아니라는 점입니다. 자세한 내용은 곧 설명하겠습니다.
구현 예시는 다음과 같습니다:
func (p *Pipeline) Run(ctx context.Context, scraper Scraper, numWorkers int) error {
rawJobs, err := scraper.Scrape(ctx)
if err != nil {
return fmt.Errorf("scraping %s: %w", scraper.Source(), err)
}
jobs := make(chan RawJob)
var wg sync.WaitGroup
// spin up workers
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for raw := range jobs {
// normalize, score, save
}
}()
}
// feed jobs into the channel
for _, raw := range rawJobs {
jobs <- raw
}
close(jobs)
wg.Wait()
return nil
}
이를 큐와 팀에 비유해 보세요. 채널이 바로 큐 역할을 합니다: 원시 작업이 한쪽 끝에 들어가면 워커가 다른 쪽 끝에서 꺼내 처리합니다. 각 워커는 파이프라인 단계들을 독립적으로 실행합니다. sync.WaitGroup 은 모든 워커가 작업을 마칠 때까지 함수가 반환되지 않도록 보장합니다.
numWorkers 매개변수가 핵심입니다. 당신이 워커 수를 결정하며, Go 런타임이 자동으로 결정하지 않습니다. 이는 무제한 동시성이 실제 비용을 초래하기 때문입니다—천 개의 고루틴이 동시에 데이터베이스에 쓰기를 시도하면 도움이 되기보다 오히려 해가 됩니다. 보통은 3~10개의 워커를 제어된 범위 내에서 사용하는 것이 적절합니다.
파이프라인 자체는 개념적으로 변하지 않았습니다; 이제는 병렬로 실행될 뿐입니다.
파이프라인은 어디에나 있다
이 패턴을 내면화하면 어디서든 보이기 시작합니다.
- Payments: validate the card → charge it → save the transaction.
결제: 카드 검증 → 청구 → 거래 저장. - Analytics: collect the event → clean it → store it.
분석: 이벤트 수집 → 정제 → 저장. - APIs: receive the request → process it → send the response.
API: 요청 수신 → 처리 → 응답 전송.
다른 도메인, 같은 형태. 데이터가 들어와서 단계들을 거치며 변형되어 나옵니다. 이것이 파이프라인 패턴이며, 실제 작업에 잘 맞기 때문에 계속 등장합니다: 단계별로 명확한 인계가 이루어지는 방식입니다.
이를 인식하는 법을 배우면, 잡 스크래퍼뿐 아니라 무언가를 받아 일련의 작업을 수행하고 결과를 만들어내는 코드를 작성하는 모든 곳에 적용할 수 있습니다.
왜 귀찮아 할까?
이것들 없이도 시스템을 만들 수 있습니다. 대부분의 작동하는 소프트웨어는 모든 일을 한 번에 처리하는 큰 루프 하나로 이루어져 있으며, 작은 일회성 프로젝트라면 괜찮습니다.
하지만 시스템이 성장해야 할 순간, 그 큰 루프가 당신을 방해하기 시작합니다:
- 새로운 데이터 소스를 추가하고 싶은데, 스크래핑 로직이 정규화와 뒤섞여 있습니다.
- 점수 계산을 독립적으로 테스트하고 싶은데, 세 단계 아래에 파묻혀 있습니다.
- 인‑메모리 저장소를 실제 데이터베이스로 교체하고 싶은데, 잡을 수 있는 깔끔한 접점이 없습니다.
스크래퍼와 최근에 만든 다른 프로젝트들을 진행하면서 이런 문제들을 겪었습니다. 파이프라인 패턴을 사용하니 이러한 문제들을 관리할 수 있었습니다.
유지할 가치가 있는 네 가지 아이디어
- 단계로 나누기. 각 단계는 하나의 일을 수행합니다.
- 단계에 집중하기. 단계 이름을 짓기 어려우면, 아마도 너무 많은 일을 하고 있는 겁니다.
- 구성 요소 교체 가능하게 만들기. 구체적인 타입이 아니라 인터페이스를 통해 연결합니다.
- 동시성 제어하기. 무제한 goroutine 대신 워커 풀을 사용합니다.
파이프라인으로 사고하면 시스템을 이해하기 쉬워지고, 이는 무언가를 구축하거나 업데이트하는 중에 매우 중요합니다.
이게 바로 패턴입니다. 도움이 되길 바랍니다!