BindProject

리뷰 정렬부터 이메일 미인증 유저 정리까지

dding-shark 2025. 8. 17. 17:12
728x90

리뷰 시스템 DSL 구현

DSL(Domain Specific Language) 설계

리뷰 조회를 위한 직관적이고 유연한 DSL을 설계하여 복잡한 쿼리를 간단하게 표현할 수 있도록 구현했습니다.

ReviewQueryBuilder 클래스 구현

public class ReviewQueryBuilder {
    private Long reservationId;
    private Long userId;
    private Long bandRoomId;
    private Long reviewRate;
    private SortCriteria sortCriteria;
    private Sort.Direction sortDirection = Sort.Direction.ASC;

    public static ReviewQueryBuilder builder() {
        return new ReviewQueryBuilder();
    }

    // 메서드 체이닝을 통한 DSL 구현
    public ReviewQueryBuilder byReservationId(Long reservationId) {
        this.reservationId = reservationId;
        return this;
    }

    public ReviewQueryBuilder byUserId(Long userId) {
        this.userId = userId;
        return this;
    }

    // JPA Specification 빌드
    public Specification<Reviews> buildSpecification() {
        return (root, query, criteriaBuilder) -> {
            List<Predicate> predicates = new ArrayList<>();

            if (reservationId != null) {
                predicates.add(criteriaBuilder.equal(root.get("reservationId"), reservationId));
            }
            // ... 기타 조건들

            return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
        };
    }
}

DSL 사용 예시

// 복합 조건 쿼리 예시
ReviewQueryBuilder query = ReviewQueryBuilder.builder()
    .byBandRoomId(1L)
    .byReviewRate(5L)
    .sortBy(ReviewQueryBuilder.SortCriteria.CREATED_AT)
    .descending();

List<ReviewResponse> reviews = reviewQueryService.findReviews(query);

서비스 레이어 구현

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ReviewQueryServiceImpl implements ReviewQueryService {

    private final ReviewRepository reviewRepository;
    private final ReviewMapper reviewMapper;

    @Override
    public List<ReviewResponse> findReviews(ReviewQueryBuilder queryBuilder) {
        Specification<Reviews> specification = queryBuilder.buildSpecification();
        Sort sort = queryBuilder.buildSort();

        List<Reviews> reviews = reviewRepository.findAll(specification, sort);
        return reviews.stream()
                .map(reviewMapper::toResponse)
                .toList();
    }
}

더미 데이터 생성 시스템

현실적인 데이터 분포 구현

@RestController
@RequestMapping("/api/reviews/v1/dummy")
public class DummyDataController {

    private final ReviewCreateService reviewCreateService;

    /**
     * 현실적인 별점 분포를 위한 가중치 적용
     */
    private Long generateWeightedRating() {
        int rand = random.nextInt(100);

        if (rand < 5) return 1L;      // 5% - 1점
        else if (rand < 15) return 2L; // 10% - 2점  
        else if (rand < 30) return 3L; // 15% - 3점
        else if (rand < 65) return 4L; // 35% - 4점
        else return 5L;                // 35% - 5점
    }
}

데이터 특성

  • 총 리뷰 수: 330개 (밴드룸 ID 0-10, 각 30개)
  • 사용자 ID 범위: 0-1000
  • 별점 분포: 4-5점 위주의 현실적 분포
  • 리뷰 내용: 25가지 미리 정의된 한국어 리뷰

BFF 모듈 클라이언트 구현

WebClient 기반 반응형 클라이언트

@Component
public class ReviewClient {

    private final ClientMethodFactory clientMethod;

    public Mono<ResponseEntity<BaseResponse<?>>> getReviewsByBandRoomId(
            Long bandRoomId, String sortBy, String sortDirection, Long reviewRate) {

        String uri = UriComponentsBuilder.fromPath(BASE_URI + "/bandroom/" + bandRoomId)
                .queryParamIfPresent("sortBy", Optional.ofNullable(sortBy))
                .queryParamIfPresent("sortDirection", Optional.ofNullable(sortDirection))
                .queryParamIfPresent("reviewRate", Optional.ofNullable(reviewRate))
                .toUriString();

        return clientMethod.get(uri);
    }
}

