Troubleshooting: 무엇이 문제였는가?/취하여 프로젝트

3주 차: 알림 기능 찾아 삼만리 Day 11 - 안녕, 지메일! 'Too many login attempts'에 질려서 SendGrid로 갈아탄다!

writingforever162 2025. 2. 25. 18:22

[인용 및 참고 출처]

1. 구글 검색: Google Help, "Too many login attemps 454 google", Community, (2025.02.25) 

2. 구글 검색: Google Help, "Too many login attemps 454 google", Community, (2025.02.25) 

3, 구글 검색: Google Help, "Too many login attempts 454 error codes", Gmail SMTP errors and codes, (2025.02.25)

4. 구글 검색: 티스토리, "SendGrid Spring", Spring Boot에서 SendGrid로 이메일 전송하기, (2025.02.25)

5. 구글 검색: Baeldung, "SendGrid Spring", Sending Emails in Spring Boot Using SendGrid, (2025.02.25)

 

[알림 기능 찾아 삼만리 링크 1주 차 : (1) (2)]

[알림 기능 찾아 삼만리 링크 2주 차: (3) (4) (5) (6) (7) (8) (9) (10)]

[깃허브(GitHub) 링크: (1) (2) (3)]

 

부팀장도 팀장님이 지목해서 호출할 때는 늘 긴장하기 마련이다.

특히 이렇게 'SMTP의 저주'를 정통으로 받았을 때는 머릿속이 새하얘진다, 마치 검은 점 하나 안 찍힌 도화지처럼. 도대체 뭐가 문제일까, 이메일이 나랑 전생에 원수라도 지었나, 팀장님이 관리하는 서버는 물론이고 다른 팀원의 애플리케이션을 빵빵 터뜨릴 때마다 마음속은 타들어 갔다. '차라리 아침에 짠 나타나든가, 왜 저녁 먹기 직전에 등장해서 날 괴롭히니', 이런 생각도 들었다.

그라파나(Grafana) 화면과 끝도 없이 펼쳐지는 로그를 보고 이번에 발생한 오류는 이메일을 한꺼번에 많이 보내려고 시도하다 생긴 문제라고 추측했다. 일단 내 컴퓨터로도 이메일 전송을 시도했다.

 

그 결과…….

데이터베이스에 저장된 알림 객체(Mapping)는 시간이 지나도 'is_email_sent'가 false에서 true로 바뀌지 않았고, 콘솔(console)에도 팀장님과 같은 오류가 떴다. 'Mail health check failed' 메시지를 본 순간 내 건강이 나빠지는 듯했다. 

 

이런저런 글을 찾아본 결과, 'Too many login attempts' 오류를 직접 해결할 방법은 없었다. 

 

해결책이라 해봤자 로그인 시도 횟수를 줄여야 했다. 동시에 이메일을 전송하려고 하면 무차별 대입 공격(brute force attack)으로 여겨져서 이 오류가 발생했기 때문에 결국 시간 간격을 두고 차례대로 이메일을 적당히 보내야 했다.

여기서 '적당히' 기준은 삭제한 이메일까지 포함하여 며칠 동안 200여 통 보냈으니, '일주일에 200통쯤 되겠구나' 가늠할 수 있었다. 혹시나 해서 앱 비밀번호도 바꿨으나 소용없었고, 구글 계정을 새로 만들면야 급한 불을 끌 수는 있을지언정 언제 또 똑같은 문제가 발생할지 예측조차 어려웠다.

 

이런 이유로 팀에서는 지메일(Gmail) 대신 SendGrid를 사용하기로 했다. '너무 쉬운 길로 방향을 틀지 않았나' 잠깐 고민이 들었지만, 직접 해결할 수 없는 문제 때문에 프로젝트 일정에 차질을 빚긴 정말 싫었다. 처음부터 SendGrid를 쓰지 않고 지메일로 이메일이 전송되는 흐름을 확인하기도 했으니, 나름대로 경험하고 더 나은 길을 고른 셈으로 쳤다.

 

SendGrid에 회원가입한 뒤에는 다음과 같은 순서로 프로젝트에 적용했다.

 

(1) Settings ▷ API Keys ▷ 'Create API Key' 버튼 누르기 ▼

 

(2) 권한 선택 후 'Create & View' 버튼 누르기

