본문 바로가기

개발일지

[WebRTC] Janus를 활용해 화상회의 만들기(Spring편) - (1)

요구사항

실제로 화상회의를 구현하기 전에 요구사항을 명확히 하려고 했다.

화상회의에서 요구되는 사항은 다음과 같다.

  • 스터디 회원은 누구든지 화상회의 방을 생성할 수 있다.
  • 생성된 화상회의는 스터디에 종속적이고 스터디에 속해있지 않은 회원이 조회할 수 없다.
  • 생성된 화상회의는 웹 관리자, 스터디 관리자, 회원만이 수정 및 삭제할 수 있다.

이를 간단하게 그림으로 표현하면 다음과 같다.

현재 우리 서비스에는 백엔드 스터디와 프론트엔드 스터디가 존재한다.

백엔드 스터디에는 A라는 사람이 참여 중이고 프론트엔드 스터디에는 B라는 사람이 참여하고 있다.

이 상황에서 백엔드 스터디에 가입한 A라는 사람은 백엔드 스터디에 존재하는 모든 방에 모두 들어갈 수 있다. (비밀번호가 없는 방이라고 가정)

마찬가지로 프론트엔드 스터디에 가입한 B라는 사람은 프론트엔드 스터디에 존재하는 모든 방에 들어갈 수 있다. (비밀번호가 없는 방이라고 가정)

하지만 반대로 A라는 사람은 프론트엔드 방에 있는 방에 참여할 수 없고 B라는 사람은 백엔드 스터디에 있는 방에 참여할 수 없다.

이러한 요구사항을 만족하기 위해 어떻게 시스템을 구성해야할까 고민했다.

나는 방 생성, 조회, 수정, 삭제에 대한 부분은 Spring에서 Janus에 요청하는 방식을, 실제로 화상회의를 진행하는 부분에 대해서는 React에서 Janus에 요청하는 방식을 채택했다.

화상회의 방은 스터디와 연관되어 있기에 스터디와 연관시켜 생성하고 이를 수정 삭제해야했고 실제로 이는 DB에 저장되야하는 데이터이기에 이를 Spring에서 처리하려고 했다.

화상회의를 실제로 진행하는 부분(시그널링, 미디어 송수신 등)에 대해서는 서버에서 관여할 필요 없이 React에서 직접적으로 처리해도 무방하다고 생각했다.

이제 실제로 어떻게 구현했는지 각각에 대해 살펴보려고 한다.

이번 포스트에서는 Spring 구현 부분에 대해서 살펴보고 다음 포스트에서 React 구현 부분에 대해 살펴보자.

사전준비

필자는 Janus와 API 통신을 진행하기 위해 RestTemplate을 활용하였다. (REST 방식 API를 호출할 수 있는 Spring 내장 클래스)

이를 사용하기 위해 Bean으로 등록해놓고 추후 주입받자.

@Bean
public RestTemplate restTemplate() {
    return new RestTemplate();
}

실제 로직들을 작성하기에 앞서 Janus API와 통신하기 위한 주소를 설정해야 한다.

이를 yml 파일에 작성하고 이를 @Value 어노테이션으로 받아오는 방식을 채택했고 이전 포스트에서 언급했듯이 VideoRoom을 생성, 수정, 삭제하는 부분에 대해서는 Admin Request를 필요로 하기에 Admin Port로 주소를 작성해주어야 한다.

추가적으로 Admin으로 통신하기 위해서는 이전에 설정해주었던 admin_secret 또한 필요하다.

이 또한 작성해주자.

janus:
  server: http://localhost:7088/admin
  admin:
    secret: admin_secret

위와 같이 yml에 작성까지 해주었다면 사전 준비는 모두 완료되었다.

도메인 구현

먼저, 도메인을 구현해보자.

VideoRoom에 필요한 부분은 다음과 같다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class VideoRoom extends EntityDate {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "videoroom_id")
    private Long id;
    @Embedded
    private VideoRoomId roomId;
    @Embedded
    private VideoRoomTitle title;
    @Embedded
    private Pin pin;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Member member;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "study_id")
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Study study;

    @Builder
    public VideoRoom(Long roomId, String title, String pin, Member member, Study study) {
        this.roomId = new VideoRoomId(roomId);
        this.title = new VideoRoomTitle(title);
        this.pin = new Pin(pin);
        this.member = member;
        this.study = study;
    }

    public void update(String title, String pin) {
        this.title = new VideoRoomTitle(title);
        this.pin = new Pin(pin);
    }

    public Long getRoomId() {
        return roomId.getRoomId();
    }

    public String getTitleName() {
        return title.getTitle();
    }

    public String getPin() {
        return Objects.isNull(pin) ? null : pin.getPin();
    }

    public Long getCreatorId() {
        return member.getId();
    }

    public String getCreatorNickname() {
        return member.getNickname();
    }

    public String getCreatorProfileImgUrl() {
        return member.getProfileImgUrl();
    }
}
  • 방 ID (roomId - 방 ID는 비어있으면 안된다.)
    • 이는 실제로 DB에 저장되는 PK가 아니라 Janus에서 생성해주는 방 식별자이다.
  • 방 제목 (title - 제목은 비어있으면 안되며 30자 이내이다.)
  • 방 비밀번호 (pin - 비밀번호는 비어있을 수 있으며 30자 이내이다.)
  • 생성자(회원 - 연관관계 매핑)
  • 소속된 스터디(스터디 - 연관관계 매핑)
  • Getter => 필요한 부분들을 부분적으로 구현

위에서 언급한 부분에 대해서는 원시 타입이 아니라 이를 포장(wrap)한 클래스로 처리하여 내부적으로 생성자에서 위의 요구사항을 만족시키는지 검증하고 가독성을 높이기 위해 위와 같이 처리했다. (이 부분에 대해서는 JPA 임베디드 타입을 이용)

이제 도메인에서 요구되는 사항들이 만족되는지 테스트해보자.

도메인 테스트

도메인 요구사항에서 각각에 대한 테스트를 진행하려고 한다.

먼저, 위에서 언급한 방 제목에 대한 테스트 케이스는 다음과 같다.

class VideoRoomTitleTest {

    @Test
    @DisplayName("화상회의 방 제목이 30자 이내일 경우 성공한다.")
    public void test1() throws Exception {
        //given

        //when, then
        Assertions.assertDoesNotThrow(() -> new VideoRoomTitle("hi".repeat(2)));
    }

