융합캡스톤 회고 2

MINJU·2022년 12월 27일
1

💻 개발


(3) Spring Boot 서버


1. Spring Scheduler

챗봇의 일상 질문에 응답이 없는 사용자

(위와 같은 기준에 따라) 특정 시간 동안 움직임이 감지되지 않은 사용자는 응급상황에 처한 것으로 예상되는 사용자로서

이러한 상황을 응급콜을 통해 관리자에게 대시보드로 알려줘야만한다!


이 기능을 구현하려면 주기적으로 서버에서 응답하지 않은 사용자를 체킹해야할 것이고, 마지막 움직임 시간을 업데이트 해야할 것이다.

바로 이 부분에서 ! Scheduler가 활용되는 것이다.


Scheduler를 활용하기 위해

@SpringBootApplication
@EnableJpaAuditing
@EnableScheduling
public class BackendApplication {

	public static void main(String[] args) {
		SpringApplication.run(BackendApplication.class, args);
	}

}

@EnableScheduling 어노테이션을 붙여준 후


@Configuration
public class SchedulingConfiguration implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(100);
        taskScheduler.initialize();
        taskRegistrar.setTaskScheduler(taskScheduler);
    }
}

해당 블로그를 참조하여, 위와 같은 @Configuration을 작성했다.


해당 클래스를 통해 Thread Pool을 설정한 이유는 다음과 같다. (참조 블로그에서도 내용을 확인할 수 있다.)

기본적으로 모든 @Scheduled 작업은 한 개의 스레드 풀에서 실행되는데, 이로 인해 하나의 Scheduled가 돌고 있으면 그것이 끝나야 다음 Scheduled가 실행되는 문제가 발생한다.

따라서 Scheduled에 대한 쓰레드 풀을 설정하고, 그 풀을 활용해서 모든 스케줄 된 작업을 실행하도록 하기 위해 위와 같은 설정을 진행하였다.


@Scheduled(cron = "0 0/30 * * * ?")
    @Transactional
    @Async
    public void createNoResponseEmergency() throws UnsupportedEncodingException, URISyntaxException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException {
        System.out.println("Scheduler.createNoResponseEmergency");
        // 1. 응답 없음인 애들 조회
        List<Client> clientsNotResponse = clientRepository.findAllByResponse(false);
        List<ClientDto> clientDtos = clientListToDtoList(clientsNotResponse);
        for (ClientDto clientDto : clientDtos) {
            createEmergency(clientDto, EmergencyType.no_response);
        }
        
    }

위는 응답없음인 사용자를 조회하는 스케줄러이다.

30분마다 조회하기 위해 cron을 활용했고
비동기 호출을 위해 @Async 어노테이션을 추가했다.

clientRepository에서 응답없는 Client를 가져온 뒤,
List<Client>List<ClientDto>로 변환하고
List<ClientDto>를 활용하여 "응답 없음 응급콜"을 생성한다.


@Scheduled(cron = "0 0/30 * * * ?")
    @Transactional
    @Async
    public void createNoMoveEmergency() throws UnsupportedEncodingException, URISyntaxException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException {
        System.out.println("Scheduler.createNoMoveEmergency");
        // 1. 현재 시간
        LocalDateTime now = LocalDateTime.now();
        // 1. 전체 Client 가져오기
        List<ClientDto> clientDtos = readAllClientDtos();
        for (ClientDto clientDto : clientDtos) {
            LastMovedTime findResult = lastMovedTimeRepository.findFirstByClientIdOrderByLastMovedTimeDesc(clientDto.getId());
            if (findResult != null) {
                LocalDateTime findLastMovedTime = findResult.getLastMovedTime();
                long between = ChronoUnit.HOURS.between(findLastMovedTime, now);
                if (between >= 24) {
                    clientDto.updateStatus(Status.위험);
                    createEmergency(clientDto, EmergencyType.no_move_danger);
                } else if (between >= 12) {
                    clientDto.updateStatus(Status.경보);
                    createEmergency(clientDto, EmergencyType.no_move_alarm);
                } else if (between >= 8) {
                    clientDto.updateStatus(Status.주의);
                }
                updateClient(clientDto); // 저장
            }
        }
    }

