fix: meeting 참석자 데이터 정규화

This commit is contained in:
djeon 2025-10-27 11:07:35 +09:00
parent 87a1bb456b
commit 06f1a13a47
12 changed files with 1102 additions and 1353 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@ -45,6 +45,8 @@ public class MeetingService implements
private final MinutesWriter minutesWriter;
private final CacheService cacheService;
private final EventPublisher eventPublisher;
private final com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantWriter participantWriter;
private final com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader participantReader;
/**
* 회의 생성
@ -96,6 +98,12 @@ public class MeetingService implements
// 5. 회의 저장
Meeting savedMeeting = meetingWriter.save(meeting);
// 5-1. 참석자 목록 저장
if (command.participants() != null && !command.participants().isEmpty()) {
participantWriter.saveParticipants(meetingId, command.participants());
log.debug("Participants saved: meetingId={}, count={}", meetingId, command.participants().size());
}
// 6. 캐시 저장 (TTL: 10분)
try {
cacheService.cacheMeeting(meetingId, savedMeeting, 600);
@ -382,22 +390,19 @@ public class MeetingService implements
}
// 이미 참석자로 등록되었는지 확인
if (meeting.getParticipants() != null && meeting.getParticipants().contains(command.email())) {
if (participantReader.existsParticipant(command.meetingId(), command.email())) {
log.warn("Email {} is already a participant of meeting {}", command.email(), command.meetingId());
throw new BusinessException(ErrorCode.DUPLICATE_RESOURCE);
}
// 참석자 목록에 추가
meeting.addParticipant(command.email());
// 저장
meetingWriter.save(meeting);
// 참석자 저장
participantWriter.saveParticipant(command.meetingId(), command.email());
// TODO: 실제 이메일 발송 구현 필요
// 이메일 발송 서비스 호출
// emailService.sendInvitation(command.email(), meeting, command.frontendUrl());
// 현재는 로그만 남기고 성공으로 처리
log.info("Invitation email would be sent to {} for meeting {} (Frontend URL: {})",
log.info("Invitation email would be sent to {} for meeting {} (Frontend URL: {})",
command.email(), meeting.getTitle(), command.frontendUrl());
log.info("Participant invited successfully: {} to meeting {}", command.email(), command.meetingId());

View File

@ -0,0 +1,24 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import java.util.List;
/**
* 참석자 조회 인터페이스
*/
public interface ParticipantReader {
/**
* 회의 ID로 참석자 목록 조회
*/
List<String> findParticipantsByMeetingId(String meetingId);
/**
* 사용자 ID로 참여 회의 목록 조회
*/
List<String> findMeetingsByParticipant(String userId);
/**
* 특정 회의에 특정 사용자가 참석자로 등록되어 있는지 확인
*/
boolean existsParticipant(String meetingId, String userId);
}

View File

@ -0,0 +1,29 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import java.util.List;
/**
* 참석자 저장 인터페이스
*/
public interface ParticipantWriter {
/**
* 회의에 참석자 추가
*/
void saveParticipant(String meetingId, String userId);
/**
* 회의에 참석자 목록 일괄 저장
*/
void saveParticipants(String meetingId, List<String> userIds);
/**
* 회의에서 참석자 삭제
*/
void deleteParticipant(String meetingId, String userId);
/**
* 회의의 모든 참석자 삭제
*/
void deleteAllParticipants(String meetingId);
}

View File

