QueryDSL의 최신 버전(2021.07.22 release)인 5.0 버전을 사용했습니다. 블로그 자료들에는 outdated된 것들이 많아서 최대한 공식 문서와 github을 참조하려고 했습니다. QueryDSL의 github에는 데이터 타입 별 튜토리얼도 있습니다. 저도 언젠가는 이런 오픈소스에 기여할 수 있는 개발자가 되고 싶습니다. 제가 개발자라는 직업에 매력을 느끼는 이유기도 합니다. 

 

QueryDSL 도입 이유

현재 JPQL을 사용하고 있는데 3가지 문제점을 발견하였습니다.

  1. 문자열(String)로 처리가 되다 보니 띄어쓰기 하나에도 오류가 날 수 있음
  2. Compile 단계에서 오류 체크가 불가능함
  3. Runtime 단계에서 오류를 발견할 수 있어 비효율적임

 

포트폴리오적으로도, 새로운 기술을 써본다는 측면으로도 QueryDSL를 사용해보고 싶었지만 위에 말한 3가지 문제를 해결할 수 있어서 QueryDSL를 도입하기로 결정했습니다. QueryDSL은 아래와 같은 장점을 가지고 있습니다.

  1. QueryDSL은 모든 쿼리에 대한 내용이 함수 형태로 제공 됨
  2. 위의 이유 덕분에, complie 단계에서 오류 체크(Type-check)가 가능함
  3. 커스터마이징하기 쉽고 유연한 코드를 작성할 수 있음

QueryDSL의 단점은 코드 라인 수가 길어진다는 것이 있지만, 함수 형태이기 때문에 가독성이 좋고 IDE의 도움(코드 자동완성)을 받을 수 있다는 게 장점입니다. 2번 이유에 대해서 멘토님께 발표할 때 human error라고 했는데 무슨 말인지 못알아들으셔서 "오타요!" 라고 외치고 말았습니다.

build.gradle 설정하기

buildscript {
    ext {
        queryDslVersion = "5.0.0"
    }
}
plugins {
 	...
 	id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
 	...
 }
 ...
 dependencies {
 	// querydsl 추가
 	implementation "com.querydsl:querydsl-jpa:5.0.0"
 	implementation "com.querydsl:querydsl-apt:5.0.0"
     ...
 }
 // Qtype 생성 경로
 def querydslDir = "$buildDir/generated/querydsl"
 querydsl {
 	jpa = true
 	querydslSourcesDir = querydslDir
 }
 sourceSets {
 	main.java.srcDir querydslDir
 }
 compileQuerydsl{
 	options.annotationProcessorPath = configurations.querydsl
 }
 configurations {
 	compileOnly {
 		extendsFrom annotationProcessor
 	}
 	querydsl.extendsFrom compileClasspath
 }

최신 버전인 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을 작성할 수 있습니다.

 

 

 참조문서

 

 

GitHub - querydsl/querydsl: Unified Queries for Java

Unified Queries for Java. Contribute to querydsl/querydsl development by creating an account on GitHub.

github.com

 

Querydsl - Unified Queries for Java

5.0 5.0.0 (22.7.2021) This release of QueryDSL targets Java 8 minimally and comes with various improvements to make QueryDSL ready for the modern Java ecosystem. This version also removes joda-time:joda-time, com.google.guava:guava and com.google.code.find

querydsl.com

 

 

 

Spring Boot에 QueryDSL을 사용해보자

1. QueryDSL PostRepository.java Spring Data JPA가 기본적으로 제공해주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도, 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL…

tecoble.techcourse.co.kr

 

주니어 개발자의 QueryDSL 찔러보기

안녕하세요. Fulfillment Engineering 팀의 입사한 지 1년이 얼마 지나지 않은 싱싱한(?) 주니어 개발자 백정현입니다. 최근 들어 JAVA를 기반으로 한 Spring boot + JPA 또는 Spring Data JPA를 이용한 프로젝트가

dev.gmarket.com

일요일에 오랜만에 노트북을 들지 않고 외출을 했습니다. 몸은 가벼운데 마음은 무거워져서 내일은 더 열심히 해야지 다짐해봅니다.

 

 

 

TIL 왜 또 안되니 SSE 230130

에러가 마구마구 터지는 중. 해결이 안되는데 일단 새벽 3시가 넘어서 잠.

pizzathedeveloper.tistory.com

 

TIL AsyncConfigurerSupport 으로 비동기 설정 230131

오늘은 계획된 배포일이지만 아무도 오늘 가능할 거라고 생각하지 못합니다. 일단은 @Async 의 Configuration 설정을 통해서 서버가 터지는 현상은 막았습니다. AsyncConfigurerSupport @Configuration @EnableAsync

pizzathedeveloper.tistory.com

 

TIL SSE 알림 기능 만들기 총정리 230201

