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

3주 차: 알림 기능 찾아 삼만리 Day 16 - 구현 사항 설명하기

writingforever162 2025. 3. 2. 19:52

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

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

[알림 기능 찾아 삼만리 링크 3주 차: (11) (12) (13) (14) (15)]

채우기 쉽지 않은 20분을 최대한 더듬거리지 않고 채우려면 문장 길이가 짧아야 했다. 어떤 글을 쓸 때든 항상 한 줄씩 띄어 쓰면 길이가 한눈에 들어왔다. 혹시 나중에 면접 준비할 때 도움이 될지 모르니, 오늘 20분짜리 영상을 제출한 김에 정리했다.

 

1. 🌟 소개

더보기

您好,我是翻译人和计算机语言的开发者申智贤。

안녕하십니까.

사람뿐만 아니라 컴퓨터 언어를 번역하며 언어 사랑을 키워나가는 개발자, 신지현입니다.

지금까지 이메일 알림 기능을 구현하면서 겪은 시행착오를 공유해 드리겠습니다.

먼저 의사결정을 내린 후, 문제를 해결하며 개선한 점을 설명하겠습니다.

그다음에는 다시 어떤 의사결정을 내렸고 그 선택을 반영하는 과정에서 어떤 문제를 추가로 해결했는지 말씀드리겠습니다.

마지막으로는 앞으로 개선할 부분과 계획을 요약하며 구현 사항 설명을 마무리하겠습니다.

2. 💡 의사결정_1_이메일로 채용 공고 목록 발송

더보기

[내가 구현한 기능]

사용자들이 선택한 기술 요건을 포함한 채용 공고 목록을 하루에 한 번 발송하는 이메일 알림 기능을 구현했습니다.

 

[주요 로직]

주요 로직은 세 부분으로 나뉩니다.

첫째, Spring Scheduler와 queryDSL을 사용하여 주기적으로 사용자 정보와 채용 공고 정보를 조회합니다.

둘째, 기술 요건이 동일한 사용자와 채용 공고를 매칭합니다.

셋째, 매칭된 정보를 곧바로 이메일로 전송합니다.

 

[배경]

‘취하여’ 프로젝트는 사용자가 기술 요건을 하나씩 검색해서 채용 공고를 조회해야 하는 불편을 해소하고자 진행되었습니다.

기획 의도에 따라, 사용자가 기술 요건을 선택하면 해당 요건을 포함한 채용 공고 목록을 쉽게 받아볼 수 있도록 해야 했습니다.

또한, 사용자가 채용 공고 사이트로 바로 들어갈 수 있어야 하는 만큼, URL 클릭 같은 상호 작용을 할 수 있는 이메일 알림 기능이 필요했습니다.

 

[요구 사항]

동작하는 기능 구현이 최우선 목표였기에 요구사항은 세 가지로 나뉘었습니다.

첫째, 가입 절차가 간단해야 했습니다.

둘째, 실제 개인 업무로 쓰지 않아서 테스트하기 쉬워야 했습니다.

셋째, 기능이 잘 동작하는지 많이 테스트할 예정이었으므로 무료이어야 했습니다.

 

[선택지]

선택지로는 Gmail, 네이버의 Cloud Outbound Mailer, 그리고 SendGrid가 있었습니다.

 

[의사결정/사유]

첫 번째 선택지 Gmail을 사용하기로 결정했습니다.

모든 요구사항에 부합했기 때문입니다.

그 대신 사용 중 문제가 발생한다면 나머지 선택지로 전환할 가능성을 염두에 두었습니다.

 

[회고]

① 기술의 장단점

- Gmail은 계정 생성 및 설정이 간편하여 빠르게 이메일 알림 기능을 구현할 수 있었습니다.

- 다만 모니터링과 통계 산출에는 한계가 있었습니다.

- 또다른 단점은 ‘로그인 횟수를 줄이세요’와 같이 모호한 해결책만 제공되어 문제를 신속하게 해결하기 어려웠습니다.

② 다시 시도한다면?

- 모든 선택지로 이메일을 전송한 다음 가장 나은 선택지를 선택하고 싶습니다.

3. 🚨 문제해결_1-A_이메일 비동기

더보기

[성능 개선 / 코드 개선 요약]

이메일 1건 발송에 평균 7초가 걸려 속도 개선 작업을 했습니다.

 

