spring_2기[본캠프]/과제

[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 12

minwoo95 2026. 4. 30. 20:11

https://github.com/Hot6-NovelCraft/Hot6-NovelCraft

 

GitHub - Hot6-NovelCraft/Hot6-NovelCraft

Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.

github.com

 

개인별 배포용 레퍼지토리 [ AWS ]

https://github.com/MinWoo1995/Hot6-NovelCraft-local

 

GitHub - MinWoo1995/Hot6-NovelCraft-local

Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.

github.com

1. 오늘 한 일
오늘은 NovelCraft 프로젝트에서 이벤트 도메인 전체를 설계부터 구현까지 완성했다.
관리자와 사용자 권한을 명확히 분리하고, 선착순 동시성 제어와 Redis 캐싱 전략을 적용했으며,
포인트 즉시 지급 및 Kafka 기반 전체 회원 알림 발송까지 연동했다.
마지막으로 CodeRabbit 리뷰 반영 후 컨트롤러/서비스 테스트 코드를 작성해 커버리지 100%를 달성했다.

 

구현 API 목록

POST /api/admin/events - 이벤트 생성 (관리자)
GET /api/admin/events - 이벤트 목록 조회 (관리자, 실시간)
GET /api/admin/events/{eventId} - 이벤트 상세 조회 (관리자)
GET /api/admin/events/{eventId}/participants - 이벤트별 참여자 목록 조회 (관리자)
GET /api/events - 이벤트 목록 조회 (사용자, Redis 캐싱)
GET /api/events/{eventId} - 이벤트 상세 조회 (사용자, 종료 이벤트 캐싱)
POST /api/events/{eventId}/participants - 이벤트 참여 신청 (사용자, 선착순 + 포인트 지급)

추가 작업

AdminEventController / UserEventController 권한별 분리
AdminEventService / UserEventService 역할별 분리
EventExceptionEnum 구현 (ErrorCode 인터페이스 구현)
EventParticipant 테이블 (event_id, user_id) UniqueConstraint 적용
SecurityConfig /api/admin/** SUPER_ADMIN, ADMIN 권한 체크 추가
RedisConfig JavaTimeModule 적용 (LocalDateTime 직렬화 지원)
PointService chargeEventReward() 메서드 추가
NotificationType EVENT_CREATED 추가
NotificationEvent eventCreated() 정적 팩토리 메서드 추가
CodeRabbit 리뷰 7건 반영
컨트롤러/서비스 테스트 코드 4개 파일 48개 케이스 작성


2. 기술 선정 이유
2-1. Redisson 분산락 - 선착순 동시성 제어
이벤트 참여 신청은 짧은 시간에 트래픽이 폭발적으로 몰리는 구조라 동시성 제어가 핵심이었다.
세 가지 방식을 비교했다.


DB 비관적 락은 트래픽이 집중될 때 DB 커넥션을 오래 점유하고 병목이 생긴다.
선착순 이벤트처럼 순간적으로 수백 건이 몰리는 상황에서는 적합하지 않다고 판단했다.


Lua 스크립트는 Redis에서 원자적으로 처리가 가능하지만,
카운터 관리 로직이 복잡해지고 디버깅이 어렵다는 단점이 있다.


Redisson 분산락은 이미 프로젝트에 Redis 인프라가 구성되어 있어 추가 비용이 없고,
TTL 기반으로 데드락을 방지하며 멀티 인스턴스 환경에서도 동작한다.
구현도 직관적이어서 가장 적합하다고 판단했다.


RLock lock = redissonClient.getLock("lock:event:participate:" + eventId);
boolean acquired = lock.tryLock(5, 3, TimeUnit.SECONDS);


waitTime 5초는 대기 허용 시간이고, leaseTime 3초는 락 자동 해제 시간이다.
서버 장애로 락 해제가 안 되는 상황을 TTL이 자동으로 막아준다.


추가로 EventParticipant 테이블에 (event_id, user_id) UniqueConstraint를 걸어
Redisson 락이 leaseTime 초과로 뚫리는 극한 케이스에서도 DB 레벨에서 최종 방어하는 이중 동시성 제어 구조로 설계했다.


1차 방어 - Redisson 분산락 : 순차 처리, 선착순 인원 제어
2차 방어 - DB UniqueConstraint : 중복 참여 최종 방어


2-2. Redis 캐싱 전략 - 조회 API 부하 분산
이벤트 조회 API는 참여 신청과 달리 실시간 정확성이 덜 중요한 반면 요청이 매우 많을 수 있다.
캐싱 대상과 TTL을 역할에 따라 명확히 나눴다.


사용자 이벤트 목록 조회 - TTL 5분 제목, 기간 등 메타 정보는 자주 바뀌지 않으므로 단기 캐싱을 적용했다.
관리자가 이벤트를 생성하면 캐시를 즉시 evict해 정합성을 유지했다.


종료된 이벤트 상세 조회 - TTL 7일 이벤트가 종료되면 결과가 확정되어 더 이상 변경이 없다.
종료 시점에 Redis에 캐싱하면 이후 결과 조회 트래픽이 몰려도 DB에 부하가 없다.


진행 중 이벤트 / 참여 API - 캐싱 없음 실시간 참여 현황과 선착순 결과는 정확성이 중요하므로 캐싱을 적용하지 않았다.


처음에는 사용자 결과 조회도 캐싱 없이 가면 부하가 생기지 않냐는 이슈가 있었는데,
이벤트 종료 후에는 결과가 고정되므로 종료 시점에 캐싱하면 된다는 방향으로 정리됐다.


2-3. Kafka 기반 알림 발송 - 이벤트 생성 시 독자 전체 공지
알림 팀원이 이미 ApplicationEventPublisher + Kafka 기반 알림 인프라를 구축해놓았다.
@TransactionalEventListener(phase = AFTER_COMMIT)로 트랜잭션 커밋 이후에
Kafka에 메시지를 발행하는 구조라 알림 발송 실패가 이벤트 생성 트랜잭션에 영향을 주지 않는다.


이 인프라를 그대로 활용해 이벤트 생성 시 READER 역할을 가진 사용자에게만 알림을 발송했다.
전체 유저를 한 번에 메모리에 올리면 OOM 위험이 있어 1000건씩 페이지 단위로 배치 조회하는 방식을 적용했다.


javaint page = 0;
int batchSize = 1000;
while (true) {
    PageRequest pageRequest = PageRequest.of(page, batchSize);
    List<User> readers = userRepository.findAllByRole(UserRole.READER, pageRequest).getContent();
    if (readers.isEmpty()) break;
    readers.forEach(user -> eventPublisher.publishEvent(
            NotificationEvent.eventCreated(user.getId(), saved.getTitle(), saved.getId())
    ));
    page++;
}

3. 트러블슈팅
3-1. LocalDateTime Redis 직렬화 실패
문제 상황
사용자 이벤트 목록 조회 API를 호출하자 500 에러가 발생했다.
에러 메시지는 다음과 같았다.
SerializationException: Could not write JSON: Java 8 date/time type
`java.time.LocalDateTime` not supported by default


원인 분석
RedisConfig를 확인하니 JavaTimeModule이 적용된 ObjectMapper로 serializer를 만들었는데
실제 setValueSerializer()에는 기본 생성자로 새로 만든 serializer를 넣고 있었다.
JavaTimeModule이 적용된 serializer 변수가 완전히 무시되고 있었던 것이다.
java// 문제 코드 - serializer 변수가 있지만 새 객체를 넣음
GenericJackson2JsonRedisSerializer serializer =
        new GenericJackson2JsonRedisSerializer(objectMapper); // 이 변수가 무시됨

template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // 기본 생성자
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); // 기본 생성자


해결
팀원들과 협의 후 RedisConfig에서 두 줄을 수정했다.
기본 생성자 대신 JavaTimeModule이 적용된 serializer 변수를 그대로 사용하도록 변경했다.
기존 도메인에는 LocalDateTime을 Redis에 캐싱하는 곳이 없어 사이드이펙트가 없었다.
java// 수정 후
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);


3-2. SecurityConfig 권한 설정 - SUPER_ADMIN vs ADMIN
문제 상황
이벤트 생성 API를 Postman으로 테스트하니 403 Forbidden이 떨어졌다.
DB를 확인하니 테스트 계정의 role이 SUPER_ADMIN이었다.


원인 분석
SecurityConfig에 /api/admin/** 에 대해 hasAuthority("SUPER_ADMIN")으로 설정했는데,
이게 SUPER_ADMIN 단독으로만 열려있는 상태였다.


해결
실무상 SUPER_ADMIN 외에도 담당 ADMIN도 이벤트 관리가 가능해야 한다는 판단으로
hasAnyAuthority("ADMIN", "SUPER_ADMIN")으로 변경했다.
CodeRabbit이 권한 범위가 넓다고 지적했지만 기획 의도에 맞는 설정이라 의견을 답변으로 달고 유지했다.


3-3. CodeRabbit 리뷰 반영 - 7건
3-3-1. evictEventListCache() 중복 호출 (Minor)
AdminEventService의 createEvent()에서 동일한 evictEventListCache()를 두 번 호출하고 있었다.
불필요한 Redis 스캔/삭제가 2배 발생하는 문제였다. 한 번만 호출하도록 수정했다.


3-3-2. 캐시 키에 size/sort 누락 (Major)
UserEventService의 getEventList()에서 캐시 키를 status:pageNumber로만 구성했다.
같은 페이지 번호라도 size나 sort가 다른 요청이 동일 캐시를 재사용하는 문제가 있었다.
캐시 키에 pageSize와 sort를 추가했다.


java// 수정 전
String cacheKey = EVENT_LIST_CACHE_KEY + status + ":" + pageable.getPageNumber();

// 수정 후
String cacheKey = EVENT_LIST_CACHE_KEY + status
        + ":" + pageable.getPageNumber()
        + ":" + pageable.getPageSize()
        + ":" + pageable.getSort();


3-3-3. UPCOMING default 처리 불일치 (Major)
EventStatus의 switch문에서 UPCOMING이 default로 처리되어 findAll()이 호출됐다.
status=UPCOMING 요청이 들어오면 진행 중/종료 이벤트가 섞여서 반환되는 버그였다.
EventRepository에 findAllUpcoming() 쿼리를 추가하고 switch case를 명시적으로 분리했다.

 

// 수정 후
Page<Event> events = switch (status) {
    case UPCOMING -> eventRepository.findAllUpcoming(now, pageable);
    case ONGOING  -> eventRepository.findAllOngoing(now, pageable);
    case ENDED    -> eventRepository.findAllEnded(now, pageable);
};


3-3-4. 전체 READER 일괄 로드 OOM 위험 (Major)
userRepository.findAllByRole(READER)로 전체 독자를 한 번에 메모리에 올리는 방식이었다.
독자 수가 늘어날수록 생성 API 지연과 메모리 압박이 커지는 구조였다.
1000건씩 페이지 단위로 배치 조회하도록 변경했다.


3-3-5. EventCreateRequest 기간 검증 없음 (Major)
DTO에서 startedAt과 endedAt의 선후 관계 검증이 없어
startedAt > endedAt인 비정상 이벤트가 생성될 수 있었다.
@AssertTrue로 DTO 레벨에서 검증을 추가했다.
서비스 레벨의 중복 검증은 제거하고 EventExceptionEnum.EVENT_INVALID_PERIOD는 향후를 위해 남겨뒀다.


@AssertTrue(message = "이벤트 시작일은 종료일보다 이전이어야 합니다")
public boolean isValidPeriod() {
    if (startedAt == null || endedAt == null) return true;
    return startedAt.isBefore(endedAt);
}


3-3-6. maxParticipants null 허용 (Major)
@Min만 있고 @NotNull이 없어 null 값이 통과될 수 있었다.
포인트 지급 이벤트 특성상 참여 인원 제한 없이 운영하면 예산 통제가 불가능하다.
@NotNull을 추가해 필수값으로 변경하고, 서비스의 null 체크 분기도 제거했다.
DB의 max_participants 컬럼도 NOT NULL로 변경했다.


3-4. 테스트 코드 - JPA 엔티티 mock() 사용 불가 문제
문제 상황
UserEventServiceTest에서 mock(Event.class)으로 Event 객체를 만들고
given(event.getId()).willReturn(EVENT_ID)로 stubbing을 하니
UnfinishedStubbingException과 UnnecessaryStubbingException이 동시에 터졌다.
UnfinishedStubbingException:
-> at UserEventServiceTest.mockOngoingEvent(UserEventServiceTest.java:59)


원인 분석
JPA 엔티티에 @Getter, @NoArgsConstructor 등 Lombok 어노테이션이 붙어있으면
Mockito가 final 메서드로 인식하는 케이스가 있다.
특히 getId() 같은 메서드는 내부적으로 stubbing이 완료되지 않은 상태에서
다른 메서드 호출이 중간에 끼어들어 UnfinishedStubbingException이 발생했다.
또한 각 테스트에서 사용하지 않는 stubbing이 포함되어 UnnecessaryStubbingException도 함께 발생했다.


해결
mock(Event.class) 대신 실제 Event.create() 정적 팩토리 메서드로 실제 객체를 생성하도록 변경했다.
엔티티는 mock()을 피하고 실제 객체를 만드는 것이 올바른 테스트 패턴이다.
java// 수정 전 - mock() 사용 (문제 발생)
private Event mockOngoingEvent() {
    Event event = mock(Event.class);
    given(event.getId()).willReturn(EVENT_ID);
    given(event.isOngoing()).willReturn(true);
    ...
}

// 수정 후 - 실제 객체 사용
private Event ongoingEvent() {
    return Event.create(1L, "테스트 이벤트", "설명",
            5000L, 100L, NOW.minusDays(1), FUTURE);
}


RLock은 인터페이스라 @Mock 필드 선언이 불가능했다.
setupLock() 헬퍼 메서드 내부에서 직접 mock(RLock.class)로 생성하는 방식으로 해결했다.


javaprivate RLock setupLock(boolean acquired) throws InterruptedException {
    RLock mockLock = mock(RLock.class);
    given(redissonClient.getLock(anyString())).willReturn(mockLock);
    given(mockLock.tryLock(anyLong(), anyLong(), any(TimeUnit.class))).willReturn(acquired);
    lenient().when(mockLock.isHeldByCurrentThread()).thenReturn(acquired);
    return mockLock;
}

4. 느낀 점
오늘은 단순히 CRUD를 넘어 실제 서비스에서 고민해야 할 기술적인 문제들을 직접 설계하고 해결한 하루였다.
선착순 이벤트 참여라는 요구사항 하나에서 Redisson 분산락, DB UniqueConstraint 이중 방어,
Redis 캐싱 전략, 페이지 단위 배치 알림 발송까지 여러 기술이 자연스럽게 연결됐다.


기술은 문제를 해결하기 위해 선택하는 것이고, 각 선택에는 명확한 이유가 있어야 한다는 것을 다시 한번 체감했다.


CodeRabbit 리뷰에서 캐시 키에 size/sort가 빠진 것과 UPCOMING default 처리 불일치 버그를 잡아줬다.
혼자 개발하면 놓치기 쉬운 부분인데 자동화된 리뷰가 실질적인 버그를 찾아준다는 점이 인상적이었다.
특히 전체 READER 일괄 로드 OOM 위험 지적은 지금은 유저 수가 적어 문제가 없지만
서비스가 커지면 치명적인 문제가 될 수 있는 부분이었다.


코드를 작성할 때 현재 상황뿐 아니라 스케일이 커졌을 때를 항상 염두에 두어야 한다는 것을 배웠다.
테스트 코드에서 JPA 엔티티를 mock()으로 쓰면 안 된다는 것도 직접 겪으면서 알게 됐다.


이론으로는 "엔티티는 실제 객체를 써야 한다"는 걸 알고 있었지만,
UnfinishedStubbingException이 왜 발생하는지 원인을 파악하고 해결하는 과정에서 더 깊이 이해하게 됐다.


테스트 코드도 설계가 필요하고, 올바른 패턴을 지켜야 유지보수가 편하다는 것을 다시 느꼈다.


오후에 알림 팀원이 Kafka 인프라를 완성하면 eventCreated() 연동만 하면 전체 흐름이 완성된다.
오늘 설계한 구조가 팀원의 코드와 깔끔하게 붙을 수 있도록 인터페이스를 잘 맞춰둔 것 같아 뿌듯했다.