기존 메신저의 노후화와 다양한 기능의 요구로 새로운 메신저를 MS Teams로 도입하게 됐습니다.
문제는 사내 결재 문서 관련해서 사용하던 기존 메신저의 알림 기능이 Teams에서는 기본 제공하지 않아 서드파티 앱을 구매해야 사용 할 수 있었습니다. (구입 시 천만원 단위의 솔루션 비용 발생)
회사 내부 중요 과제인 비용절감과 더불어 알람체계 구축 시 제가 관리하는 ERP에도 도입하여 기존 알람체계의 문제를 해결하고 사용자에게 편의성과 업무 효율성 증대를 할 수 있을 것으로 기대했기 때문에 MS Teams를 이용한 Push알람 시스템 구축을 결정하였습니다.
ERP에 도입 계획을 세운 이유를 자세하게 설명하자면 아래와 같습니다.
기존 ERP 시스템에서는 업무 알림 기능이 일반적인 알람이 아닌 To-Do 리스트 형식에 가까운 구조로 구현되어 있어 다음과 같은 문제점이 발생함:
- 알림이 단순한 알림이 아니라, 해당 업무가 완료되기 전까지 사라지지 않아 일반 알람 기능처럼 활용하기 어려움
- 알림 데이터를 전용 알림 테이블이 아닌, 각 업무별 DB에서 직접 가져오는 방식으로 인해 로직이 복잡하고, 유지보수가 어려움
- 실시간 알림 구조가 아니며, 브라우저를 새로 고침하거나 재로그인해야 알림이 갱신되어, 실시간성이 떨어짐
추가로 구축 된 알람 시스템으로 좋은 성과를 추린다면 이를 문서화 해 각 IT 부서에 공유할 계획을 수립했습니다.
공식 문서에서 확인 가능하므로 따로 기술은 생략하며 알림 구현에 대한 내용만 따로 필요하신 분이 있다면 말씀 주시기 바랍니다.
설계한 아키텍쳐를 설명하기 전 내부 서버 환경과 요구사항에 대한 설명이 필요할 것 같아 아래와 같이 정리해보았습니다. 왜 이런 아키텍쳐를 설계 했을까에 대한 부연 설명될 것 같습니다.

MQ를 사용한 비동기 아키텍쳐에 대한 주요 특징과 장점을 간단하게 정리해보았습니다.
이 아키텍쳐의 구성은 발신자(Producer), 메시지 큐(MQ), 수신자(Consumer)로 이루어져 있으며
이벤트가 발생하는 즉, 알람이 발생하는 운영 WAS에서 발신자 역할로 메시지를 MQ에 넣습니다.
수신자는 별도의 워커 서버로, MQ에 있는 메시지를 수신해 메시지 처리를 하는데 저희는 Teams로 알람을 보내는 로직을 수행하는 역할입니다.
아래는 장점에 대한 설명을 적어봤습니다.
위 요구사항을 모두 만족하며 현재 서버 환경 fit 한 방식으로 설계하기위한 MQ를 선정해보았는데 바로 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;
}
}@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를 사용한 위 아키텍쳐 설계는 확장성에 큰 강점을 가진 설계로 사용 가능하며 아래와 같은 장점을 가질 수 있었습니다.
하지만 서버 2 전체 다운 시 알람 시스템 작동이 불가능하다는 내부 결론으로 기각 되었습니다.
[아키텍쳐 설계 1]은 서버 2가 전체 다운 시 알람 시스템 작동이 안되는 문제로 각 운영서버 별 큐를 별도로 두어 서버 당 발신자, MQ, 수신부를 모두 가지는 형태로 설계하였습니다.

MQ 초기화
@Service
public class TeamsService {
//생략
// 1. 메시지 큐 생성 (최대 10,000건 제한)
private final LinkedBlockingDeque<AlertMessageToMQ> queue = new LinkedBlockingDeque<>(10_000);
//생략
}
리스너 시작
// 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();
}
}
}
}
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() + "¬iceId=" + 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);
}
}
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();
}
리스너에서 메시지 처리
/**
* 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));
}
}
}
사내 메신저를 Teams로 도입하며 기존 문제였던 알람 체계를 개선해 볼 수 있는 기회를 얻어 많은 것을 배우고 얻은 경험 이였습니다.
낮은 수준의 요구사항으로 정형적인 구조의 아키텍처를 완성하진 못하고, 미들웨어 서버 도입이 채택되지 못했지만 그 과정에서 비동기 메시지 아키텍처, RabbitMQ 기반 MQ 설계, 비관적 잠금 적용, 기능별 로그 수집·운영 등 안정적 서비스를 위한 여러 요소들을 공부하고 경험할 수 있었습니다.
이번 계기로 더욱 안정적인 서비스와 고가용성을 위한 방법들에 관심이 생겼으며, 앞으로 더욱 사용자 갗와 와 향 후 확장성을 고려한 개발을 해야겠다고 마음먹었습니다.
마지막으로, 우리 회사도 현상 유지만이 아닌 유지보수와 확장성, 고가용성을 갖춘 설계와 개발을 목표로 일 하는 날이 오기를 바라며 이 글을 마치겠습니다.