API Key 권한은 세 가지 중 고를 수 있는데, 일단 팀에서만 쓸 예정이므로 Full Access를 골랐다. 이때 생성된 API Key는 반드시 복사해서 저장해두어야 한다. 한 번 생성된 Key는 보안상 다시 볼 수 없으므로 저장하지 못했다면 새로 생성해야 한다.

 

(3) Settings ▷ Sender Authentication ▷ 'Single Sender Verification'에서 이메일 인증하기 

이메일을 인증할 때 Sender 주소 입력 칸에 구글 계정을 입력하면 'Attempting to send from a free email address domain like gmail.com is not recommended.' 메시지가 아래에 나타나는데, 'gmail.com'처럼 무료 이메일 주소 도메인에서 이메일 보내기는 권장하지 않는다는 의미이다. 인증하는 데 문제 되지는 않는다.

 

인증 요청 후에는 48시간 이내에 입력한 주소로 가서 'Verify Single Sender' 버튼을 눌러야 한다.

인증을 마치고 다시 SendGrid 화면으로 돌아오면 발송자가 인증되었다는 메시지가 뜨고, 'Return to Single Sender Verification' 화면으로 돌아가면 아래와 같이 'VERIFIED'에 체크 표시가 뜬다.

(4) 프로젝트로 돌아와서 의존성 주입하기 

implementation 'com.sendgrid:sendgrid-java:4.10.2'

만약 최신 또는 다른 버전을 사용하고 싶다면 Maven Repository에서 고르면 된다. 삽입한 링크는 'Twilio SendGrid Java Helper Library'이다.

 

(5) application.yml에 SendGrid 설정 추가하기

  sendgrid:
    api-key: ${SENDGRID_API_KEY}
    from-email: ${SENDGRID_FROM_EMAIL}

파일에서 sendgrid를 입력하면 무엇이 필요한지 알 수 있다. 팀에서는 개발용 yml과 배포용 yml을 나누어서 사용하기에 파일 이름이 'application-dev.yml'이었다. ${SENDGRID_API_KEY} 환경 변수는 아까 생성한 API KEY, ${SENDGRID_FROM_EMAIL} 환경 변수는 인증을 마친 보내는 사람 이메일 주소에 해당한다.

환경 변수를 설정할 때에는 yml과 이름이 동일해야 값이 제대로 들어간다. 

 

(6) SenderGridConfig 클래스를 생성하여 빈(Bean) 등록하기 

package com.project.cheerha.common.config;

import com.sendgrid.SendGrid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SendGridConfig {

    @Value("${SENDGRID_API_KEY}")
    private String sendGridApiKey;

    @Bean
    public SendGrid sendGrid() {
        return new SendGrid(sendGridApiKey);
    }
}

(7) EmailSender 수정하기 ▼

더보기
package com.project.cheerha.domain.notice.service;

