동시성 문제로 추정되는 오류가 발생했다.

게시글 1에서는 좋아요가 잘 반영이 되는데 게시글 2부터 안된다.

 

이럴수가.

 

뭐가 문제냐.

 

여러가지 자료를 찾아보고 있는데

이런걸 바로 버그라고 하나보다.

 

제대로 된 버그는 처음이다.

보통 에러가 뜨는데 말이다...

에러가 안뜨는데 작동이 잘 안되는 건 처음이다.....

 

로그인을 하지 않은 유저가 게시글을 상세 조회 했을 때, 좋아요 여부와 팔로우 여부는 false 값으로 처리가 되는 로직을 구현해야했습니다. 그리고 member 값이 null 로 들어가도 nullpointerException이 발생하지 않게 예외 처리를 해줘야했습니다.

 

게시글과 콜라보 조회 시에 발생하는 문제였습니다. 

 

member 값을 받아와야 해당 유저의 정보를 확인해서 팔로우를 했는지, 좋아요를 했는지를 알 수 있는데 로그인을 하지 않은 유저라면 member 값을 불러 올 수가 없어서 null로 뜨게 되서 NullPointerException이 발생합니다. 아래와 같이 NullPointerException을 처리했습니다.

Controller

@GetMapping("/details/{postid}")
public ResponseEntity<SuccessResponse<Object>> infoPost(@PathVariable Long postid, @AuthenticationPrincipal CustomUserDetails userDetails){
    Member member = null;
    try {
        member = userDetails.getMember();
    }catch (NullPointerException e){
        log.info("비로그인 사용자 접근 : infoPost");
    }
    return SuccessResponse.toResponseEntity(INFO_POST,postService.infoPost(postid, member));
}
  • @Slf4j 을 사용해서 log를 찍었습니다.
  • try-catch문을 사용하여서 NullPointerException 이 발생하면 log를 띄워주었습니다.
  • member 값은 null로 초기화 했습니다.

 

Service

@Transactional
public InfoPostDto infoPost(Long postid, Member member) {
    Post post = postRepository.findById(postid).orElseThrow(
            () -> new NotFoundException(POST, SERVICE,POST_NOT_FOUND)
    );

    post.viewCount();

    postRepository.save(post);

    Boolean isLiked = false;

    if(member!=null){
    Member postMember = memberRepository.findByNickname(post.getNickname()).orElseThrow(
            () -> new NotFoundException(POST, SERVICE, MEMBER_NOT_FOUND)
    );

    PostLikeCompositeKey postLikeCompositeKey
            = new PostLikeCompositeKey(member.getId(), postMember.getId() );
    if (postLikeRepository.findById(postLikeCompositeKey).isPresent()){
        isLiked = true;
    }
    }

    return new InfoPostDto(post, isLiked);
}
  • if문을 사용해서 member가 null이 아닌 경우, 좋아요 여부를 반환합니다.
  • null 인 경우에는 초기값인 false를 반환합니다.

 

 


이번주 토요일에 중간 발표가 있습니다. 여러모로 착착 진행되고 있습니다. 프론트와 소통해서 자잘한 에러를 수정하거나 api를 수정하고 있습니다.

IllegalStateException

IllegalStateException 에러 발생

위의 사진처럼 IllegalStateException이 발생했습니다.

 

IllegalStateException 원인

@RequestMapping의 값이 중복되어 나타나는 에러입니다.

새로운 API를 만들었는데 기존에 있는 url과 겹쳐서 IllegalStateException 이 발생했습니다.

 

IllegalStateException 해결

겹치는 url을 해결해주니 에러가 나타나지 않고 해결되었습니다.

 

https://github.com/ProjectBlueHair/FinalProject_BE/pull/165

 

🐞 BugFix : url이 겹치는 문제 해결 by GGONG1956 · Pull Request #165 · ProjectBlueHair/FinalProject_BE

PR 체크사항 PR이 다음 사항을 만족하는지 확인해주세요. 커밋 제목 규칙 커밋 메시지 작성 가이드라인 라벨, 담당자, 리뷰어 지정 PR 타입 어떤 유형의 PR인지 체크해주세요. Bugfix Feature Code style up

github.com

 

자꾸 바뀝니다.

 

프론트랑 통신하면서 API 명세도 계속 바뀝니다.

