[Java] 객체지향 연습 6 - 프로젝트 구성 및 기본 코드 학습 그리고 데코레이터, 책임연쇄 패턴 복기 (feat. 템플릿 메서드 패턴)
지난 시간에는 객체지향 연습을 위한 프로젝트를 구성하기 위한 전단계로 여러 개념을 학습했다.
이번 글에서는 지난 시간에 이어 학습용 프로젝트 구성을 이어서 진행할 예정이다.
시작하기에 앞서서 나의 생각을 좀 적어보도록 하겠다.
해당 프로젝트는 JPA나 부가적인 기술들은 전혀 고려하지 않았다.
다만 초기 학습을 위해서는 순수하게 Domain 주도로 개발을 진행하는 것이 맞다고 생각을 했다.
현실에서 실제 개발을 한다고 하면, 솔직히 기술 스펙이 바뀌지는 않을것이다.
이미 잘 돌아가고 있는 프로젝트에 한국에서 누가 이걸 새롭게 다 하고 싶은 경우가 있는지 반문을 해보았다.
이미 서비스중인 기술을 큰 리소스를 들이면서 새로운 기술을 적용하는것은 좋을 수 있지만, 그에 대한 테스트와 조사 그리고 충분한 타당성 검토도 자원이라고 생각한다면 고개를 갸웃거리는것이 사실이다.
물론 요즘에는 바이브 코딩이라는 것을 통해 빠르고 쉽게 개발이 가능하지만 그래도... 사람이 책임을 지는건 동일하다는 판단이다.
소프트웨어도 생명 주기가 있고, 현재 아무리 깔끔하고 좋게 개발을 해도 오래 되면 잘못된 부분이 있기 마련이고 이를 다시 엎고 처음부터 다시 개발하는 차세대 프로젝트를 SI에서 많이 진행하는것을 봤다. (물론 할많 하않....)
현재 단계에서는 하지만 이런 기술적인 문제와 현실성을 전부 고려하지 않고! 오로지 엔지니어링의 관점에서 정말 추후에 이것이 어느 프레임워크를 사용해도 문제가 없는지 확인하고자 함에 목표를 둔다.
목표
1. 구성한 프로젝트에 대해 간단히 설명한다.
2. 초기 작성 코드에 대해 알아본다.
3. 요금 관련 추가 코드를 생성함으로 객체지향으로 확장하는 방법에 대해 고민한다.
4. 데코레이터 패턴을 이해한다.
5. 책임 연쇄 패턴을 이해한다.
1. 프로젝트 생성
사실 어떤걸로 구성을 하던 상관은 없는데, spring boot 프로젝트를 구성하도록 하겠다. (정말이다!)
사실 크게 관심이 없던 사이에 Spring Boot의 버전이 4.0.3 까지 올라갔다 (대박...)
그리고 depencency는 우선 아무것도 추가하지 않고 진행하려고 한다.
순수하게 자바 코드만으로, 하나의 모듈로서 필자가 생성한 코드가 깔끔하게 돌아가도록 하기 위함이다.

알아서 이름으로 잘 프로젝트 만들면 된다. 다만 필자는 lombok만 추가를 해서 만들었다.
코드 일일히 치는거는 그... 프레임워크를 처음부터 만드는 게 아니라 학습을 위해서 만들기 때문에 편의성을 위해서 이렇게 했다.
크게 핵심 도메인은 Coupon, IssuedCoupon, Member가 있고 VO(Value Object)는 Inventory, Money가 있다.
글을 정리하는 현 시점에서 domain이 완전하게 구성된 것은 해당 브랜치 (feature/domain/issued-coupon-구현-20260226)
를 참고하길 바란다.
2. 관련 코드 설명
사실 코드에 대한 분석은 cursor나 antigravity사용해서 이 프로젝트 분석해줘 딸깍! 하면 되지만,
어째서 이렇게 했는지 하는 부분들을 설명을 더 하고자 한다.
2.1) CouponIssueDomainServce.java 생성 이유
Coupon이 있고, Coupon의 하위 도메인으로 IssuedCoupon이 있다.
IssuedCoupon은 Coupon이 있어야만 존재하는 도메인으로 같은 곳에 위치했다.

