부트캠프에서 제공한 개발자 우수 이력서 양식들을 보고 놀라서 자빠져있다가 일어나서 이력서 초안을 작성했습니다. 비지니스 이력서, 포맷이 정해진 이력서를 작성하고 심지어 부업으로 그런 이력서들을 첨삭해주는 일도 했던 저에게는 기절 초풍할 노릇이었습니다. 노션으로 이력서를 작성할 수도 있다는 사실은 알고 있었는데 '안녕하세요'가 들어간 이력서라니...🤦♀️🤦♀️
우선은 부트캠프에서 제공한 이력서 양식을 참조하되 제가 아는 방식으로 조금 더 포멀하게 만들었습니다. 깃헙을 검색해서 나온 이력서들도 참조했습니다. 내용은 여전히 어떻게 채워야 할지는 모르겠습니다. 최종 프로젝트를 6주동안 진행했는데 아무래도 제가 구현하지 않은 부분도 있다 보니 어떤부분을 넣을지, 어떤부분을 강조할 지에 대해서는 피드백이 필요하다고 판단했습니다. 우선 부트캠프에서 제공하는 이력서 피드백 서비스를 신청했습니다.
개발자 이력서 초안
원래는 보통 학력이 맨위에 올라가는데, 학력을 맨밑에 넣는 일이 생겼습니다.. 전공이 달라 슬픕니다. 첨부터 공대갈걸..........ㅠㅠ
/*
* Copyright 2008-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.data.domain;
import java.util.Optional;
import org.springframework.util.Assert;
/**
* Abstract interface for pagination information.
*
* @author Oliver Gierke
* @author Mark Paluch
*/
public interface Pageable {
/**
* Returns a {@link Pageable} instance representing no pagination setup.
*
* @return
*/
static Pageable unpaged() {
return Unpaged.INSTANCE;
}
/**
* Creates a new {@link Pageable} for the first page (page number {@code 0}) given {@code pageSize} .
*
* @param pageSize the size of the page to be returned, must be greater than 0.
* @return a new {@link Pageable}.
* @since 2.5
*/
static Pageable ofSize(int pageSize) {
return PageRequest.of(0, pageSize);
}
/**
* Returns whether the current {@link Pageable} contains pagination information.
*
* @return
*/
default boolean isPaged() {
return true;
}
/**
* Returns whether the current {@link Pageable} does not contain pagination information.
*
* @return
*/
default boolean isUnpaged() {
return !isPaged();
}
/**
* Returns the page to be returned.
*
* @return the page to be returned or throws {@link UnsupportedOperationException} if the object is
* {@link #isUnpaged()}.
* @throws UnsupportedOperationException if the object is {@link #isUnpaged()}.
*/
int getPageNumber();
/**
* Returns the number of items to be returned.
*
* @return the number of items of that page or throws {@link UnsupportedOperationException} if the object is
* {@link #isUnpaged()}.
* @throws UnsupportedOperationException if the object is {@link #isUnpaged()}.
*/
int getPageSize();
/**
* Returns the offset to be taken according to the underlying page and page size.
*
* @return the offset to be taken or throws {@link UnsupportedOperationException} if the object is
* {@link #isUnpaged()}.
* @throws UnsupportedOperationException if the object is {@link #isUnpaged()}.
*/
long getOffset();
/**
* Returns the sorting parameters.
*
* @return
*/
Sort getSort();
/**
* Returns the current {@link Sort} or the given one if the current one is unsorted.
*
* @param sort must not be {@literal null}.
* @return
*/
default Sort getSortOr(Sort sort) {
Assert.notNull(sort, "Fallback Sort must not be null");
return getSort().isSorted() ? getSort() : sort;
}
/**
* Returns the {@link Pageable} requesting the next {@link Page}.
*
* @return
*/
Pageable next();
/**
* Returns the previous {@link Pageable} or the first {@link Pageable} if the current one already is the first one.
*
* @return
*/
Pageable previousOrFirst();
/**
* Returns the {@link Pageable} requesting the first page.
*
* @return
*/
Pageable first();
/**
* Creates a new {@link Pageable} with {@code pageNumber} applied.
*
* @param pageNumber
* @return a new {@link PageRequest} or throws {@link UnsupportedOperationException} if the object is
* {@link #isUnpaged()} and the {@code pageNumber} is not zero.
* @since 2.5
* @throws UnsupportedOperationException if the object is {@link #isUnpaged()}.
*/
Pageable withPage(int pageNumber);
/**
* Returns whether there's a previous {@link Pageable} we can access from the current one. Will return
* {@literal false} in case the current {@link Pageable} already refers to the first page.
*
* @return
*/
boolean hasPrevious();
/**
* Returns an {@link Optional} so that it can easily be mapped on.
*
* @return
*/
default Optional<Pageable> toOptional() {
return isUnpaged() ? Optional.empty() : Optional.of(this);
}
}
나름 이력서 코칭하는 알바도 했었지만! 개발자의 이력서는 어떻게 써야하는지 알지 못했던 저에게 유용한 세선을 오전내내 듣고 이력서를 작성하는 시간을 가졌습니다. 퇴사하고 많은일을 했었지만 개발관련된 것들은 부트캠프를 제외하면 없기 때문에 공백을 설명하는 것이 걱정일 거 같긴 합니다.
멘토님의 모의 면접
갑자기 멘토님이 면접질문을 하시겠다고 해서 당황했지만 질문하신 내역을 정리해 보았습니다.
ORM 이란?
JPA란?
QueryDSL이란? (ORM이랑 묶어서 )
MyBatis?
elastic search란? (개념 알기)
Index란?
gradle 이란?
Hibernate는 뭐야?
JPA는 뭐지
MySQL 왜 도입했는지?
인증, 인가, 쿠키, 세션, 토큰
Oauth 대충이해하기
SSE vs Websocket
CDN, Nginx flow 알기
Spring 구조, 작동 원리
답변은 추후 작성하기로 하겠습니다.
기타 멘토링 사항
기술은 구구절절 나열하지 않기.
내가 어떤 문제 해결을 위해서 어떤 기술을 썼습니다 라고 하면 됩니다.
여유로운 사람을 찾게 됩니다. 과시적인 사람은 알맹이가 오히려 없습니다.
구구절절은 지양하기.
이력서 쓰기
개발자 이력서는 처음이라 어떤 포맷으로 해야할지도 고민이 됩니다. 최종 프로젝트가 커서 할 말이 많아 다행입니다. 내일까지 이력서 제출하고 피드백을 받는 일정입니다. 취업까지 화이팅!
CRUD를 갓 배운 상태에서 다양한 기능들을 접하게 되어 처음에는 압도되서 부담이 되었지만 step-by-step으로 기술 하나하나 적용했고 발생하는 에러들을 해결하다보니 어느새 멋진 프로젝트가 완성되어 모두 앞에서 선보일 수 있게 되었습니다. 실력적으로도 경험적으로도 크게 성장했습니다. 좋은 팀원들을 만나서 운이 좋았다고 생각합니다. 끝까지 노력한 저와 팀원들 모두에게 감사합니다.
QueryDSL의 최신 버전(2021.07.22 release)인 5.0 버전을 사용했습니다. 블로그 자료들에는 outdated된 것들이 많아서 최대한 공식 문서와 github을 참조하려고 했습니다. QueryDSL의 github에는 데이터 타입 별 튜토리얼도 있습니다. 저도 언젠가는 이런 오픈소스에 기여할 수 있는 개발자가 되고 싶습니다. 제가 개발자라는 직업에 매력을 느끼는 이유기도 합니다.
QueryDSL 도입 이유
현재 JPQL을 사용하고 있는데 3가지 문제점을 발견하였습니다.
문자열(String)로 처리가 되다 보니 띄어쓰기 하나에도 오류가 날 수 있음
Compile 단계에서 오류 체크가 불가능함
Runtime 단계에서 오류를 발견할 수 있어 비효율적임
포트폴리오적으로도, 새로운 기술을 써본다는 측면으로도 QueryDSL를 사용해보고 싶었지만 위에 말한 3가지 문제를 해결할 수 있어서 QueryDSL를 도입하기로 결정했습니다. QueryDSL은 아래와 같은 장점을 가지고 있습니다.
QueryDSL은 모든 쿼리에 대한 내용이 함수 형태로 제공 됨
위의 이유 덕분에, complie 단계에서 오류 체크(Type-check)가 가능함
커스터마이징하기 쉽고 유연한 코드를 작성할 수 있음
QueryDSL의 단점은 코드 라인 수가 길어진다는 것이 있지만, 함수 형태이기 때문에 가독성이 좋고 IDE의 도움(코드 자동완성)을 받을 수 있다는 게 장점입니다. 2번 이유에 대해서 멘토님께 발표할 때 human error라고 했는데 무슨 말인지 못알아들으셔서 "오타요!" 라고 외치고 말았습니다.
최신 버전인 5.0.0 버전을 사용하기로 했습니다. 다른 블로그를 참조하실 때 조심하세요! 버전이 다 다릅니다. 저는 지마켓 기술 블로그를 참조했습니다.
낮은 버전이라도 버전 숫자만 바꾸면 되겠지 하다가 엄청 해맸습니다. 기술에 대해서 완전히 이해하고 코드 쓰기로 다짐했으면서 바로 질러버리는 행동을 해서 반성합니다....
Q 클래스
QueryDSL로 쿼리를 작성할 때에는 QType을 이용해서 쿼리를 Type-safe하게 작성합니다.
사이드 바에 있는 Gradle을 클릭하면 아래처럼 윈도우가 열립니다.
Tasks-> other -> complieQuerydsl 을 실행합니다.
Q클래스가 생성된 것을 확인 할 수 있습니다.
Q타입 생성
Configuration 설정하기
@Configuration
public class QuerydslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
JPAQueryFactory를 Bean으로 등록해서 프로젝트 전역에서 QueryDSL을 작성할 수 있습니다.
배포를 하고 유저 피드백을 받고 있습니다. 좋은 칭찬도 많고 도움이 되는 피드백도 많이 오고 있습니다. 시간을 내어 피드백을 써주시는 분들께 감사하지만 한 피드백을 보고 삐딱한(?) 맘이 들었습니다. 아 삐딱한 이라기보다는 "개발자 빨리 할걸...."이라는 맘입니다.
모든 건 개발자 맘대로
회원 가입시, abc@def.ghi로 가입이 됩니다
위와 같은 피드백이 왔을 때 든 맘은
"잉?"
이었습니다. ghi 라는 도메인이 없다고 어떻게 장담해서 이걸 정규식을 도입을 안 했다고 말씀하시는 거지? 세상에 이메일 주소들이 무한대로 많은지언데!
그래도 피드백이 들어왔으니 머리를 맡대고 조원들이랑 고민을 해봤는데 저희는 이미 프론트에서 정규식으로 한번, 백에서도 정규식 메서드로 한번 총 두번을 검증하고 있습니다. 골뱅이가 들어가고 알파벳과 숫자로만 이루어지는 이메일인지 확인을 하는 정규식 입니다. 백엔드에서는 아래와 같은 코드를 사용해서 유효성검사를 합니다.
public class Validator {
public static boolean isValidEmail(String email){
final String REGEX = "^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$";
return Pattern.matches(REGEX, email);
}
그래서 정규식을 도입하지 않았다고 하는 피드백은 오해이고 이건 어쩔수 없는 문제라고 결론을 내리려다가 든 생각은,
"아, 이래서 이메일 인증을 하는구나!"
였습니다.
이메일 인증이 사용자가 입력한 이메일 값이 정확한 건지 거짓으로 입력한 건지를 알 수 있는 유일한 수단이었던 것입니다.
아아, 안탑깝게도 저희의 프로젝트는 이제 1주일 밖에 남지 않았고 부트캠프도 2주 밖에 남지 않은 상태입니다. 당장 도입을 한다고 해도 물리적으로 시간이 가능할지 (백은 가능하지만 프론트는 계속해서 들어오는 피드백과 에러를 고치기에도 시간이 모자란 상태입니다.) 의문이 들었습니다. 그래서 제가 제안한 것은, 프로젝트 기한이 끝나고 부트캠프가 끝나도 계속해서 만나서 프로젝트의 완성도를 높혀나가자 였습니다. 고맙게도 열정과 욕심히 그득그득한 조원들 대부분이 찬성해주었습니다. 다들 6주라는 시간으로 인한 서비스의 완성도에 아쉬움을 가지고 있었거든요. 부트캠프 끝나고도 계속 할일이 생겼네요.
오늘 포스팅의 제목은 '모든 건 개발자 마음대로'인데요, 정말 개발자의 경력과 능력치에 따라서 서비스가 얼마나 완성도가 있어지는지를 새삼 더욱 더 많이~ 깨닫고 있는 요즘입니다. 이메일 인증같은 다소 사소해보이는 이 기능도 개발자가 생각해냈을테니까요. 저는 경영학과 출신이라 기획만 하고 아이디어를 생각만 하고 펀드만 끌어오지 구체적인 구현을 할 줄 몰라서 답답해 했었는데 이제는 '타이탄의 도구' 하나를 획득했습니다. 전세계 갑부들을 보면 일론 머스크, 마크 주커버그 등 대부분 본인이 프로그래밍을 직접할 줄 알아서 아이디어 구현까지 할 수 있었던 엔지니어들이 대부분입니다. 물론 워런버핏도 있지만, 워런버핏이 전세계 사람들의 삶에 영향을 끼칠만한 지대한 기여는 하지 않았으니까요.
시간이 갈수록 엔지니어를 왜 진작하지 않았을까 하는 아쉬움이 밀려들지만, 앞으로 90년은 더 살아야하는 21세기 인간이기에 지금이라도 열심히 하려고 합니다.
SSE(Server-Sent-Event)를 사용해서 알림기능을 만들기로 결정을 하고 약 2주동안 고생하면서 결국에는 성공(?)을 시켰습니다. 완전하다고는 할 수 없지만 이제는 왠만하면 SSE를 사용하지는 않고 다른 옵션을 시도해볼 것 같습니다. 로컬에서는 잘 돌아갔는데 프론트랑 연결하면서 팡팡팡팡팡! 에러가 하루가 멀다하고 터졌거든요. 산넘어 산이었습니다. 이번 포스팅에서는 다른 블로그들은 언급하지 않고 넘어가는 SSE 알림 기능 관련 에러 총정리를 하려고 합니다. 저처럼 헤매지 마시길.
위 처럼 다양한 에러를 만나고 결국엔 해결했습니다. TIL에서 언급하지 않은 부분과 그래서 결국 최종 코드가 무엇인지에 대해 알려드리도록 하겠습니다. 하나 걸러 하나가 나오는 에러라 다 기록하지는 못했지만 앞으로는 더 꼼꼼히 기록해야겠다는 다짐을 하면서 우선 SSE 설정을 위한 헤더에 대해 알아봅시다.
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationListener {
private final NotificationService notificationService;
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Async
public void handleNotification(RequestNotificationDto requestNotificationDto){
notificationService.send(requestNotificationDto.getReceiver(), requestNotificationDto.getSender(),requestNotificationDto.getNotificationType(),
requestNotificationDto.getContent(), requestNotificationDto.getType(), requestNotificationDto.getTypeId(), requestNotificationDto.getPostId());
log.info("EventListener has been operated. Sender Id: " + requestNotificationDto.getSender().getId() + " NotificationType: " +requestNotificationDto.getNotificationType());
}
}
예시로는 대댓글을 작성하면 가는 알림입니다.
@Transactional
public void createComment(Long postId,Long parentId, CommentDto commentDto, String nickname) {
Post post = postRepository.findById(postId).orElseThrow(
() -> new NotFoundException(Domain.COMMENT, Layer.SERVICE, POST_NOT_FOUND, "Post ID : " + postId)
);
Comment parentComment = null;
if (parentId != null) {
parentComment = commentRepository.findById(parentId).orElseThrow(
() -> new NotFoundException(Domain.COMMENT, Layer.SERVICE, COMMENT_NOT_FOUND, "Parent Comment ID : " + parentId)
);
}
Member member = memberRepository.findByNickname(nickname)
.orElseThrow(() -> new NotFoundException(COMMENT, SERVICE, MEMBER_NOT_FOUND, "Nickname : " + nickname));
Comment comment = COMMENT_MAPPER.commentDtoToComment(commentDto, member, post, parentId);
commentRepository.save(comment);
//post 작성자에게 댓글 알림
Member postMember = memberRepository.findByNickname(post.getNickname())
.orElseThrow(() -> new NotFoundException(COMMENT, SERVICE, MEMBER_NOT_FOUND, "Nickname : " + nickname));
if (!postMember.getNickname().equals(member.getNickname())) {
String content = post.getTitle() + "에 " + nickname + "님이 댓글을 남겼습니다.";
notify(postMember, member, NotificationType.COMMENT, content, RedirectionType.detail, postId, null);
}
//댓글 작성자에게 댓글 알림
if (parentComment != null) {
Member commentMember = memberRepository.findByNickname(parentComment.getNickname())
.orElseThrow(() -> new NotFoundException(COMMENT, SERVICE, MEMBER_NOT_FOUND, "Nickname : " + comment.getNickname()));
if (!commentMember.getNickname().equals(member.getNickname())) {
String content = commentMember.getNickname() + "님의 댓글에 " + nickname + "님이 댓글을 남겼습니다.";
notify(commentMember, member, NotificationType.COMMENT, content, RedirectionType.detail, postId, null);
}
}
}
private void notify(Member postMember, Member sender, NotificationType notificationType,
String content, RedirectionType type, Long typeId, Long postId){
eventPublisher.publishEvent(new RequestNotificationDto(postMember,sender, notificationType,content,type, typeId, postId));
}
위처럼 notify 라는 메서드를 만들어서 event를 발행합니다. 해당 event가 발행되면 listener가 듣고 NotificationService의 send 메서드를 실행시켜줍니다. 이때,Eventlistener의 파라미터는 1개이어야 하기 때문에 RequestNotificationDto를 생성해서 하나의 dto에 정보를 담아서 보내주었습니다.
SSE 에러를 해결하면서 느낀 점
인터넷에 나오는 모든 정보가 정확한 것은 아닙니다. 에러가 있어도 언급을 하지 않는 경우가 있고, 내 컴퓨터에서는 안되는 경우가 더러 있습니다. 그리고 동료들이 있어서 함께 에러를 해결해나갈수 있었습니다. github의 다른 개발자들도 같은 고민을 한 적이 있고 각기 다른 방법으로 에러를 해결했다는 사실도 재미있습니다.
에러는 로컬에서 발생하는 에러(error factor : ONLY myself), 프론트와 연결하면서 발생하는 에러(error factor: still ONLY ME)가 있습니다. ngnix 설정을 하면서 또 한번 다수의 에러가 생겼고, 강결합으로 인한 에러도 있었습니다. 이번을 계기로 http 통신에 대해서 좀 더 알게되었습니다.
아직도 미해결 에러가 있습니다. 해협!!!
많은 시간 공들여서 찾아보았지만 아직 해결되지 않은 에러가 한가지 남아있습니다.
바로바로.....
net:: ERR_INCOMPLETE_CHUNKED_ENCODING 200
입니다.
구글링으로 나와있는 방법은 대부분 시도해보았습니다. 혹시 해결방안을 알고 계시는 분들은 댓글달아주세요...!!
어느새 최종프로젝트도 배포일이 2일 남았습니다. MVP 기능은 예전에 끝이 났는데 프론트와 연결하고 배포하면서 일어나는 자잘한 에러들과 디자인 변경으로 인한 새로운 api들 여러개를 구현하다보니 시간이 훌쩍 지나갔습니다. SSE 파트를 맡은 저는 하루 걸러 새로 나오는 버그들에 정신이 없었던거 같습니다. SSE 관련해서 타임라인을 보면 아래와 같습니다. 관련해서 잘 정리해놓고 면접때 꼭 얘기하라는 멘토님의 말씀이 있기도 했습니다.
기존에는 NotificationService를 주입받아 적용했는데 서비스간의 의존성이 생기고 결합도가 높아서 이슈가 생길것이 염려되었습니다. 결국 관련해서 에러가 터지기도 했습니다. 의존성을 제거하기 위해 여러가지 방법을 찾아보았는데 EventListener가 있다는 사실을 알게 되었습니다. EventListener는 말 그대로 이벤트를 리스닝(?)하고 있다가 이벤트가 발생하면 처리하는 로직입니다.
서비스간의 강한 결합, 강한 의존성을 낮출수 있는 방법입니다.
eventlistener 알림
@TransactionalEventListener
@TransactionEventListener를 사용하면, 트랜잭션 흐름에 따라 이벤트를 제어할 수 있습니다.
@TransactionalEventListener는 4가지 옵션이 있습니다.
AFTER_COMMIT(default): 트랜잭션이 성공적으로 마무리(commit)이 되었을 때 이벤트를 실행합니다.
AFTER_ROLLBACK: 트랜잭션이 rollback이 되었을 때 이벤트를 실행합니다.
AFTER_COMPLETION: 트랜잭션이 마무리 되었을 때(commit or rollback) 이벤트를 실행합니다.
BEFORE_COMMIT: 트랜잭션의 커밋 전에 이벤트를 실행합니다.
문제점,
이벤트 전달 시점을 트랜잭션 커밋 시점으로 설정한 경우 트랜잭션이 끝나 DB에 데이터를 저장하는 것이 불가능합니다.
이때 @Transactional(propagation = Propagation.REQUIRES_NEW)으로 새로운 트랜잭션을 생성시켜 데이터를 저장하는 방식으로 문제를 해결합니다.
NotificationListener 구현 (1)
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationListener {
private final NotificationService notificationService;
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Async
public void handleNotification(RequestNotificationDto requestNotificationDto){
notificationService.send(
requestNotificationDto.getReceiver(), requestNotificationDto.getSender(), requestNotificationDto.getNotificationType(),
requestNotificationDto.getContent(), requestNotificationDto.getType(), requestNotificationDto.getTypeId(),
requestNotificationDto.getPostId());
}
}
RequsetDto를 따로 만들어서 Event가 발생하면 받아오게 하였습니다.
BeanInitializationException
위의 핸들러를 실행시키자 다음과 같이 BeanInitializationException이 발생했습니다.
BeanInitializationException
org.springframework.beans.factory.BeanInitializationException: Failed to process @EventListener annotation on bean with name 'notificationService'; nested exception is java.lang.IllegalStateException: Maximum one parameter is allowed for event listener method: public void com.bluehair.hanghaefinalproject.sse.service.NotificationService.send(com.bluehair.hanghaefinalproject.member.entity.Member,com.bluehair.hanghaefinalproject.member.entity.Member,com.bluehair.hanghaefinalproject.sse.entity.NotificationType,java.lang.String,com.bluehair.hanghaefinalproject.sse.entity.RedirectionType,java.lang.Long,java.lang.Long) at org.springframework.context.event.EventListenerMethodProcessor.afterSingletonsInstantiated(EventListenerMethodProcessor.java:157) ~[spring-context-5.3.24.jar:5.3.24] at org.springframework.beans.factory.support.DefaultListableBeanFactory.preInstantiateSingletons(DefaultListableBeanFactory.java:974) ~[spring-beans-5.3.24.jar:5.3.24] at org.springframework.context.support.AbstractApplicationContext.finishBeanFactoryInitialization(AbstractApplicationContext.java:918) ~[spring-context-5.3.24.jar:5.3.24] at org.springframework.context.support.AbstractApplicationContext.refresh(AbstractApplicationContext.java:583) ~[spring-context-5.3.24.jar:5.3.24] at org.springframework.boot.web.servlet.context.ServletWebServerApplicationContext.refresh(ServletWebServerApplicationContext.java:147) ~[spring-boot-2.7.6.jar:2.7.6] at org.springframework.boot.SpringApplication.refresh(SpringApplication.java:731) ~[spring-boot-2.7.6.jar:2.7.6] at org.springframework.boot.SpringApplication.refreshContext(SpringApplication.java:408) ~[spring-boot-2.7.6.jar:2.7.6] at org.springframework.boot.SpringApplication.run(SpringApplication.java:307) ~[spring-boot-2.7.6.jar:2.7.6] at org.springframework.boot.SpringApplication.run(SpringApplication.java:1303) ~[spring-boot-2.7.6.jar:2.7.6] at org.springframework.boot.SpringApplication.run(SpringApplication.java:1292) ~[spring-boot-2.7.6.jar:2.7.6] at com.bluehair.hanghaefinalproject.HanghaeFinalProjectApplication.main(HanghaeFinalProjectApplication.java:13) ~[main/:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[na:na] at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77) ~[na:na] at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) ~[na:na] at java.base/java.lang.reflect.Method.invoke(Method.java:568) ~[na:na] at org.springframework.boot.devtools.restart.RestartLauncher.run(RestartLauncher.java:49) ~[spring-boot-devtools-2.7.6.jar:2.7.6] Caused by: java.lang.IllegalStateException: Maximum one parameter is allowed for event listener method: public void com.bluehair.hanghaefinalproject.sse.service.NotificationService.send(com.bluehair.hanghaefinalproject.member.entity.Member,com.bluehair.hanghaefinalproject.member.entity.Member,com.bluehair.hanghaefinalproject.sse.entity.NotificationType,java.lang.String,com.bluehair.hanghaefinalproject.sse.entity.RedirectionType,java.lang.Long,java.lang.Long) at org.springframework.context.event.ApplicationListenerMethodAdapter.resolveDeclaredEventTypes(ApplicationListenerMethodAdapter.java:127) ~[spring-context-5.3.24.jar:5.3.24] at org.springframework.context.event.ApplicationListenerMethodAdapter.<init>(ApplicationListenerMethodAdapter.java:117) ~[spring-context-5.3.24.jar:5.3.24] at org.springframework.transaction.event.TransactionalApplicationListenerMethodAdapter.<init>(TransactionalApplicationListenerMethodAdapter.java:65) ~[spring-tx-5.3.24.jar:5.3.24] at org.springframework.transaction.event.TransactionalEventListenerFactory.createApplicationListener(TransactionalEventListenerFactory.java:56) ~[spring-tx-5.3.24.jar:5.3.24] at org.springframework.context.event.EventListenerMethodProcessor.processBean(EventListenerMethodProcessor.java:200) ~[spring-context-5.3.24.jar:5.3.24] at org.springframework.context.event.EventListenerMethodProcessor.afterSingletonsInstantiated(EventListenerMethodProcessor.java:154) ~[spring-context-5.3.24.jar:5.3.24] ... 15 common frames omitted
Maximum one parameter is allowed for event listener method
EventListener는 최대 하나의 parameter만을 가진다고 합니다. 보니까 Singleton으로 설정되어 있어서 그렇습니다. 다시 코드를 변경해서 parameter 하나만 보내주는 것으로 notificationService.send를 변경했습니다.
NotificationListener 구현 (2)
아아... 그게 아니었습니다.
@EventListener 어노테이션을 2군데 붙여놔서 그런거였습니다.
HandleNotification 메서드에만 붙어놔야 했는데 NotificationService안에도 붙여놔서 send 메서드에는 파라미터가 여러개여서 문제가 생긴거였습니다. send 메서드 위에 있던 @EventListener 어노테이션을 제거하니 제대로 작동이 되었습니다.
Service 구현
여러가지 서비스가 알림서비스를 이용하고 있었는데 게시글 좋아요 메서드 관련한 코드입니다.
이렇게 고쳤습니다.
우선 ApplicationEventPublisher를 가져옵니다.
private final ApplicationEventPublisher eventPublisher;
@Transactional
public PostLikeDto postLike(Long postId, Member member){
Post postliked = postRepository.findById(postId)
.orElseThrow(()-> new NotFoundException(LIKE, SERVICE, POST_NOT_FOUND, "Post ID : " + postId)
);
PostLikeCompositeKey postLikeCompositeKey
= new PostLikeCompositeKey(member.getId(), postliked.getId());
boolean likecheck;
Optional<PostLike> postLike= postLikeRepository.findByPostLikedIdAndMemberId(postliked.getId(), member.getId());
if(postLike.isPresent()){
postLikeRepository.deleteById(postLikeCompositeKey);
postliked.unLike();
postRepository.save(postliked);
likecheck = false;
return new PostLikeDto(likecheck, postliked.getLikeCount());
}
postLikeRepository.save(new PostLike(postLikeCompositeKey, member, postliked));
likecheck=true;
postliked.like();
postRepository.save(postliked);
Member postMember = memberRepository.findByNickname(postliked.getNickname())
.orElseThrow(() -> new NotFoundException(COMMENT, SERVICE, MEMBER_NOT_FOUND, "Nickname : " + postliked.getNickname()));
if(!postMember.getNickname().equals(member.getNickname())) {
String content = postliked.getTitle() + "을(를) " + member.getNickname() + "님이 좋아합니다.";
notify(postMember, member, NotificationType.POST_LIKED, content, RedirectionType.detail, postId, null);
}
return new PostLikeDto(likecheck, postliked.getLikeCount());
}
notify 메서드를 만들어서 event를 publish 합니다.
private void notify(Member postMember, Member sender, NotificationType notificationType,
String content, RedirectionType type, Long typeId, Long postId){
eventPublisher.publishEvent(new RequestNotificationDto(postMember,sender, notificationType,content,type, typeId, postId));
}
이제 notify가 실행되면서 Eventlistener가 NotificationService의 send 메서드를 실행해줍니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationListener {
private final NotificationService notificationService;
@TransactionalEventListener
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Async
public void handleNotification(RequestNotificationDto requestNotificationDto){
notificationService.send(requestNotificationDto.getReceiver(), requestNotificationDto.getSender(),requestNotificationDto.getNotificationType(),
requestNotificationDto.getContent(), requestNotificationDto.getType(), requestNotificationDto.getTypeId(), requestNotificationDto.getPostId());
log.info("EventListener has been operated. Sender Id: " + requestNotificationDto.getSender().getId() + "NotificationType: " +requestNotificationDto.getNotificationType());
}
}
서치를 하다가 비동기, 동기 개념에 대해 더 깊게 파고들었고 결국 해결했습니다. 허탈하긴하지만 @Async 어노테이션 하나로 해결했습니다. 컨벤션 관련 리팩토링 중이여서 푸시를 못하고 있다가 오후에 시도했는데 @Async 하나 붙였더니 connection leak 이 발생하거나 connection pool이 다 차는 에러가 발생하지 않고 정상적으로 잘 작동이 되었습니다. 알림전송도 잘 됩니다.
@Async
비동기처리를 위해서 @Async 를 사용합니다. 사용하기 위해서는 @Enableasync를 함께 추가해줘야합니다.
@Async // 비동기 처리를 위한 어노테이션
@Transactional
public void send(Member receiver, Member sender, NotificationType notificationType, String content, RedirectionType type, Long typeId, Long postId) {
Notification notification = notificationRepository.save(new Notification(receiver, notificationType, content, type, typeId, postId, sender));
String memberId = String.valueOf(receiver.getId());
Map<String, SseEmitter> sseEmitters = emitterRepository.findAllEmitterStartWithByMemberId(memberId);
sseEmitters.forEach(
(key, emitter) -> {
emitterRepository.saveEventCache(key, notification);
sendToClient(emitter, key, SSE_MAPPER.NotificationtoResponseNotificationDto(notification));
}
);
}
아래와 같이 프로젝트Application에 @EnableAsync를 달아줍니다.
@EnableJpaAuditing
@EnableAsync
@SpringBootApplication
public class HanghaeFinalProjectApplication {
public static void main(String[] args) {
SpringApplication.run(HanghaeFinalProjectApplication.class, args);
}
}
@Async를 사용하기 위해서는 public이어야 하며 self-invocation이면 안됩니다.
우선은 @Async를 사용하면 기본 설정으로 SimpleAsyncTaskExecutor를 사용하게 됩니다. 커스터마이징 하고 싶으면 Async 설정 클래스를 만들어서 설정할 수 있는데 이는 내일 구현할 예정입니다. 일단 급한 불을 끄는데 집중했습니다.
동기(synchronous): 요청과 결과가 동시에 일어난다는 약속; 요청한 자리에서 결과가 주어져야 함
A작업이 모두 진행 될 때까지 B작업은 대기해야함
비동기(Asynchronous): 요청과 결과가 동시에 일어나지 않을거라는 약속;
A작업이 시작하면 동시에 B작업이 실행되며, A작업은 결과값이 나오는 대로 출력된다
SSE 관련 더 공부할 부분
ApplicationEventPublisher를 사용해서 서비스간의 의존성을 낮추는 방법에 대해 알게 되었습니다. EventListner를 사용해서 하는 방법이 있다고 해서 연구해보려고 합니다. 무래도 다른 Service안에 알림 Service를 넣다보니 의존성 문제가 있다고 여겨져서 방법을 찾고 있었기 때문입니다.
각자 파트를 맡아서 개발을 하다보니 컨벤션 준수가 되지 않거나 일관성이 없는 부분들에 대해서 팀장님이 리팩토링을 진행중입니다. DTO 관련해서 링크를 첨부해주셔서 읽어보려고 합니다. Mapper 사용할때 Dto를 참조하는 안티패턴이 발견되었습니다. 여러사람이 같은 프로젝트를 하니까 좋은점은 check-balance가 된다는 점입니다. 내공있는 조원들의 경험을 레버리지 할 수 있어서 좋습니다.