[문제 정의]

사용자 2,000명에게 이메일 알림을 1개씩 전송하는 데 약 3시간 53분이 걸릴 수 있었습니다.

 

[가설]

Gmail을 사용하여 이메일 알림을 전송하는데, Gmail의 SMTP 서버가 이메일을 사용자에게 전달하는 데 시간이 오래 걸렸습니다.

 

[해결 방안]

Gmail의 SMTP 서버 속도를 직접 개선할 수 없었기에 두 가지 방면으로 문제 해결을 시도했습니다.

첫째, 이메일 보내기 전 작업이 더 빨리 이루어지도록 리팩토링했습니다.

우선 queryDSL의 transform() 메서드를 사용하여 채용 공고 정보를 Map 형태로 조회했습니다.

이때, Key는 기술 요건 ID, Value는 해당 요건에 매칭되는 채용 공고 URL 목록입니다.

이렇게 원하는 형태로 데이터를 조회하여 추가 변환 없이 사용자가 선택한 기술 요건과 바로 비교할 수 있도록 했습니다.

둘째, 이메일 발송을 비동기로 처리해서 해당 작업을 병렬로 실행했습니다.

ThreadPoolTaskScheduler를 10개로 설정하여 한 이메일이 전송되는 동안 다른 이메일 발송 작업이 기다리지 않도록 했습니다.

 

[해결 완료]

문제 해결을 시도한 결과, 이메일 3건을 보냈을 때 발송 속도가 약 21초에서 9초로 단축되었습니다.

이메일 발송 속도를 평균으로 계산하면, 약 7초에서 3초로 줄어들었습니다.

 

[회고]

① 기술의 장단점

- 이메일 발송을 비동기로 처리하여 여러 이메일 발송 작업을 동시에 처리할 수 있었습니다.

- 다만, Gmail의 SMTP 서버 속도를 직접 제어할 수 없어, 서버 자체 한계는 여전히 존재합니다.

- 여러 작업을 병렬로 처리하기 때문에 CPU 사용량이 지나치게 증가하는 위험 또한 존재합니다.

② 다시 시도한다면?

- Gmail SMTP 서버의 제한을 고려하여 다른 이메일 서비스를 적용하고 싶습니다.

- 비동기 작업의 스레드 수를 조정하는 방식 등을 적용하여 과도한 CPU 사용을 방지하고 싶습니다.

- 스레드 수를 적절히 제한하거나, 스레드 풀 크기나 대기 시간을 조절하거나. 작업의 우선순위를 정하는 식으로 과도한 CPU 사용을 막을 수 있을 듯합니다.

- 데이터 조회 로직을 더 다듬어서 속도 개선뿐만 아니라 많은 데이터도 문제없이 조회하고 싶습니다.

4. 🚨 문제해결_1-B_채용 공고 중복 조회 방지

더보기

[성능 개선 / 코드 개선 요약]

채용 공고 중복 조회를 방지하고자 생성일을 ZonedDateTime으로 추가했는데, 여전히 중복으로 조회되는 문제가 발생하여 해결해야 했습니다.

 

[문제 정의]

조회 시간이 로그를 찍을 때는 한국 표준시 KST로 출력되지만, 채용 공고 생성일은 협정 세계시 UTC로 저장되었습니다.

 

[가설]

ZonedDateTime을 사용하면 MySQL에서 서버 시간과 무관하게 자동으로 UTC로 저장해서 해당 문제가 발생했다고 가정했습니다.

그다음 공식 문서를 조회하여 가설을 검증했습니다.

 

'MySQL converts TIMESTAMP values from the current time zone to UTC for storage, and back from UTC to the current time zone for retrieval.'

 

공식 문서에 나왔다시피, MySQL은 TIMESTAMP 값을 현재 시간대에서 UTC로 변환하여 저장하고, 저장된 값을 다시 UTC에서 현재 시간대로 변환하여 조회한다는 점을 알 수 있었습니다.

 

[해결 방안]

ZonedDateTime referenceTime = ZonedDateTime.now()
    .minusSeconds(30L)
    .withZoneSameInstant(ZoneId.of("UTC"));

로그와 데이터베이스 시간 일관성 문제를 해결하고자, 위와 같이 조회 시간을 UTC로 변경했습니다.

.where(jobOpening.createdAt.after(referenceTime))