여기서 Domain Service를 만들었는데, Domain Service과 Application Service에 대해 기억이 안난다면?
다음 글을 다시 읽어보면 좋다! (여기 클릭!)
필자의 경우 Coupon에서 쿠폰을 발생하기도 애매하고, IssuedCoupon에서 쿠폰을 발생하기도 애매했다.
쿠폰을 발행한다는 행위는 결국 IssuedCoupon의 객체를 만들어내는 것으로
Coupon이 무조건 있어야 하는데, Coupon에서 관리중인 갯수도 차감을 해줘야 한다.
결국 두 개의 도메인에 발을 걸치게 되는데, 하나의 도메인에서 이를 관리하기 애매하기에 이를 별도의 Domain Service로 분리했다.
package com.example.oopdddstudyproject.coupon.domain.service;
import com.example.oopdddstudyproject.common.service.NumberGenerator;
import com.example.oopdddstudyproject.common.service.TimeGenerator;
import com.example.oopdddstudyproject.coupon.domain.Coupon;
import com.example.oopdddstudyproject.coupon.domain.IssuedCoupon;
/**
* 쿠폰 발급 도메인 서비스 (Domain Service)
* * [도입 목적]
* 1. 도메인 순수성 보장
* - IssuedCoupon 엔티티 내부의 인프라성 의존성(Generator)을 제거하여, 순수한 상태 및 데이터 관리에만 집중하게 합니다.
* 2. 테스트 용이성(TDD) 향상
* - 엔티티 단위 테스트 시 복잡한 Mocking 없이, 생성된 결과 값(String, Long)만 주입하여 직관적인 검증이 가능합니다.
* 3. 도메인 정책 캡슐화
* - 번호 생성 및 발급 시간 통제라는 복합적인 '쿠폰 발급 규칙'을 하나의 서비스로 응집합니다.
*/
public class CouponIssueDomainService {
private final NumberGenerator numberGenerator;
private final TimeGenerator timeGenerator;
public CouponIssueDomainService(NumberGenerator numberGenerator, TimeGenerator timeGenerator) {
this.numberGenerator = numberGenerator;
this.timeGenerator = timeGenerator;
}
/**
* 쿠폰 발급에 대한 도메인 규칙을 캡슐화하여 IssuedCoupon 엔티티를 조립합니다.
*
* @param coupon 발급 대상 쿠폰 정책 엔티티
* @param memberId 쿠폰을 발급받을 회원 ID
* @return 조립이 완료된 IssuedCoupon 엔티티
*/
public IssuedCoupon issueToMember(Coupon coupon, Long memberId) {
long currentTimeMillis = timeGenerator.millis();
// 1. 쿠폰 수량 감소 로직 호출 (재고 소진 시 여기서 Exception 발생)
coupon.reserve(currentTimeMillis);
// 2. 발급 정책에 따른 값 생성
String generatedNumber = numberGenerator.generate(coupon);
// 3. 발급된 쿠폰 엔티티 생성
return IssuedCoupon.issue(coupon, memberId, generatedNumber, currentTimeMillis);
}
}
이렇게 애매한 경우는 Service로 빼면 Domain은 더욱 순수하고 코드가 복잡해지지 않아서 좋고
Application Service는 단순하게 조합을 하는 입장에서 트랜잭션 스크립트를 작성하지 않아서 코드가 깔끔해진다.
2.2) IssueCoupon.java생성시 다른 Domain 엮임 문제
package com.example.oopdddstudyproject.coupon.domain;
import com.example.oopdddstudyproject.common.vo.Money;
import com.example.oopdddstudyproject.coupon.domain.vo.IssuedCouponStatus;
import lombok.Builder;
import lombok.Getter;
@Getter
public class IssuedCoupon {
private final Long id;
private final Long couponId;
private final Long memberId;
private final String couponNumber;
private final IssuedCouponStatus status;
private final Long issuedAt;
private final Money appliedPrice; // 추가
private final Long createdAt;
private final Long modifiedAt;
@Builder
public IssuedCoupon(Long id, Long couponId, Long memberId, String couponNumber, IssuedCouponStatus status, Long issuedAt, Money appliedPrice, Long createdAt, Long modifiedAt) {
this.id = id;
this.couponId = couponId;
this.memberId = memberId;
this.couponNumber = couponNumber;
this.status = status;
this.issuedAt = issuedAt;
this.appliedPrice = appliedPrice;
this.createdAt = createdAt;
this.modifiedAt = modifiedAt;
}
public static IssuedCoupon issue(Coupon coupon, Long memberId, String couponNumber, Long issuedAt) {
return IssuedCoupon.builder()
.couponId(coupon.getId())
.memberId(memberId)
.couponNumber(couponNumber)
.appliedPrice(coupon.getOriginalPrice()) // 추가
.status(IssuedCouponStatus.UNUSED)
.issuedAt(issuedAt)
.createdAt(issuedAt)
.modifiedAt(issuedAt)
.build();
}
public IssuedCoupon use(long usingTime) {
if (this.status != IssuedCouponStatus.UNUSED) {
throw new IllegalStateException("사용 가능한 쿠폰이 아닙니다.");
}
return IssuedCoupon.builder()
.id(this.id)
.couponId(this.couponId)
.memberId(this.memberId)
.couponNumber(this.couponNumber)
.appliedPrice(this.appliedPrice)
.status(IssuedCouponStatus.USED)
.issuedAt(this.issuedAt)
.createdAt(this.createdAt)
.modifiedAt(usingTime)
.build();
}
public IssuedCoupon expire(long time) {
if (this.status == IssuedCouponStatus.UNUSED) {
throw new IllegalStateException("이미 사용된 쿠폰은 만료할 수 없습니다.");
}
return IssuedCoupon.builder()
.id(this.id)
.couponId(this.couponId)
.memberId(this.memberId)
.appliedPrice(this.appliedPrice)
.couponNumber(this.couponNumber)
.status(IssuedCouponStatus.EXPIRED)
.issuedAt(this.issuedAt)
.createdAt(this.createdAt)
.modifiedAt(time)
.build();
}
}
현재 코드는 사실 깔끔하다.
다만 초기에 구성시에는 IssuedCoupon은 Member정보와 Coupon정보를 바탕으로 생성이 되기 때문에 이를 Domain 으로 어떻게 넘겨서 생성할 것인지가 문제였다.
이에 대해 Gemini에게 물어봤었는데, Coupon정보는 도메인으로 넘겨주지만, member 정보는 id만 넘기는것이 맞다고 했다.
1. Coupon정보의 하위 도메인에 IssuedCoupon이 속하기 때문이고,
2. Member는 별도의 도메인이기에 이를 분리하기 위해 이렇게 했다.
2.3) VO 분석 - Inventory 와 Money
Inventory와 Money를 VO로 작성했는데, Domain과 VO(Value Object)의 차이는 ID 가 있느냐 없느나다.
객체지향적으로 코드를 풍부하게 만들기 위해서 VO를 적극 도입하기로 했다.
Coupon.java
package com.example.oopdddstudyproject.coupon.domain;
import com.example.oopdddstudyproject.common.service.TimeGenerator;
import com.example.oopdddstudyproject.common.vo.Inventory;
import com.example.oopdddstudyproject.common.vo.Money;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDate;
@Getter
public class Coupon {
private final Long id;
private final String description;
private final LocalDate expireDate;
private final Inventory inventory;
private final Money originalPrice;
private final Long createdAt;
private final Long modifiedAt;
@Builder
public Coupon(Long id, String description, LocalDate expireDate, Inventory inventory, Money originalPrice, Long createdAt, Long modifiedAt) {
this.id = id;
this.description = description;
this.expireDate = expireDate;
this.inventory = inventory;
this.originalPrice = originalPrice;
this.createdAt = createdAt;
this.modifiedAt = modifiedAt;
}
public static Coupon from(CouponCreate couponCreate, long currentTime) {
Inventory inventory = Inventory.createInitial(couponCreate.getTotalCount());
return Coupon.builder()
.description(couponCreate.getDescription())
.inventory(inventory)
.originalPrice(couponCreate.getOriginalPrice()) // 추가
.expireDate(couponCreate.getExpireDate())
.createdAt(currentTime)
.modifiedAt(currentTime)
.build();
}
public Coupon updateCouponInfo(CouponUpdate couponUpdate, long currentTime) {
return Coupon.builder()
.id(this.id)
.description(couponUpdate.getDescription())
.inventory(this.inventory)
.createdAt(this.createdAt)
.expireDate(couponUpdate.getExpireDate())
.modifiedAt(currentTime)
.build();
}
public Coupon updateInventoryInfo(CouponUpdate couponUpdate, long currentTime) {
Inventory inventory = Inventory.builder()
.usedCount(couponUpdate.getUsedCount())
.totalCount(couponUpdate.getTotalCount())
.build();
return Coupon.builder()
.id(this.id)
.description(this.description)
.inventory(inventory)
.expireDate(this.expireDate)
.createdAt(this.createdAt)
.modifiedAt(currentTime)
.build();
}
public Coupon reserve(long currentTime) {
if (this.expireDate.isBefore(LocalDate.now())) {
throw new IllegalStateException("만료된 쿠폰입니다.");
}
Inventory usedInventory = this.inventory.use();
return Coupon.builder()
.id(this.id)
.description(this.description)
.inventory(usedInventory)
.expireDate(this.expireDate)
.originalPrice(this.originalPrice)
.createdAt(this.createdAt)
.modifiedAt(currentTime)
.build();
}
}
해당 코드를 보면 다음과 Coupon의 Field로 Inventory와 Money가 선언되어 있다.
TDA의 원칙에 맞도록 객체가 단순 데이터 덩어리가 아닌 서로 협업하는 하나의 객체로서 역할을 하도록 했다.
Coupon.java의 reserve(long currentTime)메소드를 보자.
Inventory usedInventory = this.inventory.use();
사실 Coupon은 inventory를 가지고 있지만, inventory내부의 부분 부분을 속속 다 알아서 뒤져서 사용하기는 애매하다.
객체지향적인 관점에서 해당 코드처럼, TDA스럽게 하는게 맞을것이다.
2.4) 테스트 결과 모습

