
현재 지난 부트캠프 때 미니 프로젝트로 진행한 캠핑온탑 프로젝트를 리팩토링하고 있다.
결제 시스템 기능을 구현한 후, 전체 기능들을 다시 살펴보니 결제 시 할인이 되는 기능이 있었으면 좋겠다는 생각이 들었다. 생각해보면 나도 결제를 할 때, 항상 사용할 수 있는 쿠폰이 있는지 확인하고 있으면 대부분 사용하는 편이다.
캠핑온탑의 경우 원래 무조건 정가로만 결제하는 시스템이었는데 그렇게만 하면 정 없고 소비자의 입장에서 괜히 손해보는? 서운한 느낌이 들 수 있기 때문에 쿠폰 발급 기능을 추가하기로 했다.
그리고 이때까지의 캠핑온탑은 발생할 수 있는 어떠한 이벤트도 존재하지 않았다. 물론 결제 기능도 하나의 주요 이벤트이지만 트래픽이 급격하게 몰릴 수 있는 상황 내지는 이벤트가 발생할 수 있는 여지가 거의 없었다.
하지만 선착순 쿠폰 발급 기능을 도입한다면 예컨대 특정 시각에 쿠폰 이벤트를 진행하여 많은 사람이 해당 시간에 몰려서 접속을 하는, 요청을 하는 상황을 만들 수 있다.
백엔드 엔지니어로서 향후 내가 취업을 했을 때 마주칠 수 있는 문제이고 그 정도의 규모를 경험할 수 없겠지만 이번 기회에 어떠한 방식으로 내부 구조가 이루어져 있는지 들여다보며 공부하고, 어떻게 처리를 해야 하는지 경험할 수 있는 좋은 기회가 될 수 있을 것으로 생각한다.
그렇다면 어떻게 구현해야 할까?
여러 레퍼런스들을 참고한 결과 크게 2가지로 정리할 수 있었다.
Redis-Kafka-Zookeeper를 사용하는 방식Redis만 사용하는 방식
Redis-Kafka-Zookeeper를 사용하는 방식
구조
RedisKafkaZookeeper장점
단점
Redis만 사용한 방식Redis 만 사용한 방식을 선택한 이유AWS EC2 에 1차 리팩토링을 마친 후 수정된 버전으로 배포를 해놓은 상태이다. 프리티어 이기 때문에 리소스가 충분하지 않아 카프카, 주키퍼 등을 모두 하나의 EC2에서 실행할 수 없는데다가 다른 서버도 보유하고 있지 않기 때문에 현실적으로 인프라를 구축할 수 없었다.EC2에 스프링 jar와 레디스 2개를 동시에 실행하는 건 문제가 없었다는 것을 확인했기 때문에 레디스만 사용하는 방식을 선택하는데 큰 문제가 없었다. @Getter
public enum Event {
FREE_CAMPING("무료숙박권");
private String name;
Event(String name) {
this.name = name;
}
}
먼저 이벤트를 간결하고 효과적으로 관리하기 위해 자바의 Enum 타입을 이용해 이벤트들을 나열했다.
일단 단일 이벤트만 존재하고 한 명의 유저가 한 번만 쿠폰을 발급받아서 사용할 수 있다는 상황을 가정하고 진행했다.
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Event event;
@Column(nullable = false)
private Integer price;
@Column(nullable = false, updatable = false)
private Date createdAt;
@PrePersist
void createdAt() {
this.createdAt = new Date();
}
}
@Getter
@Setter
public class EventCount {
private final Event event;
private int remainingCount;
public EventCount(Event event, int count) {
this.event = event;
this.remainingCount = count;
}
}
EventCount 클래스는 특정 이벤트의 남은 수량을 관리하는데 사용된다. EventCount 객체는 Event 객체에 의존적이다.사실 이어지는 부분이 가장 어려웠다.
그래서 스스로 헷갈리지 않고 계획대로 구현하기 위해 시나리오를 다시 한번 점검했다.
개발을 시작하기 전 구상했던 시나리오는 다음과 같다.
- 로그인
- 쿠폰 발급 버튼 혹은 페이지 접근
- 쿠폰 발급 버튼 클릭 (발급 요청)
- 요청한 순서대로 대기열 이동
- 발급 요청을 한 유저들이 대기열로 들어오면 순차적으로 정해진 수량만큼 쿠폰 발급
- 대기열 비움
- 이벤트 종료
@Configuration
@RequiredArgsConstructor
public class RedisConfig {
private final RedisProperty redisProperty;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisProperty.getRedisHost(), redisProperty.getRedisPort());
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory());
return redisTemplate;
}
}
@Getter
@Setter
@Component
@ConfigurationProperties("spring.redis")
public class RedisProperty {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private int redisPort;
}
@Slf4j
@Component
@RequiredArgsConstructor
public class EventScheduler {
private final CouponService couponService;
// 애플리케이션 시작 시 쿠폰 수량 초기화
@PostConstruct
private void init() {
Event event = Event.FREE_CAMPING;
int initialCouponCount = 3; // 초기 쿠폰 수량 설정 (예: 3개)
couponService.setEventCount(event, initialCouponCount);
log.info("이벤트 {}의 초기 쿠폰 수량이 {}으로 설정되었습니다.", event, initialCouponCount);
}
// 요청이 들어올 때 호출되는 메서드
public void processQueue(Event event) {
// 쿠폰이 남아 있는지 확인한 후 발행 시도
if (couponService.getRemainingEventCount(event) > 0) {
log.info("이벤트 처리 중: {}", event);
couponService.publish(event);
} else {
log.info("===== 선착순 이벤트가 종료되었습니다: {} =====", event);
}
}
}
EventScheduler 클래스는 쿠폰 이벤트와 관련된 주요 작업을 스케줄링한다. processQueue 메서드는 이벤트 큐를 처리하고,@RestController
@RequestMapping("/coupons")
@RequiredArgsConstructor
@CrossOrigin("*")
public class CouponController {
private final CouponService couponService;
@PostMapping("/request/{eventId}")
public ResponseEntity<String> requestCoupon(@PathVariable("eventId") String eventId, @AuthenticationPrincipal User user) {
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("사용자가 인증되지 않았습니다.");
}
Event event = Event.valueOf(eventId);
// 사용자가 이미 해당 이벤트의 쿠폰을 받았는지 확인
if (!couponService.canIssueCoupon(user, event)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("이미 해당 이벤트의 쿠폰을 받았습니다.");
}
// 쿠폰 발급 큐에 사용자 추가 시도
boolean addedToQueue = couponService.addQueue(event, user);
if (!addedToQueue) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("쿠폰 요청 추가에 실패하였습니다.");
}
// 큐에 추가 후 실제 쿠폰 발급 처리
couponService.processQueue(event);
return ResponseEntity.ok("쿠폰이 발급되었습니다.");
}
@GetMapping("/my")
public ResponseEntity<List<GetCouponRes>> myCoupons(@AuthenticationPrincipal User user) {
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
List<GetCouponRes> coupons = couponService.getUserCoupons(user);
if (coupons.isEmpty()) {
return ResponseEntity.noContent().build();
}
return ResponseEntity.ok(coupons);
}
}
requestCoupon 메소드는 특정 이벤트에 대한 쿠폰을 요청한다.@Slf4j
@Service
@RequiredArgsConstructor
public class CouponService {
private final RedisTemplate<String, String> redisTemplate;
private final UserCouponRepository userCouponRepository;
private final UserRepository userRepository;
private final CouponRepository couponRepository;
// 이벤트 쿠폰 수량 설정
public void setEventCount(Event event, int count) {
redisTemplate.opsForValue().set(event.name() + "_COUNT", String.valueOf(count));
}
// 남은 이벤트 쿠폰 수량 가져오기
public int getRemainingEventCount(Event event) {
String countStr = redisTemplate.opsForValue().get(event.name() + "_COUNT");
if (countStr != null) {
try {
return Integer.parseInt(countStr);
} catch (NumberFormatException e) {
log.error("Redis에서 카운트 값을 파싱하지 못했습니다", e);
}
}
return 0;
}
// 이벤트 쿠폰 수량 감소
public void decrementEventCount(Event event) {
redisTemplate.opsForValue().decrement(event.name() + "_COUNT");
}
// 큐에 사용자 추가
public boolean addQueue(Event event, User user) {
if (user == null) {
log.error("사용자가 null입니다");
throw new IllegalArgumentException("사용자는 null일 수 없습니다");
}
if (event == null) {
log.error("이벤트가 null입니다");
throw new IllegalArgumentException("이벤트는 null일 수 없습니다");
}
if (redisTemplate == null) {
log.error("RedisTemplate이 null입니다");
throw new IllegalStateException("RedisTemplate은 null일 수 없습니다");
}
if (getRemainingEventCount(event) <= 0) {
log.error("이벤트에 대한 쿠폰이 더 이상 남아 있지 않습니다: {}", event);
return false;
}
final long now = System.currentTimeMillis();
redisTemplate.opsForZSet().add(event.toString(), user.getEmail(), now);
log.info("큐에 추가됨 - {} at {}ms", user.getName(), now);
// 큐에 추가된 후 바로 큐를 처리
processQueue(event);
return true;
}
// 큐를 처리하는 메서드
public void processQueue(Event event) {
// 쿠폰이 남아 있는지 확인한 후 발행 시도
if (getRemainingEventCount(event) > 0) {
log.info("이벤트 처리 중: {}", event);
publish(event);
} else {
log.info("===== 선착순 이벤트가 종료되었습니다: {} =====", event);
}
}
// 큐에 있는 사용자들에게 쿠폰 발행
public void publish(Event event) {
List<User> users = getUsersFromQueue(event);
for (User user : users) {
issueCoupon(event, user);
decrementEventCount(event);
redisTemplate.opsForZSet().remove(event.toString(), user.getEmail());
}
}
// 해당 이벤트의 쿠폰을 이미 받았는지 확인
public boolean canIssueCoupon(User user, Event event) {
return userCouponRepository.findByUserIdAndCouponEvent(user.getId(), event).isEmpty();
}
// 큐에 남아있는 사용자 수 가져오기
public long getSize(Event event) {
return redisTemplate.opsForZSet().size(event.toString());
}
// 사용자에게 쿠폰 발행
public void issueCoupon(Event event, User user) {
Coupon coupon = couponRepository.save(Coupon.builder()
.event(event)
.price(10000)
.build());
userCouponRepository.save(UserCoupon.builder()
.user(user)
.coupon(coupon)
.isUsed(false)
.build());
log.info("'{}'에게 {} 쿠폰이 발급되었습니다", user.getName(), event.name());
}
// 큐에 있는 사용자 목록 가져오기
private List<User> getUsersFromQueue(Event event) {
Set<String> emails = redisTemplate.opsForZSet().range(event.toString(), 0, -1);
List<User> users = new ArrayList<>();
if (emails != null) {
for (String email : emails) {
userRepository.findByEmail(email).ifPresent(users::add);
}
}
return users;
}
// 쿠폰 발급 내역 조회
public List<GetCouponRes> getUserCoupons(User user) {
return userCouponRepository.findByUser(user).stream()
.filter(userCoupon -> !userCoupon.isUsed()) // Only include unused coupons
.map(userCoupon -> GetCouponRes.builder()
.id(userCoupon.getCoupon().getId())
.eventName(userCoupon.getCoupon().getEvent().name())
.price(userCoupon.getCoupon().getPrice())
.issuedAt(userCoupon.getCoupon().getCreatedAt().toString())
.build())
.collect(Collectors.toList());
}
}
CouponService 클래스는 선착순 쿠폰 발급 시스템의 주요 비즈니스 로직을 수행한다. Redis를 사용해서 쿠폰 수량과 큐 관리를 통해 고성능과 동시성을 보장한다. Redis가 보유한 자체 자료구조와 기능을 사용해 데이터의 일관성을 유지한다. 
애플리케이션이 실행되면 바로 쿠폰 수량이 정해진다.

