Savior: 저수준 설계

발행: (2026년 2월 13일 오후 04:04 GMT+9)
15 분 소요
원문: Dev.to

Source: Dev.to

번역할 텍스트를 제공해 주시면 한국어로 번역해 드리겠습니다.

소개

저는 면접 준비와 문제 해결 능력을 갈고닦기 위해 다시 설계 단계로 돌아갔습니다. 소프트웨어 개발은 현재 이상한 단계에 있습니다. 2주 전, 친구가 저수준 설계를 하는 모습을 보았을 때, 지금 같은 시점에서는 의미가 없다고 생각했었습니다. 얼마나 큰 오판이었는지요.

친구는 저에게 문제 해결과 비판적 사고에 대해 질문하기 시작했고, 저는 요즘 추상화에만 집중하고 새 직장에서 절대 쓰지 않을 도구들을 배우느라 제 능력을 죽이고 있다는 것을 깨달았습니다. 그래서 몇 가지 조사를 해보니, 꽤 많은 개발자들이 IDE의 인라인 제안과 AI 도구가 문제 해결 능력을 해친다고 불평하고 있었습니다.

NeetCode의 현재 면접 스타일에 관한 영상을 시청하면서, 큰 변화가 없다는 것을 알게 되었습니다—문제 해결 능력이 여전히 가장 강력한 스킬 세트라는 것이죠. 그래서 저는 새로운 저장소 grinding‑go를 만들었습니다.

친구와 저는 서로에게 저수준 설계와 시스템 설계를 면접하듯 진행하고 있습니다. 그 과정에서 제 비판적 사고가 약해진 것을 느꼈습니다. 첫 면접을 할 때 아이디어가 반쯤 형성돼 있었기 때문에 중얼거리며 자세히 설명하지 못했습니다. 그래서 저는 코딩 실력의 이 단계에 대한 전략을 세웠습니다:

  1. 저수준 설계를 bare‑handed로 진행한다.
  2. LLM에게 설계와 관련된 기사들을 찾아 달라고 요청한다.
  3. LLM이 소크라테식 후속 질문을 통해 제 문제 해결 능력을 날카롭게 만든다.

이렇게 저는 추상화된 문제와 큰 단어 알림에만 집중된 저수준 설계로 다시 돌아갔습니다. 또한 알고리즘과 컴퓨팅 사고는 절대 사라지지 않으며, 대규모 코드베이스의 추상화 아래에 계속 존재한다는 것을 다시 한 번 깨달았습니다. 이를 유지하지 않으면 크게 고생하게 될 것입니다.

저는 Go의 단순함과 속도를 사랑하기 때문에 grinding‑go 프로젝트를 Go로 만들었습니다. 이 저장소와 함께 제공되는 웹사이트를 통해 100 Go Mistakes 요약과 코드 스니펫을 읽기 시작했습니다. 그 책을 읽고 나서는 Go를 다른 언어처럼 다루지 않게 되었는데, 특히 오류 처리와 동시성 부분에서 Go만의 스타일이 있다는 것을 확실히 느꼈습니다.

Core Structure – LRU Cache

코어 구조에서는 LRUCache 구조체를 사용했으며, 여기에는 연결 리스트와 스레드‑안전 및 동시성을 위한 뮤텍스가 포함됩니다:

type LRUCache struct {
    mu       sync.RWMutex
    capacity int
    cache    map[int]*Node
    list     *LinkedList
}

주된 목적은 맵을 이용해 빠르게 조회하고, 가장 오래된 사용 항목의 위치를 바꿀 때는 연결 리스트를 사용해 O(n) 연산을 피하는 것입니다. LRU 캐시에 새 항목을 추가하는 과정은 다음과 같습니다:

if len(lru.cache) > lru.capacity {
    tailKey, err := lru.list.removeTail()
    // ...
}

용량을 초과하면 연결 리스트의 꼬리를 제거하는데, 이는 O(1) 연산입니다.

Cleaning Up Removed Nodes

이 문제를 풀면서 나는 제거된 요소의 포인터를 연결 리스트에서 nil 로 설정하는 것을 놓쳤습니다. Go에서는 C/C++처럼 “dangling pointer”가 되지는 않지만, 제거된 노드가 다른 노드에 대한 참조를 유지하고 있으면 가비지 컬렉터가 메모리를 해제하지 못하게 됩니다. 또한 오래된 참조를 실수로 탐색하면 논리적 버그가 발생할 수 있습니다:

