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. 데이터베이스 최적화
- 인덱스 전략: 이메일 인증 여부와 생성 시간에 복합 인덱스
- 배치 삭제: 대량 데이터 삭제 시 성능 최적화
- 읽기 전용 트랜잭션: 조회 성능 향상
결론
본 프로젝트를 통해 다음과 같은 핵심 기술들을 성공적으로 구현하였습니다:
- DSL 기반 쿼리 시스템: 직관적이고 유연한 리뷰 조회 인터페이스
- 반응형 프로그래밍: 높은 성능과 확장성을 제공하는 비동기 처리
- 분산 락: 멀티 인스턴스 환경에서의 안전한 스케줄링
- 마이크로서비스 통합: BFF 패턴을 통한 효율적인 서비스 조합
- 성능 최적화: 캐싱, 배치 처리, 인덱스 최적화 등 다양한 기법 적용
이러한 구현을 통해 확장 가능하고 유지보수 가능한 대규모 분산 시스템을 구축할 수 있었으며, 실제 운영 환경에서 안정적으로 작동할 수 있는 견고한 아키텍처를 완성하였습니다.
728x90
'BindProject' 카테고리의 다른 글
| 유저 활동 데이터 수집 및 밴드룸(합주실) 방문자 통계 서비스 기획/분석 (11) | 2025.08.17 |
|---|---|
| 리뷰 모듈 기획·설계·구현 정리서 (review × bandroom-info 메시징 & 캐싱 중심) (8) | 2025.08.16 |
| 지금까지 프로젝트 리팩토링 시즌 도입 (7) | 2025.08.12 |
| 메시징 플레이그라운드 만들기 (4) | 2025.08.12 |
| 구현된 모든 서비스를 컨테이너화 시키는 여정기 (7) | 2025.08.05 |