    @Test
    @DisplayName("화상회의 방 제목이 30자 이상일 경우 실패한다.")
    public void test2() throws Exception {
        //given

        //when, then
        Assertions.assertThrows(InvalidVideoRoomTitleException.class, () -> new VideoRoomTitle("h".repeat(31)));
    }

    @Test
    @DisplayName("화상회의 방 제목이 공백일 경우 실패한다.")
    public void test3() throws Exception {
        //given

        //when, then
        Assertions.assertThrows(InvalidVideoRoomTitleException.class, () -> new VideoRoomTitle(" "));
    }
}

그리고 위에서 언급한 방 ID에 대한 테스트 케이스는 다음과 같다.

class VideoRoomIdTest {
    @Test
    @DisplayName("화상회의 방 번호가 존재할 경우 성공한다.")
    public void test1() throws Exception {
        //given

        //when, then
        Assertions.assertDoesNotThrow(() -> new VideoRoomId(123123123L));
    }

    @Test
    @DisplayName("화상회의 방 번호가 존재하지 않을 경우 실패한다.")
    public void test2() throws Exception {
        //given

        //when, then
        Assertions.assertThrows(InvalidVideoRoomIdException.class, () -> new VideoRoomId(null));
    }
}

마지막으로 위에서 언급한 방 비밀번호에 대한 테스트 케이스는 다음과 같다.

class PinTest {

    @Test
    @DisplayName("화상회의 방 비밀번호가 30자 이내일 경우 성공한다.")
    public void test1() throws Exception {
        //given

        //when, then
        Assertions.assertDoesNotThrow(() -> new Pin("hi".repeat(2)));
    }

    @Test
    @DisplayName("화상회의 방 비밀번호가 30자 이상일 경우 실패한다.")
    public void test2() throws Exception {
        //given

        //when, then
        Assertions.assertThrows(InvalidPinException.class, () -> new Pin("h".repeat(31)));
    }

    @Test
    @DisplayName("화상회의 방 비밀번호가 비어있어도 성공한다.")
    public void test3() throws Exception {
        //given

        //when, then
        Assertions.assertDoesNotThrow(() -> new Pin(null));
    }
}

결과는 다음과 같이 성공적으로 통과되는 모습을 확인할 수 있다.

Janus Request를 위한 Helper 클래스

이제 실제로 Janus에게 POST 요청을 보내 생성, 수정, 삭제 처리를 진행해야한다.

이를 위해, 위에서 RestTemplate을 빈으로 생성하였고 이를 이용해 처리하는 부분을 구성하려고 했다.

실제로 생성, 수정, 삭제를 하기위해 RestTemplate 코드를 사용하는 부분은 단순 Payload만 다를 뿐 중복되는 로직이고 Janus에게 Request를 하는 책임 자체를 Service Layer에 두기에는 책임이 늘어나기에 이에 대한 책임을 가지는 클래스를 따로 구성하도록 하였다.

구현한 코드는 다음과 같다.

@Component
@RequiredArgsConstructor
public class JanusHelper {

    private final Integer RANDOM_NUMBER_DIGIT = 12;

    private final RestTemplate restTemplate;
    private final Gson gson;
    @Value("${janus.server}")
    private String janusServer;
    @Value("${janus.admin.secret}")
    private String adminSecret;

    public <T> T postAndGetResponseDto(Object requestDto, Class<T> classOfT) {
        JanusRequestDto janusRequest = JanusRequestDto.create(makeRandomNumber(RANDOM_NUMBER_DIGIT), adminSecret, requestDto);
        String json = gson.toJson(janusRequest);
        try {
            ResponseEntity<String> responseEntity = restTemplate.postForEntity(janusServer, json, String.class);
            return Primitives.wrap(classOfT).cast(gson.fromJson(responseEntity.getBody(), classOfT));
        } catch (Exception e) {
            throw new JanusRequestException();
        }
    }

    private String makeRandomNumber(int n) {
        Random rand = new Random();
        String rst = Integer.toString(rand.nextInt(10));
        for(int i=0; i < n-1; i++){
            rst += Integer.toString(rand.nextInt(10));
        }
        return rst;
    }
}
  • RANDOM_NUMBER_DIGIT - Janus Admin 요청에는 각각을 구분하기 위한 Transaction을 인자로 받는다. 이에 대한 자리수를 설정하는 부분이다.

  • Gson - Json으로 파싱하고 생성하기 위해 사용되는 오픈소스로 Janus에 요청을 보내기 위한 Dto를 Json으로 생성하고 반환된 Json을 파싱하여 저장하기 위해 주입받는다.

  • janusServer, adminSecret - yml 파일에서 설정한 Janus의 주소 및 관리자 비밀번호 값이다.

  • postAndGetResponseDto

    1. 생성, 수정, 삭제에 대한 Dto(requestDto)를 입력받을 수 있도록 최상위 클래스인 Object로 선언하여 입력받을 수 있게 하였다.

    2. 정적 팩토리 메소드를 활용해 입력받은 Dto를 Janus에 요청할 수 있는 형식(JanusRequestDto)으로 변환한다.

    3. Gson을 이용해 Json으로 변경한다.

    4. RestTemplate을 활용해 실제로 Janus에 변환된 Json을 담아 요청한다.

    5. 리턴된 값을 받아 각각에 맞는 ResponseDto로 변환한다.

    6. 예외 발생시 JanusRequestException을 발생시킨다.

  • makeRandomNumber - 자리 수를 입력받아 랜덤한 자리수를 만들어낸다.

여기서 사용한 Janus Request용 Dto는 다음과 같다.

@Data
@NoArgsConstructor
@AllArgsConstructor
public class JanusRequestDto {
    private String janus;
    private String plugin;
    private String transaction;
    private String admin_secret;
    private Object request;

    public static JanusRequestDto create(String transaction, String adminSecret, Object requestDto) {
        return new JanusRequestDto("message_plugin", "janus.plugin.videoroom", transaction, adminSecret, requestDto);
    }
}
  • janus - Janus 요청에 대한 구분 ("message_plugin")
  • plugin - 어떤 plugin에 대한 요청인지 구분 ("janus.plugin.videoroom")
  • transaction - 요청에 대한 구분을 위한 랜덤 숫자
  • request - 요청을 위한 Payload