import com.project.cheerha.domain.notice.entity.Mapping;
import com.project.cheerha.domain.notice.repository.MappingRepository;
import com.sendgrid.Method;
import com.sendgrid.Request;
import com.sendgrid.SendGrid;
import com.sendgrid.helpers.mail.Mail;
import com.sendgrid.helpers.mail.objects.Content;
import com.sendgrid.helpers.mail.objects.Email;
import java.io.IOException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailSender {

    private final MappingRepository mappingRepository;

    @Value("${SENDGRID_API_KEY}")
    private String sendGridApiKey;

    @Value("${SENDGRID_FROM_EMAIL}")
    private String senderEmail;

    @Async
    public void sendEmails() {
        // 이메일별로 해당하는 Mapping들을 묶는 Map
        Map<String, Set<Mapping>> emailToMappings = new HashMap<>();

        // (1) 이메일로 발송되지 않은 Mapping 목록 조회
        // (2) 이메일별로 emailToMappings에 Mapping을 묶음
        mappingRepository.findByIsEmailSentFalse()
            .forEach(mapping -> {
                emailToMappings.computeIfAbsent(
                    mapping.getEmail(),
                    emailAsKey -> new HashSet<>()
                ).add(mapping);
                // 해당 이메일에 맞는 Mapping을 Set<Mapping>에 추가
            });

        // 이메일 주소별로 이메일 발송
        emailToMappings.forEach(this::sendMail);
    }

    // 이메일 발송
    private void sendMail(
        String recipientEmail,
        Set<Mapping> mappings
    ) {
        try {
            // 이메일 발송에 필요한 정보 설정
            // from: 보내는 사람 이메일 주소
            // to: 받는 사람 이메일 주소
            Email from = new Email(senderEmail); 
            Email to = new Email(recipientEmail);
            String subject = "📢 새로운 맞춤 채용 공고가 도착했어요!";
            StringBuilder content = new StringBuilder();

            // 내용 추가
            content.append("<h1>🚀 새로운 채용 공고가 준비됐어요! 🎉</h1>");
            content.append("<p>맞춤형 채용 공고가 도착했답니다! 💼</p>");
            content.append("<p>아래 링크에서 확인해보세요! ⬇️</p>");
            content.append("<ul>");

            // Mapping에 저장된 채용 공고 URL 목록을 내용에 추가
            for (Mapping mapping : mappings) {
                content.append("<li>👉 <a href=\"")
                    .append(mapping.getJobOpeningUrl())
                    .append("\" target=\"_blank\">")
                    .append("채용 공고 자세히 보기</a></li>");
            }

            content.append("</ul>");
            content.append("<p>행운을 빕니다! 🙌</p>");

            // 내용 설정
            Content emailContent = new Content(
                "text/html", // HTML 형식의 이메일
                content.toString() // 작성된 내용
            );

            // SendGrid Mail 객체 생성
            Mail mail = new Mail(
                from, // 보내는 사람 이메일
                subject, // 이메일 제목
                to, // 받는 사람 이메일
                emailContent // 이메일 내용
            );

            // SendGrid API 호출에 필요한 Request 객체 설정
            SendGrid sendGrid = new SendGrid(sendGridApiKey);
            Request request = new Request();

            // POST 방식으로 요청
            request.setMethod(Method.POST); 

            // SendGrid의 이메일 발송 endpoint
            request.setEndpoint("mail/send");

            // 요청 본문에 Mail 객체 포함
            request.setBody(mail.build()); 

            // SendGrid API 호출
            sendGrid.api(request);

            log.info("이메일 전송 완료: {}", recipientEmail);

            mappings.forEach(mapping -> {
                mapping.markEmailAsSent(); // 발송 완료 상태로 변경
                mappingRepository.save(mapping); // 변경된 상태 저장
            });

        } catch (IOException e) {
            log.error("이메일 전송 실패: {}", recipientEmail, e);
        }
    }
}

지메일로 이메일 알림 기능을 구현한 덕분에 막상 SendGrid로 대체하기는 금방 끝났다. 나름 공들여서 본문을 잘 꾸민 덕이라고 할 수 있으려나. 

SendGrid로 지메일을 전부 대체한 다음에는 떨리는 마음으로 채용 공고 URL 2개가 담긴 이메일 한 통 전송을 시도했다.

 

조마조마한 마음으로 '이메일 전송 완료' 로그만 기다렸는데…….

'is_email_sent' 값이 false에서 true로 바뀌기 무섭게 받은 편지함을 보니 이메일 알림이 잘 도착했다. 현재 무료 버전을 사용하기 때문에 하루에 100개만 보낼 수 있다는 점은 아쉽지만, 지긋지긋한 '로그인 횟수 시도 초과' 오류에서 벗어날 수 있어서 구름 위를 나는 듯했다.

SendGrid에서는 이메일을 보내면 사진처럼 통계를 내주었다. 7통 중 올바른 주소로 전송된 이메일은 1통뿐이고, 데이터베이스에 없는 이메일 주소로 입력되었다는 점을 뒤늦게 깨닫고 애플리케이션을 껐기 때문에 통계가 정확하진 않다. 그래도 각 항목이 무엇을 의미하는지 읽는 연습을 미리 했다.

 

(1) Requests (요청): 이메일 발송 요청 총 개수

(2) Delivered (전송 완료): 실제로 수신자에게 도달한 이메일 개수. 총 7개 요청 중 한 개만 전송되었다. 

(3) Opened (읽음 여부): 수신자가 이메일을 열어본 횟수. 유일하게 전송에 성공한 이메일을 여러 번 열어봐서 171.43%라는 숫자가 나왔다. 

(4) Clicked (클릭 횟수): 이메일 내 링크를 클릭한 횟수. 존재하지 않는 링크라서 누르지 않았고, 0번으로 기록되었다.

(5) Bounces (반송): 수신자가 이메일을 받지 못하고 반송된 횟수. 총 7개 요청 중 다섯 개가 반송되었다.

(6) Spam Reports (스팸 신고): 수신자가 이메일을 스팸으로 신고한 횟수

 

'이제 푸시(Push) 알림만 구현하면, 큰 산 하나는 넘을 수 있지 않을까?'

 

Day 12에서 계속…….