웹개발자가 되겠다고한지 벌써 5개월차입니다. 친한 지인이 퇴사하고 부트캠프 할거라고 해서 이미 고연봉자인 지인이 한다니 호기심이 생겼던 게 지난 9월이었습니다. 가족이 새로 하는 사업 때문에 백수였던 제가 도와드리고 있었는데 '웹사이트를 만들어봐라' 하셔서 돈주고할려다가 너무 비싸서 제가 만들기 시작하고 있었던 것도 한 몫 했습니다. 웹개발 툴(아임웹)을 쓰고 있었는데 간단한 html 코드 정도 써보고 있던 수준이었습니다.  부트캠프의 존재도 모르고 있었는데 어떻게 벌써 이렇게 한달밖에 안남은 시점이 되었네요. 

TIL

이제는 CRUD 정도는 척척하게 되었습니다. 처음에는 "왜 다 안알려주고 이렇게 불친절한 부트캠프를 봤나!" 하고 살짝은 불만이었던 부분들이 어느정도 해소가 되었습니다. 하다보면 자연히 필요에 의해 찾아볼 수 밖에 없습니다. 그래도 저는 여전히 제가 하고 있는 항해99를 추천하냐고 묻는다면 공부를 아주 열심히 해본 적 있는 분들만, 어느정도 이과적 공부 머리가 있는 분들에게 추천합니다.  뭐... 12시간동안 앉아서 공부할 의지가 있다는 거 자체가 남들과는 다른 의지와 공부 습관을 가진 사람들이라고 할 수는 있겠다는 생각이 듭니다. 부트캠프 신청자체가 진입장벽이라고 생각하면 괜찮을 수도 있겠네요. 저는 원래 공부만 하던 사람이라 괜찮았습니다. 스스로 찾아서 하는 개발자가 되는게 중요하다고 했는데 저는 원래 하던 일도 그래서 괜찮았습니다. 

 

친절하고 열심히하는 동료들을 만나는 것도 큰 행운으로 작용해서 제가 부트캠프에 열정적으로 임하게 되는 요인중에 하나입니다. 저는 아직까지 항해99의 동료들에게 불만이 있었던 적이 없습니다. 다들 너무 감사하고 제가 작년에 있던 불운들은 이분들을 만나기 위해 행운을 아껴놓았던 거라고 생각할 정도입니다. 최종 프로젝트는 6주동안 함께 하는 사람들인데 다들 열정적이고 저보다 코딩 배운지 오래 되신 분들이 대부분이라 배울점이 너무 많은데 친절하시기까지 하고, 새로 들어온 디자이너님도 열정 있으시고 실력도 뛰어나서 또 내게 이런 행운이 왔구나하는 마음에 감사했습니다. 저 또한 이분들에게 좋은 동료가 되고 싶습니다.

 

남은 한달동안은 최종 프로젝트를 잘 마무리하고 이력서도 쓰고 코딩 테스트 준비도 하고 면접준비도 하면 금방 지나갈 것 같습니다. 지금까지 해왔던 것처럼 앞으로도 꾸준히 무소의 뿔처럼 나아가겠습니다. 

 

 

멘토링 시간이 있었습니다. 

 

에러 처리를 할 때, 어디서 누가 무얼하다가 라는 정보를 넣으면 좋겠다는 조언이 있었습니다.

예를 들어 쇼핑몰에서 회원이 물건을 사다가 오류가 났으면, member Id와 그 상품의 id, 그리고 그 시간을 찍어줍니다.

또 stack trace를 넣어줍니다.

 

에러가 뜨면 쫙 뜨는 그 긴 문장들이 stack trace라는 것을 처음알았습니다. 배울게 많습니다. 

 

What is a Stack Trace? - Definition from Techopedia

This definition explains the meaning of Stack Trace and why it matters.

www.techopedia.com

영어를 할 줄 아는 개발자가 장점이 많을 거라는 생각이 드네요. 에러 메세지가 한글로 뜨면 참 좋을텐데 말이죠.

아 물론 저는 잘합니다😎

 

Stacktrace

stack trace는 프로그램 서브루틴에 대한 정보를 제공하는 보고서입니다. 일반적으로 stack trace을 통해 소프트웨어 엔지니어가 문제가 있는 위치 또는 실행 중에 다양한 서브루틴이 함께 작동하는 방식을 파악하는 데 도움이 되는 특정 종류의 디버깅에 사용됩니다.

 

그리고 저의 일주일 회고를 이야기하는데 이번에 구글링을 하면서 블로그 자료들 중에 정확하지 않은 것도 많고 구린것도 많아서 정보를 보는 눈을 키우고 싶다고 말씀드렸더니 공식문서를 읽으라고 하셨습니다. 부트캠프에서 학생들에게 강제하다보니 기술블로그도 우후죽순 생겨나서 그렇다고 하셨습니다. 앗 근데 저도 괜히 반성하게 되서 기술 관련해서 리뷰할 때는 좀 더 정확하게 써야겠다고 다짐했습니다.

 

