사내 Push 알람 시스템 구축 후기 (By MS Teams Graph API)

박여명·2025년 9월 14일

개발

목록 보기
3/4

목차

  1. 왜 MS Teams를 통한 알람 시스템이 필요한가?
  2. MS Teams 알람 시스템을 위한 Graph API 사용 방법
  3. 알람 시스템 구조 설계 및 구현
    1. 우리 서버 환경
    2. 아키텍쳐 설계 1 : RabbitMQ 기반
    3. 아키텍쳐 설계 2 : 내부 큐 + 서버 분산 방식
  4. 후기

1. 왜 MS Teams를 통한 알람 시스템이 필요한가?

기존 메신저의 노후화와 다양한 기능의 요구로 새로운 메신저를 MS Teams로 도입하게 됐습니다.

문제는 사내 결재 문서 관련해서 사용하던 기존 메신저의 알림 기능이 Teams에서는 기본 제공하지 않아 서드파티 앱을 구매해야 사용 할 수 있었습니다. (구입 시 천만원 단위의 솔루션 비용 발생)

회사 내부 중요 과제인 비용절감과 더불어 알람체계 구축 시 제가 관리하는 ERP에도 도입하여 기존 알람체계의 문제를 해결하고 사용자에게 편의성과 업무 효율성 증대를 할 수 있을 것으로 기대했기 때문에 MS Teams를 이용한 Push알람 시스템 구축을 결정하였습니다.

ERP에 도입 계획을 세운 이유를 자세하게 설명하자면 아래와 같습니다.

기존 ERP 시스템에서는 업무 알림 기능이 일반적인 알람이 아닌 To-Do 리스트 형식에 가까운 구조로 구현되어 있어 다음과 같은 문제점이 발생함:

  1. 알림이 단순한 알림이 아니라, 해당 업무가 완료되기 전까지 사라지지 않아 일반 알람 기능처럼 활용하기 어려움
  2. 알림 데이터를 전용 알림 테이블이 아닌, 각 업무별 DB에서 직접 가져오는 방식으로 인해 로직이 복잡하고, 유지보수가 어려움
  3. 실시간 알림 구조가 아니며, 브라우저를 새로 고침하거나 재로그인해야 알림이 갱신되어, 실시간성이 떨어짐

개선 목표

  • 사용자에게 실시간으로 업무 알림을 제공하여, 업무 인지 및 대응 속도를 향상
  • 알림 수신의 접근성과 편의성을 개선하여, 사용자 경험(UX) 제고
  • 기존 시스템의 복잡한 데이터 연동 구조를 단순화하여 유지보수 효율성을 높임

추가로 구축 된 알람 시스템으로 좋은 성과를 추린다면 이를 문서화 해 각 IT 부서에 공유할 계획을 수립했습니다.

2. MS Teams 알람 시스템을 위한 Graph API 사용 방법

공식 문서에서 확인 가능하므로 따로 기술은 생략하며 알림 구현에 대한 내용만 따로 필요하신 분이 있다면 말씀 주시기 바랍니다.

3. 알람 시스템 구조 설계 및 구현

3.1 요구사항 정리 및 현 서버 환경

설계한 아키텍쳐를 설명하기 전 내부 서버 환경과 요구사항에 대한 설명이 필요할 것 같아 아래와 같이 정리해보았습니다. 왜 이런 아키텍쳐를 설계 했을까에 대한 부연 설명될 것 같습니다.

현 서버 환경

  • 서버 2대 위에서 각각 운영용 WAS 인스턴스들이 구동되어 다중 서버 환경(이중화)을 구성하고 있음
  • ERP 데이터 I/F를 위한 배치 프로세스가 서버 2에서 같이 구동됨

요구사항

  • ERP에서 알람은 실시간성이 강하지 않은 업무 특성상, 알람 발송은 즉시성보다 안정성과 비동기 처리가 중요함
  • 특히, 알람 발송 중 오류(예: Teams API rate limit 등)가 발생해도 전체 서비스에 영향을 주지 않도록 업무 흐름과 독립된 처리 방식이 필요했음.
  • 이러한 이유와 아래 요구사항으로, 메시지 큐(MQ)를 사용한 비동기 아키텍처를 도입 결정 ① 발송 실패에 대한 재시도 처리, ② 서버 간 부하 분산, ③ 향후 알람 유형 증가 시 유연한 확장성 확보가 가능하도록 설계 필요. ④ 혹시라도 Push알람을 팀즈가 아닌 다른 매체로 보내는 상황에도 유연한 대처 필요 (사내 메신저 변경 시)