현재 TDD를 진행하면서 코드도 계속 발전하고 있으며 Domain 코드들에 대한 테스트 커버리지도 100%를 달성한다.

3. 중간 기능 추가 - 요금 계산의 필요와 알리미 기능
사실 이 부분을 위해 여태까지 이렇게 길게 학습하고 조사를 하고 프로젝트를 만들었다.
현재 핵심적인 Domain은 Member, Coupon, IssuedCoupon인데 여기서 필자가 맘에 안드는 것은 요금 계산 부분이다.
해당 문제에 대해 어떻게 코드를 작성했는지는 다음을 보면 된다(feature/problem/요금적용-객체-추가-20260302)
3.1) 요금 정책(FarePolicy), VO가 아닌 독립된 도메인(Entity)으로 분리해야 하는 이유?
설계 초기에는 계산된 요금 결과를 단순한 값 객체(VO)로 처리하려 했으나,
최종적으로는 독자적인 생명주기를 갖는 별도의 도메인 영역으로 분리하는 것이 낫다고 판단했다. 그 이유는 다음과 같다.
1. 적용 대상의 식별과 매핑 불가
FarePolicy를 VO로 작성하게 되면, 이 요금 정책이 '어떤 객실 타입'에 속하는지, '어떤 쿠폰'과 결합될 수 있는지 구분하고 매핑하는 것이 불가능해진다.
다대다(N:M) 정책 매핑이나 조건부 할인을 적용하려면 고유한 식별자(ID)를 통해 다른 도메인과 관계를 맺어야 하므로, 식별자가 없는 VO로는 한계가 명확하다.
2. 요금 계산의 복잡성과 요구사항 확장
요금은 단순히 고정된 숫자가 아니다. 성수기/비수기, 주말 할증, 연박 할인 등 다양한 변수에 따라 끊임없이 요구사항이 확장되며 계산 로직이 복잡해진다.
물론 현재는 Coupon에 성수기/비성수기가 없지만, 그건 언제든지 다양한 요구사항을 수용할 수 있어야 한다.
결론적으로, 요금 정책은 단순한 '값'이 아니라 상태와 식별자를 가지고 다양한 도메인 요소들을 조율해야 하는 핵심 비즈니스 규칙이다.
따라서 이를 VO에 욱여넣기보다는, 더 큰 개념인 독립된 도메인(Aggregate)으로 격상시켜 설계하는 것이 아키텍처의 유연성과 확장성을 확보하는 올바른 방향이라 생각한다.
3.2) 쿠폰 발급시 사용자에게 이슈 전송하고 싶어요!
쿠폰을 발급하는 것은 그렇게 어려운 일이 아니다.
하지만 이것을 다른 서비스를 이용해서 메시지를 보내야 하는 경우에는 어떻게 해야할 까?
요즘 AWS를 공부하는 필자의 경우에는 AWS SQS를 이용해서 Queue를 쌓고, 요청마다 AWS Lambda 를 이용해서 관리가 편하고 확장성 있는 서비스를 만들어서 올렸다고 하자.
사실 메시지의 경우 실시간이라기 보다는 바로 사용자에게 전송해주면 되는 것이라서 비동기적으로 작동해도 문제가 없다.
다음 코드를 보자.
CouponIssueDomainService.java 의 issueToMember 메소드
...
/**
* 쿠폰 발급에 대한 도메인 규칙을 캡슐화하여 IssuedCoupon 엔티티를 조립합니다.
*
* @param coupon 발급 대상 쿠폰 정책 엔티티
* @param memberId 쿠폰을 발급받을 회원 ID
* @return 조립이 완료된 IssuedCoupon 엔티티
*/
public IssuedCoupon issueToMember(Coupon coupon, Long memberId) {
long currentTimeMillis = timeGenerator.millis();
// 1. 쿠폰 수량 감소 로직 호출 (재고 소진 시 여기서 Exception 발생)
coupon.reserve(currentTimeMillis);
// 2. 발급 정책에 따른 값 생성
String generatedNumber = numberGenerator.generate(coupon);
// 3. 발급된 쿠폰 엔티티 생성
return IssuedCoupon.issue(coupon, memberId, generatedNumber, currentTimeMillis);
}
...
해당 DomainService에서 이것을 처리하기 위해서는 어떻게 해야할까?
5. 데코레이터 패턴
이제 정말 객체 지향의 정수인 패턴이 나오기 시작했다.
그중에서 데코레이터 패턴을 다시 알아가보자.
사실 생각해보니 엄청 옛~~~날에 디자인 패턴에 대해서 한 번 다 공부를 했었다. (궁금하면 여기 클릭)
그런데 사실 쓸데없이 부가적인 말이 많고... 딱딱하다. 어짜피 다 까먹는데 그냥 구조만 보고 이렇게 하는구나~ 하는거
체득해서 쓰는 방식으로 하겠다.