좋아요를 복합키 composite key 를 사용해서 구현했습니다. 

 

Controller

@PostMapping("/api/post/like/{postid}")
public ResponseEntity<SuccessResponse<ResponsePostLikeDto>> postLike(@PathVariable Long postid, @AuthenticationPrincipal CustomUserDetails userDetails){

    return SuccessResponse.toResponseEntity(POST_LIKE, postLikeService.postLike(postid, userDetails.getMember()));
}
  • 좋아요하려는 post의 Id와 멤버 정보를 가져옵니다.

 

Service

public ResponsePostLikeDto postLike(Long postId, Member member){
    Post postliked = postRepository.findById(postId)
            .orElseThrow(()-> new NotFoundException(LIKE, SERVICE, POST_NOT_FOUND)
            );
    PostLikeCompositeKey postLikeCompositeKey
            = new PostLikeCompositeKey(member.getId(), postliked.getId());
    boolean likecheck;

    if(postLikeRepository.findById(postLikeCompositeKey).isPresent()){
        postLikeRepository.deleteById(postLikeCompositeKey);
        postliked.disLike();
        postRepository.save(postliked);
        likecheck = false;

        return new ResponsePostLikeDto(likecheck, postliked.getLikeCount());
    }

    postLikeRepository.save(new PostLike(postLikeCompositeKey, member,postliked));
    likecheck=true;
    postliked.likeCount();
    postRepository.save(postliked);

    return new ResponsePostLikeDto(likecheck, postliked.getLikeCount());

}
  • 좋아요한 id 가 있으면 취소하고 좋아요 수를 1 감소시키고, 없으면 좋아요 아이디를 생성하고 좋아요 수를 올려줍니다.
  • Boolean 타입 likecheck을 반환해서 프론트에 보내줍니다. 프론트에서 확인하고 좋아요 아이콘 상태를 변경하는 데 사용합니다.
  • post에 likeCount 를 위한 메서드를 넣어줍니다.
public void likeCount(){
    this.likeCount++;
}

public void disLike(){
    this.likeCount--;
}

 

Composite Key

@Embeddable
@NoArgsConstructor
public class PostLikeCompositeKey implements Serializable {

    @Column(nullable = false)
    private Long memberId;

    @Column(nullable = false)
    private Long postLikedId;

    public PostLikeCompositeKey(Long memberId, Long postLikedId){
        this.memberId = memberId;
        this.postLikedId = postLikedId;
    }
}
  • 복합키는 memberId와 postLikedId를 가집니다. 좋아요한 postId 입니다.

 

PostLike

@Entity
@NoArgsConstructor
@Getter
public class PostLike {

    @EmbeddedId
    private PostLikeCompositeKey id;

    @MapsId("memberId")
    @ManyToOne(fetch = FetchType.LAZY)
    private Member member;

    @MapsId("postLikedId")
    @ManyToOne(fetch = FetchType.LAZY)
    private Post postLiked;

    public PostLike(PostLikeCompositeKey id, Member member, Post postLiked){
        this.id = id;
        this.member = member;
        this.postLiked = postLiked;
    }
}
  • postLike 엔티트를 생성합니다.
  • 복합키 composite key를 사용했기 때문에 @EmbeddeId, @MapsId 어노테이션을 사용합니다.

 

Respository

@Component
public interface PostLikeRepository {
    PostLike save(PostLike postLike);
    @Transactional
    void deleteById(PostLikeCompositeKey postLikeCompositeKey);
    Optional<PostLike> findById(PostLikeCompositeKey postLikeCompositeKey);
}
  • delete 쿼리를 사용하기 때문에 @Transactional를 추가해줍니다. 안하면 에러 뜹니다....(저도 알고싶지 않...)
  • Optional로 findById 메소드를 구현한 것은 찾는 값이 null 일 때 에러를 방지하기 위해서 입니다.

 


좋아요 기능 구현을 위해 복합키를 처음 사용해봤는데 생각보다 어렵지는 않고 단순했습니다. 그냥 안써도 될 것 같다는 느낌이 드는데 공부가 좀 더 필요한 것 같습니다.

 

 

 

 

 

오늘은 알림기능을 위해서 먼저 구현해야하는 좋아요를 복합키를 사용해서 구현해보려고 합니다. 게시글 좋아요, 댓글 좋아요가 필요한데 복합키를 써보는 경험을 다 같이 해보기 위해서 저는 게시글 좋아요 다른 팀원분이 댓글 좋아요를 맡았습니다. 복합키 자체가 자료가 잘 안나오네요. 조장님이 복합키 사용해보라고 했는데 한번 도전해봅니다. 일단 뭔지 알아야겠지요.

 