이제 이 클래스를 활용하면 Janus에게 요청을 진행할 수 있다.

그리고 실제로 이 요청에 대한 결과값을 담는 Response용 Dto는 다음과 같이 구성했다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel("화상회의 방 생성 결과 DTO")
public class VideoRoomResultDto {

    @ApiModelProperty(name = "Janus 성공 여부")
    private String janus;

    @ApiModelProperty(name = "Janus Transaction ID")
    private String transaction;

    @ApiModelProperty(name = "화상회의 방 요청 결과")
    private VideoRoomResult response;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel("Janus Video Plugin Response DTO")
public class VideoRoomResult {

    @ApiModelProperty("화상회의 방 번호")
    private Long room;

    @ApiModelProperty("화상회의 방 제목")
    private String videoroom;
}

위에 대한 설명은 @ApiModelProperty로 충분하므로 생략하고 테스트를 진행해보자.

Janus Request를 위한 Helper 클래스 테스트 구현

각각에 대해 필요한 요청 Dto를 생성하여 실제로 요청하는 부분을 테스트해보았다.

이 부분은 생성, 수정, 삭제에 대해 모두 테스트해보았고 각각에서 사용되는 요청 Dto들은 조금 뒤에 다룰 것이다.

@SpringBootTest
@Transactional
@ActiveProfiles("test")
class JanusHelperTest {

    @Autowired
    JanusHelper janusHelper;

    @Test
    @DisplayName("Janus Create Post를 진행한다.")
    public void createPost() throws Exception {
        //given
        VideoRoomCreateDto requestDto = VideoRoomFactory.createDto(1L);

        //when
        VideoRoomResultDto resultDto = janusHelper.postAndGetResponseDto(requestDto, VideoRoomResultDto.class);

        //then
        Assertions.assertNotNull(resultDto);

        //destroy
        VideoRoomDeleteDto destroyRequestDto = VideoRoomFactory.deleteDto(resultDto.getResponse().getRoom());
        janusHelper.postAndGetResponseDto(destroyRequestDto, VideoRoomResultDto.class);
    }

    @Test
    @DisplayName("Janus Edit Post를 진행한다.")
    public void editPost() throws Exception {
        //given
        VideoRoomCreateDto createRequestDto = VideoRoomFactory.createDto(1L);
        janusHelper.postAndGetResponseDto(createRequestDto, VideoRoomResultDto.class);
        VideoRoomUpdateDto editRequestDto = VideoRoomFactory.updateDto("프론트엔드 화상회의");

        //when
        VideoRoomResultDto editResultDto = janusHelper.postAndGetResponseDto(editRequestDto, VideoRoomResultDto.class);

        //then
        Assertions.assertNotNull(editResultDto);
    }

    @Test
    @DisplayName("Janus Destroy Post를 진행한다.")
    public void DestroyPost() throws Exception {
        VideoRoomCreateDto createRequestDto = VideoRoomFactory.createDto(1L);
        VideoRoomResultDto createResultDto = janusHelper.postAndGetResponseDto(createRequestDto, VideoRoomResultDto.class);
        VideoRoomDeleteDto destroyRequestDto = VideoRoomFactory.deleteDto(createResultDto.getResponse().getRoom());

        //when
        VideoRoomResultDto destroyResultDto = janusHelper.postAndGetResponseDto(destroyRequestDto, VideoRoomResultDto.class);

        //then
        Assertions.assertNotNull(destroyResultDto);
    }
}

이제 실제 결과를 보면 성공적인 모습을 보여준다.

실제 생성, 수정, 삭제하는 로직을 구성해보기 앞서 실제 DB에 접근하는 Repository 계층을 구성해보자.

레포지토리 계층 구현

필자는 Data JPA를 사용하고 있기 때문에 기본적인 부분은 구현이 되어있고 추가적으로 필요한 부분만 구현하려고한다.

위 요구사항에서 말했듯이, 각각의 스터디에 맞는 화상회의들을 불러와야하기 때문에 스터디 ID(PK)를 기준으로 각각의 화상회의를 불러올 필요성이 있다.

그리고 Room을 VideoRoom 도메인의 ID(PK)로 찾는 것이 아니라 Janus에서 제공하는 RoomID를 기반으로 찾는 부분이 필요하다.

이 두 부분을 구현해보자.

public interface VideoRoomRepository extends JpaRepository<VideoRoom, Long> {

    @Query("select vr from VideoRoom vr " +
            "join fetch vr.member m " +
            "where vr.roomId.roomId = :roomId")
    VideoRoom findByRoomId(@Param("roomId") Long roomId);

    @Query("select vr from VideoRoom vr " +
            "join fetch vr.member m " +
            "where vr.study.id = :studyId")
    List<VideoRoom> findAllByStudyId(@Param("studyId") Long studyId);
}
  • findByRoomId - 입력받은 roomId를 기반으로 조회한다. (Member의 닉네임, 프로필 이미지 등을 추가적으로 반환 값으로 요구하지만 추가적인 쿼리 발생을 방지하기 위해 패치 조인을 이용하여 같이 끌어왔다. - 이 부분에 대해서 잘 모르시겠다면 다음을 참조해주세요 [JPA] N+1 문제 (tistory.com))
  • findAllByStudyId - 입력받은 studyId를 기반으로 조회한다. (Member의 경우 findByRoomId와 마찬가지로 패치 조인을 진행했다.)

이제 이 부분에 대한 테스트를 진행해보자.

레포지토리 계층 테스트

실제 레포지토리 계층을 테스트할때는 @DataJpaTest어노테이션을 이용해서 JPA와 관련된 테스트만을 진행하는게 좋지만 이 부분에 대해서는 통합테스트를 진행하였다. (추후 수정 예정)

@ExtendWith(SpringExtension.class)
@SpringBootTest
@Transactional
@ActiveProfiles("test")
class VideoRoomRepositoryTest {

    Member member;
    Study study;
    VideoRoom videoRoom;

    @Autowired
    MemberRepository memberRepository;
    @Autowired
    StudyRepository studyRepository;
    @Autowired
    VideoRoomRepository videoRoomRepository;
    @Autowired
    PasswordEncoder passwordEncoder;
    @Autowired
    EntityManager em;