// clear dangling pointers
n.prev = nil
n.next = nil

Lessons Learned

  • 연결 리스트 요소의 이웃을 다루는 것은 오류가 발생하기 쉬우며, 나는 참조를 잃어버렸습니다.
  • 모든 코드를 하나의 파일에 작성했는데, 이는 먼저 단일 파일로 문제를 해결하고 나중에 리팩터링한다는 ThePrimeAgen의 접근법을 떠올리게 했습니다.
  • LRU 캐시는 실제 서비스에서 어디든 존재합니다. 사람들이 이런 문제를 푸는 것이 “쓸모없다”고 말하지만, 실제 프로덕션과 대규모 코드베이스에서는 동일한 패턴과 자료구조가 강력한 추상화와 함께 사용됩니다. 저수준 세부 사항을 모르면 더 어려워질 것입니다.

Rate Limiter – 토큰 버킷

문제를 시작했을 때 처음에는 슬라이딩‑윈도우 기법을 생각했지만, 이미 다른 프로젝트에서 사용해 본 경험이 있었고 Token Bucketing을 시도해 보고 싶었습니다. 영상을 보고 RateLimiter 구조체에 TokenBucket을 조합하는 코드를 구현했습니다:

type RateLimiter struct {
    buckets   map[string]*TokenBucket // maps a user/key to its bucket
    mu        sync.Mutex               // protects concurrent map access
    rate      float64                  // default rate for new buckets
    maxTokens float64                  // default burst size for new buckets
}

TokenBucket 구조체도 자체 뮤텍스를 가지고 있는데, 이는 동시에 여러 작업을 처리해야 하기 때문입니다. Allow 메서드는 특정 시간 구간 동안 클라이언트가 보낼 수 있는 요청 수를 제어합니다:

func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()

    now := time.Now()
    tb.lastSeen = now
    newTokens := now.Sub(tb.lastRefill).Seconds() * tb.refillRate
    tb.tokens += newTokens
    if tb.tokens > tb.maxTokens {
        tb.tokens = tb.maxTokens
    }
    tb.lastRefill = now

    if tb.tokens >= 1 {
        tb.tokens--
        return true
    }
    return false
}

이를 구현하고 나니 만족했지만, 소크라테스식 질문을 통해 유휴 버킷 정리가 빠져 있음을 알게 되었습니다. 수백만 개의 고유 키가 제한기에 도달하면, 해당 버킷이 메모리에 영원히 남게 됩니다. 그래서 일정 주기로 오래된 엔트리를 삭제하는 cleanUpLoop를 추가했습니다:

func (rl *RateLimiter) cleanUpLoop(interval time.Duration, ttl time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for range ticker.C {
        rl.mu.Lock()
        for key, bucket := range rl.buckets {
            if time.Since(bucket.lastSeen) > ttl {
                delete(rl.buckets, key)
            }
        }
        rl.mu.Unlock()
    }
}

Rate‑Limiter 정리 루프 (대안 구현)

func (rl *RateLimiter) cleanUpLoop(interval, maxIdle time.Duration) {
    ticker := time.NewTicker(interval)

    for range ticker.C {
        rl.mu.Lock()
        for key, tb := range rl.buckets {
            if time.Since(tb.lastSeen) > maxIdle {
                delete(rl.buckets, key)
            }
        }
        rl.mu.Unlock()
    }
}

Token Bucket vs. Sliding Window

FeatureToken BucketSliding Window
Refill토큰이 고정된 비율로 채워지고, 각 요청은 하나의 토큰을 소모합니다.요청이 이동하는 시간 윈도우 안에서 카운트됩니다.
Burst handling버킷 크기만큼 버스트를 허용 – 결제 API나 웹훅처럼 급격한 트래픽에 유용합니다.보다 부드럽고 엄격한 속도 제어를 제공; 큰 버스트는 불가능합니다.
Memory usage낮음 – 카운터와 타임스탬프만 저장합니다.높음 – 서브‑윈도우별 타임스탬프 또는 카운터를 저장해야 합니다.

시스템 설계 인터뷰에서의 Pub/Sub

아마도 알고 계시겠지만, Pub/Sub는 마이크로서비스 아키텍처를 논의할 때 자주 등장하는 주제입니다. 비동기적 특성과 서비스 간 결합을 해제하는 능력 덕분에 무거운 REST‑API 워크로드를 처리하면서도 높은 성능을 유지할 수 있는 견고한 선택이 됩니다.