SSE(Server-Sent-Event)를 사용해서 알림기능을 만들기로 결정을 하고 약 2주동안 고생하면서 결국에는 성공(?)을 시켰습니다. 완전하다고는 할 수 없지만 이제는 왠만하면 SSE를 사용하지는 않고 다른

pizzathedeveloper.tistory.com

 

 

TIL 배포하다 230202

드디어 배포를 했습니다. 🎙안녕하세요! 우연처럼, 운명처럼 하모니를 만들어가고 있는 oncounter team 입니다🎵 https://oncounter.co.kr 👆👆oncounter 방문하기👆👆 📌oncounter GUIDE 🎼Oncounter 서비스

pizzathedeveloper.tistory.com

 

TIL 모든 건 개발자 맘대로 230203

배포를 하고 유저 피드백을 받고 있습니다. 좋은 칭찬도 많고 도움이 되는 피드백도 많이 오고 있습니다. 시간을 내어 피드백을 써주시는 분들께 감사하지만 한 피드백을 보고 삐딱한(?) 맘이 들

pizzathedeveloper.tistory.com

 

'TIL' 카테고리의 다른 글

TIL JPQL을 QueryDSL로 변경하기 Repository 들 230207  (0) 2023.02.08
TIL QueryDSL 5.0.0 적용하기 230206  (0) 2023.02.07
TIL 멘토링 230204  (0) 2023.02.06
TIL 모든 건 개발자 맘대로 230203  (1) 2023.02.04
TIL 배포하다 230202  (0) 2023.02.03

오늘은 멘토님의 멘토링이 있는 날입니다. 

그동안 사용한 기술스택을 정리하고 회고하는 시간이었습니다.

'TIL' 카테고리의 다른 글

TIL QueryDSL 5.0.0 적용하기 230206  (0) 2023.02.07
TIL WIL 일주일 남음 230205  (0) 2023.02.06
TIL 모든 건 개발자 맘대로 230203  (1) 2023.02.04
TIL 배포하다 230202  (0) 2023.02.03
TIL SSE 알림 기능 만들기 총정리 230201  (0) 2023.02.02

배포를 하고 유저 피드백을 받고 있습니다. 좋은 칭찬도 많고 도움이 되는 피드백도 많이 오고 있습니다. 시간을 내어 피드백을 써주시는 분들께 감사하지만 한 피드백을 보고 삐딱한(?) 맘이 들었습니다. 아 삐딱한 이라기보다는 "개발자 빨리 할걸...."이라는 맘입니다. 

모든 건 개발자 맘대로

 

회원 가입시, 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세기 인간이기에 지금이라도 열심히 하려고 합니다. 

 

 

 

'TIL' 카테고리의 다른 글

TIL WIL 일주일 남음 230205  (0) 2023.02.06
TIL 멘토링 230204  (0) 2023.02.06
TIL 배포하다 230202  (0) 2023.02.03
TIL SSE 알림 기능 만들기 총정리 230201  (0) 2023.02.02
TIL AsyncConfigurerSupport 으로 비동기 설정 230131  (0) 2023.02.01

드디어 배포를 했습니다.

 

🎙안녕하세요!
우연처럼, 운명처럼 하모니를 만들어가고 있는 oncounter team 입니다🎵
 
👆👆oncounter 방문하기👆👆
 
 
 
 
🎼Oncounter 서비스 소개
  • 여러분의 창작물을 업로드하세요! 내가 만든 음악을 멋진 커버사진과 함께 업로드하고 다른 아티스트들에게 콜라보를 요청할 수 있습니다.
  • 다른 뮤지션들 업로드한 음악에 내가 만든 음악을 콜라보 요청을 할 수 있습니다. 콜라보가 승인이 된다면 함께 플레이 할 수 있습니다!
  • 마이페이지에서 내 자신을 알리세요! 나를 소개하고 자신의 SNS링크를 공유할 수 있을 뿐만 아니라, 내가 올린 음악과 보관한 음악을 조회할 수 있습니다. 마이페이지 편집 기능을 통해서 자신의 정보를 수정할 수 있습니다.
  • 팔로우를 통해 내가 좋아하는 뮤지션의 최신 업데이트를 받아볼 수 있습니다!
  • DM 기능을 통한 커뮤니티 형성! 내가 콜라보하고 싶은 뮤지션들에게 DM을 보내보세요!
 

🎉 설문 참여 이벤트 🎉

여러분의 피드백은 oncounter를 더욱 좋은 서비스로 발전시킵니다!
oncounter를 이용하시고 좋았던 점 또는 아쉬웠던 점에 대해서 피드백을 부탁드리겠습니다🙇‍♂️🙇‍♂️🙇‍♂️🙇‍♀️🙇‍♂️
oncounter의 발전에 큰 도움이 되는 설문조사에 참여해주신 분들께는 다음과 같이 선물🎁을 드립니다.