위는 움직임 모니터링을 위한 스케줄러이다.

마찬가지로 cronAsync를 활용하였고

Client를 모두 가져온 뒤, LocalDateTime.now()ChronoUnit을 활용하여 마지막 움직임 시간을 업데이트 하도록 구현하였다.

(경보위험의 경우에는 응급콜도 생성된다.)



DB를 체킹해서 값을 업데이트하고, 이 업데이트 마다 응급콜이 생성되어서 대시보드에 알람이 전송되는 기능이기 때문에
너무 자주자주 스케줄러가 실행되면 서버 과부하 문제도 발생할 수 있고
관리자가 해당 응급콜을 오프라인에서 해결하기도 전에 ! 다시 응급콜이 울리는 상황이 발생할 수도 있을 것 같아 30분 단위로 체킹하도록 구현하였다.



2. SSE

위와 같은 스케줄러를 통해 생성된 응급콜과 사용자가 안드로이드 어플의 응급콜 버튼을 눌러 생성된 응급콜은 관리자에게 즉시 알려줘야만하는 내용이다.



응급콜이 생성되면 서버는 대시보드에 실시간 알람을 띄운다

는 로직은 단순한데, 생각해보면 이는 통상적인 HTTP 메소드의 작동 방식과는 다름을 확인할 수 있다. Client가 요청하지 않아도, 서버에서 어떠한 신호가 오면 자동으로 Client가 반응해야하기 때문이다.


이를 구현하기 위해 소켓통신.. 등 다양한 루트를 생각해봤는데

실시간 알람은 서버가 클라이언트에게 요청을 보내기만하는 단방향 통신의 성향을 띄기 때문에, Spring Boot에서 라이브러리 지원도 잘 되고 있는 SSE를 선택하는 것이 옳다고 생각하였다.
(고민 과정에서 작성한 블로그 글이다.)


SSE의 목적은 아래와 같은 응급콜 팝업을 띄우는 것이므로

팝업을 위한 Notification 엔티티를 만들었다. (DB에 Notificaiton들을 저장해서 관리하도록 구현했다.)

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

    private String content;

    @JoinColumn(table = "account", name = "id")
    private Long userId;

    private String name;
    private String address;
    private String phoneNumber;

    public Notification(String content, Long userId, String name, String address, String phoneNumber) {
        this.content = content;
        this.userId = userId;
        this.name = name;
        this.address = address;
        this.phoneNumber = phoneNumber;
    }
}

그리고 SSE 구독을 위한 subscribe()를 아래와 같이 만들고

    public SseEmitter subscribe(Long memberId, String lastEventId) {
        String emitterId = makeTimeIncludeId(memberId);
        SseEmitter emitter = emitterRepository.save(emitterId, new SseEmitter(DEFAULT_TIMEOUT));
        emitter.onCompletion(() -> emitterRepository.deleteById(emitterId));
        emitter.onTimeout(() -> emitterRepository.deleteById(emitterId));

        // 503 에러를 방지하기 위한 더미 이벤트 전송
        String eventId = makeTimeIncludeId(memberId);
        sendNotification(emitter, eventId, emitterId, "EventStream Created. [userId=" + memberId + "]");

        // 클라이언트가 미수신한 Event 목록이 존재할 경우 전송하여 Event 유실을 예방
        if (hasLostData(lastEventId)) {
            sendLostData(lastEventId, memberId, emitterId, emitter);
        }

SSE를 통해 전송하기 위한 send()를 아래와 같이 구현하였다.

    public void send(String phoneNumber, EmergencyRequestDto emergencyRequestDto) {
        Client client = clientRepository.findByPhonenumber(phoneNumber);
        String content = client.getName() + "님의 응급콜 : " + emergencyRequestDto.getEmergencyType().getContent();
        Notification notification = notificationRepository.save(new Notification(content, client.getId(),
                client.getName(), client.getAddress(), client.getPhonenumber()));

        String id = "1";
        String eventId = id + "_" + System.currentTimeMillis();
        Map<String, SseEmitter> emitters = emitterRepository.findAll();
        emitters.forEach(
                (key, emitter) -> {
                    emitterRepository.saveEventCache(key, notification);
                    sendNotification(emitter, eventId, key, notification);
                }
        );
    }
    private void sendNotification(SseEmitter emitter, String eventId, String emitterId, Object data) {
        try {
            emitter.send(SseEmitter.event()
                    .id(eventId)
                    .name("sse")
                    .data(data));
        } catch (IOException exception) {
            emitterRepository.deleteById(emitterId);
        }
    }

모든 응급콜이 생성될 때마다 SSEE를 통한 응급콜 팝업이 생성되도록 하기 위해

응급콜 생성 로직에 아래와 같이 send() 로직을 추가했다.

    @Transactional
    public Emergency addEmergency(String phoneNumber, EmergencyRequestDto emergencyRequestDto)
            throws UnsupportedEncodingException, URISyntaxException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException {
        Client client = clientRepository.findByPhonenumber(phoneNumber);
        // 여기!
        notificationService.send(phoneNumber, emergencyRequestDto);
        //
        emergencyRequestDto.updateClient(client);
        return emergencyRepository.save(EmergencyRequestDto.toEntity(emergencyRequestDto));
    }

회고이니만큼, SSE와 관련된 모든 로직을 상세하게 설명한다면 글이 너무나도 길어질 것 같아서 ㅠㅠ 참조한 블로그를 남겨놓고자한다.

해당 블로그를 가장 많이 참조했었는데

이게 보기엔 복잡하고 어려워보여도
로직 그대로 따라가면서 전달 내용만 변경하면 되기 때문에 어렵지 않다는 말을 남기고 싶다 :)