Composite Key 복합키

복합키는 Composite Key로 2개 이상의 column을 프라이머리 키로 가지고 있습니다. 복합키는 @IdClass와 @EmbeddedId 어노테이션을 사용해서 정의할 수 있습니다. 복합키는 다음과 같은 규칙을 가지고 있습니다.

  1. composite key는 반드시 public 이어야 한다.
  2. 반드시 @NoArgsConstructor 를 가진다.
  3. equals() 와 hasCode() 메서드를 정의해야한다.
  4. 반드시 Serialized 되어야 한다.

 

 

https://www.baeldung.com/jpa-composite-primary-keys

 

SSE 방식으로 실시간 알림을 구현하는 이유는 지난 포스팅에서 확인하실 수 있습니다.

 

TIL 알람 기능 구현 SSE(Server-Sent-Events) 230110

오늘부터 MVP 2차를 개발하기 시작했습니다. 알람 기능 구현이 제가 맡은 부분입니다. 프론트엔드가 2명밖에 없어서 알람까지 할 수 있을지는 모르겠지만 일단은 만들고 생각하기로 했습니다. 프

pizzathedeveloper.tistory.com

 

 

우선 SSE를 연결하기위해서 컨트롤러부터 구현했습니다.

처음에는 Alarm이라는 도메인명을 사용하려다가 알람! 보다는 알림~ 이 좋을 거 같아서 Notification으로 변경했습니다.

 

NotificationController - SSE Subscribe 응답하기

@RestController
@RequiredArgsConstructor
public class NotificationController {

    private final NotificationService notificationService;

    @Tag(name = "SSE")
    @ApiResponses(value = {
            @ApiResponse(responseCode = "2000", description = "SSE 연결 성공"),
            @ApiResponse(responseCode = "5000", description = "SSE 연결 실패")
    })
    @Operation(summary = "SSE 연결")
    @GetMapping(value="/api/subscribe", produces = "text/event-stream")
    public SseEmitter subscribe(@AuthenticationPrincipal CustomUserDetails userDetails, @RequestHeader(value="Last-Event-ID", required = false, defaultValue = "") String lastEventId ){

        return notificationService.subscribe(userDetails.getMember().getId(), lastEventId);
    }
  • @GetMapping 어노테이션에 아래와 같이 입력해줘야 합니다.
produces = "text/event-stream"
  • SSE 통신을 위한 "text/event-stream"이 표준 MediaType입니다.
  • MemberId 값과 "Last-Event-ID"를 받아옵니다.
  • Last-Event-ID는 SSE 연결이 끊어졌을 경우, 클라이언트가 수신한 마지막 데이터의 id 값을 의미합니다.
  • 항상 있는 것이 아니기 때문에 required = false 로 설정했습니다.

 

NotificationService - SSE 연결

@Service
@RequiredArgsConstructor
public class NotificationService {
    private final EmitterRepository emitterRepository = new EmitterRepositoryImpl();
    private final NotificationRepository notificationRepository;

    private static final Long DEFAULT_TIMEOUT = 60L * 1000 * 60;

    public SseEmitter subscribe(Long memberId, String lastEventId) {
        String emitterId = memberId + "_" + System.currentTimeMillis();
        SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(DEFAULT_TIMEOUT));

        emitter.onCompletion(() -> emitterRepository.deleteById(emitterId));
        emitter.onTimeout(() -> emitterRepository.deleteById(emitterId));

        sendToClient(emitter, emitterId, "EventStream Created. [memberId=" + memberId + "]");

        if (!lastEventId.isEmpty()) {
            Map<String, Object> events = emitterRepository.findAllEventCacheStartWithByMemberId(String.valueOf(memberId));
            events.entrySet().stream()
                    .filter(entry -> lastEventId.compareTo(entry.getKey()) < 0)
                    .forEach(entry -> sendToClient(emitter, entry.getKey(), entry.getValue()));
        }

        return emitter;
    }
  • DEFAULT_TIMEOUT을 1시간으로 설정해줬습니다. SSE 연결은 1시간동안 지속됩니다.
  • SseEmitter 클래스는 SpringFramework에서 버전 4.2 부터 제공합니다.
  • emitter는 발신기라는 뜻을 가지고 있습니다. 
  • String emitterId = memberId_System.currentImeMillis(); 로 한 이유는 Last-Event-ID와 관련이 있습니다.
    • Last-Event-ID는 클라이언트가 마지막으로 수신한 데이터의 Id값을 의미합니다. 그러나 Id 값만을 사용한다면 언제 데이터가 보내졌는지, 유실 되었는지 알 수가 없기 때문에 시간을 emitterId에 붙여두면 데이터가 유실된 시점을 알 수 있으므로 저장된 Key값 비교를 통해 유실된 데이터만 재전송 할 수 있습니다.

 

SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(DEFAULT_TIMEOUT));