이벤트 참여 기간: 02.02(목) ~ 02.08(수) 자정까지

이벤트 당첨 확인 : 02월 08일 (수) 공식 인스타그램 @oncounter.official

이벤트 당첨 선물🎁 :

  🥇1등 (1명): World DJ Festival 월드디제이페스티벌 티켓🎧🎤

  🥈2등 (5명) : 스타벅스 아메리카노 기프티콘

이벤트 결과는 oncounter 공식인스타그램에 공지되며, 당첨자는 개별 연락드릴 예정입니다.

입력하신 개인정보는 상품 전달을 위해서만 사용됩니다.

 

 

 


피드백이 들어왔는데 

데이터 최적화는 어떻게 하는 걸까요?

 

ㅠㅠ

 

 

SSE(Server-Sent-Event)를 사용해서 알림기능을 만들기로 결정을 하고 약 2주동안 고생하면서 결국에는 성공(?)을 시켰습니다. 완전하다고는 할 수 없지만 이제는 왠만하면 SSE를 사용하지는 않고 다른 옵션을 시도해볼 것 같습니다. 로컬에서는 잘 돌아갔는데 프론트랑 연결하면서 팡팡팡팡팡! 에러가 하루가 멀다하고 터졌거든요. 산넘어 산이었습니다. 이번 포스팅에서는 다른 블로그들은 언급하지 않고 넘어가는 SSE 알림 기능 관련 에러 총정리를 하려고 합니다. 저처럼 헤매지 마시길.

 

SSE 알림 기능 관련 주요 TIL

230110 알림 기능 구현 뭘로 할까?

230111 SSE로 알림기능 구현하기

230120 SSE 프론트와 연결하기 (feat. 비관적락)

230124 SSE 에러: DB connection leak, open-in-view 설정

230125 SSE 또 connection leak triggered

230126 @Async 비동기처리

230126 읽지 않은 알림 갯수 반환하기

230127 @EventListener 알림 기능 강결합 제거

230131 AsyncConfigurerSupport로 비동기 설정하기

 

 

 

위 처럼 다양한 에러를 만나고 결국엔 해결했습니다. TIL에서 언급하지 않은 부분과 그래서 결국 최종 코드가 무엇인지에 대해 알려드리도록 하겠습니다. 하나 걸러 하나가 나오는 에러라 다 기록하지는 못했지만 앞으로는 더 꼼꼼히 기록해야겠다는 다짐을 하면서 우선 SSE 설정을 위한 헤더에 대해 알아봅시다.

 

NotificationController

@Tag(name = "SSE")
@ApiResponses(value = {
        @ApiResponse(responseCode = "2000", description = "SSE 연결 성공"),
        @ApiResponse(responseCode = "5000", description = "SSE 연결 실패")
})
@Operation(summary = "SSE 연결")
@GetMapping(value="/api/subscribe/{nickname}", produces = "text/event-stream")
public SseEmitter subscribe(
        @PathVariable String nickname,
        @RequestHeader(value="Last-Event-ID", required = false, defaultValue = "") String lastEventId,
        HttpServletResponse response){
    response.addHeader("X-Accel-Buffering", "no");
    response.addHeader("Content-Type", "text/event-stream");
    response.setHeader("Connection", "keep-alive");
    response.setHeader("Cache-Control", "no-cache");

    String encodedNickname = URLDecoder.decode(nickname, StandardCharsets.UTF_8);
    return notificationService.subscribe(lastEventId, encodedNickname);
}

Ngnix를 적용하고 에러가 나서,

 

response.addHeader("X-Accel-Buffering", "no");

를 적용해 주었고

 

아래와 같이 에러가 나서

EventSource's response has a Content-type specifying an unsupported type: application/json. Aborting the connection

EventSource's response has a Content-type specifying an unsupported type: application/json. Aborting the connection

response.addHeader("Content-Type", "text/event-stream");

 

를 적용해주었습니다.

 

ngnix 세팅 때문에 토큰을 헤더에 담아오지 못하게 되어서 nickname으로 파라미터를 변경해주었는데, 닉네임에 한글도 있어서 encoding관련해서 아래처럼 설정을 넣어주었습니다.

 

String encodedNickname = URLDecoder.decode(nickname, StandardCharsets.UTF_8);

 

 

