1. 개요
쿠폰의 사용 기간 정책을 저장할 방식에 대해 고민한 내용을 정리한다.
조건부 속성 문제를 DDD + Factory Method Pattern 을 사용하여
DB 레벨에서는 nullable하게 application 레벨에서는 null을 허용하지 않게 설계하여 해결했다.
쿠폰 사용 기간에 대한 요구사항은 아래와 같다.
1. 쿠폰은 사용할 수 있는 유효 기간을 가진다.
2. 쿠폰 사용 기간 정책은 3가지로 나뉜다.
2.1. FIXED: 정해진 시작일과 종료일사이에만 사용할 수 있는 정책.
2.2. AFTER: 발급일로부터 특정일 이후까지 사용할 수 있는 정책
2.3. MIXED: 발급일로부터 특정일 이후까지, 고정된 기간 내에 사용할 수 있는 정책
2. 데이터 추출
위의 요구사항을 데이터 관점에서 살펴보면 아래와 같은 데이터 프로퍼티가 필요하다.
type
: 쿠폰 사용 기간 정책의 유형을 나타낸다. (예:FIXED
,AFTER
,MIXED
)after_day
: 발급일로부터 사용 기간을 계산하는 데 필요한 일수started_at
: 쿠폰의 사용 가능 시작 날짜ended_at
: 쿠폰의 사용 종료 날짜
2.1. 조건부 속성 문제
해당 데이터를 그대로 DB에 저장하면 타입에 따른 조건부 속성 문제가 발생한다.
아래는 각 타입과 타입에 필요한 데이터를 나열한다.
FIXED
: started_at, ended_at
AFTER
: after_day
MIXED
: started_at, ended_at, after_day
즉, 타입에 따라 필요한 컬럼이 다르기에 nullable
하게 처리해야 한다.
2.2. 해결 방법
조건부 속성 문제를 테이블설계로 해결하는 방법에는 크게 3가지 방법이 존재한다.
1. nullable
컬럼에 NULL을 허용해 타입에 따라 값을 비워둘 수 있도록 허용한다.
장점:
- 대부분의 데이터베이스와 ORM에서 쉽게 처리 가능.
- 추가 테이블 없이 하나의 테이블로 모든 데이터를 관리 가능.
- 설계가 단순하고 유지보수가 쉬움.
단점:
- NULL의 의미를 명확히 이해하고 관리해야 함.
- NPE를 방지하기 위한 애플리케이션 로직이 필요함
- 타입 확장 시점에 필요 시 기존 테이블 구조 변경이 강제됨
2. 특수 값 사용
특정한 값(예: 0이나 -1)을 사용하여 "유효하지 않은 값"을 나타낸다.
장점:
- 데이터 일관성 (모든 데이터가 특정한 값으로 채워져있음)
- DB에서 NULL 처리를 완전히 피할 수 있음.
단점:
- 특수 값의 의미를 명시적으로 관리해야 하므로 가독성과 유지보수성 저하.
- 특수 값이 도메인 로직과 강하게 결합되어, 오해나 잘못된 처리 가능성 증가.
3. 테이블 분리 (정규화)
각 타입에 맞는 테이블로 분리하여 데이터를 관리한다.
장점:
- 데이터 일관성: DB에서 NULL 처리를 완전히 피할 수 있음.
- 확장성: 각 타입에 대한 추가 데이터를 추가하기 쉽고, 새로운 타입을 추가하기 용이함
단점:
- 조인의 복잡성 증가
- 설계 복잡도 증가
이번에는 타입이 추가될 가능성이 0에 가깝기에 확장성은 다소 떨어지더라도 데이터를 저장하기에 가장 간편한 nullable 방식을 사용하여 해결했다.
2.3. 추출된 DB 테이블 및 ORM 매핑 클래스
create table m_coupon_policy
(
id bigint auto_increment primary key,
type enum ('AFTER', 'FIXED', 'MIXED') not null
after_day int null,
ended_at datetime(6) null,
started_at datetime(6) null,
);
@Getter
@NoArgsConstructor
@Table(name = "m_coupon_policy")
@Entity(name = "CouponPolicy")
public class CouponPolicyEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private CouponPolicyEntityType type;
private Integer afterDay;
private LocalDateTime startedAt;
private LocalDateTime endedAt;
3. 도메인 설계
DB 레벨에서는 조건부 속성 문제를 nullable을 통해 해결했다. 하지만 개인적으로 Application 레벨에서는 프로퍼티가 Nullable한 상태를 가지는 것을 선호하지 않는다.
3.1. Nullable의 문제점
객체의 프로퍼티가 null
일 경우, 객체의 상태가 불완전하다. 의도치 않은 문제가 발생할 수 있다.
객체를 설계한 개발자가 각 타입에 따라 필수적인 프로퍼티는 null
값이 할당될 수 없게 처리를 해야한다. 이는 코드의 복잡성을 증가시키고, 객체를 사용하는 개발자에게 혼란을 줄 수 있다.
3.2. Switch문의 문제점
public class CouponPolicy {
private Long id;
private CouponPolicyType type;
private Integer afterDay;
private LocalDateTime startedAt;
private LocalDateTime endedAt;
// ...
// 쿠폰의 만료일을 계산하여 반환
public LocalDateTime calcExpireTime(LocalDateTime baseTime) {
return switch (type) {
case FIXED -> endedAt;
case AFTER -> baseTime.plusDays(afterDay);
case MIXED -> {
LocalDateTime endedAtForAfterDay = baseTime.plusDays(afterDay);
yield (endedAt.isBefore(endedAtForAfterDay)) ? endedAt : endedAtForAfterDay;
}
};
}
}
JPA Entity와 동일한 방식으로 도메인 클래스를 정의했을 때, 쿠폰의 만료일을 계산하는 로직안에 타입을 기준으로 Switch
문을 사용하게 된다.
Switch문은 확장성이 떨어지기에 유지보수에 용이한 구조는 아니지만 현재 프로젝트에서는 type이 추가될 가능성이 거의 없기에 나쁜 코드가 아니라고 생각한다.
하지만 클린 코드라는 도서에는 이런 구절이 있다.
일반적으로 나는 switch 문을 단 한 번만 참아준다. 다형적 객체를 생성하는 코드 안에서다. - Clean Code (로버트 C. 마틴)
코드가 클린해서 나쁠 건 없기에 nullable 문제도 해결할 겸 로버트씨의 말씀을 귀담아 듣고 적용해보겠다.
3.3. 해결 방법 (DDD + Factory Method)
해당 프로젝트는 도메인 주도 개발으로 진행중이었기에 JPA Entity와 Domain Entity의 구분이 되어있는 상태이다. 즉, JPA Entity와 Domain Entity가 동일한 형식으로 매핑되지 않아도 된다는 의미이기에 해당 장점을 이용하여 nullable의 문제를 해결한다.
이번에는 Desiegn Pattern 중 Factory Method Pattern을 사용하여 해당 문제를 해결하려 한다.
3.4. Factory Method Pattern
Factory Method를 사용하여 nullable의 문제점과 만료일을 결정하는 메소드의 swich문을 개선해보자.
먼저 모든 쿠폰 사용 기간 정책 타입에 필요한 기능을 인터페이스로 정의한다.
public interface CouponPolicy {
Long getId();
LocalDateTime calculateExpireDateTime(LocalDateTime baseTime);
}
CouponPolicy 를 구현하는 각 타입별 클래스를 정의한다.
public class MixedCouponPolicy implements CouponPolicy{
private final Long id;
private final Integer afterDay;
private final LocalDateTime startedAt;
private final LocalDateTime endedAt;
// ...
@Override
public LocalDateTime calculateExpireDateTime(LocalDateTime baseTime) {
LocalDateTime endedAtForAfterDay = baseTime.plusDays(afterDay);
if (endedAt.isBefore(endedAtForAfterDay)) {
return endedAt;
}
return endedAtForAfterDay;
}
}
public class FixedCouponPolicy implements CouponPolicy{ ... }
public class AfterCouponPolicy implements CouponPolicy{ ... }
구체 클래스를 생성해줄 팩토리 메소드를 정의한다.
public class CouponPolicyFactory {
public static CouponPolicy generateCouponPolicy(
CouponPolicyType type, Long id, Integer afterDay,
LocalDateTime startedAt, LocalDateTime endedAt
) {
return switch (type) {
case MIXED -> new MixedCouponPolicy(id, afterDay, startedAt, endedAt);
case FIXED -> new FixedCouponPolicy(id, startedAt, endedAt);
case AFTER -> new AfterCouponPolicy(id, afterDay);
};
}
}
JPA Entity에서는 팩토리 메소드를 이렇게 사용할 수 있다.
public class CouponPolicyEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ...
public CouponPolicy toDomain() {
return CouponPolicyFactory.generateCouponPolicy(
type.toDomain(), id, afterDay, startedAt, endedAt
);
}
}
4. 결론
쿠폰 사용 기간 정책을 저장하고 처리하는 과정에서 발생하는 조건부 속성 문제와 Application 레벨에서의 Nullable 속성 문제를 해결하기 위해 DDD와 Factory Method Pattern을 활용한 설계를 적용했다.
조건부 속성 문제 해결:
- DB 레벨에서는 조건부 속성을
nullable
로 처리하여 간단하고 효율적으로 데이터를 저장할 수 있게 했다. - Application 레벨에서는 각 정책 타입별 클래스를 정의하고,
nullable
속성을 제거하여 객체의 상태를 명확히 유지했다.
객체 지향 설계 원칙 준수:
- 단일 책임 원칙(SRP): 각 클래스는 자신만의 책임을 가지며, 객체 상태가 명확해졌다.
- 개방-폐쇄 원칙(OCP): 정책 타입 확장 시 기존 코드를 수정할 필요가 없도록 설계되었다.
Reference
클린 코드 - 로버트 C. 마틴
Factory Method
/ Design Patterns / Creational Patterns Factory Method Also known as: Virtual Constructor Intent Factory Method is a creational design pattern that provides an interface for creating objects in a superclass, but allows subclasses to alter the type of objec
refactoring.guru
'트러블슈팅' 카테고리의 다른 글
커서 기반 페이지네이션으로 조회 성능 개선 (0) | 2025.02.12 |
---|---|
DB의 외부식별자와 내부식별자 분리 (Auto_Increment vs UUID) (0) | 2025.02.10 |
Spring Event - Listener가 이벤트를 인식하지 못하는 문제 (Type Erasure) (0) | 2025.01.16 |
이벤트 기반 비동기 통신 구현 및 Kafka를 사용한 이유 (feat. RabbitMQ) (2) | 2024.12.20 |
Jackson 직렬화 내부 동작 방식으로 인한 Redis 캐시 데이터 파싱 오류 (0) | 2024.12.16 |