emitter.onCompletion(() -> emitterRepository.deleteById(emitterId));
emitter.onTimeout(() -> emitterRepository.deleteById(emitterId));
  • SSE 연결을 위해서 SseEmitter 객체를 만들어 반환해야합니다.
    • 유효시간 DEFAULT_TIMEOUT을 넣어줍니다.
    • 시간이 지나면 자동으로 클라이언트에서 재연결 요청을 보냅니다.
    • emitterId도 함께 저장해줍니다.
  • 시간이 초과하거나 비동기요청이 정상동작이 안되면 저장한 SseEmitter를 삭제합니다.

 

sendToClient(emitter, emitterId, "EventStream Created. [memberId=" + memberId + "]");
  • Sse 연결이 이뤄진 후, 데이터가 하나도 전송되지 않았는데 SseEmitter의 유효시간이 끝나면 503 에러가 발생한다고 합니다. 따라서, 최초 연결 시 더미 데이터를 보내줍니다.

 

if (!lastEventId.isEmpty()) {
    Map<String, Object> events = emitterRepository.findAllEventCacheStartWithByMemberId(String.valueOf(memberId));
    events.entrySet().stream()
            .filter(entry -> lastEventId.compareTo(entry.getKey()) < 0)
            .forEach(entry -> sendToClient(emitter, entry.getKey(), entry.getValue()));
}
  • lastEventId값이 있는 경우, 저장된 데이터 캐시에서 유실된 데이터들을 다시 전송합니다.

 

Notification - 객체 생성

@Getter
@Entity
@NoArgsConstructor
public class Notification extends Timestamped {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Embedded
    private NotificationContent content;

    @Embedded
    private RelatedUrl url;

    @Column(nullable = false)
    private Boolean isRead;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private NotificationType notificationType;

    @OnDelete(action = OnDeleteAction.CASCADE)
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "MEMBER_ID")
    private Member receiver;

    @Builder
    public Notification(Member receiver, NotificationType notificationType, String content, String url) {
        this.receiver = receiver;
        this.notificationType = notificationType;
        this.content = new NotificationContent(content);
        this.url = new RelatedUrl(url);
        this.isRead = false;
    }

    public String getContent() {
        return content.getContent();
    }

    public String getUrl() {
        return url.getUrl();
    }

    public void read(){
        isRead = true;
    }
}
  • Notification 객체를 생성해줍니다.
  • 필요한 값들 (receiver, notificationType, content, url, isRead) 를 넣어줍니다.
    • @Embedded 는 객체를 엔티티로 사용하는게 아니라 값 타입으로 사용하기 위해 붙여줍니다. 좀 더 객체지향적이고 각 상황에 따라 수정이 용이합니다.
    • isRead 는 조회 여부를 알기 위해 Boolean 타입으로 넣었습니다.
    • notificationType은 서비스도메인 별로 enum으로 구분해서 작성했습니다. 저희 프로젝트에서 보내야 하는 알림 종류는 5가지입니다. (콜라보 요청, 콜라보 승인, 게시글 댓글, 게시글 좋아요, 댓글 좋아요)
    • @OnDelete 에 대해서는 여기 블로그를 참조해주세요. receiver를 삭제하면 연관관계도 함께 삭제됩니다.  

 

NotificationContent

@Getter
@Embeddable
@NoArgsConstructor
public class NotificationContent {
    @Column(nullable = false)
    private String content;

    public NotificationContent(String content){
        this.content = content;
    }
}

 

RelatedUrl

@Getter
@Embeddable
@NoArgsConstructor
public class RelatedUrl {
    @Column(nullable = false)
    private String url;

    public RelatedUrl(String url){
        this.url = url;
    }
}

 

 

Entity를 만들었으니 Repository도 만들어줍니다.

 

NotificationRepository

public interface NotificationRepository extends JpaRepository<Notification, Long> {
    List<Notification> findAllByReceiver(Member member);
}
  • JpaRepository를 상속합니다.
  • findAllByReceiver는 알림 전체 목록을 조회할 때 사용한 메서드 입니다.

 

EmitterRepository

public interface EmitterRepository {
    SseEmitter save(String emitterId, SseEmitter sseEmitter);
    void saveEventCache(String emitterId, Object event);
    
    Map<String, SseEmitter> findAllEmitterStartWithByMemberId(String memberId);
    Map<String,Object> findAllEventCacheStartWithByMemberId(String memberId);
    