    @BeforeEach
    void beforeEach() {
        Member createMember = new Member("xptmxm3!", passwordEncoder.encode("xptmxm3!"), "상현", Department.CSE, "C:\\Users\\Family\\Pictures\\Screenshots\\1.png", MemberRole.ROLE_MEMBER, "상현입니다.");
        member = memberRepository.save(createMember);

        Study createStudy = new Study("백엔드 모집", List.of("백엔드", "JPA", "스프링"), "백엔드 모집합니다.", "C:\\Users\\Family\\Pictures\\Screenshots\\2.png", 5L, "2021-10-01", "2021-12-25", StudyCategory.CSE, StudyMethod.FACE, StudyState.STUDYING, RecruitState.PROCEED, member, new ArrayList<>(), new ArrayList<>());
        study = studyRepository.save(createStudy);

        VideoRoom createVideoRoom = new VideoRoom(1234L, "백엔드 스터디", null, member, study);
        videoRoom = videoRoomRepository.save(createVideoRoom);
    }

    @Test
    @DisplayName("화상회의 방 번호로 방을 찾는다.")
    public void test1() throws Exception {
        //given
        Long roomId = 1234L;

        //when
        VideoRoom videoRoom = videoRoomRepository.findByRoomId(roomId);

        //then
        Assertions.assertEquals("백엔드 스터디", videoRoom.getTitleName());
    }


    @Test
    @DisplayName("해당 스터디에 포함된 모든 화상회의를 조회한다.")
    public void test2() throws Exception {
        //given
        Long studyId = study.getId();

        //when
        List<VideoRoom> videoRooms = videoRoomRepository.findAllByStudyId(studyId);

        //then
        Assertions.assertEquals(1, videoRooms.size());
    }
}

이를 실제 테스트해보면 다음과 같이 성공적으로 통과된다.

이제 로직을 작성하러 가보자.

서비스 구현

위에서 작성한 부분들을 토대로 로직을 구현하면 쉽게 구성할 수 있다.

작성한 코드는 다음과 같다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class VideoRoomService {

    private final JanusHelper janusHelper;
    private final MemberRepository memberRepository;
    private final StudyRepository studyRepository;
    private final VideoRoomRepository videoRoomRepository;

    @Transactional
    public VideoRoomDto createRoom(Long studyId, VideoRoomCreateDto requestDto) {
        VideoRoomCreateResultDto resultDto = janusHelper.postAndGetResponseDto(requestDto, VideoRoomCreateResultDto.class);
        Member member = findMemberById(requestDto.getMemberId());
        Study study = findStudyById(studyId);
        VideoRoom videoRoom = videoRoomRepository.save(requestDto.toEntity(resultDto.getResponse().getRoom(), member, study));
        return VideoRoomDto.create(videoRoom);
    }

    @Transactional
    public void updateRoom(Long roomId, VideoRoomUpdateDto requestDto) {
        janusHelper.postAndGetResponseDto(requestDto, VideoRoomUpdateResultDto.class);
        VideoRoom videoRoom = videoRoomRepository.findByRoomId(roomId);
        videoRoom.update(requestDto.getTitle(), requestDto.getPin());
    }

    @Transactional
    public void deleteRoom(Long roomId) {
        VideoRoomDeleteDto requestDto = VideoRoomDeleteDto.create(roomId);
        janusHelper.postAndGetResponseDto(requestDto, VideoRoomDeleteResultDto.class);
        VideoRoom videoRoom = videoRoomRepository.findByRoomId(roomId);
        videoRoomRepository.delete(videoRoom);
    }

    public List<VideoRoomDto> findRooms(Long studyId) {
        List<VideoRoom> videoRooms = videoRoomRepository.findAllByStudyId(studyId);
        return videoRooms.stream()
                .map(VideoRoomDto::create)
                .collect(Collectors.toList());
    }

    private Member findMemberById(Long memberId) {
        return memberRepository.findById(memberId).orElseThrow(MemberNotFoundException::new);
    }

    private Study findStudyById(Long studyId) {
        return studyRepository.findStudyById(studyId);
    }
}
  • createRoom - 입력받은 Dto를 기반으로 Janus에 요청을 보내 반환받은 결과 값과 입력받은 StudyId 값을 토대로 DB에 저장해주고 이를 반환해준다.
  • updateRoom - CreateRoom과 동작방식이 같다. (단지 생성과 수정의 차이일뿐)
  • deleteRoom - 위의 두 경우와 비슷하지만 단순 roomID를 입력받아 화상회의 방을 Destroy한다. (DB에서도 제거, 미디어 서버에서도 제거)
  • findRooms - 위에서 작성한 findAllByStudyId를 기반으로 해당 스터디에 생성된 모든 화상회의 방을 조회한다.

위에서 입력받는 요청 Dto에 대해서 살펴보자.

이 부분에 대해서 알아야될 부분은 Controller에서 입력받는 Dto와는 개별적으로 생성된 Service Layer용 Dto이다.

이렇게 구성한 이유는 의존성 때문이다.

Controller에서 사용하는 부분이 Service에서 사용하는 부분이랑 똑같거나 거의 같을텐데 대체 왜 이런 불편한 방식으로 처리하는걸까?라고 생각할 수 있겠지만 생성된 Dto를 그대로 Service로 가져오게 되면 해당 Service에서 제공하는 Method는 해당 Controller에 그대로 의존해버리고 만다.

그렇기 때문에 이러한 부분에 대한 의존을 끊기 위해 Service Dto를 따로 구성하여 처리하도록 하였다.

이제 Dto들을 각각 살펴보자.

생성을 위한 요청 Dto는 다음과 같다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel("화상회의 방 생성 요청 서비스 계층 DTO")
public class VideoRoomCreateDto {

    @ApiModelProperty(name = "화상회의 방 요청 구분")
    @NotBlank(message = "화상회의 요청 구분을 입력해주세요.")
    private String request;

    @ApiModelProperty(name = "화상회의 방 생성자 ID(PK)")
    private Long memberId;

    @ApiModelProperty(name = "화상회의 방 제목")
    private String title;

    @ApiModelProperty(name = "화상회의 방 비밀번호")
    private String pin;

    public VideoRoom toEntity(Long roomId, Member member, Study study) {
        return VideoRoom.builder()
                .title(title)
                .pin(pin)
                .roomId(roomId)
                .member(member)
                .study(study)
                .build();
    }
}

수정을 위한 수정 Dto는 다음과 같다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel("화상회의 방 수정 요청 서비스 계층 DTO")
public class VideoRoomUpdateDto {