SSE, 소켓 이런게 막연하게 어렵게만 느껴지고 좀 무서웠는데
한 번 경험해보니까 자신감이 좀 붙은 것 같다.

본 프로젝트는, 한 명의 관리자를 가정하고 있기 때문에 (실제로 대시보드에서는 어떤 로그인 기능도 구현하지 않았다.) SSE 구현이 오히려 더 용이했었다.
하지만 실제 프로젝트는 대부분 특정 사용자에게만 실시간 알림을 보내는 상황이 더 많을 것이기 때문에, 이를 고려한 구현도 한 번 진행해보고 싶다.

Client단의 구현이 필수적인 기능인만큼 간단한 React 기능을 좀 익힌 뒤 좀 더 제대로 된 Socket 통신, SSE 통신을 구현해보고 싶다 :)



3. SMS

관리자가 응급콜이 발생하기만을 대시보드를 보며 기다릴 수는 없지 않겠냐~ 는 교수님의 피드백을 기반으로 도입하게 된 기능이다!

SMS 발송은 정~말 많은 참조 자료가 있기도 했고 API도 매우매우 잘 되어 있어서 구현하기 용이했다.

많은 API가 있었지만 우리는 네이버 클라우드 플랫폼에서 제공해주는 API를 사용하게 됐고

    public String getMessageContent(Client client, EmergencyRequestDto emergencyRequestDto){
        return client.getName() +
                "님의 응급콜 발생" +
                "(내용 : " +
                emergencyRequestDto.getEmergencyType().getContent() +
                ")";
    }


API와 해당 로직을 활용하여

아래와 같이 응급콜 생성시 SMS를 같이 전송할 수 있도록 구현했다.

    @Transactional
    public Emergency addEmergency(String phoneNumber, EmergencyRequestDto emergencyRequestDto)
            throws UnsupportedEncodingException, URISyntaxException, NoSuchAlgorithmException, InvalidKeyException, JsonProcessingException {
        Client client = clientRepository.findByPhonenumber(phoneNumber);
        notificationService.send(phoneNumber, emergencyRequestDto);
        //여기!
        smsService.sendSms(phoneNumber, emergencyRequestDto); 
        //
        emergencyRequestDto.updateClient(client);
        return emergencyRepository.save(EmergencyRequestDto.toEntity(emergencyRequestDto));
    }

API에 필요한 다양한 정보들은 application.properties에서 관리하도록 구현했다 :)


위 두 가지의 결과를 시연 영상 캡쳐를 통해 살펴보면

이렇게 사용자가 응급콜 버튼을 누르면

대시보드에 알람팝업이 뜨고

관리자의 핸드폰에 응급상황과 관련된 SMS가 전송됨을 확인할 수 있다 :)