    void deleteById(String emitterId);
}
  • findAllEmitterStartWithByMemberId 는 해당 member와 관련된 모든 emitter를 찾습니다.
  • findAllEventCacheStartWithByMemberId 는 해당 member와 관련된 모든 event를 찾습니다.

 

EmitterRepositoryImpl

위의 EmitterRepository를 아래와 같이 구현합니다.

public class EmitterRepositoryImpl implements EmitterRepository {
    private final Map<String, SseEmitter> emitters = new ConcurrentHashMap<>();
    private final Map<String, Object> eventCache = new ConcurrentHashMap<>();

    @Override
    public SseEmitter save(String emitterId, SseEmitter sseEmitter) {
        emitters.put(emitterId,sseEmitter);
        return sseEmitter;
    }

    @Override
    public void saveEventCache(String emitterId, Object event) {
        eventCache.put(emitterId,event);
    }

    @Override
    public Map<String, SseEmitter> findAllEmitterStartWithByMemberId(String memberId) {
        return emitters.entrySet().stream()
                .filter(entry -> entry.getKey().startsWith(memberId))
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }

    @Override
    public Map<String, Object> findAllEventCacheStartWithByMemberId(String memberId) {
        return eventCache.entrySet().stream()
                .filter(entry -> entry.getKey().startsWith(memberId))
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    }

    @Override
    public void deleteById(String emitterId) {
        emitters.remove(emitterId);

    }

    @Override
    public void deleteAllEmitterStartWithId(String memberId) {
        emitters.forEach(
                (key,emitter) -> {
                    if(key.startsWith(memberId)){
                        emitters.remove(key);
                    }
                }
        );
    }

    @Override
    public void deleteAllEventCacheStartWithId(String memberId) {
        eventCache.forEach(
                (key,emitter) -> {
                    if(key.startsWith(memberId)){
                        eventCache.remove(key);
                    }
                }
        );

    }
}
  • 동시성을 고려하여 ConcurrentHashmap을 사용합니다.
    • HashMap은 멀티스레드 환경에서 동시에 수정을 시도하는 경우 예상하지 못한 결과가 발생할 수 있습니다. 멀티스레드 환경하에서 HashMap을 안전하게 사용하기위해 java에서는 concurrent 패키지를 제공합니다. ConcurrentHashmap을 사용하면 thread-safe가 보장됩니다. ConcurrentHashmap 관련 설명은 여기 블로그를 참조하세요.
    • 그냥 사용한다면 ConcurrentModificationException이 발생할 수 있습니다. 

 

Dto와 MapStruct

추가적으로 저희 프로젝트 컨벤션이기 때문에 MapStruct도 작성해주었습니다. 

@Mapper
public interface SseMapStruct {
    SseMapStruct SSE_MAPPER = Mappers.getMapper(SseMapStruct.class);

    ResponseNotificationDto NotificationtoResponseNotificationDto(Notification notification);

}

ResponseDto도 작성해주었습니다.

@Schema(description = "알림 Dto")
@Getter
@Setter
public class ResponseNotificationDto {
    private Long id;
    private String content;
    private String url;
    private Boolean isRead;
    private String createdAt;

    @Builder
    public ResponseNotificationDto(Notification notification) {
        this.id = notification.getId();
        this.content = notification.getContent();
        this.url = notification.getUrl();
        this.isRead = notification.getIsRead();
        this.createdAt = LocalDateTimeConverter.timeToString(notification.getCreatedAt());
    }

}
  • 프로젝트에서 사용하는 LocalDateTimeConverter를 사용해서 알림 발송시간을 한글로 바꿔줍니다😎

 

 

이제 클라이언트에 알림을 보낼 준비가 끝났습니다.

 

 

NotificationService - 클라이언트에 데이터 전송하기

public void send(Member receiver, NotificationType notificationType, String content, String url) {
    Notification notification = notificationRepository.save(createNotification(receiver, notificationType, content, url));
    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));
            }
    );
}

private void sendToClient(SseEmitter emitter, String emitterId, Object data) {
    try {
        emitter.send(SseEmitter.event()
                .id(emitterId)
                .data(data));
    } catch (IOException exception) {
        emitterRepository.deleteById(emitterId);
        throw new InvalidRequestException(SSE, SERVICE, UNHANDLED_SERVER_ERROR);
    }
}

 

Notification을 전송하기 위한 메서드 입니다.