+ 메시지 큐를 사용한 비동기 아키텍쳐 주요 특징

MQ를 사용한 비동기 아키텍쳐에 대한 주요 특징과 장점을 간단하게 정리해보았습니다.

이 아키텍쳐의 구성은 발신자(Producer), 메시지 큐(MQ), 수신자(Consumer)로 이루어져 있으며

이벤트가 발생하는 즉, 알람이 발생하는 운영 WAS에서 발신자 역할로 메시지를 MQ에 넣습니다.

수신자는 별도의 워커 서버로, MQ에 있는 메시지를 수신해 메시지 처리를 하는데 저희는 Teams로 알람을 보내는 로직을 수행하는 역할입니다.

아래는 장점에 대한 설명을 적어봤습니다.

  • 비동기 처리
    • 요청을 바로 응답하고, 실제 작업은 큐에 맡겨서 뒤에서 처리.
    • 사용자/클라이언트는 대기 시간이 줄고, 시스템 응답성이 올라감.
    • 발신자는 작업을 큐에 넣고 다음 작업 수행이 바로 가능하기 때문에 높은 처리 속도 확보가 가능
  • 시스템 간 결합도 감소 (Decoupling)
    • 발신자(Producer)와 수신자(Consumer)가 직접 연결되지 않고, 큐를 통해 간접적으로 통신. (의존성 down)
  • 부하 완화 & 안정성
    • 갑자기 알람 트래픽이 몰려도 큐가 완충 역할을 해줌.
    • Consumer가 처리 가능한 속도만큼만 꺼내 처리하므로 서버 과부하 방지.
  • 확장성 (Scalability)
    • 부하량이 많아지면 Consumer(알람 처리 워커)를 여러 대 수평 확장하며 병렬 처리 가능.
    • 부하량에 따라 워커 수를 유연하게 조정할 수 있음.
  • 신뢰성 (Reliability)
    • 메시지는 큐에 보관되므로 일시적으로 Consumer가 죽어도 손실 없이 재처리 가능.
    • 장애 발생 시에도 알람 누락 확률을 최소화.

3.2 아키텍쳐 설계 1 : RabbitMQ 기반

위 요구사항을 모두 만족하며 현재 서버 환경 fit 한 방식으로 설계하기위한 MQ를 선정해보았는데 바로 RabbitMQ였습니다. 이유는 다음과 같습니다.

  • 내부 사정에 따라 워커 서버 증설이 불가능한 상황에 Server 2에 같이 실행중인 Batch 인스턴스를 소비자(Consumer)로 사용하며 MQ 또한 Server 2에 구동하기로 결정
  • 하지만 이미 2개의 인스턴스(Was, Batch) + MQ까지 구동시키면 메모리 안정성 확보가 중요하기 때문에 메모리가 아닌 Disk 기반의 MQ 기능 사용 가능하며 Spring 친화적이고 편리한 대시보드 사용이 용이한 이유 등 여러가지 조건으로 RabbitMQ를 채택 ( 이 외 다른 이유는 생략 하겠습니다.)

알람 시스템 아키텍쳐 설계