    @ApiModelProperty(name = "화상회의 방 요청 구분")
    @NotBlank(message = "요청 구분을 입력해주세요.")
    private String request;

    @ApiModelProperty(name = "화상회의 방 제목")
    private String title;

    @ApiModelProperty(name = "화상회의 방 비밀번호")
    private String pin;
}

삭제를 위한 삭제 Dto는 다음과 같다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel("화상회의 방 파괴 요청 서비스 계층 DTO")
public class VideoRoomDeleteDto {

    @ApiModelProperty(name = "화상회의 방 요청 구분")
    @NotBlank(message = "화상회의 요청 구분을 입력해주세요.")
    private String request;

    @ApiModelProperty(name = "화상회의 방 ID")
    @NotBlank(message = "화상회의 방 ID를 입력해주세요.")
    private Long room;

    public static VideoRoomDeleteDto create(Long roomId) {
        return new VideoRoomDeleteDto("destroy", roomId);
    }
}

이 부분 또한 잘 설명이 되어있기에 추가적인 설명은 생략하겠다.

그리고 반환 값에 대한 부분이다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@ApiModel("화상회의 방 조회 결과 서비스 계층 DTO")
public class VideoRoomDto {

    @ApiModelProperty(name = "화상회의 방 ID")
    private Long roomId;

    @ApiModelProperty(name = "화상회의 방 제목")
    private String title;

    @ApiModelProperty(name = "화상회의 방 비밀번호")
    private String pin;

    @ApiModelProperty(name = "화상회의 방 생성자")
    private MemberProfile creator;

    public static VideoRoomDto create(VideoRoom room) {
        return new VideoRoomDto(room.getRoomId(), room.getTitleName(),
                room.getPin(), MemberProfile.create(room));
    }
}

이 부분 또한 잘 설명이 되어있기에 추가적인 설명은 생략하겠다.

이제 작성한 로직에 대해 구동이 잘 되는지 테스트를 해보자.

서비스에 대해서는 단위 테스트와 통합 테스트를 모두 진행해보겠다.

Service 단위 테스트

생성, 수정, 삭제를 위한 Dto들을 생성하고 실제로 서비스가 동작하는지 확인하자.

필요한 부분들에 대해 Mocking을 진행하고 테스트해보자. (테스트에 대해 자세하게 다루는 포스트가 아니므로 자세한 설명은 생략하겠습니다.)

@ExtendWith(MockitoExtension.class)
class VideoRoomServiceUnitTest {

    Member member;
    Study study;
    VideoRoom videoRoom;

    @InjectMocks
    VideoRoomService videoRoomService;
    @Mock
    JanusHelper janusHelper;
    @Mock
    MemberRepository memberRepository;
    @Mock
    StudyRepository studyRepository;
    @Mock
    VideoRoomRepository videoRoomRepository;

    @BeforeEach
    public void init() {
        member = VideoRoomFactory.makeTestAuthMember();
        study = VideoRoomFactory.makeTestStudy(member, new ArrayList<>(), new ArrayList<>());
        videoRoom =VideoRoomFactory.makeTestVideoRoom(1L, "백엔드 스터디 화상회의 방", member, study);
    }

    @Test
    @DisplayName("화상회의 방을 생성한다.")
    public void createRoom() throws Exception {
        //given
        VideoRoomCreateDto createDto = VideoRoomFactory.createDto(member.getId());
        VideoRoomResultDto resultDto = VideoRoomFactory.createResultDto();

        //mocking
        given(janusHelper.postAndGetResponseDto(createDto, VideoRoomResultDto.class)).willReturn(resultDto);
        given(memberRepository.findById(member.getId())).willReturn(Optional.ofNullable(member));
        given(studyRepository.findStudyById(study.getId())).willReturn(study);
        given(videoRoomRepository.save(any())).willReturn(videoRoom);

        //when
        VideoRoomDto responseDto = videoRoomService.createRoom(study.getId(), createDto);

        //then
        Assertions.assertEquals("백엔드 스터디 화상회의 방", responseDto.getTitle());
    }

    @Test
    @DisplayName("화상회의 방을 수정한다.")
    public void editRoom() throws Exception {
        //given
        VideoRoomUpdateDto updateDto = VideoRoomFactory.updateDto("프론트엔드 화상회의");
        VideoRoomResultDto resultDto = VideoRoomFactory.updateResultDto();

        //mocking
        given(janusHelper.postAndGetResponseDto(updateDto, VideoRoomResultDto.class)).willReturn(resultDto);
        given(videoRoomRepository.findByRoomId(any())).willReturn(videoRoom);

        //when, then
        Assertions.assertDoesNotThrow(() -> videoRoomService.updateRoom(videoRoom.getRoomId(), updateDto));
    }

    @Test
    @DisplayName("화상회의 방을 제거한다.")
    public void destroyRoom() throws Exception {
        //given
        VideoRoomDeleteDto deleteDto = VideoRoomFactory.deleteDto(videoRoom.getRoomId());
        VideoRoomResultDto resultDto = VideoRoomFactory.deleteResultDto();

        //mocking
        given(janusHelper.postAndGetResponseDto(deleteDto, VideoRoomResultDto.class)).willReturn(resultDto);
        given(videoRoomRepository.findByRoomId(any())).willReturn(videoRoom);
        willDoNothing().given(videoRoomRepository).delete(videoRoom);

        //when, then
        Assertions.assertDoesNotThrow(() -> videoRoomService.deleteRoom(videoRoom.getRoomId()));
    }

    @Test
    @DisplayName("화상회의 방을 모두 조회한다.")
    public void findRooms() throws Exception {
        //given

        //mocking
        given(videoRoomRepository.findAllByStudyId(study.getId())).willReturn(List.of(videoRoom));

        //when
        List<VideoRoomDto> rooms = videoRoomService.findRooms(study.getId());

        // then
        Assertions.assertEquals(1, rooms.size());
    }
}

이에 대한 결과는 다음과 같이 성공적이다.

Service 통합 테스트

미리 생성해놓은 Test Data Dummy들을 토대로 실제로 생성, 수정, 삭제하는 로직을 진행해봤다.

@SpringBootTest
@Transactional
@ActiveProfiles("test")
class VideoRoomServiceIntegrationTest {
    Member member;
    Study study;