where() 메서드와 after() 메서드를 사용하여 이전 조회 시간 이후에 생성된 채용 공고만 조회하는 로직은 변경하지 않았습니다.

 

[해결 완료]

조회 시간을 UTC로 통일한 결과, 한 번 조회된 채용 공고 목록이 다시 조회되지 않았습니다.

 

[회고]

① 기술의 장단점

- 조회 시간을 UTC로 바꾸어 국외 사용자를 고려한 ZonedDateTime을 계속 사용할 수 있었습니다.

- 다만, 테스트할 때마다 KST를 UTC로 변환해야 한다는 한계가 있었습니다.

② 다시 시도한다면?

- 데이터베이스 Timezone 설정을 KST로 변경하여 테스트 시 시간을 더 직관적으로 다룰 수 있도록 개선해 보고 싶습니다. 

5. 💡 의사결정_2_비동기 처리

더보기

[내가 구현한 기능]

비동기 처리를 이용하여 이메일 전송 작업 속도를 높였습니다.

 

[주요 로직]

이메일 전송에 해당하는 sendMail() 메서드에 @Async를 추가하여 이메일 전송 작업을 비동기로 처리했습니다.

 

[배경]

ThreadPoolTaskScheduler를 사용하여 별도의 스레드 풀을 관리하길 기대했으나, 실제로는 기존의 풀을 덮어씌우는 방식으로 작동했습니다.

이렇게 되면 다른 작업과 같은 스레드 풀을 공유하여 병목 현상이 생길 수 있었습니다.

 

[요구 사항]

첫째, 작업에 우선순위가 없으므로, 이메일 알림 기능이 스레드 풀을 너무 많이 가져가지 말아야 했습니다.

둘째, 쉽게 스레드 풀을 관리해야 했습니다.

 

[선택지]

선택지로는 ThreadPoolTaskScheduler를 그대로 사용하거나 @Async 어노테이션(annotation)을 적용하는 방법이 있었습니다.

 

[의사결정/사유]

@Async 어노테이션을 사용하여 스프링이 내부적으로 스레드 풀을 관리하도록 했습니다.

 

[회고]

① 기술의 장단점

- Spring(스프링)이 관리하기 때문에 비동기 처리를 쉽게 구현할 수 있었습니다.

- 다만 세부적으로 정의하지 않는다면 매번 새로운 스레드를 생성하는 문제가 있었습니다.

② 다시 시도한다면?

- 동작하는 기능에 초점을 두었기 때문에 대용량 데이터를 고려하지 않았습니다.

- 따라서 스레드 풀을 명시하여 제대로 된 비동기 처리를 하고 싶습니다.

- 예를 들어, @Async의 기본 스레드 풀의 크기와 관리 방식을 직접 설정하고 싶습니다.

6. 🚨 문제해결_2_Too many login attempts

더보기

[성능 개선 / 코드 개선 요약]

Too many login attempts 오류가 발생하여 이를 해결하는 작업을 수행했습니다.

 

[문제 정의]

Gmail로 이메일을 대량으로 전송하려고 할 때 Too many login attempts 오류가 발생하여 서버 운영에 차질이 생겼습니다.

 

[가설]

이메일을 한 번에 너무 많이 보내면 구글에서 이를 무차별 대입 공격 brute force attack으로 인식하여, Too many login attempts 오류가 발생했습니다.

 

[해결 방안]

해결 방안은 크게 두 가지로 나뉘었습니다.

첫째, Gmail을 그대로 사용한다고 가정했을 때였습니다.

무차별 대입 공격으로 여겨지지 않도록, 시간 간격을 두고 이메일을 보내는 방법이 있었습니다.

또는 팀원마다 새로운 Gmail 계정을 생성하여 이메일을 발송하는 방법이 있었습니다.

둘째, Gmail 대신 SendGrid로 이메일 발송을 대체하는 방법이 있었습니다.

일주일 동안 이메일을 200개 미만 보냈는데도 로그인 횟수 초과 오류가 발생한 점을 고려할 때, 이메일 발송에 특화된 API를 사용하는 방안이 앞으로 서버 운영에 더 유리해 보였습니다. 

implementation 'com.sendgrid:sendgrid-java:4.10.2'
sendgrid:
  api-key: ${SENDGRID_API_KEY}
  from-email: ${SENDGRID_FROM_EMAIL}
