feat: 텔레그램 알림 봇 연동 — 학생 가입·과제·코딩 제출을 멘토에게 즉시 알림

- TelegramNotifier: 봇 API sendMessage 호출. 두 가지 원칙 —
  · 알림 실패가 본 기능(가입·제출)을 절대 막지 않는다: 예외 전부 삼킴 + @Async 비동기
  · 시크릿은 env로만: TELEGRAM_BOT_TOKEN/CHAT_ID 비어 있으면 기능이 조용히 꺼짐(로컬 무설정 OK)
  · 에러 로그에 예외 종류만 남김 — HTTP 에러 메시지의 URL에 토큰이 포함되는 유출 경로 차단
- AsyncConfig: @EnableAsync (없으면 @Async가 조용히 무시되는 함정 문서화)
- 연결 지점 3곳: AuthService.signup(가입) / CodingService.submit(코딩) / SubmissionService(과제)
- 테스트: 생성자에 mock 주입 + 가입 시 알림 발송 검증 1건 추가 → 전체 17개 통과

운영 검증: 봇(@awesomedevmirim_bot)→'2026 현장실습 사전 과제' 그룹 발송 확인,
실가입 E2E로 알림 발송 확인. 토큰은 서버 .env에만 존재(git 미포함).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AWESOMEDEV 2026-07-17 10:09:52 +09:00
parent 0b6c2a15a7
commit a52999d81d
9 changed files with 155 additions and 10 deletions

View File

@ -0,0 +1,18 @@
package dev.awesomedev.mirim.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* 파일이 하는 :
* @Async 애노테이션을 켠다(@EnableAsync가 없으면 @Async는 그냥 무시된다 조용히!).
*
* 학습 포인트: 스프링의 많은 기능이 이렇게 "애노테이션 + Enable 스위치" 짝으로 있다.
* @Async/@EnableAsync, @Scheduled/@EnableScheduling, @Cacheable/@EnableCaching
* 스위치를 빼먹으면 에러도 없이 기능만 조용히 돌아서, 오래 헤매게 되는 함정이다.
* 현재 @Async를 쓰는 : TelegramNotifier(알림 전송을 요청 처리와 분리).
*/
@Configuration
@EnableAsync
public class AsyncConfig {
}

View File