    @Autowired
    VideoRoomService videoRoomService;
    @Autowired
    VideoRoomRepository videoRoomRepository;
    @Autowired
    TestDB testDB;

    @BeforeEach
    public void init() {
        testDB.init();
        member = testDB.findStudyGeneralMember();
        study = testDB.findBackEndStudy();
    }

    @Test
    @DisplayName("화상회의 방을 생성한다.")
    public void createRoom() throws Exception {
        //given
        VideoRoomCreateDto requestDto = VideoRoomFactory.createDto(member.getId());

        //when
        VideoRoomDto responseDto = videoRoomService.createRoom(study.getId(), requestDto);

        //then
        Assertions.assertNotNull(videoRoomRepository.findByRoomId(responseDto.getRoomId()));

        //destroy
        videoRoomService.deleteRoom(responseDto.getRoomId());
    }

    @Test
    @DisplayName("화상회의 방을 수정한다.")
    public void editRoom() throws Exception {
        //given
        VideoRoomCreateDto createRequestDto = VideoRoomFactory.createDto(member.getId());
        VideoRoomDto room = videoRoomService.createRoom(study.getId(), createRequestDto);
        VideoRoomUpdateDto updateRequestDto = VideoRoomFactory.updateDto("프론트엔드 화상회의");

        //when, then
        Assertions.assertDoesNotThrow(() -> videoRoomService.updateRoom(room.getRoomId(), updateRequestDto));

        //destroy
        videoRoomService.deleteRoom(room.getRoomId());
    }

    @Test
    @DisplayName("화상회의 방을 제거한다.")
    public void destroyRoom() throws Exception {
        //given
        VideoRoomCreateDto requestDto = VideoRoomFactory.createDto(member.getId());
        VideoRoomDto room = videoRoomService.createRoom(study.getId(), requestDto);

        //when, then
        Assertions.assertDoesNotThrow(() -> videoRoomService.deleteRoom(room.getRoomId()));
    }

    @Test
    @DisplayName("화상회의 방을 모두 조회한다.")
    public void findRooms() throws Exception {
        //given
        VideoRoomCreateDto requestDto = VideoRoomFactory.createDto(member.getId());
        VideoRoomDto room = videoRoomService.createRoom(study.getId(), requestDto);

        //when
        List<VideoRoomDto> rooms = videoRoomService.findRooms(study.getId());

        // then
        Assertions.assertEquals(2, rooms.size());

        //destroy
        videoRoomService.deleteRoom(room.getRoomId());
    }
}

이에 대한 결과도 성공적이였다.

이제 마지막 Presentation Layer로 가보자.

Controller 구현

클라이언트로부터 입력받은 값들을 토대로 실제로 값을 반환하는 부분이다.

로직을 처리하는 VideoRoomService와 결과 값을 처리하는 ResponseService를 주입받아 이를 처리한다.

위에서 언급했던 것처럼 Controller 계층에서 입력받은 Dto를 Service 계층 Dto로 변환하여 Service Layer로 넘겨주는 모습을 볼 수 있다.

그리고 얻어낸 결과값을 다시 변환하여 내보낸다.

아래 코드에서 동작하는 방식은 단순 서비스에 Dto를 넘겨 로직을 실행하는 부분이기에 자세한 설명은 생략한다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/study/{studyId}/videoroom")
public class VideoRoomController {

    private final VideoRoomService videoRoomService;
    private final ResponseService responseService;

    @ApiOperation(value = "스터디 화상회의 방 로드", notes = "스터디에 생성된 화상회의 방을 모두 로드한다.")
    @GetMapping
    @ResponseStatus(HttpStatus.OK)
    public MultipleResult<VideoRoomResponseDto> findAllRooms(@PathVariable Long studyId) {
        List<VideoRoomDto> videoRoomDto = videoRoomService.findRooms(studyId);
        List<VideoRoomResponseDto> responseDto = responseService.convertToControllerDto(videoRoomDto, VideoRoomResponseDto::create);
        return responseService.getMultipleResult(responseDto);
    }

    @ApiOperation(value = "스터디 화상회의 방 생성", notes = "스터디에 화상회의 방을 생성한다.")
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public SingleResult<VideoRoomResponseDto> createRoom(@PathVariable Long studyId,
                                                         @Valid @RequestBody VideoRoomCreateRequestDto requestDto) {
        VideoRoomDto videoRoomDto = videoRoomService.createRoom(studyId, requestDto.toServiceDto());
        return responseService.getSingleResult(VideoRoomResponseDto.create(videoRoomDto));
    }

    @ApiOperation(value = "스터디 화상회의 방 수정", notes = "스터디에 생성된 화상회의 방을 수정한다.")
    @PutMapping("/{videoRoomId}")
    @ResponseStatus(HttpStatus.OK)
    public Result updateRoom(@PathVariable Long studyId, @PathVariable Long videoRoomId,
                             @Valid @RequestBody VideoRoomUpdateRequestDto requestDto) {
        videoRoomService.updateRoom(videoRoomId, requestDto.toServiceDto());
        return responseService.getDefaultSuccessResult();
    }

    @ApiOperation(value = "스터디 화상회의 방 삭제", notes = "스터디에 생성된 화상회의 방을 삭제한다.")
    @DeleteMapping("/{videoRoomId}")
    @ResponseStatus(HttpStatus.OK)
    public Result deleteRoom(@PathVariable Long studyId, @PathVariable Long videoRoomId) {
        videoRoomService.deleteRoom(videoRoomId);
        return responseService.getDefaultSuccessResult();
    }

이제 테스트를 진행해보자.

현재 접근 제어에 대한 부분은 Spring Security를 통해 구현이 되어 있는데 현재 포스트에서는 Security에 대해 다루는 포스트가 아니기에 다루지 않겠지만 위에서 언급한 요구사항(웹 관리자, 스터디 관리자에 대한 허용, 스터디 회원이 아닌 자에 대한 허가 X)에 대해 통과되는지 테스트를 해보아 사이드 이펙트가 일어나지 않게 조심해야한다.

(만약 Security에 관심이 있으시다면 [Security] Spring Security란 (tistory.com)부터 이어지는 Security 글 시리즈들을 읽으시면 도움이 될 것 같습니다.)

Controller 또한 단위, 통합 테스트로 나누어 진행해보았다.

Controller 단위 테스트

단위 테스트에서는 기능이 정상적으로 동작하는지에 대해 빠르게 테스트해보기 위해 위에서 언급한 사이드 테스트에 대해서는 진행하지 않았고 이 부분에 대해서는 통합 테스트에서 진행했다.

@ExtendWith(MockitoExtension.class)
public class VideoRoomControllerUnitTest {

