
확장 불가능한 코드로 고생하고 계신가요?
어느 날 갑자기 서비스에 새로운 연산 로직을 추가해야 하는 상황이 왔어요. 기존에 작성해 둔 switch-case 문 아래에 새로운 조건을 하나 더 붙이는 건 식은 죽 먹기처럼 느껴져요. 하지만 이 작업이 반복되다 보면 어느 순간 코드는 거대한 스파게티처럼 엉키기 시작해요. 연산자가 하나 추가될 때마다 기존 코드를 건드려야 하고, 그 과정에서 엉뚱한 곳에 버그가 생겨 서비스가 멈추는 아찔한 경험을 해본 적이 있으신가요?
많은 입문 개발자가 직면하는 이 문제는 단순히 코드가 길어지는 문제가 아니에요. 바로 연산자 확장성이 결여되었기 때문에 발생하는 구조적인 결함이에요. 로직이 복잡해질수록 수정은 두려워지고, 테스트는 불가능에 가까워지며, 결국 새로운 기능을 넣는 것조차 부담스러운 상황에 놓이게 돼요.
우리가 목표로 해야 하는 것은 새로운 연산자가 추가되더라도 기존의 핵심 로직을 단 한 줄도 수정하지 않아도 되는 설계예요. 이를 위해 Go 아키텍처의 강점을 최대한 활용하는 방법을 익혀야 해요. Go는 인터페이스와 강력한 타입 시스템을 갖추고 있어서, 올바른 방향으로만 설계한다면 놀라울 정도로 유연한 구조를 만들 수 있어요.
이 글을 읽고 나면 여러분은 다음과 같은 능력을 갖추게 될 거예요.
- 확장성을 고려한 연산자 설계의 핵심 원칙 이해하기
- 인터페이스를 활용하여 결합도를 낮추는 구체적인 방법 습득하기
- 실제 프로덕션 환경에서 바로 사용할 수 있는 연산자 패턴 익히기
- 코드 수정 없이 기능을 추가하는 유연한 아키텍처 구축하기
설계에 앞서 반드시 알아야 할 기초 지식
본격적으로 코드를 작성하기 전에, 우리가 어떤 도구를 사용하여 확장성을 확보할 것인지 명확히 정해야 해요. 무턱대고 코드를 짜기 시작하면 나중에 설계 자체를 뒤엎어야 하는 상황이 올 수 있거든요. Golang에서 연산자 로직을 확장할 때 가장 많이 고민하는 세 가지 접근 방식을 비교해 드릴게요.
확장성이란 새로운 요구사항이 들어왔을 때, 기존의 검증된 코드를 수정하지 않고도 새로운 기능을 안전하게 덧붙일 수 있는 능력을 의미해요. 이를 ‘개방-폐쇄 원칙(OCP)’이라고도 불러요.
우선 각 방식의 장단점을 파악하는 것이 중요해요. 어떤 방식이 여러분의 프로젝트 규모에 적합할지 아래 표를 통해 판단해 보세요.
| 설계 방식 | 특징 | 장점 | 단점 |
|---|---|---|---|
| 하드코딩 방식 | Switch-Case 문 사용 | 구현이 매우 빠르고 단순함 | 기능 추가 시 기존 코드 수정 필수 |
| 인터페이스 방식 | Interface 정의 후 구현 | 새 연산자 추가가 매우 자유로움 | 초기 설계 단계의 복잡도 증가 |
| 함수 맵 방식 | Map에 함수 등록 | 런타임에 동적 추가 가능 | 타입 안정성 관리가 까다로움 |
초보자라면 처음에는 하드코딩 방식의 편리함에 유혹되기 쉬워요. 하지만 서비스가 커질 것을 대비한다면 인터페이스 기반 설계를 기본값으로 잡는 것을 강력하게 추천해요. 인터페이스를 사용하면 연산 로직을 하나의 독립된 객체로 취급할 수 있어서, 각 연산자가 서로의 내부 구현을 몰라도 되기 때문이에요.
또한, 연산자를 설계할 때 고려해야 할 세 가지 체크리스트가 있어요. 첫째, 연산의 결과값이 항상 일관된 형식을 반환하는가? 둘째, 연산 도중 발생할 수 있는 오류를 어떻게 전달할 것인가? 셋째, 새로운 연산자가 추가될 때 기존의 테스트 코드를 깨뜨리지 않는가? 이 질문들에 대해 스스로 답할 수 있을 때 비로소 확장 가능한 설계를 시작할 준비가 된 것이에요.
실전! 확장 가능한 연산자 설계 단계별 가이드
이제 이론을 넘어 실제 코드로 어떻게 구현하는지 살펴볼게요. 우리가 만들 시스템은 다양한 할인 정책을 적용하는 Discount Engine이라고 가정해 볼게요. 처음에는 단순히 정가에서 1,000원을 깎아주는 기능만 있으면 되었지만, 나중에는 백분율 할인, VIP 할인 등 수십 가지 기능이 추가될 예정이에요.
STEP 1. 인터페이스를 활용한 기본 구조 잡기
가장 먼저 해야 할 일은 모든 연산자가 공통적으로 가져야 할 규칙을 정의하는 거예요. Go에서는 이를 interface로 표현해요. 모든 할인 연산자는 “금액을 입력받아 할인된 금액을 반환한다”는 동일한 약속을 지켜야 해요.
인터페이스는 ‘무엇을 할 수 있는가’를 정의합니다. ‘어떻게 하는가’는 각 구현체에 맡기죠. 이것이 바로 결합도를 낮추는 핵심입니다.
구조는 다음과 같아요. 먼저 `DiscountOperator`라는 인터페이스를 만들고, `Apply`라는 메서드를 정의합니다. 이렇게 하면 어떤 새로운 할인 방식이 들어오더라도 `Apply` 메서드만 가지고 있다면 엔진은 이를 완벽하게 다룰 수 있어요.
STEP 2. 개별 연산자 구현하기
이제 인터페이스를 실제로 구현하는 구체적인 연산자들을 만들어 볼 차례예요. 예를 들어, 고정 금액을 할인하는 `FixedAmountDiscount`와 비율로 할인하는 `PercentageDiscount`를 만들어 볼게요. 각 구조체는 인터페이스를 만족하는 방식으로 자신만의 로직을 작성해요.
여기서 중요한 점은 각 연산자가 서로의 존재를 모른다는 것이에요. `FixedAmountDiscount`는 `PercentageDiscount`가 있는지 없는지 알 필요가 없어요. 오직 자신이 받은 값을 어떻게 처리할지만 고민하면 돼요. 이러한 독립성이 바로 확장성의 원천이에요. 코드가 깔끔해지는 것은 물론이고, 나중에 특정 할인 로직만 따로 떼어내어 테스트하기도 매우 쉬워져요.
STEP 3. 레지스트리를 이용한 동적 관리
연산자가 많아지면 이를 관리하는 방식도 진화해야 해요. 수많은 `if-else` 문을 쓰는 대신, Map(맵)을 활용한 레지스트리 패턴을 도입해 보세요. 연산자의 이름(예: “FIXED”, “PERCENT”)을 키로 하고, 인터페이스 구현체를 값으로 하는 맵을 만드는 거예요.
새로운 연산자가 추가되면, 우리는 기존의 엔진 코드를 수정하는 대신 맵에 새로운 연산자를 등록하기만 하면 돼요. 런타임에 동적으로 새로운 기능을 추가할 수 있는 매우 강력한 구조가 완성되는 것이죠. 이 단계에서 Go 연산자 예제를 직접 작성해 보며 맵에 함수나 객체를 담는 연습을 해보시면 큰 도움이 될 거예요.
STEP 4. 오류 처리와 안정성 확보하기
실제 서비스에서는 연산 중에 예상치 못한 상황이 반드시 발생해요. 예를 들어, 할인율이 100%를 넘어가거나 할인 금액이 음수로 입력되는 경우죠. 확장성 있는 설계를 위해서는 오류를 처리하는 방식도 인터페이스에 포함되어야 해요.
주의: `Apply` 메서드가 단순히 금액만 반환하게 설계하면 안 돼요. 반드시 `error` 타입을 함께 반환하도록 설계해야 합니다. 그래야 연산 중에 문제가 생겼을 때 엔진이 이를 인지하고 안전하게 동작을 멈추거나 사용자에게 알릴 수 있어요. 오류 처리가 제대로 되지 않은 연산자는 시스템 전체를 불안정하게 만드는 시한폭탄과 같아요.
STEP 5. 테스트 가능한 구조로 완성하기
마지막 단계는 테스트입니다. 인터페이스를 사용하면 Mock(모의 객체)을 만들기 매우 쉬워져요. 엔진을 테스트할 때 실제 복잡한 할인 로직을 다 돌려볼 필요 없이, 단순히 “입력값을 받으면 특정 값을 반환한다”는 가짜 연산자를 만들어 넣어볼 수 있죠.
이 과정을 통해 우리는 엔진의 논리적 결함이 없는지 아주 빠르게 검증할 수 있어요. 각 연산자 자체에 대한 단위 테스트와 엔진에 대한 통합 테스트를 분리하여 수행하는 것이 가장 이상적인 시나리오예요. 아래는 우리가 설계한 시스템의 흐름을 정리한 예시 일정표예요.
[할인 엔진 적용 시나리오]
- 오전 10:00: 시스템 가동 및 기본 연산자(고정 할인) 등록
- 오후 01:00: 프로모션 시작에 맞춰 새로운 연산자(VIP 20% 할인)를 레지스트리에 동적 등록
- 오후 02:00: 등록된 연산자를 통해 실시간 주문 금액 계산 및 검증
- 오후 05:00: 연산 중 발생한 경계값 오류(할인율 초과)를 로그로 기록하고 안전하게 차단
이처럼 체계적인 단계별 접근을 통해 우리는 단순한 코드를 넘어, 변화에 유연하게 대처할 수 있는 강력한 아키텍처를 구축할 수 있습니다.
자주 하는 실수와 해결법
설계 과정에서 많은 개발자가 빠지는 함정들이 있어요. 실수를 미리 알고 있다면 훨씬 빠르게 성장할 수 있습니다.
- ❌ 모든 로직을 하나의 큰 switch 문에 넣기
왜 발생하는가: 구현이 가장 빠르고 직관적이라 유혹에 빠지기 쉽기 때문이에요.
✅ 해결법: 반드시 인터페이스를 정의하고 각 로직을 별도의 구조체로 분리하세요. - ❌ 에러 처리를 무시하고 값만 반환하기
왜 발생하는가: 코드를 단순하게 유지하고 싶어 하는 마음 때문이에요.
✅ 해결법: 메서드 시그니처에 반드시 error를 포함시켜 예외 상황을 전달하세요. - ❌ 전역 변수를 사용하여 연산자 레지스트리 관리하기
왜 발생하는가: 어디서든 쉽게 접근하고 싶기 때문이에요.
✅ 해결법: 의존성 주입(Dependency Injection)을 통해 필요한 곳에만 레지스트리를 전달하세요. - ❌ 과도한 추상화로 단순한 기능까지 복잡하게 만들기
왜 발생하는가: 모든 것에 패턴을 적용해야 한다는 강박 때문이에요.
✅ 해결법: 기능이 단순하다면 처음부터 복잡한 패턴을 쓰지 마세요. 확장이 필요할 때 리팩토링하는 것도 기술입니다. - ❌ 연산자의 상태(State)를 공유하기
왜 발생하는가: 연산자 간의 데이터를 주고받기 편하게 하려고 하기 때문이에요.
✅ 해결법: 연산자는 최대한 순수 함수처럼 동작하게 하여, 입력값에 대해서만 결과를 내놓도록 만드세요.
자주 묻는 질문
Q. 인터페이스를 쓰면 코드가 너무 많아져서 복잡해지지 않나요?
처음에는 파일 개수도 늘어나고 구조가 복잡해 보일 수 있어요. 하지만 기능이 5개, 10개로 늘어나는 시점부터 인터페이스의 진가가 드러나요. 파일이 많은 것은 관리의 대상이지, 결함이 아닙니다. 복잡한 하나의 파일보다 깔끔한 여러 개의 파일이 훨씬 유지보수하기 좋습니다.
Q. Go에서 연산자 확장을 위해 가장 추천하는 패턴은 무엇인가요?
프로젝트 규모에 따라 다르지만, 중대형 서비스라면 인터페이스와 레지스트리 패턴의 조합을 가장 추천해요. 이 조합은 테스트 용이성, 확장성, 결합도 감소라는 세 마리 토끼를 모두 잡을 수 있는 검증된 방법입니다.
Q. 맵(Map)을 사용할 때 타입 안정성을 어떻게 지키나요?
맵의 값 타입을 구체적인 클래스가 아닌 인터페이스(예: DiscountOperator)로 지정하면 됩니다. 그러면 맵에 넣을 때 해당 인터페이스를 구현하지 않은 객체는 컴파일 에러가 발생하므로 타입 안정성을 확보할 수 있어요.
Q. 새로운 연산자를 추가할 때 기존 테스트 코드를 고쳐야 하나요?
아니요, 그게 바로 우리가 목표로 하는 바입니다. 기존 엔진의 테스트 코드는 인터페이스의 동작 방식만 검증하므로, 새로운 연산자가 추가되어도 기존 테스트는 그대로 통과해야 합니다. 만약 기존 테스트를 고쳐야 한다면 설계가 잘못된 것입니다.
성공적인 설계를 위한 마지막 체크리스트
오늘 우리는 Go 언어를 활용해 어떻게 하면 변화에 유연하게 대처할 수 있는 연산자 구조를 만들 수 있는지 깊이 있게 살펴보았어요. 처음에는 설계 단계가 번거롭게 느껴질 수 있지만, 이 시간이 결국 미래의 여러분을 고통에서 구해줄 거예요.
- 연산자 설계의 핵심은 인터페이스를 통한 결합도 감소입니다.
- 새로운 기능 추가 시 기존 코드를 건드리지 않는 OCP를 지향하세요.
- 에러 처리를 설계 단계부터 포함하여 안정성을 확보하세요.
- 레지스트리 패턴을 활용해 런타임 확장성을 높이세요.
- 단위 테스트를 통해 각 연산자의 독립성을 검증하세요.
이제 여러분이 해야 할 일은 명확합니다. 지금 작성 중인 코드 중에 혹시 거대한 switch-case 문이 있다면, 그것을 인터페이스 기반의 구조로 리팩토링하는 계획을 세워보세요. 작은 부분부터 하나씩 분리하다 보면 어느새 견고한 아키텍처가 완성되어 있을 거예요.
오늘 배운 내용이 실전에서 큰 힘이 되기를 바랍니다. 다음 편에서는 이러한 인터페이스 구조를 더 깊게 활용하여 복잡한 비즈니스 로직을 처리하는 고급 디자인 패턴을 다룰 예정이에요. 놓치고 싶지 않다면 구독을 통해 가장 빠르게 새로운 기술을 만나보세요!
함께 읽으면 좋은 글: Go 연산자 완벽 가이드