쿠폰 발급 창으로 이동한다.

쿠폰 발급 버튼을 클릭한다.

만약 이미 발급받은 쿠폰이라면 더 이상 발급받을 수 없다.

발급 여부를 확인한 후 대기열에 추가된다.

쿠폰이 성공적으로 발급된 모습을 로그로 확인할 수 있다.

쿠폰 내역 조회 페이지에서 발급된 쿠폰을 확인할 수 있다.

장바구니 페이지에서 총액 220,000원에서 10,000원 할인 쿠폰을 적용해서 210,000원으로 최종 결제 금액이 변경된 것을 확인할 수 있다.
처음에 기획한 시나리오 그대로 구현이 되어서 정말 뿌듯하다.
그리고 여러 유저로 쿠폰 발급 시도를 해본 결과 요청한 순서대로 쿠폰이 발급되고 이후에 유저들의 경우 쿠폰 발급이 되지 않은 모습을 확인하는 것으로 구현 작업을 마무리했다.
여러 레퍼런스들을 참고했지만 우리 서비스와 유사한 버전이 많지 않았기에 초기에 코드를 작성하는데 어려움이 있었지만 Redis의 자료구조에 대해서 학습하면서 방향을 잡을 수 있었다.
물론 내가 구현한 코드가 대규모 서비스에는 적합하지 않을 수 있고, 코드가 효율적이지 않을 수 있다. 더 좋은 방법이 있을수도 있다.
하지만 구현을 시작할 때 다짐한 건 초기 기획을 흔들지 않고 그대로 구현하자는 것이었다. 타협하기보다는 하는데까지 일단 한 번 해보자는 마음으로 진행했고 구현한 후에 여러 테스트를 통해 유지보수 및 성능 개선은 추후에 하는 것을 목표로 생각했기에 계획한 기간 내에 완성할 수 있었다.
여담이지만 부트캠프 때 최종 프로젝트를 진행하면서 느낀점이 이번 리팩토링 스프린트에서도 녹아들었다. 개발자는 코드를 잘 짜는 것이 중요하지만, WBS에서 기획한 대로, 스케줄대로 개발을 진행하는 것도 개발자의 중요한 덕목이라는 것을 몸소 깨달았기 때문에 이후에 진행하는 프로젝트도 리팩토링이지만 스프린트로 차근차근 진행하고 있다.
다시 쿠폰 시스템으로 돌아가서 이번 선착순 쿠폰 발급 시스템을 도입하고 나니 기존에 구현한 결제 시스템도 수정했다. 그래서 미리 이야기하자면 다음에 포스팅할 쿠폰 적용 + 결제 시스템 도 수정해서 결과적으로 할인된 가격으로 결제할 수 있는 시스템이 캠핑온탑 서비스에 도입되었다.