public void send(Member receiver, NotificationType notificationType, String content, String url) {
    Notification notification = notificationRepository.save(createNotification(receiver, notificationType, content, url));
    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));
            }
    );
}
  • Notification 객체를 만들고 해당 Member의 emitter를 다 불러옵니다.(여러 브라우저에서 접속할 수 있기 때문에 emitter가 여러개 일 수 있습니다.)
  • 해당 데이터를 EventCache에 저장합니다.
  • sendToClient 메서드를 통해 클라이언트에 전송합니다.
    • MapStruct를 사용해서 Dto로 변환한 값을 보내줬습니다.

 

private void sendToClient(SseEmitter emitter, String emitterId, Object data) {
    try {
        emitter.send(SseEmitter.event()
                .id(emitterId)
                .data(data));
    } catch (IOException exception) {
        emitterRepository.deleteById(emitterId);
        throw new InvalidRequestException(SSE, SERVICE, UNHANDLED_SERVER_ERROR);
    }
}
  • emitter, emitterId와 함께 알림 내용을 클라이언트에 보냅니다.
  • 전송이 안된 경우 IOException을 터트려줍니다. IOException은 스트림, 파일 및 디렉터리를 사용해 정보에 엑세스하는 동안 throw된 예외에 대한 기본 클래스입니다. 

 

 

알림 만들기 🛎

이제 위에서 만든 알림 메서드를 호출해서 콜라보요청이 승인 되면 콜라보 요청 작성자에게 알림을 보내는 기능을 추가해보겠습니다.

@Transactional
public void approveCollaboRequest(Long collaborequestid, Member member) {
    /.../

    //요청한 사람한테 승인 완료 알림 - 게시글 상세 조회로 이동
    Long postId = post.getId();
    Member collaboMember = memberRepository.findByNickname(collaboRequest.getNickname())
            .orElseThrow(() -> new NotFoundException(COLLABO_REQUEST, SERVICE, MEMBER_NOT_FOUND));
    String url = "/api/post/"+postId;
    String content = post.getTitle()+"에 대한 콜라보 요청이 승인되었습니다.";
    notificationService.send(collaboMember, NotificationType.COLLABO_APPROVED, content, url);
}
  • 콜라보 요청을 한 member가 receiver이고, 해당 Post의 상세조회 페이지의 Url을 넣었습니다. NotificationType으로는 미리 enum으로 저장해 놓은 COLLABO_APPROVED(콜라보 승인)을 넣어줬습니다.

 

알림 결과는 아래와 같이 Postman 으로 확인할 수 있습니다.

클라이언트 사이드는 구현은 아직 안해서 알림 전체 조회하는 api를 만들어서 조회했습니다. 

 

 

 

 

참조 문서

여러 블로그를 참조해서 작성했습니다. 주요 참고한 블로그는 아래와 같습니다.

 

 

[Spring + SSE] Server-Sent Events를 이용한 실시간 알림

코드리뷰 매칭 플랫폼 개발 중 알림 기능이 필요했다. 리뷰어 입장에서는 새로운 리뷰 요청이 생겼을 때 모든 리뷰가 끝나고 리뷰이의 피드백이 도착했을 때 리뷰이 입장에서는 리뷰 요청이 거

velog.io

 

알림 기능을 구현해보자 - SSE(Server-Sent-Events)!

시작하기에 앞서 이번에 개발을 진행하면서 알림에 대한 요구사항을 만족시켜야하는 상황이 발생했다. 여기서 말하는 알림이 무엇인지 자세하게 살펴보자. A라는 사람이 스터디를 생성했고 B라

gilssang97.tistory.com

 

Node긴 하지만 개념 정리가 잘 되있어서 아래 블로그도 공유합니다.

 

[NODE] 📚 Server Sent Events 💯 정리 (+사용법)

SSE - Server Sent Events 란? SSE는 서버의 데이터를 실시간으로, 지속적으로 Streaming 하는 기술 이다. SSE는 웹 표준으로써 IE를 제외한 모든 브라우저에서 지원되며, IE역시 polyfill을 통해 지원이 가능하

inpa.tistory.com

 

 

 

오늘부터 MVP 2차를 개발하기 시작했습니다. 알람 기능 구현이 제가 맡은 부분입니다. 프론트엔드가 2명밖에 없어서 알람까지 할 수 있을지는 모르겠지만 일단은 만들고 생각하기로 했습니다. 프론트엔드분들도 우는소리 하시면서 척척 해내시더라고요. 저희 프론트엔드분들도 그렇고 백엔드분들도 능력자들만 있어서 참 팀원운이 좋다고 생각합니다.

 

구현해야하는 기능

1. 승인요청알람

2. 승인완료알람

3. 게시글 좋아요 알람

4. 댓글 좋아요 알람

5. 댓글 등록 알람

 