@ -3,6 +3,7 @@ package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingJpaRepository;
import lombok.RequiredArgsConstructor;
@ -24,48 +25,72 @@ import java.util.stream.Collectors;
public class MeetingGateway implements MeetingReader, MeetingWriter {
private final MeetingJpaRepository meetingJpaRepository;
private final ParticipantReader participantReader;
@Override
public Optional<Meeting> findById(String meetingId) {
return meetingJpaRepository.findById(meetingId)
.map(MeetingEntity::toDomain);
.map(this::enrichWithParticipants);
}
@Override
public List<Meeting> findByOrganizerId(String organizerId) {
return meetingJpaRepository.findByOrganizerId(organizerId).stream()
.map(MeetingEntity::toDomain)
.map(this::enrichWithParticipants)
.collect(Collectors.toList());
}
@Override
public List<Meeting> findByStatus(String status) {
return meetingJpaRepository.findByStatus(status).stream()
.map(MeetingEntity::toDomain)
.map(this::enrichWithParticipants)
.collect(Collectors.toList());
}
@Override
public List<Meeting> findByOrganizerIdAndStatus(String organizerId, String status) {
return meetingJpaRepository.findByOrganizerIdAndStatus(organizerId, status).stream()
.map(MeetingEntity::toDomain)
.map(this::enrichWithParticipants)
.collect(Collectors.toList());
}
@Override
public List<Meeting> findByScheduledTimeBetween(LocalDateTime startTime, LocalDateTime endTime) {
return meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.map(MeetingEntity::toDomain)
.map(this::enrichWithParticipants)
.collect(Collectors.toList());
}
@Override
public List<Meeting> findByTemplateId(String templateId) {
return meetingJpaRepository.findByTemplateId(templateId).stream()
.map(MeetingEntity::toDomain)
.map(this::enrichWithParticipants)
.collect(Collectors.toList());
}
/**
* Meeting 엔티티를 도메인으로 변환하면서 participants 정보 추가
*/
private Meeting enrichWithParticipants(MeetingEntity entity) {
Meeting meeting = entity.toDomain();
List<String> participants = participantReader.findParticipantsByMeetingId(entity.getMeetingId());
return Meeting.builder()
.meetingId(meeting.getMeetingId())
.title(meeting.getTitle())
.purpose(meeting.getPurpose())
.description(meeting.getDescription())
.scheduledAt(meeting.getScheduledAt())
.endTime(meeting.getEndTime())
.location(meeting.getLocation())
.startedAt(meeting.getStartedAt())
.endedAt(meeting.getEndedAt())
.status(meeting.getStatus())
.organizerId(meeting.getOrganizerId())
.participants(participants)
.templateId(meeting.getTemplateId())
.build();
}
@Override
public Meeting save(Meeting meeting) {
MeetingEntity entity = MeetingEntity.fromDomain(meeting);

View File

@ -0,0 +1,101 @@
package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantWriter;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingParticipantJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
* 참석자 Gateway 구현체
* ParticipantReader, ParticipantWriter 인터페이스 구현
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ParticipantGateway implements ParticipantReader, ParticipantWriter {
private final MeetingParticipantJpaRepository participantRepository;
@Override
@Transactional(readOnly = true)
public List<String> findParticipantsByMeetingId(String meetingId) {
return participantRepository.findByMeetingId(meetingId).stream()
.map(MeetingParticipantEntity::getUserId)
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public List<String> findMeetingsByParticipant(String userId) {
return participantRepository.findByUserId(userId).stream()
.map(MeetingParticipantEntity::getMeetingId)
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public boolean existsParticipant(String meetingId, String userId) {
return participantRepository.existsByMeetingIdAndUserId(meetingId, userId);
}
@Override
@Transactional
public void saveParticipant(String meetingId, String userId) {
if (!participantRepository.existsByMeetingIdAndUserId(meetingId, userId)) {
MeetingParticipantEntity participant = MeetingParticipantEntity.builder()
.meetingId(meetingId)
.userId(userId)
.invitationStatus("PENDING")
.attended(false)
.build();
participantRepository.save(participant);
log.debug("Participant saved: meetingId={}, userId={}", meetingId, userId);
} else {
log.debug("Participant already exists: meetingId={}, userId={}", meetingId, userId);
}
}
@Override
@Transactional
public void saveParticipants(String meetingId, List<String> userIds) {
if (userIds == null || userIds.isEmpty()) {
return;
}
List<MeetingParticipantEntity> participants = userIds.stream()
.filter(userId -> !participantRepository.existsByMeetingIdAndUserId(meetingId, userId))
.map(userId -> MeetingParticipantEntity.builder()
.meetingId(meetingId)
.userId(userId)
.invitationStatus("PENDING")
.attended(false)
.build())
.collect(Collectors.toList());
if (!participants.isEmpty()) {
participantRepository.saveAll(participants);
log.debug("Participants saved: meetingId={}, count={}", meetingId, participants.size());
}
}
@Override
@Transactional
public void deleteParticipant(String meetingId, String userId) {
participantRepository.deleteById(new com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantId(meetingId, userId));
log.debug("Participant deleted: meetingId={}, userId={}", meetingId, userId);
}
@Override
@Transactional
public void deleteAllParticipants(String meetingId) {
participantRepository.deleteByMeetingId(meetingId);
log.debug("All participants deleted: meetingId={}", meetingId);
}
}

View File

@ -9,7 +9,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@ -59,12 +59,16 @@ public class MeetingEntity extends BaseTimeEntity {
@Column(name = "organizer_id", length = 50, nullable = false)
private String organizerId;
@Column(name = "participants", columnDefinition = "TEXT")
private String participants;
@Column(name = "template_id", length = 50)
private String templateId;
/**
* 회의 참석자 목록 (일대다 관계)
*/
@OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<MeetingParticipantEntity> participants = new ArrayList<>();
public Meeting toDomain() {
return Meeting.builder()
.meetingId(this.meetingId)
@ -78,7 +82,11 @@ public class MeetingEntity extends BaseTimeEntity {
.endedAt(this.endedAt)
.status(this.status)
.organizerId(this.organizerId)
.participants(parseParticipants(this.participants))
.participants(this.participants != null
? this.participants.stream()
.map(MeetingParticipantEntity::getUserId)
.collect(Collectors.toList())
: List.of())
.templateId(this.templateId)
.build();
}
@ -96,7 +104,6 @@ public class MeetingEntity extends BaseTimeEntity {
.endedAt(meeting.getEndedAt())
.status(meeting.getStatus())
.organizerId(meeting.getOrganizerId())
.participants(formatParticipants(meeting.getParticipants()))
.templateId(meeting.getTemplateId())
.build();
}
@ -110,18 +117,4 @@ public class MeetingEntity extends BaseTimeEntity {
this.status = "COMPLETED";
this.endedAt = LocalDateTime.now();
}
private static List<String> parseParticipants(String participants) {
if (participants == null || participants.isEmpty()) {
return List.of();
}
return Arrays.asList(participants.split(","));
}
private static String formatParticipants(List<String> participants) {
if (participants == null || participants.isEmpty()) {
return "";
}
return String.join(",", participants);
}
}

View File

@ -0,0 +1,78 @@
package com.unicorn.hgzero.meeting.infra.gateway.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 회의 참석자 Entity
* meeting_id와 user_id를 복합키로 사용하여 다대다 관계 표현
*/
@Entity
@Table(name = "meeting_participants")
@IdClass(MeetingParticipantId.class)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MeetingParticipantEntity extends BaseTimeEntity {
/**
* 회의 ID (복합키)
*/
@Id
@Column(name = "meeting_id", length = 50)
private String meetingId;
/**
* 사용자 ID (이메일) (복합키)
*/
@Id
@Column(name = "user_id", length = 100)
private String userId;
/**
* 초대 상태 (PENDING, ACCEPTED, DECLINED)
*/
@Column(name = "invitation_status", length = 20)
@Builder.Default
private String invitationStatus = "PENDING";
/**
* 참석 여부
*/
@Column(name = "attended")
@Builder.Default
private Boolean attended = false;
/**
* 회의 엔티티와의 관계
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "meeting_id", insertable = false, updatable = false)
private MeetingEntity meeting;
/**
* 초대 수락
*/
public void accept() {
this.invitationStatus = "ACCEPTED";
}
/**
* 초대 거절
*/
public void decline() {
this.invitationStatus = "DECLINED";
}
/**
* 참석 처리
*/
public void markAsAttended() {
this.attended = true;
}
}

View File

@ -0,0 +1,31 @@
package com.unicorn.hgzero.meeting.infra.gateway.entity;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 회의 참석자 복합키
* meeting_id와 user_id를 복합키로 사용
*/
@Getter
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class MeetingParticipantId implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 회의 ID
*/
private String meetingId;
/**
* 사용자 ID (이메일)
*/
private String userId;
}

View File

@ -0,0 +1,45 @@
package com.unicorn.hgzero.meeting.infra.gateway.repository;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantId;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 회의 참석자 JPA Repository
*/
@Repository
public interface MeetingParticipantJpaRepository extends JpaRepository<MeetingParticipantEntity, MeetingParticipantId> {
/**
* 회의 ID로 참석자 목록 조회
*/
List<MeetingParticipantEntity> findByMeetingId(String meetingId);
/**
* 사용자 ID로 참여 회의 목록 조회
*/
List<MeetingParticipantEntity> findByUserId(String userId);
/**
* 회의 ID와 초대 상태로 참석자 목록 조회
*/
List<MeetingParticipantEntity> findByMeetingIdAndInvitationStatus(String meetingId, String invitationStatus);
/**
* 회의 ID로 참석자 전체 삭제
*/
@Modifying
@Query("DELETE FROM MeetingParticipantEntity p WHERE p.meetingId = :meetingId")
void deleteByMeetingId(@Param("meetingId") String meetingId);
/**
* 회의 ID와 사용자 ID로 참석자 존재 여부 확인
*/
boolean existsByMeetingIdAndUserId(String meetingId, String userId);
}

View File

@ -0,0 +1,41 @@
-- 회의 참석자 테이블 생성
CREATE TABLE IF NOT EXISTS meeting_participants (
meeting_id VARCHAR(50) NOT NULL,
user_id VARCHAR(100) NOT NULL,
invitation_status VARCHAR(20) DEFAULT 'PENDING',
attended BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (meeting_id, user_id),
CONSTRAINT fk_meeting_participants_meeting
FOREIGN KEY (meeting_id) REFERENCES meetings(meeting_id)
ON DELETE CASCADE
);
-- 기존 meetings 테이블의 participants 데이터를 meeting_participants 테이블로 마이그레이션
INSERT INTO meeting_participants (meeting_id, user_id, invitation_status, attended, created_at, updated_at)
SELECT
m.meeting_id,
TRIM(participant) as user_id,
'PENDING' as invitation_status,
FALSE as attended,
m.created_at,
m.updated_at
FROM meetings m
CROSS JOIN LATERAL unnest(string_to_array(m.participants, ',')) AS participant
WHERE m.participants IS NOT NULL AND m.participants != '';
-- meetings 테이블에서 participants 컬럼 삭제
ALTER TABLE meetings DROP COLUMN IF EXISTS participants;
-- 인덱스 생성
CREATE INDEX idx_meeting_participants_user_id ON meeting_participants(user_id);
CREATE INDEX idx_meeting_participants_invitation_status ON meeting_participants(invitation_status);
CREATE INDEX idx_meeting_participants_meeting_id_status ON meeting_participants(meeting_id, invitation_status);
-- 코멘트 추가
COMMENT ON TABLE meeting_participants IS '회의 참석자 정보';
COMMENT ON COLUMN meeting_participants.meeting_id IS '회의 ID';
COMMENT ON COLUMN meeting_participants.user_id IS '사용자 ID (이메일)';
COMMENT ON COLUMN meeting_participants.invitation_status IS '초대 상태 (PENDING, ACCEPTED, DECLINED)';
COMMENT ON COLUMN meeting_participants.attended IS '참석 여부';