programming language/Java

[Java] 객체지향 연습 7 - 요금 기능 추가하기 (feat. 개인적인 생각과 향후 방향)

공대키메라 2026. 3. 8. 15:57

지난 글에서는 공부할 프로젝트에 대해 학습하고, 어느 기능을 추가할 것인지 고민했고

 

이를 위해 데코레이터 패턴과 책임 연쇄 패턴에 대해 알아보았다. (궁금하면 여기 클릭!)

 

이번 시간에는 요금 기능과 알림 기능을 추가하려고 하는데...! 생각해보니 지난 글에서 알림 기능은 내가 고민했는데 

 

말만 하고 넘어가버리고 말았다.

 

요금 기능을 직접 구현하기 전에, 알림 기능을 위한 디자인패턴을 알아보고, 실제 코드를 확장할 것이다.

 

사실 객체지향을 연습하기 위해서는 코드를 직접 작성하고 학습해야 하는데,

 

일일히 이것을 복붙하고 설명하는것이 애매하다고 생각했다.

 

그래서 과감히 전부 제거했고, 해당 작업들에 대한 코드와 히스토리는 다음 Git Branch에서 다 확인할 수 있다

(코드 보고싶으면 여기 클릭!)

 


목표

1. 알람 기능 확장하기 위해 어떻게 해야 하는지 고민한다.

2. 옵저버 패턴을 학습한다.

3. 구현 전 간단한 흐름을 생각한다.

4. 요금 기능 코드를 구현 및 분석한다.

 


1. 알림 기능 추가를 위한 고민

알람이라는것이 사실 필수적인 기능은 아니다. 

 

부가적으로 사람들이 사용하면 좋은 그런것이긴 한데, 소비자가 결제를 하는 경우 이러한 경우가 있다.

 

부가가치세법 제32조 (세금계산서 등)

사업자가 물건을 팔 때는 원칙적으로 세금계산서를 발급해야 합니다. 다만, 일반 소비자를 대상으로 하는 소매업, 음식점업 등은 세금계산서 대신 영수증(신용카드 매출전표, 현금영수증 등)을 발급할 수 있도록 규정

 

이러한 경우 영수증이나 혹은 구매 사실에 대해서 소비자에게 알려야 하는데, 이것을 동기적으로 처리하기에는 애매하다.

(1~2초 늦게 받는다고 해서 크게 문제될게 없지 않은가?)

 

그래서 이것도 생각을 해보니... domain으로 격상하고 Queue형식으로 쌓아서 알림 메시지를 보내는것이 맞다고 생각한다.

 

그렇지만, 필자의 경우에는 주제가 객체지향이기에 Oberserver Pattern을 사용해서 이게 ... 되는지 고려를 해보려고 한다.

 

2. 옵저버 패턴 학습