처음에는 웹소켓으로 해야하나라고 생각했는데 서버에서 클라이언트에 알람을 보내는 단방향 형식이기 때문에 SEE라는 것을 사용하기로 했습니다. 웹소켓(WebSocket)은 클라이언트와 서버 간의 효율적인 양방향 통신을 실현하기 위한 구조입니다. 채팅과 같이 클러이언트와 서버가 양방향 통신이 필요하면 웹소켓을 쓰겠지만 알람은 서버만 클라이언트에게 정보를 보내는 구조입니다. 

 

추후에 DM을 구현하게 된다면 웹소켓을 사용할지 다시 검토해봐야 겠습니다.

 

 

SSE(Server-Sent-Events)

전통적으로 웹에서는 새 데이터를 수신하기 위해 서버에 요청을 보내야 합니다. 클라이언트가 서버에 데이터를 요청합니다. SSE를 사용하면 서버가 클라이언트에 메시지를 푸시하여 언제든지 새 데이터를 클라이언트로 보낼 수 있습니다. 이러한 수신 메시지는 클라이언트의 이벤트 + 데이터로 처리될 수 있습니다. 

 

Server-sent events - Web APIs | MDN

Traditionally, a web page has to send a request to the server to receive new data; that is, the page requests data from the server. With server-sent events, it's possible for a server to send new data to a web page at any time, by pushing messages to the w

developer.mozilla.org

 

SSE는 웹소켓과 비슷하지만 단방향 통신입니다. 클라이언트는 서버로 데이터를 보낼 수 없습니다. 웹소켓과 달리 별도의 프로토콜을 사용하지 않아서 훨씬 가볍다고 합니다.  HTTP 스트리밍을 통해 서버에서 클라이언트로 단방향의 Push Notification을 전송할 수 있는 HTML5 표준 기술입니다.  

SSE는 서버와 한번 연결을 맺고나면 일정 시간동안 서버에서 변경이 발생할 때마다 데이터를 전송합니다.

 

위의 mozilla.org 사이트에서 보여준 예시는 자바스크립트인데, 자바스크립트에서는 EventSource를 이용해서 구현할 수 있습니다. Spring Framework 4.2 부터 SseEmitter 클래스를 제공해 SSE 통신을 구현할 수 있습니다.

 

1. 클라이언트가 서버의 이벤트를 subscribe하기 위한 요청을 보낸다.

2. 서버가 이벤트를 전송한다.

 

주의할점(다른 블로그에서 퍼옴)

1. 첫 SSE 응답 보낼 때, 더미 데이터를 넣어야 503 에러가 발생하지 않음

2. JPA 사용시 open-in-view 설정을 false로 하기

3. Ngnix 사용시 1.1 version으로 설정 

 

 

참고 블로그

 

Spring Boot, SSE(Server-Sent Events)로 단방향 스트리밍 통신 구현하기

개요 Server-Sent Events(이하 SSE)는 HTTP 스트리밍을 통해 서버에서 클라이언트로 단방향의 Push Notification을 전송할 수 있는 HTML5 표준 기술이다. 이번 글에서는 Spring Boot에서 SSE를 이용한 단방향 스트

jsonobject.tistory.com

 

 

 

알림 기능을 구현해보자 - SSE(Server-Sent-Events)!

시작하기에 앞서 이번에 개발을 진행하면서 알림에 대한 요구사항을 만족시켜야하는 상황이 발생했다. 여기서 말하는 알림이 무엇인지 자세하게 살펴보자. A라는 사람이 스터디를 생성했고 B라

gilssang97.tistory.com

 

 

[Spring + SSE] Server-Sent Events를 이용한 실시간 알림

코드리뷰 매칭 플랫폼 개발 중 알림 기능이 필요했다. 리뷰어 입장에서는 새로운 리뷰 요청이 생겼을 때 모든 리뷰가 끝나고 리뷰이의 피드백이 도착했을 때 리뷰이 입장에서는 리뷰 요청이 거

velog.io

 


 

아침에는 오랜만에 헬스를 다녀왔습니다. 부트캠프 시작하고 몸무게는 변동은 없지만 체성분이 변했습니다. 스쿼트를 40kg 10x3 set 밖에 못해서 놀랐습니다. 근육 놀랄까봐 무게를 더 치진 않았습니다. 2월 17일에 부트캠프가 끝나는데 다시 스쿼트랑 데드 70 들던 때로 돌아가겠습니다.  개발자지망생이 되니 다시 거북목이 심해져서 내일은 등이랑 어깨를 뿌실겁니다. 그리고 알람은.... 내일까지 해보자!!!

 

 

+ Recent posts