구현

  • [발신자:WAS서버] RabbitMQ 큐로 메시지 전송
    • 메시지는 DTO 객체(AlertMessage)를 통해 전송하되, Spring AMQP 내부에서 Jackson2JsonMessageConverter를 사용하여 메시지를 JSON 형식으로 자동 변환해서, 서로 DTO 객체로 주고받을 수 있도록 설정
      • 예시
        • 송신자 : DTO → JSON 문자열 (직렬화)
        • 수신자 : JSON → DTO 객체 (역직렬화)
    • 알람 전송 로그 데이터 저장
      • 기본 PENDING 상태이며 배치서버에서 팀즈알람 전송 후 상태 변경(성공:SUCCESS/실패:FAIL)
    • 알람 전송 프로세스 구간
      • 업무 프로세스 구간 4구간 지정 하여 모듈화된 메시지 전송 함수를 각 프로세스 로직에 반영
        • 64명에게 원하는 알람에 대한 설문조사 진행 후 해당 구간에 알람 선 반영
        • 추후 파일럿 테스트 이후 전 구간 알람 적용
  • [수신부:배치서버] 알람 발신 로직 개발 - 예외/재시도 로직 구현
    • RabbitMQ기본 설정
      @Configuration
      public class RabbitConfig {
          public static final String QUEUE_NAME = "teams.notify.queue";
          @Bean
      	public Queue lazyQueue() {
      	    return QueueBuilder.durable(QUEUE_NAME)
      	        .withArgument("x-queue-mode", "lazy")
      	        .withArgument("x-max-length", 100000)       // 최대 10만개
      	        .withArgument("x-overflow", "reject-publish") // 넘치면 메시지 거절
      	        .build();
      	}
          // 리스터에서 컨버터 사용하도록
          @Bean
          public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory,
                                                                                     MessageConverter messageConverter) {
              SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
              
              factory.setMissingQueuesFatal(false); // 큐가 없거나 접속 실패해도 앱 전체 죽지 않음
              factory.setConnectionFactory(connectionFactory);
      
              factory.setMessageConverter(messageConverter);
              
              factory.setAcknowledgeMode(AcknowledgeMode.MANUAL); //수동 ACK 모드
              factory.setPrefetchCount(1); // 리스너에서 한번에 기본 250씩 가져오는데 딱 1개만 가져와서 처리하고, 끝나면 다음꺼 하도록
              factory.setConcurrentConsumers(1); //리스너에서 동시에 돌아가는 쓰레드 수 1개로 제한
              return factory;
          }
      
          // 객체로 메시지 받을때 타입이 송신자 쪽 타입으로 넘어오기 때문에 수신자 쪽 객체로 받도록 명시
          @Bean
          public MessageConverter messageConverter() {
              Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
              
              // 선택사항: TypeId -> 클래스 매핑
              Map<String, Class<?>> typeMapper = new HashMap<>();
              typeMapper.put("com.sbs.derp.teams.dto.AlertMessageToMQ", AlertMessageFromMQ.class);
              converter.setClassMapper(new DefaultClassMapper() {{
                  setIdClassMapping(typeMapper);
              }});
              
              return converter;
          }
      }
      • x-queue-mode 를 lazy로 설정 해야 큐 메시지를 메모리가 아닌 Disk에 두고 사용
      • 백프레셔 구현을 위해 최대 100000개 제한을 설정
      • 팩토리 설정에서 한번에 하나의 메시지만 가져오는 이유는 팀즈 API의 사용이 1초당 1번으로 제한되기 때문에 쓰레드와 FetchCount를 1로 설정
    • 리스너
      @Component
      public class MessageReceiver {
      
      	@Autowired
      	private TeamsService teamsService;
      	
          @RabbitListener(queues = RabbitConfig.QUEUE_NAME)
          public void handleMessage(
          		AlertMessageFromMQ message, //RabbitMQ의 연결 세션 (수동 ACK/NACK 보낼 때 사용)
          		Channel channel,
                  @Header(AmqpHeaders.DELIVERY_TAG) long tag // 메시지 고유 ID (이걸로 어떤 메시지를 ack/nack 할지 지정)
          		) throws IOException {
          	
              String token = teamsService.getDelegateAccessToken(false);
              
              // 여기서 실제 알림 전송, 로그 저장, DB 처리 등 원하는 작업 실행
              // 메시지 전송 API 호출
              HttpResponseWithHeader response;
      		try {
      			// 보내기
      			System.out.println(message.toString());
      			response = GraphAPIUtil.sendMessageWithMentionAndAdaptiveCard(token, message);
      			
      			int statusCode = response.getStatusCode();
      	        String time = java.time.LocalTime.now().toString();
      	        
      	        if (statusCode == 201) {
      	            // 성공하면 뭘할까?
      	        	channel.basicAck(tag, false); // -> ACK
      	        	// LOG 성공으로 변경
      	        	try {
      	        		teamsService.updateAlarmLogSuccessStatus(message.getId());
      	        	}catch(Exception e) {
      	        		//log 상태 변경 실패
      	        	}
      	        	
      	        } else if (statusCode == 429) {
      	            // 429가 발생한 경우, Retry-After 값을 읽어 그 시간만큼 대기
      	            String retryAfterHeader = Optional.ofNullable(response.getHeaders().get("Retry-After")).orElse("1");
      	            
      	            long retryAfterSeconds;
      	            try {
      	                retryAfterSeconds = Long.parseLong(retryAfterHeader);
      	            } catch (NumberFormatException ex) {
      	                retryAfterSeconds = 1; // 기본값 1초
      	            }
      	            System.out.println("[" + time + "] 429 Too Many Requests 수신. Retry-After: " + retryAfterSeconds + "초 대기합니다.");
      	            // 로그에 대기 시간을 기록 (원하는 경우 log 리스트에 추가할 수 있음)
      	            
      	            // 지정된 대기 시간만큼 sleep한 후 재시도 (현재 반복문의 끝으로 이동하여 다시 전송) + 1초 추가 
      	            Thread.sleep(retryAfterSeconds * 1000 + 1000);
      
      	            channel.basicNack(tag, false, true);
      	        }else {
      	        	System.out.println("[" + time + "] 응답 코드: " + statusCode + "\n" + response.getBody());
      	        }
      			
      		} catch (Exception e) {
      			// 여기는 실패한거라 Log로 저장
      			teamsService.updateAlarmLogFailStatus(message.getId());
      			// 메시지는 처리 완료된 것으로 간주하고 제거
      			System.out.println("에러남 :" + e);
      	        channel.basicAck(tag, false);
      		}
          }
      }
      • RabbitMQ에 메시지 수신 시 dequeue하여 해당 내용대로 Teams API 호출
      • Teams API 호출 결과에 따라 다음과 같이 분기 처리:
        • ✅ 201: 성공 시 수동 ACK
        • ⚠️ 429 Too Many Requests: Retry-After 헤더 기반으로 Thread.sleep 후 retryCount를 증가시켜 재전송 (max 3회)
        • ❌ 기타 에러(예: 404, 토큰 문제 등): 재시도 없이 DB 로그 또는 로그 수집만 진행
      • retryCount가 메시지 객체에 포함되며, 재전송 시 직접 증가 후 다시 큐에 넣는 방식 (max 3회까지만)
      • 모든 메시지 처리 후에는 channel.basicAck(...) 또는 basicNack(...)을 명확히 구분하여 리소스 누수/중복 방지 (ACK 처리) → 수신부가 메시지를 받고 제대로 처리 끝냈음을 MQ에 알려줌
      • 메시지 전송 처리 후 로그 데이터 상태 변경 (성공:SUCCESS/실패:FAIL)
      • 모든 작업내용 테스트 완료