NotificationService

    public SseEmitter subscribe(String lastEventId, String nickname) {
        Member member = memberRepository.findByNickname(nickname)
                .orElseThrow(()-> new NotFoundException(SSE, SERVICE, MEMBER_NOT_FOUND, "Nickname : " + nickname));
        Long memberId = member.getId();
        String emitterId = memberId + "_" + System.currentTimeMillis();
        SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(DEFAULT_TIMEOUT));

        log.info("emitter created");

        emitter.onCompletion(() -> {
            synchronized (emitter){
            emitterRepository.deleteById(emitterId);}});
        emitter.onTimeout(() -> {
            emitter.complete();
            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;
    }

    @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, notification.getContent());
                }
        );
    }

    private void sendToClient(SseEmitter emitter, String emitterId, Object data) {
        try {
            log.warn("emitterId : " + emitterId);
            log.warn("data : " + data.toString());
            emitter.send(SseEmitter.event()
                    .id(emitterId)
                    .data(data));
            log.info(emitterId+"-emitter has been sent and completed");
        } catch (IOException exception) {
            log.error("Unable to emit");
            emitter.completeWithError(exception);
            emitterRepository.deleteById(emitterId);
        }
    }

    @Transactional
    public List<ResponseNotificationDto> getNotificationList(Member member) {
        List<Notification> notificationList = notificationRepository.findAllByReceiverOrderByCreatedAtDesc(member);
        List<ResponseNotificationDto> responseNotificationDtoList= new ArrayList<>();
        for (Notification notification: notificationList) {
            responseNotificationDtoList.add(SSE_MAPPER.NotificationtoResponseNotificationDto(notification));
        }
        return responseNotificationDtoList;
    }

    @Transactional
    public void readNotification(Long notificationid, Member member) {
        Notification notification = notificationRepository.findById(notificationid)
                .orElseThrow(()-> new NotFoundException(SSE, SERVICE, NOTIFICATION_NOT_FOUND, "Notification ID : " + notificationid));
        if(member.getId().equals(notification.getReceiver().getId())) {
            notification.read();
        }
        notificationRepository.save(notification);
    }

    @Transactional
    public ResponseCountNotificationDto countUnreadNotifications(Member member) {
        String nickname = member.getNickname();
        Long count = notificationRepository.countUnreadNotifications(nickname);

        return new ResponseCountNotificationDto(count);

    }
}

여러가지 메소드가 들어있어서 좀 깁니다. 

 

중요한 수정사항들을 몇가지 언급하자면,

 

synchornized() : 비동기를 사용했던 친구들이 완료되면 동기화 시키는 메서드
emitter.completeWithError(exception);

에미터가 발신 되지 않았을 경우 에러와 함께 종료 시키기

 

 

 

대댓글 알림 서비스 메서드 예시

제가 EventListener를 사용했다는 사실 알고 계시죠? (@EventListener 사용)  

아래와 같이 EventListener를 구현하였습니다.

@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

 

입니다.

 

구글링으로 나와있는 방법은 대부분 시도해보았습니다. 혹시 해결방안을 알고 계시는 분들은 댓글달아주세요...!!

 

라고 하자마자 해결방안이 나와서 에러가 이제 안납니다.

 

ngnix 설정을 추가해 줍니다.

proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;

 

에러가 사라졌습니다.

위에 헤더로 ngnix 설정해줬다고 생각했었는데 아니었나봅니다....

 

오늘은 계획된 배포일이지만 아무도 오늘 가능할 거라고 생각하지 못합니다.

일단은 @Async 의 Configuration 설정을 통해서 서버가 터지는 현상은 막았습니다. 

AsyncConfigurerSupport 

@Configuration
@EnableAsync
public class AsyncConfig extends AsyncConfigurerSupport {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(30);
        executor.setQueueCapacity(50);
        executor.setThreadNamePrefix("NOTIFICATION-ASYNC");
        executor.initialize();
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return super.getAsyncUncaughtExceptionHandler();
    }
}

위와 같이 설정해주었는데 나중에 Chatgpt한테 물어보니 코드를 그대로 짜주더라고요. 살짝 개발자 커리어에 대한 불안감이 엄습해왔지만 당장 닥친 미래가 더 중요해서 집중을 했습니다. 무서운 세상입니다... 취업하면 MS 주식 풀매수... 

chatgpt

 

에러 넘어 에러!! ERROR!!

대댓글에는 알림이 가지 않는 현상을 발견했습니다. 생각을 깊게 못한 제 탓인가요. 기능이 하루걸러 추가되는 프로젝트였다 보니 그런거 같기도 합니다. 

에러가 마구마구 터지는 중.

해결이 안되는데

일단 새벽 3시가 넘어서 잠.

어느새 최종프로젝트도 배포일이 2일 남았습니다. MVP 기능은 예전에 끝이 났는데 프론트와 연결하고 배포하면서 일어나는 자잘한 에러들과 디자인 변경으로 인한 새로운 api들 여러개를 구현하다보니 시간이 훌쩍 지나갔습니다. SSE 파트를 맡은 저는 하루 걸러 새로 나오는 버그들에 정신이 없었던거 같습니다. SSE 관련해서 타임라인을 보면 아래와 같습니다. 관련해서 잘 정리해놓고 면접때 꼭 얘기하라는 멘토님의 말씀이 있기도 했습니다. 

 

 

 

TIL SSE 에러 230124

