오늘도 어김없이 프로젝트를 진행하고 있었다. 거의 마무리 단계라 버그를 잡거나 프론트에서 수정해 달라는 것을 수정하고 있었다.
이슈가 터진 로직은 메일 전송 로직인데, 사용자들의 이메일을 입력하고 설문 공유를 하면 입력한 메일에 설문 초대가 전송 되는 로직이었다.

위처럼 이메일 초대를하면

이런 형식으로 설문 참여를 할 수 있는 로직이다.
로직 개발은 그리 어렵지 않아 이전에 개발 및 테스트를 완료하고 배포 해놨었다. 그런데, 두명 이상에게 메일을 보낼 때 500 에러가 뜬다고하여 우선적으로 로그를 확인해봤다.

이상했다. 분명 로컬에서 테스트 할 때 다수에게 초대를 해도 에러가 발생하지 않았는데 배포할때만 에러가 발생했다.
로그를 보면 html 파일(정적 리소스)를 찾을 수 없다는 에러들이 있었다.
그래서 막 찾아보다가 Jar로 배포할 때 경로가 잘못되었나 싶어 여러가지 시도를 해봤지만 실패하였다.
원래는 배포시에 정적 리소스 파일을 못 찾는일이 없었는데 갑자기 못 찾으니 의심스러웠다.
그래서 혹시나 하고 다수의 사용자가 아닌 한명의 사용자에게 초대 메일을 보내는 요청을 보냈더니 성공했다.

