❤️🔥 과제 목표
- 실시간 데이터 처리를 위한 아키텍처 설계 흐름을 복습
- Kafka + WebSockect(STOMP) 기반의 실시간 메시징 구조를 직접 구현해본다
- DB 설계를 통해 메시지/이벤트 기반 데이터 모델링 능력을 강화한다.
☠️ 트러블 슈팅
- 발생한 Issue
- 1. 이전 과제 및 프로젝트 진행 간 @Transactional을 어디에, 무엇을 써야 하는가?
- 2. 엔티티가 아닌 DTO 중심으로 실시간 메시지/응답을 어떻게 설계하면 좋은가?
- 3. 투표 수 집계 과정을 구현하면서 기존 엔티티에 별도로 컬럼값으로 매칭을 시켜주지 않아도 구현이 가능한가?
- 해결방법 / 찾아본 내용
- 1. 이전에 정리했던 Transactional의 개념처럼 DB의 수정이 일어나는 구간에서의 안정적인 변경과정을 위한 @Transactional 사용이 필요했다.
→ 실제 사용한 메서드는 DB 변경이 이루어지는 투표 생성, 투표 종료 메서드에 해당 어노테이션을 사용하였다.
// 투표하기
@Transactional // DB 변경을 안전하게 처리하기 위한 어노테이션
public VoteRecordResponseDto vote(Long voteId, VoteRequestDto requestDto) {
Vote vote = voteRepository.findById(voteId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 투표입니다."));
if (vote.getVoteStatus() == VoteStatus.CLOSED) {
throw new IllegalStateException("이미 종료된 투표입니다.");
}
User user = userRepository.findByLoginId(requestDto.getVoterId())
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 사용자입니다."));
Candidate candidate = candidateRepository.findById(requestDto.getCandidateId())
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 후보입니다."));
if(!candidate.getVote().getId().equals(voteId)) {
throw new IllegalArgumentException("해당 투표의 후보가 아닙니다.");
}
VoteRecord voteRecord = voteRecordRepository
.findByCandidate_Vote_IdAndUser_LoginId(voteId, requestDto.getVoterId())
.orElseGet(() -> {
VoteRecord newRecord = new VoteRecord();
newRecord.setUser(user);
newRecord.setCreatedAt(LocalDateTime.now());
return newRecord;
});
voteRecord.setCandidate(candidate);
VoteRecord saved = voteRecordRepository.save(voteRecord);
//알림 발송 부분 추가
VoteNotificationDto notification = VoteNotificationDto.builder()
.voteId(vote.getId())
.title(vote.getTitle())
.message("누군가 [" + vote.getTitle() + "] 투표에 투표를 진행했습니다!")
.build();
messagingTemplate.convertAndSend("/topic/announcements", notification);
return VoteRecordResponseDto.from(saved);
}
//투표 종료
@Transactional
public VoteCloseResponseDto closeVote(Long voteId, String authorLoginId) {
// 투표 조회
Vote vote = voteRepository.findById(voteId)
.orElseThrow(() -> new IllegalArgumentException("존재하지 않는 투표입니다."));
// 투표 생성자 여부 검증
String ownerLoginId = vote.getUser().getLoginId();
if (!ownerLoginId.equals(authorLoginId)) {
throw new IllegalStateException("투표를 생성한 사용자만 종료할 수 있습니다.");
}
// 투표 상태가 이미 CLOSED인지 체크
if (vote.getVoteStatus() == VoteStatus.CLOSED) {
throw new IllegalStateException("이미 종료된 투표입니다.");
}
// 투표 상태 변경
vote.close();
// 응답 DTO 반환
return VoteCloseResponseDto.from(vote);
}
- 2. 엔티티를 바로 보내게 되면 지나치게 많은 정보들이 노출될 가능성이 있고, 양방향 관계에서는 무한루프에 빠지게 될 수 있다.
엔티티는 내부에서 사용할 구조이고, dto는 외부에서 사용할 구조라고 정리하는 것이 필요하다.
→ 실시간 데이터 처리하는 과정에서는 그 종류가 늘어날 가능성이 높기 때문에 요구하는 응답문의 양식에 맞춰 필요한 요소들만 dto에 담아 처리하도록 진행했다.
public class VoteDetailResponseDto {
// 투표 기본 정보
private Long voteId;
private String title;
private VoteStatus status;
// 후보 & 득표 정보
private List<CandidateResultDto> candidates; // (id, name, voteCount)
// 전체 투표 수
private long totalVotes;
}
- 3. 서비스 레이어에서 계산하도록 처리하여 후보별 득표 수를 countMap으로 계산하도록 처리했다. 이후 CandidateDtos로 감싸서 전달하였다.
// 투표 상세 조회
public VoteDetailResponseDto getVoteDetail(Long voteId) {
// 투표 Entity 조회
Vote vote = voteRepository.findById(voteId)
.orElseThrow(() -> new IllegalArgumentException("해당 투표가 없습니다. id = " + voteId));
// 후보 목록 조회
List<Candidate> candidates = candidateRepository.findByVote_Id(voteId);
// 해당 후보에 대한 모든 투표 기록 조회
List<VoteRecord> records = voteRecordRepository.findByCandidate_Vote_Id(voteId);
// 후보 별 득표 수 집계
Map<Long, Long> countMap = records.stream()
.collect(Collectors.groupingBy(
r-> r.getCandidate().getId(),
Collectors.counting()
));
// 후보 DTO 변환
List<CandidateResultDto> candidateDtos = candidates.stream()
.map(c -> new CandidateResultDto(
c.getId(),
c.getName(),
countMap.getOrDefault(c.getId(), 0L)
))
.toList();
// totalVotes = 전체 투표 기록 수
long totalVotes = records.size();
return new VoteDetailResponseDto(vote, candidateDtos, totalVotes);
}
- stream().collect(Collectors.groupingBy(...)) 를 통해 후보의 id값을 확인하고, 그 값에 해당하는 VoteRecord의 개수를 세도록 한다.
- 이후 후보 Dto를 통해 후보의 번호를 조회, 키 값이 있는 경우는 그 값을 그대로 반환하고, 없는 경우는 0표로 보여주기 위한 과정을 구현했다.