밴드룸 상세 정보 통합

다중 서비스 호출 및 데이터 통합

@RestController
public class BandRoomController {

    public Mono<ResponseEntity<BaseResponse<BandRoomDetailResponseForClient>>> getBandRoom(
            @RequestParam Long bandRoomId) {

        // 병렬 서비스 호출
        Mono<BaseResponse<BandRoomDetailResponse>> bandRoomMono = 
            bandRoomClient.getBandRoom(bandRoomId)
                .map(response -> convertBaseResponse(response, new TypeReference<>() {}));

        Mono<BaseResponse<List<StudioResponse>>> studiosMono = 
            studioClient.getStudiosByBandRoom(bandRoomId)
                .map(response -> convertBaseResponse(response, new TypeReference<>() {}));

        Mono<BaseResponse<List<ReviewResponse>>> reviewsMono = 
            reviewClient.getReviewsByBandRoomId(bandRoomId, null, null, null)
                .map(response -> convertBaseResponse(response, new TypeReference<>() {}))
                .onErrorReturn(BaseResponse.success(new ArrayList<>()));

        // 데이터 통합 및 닉네임 조회
        return Mono.zip(bandRoomMono, studiosMono, productsMono, reviewsMono)
                .flatMap(this::processAndCombineData);
    }
}

반응형 프로그래밍 적용

블로킹 호출을 반응형으로 변환

Before (블로킹):

public String fetchNickname(String userId) {
    BaseResponse<String> response = webClient.get()
        .uri(uri)
        .retrieve()
        .bodyToMono(new ParameterizedTypeReference<>() {})
        .block(); // 블로킹 호출
    return response.getResult();
}

After (반응형):

public Mono<String> fetchNickname(String userId) {
    return webClient.get()
        .uri(uri)
        .retrieve()
        .bodyToMono(new ParameterizedTypeReference<>() {})
        .map(response -> response.getResult())
        .onErrorReturn("Unknown User");
}

닉네임 배치 조회 최적화

public Mono<Map<String, String>> getNicknamesByUserIds(List<String> userIds) {
    // 1. 캐시에서 먼저 조회
    Map<String, String> cached = getCachedNicknames(userIds);

    // 2. 캐시 미스된 ID들만 API 호출
    return userProfileClient.fetchNicknames(cacheMissedUserIds)
            .map(fetchedNicknames -> {
                // 3. 결과 통합 및 캐시 저장
                return combineAndCache(cached, fetchedNicknames);
            });
}

이메일 미인증 유저 정리 시스템

스케줄링 기반 정리 시스템

@Service
@RequiredArgsConstructor
@Slf4j
public class UnverifiedUserCleanupService {

    private final UserRepository userRepository;

    /**
     * 매시간 정각에 실행되어 1시간 이전 미인증 사용자 삭제
     */
    @Scheduled(cron = "0 0 * * * *")
    @Transactional
    public void cleanupUnverifiedUsers() {
        LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1);
        List<User> unverifiedUsers = 
            userRepository.findUnverifiedUsersCreatedBefore(oneHourAgo);

        if (!unverifiedUsers.isEmpty()) {
            userRepository.deleteAll(unverifiedUsers);
            log.info("이메일 미인증 사용자 {}명이 삭제되었습니다.", 
                    unverifiedUsers.size());
        }
    }
}

맞춤 쿼리 구현

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    @Query("SELECT u FROM User u WHERE u.isEmailVerified = false AND u.createdAt < :cutoffTime")
    List<User> findUnverifiedUsersCreatedBefore(@Param("cutoffTime") LocalDateTime cutoffTime);
}

분산 락 구현

Redis 기반 분산 락 서비스

@Service
@RequiredArgsConstructor
public class DistributedLockService {

    private final StringRedisTemplate redisTemplate;

    /**
     * 분산 락을 획득하고 작업을 실행한 후 자동 해제
     */
    public boolean executeWithLock(String lockKey, Duration lockDuration, Runnable task) {
        String lockValue = acquireLock(lockKey, lockDuration);

        if (lockValue == null) {
            return false; // 락 획득 실패
        }

        try {
            task.run(); // 작업 실행
            return true;
        } finally {
            releaseLock(lockKey, lockValue); // 락 해제
        }
    }