@ -26,13 +26,16 @@ public class AuthService {
private final UserRepository userRepository;
private final StudentProfileRepository studentProfileRepository;
private final PasswordEncoder passwordEncoder;
private final TelegramNotifier telegramNotifier;
public AuthService(UserRepository userRepository,
StudentProfileRepository studentProfileRepository,
PasswordEncoder passwordEncoder) {
PasswordEncoder passwordEncoder,
TelegramNotifier telegramNotifier) {
this.userRepository = userRepository;
this.studentProfileRepository = studentProfileRepository;
this.passwordEncoder = passwordEncoder;
this.telegramNotifier = telegramNotifier;
}
/**
@ -103,6 +106,11 @@ public class AuthService {
req.emergencyName(), req.emergencyRelation(), req.emergencyPhone(),
java.time.Instant.now()));
// 멘토에게 텔레그램 알림. 실패해도 가입은 성공한다(TelegramNotifier가 예외를 삼킨다).
telegramNotifier.send("🎓 새 수습생 가입\n"
+ user.getName() + " (" + ("DESIGN".equals(user.getTrack()) ? "디자인" : "개발") + " 트랙)\n"
+ req.companyEmailLocal() + "@" + StudentProfile.COMPANY_DOMAIN);
return user;
}

View File

@ -25,11 +25,14 @@ public class CodingService {
private final CodingProblemRepository codingProblemRepository;
private final CodingSubmissionRepository codingSubmissionRepository;
private final TelegramNotifier telegramNotifier;
public CodingService(CodingProblemRepository codingProblemRepository,
CodingSubmissionRepository codingSubmissionRepository) {
CodingSubmissionRepository codingSubmissionRepository,
TelegramNotifier telegramNotifier) {
this.codingProblemRepository = codingProblemRepository;
this.codingSubmissionRepository = codingSubmissionRepository;
this.telegramNotifier = telegramNotifier;
}
/** 전체 문제 목록 (정렬 순서대로) */
@ -81,13 +84,23 @@ public class CodingService {
Integer total = problem.isAutoGraded() ? totalCount : null;
String results = problem.isAutoGraded() ? resultsJson : null;
return codingSubmissionRepository.findByProblemAndUser(problem, user)
CodingSubmission submission = codingSubmissionRepository.findByProblemAndUser(problem, user)
.map(existing -> {
existing.submit(code, passed, total, results);
return existing;
})
.orElseGet(() -> codingSubmissionRepository.save(
new CodingSubmission(problem, user, code, passed, total, results)));
// 멘토에게 텔레그램 알림 (실패해도 제출은 성공)
String grading = problem.isAutoGraded()
? submission.getPassedCount() + "/" + submission.getTotalCount() + " 통과"
: "멘토 리뷰 대기";
telegramNotifier.send("💻 코딩 문제 제출\n"
+ user.getName() + " — [" + problem.getLanguage() + "] " + problem.getTitle()
+ "\n" + grading + " (" + submission.getAttempts() + "번째 시도)");
return submission;
}
/** 멘토 피드백 등록 — 없으면 404 */

View File

@ -23,11 +23,14 @@ public class SubmissionService {
private final SubmissionRepository submissionRepository;
private final AssignmentRepository assignmentRepository;
private final TelegramNotifier telegramNotifier;
public SubmissionService(SubmissionRepository submissionRepository,
AssignmentRepository assignmentRepository) {
AssignmentRepository assignmentRepository,
TelegramNotifier telegramNotifier) {
this.submissionRepository = submissionRepository;
this.assignmentRepository = assignmentRepository;
this.telegramNotifier = telegramNotifier;
}
/**
@ -46,12 +49,21 @@ public class SubmissionService {
HttpStatus.NOT_FOUND, "과제를 찾을 수 없습니다: " + assignmentId));
Optional<Submission> existing = submissionRepository.findByAssignmentAndUser(assignment, user);
if (existing.isPresent()) {
Submission submission = existing.get();
Submission submission;
boolean isResubmit = existing.isPresent();
if (isResubmit) {
submission = existing.get();
submission.resubmit(content, link);
return submission; // 학습 포인트: 트랜잭션 안에서 엔티티를 바꾸면 JPA가 커밋 자동으로 UPDATE 한다(더티 체킹).
// 학습 포인트: 트랜잭션 안에서 엔티티를 바꾸면 JPA가 커밋 자동으로 UPDATE 한다(더티 체킹).
} else {
submission = submissionRepository.save(new Submission(assignment, user, content, link));
}
return submissionRepository.save(new Submission(assignment, user, content, link));
// 멘토에게 텔레그램 알림 (실패해도 제출은 성공)
telegramNotifier.send("📝 과제 " + (isResubmit ? "재제출" : "제출") + "\n"
+ user.getName() + "" + assignment.getTitle());
return submission;
}
/** 내 제출물을 최신순으로 조회한다. */

View File

@ -0,0 +1,78 @@
package dev.awesomedev.mirim.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import java.util.Map;
/**
* 파일이 하는 :
* 텔레그램 봇으로 멘토(대표)에게 알림을 보낸다 학생 가입, 과제·코딩 제출 .
*
* 학습 포인트 외부 API 연동의 원칙: "부가 기능이 본 기능을 죽이면 안 된다".
* 알림은 있으면 좋은 (nice to have)이고, 가입·제출은 반드시 돼야 하는 것이다.
* 텔레그램이 점검 중이라고 학생이 가입을 하면 주객전도다. 그래서
* (1) 모든 예외를 여기서 삼키고(로그만 남긴다), (2) @Async로 스레드에서 보낸다
* 텔레그램 응답이 늦어도 학생의 요청 처리 시간에는 영향이 없다.
*
* 학습 포인트 시크릿은 코드가 아니라 환경변수에.
* 토큰이 코드나 git에 들어가면 저장소를 보는 모든 사람이 봇을 탈취할 있다.
* 토큰은 운영 서버의 .env 파일에만 있고(TELEGRAM_BOT_TOKEN), 값이 비어 있으면
* 클래스는 조용히 아무것도 한다 로컬 개발에서 토큰 없이도 앱이 그대로 돈다.
*/
@Service
public class TelegramNotifier {
private static final Logger log = LoggerFactory.getLogger(TelegramNotifier.class);
private final String botToken;
private final String chatId;
// RestClient: 스프링의 현대식 HTTP 클라이언트 (RestTemplate의 후속).
private final RestClient restClient = RestClient.create();
public TelegramNotifier(@Value("${telegram.bot-token:}") String botToken,
@Value("${telegram.chat-id:}") String chatId) {
this.botToken = botToken;
this.chatId = chatId;
if (!isEnabled()) {
log.info("텔레그램 알림 비활성 상태입니다 (TELEGRAM_BOT_TOKEN/CHAT_ID 미설정).");
}
}
private boolean isEnabled() {
return !botToken.isBlank() && !chatId.isBlank();
}
/**
* 알림 건을 보낸다. 실패해도 예외를 밖으로 던지지 않는다.
*
* 학습 포인트: @Async 메서드는 호출한 요청 스레드가 아니라 별도 스레드에서 돈다.
* 호출한 쪽은 기다리지 않고 바로 다음 줄로 넘어간다(fire-and-forget).
* 주의: @Async는 "다른 빈에서 호출할 때" 동작한다. 같은 클래스 안에서
* this.send() 부르면 프록시를 거쳐서 그냥 동기 실행된다 스프링 AOP의 대표 함정.
*/
@Async
public void send(String text) {
if (!isEnabled()) {
return;
}
try {
restClient.post()
.uri("https://api.telegram.org/bot{token}/sendMessage", botToken)
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("chat_id", chatId, "text", text))
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
// 학습 포인트: e.getMessage() 그대로 찍지 않는다 HTTP 에러 메시지에는
// 요청 URL이 포함되는데, 우리 URL에는 토큰이 들어 있다.
// "로그에 시크릿이 새는" 흔한 경로라서, 예외의 종류만 남긴다.
log.warn("텔레그램 알림 전송 실패(무시하고 계속): {}", e.getClass().getSimpleName());
}
}
}

View File

@ -36,6 +36,14 @@ server:
secure: ${COOKIE_SECURE:false} # HTTPS로만 쿠키 전송(운영은 true). 로컬(http)은 false여야 로그인됨
same-site: strict # 다른 사이트에서 온 요청엔 쿠키를 안 붙인다 → CSRF 방어의 핵심
# 텔레그램 알림 (TelegramNotifier) — 학생 가입·제출을 멘토 텔레그램으로.
# 학습 포인트: 둘 다 비어 있으면 알림 기능이 통째로 꺼진다. 로컬 개발에선 아무것도
# 설정하지 않아도 앱이 그대로 돈다 — "설정이 없으면 기능이 꺼지는" 안전한 기본값 설계.
# 실제 토큰은 운영 서버 .env에만 둔다(절대 git에 넣지 않는다!).
telegram:
bot-token: ${TELEGRAM_BOT_TOKEN:}
chat-id: ${TELEGRAM_CHAT_ID:}
logging:
level:
# 학습 포인트: 개발은 DEBUG로 자세히, 운영은 INFO로. 로그도 자원이고, 과한 로그는 비용·노이즈다.

View File

@ -34,6 +34,7 @@ class AuthServiceTest {
private UserRepository userRepository;
private StudentProfileRepository studentProfileRepository;
private PasswordEncoder passwordEncoder;
private TelegramNotifier telegramNotifier;
private AuthService authService;
@BeforeEach
@ -41,7 +42,8 @@ class AuthServiceTest {
userRepository = mock(UserRepository.class);
studentProfileRepository = mock(StudentProfileRepository.class);
passwordEncoder = new BCryptPasswordEncoder(); // 진짜 인코더
authService = new AuthService(userRepository, studentProfileRepository, passwordEncoder);
telegramNotifier = mock(TelegramNotifier.class); // 테스트에선 진짜 알림을 보내지 않는다
authService = new AuthService(userRepository, studentProfileRepository, passwordEncoder, telegramNotifier);
}
/** 테스트용 사용자 하나 만들기. User엔 id setter가 없어 리플렉션으로 넣는다. */
@ -141,6 +143,8 @@ class AuthServiceTest {
var captor = org.mockito.ArgumentCaptor.forClass(dev.awesomedev.mirim.domain.StudentProfile.class);
verify(studentProfileRepository).save(captor.capture());
assertNotNull(captor.getValue().getPrivacyConsentAt());
// 가입 성공 멘토에게 텔레그램 알림이 나간다
verify(telegramNotifier).send(org.mockito.ArgumentMatchers.contains("새 수습생 가입"));
}
// 멘토 재설정 권한

View File

@ -38,7 +38,8 @@ class CodingServiceTest {
void setUp() {
codingProblemRepository = mock(CodingProblemRepository.class);
codingSubmissionRepository = mock(CodingSubmissionRepository.class);
codingService = new CodingService(codingProblemRepository, codingSubmissionRepository);
codingService = new CodingService(codingProblemRepository, codingSubmissionRepository,
mock(TelegramNotifier.class)); // 테스트에선 진짜 알림을 보내지 않는다
student = new User("student1", "해시", "수습생1", "STUDENT", "DEV");
}

View File

@ -33,6 +33,9 @@ services:
DB_PASSWORD: ${DB_PASSWORD}
COOKIE_SECURE: "true" # 운영은 HTTPS(Caddy)이므로 세션 쿠키를 HTTPS 전용으로
LOG_LEVEL: INFO # 운영 로그는 INFO
# 텔레그램 알림 (값은 .env에 — 비어 있으면 알림만 꺼지고 앱은 정상)
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
depends_on:
postgres:
condition: service_healthy