알림관련해서 성공한 줄 알았는데 아니었습니다. FE와 연결 시 발생한 SSE 관련 에러는 다음과 같습니다. 1. subscribe 성공 후, 알림 2~3번 수신하면 서버가 멈추는 현상 -> DB Connection Pool이 다 차서 con

pizzathedeveloper.tistory.com

 

 

TIL SSE 에러 트러블슈팅 230125

아직 해결중입니다. 버그가 쉽게 해결되지 않아서 스트레스가 밀려오지만 또 면접에서 할말 생겼다는 생각이 들어서 긍정적으로 생각하기로 했습니다. 집에서 공부하니까 집중이 잘 안되는 거

pizzathedeveloper.tistory.com

 

 

TIL @Async 비동기 동기 230126

결국 SSE 관련 버그를 처리했습니다. TIL SSE 에러 트러블슈팅 230125 아직 해결중입니다. 버그가 쉽게 해결되지 않아서 스트레스가 밀려오지만 또 면접에서 할말 생겼다는 생각이 들어서 긍정적으

pizzathedeveloper.tistory.com

 

 

TIL @EventListener 알림 기능 강한 결합 제거 230127

기존에는 NotificationService를 주입받아 적용했는데 서비스간의 의존성이 생기고 결합도가 높아서 이슈가 생길것이 염려되었습니다. 결국 관련해서 에러가 터지기도 했습니다. 의존성을 제거하기

pizzathedeveloper.tistory.com

 

 

TIL 에러 넘어 에러 (feat. 객체직렬화) 230128

에러 EventSource's response has a Content-Type specifying an unsupported type; 이라는 에러가 발생했습니다. 프론트로부터 위와 같은 에러를 전달 받았습니다. 이벤트 데이터를 저장할 때 원본 객체를 문자열로

pizzathedeveloper.tistory.com

 

 

덕분에 비동기, 동기 관련 개념을 알게 되었고 @EventListener라는 어노테이션을 활용할 수 있게 되었으며 Gson이라는 구글의 라이브러라리를 알게 되었습니다. 

최종프로젝트 로고

팀원들과 소통하며 각자의 파트에서 배운 것들을 공유했습니다. 또 facade 패턴을 도입하는 것도 고려 했었는데 멘토님이 SAGA 패턴이라는 것을 알려주셔서 패턴에 대해서도 공부를 했습니다. 하루하루 배울 것이 천지이고 알게 될수록 개발은 흥미롭기만 합니다.

 

(영어 2배속으로 들었는데 한번 더 들어야 이해가 될 듯 합니다. 영어는 영어이지만 외계어인듯.... Speak English Ma'am!!)

 

영어를 아는 것과 프로그래밍 언어를 아는 것은 다르고 컴퓨터 용어를 아는 것도 다릅니다... 배울게 넘  많아요!!

 

이제 2주 남짓한 시간이 남았는데 최종 프로젝트 끝나면 알고리즘 공부를 빡세게 해야겠습니다.... 마지막까지 화이팅🙏🙏🙏🙏

 

 

 

 

 

에러

EventSource 에러

EventSource's response has a Content-Type specifying an unsupported type;

 

이라는 에러가 발생했습니다. 프론트로부터 위와 같은 에러를 전달 받았습니다.

이벤트 데이터를 저장할 때 원본 객체를 문자열로 변환하지 않고 그대로 저장할 경우 EventSteam 연결이 즉시 종료되는 현상이 확인되었습니다. 그래서 여기저기 물어보고 구글링 해서 json 객체를 문자열로 변환하는 방법을 찾았습니다.

 

json 마샬링 언마샬링 하기에 대해서 아래글을 참조하세요.

 

JSON 마샬링/언마샬링 하기 Part1

JOSN 마샬링/언마샬링에 대해서 말씀 드리려고 합니다. 설명에 앞서 용어에 대한 정리를 간략하게 해보겠습니다. JSON (JavaScript Object Notation) JSON에 대한 정의는 아래와 같습니다. JSON (JavaScript Object

beyondj2ee.tumblr.com

 

 

객체직렬화 하기

@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);
                Gson gson = new Gson();
                String data = gson.toJson(notification);
                sendToClient(emitter, key, data);
            }
    );
}

 

Gson을 사용해서 객체직렬화를 하였습니다.

 

Gson gson = new Gson();
String data = gson.toJson(notification);

아직 프론트랑 연결은 안해봤는데 일단 string 형태로 보내니까 json 형태라는 에러는 안뜰거라고 예상합니다.

 

 

 

참고 자료

 

Google의 Gson 라이브러리를 사용한 Java 객체 직렬화

이 게시물은 Java 객체의 직렬화 및 역직렬화 Google의 Gson 라이브러리를 사용합니다. 오늘날 거의 모든 RESTful 웹 서비스는 XML 대신 JSON 데이터를 사용하고 생성합니다. 불행히도 Java SE는 JSON을 Java O