그러다 문득 메일 전송 속도를 향상시키기 위해 사용한 ParallelStream(병렬 스트림)에서 동시에 html, png 파일에 접근해서 락이 걸린 상태일 경우 파일을 못 찾는게 아닐까 란 생각이 들었다.
빙고 정답이었다.
문제 상황을 알아보기 쉽게하기 위해 메서드를 수정했다.
public void sendInvitationLink(String senderId, Long surveyId, List<String> encryptedEmailList) {
User sender = userService.findByUserId(senderId);
Survey surveyToInvite = surveyService.findBySurveyId(surveyId);
String encryptedLink = encryptLink(surveyToInvite.getSurveyId());
Context context = new Context();//타임리프 템플릿에 전달할 데이터 저장하는 컨테이너
context.setVariable("inviterName", sender.getName());
context.setVariable("surveyTitle", surveyToInvite.getTitle());
context.setVariable("surveyLink", encryptedLink);
context.setVariable("expireDate", getFormatedDate(surveyToInvite.getExpireDate()));
//암호화 된 이메일 복호화
List<String> decryptedEmailList = encryptionUtil.decryptList(encryptedEmailList);
//이메일 요청 정규식 검사
validateEmailRequest(decryptedEmailList);
decryptedEmailList.parallelStream()
.forEach(recipientEmail -> {
sendMail(context, surveyToInvite.getTitle(), recipientEmail, "mail/invitation");
});
}
public void sendMail(Context context, String title, String email, String htmlPath) {
String htmlContent = templateEngine.process(htmlPath, context);//타임리프 템플릿 처리 후 HTML 콘텐츠 최종 생성
MimeMessage message = mailSender.createMimeMessage();// 이메일 메시지 생성 객체
try {
MimeMessageHelper helper = new MimeMessageHelper(message, true);// T: html 형식, F: 텍스트 형식
helper.setFrom(senderEmail);
helper.setSubject(title);
helper.setTo(email);
helper.setText(htmlContent, true);
// 이미지 첨부 (첨부파일로 cid를 사용)
ClassPathResource logoResource = new ClassPathResource(LOGO_IMAGE_PATH);
helper.addInline("logoImage", logoResource); // 이미지 ID 'logoImage'로 첨부
ClassPathResource titleResource = new ClassPathResource(TITLE_IMAGE_PATH);
helper.addInline("titleImage", titleResource); // 이미지 ID 'titleImage'로 첨부
//메일 전송
mailSender.send(message);
} catch (MessagingException e) {
throw new CustomException(HttpStatus.INTERNAL_SERVER_ERROR, FAILED_TO_SEND_EMAIL);
}
}
- sendInvitationLink 메서드의 ParallelStream 부분에서 각 이메일로 이메일 전송 시도
- 각 쓰레드마다 정적 리소스 접근 시도
- 한 쓰레드에서 정적 리소스에 접근할 시 다른 쓰레드에서 접근 불가
- 이로 인해 정적 리소스 파일을 찾을 수 없다는 에러 출력
처음 내가 생각했을때 문제가 된 부분은 아래 두 파트였다.
Context context = new Context();
context.setVariable("inviterName", sender.getName());
context.setVariable("surveyTitle", surveyToInvite.getTitle());
context.setVariable("surveyLink", encryptedLink);
context.setVariable("expireDate", getFormatedDate(surveyToInvite.getExpireDate()));
ClassPathResource logoResource = new ClassPathResource(LOGO_IMAGE_PATH);
helper.addInline("logoImage", logoResource);
ClassPathResource titleResource = new ClassPathResource(TITLE_IMAGE_PATH);
helper.addInline("titleImage", titleResource);
위 부분에서 한 쓰레드가 접근하면 락이 걸려 정적 리소스를 못 찾는다는 에러를 출력하는 것 같았다.
그리고 로컬에선 위 코드로 문제가 발생하지 않았는데, 배포 할 때 발생한 이유는, 아래와 같을 것으로 예상한다.
로컬 환경
- 로컬에서는 파일 시스템 상의 디렉토리 구조(예: src/main/resources/)에서 리소스를 직접 읽는다.
- 이는 일반 파일로 접근하므로 I/O 문제나 동시성 문제가 잘 발생하지 않는다.
JAR 배포 환경
- JAR 파일 내부의 리소스는 압축된 상태로 포함된다.
- 클래스패스 리소스를 읽으려면 JAR 내부에서 파일을 추출해야 하며, 이는 파일 시스템처럼 동작하지 않는다.
- ClassPathResource는 JAR 파일 내부의 리소스를 스트림(Stream)으로 처리하며, 동시 접근 시 I/O 충돌이나 락 문제가 발생할 수 있다.
따라서 아래와 같은 방식으로 임시로 로직을 수정했다.(추후 리펙토링 예정)
decryptedEmailList.parallelStream()
.forEach(recipientEmail -> {
Context context = new Context(); //타임리프 템플릿에 전달할 데이터 저장하는 컨테이너
context.setVariable("inviterName", sender.getName());
context.setVariable("surveyTitle", surveyToInvite.getTitle());
context.setVariable("surveyLink", encryptedLink);
context.setVariable("expireDate", getFormatedDate(surveyToInvite.getExpireDate()));
sendMail(context, surveyToInvite.getTitle(), recipientEmail, "mail/invitation");
});
//ConcurrentHashMap 선언
private static final Map<String, ClassPathResource> imageCache = new ConcurrentHashMap<>();
//값이 있다면 가져오고 없으면 추가하고 가져오는 메서드
private ClassPathResource getImage(String imagePath) {
return imageCache.computeIfAbsent(imagePath, ClassPathResource::new);
}
//사용
helper.addInline("logoImage", getImage(LOGO_IMAGE_PATH));
helper.addInline("titleImage", getImage(TITLE_IMAGE_PATH));
우선적으로 위와 같은 방식을 사용하여 문제를 해결하였다. 문제 해결 후 이전 Eyeve 프로젝트에서도 사용한 ConcurrentHashMap이 궁금하여 조금 더 찾아보았다.
Map 인터페이스의 구현체로는 3가지가 있다.
HashMap
HashTable
ConcurrentHashMap
우리가 흔히 사용하는 HashMap이다. HashMap은 싱글 쓰레드 환경에 적합하고, 동기화(Synchronize) 처리를 하지 않기 때문에 속도가 HashTable, ConcurrentHashMap보다 빠르다.
위에서 말했듯, 동기화 처리를 하지 않기 때문에 멀티 쓰레드 환경에선 적합하지 않다.
그리고 Key, Value에 Null값을 허용한다.
HashTable은 ConcurrentMap 이전에 등장한 멀티 쓰레드 환경에 사용할 수 있는 Map으로, Synchronized 키워드를 사용하여 ThreadSafe하다. 하지만, 성능의 이슈가 있다.
왜냐하면 get, put, remove 등에 메서드에 모두 Synchronized가 적용되어 있어, 하나의 쓰레드가 사용하는 동안 락이 걸려 다른 쓰레드는 사용하지 못한다.
결과적으로, 하나의 스레드가 HashTable을 사용하는 동안 다른 스레드는 대기해야 하므로 속도적으로 느리다.
Key, Value에 Null을 허용하지 않는다.
마찬가지로 멀티 쓰레드 환경에서 사용할 수 있는 Map으로, HashTable과 달리, Entry마다 락을 설정하여 한번에 여러 쓰레드가 접근 가능하다.
읽기 작업(get)은 잠금을 사용하지 않고, 쓰기 작업(put, remove)도 특정 버킷에만 잠금을 걸기 때문에 성능이 뛰어나다.
put과 같은 경우에는, 빈 bucket일 경우 Lock을 걸지 않고 새로운 노드를 삽입한다.
빈 bucket이 아닐경우 synchronized를 이용해 Lock을 걸어 다른 쓰레드가 해당 hash bucket에 접근하지 못하도록 막는다
Key, Value에 Null을 허용하지 않는다.
정말 이해하기 쉬운 그림이 있어 퍼왔다.
HashTable

ConcurrentHashMap

이전에는 그냥 아 이런거 쓰면 되나보다하고 가볍게 넘겼었는데 확실히 원리를 알아야 더욱 더 성장하는 것 같다. 앞으로 이런 상황이 있으면 왜 이것을 사용하면 문제가 해결되는지 알아야겠다는 생각이 많이 들었다.