이번 시간에는 알람 기능을 구현해보겠습니다.
어떤 게시글에 댓글이 달린다면, 게시글 작성자와 상위 댓글 작성자에게 이메일, SMS, 라인 메신저로 알림 메시지를 보내도록 하겠습니다.
물론, 실제로 보내는 것은 아니고, 간단한 로그 메시지만 남길 것입니다.
위 기능을 구현하면서 이벤트의 필요성을 중점적으로 알아볼 것입니다.
일단 이메일, SMS, 라인 메신저를 보내주는 알람 서비스를 구현하도록 하겠습니다.
service.alarm.AlarmService 인터페이스를 작성해줍니다.
package kukekyakya.kukemarket.service.alarm;
import ...
public interface AlarmService {
void alarm(AlarmInfoDto infoDto);
}
알람 정보를 전달 받으면, 알람을 보내게 될 것입니다.
이를 구현하는 EmailAlarmService, LineAlarmService, SmsAlarmService를 작성해주겠습니다.
package kukekyakya.kukemarket.service.alarm;
import ...
@Component
@Slf4j
public class EmailAlarmService implements AlarmService {
@Override
public void alarm(AlarmInfoDto infoDto) {
log.info("{} 에게 이메일 전송 = {}", infoDto.getTarget().getEmail(), infoDto.getMessage());
}
}
package kukekyakya.kukemarket.service.alarm;
import ...
@Component
@Slf4j
public class LineAlarmService implements AlarmService {
@Override
public void alarm(AlarmInfoDto infoDto) {
log.info("{} 에게 라인 전송 = {}", infoDto.getTarget().getNickname(), infoDto.getMessage());
}
}
package kukekyakya.kukemarket.service.alarm;
import ...
@Component
@Slf4j
public class SmsAlarmService implements AlarmService {
@Override
public void alarm(AlarmInfoDto infoDto) {
log.info("{} 에게 문자메시지 전송 = {}", infoDto.getTarget().getUsername(), infoDto.getMessage());
}
}
단순히 전송했다는 로그만 남길 뿐입니다.
* 라인 메신저나 전화번호에 대한 정보는 없으므로, 임의로 닉네임과 사용자 명을 기록하였습니다.
파라미터로 전달 받는 dto.alarm.AlarmInfoDto는 다음과 같습니다.
package kukekyakya.kukemarket.dto.alarm;
import ...
@Data
@AllArgsConstructor
public class AlarmInfoDto {
private MemberDto target;
private String message;
}
알람을 수신할 대상과 메시지를 가지고 있습니다.
이제 댓글이 작성될 때마다, 위에서 작성된 3개의 서비스를 이용하여 알람을 전송해야합니다.
이를 어떻게 구현할 수 있을까요?
가장 간단한 방법은, 단순히 댓글이 작성될 때마다 의존하고 있는 알람 서비스를 호출하는 것입니다.
코드로 살펴보면 다음과 같을 것입니다.
package kukekyakya.kukemarket.service.comment;
import ...
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CommentService {
...
private final AlarmService emailAlarmService;
private final AlarmService lineAlarmService;
private final AlarmService smsAlarmService;
@Transactional
public void create(CommentCreateRequest req) {
Comment comment = commentRepository.save(CommentCreateRequest.toEntity(req, memberRepository, postRepository, commentRepository));
emailAlarmService.alarm(new AlarmInfoDto(...));
lineAlarmService.alarm(new AlarmInfoDto(...));
smsAlarmService.alarm(new AlarmInfoDto(...));
}
...
}
구현된 알람 서비스들을 주입받고, 댓글이 작성될 때마다 직접 AlarmService.alarm을 수행해주고 있습니다.
어떻게 보면 가장 직관적으로 보이지만, 이러한 방식에는 몇 가지 문제가 있습니다.
CommentService는 댓글에 관한 비즈니스 로직을 담당하고 있습니다.
하지만 댓글이 작성될 때마다 알람을 전송한다는 것은, 댓글에 관한 비즈니스 로직으로 보기에는 어렵습니다.
물론, 실질적인 알람 전송은 AlarmService의 책임이지만, 알람 전송을 해달라고 AlarmService에게 요청하는 과정도, CommentService가 가져야할 책임이라고 보기에는 어려운 것입니다.
이러한 과정을 수행하기 위해, 새로운 의존성이 추가되면서 결합도도 높아지고 있습니다.
CommentService가 알아야할 사항이 늘어나게 된 것입니다.
지금은 단순히 3개의 알람을 전송할 뿐이지만, 댓글이 작성될 때마다 로그를 남겨야 한다거나 관리자에게 새로운 알림을 보내야한다면,
그 때마다 CommentService에 새로운 의존성을 추가하고, 새로운 코드를 작성해줘야합니다.
의존성이 늘어남에 따라 코드는 이해하기 어려워지고, 확장에는 번거로운 작업이 뒤따르며, 변경에는 취약해지게 될 것입니다.
이러한 문제 사항을 해결하기 위해 이벤트를 활용해보겠습니다.
CommentService는 다음과 같은 코드로 바뀌게 될 것입니다.
package kukekyakya.kukemarket.service.comment;
import ...
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CommentService {
private final ApplicationEventPublisher publisher;
...
@Transactional
public void create(CommentCreateRequest req) {
Comment comment = commentRepository.save(CommentCreateRequest.toEntity(req, memberRepository, postRepository, commentRepository));
comment.publishCreatedEvent(publisher);
log.info("CommentService.create");
}
...
}
이제 단순히 ApplicationEventPublisher만 주입받아서 사용하고 있습니다.
댓글이 생성된다면, Comment.publishCreatedEvent에서 댓글 생성 이벤트를 발행해주기만 하면 됩니다.
댓글 생성 이벤트를 나타내는 event.comment.CommentCreatedEvent는 다음과 같습니다.
package kukekyakya.kukemarket.event.comment;
import ...
@Data
@AllArgsConstructor
public class CommentCreatedEvent {
private MemberDto publisher;
private MemberDto postWriter;
private MemberDto parentWriter;
private String content;
}
publisher : 댓글 작성자(이벤트 발행자)
postWriter : 게시글 작성자
parentWriter : 상위 댓글 작성자
content : 댓글 내용
위와 같은 네 가지 정보를 가지고 있습니다.
이제 CommentCreatedEvent를 발행하는, Comment.publishCreatedEvent를 살펴봅시다.
package kukekyakya.kukemarket.entity.comment;
import ...
...
public class Comment extends EntityDate {
...
public void publishCreatedEvent(ApplicationEventPublisher publisher) {
publisher.publishEvent(
new CommentCreatedEvent(
MemberDto.toDto(getMember()),
MemberDto.toDto(getPost().getMember()),
Optional.ofNullable(getParent()).map(p -> p.getMember()).map(m -> MemberDto.toDto(m)).orElseGet(() -> MemberDto.empty()),
getContent()
)
);
}
}
전달받은 ApplicationEventPublisher를 이용하여, CommentCreatedEvent를 발행하고 있습니다.
상위 댓글은 없을 수 있으므로, 그런 상황에는 비어있는 MemberDto를 주입해주도록 하겠습니다.
MemberDto.empty는 다음과 같습니다.
package kukekyakya.kukemarket.dto.member;
...
public class MemberDto {
...
public static MemberDto empty() {
return new MemberDto(null, "", "", "");
}
}
비어있는 필드를 가지고 있습니다.
새롭게 작성된 CommentService를 테스트해주겠습니다.
CommentServiceTest.createTest를 다음과 같이 수정하였습니다.
package kukekyakya.kukemarket.service.comment;
import ...
@ExtendWith(MockitoExtension.class)
class CommentServiceTest {
@InjectMocks CommentService commentService;
...
@Mock ApplicationEventPublisher publisher;
...
@Test
void createTest() {
// given
ArgumentCaptor<Object> eventCaptor = ArgumentCaptor.forClass(Object.class);
given(memberRepository.findById(anyLong())).willReturn(Optional.of(createMember()));
given(postRepository.findById(anyLong())).willReturn(Optional.of(createPost()));
given(commentRepository.save(any())).willReturn(createComment(null));
// when
commentService.create(createCommentCreateRequest());
// then
verify(commentRepository).save(any());
verify(publisher).publishEvent(eventCaptor.capture());
Object event = eventCaptor.getValue();
assertThat(event).isInstanceOf(CommentCreatedEvent.class);
}
...
}
새로운 의존성 ApplicationEventPublisher를 CommentService에 주입해주고,
ArgumentCatpor를 이용하여 발행된 이벤트를 검증하는 로직도 추가되었습니다.
이제 CommentCreatedEvent를 처리하는 리스너를 작성해주도록 하겠습니다.
위 이벤트가 발행된다면, 리스너가 해당 이벤트의 정보를 이용하여 알람을 전송하게 될 것입니다.
package kukekyakya.kukemarket.event.comment;
import ...
@Component
@RequiredArgsConstructor
@Slf4j
public class CommentCreatedListener {
private final AlarmService emailAlarmService; // 1
private final AlarmService lineAlarmService; // 1
private final AlarmService smsAlarmService; // 1
private List<AlarmService> alarmServices = new ArrayList<>();
@PostConstruct
public void postConstruct() { // 2
alarmServices.add(emailAlarmService);
alarmServices.add(lineAlarmService);
alarmServices.add(smsAlarmService);
}
@TransactionalEventListener // 3
@Async // 4
public void handleAlarm(CommentCreatedEvent event) { // 5
log.info("CommentCreatedListener.handleAlarm");
String message = generateAlarmMessage(event);
if(isAbleToSendToPostWriter(event)) alarmTo(event.getPostWriter(), message);
if(isAbleToSendToParentWriter(event)) alarmTo(event.getParentWriter(), message);
}
private void alarmTo(MemberDto memberDto, String message) { // 6
alarmServices.stream().forEach(alarmService -> alarmService.alarm(new AlarmInfoDto(memberDto, message)));
}
private boolean isAbleToSendToPostWriter(CommentCreatedEvent event) { // 7
if(!isSameMember(event.getPublisher(), event.getPostWriter())) {
if(hasParent(event)) return !isSameMember(event.getPostWriter(), event.getParentWriter());
return true;
}
return false;
}
private boolean isAbleToSendToParentWriter(CommentCreatedEvent event) { // 8
return hasParent(event) && !isSameMember(event.getPublisher(), event.getParentWriter());
}
private boolean isSameMember(MemberDto a, MemberDto b) {
return Objects.equals(a.getId(), b.getId());
}
private boolean hasParent(CommentCreatedEvent event) {
return event.getParentWriter().getId() != null;
}
private String generateAlarmMessage(CommentCreatedEvent event) { // 9
return event.getPublisher().getNickname() + " : " + event.getContent();
}
}
1. 알람을 전송하기 위한 의존성을 주입받습니다.
CommentService에서 의존하고 있던 기존의 방식과는 달리, CommentCreatedEvent를 수신하는 리스너에서 대신 의존성을 가지고 있는 것입니다.
2. 주입받은 알람 서비스들을 리스트에 담아주었습니다.
3. @EventListener를 등록하면, 파라미터로 전달 받는 이벤트가 발생할 때마다 동기적으로 해당 메소드가 호출될 것입니다.
하지만 이러한 방식에는 문제가 있습니다.
이벤트를 먼저 발행하고, CommentService.create에서 댓글을 생성하다가 예외가 발생했다고 가정해보겠습니다.
실제로 작성된 댓글은 없는데, 사용자에게는 알람이 전송될 것입니다.
물론, 댓글 생성 로직을 먼저 수행하여, 알람을 전송하기 전에 예외를 발생시키는 방법도 있겠지만, 순서를 강제해야하는 번거로움이 있습니다.
마냥 순서를 강제한다고 해서 해결되는 것도 아닙니다.
CommentService.create를 정상적으로 수행한 뒤에, 이벤트를 발행하는 상황을 가정해보겠습니다.
CommentCreatedListener에서는, 이메일, SMS, 라인 메신저로 알람을 전송하고 있습니다.
이러한 작업은 어떤 문제가 발생할지 모르는 외부 API와 연동해야합니다.
네트워크에 문제가 생긴다거나, API 서버가 내려간다거나, API에서 오류가 발생하는 등 다양한 문제 상황이 생길 수 있습니다.
이로 인해 예외가 발생한다면, 정상적으로 처리되었던 댓글 생성도 @Transactional의 기본 정책에 의해 롤백이 되어버릴 것입니다.
댓글 생성이라는 핵심 로직과 전혀 관계없는 외부 API에 의해 예외가 전파되면서, 우리의 서비스에도 영향을 끼치게 되는 것입니다.
이를 해결하기 위해 @TransactionalEventListener를 이용할 수 있습니다.
이벤트를 발행하던 트랜잭션의 흐름에 따라, 이벤트를 제어할 수 있게 되는 것입니다.
여기에는 다양한 옵션이 있지만, 우리는 디폴트 설정인 TransactionPhase.AFTER_COMMIT을 사용하겠습니다.
트랜잭션이 커밋된 이후, 리스너에서 이벤트를 처리하게 됩니다.
4. @TransactionalEventListener를 이용한다고 해서 모든 문제가 해결되는 것은 아닙니다.
커밋 이후에 리스너에서 이벤트를 처리한다고 해도, 이러한 모든 과정은 하나의 스레드에서 수행됩니다.
이벤트에 대해 처리하는 과정은, 네트워크 요청이나 I/O 작업을 수행하면서 많은 비용이 생길 수 있습니다.
본래의 작업은 진작 끝났음에도 불구하고, 응답이 지연되는 등의 문제가 발생할 수 있는 것입니다.
이를 해결하기 위해 @Async를 지정해주었습니다.
해당 이벤트를 처리하는 메소드는, 새로운 스레드에서 비동기적으로 처리될 것입니다.
따라서 이벤트를 발행했던 메소드는 더이상 블록되지않고, 즉시 응답을 내려줄 수 있게 됩니다.
이를 활성화하기 위해서는 특별한 설정이 필요한데, 아래에서 다시 살펴보겠습니다.
5. CommentCreatedEvent가 발행되었을 때 처리하는 메소드입니다.
전송할 알람 메시지를 생성하고, 게시글 작성자와 상위 댓글 작성자에게 알람을 전송할 수 있는지 확인한 뒤, 알람 전송을 수행합니다.
6. 알람 서비스를 이용하여 알람을 전송하게 됩니다.
AlarmInfoDto를 이용하여 수신자에게 메시지를 전달하게 될 것입니다.
7. 알람을 전송한다는 것은 큰 비용이 생기게 됩니다.
이러한 비용을 줄이기 위해서는 불필요한 알람 전송을 최소화해야합니다.
댓글의 작성자가 게시글의 작성자이거나, 게시글의 작성자가 상위 댓글의 작성자라면, 알람을 전송할 필요가 없습니다.
* 게시글의 작성자가 상위 댓글의 작성자라는 것은, 어차피 상위 댓글의 작성자에게 알람을 전송할 것이기 때문에, 중복된 알람 전송을 없애는 것입니다.
8. 댓글의 작성자가 상위 댓글의 작성자라면, 알람을 전송할 필요가 없습니다.
9. 발행자 닉네임과 댓글 내용으로 알람 메시지를 생성합니다.
dto.alarm.AlarmInfoDto는 다음과 같습니다.
package kukekyakya.kukemarket.dto.alarm;
import ...
@Data
@AllArgsConstructor
public class AlarmInfoDto {
private MemberDto target;
private String message;
}
알람 수신자에 대한 정보와 메시지를 가지고 있습니다.
이제 @Async를 활성화하기 위한 설정을 살펴보도록 하겠습니다.
package kukekyakya.kukemarket.config;
import ...
@EnableAsync // 1
@Configuration
@Slf4j
@Profile("!test") // 2
public class AsyncConfig implements AsyncConfigurer { // 3
@Override
public Executor getAsyncExecutor() { // 4
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setCorePoolSize(3);
taskExecutor.setMaxPoolSize(10);
taskExecutor.setQueueCapacity(50);
taskExecutor.setThreadNamePrefix("async-thread-");
taskExecutor.initialize();
return taskExecutor;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { // 5
return (ex, method, params) -> log.info("exception occurred in {} {} : {}", method.getName(), params, ex.getMessage());
}
}
1. Async를 활성화하기 위해 @EnableAsync를 지정해줍니다.
2. 이벤트에 대한 테스트를 수행할 때는, 반드시 비동기로 처리할 필요는 없을 것입니다. 테스트의 복잡도만 증가할 뿐입니다. 물론, 단위 테스트로 작성하는 방법도 있겠습니다.
3. AsyncConfigurer 인터페이스를 구현해주겠습니다. 해당 인터페이스에 작성된 디폴트 메소드를 오버라이딩하여, 각종 설정 정보를 등록할 수 있습니다.
4. 스레드풀을 지정해주도록 하겠습니다. 이를 지정해주지 않는다면, 비효율적인 디폴트 방식을 사용하게 됩니다.
초기 스레드 개수(corePoolSize), corePoolSize가 모두 사용 중일 때 새로 만들어지는 최대 스레드 개수(maxPoolSize), maxPoolSize가 모두 사용 중일 때 대기하는 큐의 크기(queueCapacity), 스레드명의 접두어(threadNamePrefix)를 지정해주었습니다.
5. 비동기 메소드에서 발생하는 예외는, @RestControllerAdvice에서 잡아낼 수 없습니다. 비동기 메소드에서도 단일화된 예외 관리를 위해, AsyncUncaughtExceptionHandler를 구현해주었습니다. 간단한 로그를 남겨주도록 하겠습니다.
이제 CommentCreatedListener도 테스트해주겠습니다.
package kukekyakya.kukemarket.event.comment;
import ...
@SpringBootTest // 1
@ActiveProfiles(value = "test")
@Transactional // 2
@Commit // 3
class CommentCreatedListenerTest {
@Autowired ApplicationEventPublisher publisher;
@MockBean(name = "smsAlarmService") AlarmService smsAlarmService; // 4
@MockBean(name = "emailAlarmService") AlarmService emailAlarmService; // 4
@MockBean(name = "lineAlarmService") AlarmService lineAlarmService; // 4
int calledCount;
@AfterTransaction // 5
void afterEach() {
verify(emailAlarmService, times(calledCount)).alarm(any(AlarmInfoDto.class));
verify(lineAlarmService, times(calledCount)).alarm(any(AlarmInfoDto.class));
verify(smsAlarmService, times(calledCount)).alarm(any(AlarmInfoDto.class));
}
@Test
void handleCommentCreatedEventTest() {
// given
MemberDto publisher = MemberDto.toDto(createMemberWithId(1L));
MemberDto postWriter = MemberDto.toDto(createMemberWithId(2L));
MemberDto parentWriter = MemberDto.toDto(createMemberWithId(3L));
String content = "content";
// when
this.publisher.publishEvent(new CommentCreatedEvent(publisher, postWriter, parentWriter, content));
//then
calledCount = 2;
}
@Test
void handleCommentCreatedEventWhenPublisherIsPostWriterTest() {
// given
MemberDto publisher = MemberDto.toDto(createMemberWithId(1L));
MemberDto postWriter = MemberDto.toDto(createMemberWithId(1L));
MemberDto parentWriter = MemberDto.empty();
String content = "content";
// when
this.publisher.publishEvent(new CommentCreatedEvent(publisher, postWriter, parentWriter, content));
// then
calledCount = 0;
}
@Test
void handleCommentCreatedEventWhenPublisherIsParentWriterTest() {
// given
MemberDto publisher = MemberDto.toDto(createMemberWithId(1L));
MemberDto postWriter = MemberDto.toDto(createMemberWithId(2L));
MemberDto parentWriter = MemberDto.toDto(createMemberWithId(1L));
String content = "content";
// when
this.publisher.publishEvent(new CommentCreatedEvent(publisher, postWriter, parentWriter, content));
// then
calledCount = 1;
}
@Test
void handleCommentCreatedEventWhenPostWriterIsParentWriterTest() {
// given
MemberDto publisher = MemberDto.toDto(createMemberWithId(1L));
MemberDto postWriter = MemberDto.toDto(createMemberWithId(2L));
MemberDto parentWriter = MemberDto.toDto(createMemberWithId(2L));
String content = "content";
// when
this.publisher.publishEvent(new CommentCreatedEvent(publisher, postWriter, parentWriter, content));
// then
calledCount = 1;
}
}
1. 발행된 이벤트를 리스너가 처리하는 과정을 검증하기 위해, 통합테스트로 진행해주겠습니다.
2. @TransactionalEventListener로 이벤트를 처리하고 있으므로, 트랜잭션의 흐름에 따라 리스너가 이벤트를 처리할 것입니다.
3. 테스트에서 @Transactional은 기본적으로 롤백을 하게 됩니다. 하지만 우리는 트랜잭션이 커밋되고 난 이후에 @TransactionalEventListener가 동작하고 있습니다. 강제로 커밋을 내려주기 위해 @Commit을 지정해주었습니다.
4. 필요한 의존성들을 Mock으로 바꿔줍니다. 이를 이용하여 알람 전송 여부 등을 검증할 수 있을 것입니다.
5. 트랜잭션이 끝난 뒤에, 해당 메소드가 호출될 것입니다. 각 테스트에서는 호출된 횟수(calledCount)를 업데이트하고, 실질적인 검증은 여기에서 하도록 하겠습니다.
각 테스트에 대한 자세한 설명은 생략하겠습니다.
새롭게 사용된 팩토리 메소드 MemberFactory.createMemberWithId는 다음과 같습니다.
// MemberFactory.java
public static Member createMemberWithId(Long id) {
Member member = new Member("email@email.com", "123456a!", "username", "nickname", emptyList());
ReflectionTestUtils.setField(member, "id", id);
return member;
}
테스트를 수행해보겠습니다.
모든 테스트가 성공하였습니다.
실제로 서버를 구동하여 댓글을 작성해보고, 정상적으로 알람이 전송되는지 로그 메시지를 확인해봅시다.
admin이 작성한 게시글에 최상위 댓글을 작성하였습니다.
2022-01-01 17:14:24.738 INFO 11136 --- [nio-8080-exec-5] k.k.service.comment.CommentService : CommentService.create
2022-01-01 17:14:24.766 INFO 11136 --- [ async-thread-1] k.k.e.comment.CommentCreatedListener : CommentCreatedListener.handleAlarm
2022-01-01 17:14:24.768 INFO 11136 --- [ async-thread-1] k.k.service.alarm.EmailAlarmService : admin@admin.com 에게 이메일 전송 = member2 : my content
2022-01-01 17:14:24.768 INFO 11136 --- [ async-thread-1] k.k.service.alarm.LineAlarmService : admin 에게 라인 전송 = member2 : my content
2022-01-01 17:14:24.769 INFO 11136 --- [ async-thread-1] k.k.service.alarm.SmsAlarmService : admin 에게 문자메시지 전송 = member2 : my content
CommentService.create에서 실행되던 nio-8080-exec-5 스레드가 이벤트를 발행하고,
CommentCreatedListene.handleAlarm에서는 async-thread-1 스레드로 실행되고 있습니다.
비동기 작업의 예외를 잘 잡아내고있는지도 확인해봅시다.
임의로 SmsAlarmService.alarm에서 RuntimeException을 던져주었습니다.
{
"success": true,
"code": 0
}
댓글 작성은 정상적으로 수행되었습니다.
로그를 살펴보겠습니다.
2022-01-01 17:50:13.437 INFO 21352 --- [ async-thread-1] k.kukemarket.config.AsyncConfig : exception occurred in handleAlarm [CommentCreatedEvent(publisher=MemberDto(id=3, email=member2@member.com, username=member2, nickname=member2), postWriter=MemberDto(id=1, email=admin@admin.com, username=admin, nickname=admin), parentWriter=MemberDto(id=null, email=, username=, nickname=), content=my content)] : 예외 발생
비동기 작업에서 발생한 예외는, AsyncUncaughtExceptionHandler에 의해 로그가 기록되고 있습니다.
이번 시간에는 이벤트를 활용하여 알람 기능을 작성할 수 있었습니다.
기존의 방식으로 알람 기능을 작성할 때의 문제 상황을 살펴보며 이벤트의 필요성을 알아보았고,
트랜잭션과 외부 API 호출이 필요한 상황에 이벤트 처리의 개선점을 살펴보았으며,
비동기 메소드에서 일어나는 예외를 잡아낼 수 있게 되었습니다.
이벤트로 인해 전체적인 구조는 더욱 복잡해졌지만, 다양한 책임은 여러 곳으로 분산되었습니다.
이로 인해 효율적으로 코드를 관리할 수 있을 것입니다.
* 질문 및 피드백은 환영입니다.
* 전체 소스코드에서는 여기에서 확인해볼 수 있습니다.
'Spring > 게시판 만들기' 카테고리의 다른 글
스프링부트 게시판 API 서버 만들기 (35) - 토큰 인증 방식 수정 (2) | 2022.01.04 |
---|---|
스프링부트 게시판 API 서버 만들기 (34) - 국제화 (2) | 2022.01.02 |
스프링부트 게시판 API 서버 만들기 (32) - 쪽지 - 무한 스크롤 - 2 (0) | 2021.12.27 |
스프링부트 게시판 API 서버 만들기 (31) - 쪽지 - 무한 스크롤 - 1 (4) | 2021.12.27 |
스프링부트 게시판 API 서버 만들기 (30) - 코드 리팩토링 (0) | 2021.12.24 |