저의 개인 학습 전략은 먼저 순진한 구현을 작성하고, 그 후에 조사하고 구축하면서 다듬는 것입니다. 저는 Hussein Nasser의 해당 주제에 대한 영상을 매우 유용하게 보았으며, 도구보다 개념을 선호하는 모든 분께 강력히 추천합니다.

핵심 타입

핵심 구조체는 네 개입니다:

구조체역할
Topic관련된 메시지를 그룹화합니다.
Subscriber메시지를 수신합니다.
Broker구독자들이 어떤 토픽에 관심이 있는지 알고, 메시지를 해당 구독자에게 라우팅합니다.
Message실제 데이터를 보유합니다.

Broker는 중개자 역할을 하며, 그 메서드들은 Topic 메서드에 위임하고 오류를 그대로 반환합니다.

구독 취소 구현

제가 처음 Unsubscribe를 작성했을 때는 전체 작업 동안 브로커의 뮤텍스를 잡고 있었는데, 이는 토픽 조회에만 잠금이 필요하기 때문에 불필요했습니다. 토픽 수준의 작업을 수행하는 동안 잠금을 유지하면 다른 모든 브로커 메서드가 차단됩니다.

수정된 버전:

func (b *Broker) Unsubscribe(topicName string, sub *Subscriber) error {
    b.mu.Lock()
    t, ok := b.topics[topicName]
    b.mu.Unlock() // 토픽을 다루기 전에 브로커 잠금을 해제

    if !ok {
        return ErrTopicNotFound
    }
    return t.RemoveSubscriber(sub.id)
}

Broadcast – 클로저 버그 방지

Broadcast 메서드에서는 구독자를 고루틴에 매개변수로 전달하도록 했습니다. 이렇게 하지 않으면 루프 변수 sub가 잘못 캡처되어 모든 고루틴이 마지막 구독자를 참조하게 될 수 있습니다.

Go 1.22부터는 루프 변수가 반복마다 스코프가 분리되어 이 특정 버그가 사라졌지만, 변수를 명시적으로 전달하는 것이 가독성과 이전 버전과의 호환성을 위해 여전히 좋은 습관입니다.

for _, sub := range t.subscribers {
    // Pass sub as a parameter to avoid closure capture issues
    go func(s *Subscriber) {
        select {
        case s.ch <- msg:
        default:
        }
    }(sub)
}

이와 관련된 자세한 내용은 공식 Go 블로그의 루프 변수 스코핑에 관한 게시물을 참고하세요.

Personal Reflections

  • 저는 제 프로젝트에 PR을 열고 친구에게 리뷰를 부탁했습니다. 그의 피드백은 도구는 tools일 뿐이라는 것을 다시 일깨워 주었습니다. 초점은 화려한 용어가 아니라 아키텍처에 두어야 합니다.
  • 저는 이 시리즈를 계속 진행할 계획입니다: 매 3개의 로우‑레벨‑디자인 글을 쓴 뒤에, 저의 순수한 해결책을 공유하고 트레이드‑오프를 논의할 것입니다.
  • 친구와 저는 구직 활동으로 바쁘지만, 동시에 verdis라는 프로젝트를 진행하고 있습니다. 이전 블로그 글에서 첫 번째 팟캐스트(냉장고 카메라로 녹음)를 언급했었는데, 두 번째 에피소드는 이미 훨씬 나아졌습니다!
  • 저는 제 “공식”에 새로운 기법을 추가했습니다: open‑source contribution + low‑level‑design problem solving. 제 솔루션에서 보이는 점이 있으면 자유롭게 개선안을 제시해 주세요. 흥미로운 기술 과제가 있다면 grinding‑go 저장소에 이슈를 만들어 주세요.
0 조회
Back to Blog

관련 글

더 보기 »

TIL: 템플릿 엄격 로컬 (TSL)

Template Strict Locals TSL 며칠 전 나는 Chris Oliver의 “Powerful Rails Features You Might Not Know” 발표를 검토하고 있었다. 많은 유용한 팁 중에 d...

Zig에서 배운 교훈

Zig 프로그래밍 언어는 의도적으로 작은 표준 라이브러리를 유지합니다. 엄격한 포함 기준을 충족하지 못하는 구성 요소는 제거되고 재배치됩니다.