추가로 이렇게 응급콜도 대시보드에 띄워진다!



4. 응급콜 해결 로직

위 사진을 보면 응급콜 옆에 해결 여부 체킹이 가능함을 확인할 수 있다.

응급콜은

  1. 응급콜 버튼 누름
  2. 응답 없음
  3. 움직임 없음

총 세 가지로 나뉘어지는 데, 2번 3번의 경우 관리자가 해결 버튼을 누르면 응답 여부 필드가 true로 바뀌어야하고, 움직임 시간 필드가 최근 시간으로 변경되어야 한다. (그래야지 스케줄러에서 응급콜을 또 생성하지 않게 된다.)

단순히 DB를 변경하는 것이므로

    @Transactional
    public Emergency updateEmergency(Long emergencyId, EmergencyResponseDto emergencyResponseDto) {
        Emergency emergency = emergencyRepository.findById(emergencyId).get();
        emergencySolution(emergency);
        emergency.updateEmergency(emergencyResponseDto);
        return emergencyRepository.save(emergency);
    }

이렇게 Emergency update로직에 emergencySolution()을 추가해서


private void emergencySolution(Emergency emergency) {
        EmergencyType emergencyType = emergency.getEmergencyType();
        Client client = emergency.getClient();

        if (emergencyType == EmergencyType.no_response) {
            client.updateResponse(true);
            clientRepository.save(client);
        } else if (emergencyType == EmergencyType.no_move_alarm || emergencyType == EmergencyType.no_move_danger) {
            LastMovedTime result = lastMovedTimeRepository.findFirstByClientIdOrderByLastMovedTimeDesc(client.getId());
            LastMovedTimeDto buildDto = LastMovedTimeDto.builder()
                    .location(result.getLocation())
                    .lastMovedTime(LocalDateTime.now())
                    .build();
            result.update(buildDto);
            lastMovedTimeRepository.save(result);
        }
    }

위와 같이 동작하도록 구현했다:)



5. 외부 API 호출

해당 주제는 기업 연계 주제여서, 우리는 멘토링 기업 대표님과도 몇 번 미팅을 진행했었다.

멘토링 기업에서는 우리에게 본사가 개발 중인 콜봇에 연결할 수 있는 API를 제공해주시겠다는 말씀을 해주셨고

따라서 해당 API를 효과적으로 활용해보고자
하루하루별로 사용자의 첫 움직임이 감지되면 멘토링 기업의 콜봇에 Request를 보내, 사용자의 핸드폰으로 콜봇 전화가 갈 수 있도록 구현하고자 했다.

        LastMovedTime result = lastMovedTimeRepository.findFirstByClientIdOrderByLastMovedTimeDesc(client.getId());
            if (result == null || result.getLastMovedTime().isBefore(LocalDateTime.now().with(LocalTime.NOON))) {
                post(client.getPhonenumber());
            }

post가 바로 Spring Boot 프로젝트에서 다른 서버에 HTTP Request를 보내는 로직인데

public DnkResponseDto post(String phoneNumber) throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
        URI uri = UriComponentsBuilder
                .fromUriString("https://www.campaignbot.co.kr")
                .path("/api/camp/camp_auto_start")
                .build()
                .toUri();

        System.out.println(uri);

        List<DnkRequestBody> req_data = new ArrayList<>();
        req_data.add(DnkRequestBody.builder().phone_num(phoneNumber.replaceAll("-", "")).build());

        DnkRequestDto requestDto = DnkRequestDto.builder()
                .apikey(apikey)
                .channel(channel)
                .req_data(req_data)
                .build();
        ResponseEntity<DnkResponseDto> response = null;
        if(IGNORE_SSL){
            response = new RestTemplate(HttpClientConfig.trustRequestFactory()).postForEntity(uri, requestDto, DnkResponseDto.class);
        } else {
            response = new RestTemplate().postForEntity(uri, requestDto, DnkResponseDto.class);
        }

        return response.getBody();
    }

이렇게! RestTemplate을 활용하여 로직을 구현하였다 :)


하지만 여기서 에러를 마주하게 됐는데 (위 코드는 에러를 해결한 코드이다)

권한 인증 때문에