이것을 구구절절 어쩌구 하기보다는 실제 예시로 바로 그려보겠다.

빵집이 있는데, 기본 케이크에 여러가지 소비자의 기호에 맞게 데코레이션을 추가할 것이다.
초코, 과일, 크림이 있다.
PlainCake는 그대로 Cake의 구현체이지만, CakeDecorator 내부에는 private Cake 으로 선언한 wrappedCake이라는 field가 있고, 이 wrappedCake가 cake의 기능을 호출해서 케익에 토핑을 여러번 추가할 수 있다.
IntelliJ 에서 실제로 코드로 만들어보고 구조를 이쁘게 배치해봤다. 비슷하다!

Cake - 객체에 대한 인터페이스
public interface Cake {
String makeCake();
}
PlainCake- 추가적인 서비스를 위한 객체
public class PlainCake implements Cake {
@Override
public String makeCake() {
return "촉촉한 기본 빵";
}
}
CakeDecorator - 참조자를 관리하며 정의 인터페이스도 만족
@Slf4j
public abstract class CakeDecorator implements Cake{
private Cake wrappedCake;
public CakeDecorator(Cake cake) {
this.wrappedCake = cake;
}
@Override
public String makeCake() {
return wrappedCake.makeCake(); }
}
Chocolate, Cream, FruitDecorator -새로운 케이크가 추가될 서비스를 실제 구현
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class ChocolateDecorator extends CakeDecorator{
public ChocolateDecorator(Cake cake) {
super(cake);
}
@Override
public String makeCake() {
return super.makeCake() + " + 꾸덕한 초콜릿 코팅";
}
}
---------------------------------------------------------------------
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class CreamDecorator extends CakeDecorator{
public CreamDecorator(Cake cake) {
super(cake);
}
@Override
public String makeCake() {
return super.makeCake() + " + ☁️부드러운 생크림";
}
}
---------------------------------------------------------------------
public class FruitDecorator extends CakeDecorator {
public FruitDecorator(Cake cake) {
super(cake);
}
@Override
public String makeCake() {
return super.makeCake() + " + 🍓싱싱한 제철 과일";
}
}
실행 결과
/*
실행 결과 첨부
주문 1: 촉촉한 기본 빵
--- 토핑 추가 시작 ---
주문 2: 촉촉한 기본 빵 + ☁️부드러운 생크림
주문 3: 촉촉한 기본 빵 + ☁️부드러운 생크림 + 🍓싱싱한 제철 과일
주문 4(최종): 촉촉한 기본 빵 + ☁️부드러운 생크림 + 🍓싱싱한 제철 과일 + 꾸덕한 초콜릿 코팅
--- 한 번에 만들기 (체이닝) ---
풀옵션 케이크: 촉촉한 기본 빵 + ☁️부드러운 생크림 + 🍓싱싱한 제철 과일 + 꾸덕한 초콜릿 코팅
*/
package com.example.oopdddstudyproject.study.decorator;
public class Bakery {
public static void main(String[] args) {
// 1. 가장 기본적인 빵만 주문
Cake myCake = new PlainCake();
System.out.println("주문 1: " + myCake.makeCake());
System.out.println("\n--- 토핑 추가 시작 ---\n");
// 2. 기본 빵에 '생크림' 바르기
// PlainCake을 CreamDecorator로 감싼다.
myCake = new CreamDecorator(new PlainCake());
System.out.println("주문 2: " + myCake.makeCake());
// 3. 생크림 케이크 위에 '과일' 올리기
// (기본빵+생크림) 상태인 객체를 다시 FruitDecorator로 감싼다.
myCake = new FruitDecorator(myCake);
System.out.println("주문 3: " + myCake.makeCake());
// 4. 마지막으로 전체에 '초콜릿' 코팅하기
// (기본빵+생크림+과일) 상태인 객체를 다시 ChocolateDecorator로 감싼다.
myCake = new ChocolateDecorator(myCake);
System.out.println("주문 4(최종): " + myCake.makeCake());
System.out.println("\n--- 한 번에 만들기 (체이닝) ---\n");
// 이렇게 한 줄로도 표현 가능합니다. (안쪽에서부터 실행됨)
Cake fullOptionCake = new ChocolateDecorator(
new FruitDecorator(
new CreamDecorator(
new PlainCake()
)
)
);
System.out.println("풀옵션 케이크: " + fullOptionCake.makeCake());
}
}
OCP를 준수해서 기능을 추가할 수 있다는 점이 장점이지만 클래스가 많아진다는 점이 단점이다.
6. 책임연쇄 패턴 (+ 템플릿 메소드 패턴)
이것도 옛날에 공부를 했었다.(책임연쇄 패턴 궁금하면 여기 클릭!)
그리고 공부하면서 하다보니 Template Method Pattern도 섞여있었다. (템플릿 메소드 패턴 궁금하면 여기 클릭!)
웃긴건 기억이 하나도 안난다. 그런데 막상 보면 또 별거는 없다.
이것도 데코레이터 패턴과 마찬가지로 원본을 보고, 예시 코드 구조를 이에 맞춰서 그려보도록 하겠다.