종합

RabbitMQ를 사용한 위 아키텍쳐 설계는 확장성에 큰 강점을 가진 설계로 사용 가능하며 아래와 같은 장점을 가질 수 있었습니다.

  • 알람발송 전용 워커 서버를 별도로 구동하기 때문에 각 서버별 역할 분담이 확실히 되어 확장성과 안정성을 확보할 수 있음
  • Teams Graph API 사용을 위한 Access Token을 알람을 전송하는 워커서버에서만 관리 되어 별도의 키 중복 사용 방지 로직을 처리 하지 않아도 됨

하지만 서버 2 전체 다운 시 알람 시스템 작동이 불가능하다는 내부 결론으로 기각 되었습니다.

3.3 아키텍쳐 설계 2 : 내부 큐

[아키텍쳐 설계 1]은 서버 2가 전체 다운 시 알람 시스템 작동이 안되는 문제로 각 운영서버 별 큐를 별도로 두어 서버 당 발신자, MQ, 수신부를 모두 가지는 형태로 설계하였습니다.

알람 시스템 아키텍쳐 설계

  • 각각의 운영 서버에서 Java에서 제공하는 LinkedBlockingDeque로 MQ를 구현하였으며 리스너는 [설계1]에서의 리스너와 동일한 로직으로 Teams 알람을 보내도록 구현
  • 다만 팀즈 알람을 보내는 리스너가 2개 이며 1개의 AcceessToken 공유하게 되므로 사용,갱신에 충돌이 없게 하기 위해 비관적 잠금 방식을 사용하여 AccessToken 무결성을 보장(DB 선점)