요렇게 에러가 발생한 것이다.

우리는 서버를 배포하지 않았고 로컬에서 진행중이었어서 해당 에러를 어떻게 해결해야할지 ㅠㅠ 감이 잡히지 않았는데

다행히 기업멘토님께서 도움을 주셔서 해결할 수 있었다.


바로 해당 블로그 글에서도 확인할 수 있듯, 모든 인증서를 신뢰할 수 있도록 만들어놓는 것이다.

이것이 완벽한 해결책도 아닐 뿐더러 조금은 위험한 ㅠㅠ 방법이지만 배포를 하지 않을 프로젝트였기 때문에 해당 방법을 선택하게 됐다.


외부 API 연동을 하면서 작성하고 싶은 부분은

  1. Spring Boot도 Request를 보내고 Response를 받을 수 있다! (서버랑 서버끼리 통신할 수 있다.)
  2. 심지어 1번을 위한 다양한 방법이 존재한다. (나는 그 중 POST Request 레퍼런스가 잘 되어있는 RestTemplate을 적용했다.)
  3. SSL 인증과 관련된 에러를 처음 접해봤고.. 네트워크를 공부했어서 해당 에러를 마주했을 때 생각보다 당황하지 않았음에 뿌듯했다
  4. 하지만 야매(?)로 해당 에러를 해결했음에 아쉬움이 남았다.

이거였다 ~ ~

RestTemplate도 제대로 활용해보고 싶다 ~ ~

다양한 기술을 적용하다보니까 배우는 것도 느끼는 것도 많은 것 같다.



💦 아쉬운 점

1. Docker 파일 생성

레퍼런스를 기반으로 KoELECTRA, KoBERT, KoGPT2를 학습시키는 과정에서 라이브러리 설치에만 족히 이틀이 소요됐다.
그마저도 건드리기 무서워서 챗봇 관련된 코드는 가상환경이 완벽히 구축된 내 노트북에서만 진행했음 ^^...

연구실에서 몇 번 NLP를 깔짝거려봤을 때도 라이브러리 설치로 꽤나 곤욕을 치뤘어서 예상했던 부분이었음에도 불구하고 정말 .. 지옥이었다
ㅋㅋㅠㅠ 진척이 하나도 없는 상태로 계속 돌리고만 있는 상태란 .. 😂

그래서 Spring Boot의 경우에는 손쉽게 Dockerfile을 만들었지만, Flask는 라이브러리 설치에서 기냥 막혀버렸다. (그리고 제출 기간이 다 돼서 그냥 제출했다)

배포가 필수인 프로젝트가 아니었어서 상관은 없었지만,
당장 다음에 내가 이 프로젝트를 다운 받아서 다시 돌리고 싶을 때 Dockerfile이 있다면 더 수월해질 것임을 알기에 만들지 못했다는 점에 많은 아쉬움이 남는 것 같다.

시간 많이 걸릴 것을 각오하고 함 도전해봐야지 . .


2. 배포

앞서 말했던 것처럼 배포가 필수적인 프로젝트가 아니어서 배포를 하지 않았다.
시연 영상을 찍을 때도 같은 wifi를 잡아서 로컬로 실행했음😶

React + Spring Boot 까지였으면 어찌저찌 해봤을 텐데, 딥러닝 모델이 돌아가고 있는 Flask에 잘 모르겠는 안드로이드까지 껴있으니 괜히 겁먹어서 시도조차 하지 못했다.

하면 될 것이었다는 것을 알기에 ..
무거운 딥러닝 모델 배포는 보통 어떻게 하는지, 안드로이드 배포는 어떻게 하는지 함 찾아나 봐야겠다. (안드로이드 프로젝트도 한 번 제대로 해보고 싶다!)




🗣 느낀 점


뒤집어지는 우리의 Languages


큼지막한 프로젝트는 정말 사람을 많이 성장 시켜주는 것 같다.
내가 개발자를 꿈꾸면서 맨날 맨날 느끼는 것은, 정말 막상 하면 안될 것은 없다..는 것이다.

이번 프로젝트 하면서 배운 점, 그리고 또 더 배우고 싶은 점을 기억하고 더 발전해나가야겠다.

🙄🤍

0개의 댓글