@Configuration
public class SendGridConfig {

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

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

이후 한 시간 이내로 SendGrid 회원 가입 및 Gmail 대체를 마쳤습니다. 사진에서 나왔다시피 갈아 끼우는 식으로 대체가 진행되어 금방 끝낼 수 있었습니다.

 

[해결 완료]

SendGrid API 적용 후에는 Too many login attempts 오류 없이 이메일을 보내는 데 성공했습니다.

7. 💡 의사결정_3_로직 분리 

더보기

[내가 구현한 기능]

30초마다 사용자와 채용 공고 정보를 조회한 다음, 해당 정보를 기반으로 이메일 알림을 하루에 1번 전송하는 스케줄러를 구현했습니다.

 

[주요 로직]

주요 로직은 네 부분으로 나뉩니다.

첫째, 30초마다 스케줄러가 실행되어 사용자와 채용 공고 정보를 조회합니다.

둘째, 조회한 채용 공고와 사용자의 기술 요건을 비교하여 일치하는 공고를 매칭합니다.

셋째, 매칭된 정보는 알림 객체로 데이터베이스에 저장됩니다.

넷째, 하루에 한 번 데이터베이스에 저장된 객체를 조회하여 이메일로 전송합니다.

 

[배경]

현재 스케줄러는 정보 조회와 이메일 전송을 한 번에 처리했는데, 이럴 때 두 가지 문제가 발생했습니다.

첫째, 정보 조회는 30초마다 실행되어야 하지만, 이메일 전송은 하루에 한 번이면 충분했습니다.

둘째, 매칭된 정보를 저장하지 않기 때문에, 전송이 실패하면 복구할 방법이 없었습니다.

 

[요구 사항]

결정하기 전 고려해야 할 요구 사항은 세 가지였습니다.

첫째, 주기가 다른 각 작업이 서로 영향을 주지 않아야 했습니다.

둘째, 전송에 실패한 알림을 다시 전송할 수 있도록 정보를 저장해야 했습니다.

셋째, 푸시 알림 등 다른 알림 기능을 짧은 시간 안에 추가할 수 있도록 확장성이 높아야 했습니다.

 

[선택지]

선택지로는 두 가지 방안이 있었습니다.

[의사결정/사유] 

로직을 여러 계층으로 분리하기로 했습니다.

첫째, 데이터가 늘어나면 현재 스케줄러에 부담이 커질 수 있었습니다.

둘째, 코드가 복잡해질 수 있지만, 로직을 분리해야 나중에 다른 알림 기능을 구현할 때 작업 속도를 단축할 수 있었습니다.

셋째, 전송에 실패했을 때 저장된 데이터를 다시 전송하는 로직을 구현하면 복구가 가능했습니다.

 

[회고]

① 기술의 장단점

- 확장성이 높고 알림 전송에 실패했을 때도 복구가 가능했습니다.

- 다만 관리해야 할 계층과 데이터를 저장해야 한다는 부담 또한 늘어났습니다.

② 다시 시도한다면?

- 알림 기능을 구현할 때부터 로직을 나누고 싶습니다.

- 또한 아래와 같이 데이터베이스의 부담을 줄일 방법을 지금보다 더 일찍 적용하고 싶습니다.

8. 📈 향후 계획 

더보기

(1) 🏅 이메일 알림 서비스 리팩토링하기

- 1순위: 클래스, 변수, 메서드 이름 수정

- 2순위: 패키지 세분화

- 3순위: 수신자 검증 로직 반영

(2) 🥈 이메일 알림 서비스 개선하기: 대용량 데이터를 문제없이 처리할 수 있도록

- 1순위: Gmail 사용 시 속도 vs 로직 분리 및 SendGrid 사용 시 속도 비교하기

- 2순위: 사용자 500명, 키워드 1개, 채용 공고 1000개 데이터로 병목 현상 위치 확인 및 트러블슈팅

- 3순위: 전송한 뒤에는 저장할 필요가 없는 알림 객체를 어떻게 처리할지 고민 및 의사결정 반영하기

(3) 🥉 푸시(Push) 알림 구현하기
- 1순위: 이메일 알림 서비스의 로직 재사용 + FCM + 카카오톡 API로 구현하기
- 2순위: Redis vs Kafka vs RabbitMQ 장단점 비교 및 의사결정 후 반영하기

 

Day 17에서 계속…….