www.techiedelight.com

 

기존에는 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());
    }

}

 

참고 문서

 

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

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

gilssang97.tistory.com

 

읽지 않은 알림 갯수 반환 하기

FE로부터 요청이 왔습니다. 읽지 않은 알림의 갯수를 보내달라고 합니다. API를 새로 파서 보내기로 결정 했습니다. Event 발생할때 마다 넣어달라고 처음에 요청하셨는데 Transactional 이슈가 발생할 것 같아서 우선 API를 따로 만들어서 구현하기로 했습니다.

 

DB에서 바로 몇개인지 세서 보내기 위해서 Query를 직접 작성하기로 했습니다.

 

JPQL

@Transactional
@Query("SELECT COUNT(n) from Notification n where n.receiver.nickname =:nickname and n.isRead = false")
Long countUnreadNotifications(String nickname);

Count를 써서 조건에 해당하는 값들을 셌습니다.

 

해당 nickname을 가지고 있는 수신자(receiver)를 가지고 있는 notification의 isRead 칼럼이 false이면 count를 합니다.

 

생각보다 간단해서 다행이었습니다.

querydsl를 써보려다 더 복잡한 거 같아 간단한 JPQL을 사용해서 읽지않은 알림 갯수를 가져오는 메서드를 구현했습니다.

결국 SSE 관련 버그를 처리했습니다. 

 

TIL SSE 에러 트러블슈팅 230125

아직 해결중입니다. 버그가 쉽게 해결되지 않아서 스트레스가 밀려오지만 또 면접에서 할말 생겼다는 생각이 들어서 긍정적으로 생각하기로 했습니다. 집에서 공부하니까 집중이 잘 안되는 거

pizzathedeveloper.tistory.com

 

서치를 하다가 비동기, 동기 개념에 대해 더 깊게 파고들었고 결국 해결했습니다. 허탈하긴하지만 @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 설정 클래스를 만들어서 설정할 수 있는데 이는 내일 구현할 예정입니다. 일단 급한 불을 끄는데 집중했습니다. 