구현

  1. MQ 초기화

    @Service
    public class TeamsService {
    //생략
    		// 1. 메시지 큐 생성 (최대 10,000건 제한)
        private final LinkedBlockingDeque<AlertMessageToMQ> queue = new LinkedBlockingDeque<>(10_000);
    
    //생략
    }
    • 들어온 순서대로 처리를 보장하는 LinkedBlockingDeque 사용하며 백프레셔를 위한 10000 개 설정
  2. 리스너 시작

    		// 2. 쓰레드 풀로 리스너 1~2개 돌리기
        private final ExecutorService workerExecutor = Executors.newSingleThreadExecutor();
        // 3. 서버 시작 시 리스너 등록
        @PostConstruct
        public void startConsumer() {
            // 워커 시작
            workerExecutor.submit(new TeamsMessageConsumer());
        }
        // 4. 소비자 (리스너 1개만)
        private class TeamsMessageConsumer implements Runnable {
            @Override
            public void run() {
                while (true) {
                    try {
                    	AlertMessageToMQ msg = queue.take(); // 큐 비어있으면 block
                        // API로 메시지 보내기
                    	sendMessageToTeams(msg);
                        // Thread.sleep(1000); // 초당 1건 제한
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    • Thread 한개를 할당하여 지속적으로 MQ의 메시지를 확인 및 처리하는 리스너 설정
  3. MQ에 메시지 넣기

    		/**
         * 2. queue에 메시지 넣기 
         * @param 여러가지
         * @return void
         * @throws RuntimeException 토큰 갱신 실패 시
         */
        public void sendMessageToMQ(NoticeDto notiDto) {
        	try {
        		// 수신자 정보 가져오기(팀즈 아이디, 등등)
    	    	TeamsUserDTO toEmpInfo = teamsDao.selectTeamsUserInfoByEmpId(notiDto.getEmpId());
    	    	// 팀즈 연동 정보가 없는경우
    	    	if(toEmpInfo == null) throw new DmsException("팀즈 연동 정보가 없습니다.");
    	    	
    	    	//알람 LOG 저장
    	    	DmsTeamsAlarmLogDto alarmLog = new DmsTeamsAlarmLogDto();
    	    	alarmLog.setFromEmpId(notiDto.getFirEmpNo());
    	    	alarmLog.setToEmpId(notiDto.getEmpId());
    	    	alarmLog.setSendStatus("PENDING");
    	    	alarmLog.setNoticeId(notiDto.getNoticeId());
    	    	teamsDao.insertTeamsAlarmLog(alarmLog);
    	    	
    	    	// 큐에 넣을 메시지객체 생성
    	    	AlertMessageToMQ msgToMQ = new AlertMessageToMQ();
    	    	msgToMQ.setEmpId(notiDto.getEmpId());
    	    	msgToMQ.setUserId(toEmpInfo.getTeamsUserId());
    	    	msgToMQ.setDisplayName(toEmpInfo.getDisplayName());
    	    	msgToMQ.setChatId(toEmpInfo.getChatId());
    	    	msgToMQ.setMessage(notiDto.getMsg());
    	    	msgToMQ.setCardUrl("https://dms.asungcorp.com/sub1/sub?targetProgramId=" + notiDto.getProgramId() + "&reqId=" + notiDto.getReqId() + "&noticeId=" + notiDto.getNoticeId());
    	    	msgToMQ.setLogId(alarmLog.getId());
    	    	
    	    	// 큐에 알람 내용 넣기
    	    	try {
    	    		// 큐가 꽉차면 에러 로그 남기고 취소 시키기
    	    		if (!queue.offer(msgToMQ)) {
    	    			throw new Exception("큐가 가득 차서 메시지 삽입 실패: logId=" + msgToMQ.getLogId());
    	    		}
    	    	}catch(Exception e) {
    	    		alarmLog.setSendStatus("QUEUE_FAILED");
    	    		teamsDao.updateTeamsAlarmLog(alarmLog);
    	    		throw new RuntimeException("큐 전송 중 예외 발생", e);
    	    	}
    	    	
        	}catch(Exception e) { //모든 에러 로그 남기기
        		logger.error(String.format("sendMessageToMQ 실패: reqId=%s, toEmpId=%s, sceneKey=%s, message=%s", 
        			notiDto.getReqId(), notiDto.getEmpId(), notiDto.getNotiTypCd(), e.getMessage()), e);
        	}
        }
    • 팀즈 정보가 없는 유저는 MQ에 데이터 넣기 전 오류
      → 팀즈로 알람을 보낼 준비가 된 메시지만 MQ에 저장
    • Teams의 ActiveCard 클릭 시 ERP의 특정 프로그램, 페이지로 이동하기 위한 인자를 담은 url 같이 MQ에 포함 하기
    • 큐가 꽉 차면 취소하며 더 이상 담지 않기
    • 모든 에러 로그는 최종 catch에서 처리하며 관련 로그를 모두 저장하도록 logback.xml로 설정 처리하여 알람 관련 에러로그 확인 가능
  4. Access Token 사용 및 갱신 시 비관적 잠금

    		@Transactional
        public String getDelegateAccessToken(boolean reissueYn) throws IOException {
            // DB에서 해당 TEAMS_ACCOUNT_ID의 토큰 정보를 조회함
            DmsTeamsApiKeyDTO tokenDTO = teamsDao.selectByTeamsAccountName(TEAMS_ACCOUNT_NAME);
            if (tokenDTO == null) {
                throw new IllegalStateException("Token record not found for " + TEAMS_ACCOUNT_NAME);
            }
            /* 생략 */
            // 만료된 토큰이거나 재발급 요청이면
            if (now > tokenExpire || reissueYn) {
                
                /* 갱신작업 */
            }
            // 만료되지 않은 경우 기존 delegate 토큰 반환
            return tokenDTO.getDelegateAccessToken();
        }
    • Access Token 공유하며 무결성 유지를 위한 장치로 DB 선점 방식의 비관적 잠금을 사용하며 이때 Transactional 단위로 잠금이 진행되므로 메서드에 반드시 @Transactional 을 필요
    • Access Token을 가져오기 위한 Select 문에서도 반드시 ‘FOR UPDATE‘ 를 마지막에 붙여 DB 선점의 시작 구간을 지정 해야 함
  5. 리스너에서 메시지 처리

        /**
         * 3. 팀즈로 알림 보내기(Queue에서 뺴낸 메시지) 
         * @param AlertMessageMQ
         * @return void
         * @throws InterruptedException 
         * @throws RuntimeException 토큰 갱신 실패 시
         */
        public void sendMessageToTeams(AlertMessageToMQ message) throws IOException, InterruptedException {
    
            // 여기서 실제 알림 전송, 로그 저장, DB 처리 등 원하는 작업 실행
            // 메시지 전송 API 호출
            HttpResponseWithHeader response;
    		try {
    			String accessToken = this.getDelegateAccessToken(false);
    			
    			//chatId가 없으면 chatID 가져와서 넣기
    			
    			// 생략
    			
    			// 보내기
    			response = GraphAPIUtil.sendMessageWithMentionAndAdaptiveCard(accessToken, message);
    			
    			int statusCode = response.getStatusCode();
    	        String time = java.time.LocalTime.now().toString();
    	        
    	        if (statusCode == 201) {
    	        	// LOG 성공으로 변경
    	        	try {
    	        		teamsDao.updateAlarmLogSuccessStatus(message.getLogId());
    	        	}catch(Exception e) {
    	        		//log 상태 변경 실패
    	        		logger.error(String.format("로그상태 성공 변경 실패: logId=%s", message.getLogId(), e.getMessage()), e);
    	        	}
    	        	
    	        } else if (statusCode == 429) {
    	            // 429가 발생한 경우, Retry-After 값을 읽어 그 시간만큼 대기
    	            String retryAfterHeader = Optional.ofNullable(response.getHeaders().get("Retry-After")).orElse("1");
    	            
    	            long retryAfterSeconds;
    	            try {
    	                retryAfterSeconds = Long.parseLong(retryAfterHeader);
    	            } catch (NumberFormatException ex) {
    	                retryAfterSeconds = 1; // 기본값 1초
    	            }
    	            logger.error("[" + time + "] 429 Too Many Requests 수신. Retry-After: " + retryAfterSeconds + "초 대기합니다.");
    	            // 로그에 대기 시간을 기록 (원하는 경우 log 리스트에 추가할 수 있음)
    	            // 지정된 대기 시간만큼 sleep한 후 재시도 (현재 반복문의 끝으로 이동하여 다시 전송) + 1초 추가 
    	            Thread.sleep(retryAfterSeconds * 1000 + 1000);
    	            
    	            // 큐 제일 앞에 다시 넣기
    	            queue.putFirst(message);
    	            
    	            //channel.basicNack(tag, false, true);
    	        }else {
    	        	logger.error("[" + time + "] 응답 코드: " + statusCode + "\n" + response.getBody());
    	        	// 성공, 딜레이 가 아닌 모든 케이스는 오류 발생
    	        	throw new HttpResponseException(statusCode, time);
    	        }
    			
    		} catch (Exception e) {
    			// 여기는 실패한거라 Log로 저장
    			logger.error(String.format("팀즈 알람 전송 실패!! : logId=%s", message.getLogId(), e.getMessage()), e);
    			
    			teamsDao.updateAlarmLogFailStatus(message.getLogId()); // 큐가 꽉찰때 아래 코드가 무한 대기 할 수 있어서 먼저 실행
    			int sendCnt = teamsDao.selectSendCountById(message.getLogId()); // ← SEND_CNT 조회
    			
    	        if (sendCnt < 3) {
    	            logger.error(String.format("전송 재시도: logId=%s (현재 시도: %d회)", message.getLogId(), sendCnt));
    	            queue.putFirst(message); // 큐 맨 앞에 다시 넣기
    	        } else {	        	
    	            logger.error(String.format("최대 재시도 초과: logId=%s (시도: %d회)", message.getLogId(), sendCnt));
    	        }
    		}
        }
    • Graph API를 통해 Teams알람을 보내기 전 항상 AccessToken 확인 후 미갱신 시 바로 갱신
    • 알람을 보낸 후 전송 성공할 경우 로그 상태를 ‘SUCCESS’로 변경
    • 발송 제한에 대한 쓰로틀링 핸들링의 경우 1초 대기 후 바로 ReQueue하여 리스너 에서 바로 재 시도 할 수 있도록 처리
    • 이 외 오류의 경우 최대 3번까지 시도하며 모든 에러 로그 저장 → 모든 에러 로그는 LogId를 통해 추적 가능

종합

사내 메신저를 Teams로 도입하며 기존 문제였던 알람 체계를 개선해 볼 수 있는 기회를 얻어 많은 것을 배우고 얻은 경험 이였습니다.

낮은 수준의 요구사항으로 정형적인 구조의 아키텍처를 완성하진 못하고, 미들웨어 서버 도입이 채택되지 못했지만 그 과정에서 비동기 메시지 아키텍처, RabbitMQ 기반 MQ 설계, 비관적 잠금 적용, 기능별 로그 수집·운영 등 안정적 서비스를 위한 여러 요소들을 공부하고 경험할 수 있었습니다.

이번 계기로 더욱 안정적인 서비스와 고가용성을 위한 방법들에 관심이 생겼으며, 앞으로 더욱 사용자 갗와 와 향 후 확장성을 고려한 개발을 해야겠다고 마음먹었습니다.
마지막으로, 우리 회사도 현상 유지만이 아닌 유지보수와 확장성, 고가용성을 갖춘 설계와 개발을 목표로 일 하는 날이 오기를 바라며 이 글을 마치겠습니다.

0개의 댓글