    private String acquireLock(String lockKey, Duration lockDuration) {
        String lockValue = UUID.randomUUID().toString();
        Boolean lockAcquired = redisTemplate.opsForValue()
            .setIfAbsent("distributed_lock:" + lockKey, lockValue, lockDuration);

        return Boolean.TRUE.equals(lockAcquired) ? lockValue : null;
    }
}

Lua 스크립트를 활용한 원자적 락 해제

private static final String UNLOCK_SCRIPT = 
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "    return redis.call('del', KEYS[1]) " +
    "else " +
    "    return 0 " +
    "end";

public boolean releaseLock(String lockKey, String lockValue) {
    Long result = redisTemplate.execute(
        unlockScript,
        Collections.singletonList("distributed_lock:" + lockKey),
        lockValue
    );

    return result != null && result == 1L;
}

분산 환경에서의 스케줄링 보호

@Scheduled(cron = "0 0 * * * *")
public void cleanupUnverifiedUsers() {
    boolean executed = distributedLockService.executeWithLock(
        CLEANUP_LOCK_KEY,
        Duration.ofMinutes(30),
        this::performCleanup
    );

    if (!executed) {
        log.info("다른 인스턴스에서 이미 실행 중이므로 건너뜁니다.");
    }
}

시스템 아키텍처

마이크로서비스 간 통신 흐름

[Client] → [BFF] → [Review Service]
    ↓         ↓         ↓
    ↓    [Redis Cache] [MySQL]
    ↓         ↓         ↓
    ↓    [User Profile Service]
    ↓         ↓
    ↓    [Auth Service]
    ↓         ↓
    → [RabbitMQ] ← [BandRoom Info Service]

데이터 흐름도

리뷰 생성 → RabbitMQ 이벤트 → 밴드룸 통계 업데이트
    ↓
캐시 무효화 → 닉네임 서비스 호출 → BFF 응답 통합

스케줄링 및 락 메커니즘

[Scheduler] → [Distributed Lock] → [Database Cleanup]
     ↓              ↓                    ↓
[Multiple Instances] → [Redis Lock] → [Single Execution]

성능 및 확장성 고려사항

1. 캐시 전략

  • Cache-Aside 패턴: 닉네임 조회 성능 최적화
  • TTL 설정: 10분간 캐시 유지로 메모리 효율성 확보
  • 배치 조회: 여러 사용자 닉네임을 한 번에 조회하여 N+1 문제 해결

2. 반응형 프로그래밍 도입 효과

  • 논블로킹 I/O: 높은 동시성 처리 능력
  • 백프레셔 처리: 시스템 안정성 향상
  • 리소스 효율성: 스레드 풀 사용량 최적화

3. 분산 락의 안전성

  • 원자적 연산: Lua 스크립트를 통한 경쟁 상태 방지
  • 자동 만료: 데드락 방지를 위한 TTL 설정
  • 소유권 검증: 락을 획득한 인스턴스만 해제 가능

4. 데이터베이스 최적화

  • 인덱스 전략: 이메일 인증 여부와 생성 시간에 복합 인덱스
  • 배치 삭제: 대량 데이터 삭제 시 성능 최적화
  • 읽기 전용 트랜잭션: 조회 성능 향상

결론

본 프로젝트를 통해 다음과 같은 핵심 기술들을 성공적으로 구현하였습니다:

  1. DSL 기반 쿼리 시스템: 직관적이고 유연한 리뷰 조회 인터페이스
  2. 반응형 프로그래밍: 높은 성능과 확장성을 제공하는 비동기 처리
  3. 분산 락: 멀티 인스턴스 환경에서의 안전한 스케줄링
  4. 마이크로서비스 통합: BFF 패턴을 통한 효율적인 서비스 조합
  5. 성능 최적화: 캐싱, 배치 처리, 인덱스 최적화 등 다양한 기법 적용

이러한 구현을 통해 확장 가능하고 유지보수 가능한 대규모 분산 시스템을 구축할 수 있었으며, 실제 운영 환경에서 안정적으로 작동할 수 있는 견고한 아키텍처를 완성하였습니다.

728x90