참조 블로그 (https://steady-coding.tistory.com/611)

 

 

동기 vs. 비동기

  • 동기(synchronous): 요청과 결과가 동시에 일어난다는 약속; 요청한 자리에서 결과가 주어져야 함
    • A작업이 모두 진행 될 때까지 B작업은 대기해야함
  • 비동기(Asynchronous): 요청과 결과가 동시에 일어나지 않을거라는 약속; 
    • A작업이 시작하면 동시에 B작업이 실행되며, A작업은 결과값이 나오는 대로 출력된다

 

SSE 관련 더 공부할 부분

ApplicationEventPublisher를 사용해서 서비스간의 의존성을 낮추는 방법에 대해 알게 되었습니다. EventListner를 사용해서 하는 방법이 있다고 해서 연구해보려고 합니다. 무래도 다른 Service안에 알림 Service를 넣다보니 의존성 문제가 있다고 여겨져서 방법을 찾고 있었기 때문입니다.

 

 

 

추가로 공부한 것

 

[Spring JPA] Entity, DTO

Entity, DTO 개념, DTO 사용법

velog.io

각자 파트를 맡아서 개발을 하다보니 컨벤션 준수가 되지 않거나 일관성이 없는 부분들에 대해서 팀장님이 리팩토링을 진행중입니다. DTO 관련해서 링크를 첨부해주셔서 읽어보려고 합니다. Mapper 사용할때 Dto를 참조하는 안티패턴이 발견되었습니다. 여러사람이 같은 프로젝트를 하니까 좋은점은 check-balance가 된다는 점입니다. 내공있는 조원들의 경험을 레버리지 할 수 있어서 좋습니다.

 

 

 

아직 해결중입니다. 버그가 쉽게 해결되지 않아서 스트레스가 밀려오지만 또 면접에서 할말 생겼다는 생각이 들어서 긍정적으로 생각하기로 했습니다. 집에서 공부하니까 집중이 잘 안되는 거 같아 나가려다 영하 17도를 보고 다시 담요를 여몄습니다. 집 세탁기가 얼었습니다. 다행히 보일러는 아직 살아있네요. 

 

발생한 버그에 대한 로그는 아래와 같습니다.

Connection leak detection triggered

 Connection leak detection triggered for com.mysql.cj.jdbc.ConnectionImpl@

HikariPool에 대해서 나오고 있어서 Hikari가 문젠가 하고 먼저 들여다 보았습니다.

 

HikariCP DeadLock

잠재적인 원인에 대해서 공부를 하기로 했습니다. 

 

HikariCP Dead lock에서 벗어나기 (이론편) | 우아한형제들 기술블로그

{{item.name}} 안녕하세요! 공통시스템개발팀에서 메세지 플랫폼 개발을 하고 있는 이재훈입니다. 메세지 플랫폼 운영 장애를 바탕으로 HikariCP에서 Dead lock이 발생할 수 있는 case와 Dead lock을 회피할

techblog.woowahan.com

읽어보니 저희 오류랑은 조금은 다릅니다.

 

 

 

제가 다시 눈여겨본 로그는 다음입니다:

Async request timed out

 

무시해도 된다는 블로그 글을 찾긴 했습니다만...(https://jsonobject.tistory.com/558)  궁금해서 찾아봤습니다.

 

디버깅은 계속됩니다....

'TIL' 카테고리의 다른 글

TIL @EventListener 알림 기능 강한 결합 제거 230127  (0) 2023.01.28
TIL @Async 비동기 동기 230126  (0) 2023.01.27
TIL SSE 에러 230124  (0) 2023.01.25
TIL 설연휴 월요일 230123  (0) 2023.01.23
TIL 설날 2023 230122  (0) 2023.01.23

알림관련해서 성공한 줄 알았는데 아니었습니다. FE와 연결 시 발생한 SSE 관련 에러는 다음과 같습니다. 

 

1. subscribe  성공 후, 알림 2~3번 수신하면 서버가 멈추는 현상 -> DB Connection Pool이 다 차서 connection leack 이 발생

2. open-in-view 를 false로 설정하면 된다길래 (https://tecoble.techcourse.co.kr/post/2022-10-11-server-sent-events/) 설정했더니, transaction에 문제가 생겨 다른 service들 기능이 안되고 알림만 되는 현상

 

 

일단 오류가 발생한 것을 확인했습니다.

 

또 FE로부터 읽지않은알림 갯수를 보내달라는 요청을 받았는데 두 가지 안이 있습니다. 

FE에서 제안한 방법은

 

- 이벤트가 발생해서 알림을 보내줄 때 함께 읽지않은알림 갯수를 보내주기

 

입니다.

 

저의 생각은

 

- API를 새로 파서 unread notification count를 따로 보내주는 겁니다. 

 

 

여러 깃헙을 찾아보고 구글링해보면서 방법을 찾고 있습니다.

 

SSE가 자료도 많아서 간단할 줄 알았는데 쉽지 않네요. 

'TIL' 카테고리의 다른 글

TIL @Async 비동기 동기 230126  (0) 2023.01.27
TIL SSE 에러 트러블슈팅 230125  (0) 2023.01.26
TIL 설연휴 월요일 230123  (0) 2023.01.23
TIL 설날 2023 230122  (0) 2023.01.23
TIL 최종 프로젝트 중간 발표 230121  (0) 2023.01.23

오늘은 설연휴 첫날입니다. 게더에 접속했는데 생각보다 사람들이 많이 없어서 놀랐어요. 공부를 좀 해야겠습니다. 이제 정말 3주도 안남았거든요. 하차를 많이 한다고 했는데 하차를 할 이유는 이제 없어졌습니다. 이제 이 부트캠프에 투자한 돈과 시간이 가치가 있었는지를 검증할 일만 남았네요. 

 

내년 설연휴에는 어떤 모습으로 TIL을 쓰고 있을지 궁금해지네요. 인생이 5G로 달리고 있는 느낌입니다.

아침부터 차례지내고 친척들과 담소를 나누니 피곤해서 뻗었습니다. 저녁에는 오랜만에 친구들과의 약속이 있어서 나갔다 왔습니다. 대학교친구들인데 어느새 저희가 만난지도 10년이 다되어 간다고 하며 세월이 너무 빠르다고 했습니다. 제가 부트캠프 한다고 작년 가을에 말했었는데 그것도 이제 3주도 안남았습니다. 

 

근데 재밌는건 다른 친구들도 진로 변경에 대한 고민을 하고 있다는 거였습니다. 지금 나이가 과도기인거 같아요. 다들 뭐든 잘 할 수 있어서 더욱 더 고민이 되는 것 같습니다. 하나만 잘하는 게 아니라 여러개를 잘하는데 여러개에 흥미가 있어서 여러가지를 도전할 수 있는 상황인거에요. 지금 저희 세대는 평균 수명이 100세가 넘어가서 앞으로 최소 80년은 더 살아야하고 직업을 3~4개 가질 거라고 합니다. 저는 이미 하나는 클리어 했고 2개째 입니다. 고용되는 직업으로서는 그렇습니다. 

 

웹개발자는 전세계에 내가 만든 것을 선보일 수 있다는 점에서 더 재미가 있습니다. 디지털 세상에 내 영토를 만드는 것이라고 생각하면 개발자는 매력적인 직업입니다.

 

 

 

중간발표하면서 느낀건 이 하나의 프로젝트로 면접할 때 할말은 많겠다 입니다.

예상질문 잘 정리해서 답변 잘 할 수 있도록 준비해야겠습니다.

드디어 내일이 중간 발표날입니다. 조금은 해이해진 요즘인데 프론트로부터 알림 구현에 대해 수정 요청이 들어왔습니다. 제가 요청받은 사항은 아래와 같습니다.

 

  1. redirect 되는 페이지의 정보를 게시물 타입, 게시물 아이디로 보내주기
  2. 알림에 알림을 발신하는 멤버의 정보(닉네임, 프로필사진)을 추가
  3. 알림 목록 조회시 최신순으로 조회

 

1, 2번을 하고 실행을 눌렀는데 어제와 같은 에러가 발생했습니다. IP 주소를 확인하니 그새 또 변경이 되었더라고요. 제가 무슨 해커도 아니고 맨날 IP 주소가 변경이 되네요. 고정 IP 신청을 하면 된다는데 일단은 버텨봅니다.

 

 

비관적 락 pessimistic Lock

@Transactional
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="3000")})
Optional<PostLike> findByPostLikedIdAndMemberId(Long postLikedId, Long memberId);