    String accessToken;
    Member member;
    Study study;
    VideoRoom videoRoom;
    VideoRoom updatedVideoRoom;

    MockMvc mockMvc;
    @InjectMocks
    VideoRoomController videoRoomController;
    @Mock
    VideoRoomService videoRoomService;
    @Mock
    ResponseService responseService;

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.standaloneSetup(videoRoomController).build();

        accessToken = "accessToken";
        member = VideoRoomFactory.makeTestAdminMember();
        study = VideoRoomFactory.makeTestStudy(member, new ArrayList<>(), new ArrayList<>());
        videoRoom = VideoRoomFactory.makeTestVideoRoom(1L, "백엔드 화상회의", member, study);
        updatedVideoRoom = VideoRoomFactory.makeTestVideoRoom(1L, "프론트엔드 화상회의", member, study);
    }

    @Test
    @DisplayName("화상회의 방을 생성한다.")
    public void createRoom() throws Exception {
        //given
        VideoRoomCreateRequestDto createRequestDto = VideoRoomFactory.createRequestDto(member.getId());
        VideoRoomCreateDto createDto = VideoRoomFactory.createDto(member.getId());
        VideoRoomDto videoRoomDto = VideoRoomFactory.makeDto(videoRoom);
        VideoRoomResponseDto responseDto = VideoRoomFactory.makeResponseDto(videoRoomDto);
        SingleResult<VideoRoomResponseDto> ExpectResult = VideoRoomFactory.makeSingleResult(responseDto);

        //mocking
        given(videoRoomService.createRoom(study.getId(), createDto)).willReturn(videoRoomDto);
        given(responseService.getSingleResult(responseDto)).willReturn(ExpectResult);

        //when, then
        mockMvc.perform(post("/study/{studyId}/videoroom", study.getId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .content(new Gson().toJson(createRequestDto))
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.data.title").value("백엔드 화상회의"));
    }

    @Test
    @DisplayName("화상회의 방을 수정한다.")
    public void editRoom() throws Exception {
        //given
        VideoRoomUpdateRequestDto updateRequestDto = VideoRoomFactory.updateRequestDto("프론트엔드 화상회의 방");
        VideoRoomUpdateDto updateDto = VideoRoomFactory.updateDto("프론트엔드 화상회의 방");
        Result ExpectResult = VideoRoomFactory.makeDefaultSuccessResult();

        //mocking
        willDoNothing().given(videoRoomService).updateRoom(videoRoom.getRoomId(), updateDto);
        given(responseService.getDefaultSuccessResult()).willReturn(ExpectResult);

        //when, then
        mockMvc.perform(put("/study/{studyId}/videoroom/{roomId}", study.getId(), videoRoom.getRoomId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .content(new Gson().toJson(updateRequestDto))
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("화상회의 방을 제거한다.")
    public void destroyRoom() throws Exception {
        //given
        Result ExpectResult = VideoRoomFactory.makeDefaultSuccessResult();

        //mocking
        willDoNothing().given(videoRoomService).deleteRoom(videoRoom.getRoomId());
        given(responseService.getDefaultSuccessResult()).willReturn(ExpectResult);

        //when, then
        mockMvc.perform(delete("/study/{studyId}/videoroom/{roomId}", study.getId(), videoRoom.getRoomId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("화상회의 방을 모두 조회한다.")
    public void findRooms() throws Exception {
        //given
        List<VideoRoomDto> videoRoomDto = List.of(VideoRoomFactory.makeDto(videoRoom));
        List<VideoRoomResponseDto> responseDto = videoRoomDto.stream()
                                                    .map(VideoRoomFactory::makeResponseDto)
                                                    .collect(Collectors.toList());
        MultipleResult<VideoRoomResponseDto> ExpectResult = VideoRoomFactory.makeMultipleResult(responseDto);

        //mocking
        given(videoRoomService.findRooms(study.getId())).willReturn(videoRoomDto);
        given(responseService.convertToControllerDto(any(List.class), any(Function.class))).willReturn(responseDto);
        given(responseService.getMultipleResult(responseDto)).willReturn(ExpectResult);

        //when, then
        mockMvc.perform(get("/study/{studyId}/videoroom", study.getId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isOk());
    }
}

실제 이 부분을 테스트 하면 다음과 같이 성공적으로 진행된다.

Controller 통합 테스트

아래에서는 위에서 언급한 부분에 대한 테스트를 모두 진행했다.

각각의 요구사항이 부합하는지에 대한 테스트를 진행했고 그 결과를 확인해보려고 했다.

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@ActiveProfiles("test")
public class VideoRoomControllerIntegrationTest {
    Member studyMember;
    Member notStudyMember;
    Study study;
    Member webAdminMember;
    Member studyAdminMember;
    Member hasNoResourceMember;

    @Autowired
    WebApplicationContext context;
    @Autowired
    MockMvc mockMvc;
    @Autowired
    JwtTokenHelper accessTokenHelper;
    @Autowired
    TestDB testDB;

    @BeforeEach
    void beforeEach() {
        mockMvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
        testDB.init();

        studyMember = testDB.findStudyGeneralMember();
        notStudyMember = testDB.findNotStudyMember();
        study = testDB.findBackEndStudy();
        webAdminMember = testDB.findAdminMember();
        studyAdminMember = testDB.findStudyAdminMember();
        hasNoResourceMember = testDB.findStudyMemberNotResourceOwner();
    }

    @Test
    @DisplayName("화상회의 방을 생성한다.")
    public void createRoom() throws Exception {
        //given
        String accessToken = accessTokenHelper.createToken(studyMember.getEmail());
        VideoRoomCreateRequestDto createRequestDto = VideoRoomFactory.createRequestDto(studyMember.getId());

        //when, then
        mockMvc.perform(post("/study/{studyId}/videoroom", study.getId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .content(new Gson().toJson(createRequestDto))
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.data.title").value("백엔드 화상회의"));
    }

    @Test
    @DisplayName("스터디 멤버가 아닌 회원은 해당 스터디에 화상회의 방을 생성할 수 없다.")
    public void createRoom_fail() throws Exception {
        //given
        String accessToken = accessTokenHelper.createToken(notStudyMember.getEmail());
        VideoRoomCreateRequestDto createRequestDto = VideoRoomFactory.createRequestDto(notStudyMember.getId());

        //when, then
        mockMvc.perform(post("/study/{studyId}/videoroom", study.getId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .content(new Gson().toJson(createRequestDto))
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().is3xxRedirection());
    }

    @Test
    @DisplayName("화상회의 방을 수정한다.")
    public void editRoom() throws Exception {
        //given
        VideoRoom videoRoom = testDB.findJPAVideoRoom();
        String accessToken = accessTokenHelper.createToken(studyMember.getEmail());
        VideoRoomUpdateRequestDto updateRequestDto = VideoRoomFactory.updateRequestDto("React Query 회의 방");

        //when, then
        mockMvc.perform(put("/study/{studyId}/videoroom/{roomId}", study.getId(), videoRoom.getRoomId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .content(new Gson().toJson(updateRequestDto))
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("스터디 관리자는 어떤 스터디 화상회의 방이든 수정할 수 있다.")
    public void editRoom2() throws Exception {
        //given
        VideoRoom videoRoom = testDB.findJPAVideoRoom();
        String accessToken = accessTokenHelper.createToken(studyAdminMember.getEmail());
        VideoRoomUpdateRequestDto updateRequestDto = VideoRoomFactory.updateRequestDto("React Query 회의 방");

        //when, then
        mockMvc.perform(put("/study/{studyId}/videoroom/{roomId}", study.getId(), videoRoom.getRoomId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .content(new Gson().toJson(updateRequestDto))
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("웹 관리자는 어떤 스터디 화상회의 방이든 수정할 수 있다.")
    public void editRoom3() throws Exception {
        //given
        VideoRoom videoRoom = testDB.findJPAVideoRoom();
        String accessToken = accessTokenHelper.createToken(webAdminMember.getEmail());
        VideoRoomUpdateRequestDto updateRequestDto = VideoRoomFactory.updateRequestDto("React Query 회의");

        //when, then
        mockMvc.perform(put("/study/{studyId}/videoroom/{roomId}", study.getId(), videoRoom.getRoomId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .content(new Gson().toJson(updateRequestDto))
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("스터디 화상회의 방 생성자가 아니라면 해당 방을 수정할 수 없다.")
    public void editRoom_fail2() throws Exception {
        //given
        VideoRoom videoRoom = testDB.findJPAVideoRoom();
        String accessToken = accessTokenHelper.createToken(hasNoResourceMember.getEmail());
        VideoRoomUpdateRequestDto updateRequestDto = VideoRoomFactory.updateRequestDto("React Query 회의 방");

        //when, then
        mockMvc.perform(put("/study/{studyId}/videoroom/{roomId}", study.getId(), videoRoom.getRoomId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .content(new Gson().toJson(updateRequestDto))
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().is3xxRedirection());
    }

    @Test
    @DisplayName("화상회의 방을 제거한다.")
    public void destroyRoom() throws Exception {
        //given
        VideoRoom videoRoom = testDB.findJPAVideoRoom();
        String accessToken = accessTokenHelper.createToken(studyMember.getEmail());

        //when, then
        mockMvc.perform(delete("/study/{studyId}/videoroom/{roomId}", study.getId(), videoRoom.getRoomId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("스터디 관리자는 어떤 화상회의 방이든 제거할 수 있다.")
    public void destroyRoom2() throws Exception {
        //given
        VideoRoom videoRoom = testDB.findJPAVideoRoom();
        String accessToken = accessTokenHelper.createToken(studyAdminMember.getEmail());

        //when, then
        mockMvc.perform(delete("/study/{studyId}/videoroom/{roomId}", study.getId(), videoRoom.getRoomId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("웹 관리자는 어떤 화상회의 방이든 제거할 수 있다.")
    public void destroyRoom3() throws Exception {
        //given
        VideoRoom videoRoom = testDB.findJPAVideoRoom();
        String accessToken = accessTokenHelper.createToken(webAdminMember.getEmail());

        //when, then
        mockMvc.perform(delete("/study/{studyId}/videoroom/{roomId}", study.getId(), videoRoom.getRoomId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("스터디 화상회의 방 생성자가 아니라면 화상회의 방을 제거할 수 없다.")
    public void destroyRoom_fail() throws Exception {
        //given
        VideoRoom videoRoom = testDB.findJPAVideoRoom();
        String accessToken = accessTokenHelper.createToken(hasNoResourceMember.getEmail());

        //when, then
        mockMvc.perform(delete("/study/{studyId}/videoroom/{roomId}", study.getId(), videoRoom.getRoomId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().is3xxRedirection());
    }

    @Test
    @DisplayName("화상회의 방을 모두 조회한다.")
    public void findRooms() throws Exception {
        //given
        String accessToken = accessTokenHelper.createToken(studyMember.getEmail());

        //when, then
        mockMvc.perform(get("/study/{studyId}/videoroom", study.getId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("스터디 멤버가 아닌 회원은 해당 스터디 화상회의 방을 조회할 수 없다.")
    public void findRooms_fail() throws Exception {
        //given
        String accessToken = accessTokenHelper.createToken(notStudyMember.getEmail());

        //when, then
        mockMvc.perform(get("/study/{studyId}/videoroom", study.getId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .characterEncoding("utf-8")
                        .header("X-AUTH-TOKEN", accessToken))
                .andExpect(status().is3xxRedirection());
    }
}

다행히도 성공적으로 테스트가 완료되었다.

이제 Spring에서 처리하는 부분에 대해서는 모두 완료되었다.

방을 생성하고 수정하고 삭제하는 부분에 대해서 모두 처리가 가능하니 이제 실제 화상회의를 진행할 수 있도록 React에서 이 부분을 구현해주면 된다.

이 부분에 대한 것까지 이번 포스트에서 다루게 된다면 너무 길어지므로 다음 포스트에서 이어서 진행하려고한다.

정리

이 부분에 대한 구현은 지극히 개인적인 생각을 토대로 구현한 부분이라 부족한 부분이 많습니다.

부족한 부분에 대한 피드백이 있으시다면 언제든지 피드백 주시면 감사하겠습니다! (바로 수정하도록 하겠습니다.)