해당 옵저버 패턴에 대해서는 과거 공부를 했다(궁금하면 여기 클릭!

 

이것도 사실 말이 어렵지 구조를 먼저 보고 파악한 후, 적용해보면 금방이다.

 

다음 그림은 1994년 GoF 디자인패턴에서 정의한 옵저버 패턴의 구조이다. 

 

 

Observer가 있고, 이를 구현한 ConcreteObserver가 있다.

 

Subject는 Observer를 통해서 이를 바라본다.

 

이것의 문제는 이 객체를 누가 생성하고 묶어주는지에 대한 실무적 맥락은 생략되어 있다.

 

그래서 다음을 참고해서 다시 그렸다. 해당 그림은 Refactoring.Guru 라는 사이트에서 참고했다.

 

확실히 기존에 비해서 구체적이니 좀 더 사용하기 알아보기 편한다.

 

이름이 Publisher, Subscriber라고 해서 Pub-Sub구조는 아니니 오해 말라!

 

그런데 사실 이 구조는 이렇게 쓰는 사람이 없을것이다.

 

이제는 이벤트 기반 아키텍처, pub-sub 패턴 혹은, 도메인 이벤트를 이용해서 하는게 깔끔하지 직접 publisher가 

구독자를 일일히 파악하고 코드를 구현할 때 반복분을 통해서 이를 전달하는데, 굳이... 안쓸거라서

 

차라리 이벤트로 뭘 한다 생각하고 이 디자인패턴은 여기까지만 볼 것이다. (재미없어 너 탈락!)

 

그래도 코드는 올려놨으니 관심있으면 봐도 된다. 

3. 구현 전 계획 세우기

작업을 하기 전에 고민하니 이것이 잘 될련지 고민이었다.

 

동적으로 사용자가 요금에 대해 등록하고, 이를 계산한다고 하자.

 

도메인 레벨에서 요금을 관리하면 객체지향적으로 이것이 관리가 되는가? 대답은 Yes!

 

 

도메인은 그대로 도메인으로 등록하고, 코드적으로 이것을 넣고 추가하는 것이다.

 

생각을 해보면 코드가 아무리 SOLID를 지키고, 개발을 잘한다고 해도 용빼는 재주는 없다.

 

개발자가 기능은 결국 구현해야 하는것이다. 

 

 

이것에 대해서 좀 더 자세히 말해보자.

 

요금과 정책에 대해서 키메라가 등록을 하면 이 요금이 무엇이고 어떻게 적용될지는 사실 데이터상으로는 등록할 수 있게 하지만 

 

비즈니스로직을 데이터상으로 등록해서 관리할 수는 없지 않은가?

 

Domain으로 등록을 하는것과, 이것을 확장해서 Domain들이 상호작용해서 비즈니스 로직을 해결하는 것은 결국 우리가 해야 한다는 점!

 

 

그러면 어디까지를 Domain으로 승격시키고 코드를 어떻게 작성해야 하는가?

우선 요금과 요금정책 도메인을 생성하기로 했다.

 

요금은 말 그대로 기본이 되는 요금이고, 요금 정책은 요금과 엮여 있는 하위 도메인으로 생성한다.

 

그렇다면 요금 정책의 세부적인 작업은 코드를 통해 풀어가야 한다.

 

 

쿠폰의 요금은 여러 요인으로 결정이 될 수 있다. 

 

일반적으로 떠오르는 것은 쿠폰을 판매할 때, 이것이 현재 특별 한정 쿠폰이라서, 비싸게 팔 수도 있고,

 

아니면 특별 할인해서 얼마정도 깎아서 팔 수 있다.

 

만약에 숙박 예약 시스템이라고 한다면 일별로 요금을 산정해야겠지만, 현재 쿠폰의 경우에는 쿠폰 장당 가격을 매기는 전략임을 오해하지 말자!

 

 

요금 정책은 사실 정책이래봤자, 정해진 틀 안에서, 얼마를 추가금액을 붙인다던가, 얼마큼의 할인을 퍼센트 혹은 금액으로 할인한다던가 정도이지 사실 크게 달라지지 않는다. 

 

그래서, 필자는 FarePolicy가 그렇게 작용할 수 있게 하고,  금액이나 할인할 금액에 따라서만 기계적으로 계산을 할 수 있도록 작업할 예정이다.

 

 

또 구조도 고민을 했는데, 책임연쇄 패턴이 알맞지 않다는것을 알게되었다.

 

이렇게 동적으로 요금정책이 늘어나는데, 이것을 직업 책임연쇄 패턴에서 하듯이 다음 다음 것을 직접 선언해서 요금정책을 적용한다.?

 

예시

press.setNext(assembly)
     .setNext(paint)
     .setNext(shipment);

 

내가 생각했던 것과는 어긋나는 방식이라서 다시 정리하고, 다른 방법을 찾으려고 한다.

 

내가 원하는 건 이거다.

  1. 요금 정책의 종류(할증, 할인 등)는 개발자가 코드로 정의한다.
  2. 요금 정책의 파라미터(몇 퍼센트, 적용 순서 등)는 데이터로 자유롭게 등록한다.
  3. 등록된 정책들이 순서대로 전부 적용되어 최종 금액이 나온다.

그러면 이것을 어떻게 해서 코드로 풀어내서 더 좋게 할 수 있는지 작업해보자.

 

4. 요금 기능 코드 추가 (feat. 전략 패턴 + Pipes and Filters)

 

요금 관련 코드를 작성하기 위해 fare내부에 여러개를 만들었다.

 

정책을 등록하는 부분과 실제로 요금 계산을 위한 부분은 코드가 많아져서 분리하였다.

 

큰 그림을 먼저 보고 가자.

 

큰 구조

 

Fare, FarePolicy, FareCalculationContext, Money 연관관계

 

Fare와  FarePolicy는 1:N 연관관계이고, Fare에서는 Money를 참고해서 field로 사용하지만 

FarePolicy에서는 Money를 사용하지 않고 BigDecimal로 Value를 쓴다.

 

이유는 FarePolicy에서 액수의 사칙연산만 하는게 아니라, 백분율 계산의 경우 굳이... Money같이 복잡한 VO가 필요없기 때문이다.

 

Field를 자세하게 보게 되면 Enum이 많은데, 필자는... Enum이면 이름에서부터 Enum이라고 해야 아! 이게 Enum이구나 하는것을 이름으로부터 유추가 가능하기에, 이렇게 이름 붙였고, 굳이... 그 항목들은 그리지 않았다.

 

FarePolicyStrategy과 하위 구현체, FarePolicyStrategyRegistry, FareCalculationPipeline 

 

 

여러 패턴을 고민해 보았는데, 정책이 여러개로 동적으로 늘어날 수 잇으며

여러개의 정책이 한번에 와다다 작업이 되어야 하는 것이라서 현재 필자는 전략 패턴과 파이프 앤 필터 패턴을 사용했다.

 

뭐 들어보면 엄청난거 같은데, 대충 말해보면 파이프 앤 필터 패턴이라고 하면 여러개 입력 받아서 순서대로 쭈루룩 실행되게 하는거다. 

 

interface에 미리 등록을 하고 기능이 생길 때 마다 추가를 하도록 전략 패턴으로 구성했다.

 

다만 이렇게 되면 코드에 따라서 구현체가 많이 생긴다는 문제가 생긴다는데 글쎄... 기능이 많아지면 DB자체를 자동화가 가능하게 설계하지 않는 이상 코드가 늘어나는건 어디서는 당연한 현상이다. (이게 내가 말한 용뺴는 재주는 없다는 말이다.)

 

다만 현재에서는 전략 패턴이라는 틀 안에서, 이를 담아놓는 Registry 객체를 통해서 반복작업을 할 수 있다.

 

핵심을 간단히 정리하면 다음과 같다.

 

FarePolicyStrategyRegistry : 매핑만 담당

FareCalculationContext : 상태를 들고 다님

FareCalculationPipeline : 전체 흐름 조율

 

1. 순서대로 각 요금 적용 타입에 대해 비즈니스 로직, 여기서는 전략을 생성하고

2. Context로 생성한 다음

3. Pipeline에서 이를 받아 실행한다.

 

다른것은 그려러니 싶어도, 가장 핵심은 Application Service에서 Domain Service를 잘 조립해서 사용하는게 아닌가하는 생각이 들었다.

 

너무 내용이 길어지기 때문에 다른 코드들은 제쳐두고, 해당 코드를 가져왔다.

 

FareCalculationDomainService.java

package com.example.oopdddstudyproject.fare.domain.service;

import com.example.oopdddstudyproject.common.vo.Money;
import com.example.oopdddstudyproject.fare.domain.Fare;
import com.example.oopdddstudyproject.fare.domain.calculation.FareCalculationContext;
import com.example.oopdddstudyproject.fare.domain.calculation.FareCalculationPipeline;
import com.example.oopdddstudyproject.fare.domain.policy.FarePolicy;

import java.util.List;

public class FareCalculationDomainService {
    private final FareCalculationPipeline pipeline;

    public FareCalculationDomainService(FareCalculationPipeline pipeline) {
        this.pipeline = pipeline;
    }

    public Money calculateFinalPrice(Fare fare, List<FarePolicy> policies) {
        FareCalculationContext result = pipeline.calculate(fare, policies);
        return result.getCurrentPrice();
    }
}

 

사실 파이프라인 이런 코드들은 복잡하고 이를 직접 등록하고 하는것보다는, 

 

사용자가 등록한 정보를 넘겨줘야, pipe라인에서 이것을 받아서 계산을 하던 말던 하지 않겠나?

 

이것을 Application 레벨에서 조회해서 해결하는것이 맞다고 생각했다.

 

CouponIssueApplicationService.java

package com.example.oopdddstudyproject.coupon.application;


import com.example.oopdddstudyproject.common.vo.Money;
import com.example.oopdddstudyproject.coupon.domain.Coupon;
import com.example.oopdddstudyproject.coupon.domain.IssuedCoupon;
import com.example.oopdddstudyproject.coupon.domain.service.CouponIssueDomainService;
import com.example.oopdddstudyproject.coupon.infrastructure.CouponRepository;
import com.example.oopdddstudyproject.fare.domain.Fare;
import com.example.oopdddstudyproject.fare.domain.policy.FarePolicy;
import com.example.oopdddstudyproject.fare.infrastructure.FarePolicyRepository;
import com.example.oopdddstudyproject.fare.infrastructure.FareRepository;
import com.example.oopdddstudyproject.fare.domain.service.FareCalculationDomainService;
import lombok.AllArgsConstructor;

import java.util.List;

@AllArgsConstructor
public class CouponIssueApplicationService {

    private final FareRepository fareRepository;
    private final FarePolicyRepository farePolicyRepository;
    private final CouponRepository couponRepository;
    private final FareCalculationDomainService fareCalculationDomainService;
    private final CouponIssueDomainService couponIssueDomainService;

    public IssuedCoupon issueCoupon(Long fareId, Long couponId, Long memberId) {
        // 인프라에서 꺼내고
        Fare fare = fareRepository.findById(fareId);
        List<FarePolicy> policies = farePolicyRepository.findByFareId(fareId);
        Coupon coupon = couponRepository.findById(couponId);

        // 도메인한테 시키고
        Money finalPrice = fareCalculationDomainService.calculateFinalPrice(fare, policies);

        // 도메인한테 시키고
        return couponIssueDomainService.issueToMember(coupon, memberId, finalPrice);
    }
}

 

위 코드가 필자가 많이 신경쓰게 된 부분인데 개발자가 해당 기능을 적용시에는 application service레벨에서 이를 조립해야한다.

 

각자의 Coupon이나 Fare 및 FarePolicy에 직접 접근하기보다는 application service에서 각각의 domain service를 잘 조합하면 TDA스럽고, 변경 및 수정에도 다른 기능에 영향을 주지 않을 것이다. 

 


 

사실 글을 읽어보면 내가 모든것을 직접 생각해서 한듯이 보이지만,

 

정말 DDD에 객체지향을 녹여내기 위해서 실제로 해본적이 없고, 그냥 이러한 기능을 어떻게 확장하고 테스트를 할 수 있을가 하는 고민에서 시작부터 했다.

 

AI의 정말 무서운 점은 내가 얼마나 잘하고 못하던 간에 집요하게 알아보고 명령만 하면 어떻게든 문제를 해결할 수 있다는 점이다. 

 

이것이 나에게 정말 좋은 학습 동반자이기도 온전히 내가 생각해서 하고 싶은데 안되니 답답할 노릇이다.

 

그러다보니 내가 이게 아무리 명령을 해서 결과를 만들었지만 이게 맞나? 하는 의구심도 드는 것이 사실이다. 

 

그렇다보니 결국에는 이를 더 잘 활용해서 실전에서 빠르고 정확하게 결과를 내는 것이 AI시대에서 과도기에 있는 우리에게 필요한 덕목이 아닌가 싶다.

 

물론 잘못된 부분은 일정하게 필자가 수정을 했다.

 

예를 들어, Strategy를 더 작성하는 과정에서, 고정 금액 할인이 있는데 

 

이 부분에 계산이 잘못되어서 수정한 후 테스트코드를 작성했고 요금 계산은 Domain 레벨에서 작동하도록 필자가 분리했다.

 

이것을 제외한다면 Domain관련 코드에 대한 작성과 테스트코드는 95%를 Claude와 Gemini를 사용해서 해결했다.

 

그렇다고 AI가 다 해줬으니 나는 아무것도 안 한 것인가? 그건 아니다.

 

AI에게 명령을 하려면 내가 뭘 원하는지 알아야 한다.

 

동적으로 정책이 추가되는데 책임연쇄 패턴은 안 맞는 것 같다는 판단,

 

도메인 간 의존이 생기면 안 되니까 Application Service에서 조율해야 한다는 결정,

 

FareCalculationContext가 왜 필요한지 이해하려고 계속 질문한 것 등


이런 과정이 없었다면 AI가 아무리 코드를 뱉어도 쓸 수 없었을 것이다.

결국 AI 시대에 개발자에게 필요한 건 문제를 정확히 정의하고, 결과물이 맞는지 판단하는 능력이라고 느꼈다.

 

코드를 타이핑하는 속도는 AI가 압도적이지만, 이 방향이 맞나?를 판단하는 건 여전히 사람의 몫이다.

(이라고 하면서 오늘도 불안에 떨어본다 ㅋㅋㅋㅋㅋ)

 

 

계속 사용을 해보면 어느때는 정말 좋지만, 한편으로 마음이 불편하니 정말 말 그대로 정말 천국과 지옥을 오가고 있다.

 

그럼에도 어쩌나! 할 수 있는 데까지 최선을 다해서 할 예정이다. 

 

건방지지만 확신하는 부분은, 큰 그림을 AI와 함께 자세하게 분석하고 구현하고 테스트를 하고 

 

1%의 부분을 사람이 조율하는것이 2~3년 내에 일어나지 않을까 하는 불안이 있다.

 

그때가 되었을 때도 과연... 더 성장하고 분석할 수 있는 엔지니어가 되길 바라며 이 글을 마친다.

 

 

애초에 이 객체지향 글의 목적은 Netty를 사용해서 직접 서버를 구축하는데, 내가 직접 만든 기능들을 그대로 하나의 모듈로 가져가서 잘 작동하는지 보기 위함이었다.

 

적용을 하면서 부족한 부분이 있다면 해당 코드를 계속 업데이트하고 Netty에서 이것을 어떻게 적용해서 프레임워크와의 결합을 통해 정말 내 코드가 잘 작성된건지 확인하고 싶다면 Framework 탐방 Zone 카테고리에 작성중인 Netty 시리즈를 봐주면 된다.

(Framework 탐방 Zone 여기 클릭!)