이틀 전부터 있던 버그는 위와 같이 비관적 락을 걸어줘서 바꿨습니다. 

 

TIL 동시성 문제? 나의 첫 버그 230118

동시성 문제로 추정되는 오류가 발생했다. 게시글 1에서는 좋아요가 잘 반영이 되는데 게시글 2부터 안된다. 이럴수가. 뭐가 문제냐. 여러가지 자료를 찾아보고 있는데 이런걸 바로 버그라고 하

pizzathedeveloper.tistory.com

비관적 락(Pessimistic Lock)을 사용하는 이유는 아래와 같습니다.

 

1. 트랜잭션이 충돌한다고 가정하고 락을 사용

2. DBMS의 락 기능을 사용

3. 데이터 수정 시 즉시 트랜잭션 충돌 여부를 확인 가능

 

비관적 락에는 두가지 종류가 있습니다:

  • Exclusive Lock은 데이터를 변경하고자 할 때 사용되며, 트랜잭션이 완료될 때까지 유지되어 해당 Lock이 해제될 때까지 다른 트랜잭션은 해당 데이터에 읽기를 포함하여 접근을 할 수 없습니다.
  • Shared Lock은 다른 사용자가 동시에 데이터를 읽을 수는 있지만 Write는 할 수 없습니다.

PESSIMISTIC_WRITE Exclusive Lock을 걸고, 데이터를 읽거나, 업데이트하거나, 삭제하는 것을 막습니다.

 

JPA는 PESSIMISTIC_WRITE 이외에도 

 

  • PESSIMISTIC_READ - Shared Lock을 걸고 데이터가 업데이트 되거나 삭제되지 않도록 한다
  • PESSIMISTIC_FORCE_INCREMENT - PESSIMISTIC_WRITE와 유사하고 ENTITY의 버전 속성을 추가로 증가시킨다.

 

EXCEPTION의 경우에는 PersistenceException(락을 가져오는데 실패하면 예외 발생), LockTimeoutException(락을 기다리다가 설정해놓은 wait time을 지났을 때 예외 발생) 이 있습니다.

 

저는 좋아요 실행 시 충돌이 발생한 것을 이미 봤기 때문에 충돌이 발생한다는 것을 가정하는 비관적 락을 걸었습니다. Lock이 길어지면 Latency가 길어지기 때문에, @QueryHints를 사용하여 Lock에 3초 타임 아웃을 적용했습니다.

 

 

 

결과는?

잘 됩니다.

좋아요를 여러번 연속해서 처리할 때 true 값만 반환이 되던(아마 충돌이 일어나서 실행이 되지 못했겠던) 현상이 사라지고 정상적으로 잘 작동이 됩니다.

 

 

 

프론트 요청 처리하기

새로운 칼럼을 추가하려고 하니 이미 DB가 서버에 올라가 있어서 다 날리고 새로 올리는 작업을 여러번 반복해야했습니다. 저만 칼럼을 수정하고 추가하고 있던게 아니었거든요.. API 명세가 처음이랑은 전혀 다른 모습이 되었습니다. 내일이 중간 발표인데 중간 발표가 끝나면 한번 정리하는 시간을 가져야 할 것 같습니다.

 

이제는 CRUD 수정하는 정도는 금방 해버립니다. 실제로 위의 1,2,3, 요청은 2 시간도 안걸려서 해결해버렸답니다. JPA가 정말 편해요.. 스프링은 사랑입니다😘

 

 

아래와 같이 SQL Error 가 발생했습니다.

SQL 에러

기존에 있던 Column 명을 변경해서 에러가 생긴 줄 알았는데 SQL에 등록해놓았던 ip 주소가 변경되어서 그런거였습니다.

ip 주소는 고정 ip를 신청하지 않는 이상 변경 될 수 있다고 하네요. 

+ Recent posts