개요
사실 블로깅할 소재가 쌓여있다.. 인턴하면서 국제화 리팩터링 한 건도 그렇고 알림/메일링 서버도,,, 사업보안인증을 위해서 메인서버 취약점을 고쳤던 소재도 남아있는데,,
클라이언트께서 신학기 맞이 이벤트를 준비하시면서 첫회원가입시에 발급하는 쿠폰 옵션 변경 요청이 들어왔는데 나름 유연하게 리팩터링 해놨던 터라 무려 10분도 안되어서 대응을 완료했기 때문에 겸사겸사 작성을 해본다.
실제 운영을 하면서 개발자로서 가장 고민되는 부분은, 클라이언트의 갑작스러운 요청사항에 얼마나 빠르고 효율적으로 대응할 수 있는가이다.
최근 클라이언트로부터 '신학기 맞이 이벤트'를 위한 쿠폰 옵션 변경 요청을 받았다.
다행히도 이전에 쿠폰 발급 전략을 기간별, 이벤트별, 회원별로 유연하게 관리할 수 있도록 설계해둔 덕분에, 단 10분 이내에 요청 사항을 처리할 수 있었다.
본 글에서는 실제 클라이언트의 요구 사항을 사례로 들어, 하드코딩 방식의 한계점과 상태관리 및 쿠폰 발급 전략이 왜 중요한지, 그리고 어떻게 설계하면 다양한 상황에 유연하게 대응할 수 있는지 분석하고자 한다.
또한 앞으로 소개할 쿠폰 관리 전략에서는 회원가입 축하 쿠폰뿐만 아니라 생일 쿠폰 등 정기적 또는 일회성 쿠폰들의 효율적인 발급 및 관리 방안까지 다룰 예정이다.
요청 사항 분석과 내 생각
대부분 클라이언트 요청은 예상을 벗어난다.
실제 요구는 예측하기 어렵고, 때로는 합의된 요구 사항 이상의 기능을 요청하는 경우도 흔히 발생한다.
클라이언트는 자신의 사업이 요구하는 비즈니스 흐름을 명확히 이해하고 있기에, 이를 온전히 기술적으로 구현하기 위해선 개발자 역시 해당 비즈니스에 대한 깊은 이해가 필요하다.
아래는 실제로 클라이언트와의 대화내용이다.
개발자 입장에서 해당 요청을 분석해보자면,
- 신규 가입 회원 및 기존 회원을 위한 일회성 쿠폰 발급
- 발급 기간과 사용 기한이 명확히 정해져 있음
- 기존의 웰컴 쿠폰은 일시 중단
- 쿠폰 사용 조건이 기존과 다름 (모든 음료 및 디저트 중 하나를 50% 할인)
만약 쿠폰 발급 로직을 하드코딩 방식으로 작성했다면, 클라이언트의 이번 요청을 해결하기 위한 개발자의 선택지는 극히 제한적이다.
예를 들어:
- 기존 쿠폰 로직을 즉시 변경하고, 이벤트 기간이 끝나면 원상복구하거나,
- 쿠폰의 유효기간이 기존에 고정값으로 설정되어 있다면, 이벤트 기간을 임시로 코드에 직접 삽입하는 등 임시방편적 작업을 해야 한다.
추가로 클라이언트가 '발급된 모든 가입 쿠폰을 종류별로 한눈에 관리'하고 싶다고 했을 때도 역시 비슷한 문제가 발생한다.
결국, 쿠폰에 추가 상태 필드를 급히 넣거나 새로운 타입 구분자를 만들어 대응할 수밖에 없다.
생일 쿠폰 역시 마찬가지다.
회원별로 매달 정기적으로 발급되며, 이를 배치작업으로 처리해야 한다면? 일회성 쿠폰과 정기적 쿠폰의 관리 방식은 어떻게 다르게 접근해야 할까?
이외에도 다음과 같은 다양한 상황을 미리 고려해 설계해야 한다.
- 쿠폰의 유효기간이 회원가입일이 아니라 특정 이벤트 날짜에 따라 정해질 경우
- 쿠폰의 발급 및 중단 기간이 빈번하게 변경되는 경우
- 발급된 쿠폰을 별도의 조건(회원 등급, 과거 구매 내역)에 따라 분류해 관리하고자 하는 경우
본 글에서는 이러한 현실적 문제를 깊게 고민한 결과로, 다양한 쿠폰 전략과 상태 관리를 활용하여 유연하고 신속한 대응 방식을 제안하고자 한다.
기존 구조, 그리고 쿠폰 발급 전략의 도입
기존의 구조 및 문제점
초기 시스템에서는 기프티콘과 같은 단순 쿠폰만 지원하였으나, 회원가입 축하, 생일 축하(배치 처리), 이벤트 쿠폰과 같은 다양한 요구사항이 추가됨에 따라 서비스 로직이 점점 복잡해졌다.
초기에는 각 기능을 별도의 서비스 코드로 만들어 회원가입이나 이벤트 로직 내부에서 직접 호출하는 방식을 취했고, 이로 인해 도메인이 혼잡해지기 시작했다.
이벤트의 경우에도 부랴부랴 당시 참여했던 이벤트이름을 가진 도메인을 만들고 구현에 급급했었다.
하지만 여러 기능을 만들다보기 기능의 속성들에 대해서 고찰하기 시작했고 이는 정책 도메인으로의 성숙에 이르렀다.
당시의 서비스 구조는 다음과 같이 복잡했다
CouponService
├── CouponRepository (쿠폰 저장)
├── CouponHistoryRepository (기록 조회)
├── 쿠폰 발급 조건 검증 (직접 처리)
├── 정책별 발급 방식 분기 (하드 코딩)
├── DB에서 중복 체크 (서비스단에서 직접 처리)
이 방식은 쿠폰 발급 조건이 다양해지면서 유지보수가 어렵고 코드의 복잡도가 증가하는 문제를 야기했다.
쿠폰 발급 전략과 정책 도입
위와 같은 문제를 해결하기 위해 정책(Policy) 도메인을 도입했다.
쿠폰의 특성을 분석하여 불변 속성(쿠폰 이미지, 설명, 발급자 등)과 가변 속성(사용정보, 유효기간 등)을 분리하여 각각 별도의 클래스로 관리했다.
금융 자산과 유사한 쿠폰의 특성상 불변성과 가변성을 명확히 구분하는 것이 중요했기 때문이다.
정책과 속성의 명확한 구분
정책은 아래와 같은 명확한 속성들로 정의되었다:
public class CouponPolicy {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private PolicyType policyType;
private String policyName;
@Embedded
private CouponAttribute couponAttribute;
@Enumerated(EnumType.STRING)
private IssuanceFrequency issuanceFrequency;
@JsonProperty("isActive")
private boolean isActive;
private int stock;
private Long expiryDurationInSeconds;
}
겸사겸사 Policy 로 인해 변하지 않는(불변) 속성은 따로 쿠폰 속성이라고 따로 클래스를 만들어 주었다.
금융자산의 특성을 가지기 때문에 불변인 속성과 그렇지 않은 속성간의 바운더리 또한 필요함을 느꼈었다.
불변 속성은 CouponAttribute로 따로 관리했다:
@Embeddable
@Getter
public class CouponAttribute {
private String imageUrl;
private String description;
@Column(nullable = false)
private Long giverId;
private String giverInfo;
private boolean isPrepaid;
private int maxUsageAmount;
private LocalDateTime expiryDate;
}
쿠폰 엔티티도 구조가 명확해졌다:
public class Coupon extends BaseEntity {
@Id
@GeneratedValue(generator = "uuid2")
@Column(nullable = false)
private UUID id;
@Column(nullable = false)
private String couponNumber;
@Column
private Long receiverId;
@Column
private Long policyId;
@Embedded
private CouponAttribute couponAttribute;
@Embedded
private CouponUsageInfo couponUsageInfo;
}
당연하지만 CouponUsageInfo 는 변할수 있는 객체들을 묶어두었다.
자 그럼 서비스단이 가벼워지지는 않았지만 어느정도 책임을 분산할 준비가 되었다.
CouponService
├── CouponRepository (쿠폰 저장)
├── CouponPolicyRepository (정책 조회)
├── CouponHistoryRepository (기록 조회)
├── 쿠폰 발급 조건 검증 (직접 처리) ❌
├── 정책별 발급 방식 분기 ❌
├── DB에서 중복 체크 ❌
책임 분리의 성과
정책과 속성을 명확히 구분한 결과 다음과 같은 책임 분리가 가능해졌다
- CouponPolicy: 쿠폰 발급 조건 및 전략 관리
- CouponAttribute: 쿠폰의 불변 속성 관리
- CouponProcessor: 쿠폰 발급 프로세스 관리
- EligibilityStrategy: 쿠폰 발급 가능 여부 판단
현재 서비스 레이어의 한계와 미해결 과제
서비스 레이어를 명확히 구분했지만, 서비스 계층의 구조는 여전히 단일 클래스에 많은 책임이 집중되는 문제가 존재했다
서비스단은 어떨까? 대강 코드를 보면(실제로는 더 비대하다)
@Service
public class CouponService {
private final CouponRepository couponRepository;
private final CouponPolicyRepository couponPolicyRepository;
public void issueCoupon(Long userId, PolicyType policyType) {
CouponPolicy policy = couponPolicyRepository.findByPolicyTypeAndIsActive(policyType, true)
.orElseThrow(() -> new NoSuchElementException("정책 없음"));
// 발급 가능 여부 확인 (여기서 DB 조회 포함)
if (couponRepository.existsByUserIdAndPolicyId(userId, policy.getId())) {
throw new IllegalArgumentException("이미 발급됨");
}
// 쿠폰 생성 후 저장
Coupon coupon = new Coupon(userId, policy);
couponRepository.save(coupon);
}
}
구조는 직관적이지만, 클래스 하나가 너무 많은 역할을 담당한다.
그럼에도 아직 해결되지 않은 문제
- 단일 책임 원칙(SRP) 위반
- 쿠폰 발급 조건 검사는 발급 정책(Policy)의 역할이지만 CouponService가 담당한다.
- 만약 정책이 복잡해지면 CouponService는 거대해지고, 유지보수도 어려워진다.
- 테스트가 어려움
- CouponService는 CouponRepository, CouponPolicyRepository 등을 직접 사용하므로,
단위 테스트가 어렵고, DB 종속적인 테스트가 필요해진다.
- CouponService는 CouponRepository, CouponPolicyRepository 등을 직접 사용하므로,
- 유연성이 부족
- 정책 추가(예: 특정 VIP 회원만 발급 가능, 이번 요청 사항등,,)를 하려면 issueCoupon을 직접 수정해야 한다.
- 기존 코드 수정이 필요하므로, OCP(개방-폐쇄 원칙)를 위반한다.
결론적으로, 앞으로는 정책과 검증 로직을 완전히 서비스 레이어에서 분리하고, 전략 패턴과 인터페이스를 적극적으로 활용하여 쿠폰 발급과 관련된 유연성을 극대화하는 방향으로 구조를 더욱 발전시킬 필요가 있다.
서비스 레이어의 분리에 대한 고민
서비스 레이어 분리의 필요성
서비스 레이어(Service Layer)의 분리는 애플리케이션 구조를 명확하게 만들고 유지보수성과 확장성을 높이기 위한 중요한 설계 원칙이다.
하지만 현실적으로는 메서드가 몇 개 없더라도 무조건적으로 인터페이스와 구현체를 나누는 사례가 빈번히 발생한다.
나 역시 초기에 그 필요성을 명확히 인지하지 못해 불편함을 느낀 이후에야 레이어 분리의 중요성을 깨닫게 되었다. (미리 할걸,,)
위에서도 봤다싶이 현재의 쿠폰 관리 서비스 구조를 분석해보면 명확히 서비스 레이어 분리가 요구된다.
다양한 유즈케이스와 도메인이 뒤섞여 코드의 책임과 목적이 불분명해졌기 때문이다.
실제로 쿠폰 관련 단순 CRUD 작업부터, 사용자와의 도메인 연관성, 발급 여부 검증(Validation), 쿠폰 사용 이력 관리, 금융 거래로 인한 원장 기록 등 수많은 의존성이 복잡하게 얽혀 있었다. omg,,
서비스 레이어 분리를 위한 접근법
서비스 레이어를 명확히 나누기 위해 다음과 같은 기준으로 분석 및 분리를 시도했다.
- 책임 주체와 도메인 구분
- 행위의 명확한 정의
- 객체의 생애주기와 목적
이처럼 여러가지 기준을 통해서 레이어를 바라보았다.
지금 위의 구조를 보면 확실하게 상위 서비스레이어의 분리가 필요함이 느껴진다.
1. CRUD 중심의 기본 서비스 레이어
가장 기본적인 CRUD 작업은 서비스 레이어의 핵심이자 기본이다.
이 작업은 복잡하지 않지만, 반드시 독립적으로 관리되어야 한다고 생각한다.
이건 가장 근본에 가깝다고 생각하기에, 현재 존재하는 서비스 코드인 CouponService 에 남겨두려했다.
혹은 CouponCRUDService 라고 단순하게 네이밍 해도 좋겠다.
2. 정책과 전략 중심의 서비스 레이어
정책과 관련된 기능은 별도의 레이어로 명확히 분리했다.
여기서의 핵심 포인트는 쿠폰 정책을 이벤트 기반, 회원 기반, 일반 쿠폰 등 다양한 조건에 따라 분류하여 독립적으로 관리하는 것이다.
이를 효과적으로 관리하기 위해 팩토리 패턴과 전략 패턴을 조합하여 유연한 구조를 구축했다.
이를 통해 각 쿠폰 정책의 유효성 검증 및 유효기간 처리 등을 명확하게 관리할 수 있다.
3. 행위 중심의 서비스 레이어
쿠폰의 주요 행위는 크게 ‘발급’과 ‘사용’으로 나뉜다.
‘사용’은 상대적으로 명확하지만, 실제로는 유효기간 관리, 회원 등급별 조건 처리, 무효화 처리 등 복합적인 로직이 존재한다.
‘발급’ 또한 앞서 설명한 전략 기반의 정책을 적용하여 보다 복잡한 로직이 존재한다.
따라서 쿠폰의 사용과 발급 행위를 각각 명확하게 정의하고, 이를 별도의 서비스로 분리하여 관리하는 것이 효율적이다.
발급 전략 분리와 레이어 분리 적용
쿠폰 발급 로직의 복잡성과 책임의 명확한 분리를 위해 전략 패턴을 적용하고 서비스 레이어를 분리하였다. 특히, 기존의 직접적인 검증 방식에서 벗어나, 명확한 책임 분리를 통해 쿠폰 발급 프로세스를 단순화하고 유지보수성을 높였다.
CouponProcessor 구조 개선
CouponProcessor는 다음과 같은 흐름으로 쿠폰 발급 프로세스를 관리하도록 구조화되었다
@Service
@RequiredArgsConstructor
public class CouponProcessor {
private final CouponPolicyRepository couponPolicyRepository;
private final EligibilityStrategyFactory eligibilityStrategyFactory;
@Transactional
public void processCouponIssuance(Long userId, PolicyType policyType, int maxRetries) {
CouponPolicy policy = couponPolicyRepository.findByPolicyTypeAndIsActive(policyType, true)
.orElseThrow(() -> new NoSuchElementException(ErrorMessage.POLICY_NOT_FOUND));
EligibilityStrategy strategy = eligibilityStrategyFactory.getStrategy(policy.getIssuanceFrequency());
if (!strategy.isEligible(userId, policy.getId())) {
throw new IllegalArgumentException(ErrorMessage.NOT_ELIGIBLE);
}
int retryCount = 0;
while (retryCount <= maxRetries) {
// 재고 감소 및 정책 저장에 대한 예외 처리
try {
CouponPolicy couponPolicy = couponPolicyRepository.findById(policy.getId())
.orElseThrow(() -> new NoSuchElementException("Policy not found"));
couponPolicy.decrementStock(); // 재고 감소
couponPolicyRepository.save(couponPolicy); // 재고 감소 저장 (낙관적 락 적용)
// 유니크한 카드 번호를 생성하거나,
// 쿠폰 생성에 따른 개인정보보호법을 따르기 위해 발급 내역을 저장하거나(이건 나중에 메시지큐나 비동기로 빼도 좋을듯하다.)
completeCouponIssuance(userId, couponPolicy);
break;
} catch (OptimisticLockException e) {
retryCount++;
if (retryCount > maxRetries) {
throw new RuntimeException(ErrorMessage.CONCURRENT_UPDATE);
}
}
}
}
}
물론 완벽한 객체지향은 아니다. 메서드 내부에 흐름이 존재하고 누군가는 함수형 프로그래밍이라고 이야기 할수도 있다. (또 불편함을 겪으면 다시 변경하지 않을까?)
이 구조는 기존에 쿠폰 서비스가 직접 검증을 처리하던 방식에서 벗어나, EligibilityStrategyFactory가 적절한 발급 전략을 반환하고 전략별 클래스에서 발급 조건을 분리하여 개별 처리하여 책임을 분산하였다.
책임 분리 및 얻은 이점
책임 분리 (SRP 적용)
- CouponProcessor는 쿠폰 발급만 담당
- 발급 조건 검사는 EligibilityStrategy에 위임하여 역할을 분리
테스트 용이성 증가
- CouponProcessor는 EligibilityStrategyFactory를 주입받아 사용
- 발급 정책과 관계없이 개별 테스트가 가능
- Mock을 활용한 단위 테스트가 가능함
확장성 증가 (OCP 적용)
- 새로운 발급 정책을 추가할 때 기존 코드 수정 없이 새로운 EligibilityStrategy를 추가하면 된다.
- Strategy 패턴을 활용하여 비즈니스 로직 변경을 유연하게 처리
마무리
현재 구조가 상당히 개선되었으나 여전히 추가로 다룰 만한 주제들이 존재한다.
생일 쿠폰과 같이 주기적으로 발급이 필요한 경우 배치(batch) 처리를 통해 별도의 주기적 프로세스를 구성할 필요가 있으며, 이벤트 쿠폰과 같이 순간적으로 높은 트래픽이 몰릴 때는 락(lock)을 이용한 동시성 처리 메커니즘 도입이 필요하다.
다음 글에서는 정책을 나눈 기준과, 각각의 정책이 적용된 예시, 배치 및 락을 활용한 발급 방식에 대해 보다 심도 있게 다뤄볼 예정이다.
참고 문헌
데이터 중심 애플리케이션 설계 - 마틴 클레프만
자바/스프링 개발자를 위한 실용주의 프로그래밍 - 김우근
내 머리속 내공냠냠
'Dayner 프로젝트' 카테고리의 다른 글
Dayner에서 이벤트성, 일회성 쿠폰을 발급하고 관리하는 방법 [2] (1) | 2025.03.07 |
---|---|
Dayner에서 구매 이력을 관리하는 방법 [1] (feat: 개인정보보호법, 원장 데이터) (4) | 2024.08.31 |
영업시간 디자인 변경에 대응한 리팩토링 일지 [2] (feat: 버전별 API 캐시 전략) (0) | 2024.08.25 |
영업시간 디자인 변경에 대응한 리팩토링 일지 [1] (feat: 연결된 일정 구현하기) (0) | 2024.08.22 |
반년 간의 EC2 Spring 배포 Thread Stravation 장애 해결기 (0) | 2024.08.10 |