이를 필자는 자동차 공정으로 예시를 들겠다.
자동차를 만들려면 여러 공정을 하나의 단지에서 전부 해결하려고 한다.
그렇게 하나의 산업을 효율적으로 처리하기 위해 클러스터 단위의 공장이 생기는데, 자동차의 경우
철판을 피고, 조립하고, 도색하고 마지막으로 출고를 한다.

사실 처리는 각각 handler에서 직접 처리하지만 모두가 동일하다. 그래서 템플릿 메소드 패턴이 자연스럽게 들어갔다.
뭐 거창하게 어떤 패턴이다~ 이런건 아닌데 하다보니 그렇게 된 것이다.

Car.java - Request에 해당. 파라미터용
import lombok.Getter;
import java.util.ArrayList;
import java.util.List;
@Getter
public class Car {
private String status = "원자재(철판)";
private List<String> history = new ArrayList<>();
public void addLog(String log) {
this.history.add(log);
this.status = log; // 현재 상태 업데이트
System.out.println("Current Status: " + this.status);
}
}
PaintHandler, PressHandler, ShipmentHandler, AssemblyHandler 정의 - 처리할 행동 정의
class PaintHandler extends ProcessHandler {
@Override
protected void process(Car car) {
System.out.println("--- 3단계: 도장 공정 ---");
car.addLog("외부 도색 및 코팅 완료");
}
}
--------------------------------------------------------
class PressHandler extends ProcessHandler {
@Override
protected void process(Car car) {
System.out.println("--- 1단계: 프레스 공정 ---");
car.addLog("철판 평탄화 및 절단 완료");
}
}
--------------------------------------------------------
class ShipmentHandler extends ProcessHandler {
@Override
protected void process(Car car) {
System.out.println("--- 4단계: 출고 공정 ---");
car.addLog("최종 검수 및 고객 인도 준비 완료");
}
}
-------------------------------------------------------
class AssemblyHandler extends ProcessHandler {
@Override
protected void process(Car car) {
System.out.println("--- 2단계: 차체 조립 공정 ---");
car.addLog("엔진 장착 및 프레임 조립 완료");
}
}
ProcessHandler.java - 요청을 처리하는 인터페이스
public abstract class ProcessHandler {
protected ProcessHandler nextHandler;
// 체인을 연결하는 메서드 (Set Next)
public ProcessHandler setNext(ProcessHandler nextHandler) {
this.nextHandler = nextHandler;
return nextHandler; // 메서드 체이닝을 위해 반환
}
// 공통 실행 로직 (템플릿 메서드 패턴처럼 동작)
public void handle(Car car) {
process(car); // 1. 내 공정을 수행한다.
// 2. 다음 공정이 있으면 넘긴다.
if (nextHandler != null) {
nextHandler.handle(car);
}
}
// 각 공장이 구현해야 할 추상 메서드
protected abstract void process(Car car);
}
여기서 인터페이스라고 해서 어? 왜 인터페이스로 안만들고 추상클래스로 만들었어? 에잇 뭐야~ 이러면 안되는거 알죠? (찡긋)
여기서의 인터페이스는 객체가 외부와 통신하기 위해 공개한 메서드들의 집합을 말한다.
ProcessHandler내부에 protected로 선언된 메소드를 보면 abstract로 정의되어 잇는데, setNext와 handle이 일을 하고 process만 정의하면 구현이 되도록 지원하는 일종의 Template Method Pattern으로도 볼 수 있다는 점!
FactoryClient.java - 실행 테스트
/*
실행 결과
🚗 자동차 생산을 시작합니다...
--- 1단계: 프레스 공정 ---
Current Status: 철판 평탄화 및 절단 완료
--- 2단계: 차체 조립 공정 ---
Current Status: 엔진 장착 및 프레임 조립 완료
--- 3단계: 도장 공정 ---
Current Status: 외부 도색 및 코팅 완료
--- 4단계: 출고 공정 ---
Current Status: 최종 검수 및 고객 인도 준비 완료
🎉 모든 공정 완료!
*/
public class FactoryClient {
public static void main(String[] args) {
// 1. 각 공정 생성
ProcessHandler press = new PressHandler();
ProcessHandler assembly = new AssemblyHandler();
ProcessHandler paint = new PaintHandler();
ProcessHandler shipment = new ShipmentHandler();
// 2. 체인 연결 (철판 -> 조립 -> 도색 -> 출고)
press.setNext(assembly)
.setNext(paint)
.setNext(shipment);
// 3. 자동차 생산 시작! (맨 처음 단계에만 넣으면 알아서 끝까지 감)
System.out.println("🚗 자동차 생산을 시작합니다...");
Car newCar = new Car();
press.handle(newCar);
System.out.println("\n🎉 모든 공정 완료!");
}
}
책임연쇄 패턴과 템플릿 메소드 패턴을 섞어서 쓰는 경우 장점에 대해 Gemini에게 물어보았다.
궁금하면 다음을 열어서 보면 된다.
네, 코드 없이 핵심만 딱 집어서 '템플릿 메서드 + 책임 연쇄 패턴' 조합의 장점을 4가지로 요약해 드립니다.
이 내용을 발표나 문서에 그대로 쓰시면 됩니다.
1. 👮♂️ 실수를 원천 봉쇄하는 '안전 장치' (Human Error 방지)
- 핵심: 개발자가 실수로 **"다음 단계로 넘기는 코드"**를 깜빡해도 시스템은 멈추지 않습니다.
- 설명: 부모 클래스(템플릿)가 "일 다 했으면 무조건 다음으로 넘겨!"라고 강제하고 있기 때문입니다. 신입 개발자가 들어와서 로직을 짜도 공장 컨베이어 벨트는 절대 끊기지 않습니다.
2. 🧩 레고 블록 같은 '무한한 유연성' (유지보수성)
- 핵심: 공정 순서를 바꾸거나, 새로운 공정을 끼워 넣을 때 기존 코드를 한 줄도 건드릴 필요가 없습니다.
- 설명: "도색 전에 검수를 추가해 주세요"라는 요청이 와도, Paint나 Assembly 클래스를 수정하는 게 아니라, 조립하는 곳에서 순서만 바꿔주면 끝납니다.
3. 🧹 잡동사니를 한 곳에서 처리 (중복 제거 & AOP)
- 핵심: 모든 단계에 공통으로 필요한 기능(시간 측정, 로깅, 에러 처리)을 단 한 곳에서 관리합니다.
- 설명: 만약 공정마다 "시작 시간 기록"을 해야 한다면? 100개의 클래스에 복사-붙여넣기 할 필요 없이, 부모 클래스(템플릿)에 딱 한 줄만 적으면 전 공정에 자동 적용됩니다.
4. 🎯 "내 일만 잘하면 돼" (단일 책임 원칙 준수)
- 핵심: **'일하는 사람(Logic)'**과 **'줄 세우는 사람(Flow)'**이 완벽하게 분리됩니다.
- 설명: 도색 담당자는 "내 다음이 누구지?"를 고민할 필요 없이 오직 '색칠'에만 집중하면 됩니다. 코드가 깔끔해지고 집중도가 높아집니다.
지식이라는게 참 재미있는게 외우면 이것이 내꺼라는 착각을 해왔다.
하지만 이를 다시 스스로 재정의를 했다.
진정으로 내가 이해한 지식이라 함은, 내가 스스로 적용해서 쓸 수 있느냐로 판가름 나더라!
물론 많은 부분을 Gemini와의 상호작용을 통해서 글을 적고 찾았지만 그마저도 사람이 시켜서 확인받고, 학습하고 이해하고 글을 적는 것이기에 AI는 내게 바른 길잡이요 선생님이라는 생각이 든다.
이번에는 기본적으로 세팅한 프로젝트에 코드를 더하는 방식을 고민해 보았고,
이를 이제 정말 다음에는 확장시켜볼 것이다.