이 글에서는 단위 테스트 작성에 대해서 다루겠습니다.
글의 목차는 다음과 같습니다.
1) 단위 테스트와 인수 테스트
2) 단위 테스트 및 인수 테스트 작성하기
1) 단위 테스트와 인수 테스트
(1) 단위 테스트
- 단위 테스트는 일반적으로 비즈니스 로직의 기본 단위인 메소드를 대상으로 작성되는 테스트를
의미합니다. 단위 테스트를 통해서 내가 작성한 비즈니스 로직이 정확하고 안정적으로 동작함을
배포 전에 검증할 수 있습니다.
또한, 단위 테스트 개발은 TDD와 결합되어 사용되기도 합니다. TDD는 Test-Driven-Development
의 줄임말로, 우리나라 말로는 테스트 주도 개발이라고 합니다. TDD는 테스트를 먼저 작성하고,
그 테스트를 통과할 수 있는 비즈니스 로직을 작성하는 방식으로 개발을 진행합니다.
단위 테스트에 TDD를 결합했을 때의 장점은,
(1-1) 비즈니스 로직을 테스트 가능한 단위로 단순하게 개발 가능함
(1-2) 테스트 작성으로 예외 사항이 선제적으로 검증되므로, 비즈니스 로직을 재설계하는 시간이 감소함
입니다.
(2) 인수 테스트
- 인수 테스트는 사용자 시나리오에 맞춰 수행하는 테스트입니다. 즉, 소프트웨어가 사용자 요구사항에 맞게
잘 동작하는지를 테스트합니다. 특정한 기능 하나에 초점을 맞추는 단위 테스트와 비교할 때, 인수 테스트는
통합 테스트에 가깝다고 볼 수 있습니다.
스프링에서 사용하는 인수 테스트 프레임워크에는 MockMvc, RestAssured, WebTestClient 등이 있습니다.
2) 단위 테스트 및 인수 테스트 작성하기
- 단위 테스트와 인수 테스트를 각각 AnswerService 클래스, AnswerController 클래스에 대해
작성해보겠습니다. AnswerServiceTest는 Create에 대해, AnswerController 클래스는 Read에 대해 설명 드리겠습니다. 우선 AnswerServiceTest부터 설명드리겠습니다.
(1) AnswerServiceTest
- AnswerService 클래스에 대한 테스트 클래스입니다.
글에서는 CRUD 중 Create에 대한 테스트를 설명드리겠습니다.
package com.example.interviewPrep.quiz.Answer.service;
import com.example.interviewPrep.quiz.answer.domain.Answer;
import com.example.interviewPrep.quiz.answer.dto.response.AnswerResponse;
import com.example.interviewPrep.quiz.heart.repository.HeartRepository;
import com.example.interviewPrep.quiz.jwt.service.JwtService;
import com.example.interviewPrep.quiz.member.domain.Member;
import com.example.interviewPrep.quiz.member.repository.MemberRepository;
import com.example.interviewPrep.quiz.question.domain.Question;
import com.example.interviewPrep.quiz.answer.dto.request.AnswerRequest;
import com.example.interviewPrep.quiz.answer.repository.AnswerRepository;
import com.example.interviewPrep.quiz.question.repository.QuestionRepository;
import com.example.interviewPrep.quiz.answer.service.AnswerService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Optional;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;
public class AnswerServiceTest {
@Autowired
private AnswerService answerService;
private final JwtService jwtService = mock(JwtService.class);
private final AnswerRepository answerRepository = mock(AnswerRepository.class);
private final QuestionRepository questionRepository = mock(QuestionRepository.class);
private final MemberRepository memberRepository = mock(MemberRepository.class);
private final HeartRepository heartRepository = mock(HeartRepository.class);
@BeforeEach
public void setUp(){
answerService = new AnswerService(jwtService, memberRepository, answerRepository, questionRepository, heartRepository);
}
@Test
@DisplayName("답안 생성")
public void createAnswer(){
AnswerRequest answerRequest = AnswerRequest.builder()
.content("hello")
.questionId(1L)
.build();
Member member = Member.builder()
.id(1L)
.email("abc@gmail.com")
.name("abc")
.password("1234")
.build();
Question question = Question.builder()
.id(1L)
.title("Question 1")
.type("java")
.difficulty("easy")
.build();
given(jwtService.getMemberId()).willReturn(1L);
given(memberRepository.findById(1L)).willReturn(Optional.ofNullable(member));
given(questionRepository.findById(1L)).willReturn(Optional.ofNullable(question));
AnswerResponse answerResponse = answerService.createAnswer(answerRequest);
assertThat(answerResponse.getQuestionId()).isEqualTo(1L);
assertThat(answerResponse.getContent()).isEqualTo("hello");
}
}
- 코드를 나눠서 살펴보겠습니다.
(1-1) Fixture 생성
@Autowired
private AnswerService answerService;
private final JwtService jwtService = mock(JwtService.class);
private final AnswerRepository answerRepository = mock(AnswerRepository.class);
private final QuestionRepository questionRepository = mock(QuestionRepository.class);
private final MemberRepository memberRepository = mock(MemberRepository.class);
private final HeartRepository heartRepository = mock(HeartRepository.class);
Answer answer;
@BeforeEach
public void setUp(){
answerService = new AnswerService(jwtService, memberRepository, answerRepository, questionRepository, heartRepository);
}
- 테스트를 위한 Fixture를 생성했습니다.
Fixture란, 테스트 실행을 위해 필요한 객체 혹은 데이터들을 초기화하는 것을 의미합니다.
Fixture를 클래스 내에서 한 번 선언해서, 여러 테스트에서 재사용할 수 있게 할 수 있고,
혹은 각각의 메소드마다 Fixture를 생성할 수도 있습니다.
- AnswerServiceTest 클래스에서는 AnswerService의 createAnswer 메소드와 readAnswer를 테스트하므로,
AnswerService 클래스의 객체가 필요합니다.
이 때, AnswerService 클래스의 객체는 @Autowired로 선언하고, AnswerService가 의존하고 있는
의존 객체들은 Mockito.mock()을 통해서 생성했습니다.
- 객체를 모킹하는 방법에는 크게 3가지가 있습니다.
그것은 1) Mockito.mock() 2) @Mock 3) @MockBean입니다.
이 중에서 @Mock은 Mockito.mock()과 같은 것으로, Mockito.mock()을 축약한 것입니다.
그리고 @MockBean은 스프링 컨텍스트에서 사용 가능하다는 특징을 갖고 있습니다.
- AnswerService의 객체는 setUp 메소드에 선언했고, setUp 메소드에는 @BeforeEach를 추가했습니다.
@BeforeEach로 인해 setUp 메소드는 모든 테스트가 실행되기 전에 실행이 됩니다.
(1-2) createAnswer 테스트
package com.example.interviewPrep.quiz.Answer.service;
import com.example.interviewPrep.quiz.answer.domain.Answer;
import com.example.interviewPrep.quiz.answer.dto.response.AnswerResponse;
import com.example.interviewPrep.quiz.heart.repository.HeartRepository;
import com.example.interviewPrep.quiz.jwt.service.JwtService;
import com.example.interviewPrep.quiz.member.domain.Member;
import com.example.interviewPrep.quiz.member.repository.MemberRepository;
import com.example.interviewPrep.quiz.question.domain.Question;
import com.example.interviewPrep.quiz.answer.dto.request.AnswerRequest;
import com.example.interviewPrep.quiz.answer.repository.AnswerRepository;
import com.example.interviewPrep.quiz.question.repository.QuestionRepository;
import com.example.interviewPrep.quiz.answer.service.AnswerService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Optional;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;
public class AnswerServiceTest {
@Autowired
private AnswerService answerService;
private final JwtService jwtService = mock(JwtService.class);
private final AnswerRepository answerRepository = mock(AnswerRepository.class);
private final QuestionRepository questionRepository = mock(QuestionRepository.class);
private final MemberRepository memberRepository = mock(MemberRepository.class);
private final HeartRepository heartRepository = mock(HeartRepository.class);
@BeforeEach
public void setUp(){
answerService = new AnswerService(jwtService, memberRepository, answerRepository, questionRepository, heartRepository);
}
@Test
@DisplayName("답안 생성")
public void createAnswer(){
AnswerRequest answerRequest = AnswerRequest.builder()
.content("hello")
.questionId(1L)
.build();
Member member = Member.builder()
.id(1L)
.email("abc@gmail.com")
.name("abc")
.password("1234")
.build();
Question question = Question.builder()
.id(1L)
.title("Question 1")
.type("java")
.difficulty("easy")
.build();
given(jwtService.getMemberId()).willReturn(1L);
given(memberRepository.findById(1L)).willReturn(Optional.ofNullable(member));
given(questionRepository.findById(1L)).willReturn(Optional.ofNullable(question));
AnswerResponse answerResponse = answerService.createAnswer(answerRequest);
assertThat(answerResponse.getQuestionId()).isEqualTo(1L);
assertThat(answerResponse.getContent()).isEqualTo("hello");
}
}
- createAnswer 테스트를 위해 필요한 AnswerRequest 객체, Member 객체, Question 객체, Answer 객체를
생성합니다.
그 다음 Mockito.mock()을 통해 Mock 객체를 생성했으므로, 필요한 동작들을 Stubbing 해줍니다.
given(jwtService.getMemberId()).willReturn(1L);
given(memberRepository.findById(1L)).willReturn(Optional.ofNullable(member));
given(questionRepository.findById(1L)).willReturn(Optional.ofNullable(question));
- Stubbing이란 Mock 객체의 동작을 정의하는 것입니다.
Mocking을 통해 Mock 객체를 생성한다면, 필수적으로 Stubbing을 통해 해당 Mock 객체의 동작을
정의해줘야 합니다.
왜냐하면 Mock 객체는 말 그대로 '가짜' 객체이므로, 그 동작을 정의하지 않으면 실제 객체와는 다르게
해당 동작을 수행하지 않기 때문입니다.
- Stubbing을 할 때, BDDMockito의 given 메소드를 사용했습니다.
BDDMockito는 Mockito 클래스를 상속한 클래스이며,
BDDMockito의 given 메소드는 Mockito의 when 메소드와 같은 기능을 한다고 이해하면 됩니다.
(2) AnswerReadWebControllerTest
- 다음은 인수 테스트인 AnswerReadWebControllerTest입니다.
인수 테스트를 위한 프레임워크로는 MockMvc를 사용했습니다.
package com.example.interviewPrep.quiz.Answer.controller;
import com.example.interviewPrep.quiz.answer.controller.AnswerController;
import com.example.interviewPrep.quiz.answer.domain.Answer;
import com.example.interviewPrep.quiz.answer.dto.request.AnswerRequest;
import com.example.interviewPrep.quiz.answer.repository.AnswerRepository;
import com.example.interviewPrep.quiz.config.CustomAuthenticationEntryPoint;
import com.example.interviewPrep.quiz.exception.advice.CommonException;
import com.example.interviewPrep.quiz.filter.JwtAuthenticationFilter;
import com.example.interviewPrep.quiz.member.domain.Member;
import com.example.interviewPrep.quiz.question.domain.Question;
import com.example.interviewPrep.quiz.answer.service.AnswerService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Optional;
import static com.example.interviewPrep.quiz.exception.advice.ErrorCode.NOT_FOUND_ANSWER;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.verify;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(AnswerController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureMockMvc(addFilters = false)
public class AnswerReadWebControllerTest {
@Autowired
MockMvc mockMvc;
@MockBean
AnswerService answerService;
@MockBean
AnswerRepository answerRepository;
@MockBean
CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
@MockBean
JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
ObjectMapper objectMapper;
String validJsonRequest;
String invalidJsonRequest;
@BeforeEach
void setUp() throws Exception {
AnswerRequest validAnswerRequest = AnswerRequest.builder()
.content("new answer")
.questionId(2L)
.build();
AnswerRequest invalidAnswerRequest = AnswerRequest.builder()
.content("new answer")
.questionId(2L)
.build();
validJsonRequest = objectMapper.writeValueAsString(validAnswerRequest);
invalidJsonRequest = objectMapper.writeValueAsString(invalidAnswerRequest);
}
@Test
@DisplayName("유효한 답안 읽기")
void readValidAnswer() throws Exception{
Member member = Member.builder()
.id(1L)
.email("abc@gmail.com")
.name("abc")
.password("1234")
.build();
Question question = Question.builder()
.id(1L)
.title("Question 1")
.type("java")
.difficulty("easy")
.build();
Answer answer = Answer.builder()
.content("hello")
.member(member)
.question(question)
.build();
Long id = 1L;
given(answerRepository.findById(id)).willReturn(Optional.ofNullable(answer));
mockMvc.perform(get("/api/v1/answers/"+id))
.andDo(print())
.andExpect(status().isOk());
verify(answerService).readAnswer(id);
}
@Test
@DisplayName("유효하지 않은 답안 읽기")
void readInValidAnswer() throws Exception{
Long id = 2L;
given(answerService.readAnswer(id)).willThrow(new CommonException(NOT_FOUND_ANSWER));
mockMvc.perform(get("/api/v1/answers/"+id))
.andDo(print())
.andExpect(status().isNotFound())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.error.errorCode").value("not_found_answer"))
.andReturn();
}
}
- 우선 클래스에 사용된 어노테이션들부터 하나씩 살펴보겠습니다.
(2-1) 클래스 어노테이션
@WebMvcTest(AnswerController.class)
@MockBean(JpaMetamodelMappingContext.class)
@AutoConfigureMockMvc(addFilters = false)
- @WebMvcTest(AnswerController.class)는 Application Context를 시작시키지 않고, 컨트롤러 계층만 테스트하고 싶을 때 사용합니다. 여기서 AnswerController.class를 지정했으므로, AnswerController 클래스만 지정하여 컴포넌트 스캔을 하게 됩니다.
- @MockBean(JpaMetamodelMappingContext.class)가 사용된 이유를 이해하려면,
우선 메인 클래스인 InterviewPrepApplication 클래스를 살펴봐야 합니다.
@EnableCaching
@EnableJpaAuditing
@EnableAspectJAutoProxy
@SpringBootApplication
public class InterviewPrepApplication {
public static void main(String[] args) {
SpringApplication.run(InterviewPrepApplication.class, args);
}
}
- 메인 클래스인 InterviewPrepApplication 클래스에는 @EnableJpaAuditing이 선언되어 있습니다.
이것 때문에 모든 테스트들이 JPA 관련 빈들을 필요로 하게 됩니다. 하지만 @WebMvcTest는 컨트롤러
계층만 컴포넌트 스캔하고, JPA 관련 빈들을 로드하지 않으므로 문제가 발생합니다.
- 따라서 @MockBean(JpaMetamodelMappingContext.class)를 통해 AnswerReadWebControllerTest에서
JPA 관련 빈들을 Mocking하면 이 문제를 해결할 수 있습니다.
- @AutoConfigureMockMvc(addFilters=False)는 Spring Web Layer에서 테스트 시 Filter도 같이 스캔하는데, Filter가 없이 테스트하기 위해 지정해 주었습니다.
(2-2) Fixture
@Autowired
MockMvc mockMvc;
@MockBean
AnswerService answerService;
@MockBean
AnswerRepository answerRepository;
@MockBean
CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
@MockBean
JwtAuthenticationFilter jwtAuthenticationFilter;
@Autowired
ObjectMapper objectMapper;
String validJsonRequest;
String invalidJsonRequest;
@BeforeEach
void setUp() throws Exception {
AnswerRequest validAnswerRequest = AnswerRequest.builder()
.content("new answer")
.questionId(2L)
.build();
AnswerRequest invalidAnswerRequest = AnswerRequest.builder()
.content("new answer")
.questionId(2L)
.build();
validJsonRequest = objectMapper.writeValueAsString(validAnswerRequest);
invalidJsonRequest = objectMapper.writeValueAsString(invalidAnswerRequest);
}
- MockMvc 객체를 @Autowired로 선언해줍니다. MockMvc 객체는 스프링 MVC 테스트의 메인 엔트리 포인트 역할을 합니다.
- AnswerRepository, CustomAuthenticationEntryPoint, JwtAuthenticationFilter, ObjectMapper 객체를 선언합니다.
CustomAuthenticationEntryPoint는 Authentication과 관련한 Exception을 처리하는데 필요하고,
JwtAuthenticationFilter는 Jwt과 관련한 Filter를 Mocking하는데 필요합니다.
그리고 ObjectMapper는 요청 객체를 String으로 변환하는데 사용됩니다.
- AnswerRequest 객체를 각각 valid한 경우와 invalid한 경우에 대해서 선언을 해주고,
각각을 String으로 변환합니다.
(2-3) readValidAnswer
@Test
@DisplayName("유효한 답안 읽기")
void readValidAnswer() throws Exception{
Member member = Member.builder()
.id(1L)
.email("abc@gmail.com")
.name("abc")
.password("1234")
.build();
Question question = Question.builder()
.id(1L)
.title("Question 1")
.type("java")
.difficulty("easy")
.build();
Answer answer = Answer.builder()
.content("hello")
.member(member)
.question(question)
.build();
Long id = 1L;
given(answerRepository.findById(id)).willReturn(Optional.ofNullable(answer));
mockMvc.perform(get("/api/v1/answers/"+id))
.andDo(print())
.andExpect(status().isOk());
verify(answerService).readAnswer(id);
}
- readValidAnswer는 유효한 답을 테스트 하기 위한 테스트 메소드입니다.
Answer 객체 생성에 필요한 Member, Question 객체를 fixture로 만들어주고,
AnswerService 클래스에 존재하는 answerRepository.findById를 stubbing합니다.
- 그리고 /api/v1/answers에 대해 읽기 요청을 하면 200 Ok 응답이 반환됨을 확인할 수 있습니다.
(2-4) readInValidAnswer
@Test
@DisplayName("유효하지 않은 답안 읽기")
void readInValidAnswer() throws Exception{
Long id = 2L;
given(answerService.readAnswer(id)).willThrow(new CommonException(NOT_FOUND_ANSWER));
mockMvc.perform(get("/api/v1/answers/"+id))
.andDo(print())
.andExpect(status().isNotFound())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.error.errorCode").value("not_found_answer"))
.andReturn();
}
- readInValidAnswer는 유효하지 않은 답을 테스트하기 위한 테스트 메소드입니다.
id가 2L인 Answer 객체가 없다고 가정하여, 해당 조건에 대해 stubbing을 한 후에 테스트를 진행했습니다.
- 프로젝트 전반에 예외 처리 시 CommonException과 CommonControllerAdvice를 적용했습니다.
이를 통해 Exception 처리를 일관성 있게 함과 동시에, Exception과 관련된 중복 코드를
제거할 수 있었습니다.
(2-4-1) CommonException
package com.example.interviewPrep.quiz.exception.advice;
import lombok.Getter;
@Getter
public class CommonException extends RuntimeException {
private ErrorCode error;
private String message;
public CommonException(ErrorCode error) {
this.error = error;
}
public CommonException(ErrorCode error, String message) {
this.error = error;
this.message = message;
}
}
(2-4-2) CommonControllerAdvice
package com.example.interviewPrep.quiz.exception.advice;
import com.example.interviewPrep.quiz.response.ErrorResponse;
import com.example.interviewPrep.quiz.response.ResultResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import static com.example.interviewPrep.quiz.exception.advice.ErrorCode.*;
@Slf4j
@RestControllerAdvice("com.example.interviewPrep.quiz")
public class CommonControllerAdvice {
@ExceptionHandler()
public ResponseEntity<ResultResponse<?>> commonExHandler(CommonException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ResultResponse.fail(ErrorResponse.of(ex.getError())));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResultResponse<?> processValidationError(MethodArgumentNotValidException exception) {
BindingResult bindingResult = exception.getBindingResult();
StringBuilder builder = new StringBuilder();
for (FieldError fieldError : bindingResult.getFieldErrors()) {
builder.append("[");
builder.append(fieldError.getField());
builder.append("](은)는 ");
builder.append(fieldError.getDefaultMessage());
builder.append(" 입력된 값: [");
builder.append(fieldError.getRejectedValue());
builder.append("]");
}
return ResultResponse.fail(ErrorResponse.of(MISSING_PARAMETER.setMissingParameterMsg(builder.toString())));
}
}
- 다만, 테스트에서는 에러가 발생 시 404 Not Found 에러를 리턴하도록 했다는 점을 고려해야 합니다.
이를 위해서는 CommonControllerAdvice에서 CommonException 발생 시,
404 Not Found에러가 발생하도록 수정해야 합니다.
- /api/v1/answers에 대해 읽기 요청을 하면 404 Not Found가 반환됨을 확인할 수 있습니다.
참고자료
단위 테스트 vs 통합 테스트 vs 인수 테스트 (techcourse.co.kr)
[TDD] 단위 테스트(Unit Test) 작성의 필요성 (1/3) - MangKyu's Diary (tistory.com)
TDD, BDD란? (tistory.com)
kotest가 있다면 TDD 묻고 BDD로 가! - kakaoTV
[Spring] MockMvc 공부하자!! (velog.io)
인수테스트에서 테스트 격리하기 (techcourse.co.kr)
Difference between Mockito.mock(), @Mock and @MockBean annotation in Spring Boot | Java67
Importance of mocking and stubbing in Tests | by pandaquests | Medium
Mockito와 BDDMockito는 뭐가 다를까? (techcourse.co.kr)
[Spring Boot] Controller 단위 테스트 (@WebMvcTest, MockMvc) (tistory.com)
[Spring] @WebMvcTest에 의해 느려지는 테스트 속도와 해결 방법(컨트롤러에 대한 단위 테스트 작성하기) - MangKyu's Diary (tistory.com)
Spring Boot jUnit Test : JPA metamodel must not be empty! 에러 해결 방법 (tistory.com)
'interviewPrep 프로젝트' 카테고리의 다른 글
Jenkins로 CI/CD 구축하기 (2) (0) | 2023.10.25 |
---|---|
Jenkins로 CI/CD 구축하기 (1) (0) | 2023.10.24 |
로그인 시 JWT Access Token, Refresh Token 적용하기(with Redis) - 1부 (0) | 2023.09.12 |
Mysql Named Lock으로 좋아요 기능에 대한 동시성 처리하기 (0) | 2023.09.11 |
SSE 댓글 알림 기능 개발(with Redis) (0) | 2023.09.11 |