인증코드 발송 구현을 마쳤으니 이제 프로젝트 구독자에게 발송할 문제풀이 링크를 구현해야 했다.

프로젝트 기획 기반 문제풀이 링크 설정

CS25 프로젝트와 다른 프로젝트의 차별점은 회원가입을 좋아하지 않는 유저들의 접근성을 위해
로그인을 하지 않아도 문제를 풀 수 있도록 했다는 것이다.
대신 로그인한 유저에게는 마이페이지 기반 취약점 분석 등의 부가 기능을 제공한다.

따라서, 사용자 정보는 비로그인 유저와 로그인 유저 모두를 관리하는 Subscription과
로그인 유저를 관리하는 User 두 개의 테이블로 관리한다.

비로그인 유저 식별하기

위와 같은 기획대로 로그인하지 않은 유저가 메일로 받은 문제풀이 링크를 통해
문제풀이 페이지에 접근하여 문제를 푼 기록을 남길 때, 어떤 사람이 풀었는지를 어떻게 식별할 것인가?
를 구현 초기 단계에서 고민하였다.

그 결과 메일로 발송하는 문제풀이 링크 주소에 SubscriptionId와 QuizId를 Parameter로 함께 기입하기로 하였다.

문제풀이 링크 이메일 폼

그렇게 완성된 이메일 폼

1. 문제 풀러 가기 버튼을 누르면, SubscriptionId와 QuizId 정보가 함께 넘어간다.

2. 사용자의 브라우저가 자동으로 문제 조회 API를 호출한다.

3. Thymeleaf가 QuizId에 따라 동적으로 웹 페이지를 렌더링한다.
(문제가 100개라고 할 때, 100개의 웹 페이지가 아닌 1개의 Html만 있으면 되는 것!)

문제풀이 페이지

4. SubscriptionId는 보안을 위해 쿠키에 담기고, 위와 같은 페이지에서 문제를 풀고 답안 제출을 눌렀을 때,
값이 Header로 넘어가며 누가 문제를 풀었는지를 식별하게 된다.


순서도

실제로 어떻게 문제풀이 링크가 사용자에게 도달하는지까지의 과정을 정리해보겠다.

매일 정해진 시간에 Spring Batch를 활성화하면 다음과 같은 업무를 수행한다.

  1. 해당 요일에 문제를 받기로 한 구독 정보를 모두 조회한다.
  2. 각 구독 정보에 대하여 구독 시작일과 오늘 날짜의 차이를 계산한다.
  3. 그 값을 기반으로 슬라이딩 인덱스 알고리즘으로 DB에 저장되어있는 문제들 중 하나를 출제한다.
    (추후 더 보완할 예정)
  4. 구독 정보와 문제를 기반으로 메일 내용을 구성 및 발송한다.

 

개선 사항

💡 MessageQueue 적용의 필요성

현재 프로젝트의 흐름은 동기로 처리되고 있다.

1000개의 메일을 보낼 때, 위와 같은 순서로 1000번이 진행되는 것이다.

만약 526번째 메일을 보내다가 중간에 서버가 다운된다면?
나머지 메일은 보내지지 않을 것이다.

따라서, MessageQueue를 적용하여 유실율 감소 및 정확성을 높일 필요가 있다.

관련된 내용은 다음 글에서 다루도록 하겠다.

 

 

Spring에서 이메일 발송하기

이메일 발송을 구현하기에 앞서 관련된 원리를 먼저 알아보았다.

 

📨이메일 프로토콜 

이메일과 관련된 프로토콜에는 3가지가 있다.

그중 POP3와 IMAP 메일 수신용이고 SMTP는 발신용 프로토콜이다.

 

✅ SMTP 프로토콜 기반 메일 시스템

  • Mail User Agent (MUA) : 이메일을 작성하거나 열람하는 클라이언트 
  • Mail Transfer Agent (MTA) : MUA로부터 메일을 전달받아서 외부로 전달, 받은 메일을 MDA로 메일을 전달 
  • Mail Delivery Agent (MDA) : 사용자의 메일함에 메일을 저장

이메일이 발송되는 과정은 다음과 같다.

MUA에서 메일을 작성하고 발송 요청을 보내면 이를 MTA가 실제로 발송한다.
발송된 메일은 메일을 받는 사람이 사용하는 email 도메인의 MTA로 전송되고
해당 MTA에서 MDA를 통해 최종적으로 메일함에 메일이 도착한다.

 

📨Spring Boot Mail

Spring에서 메일을 보낼 때는 거의 대부분 JavaMailSender를 사용한다.

JavaMailSender를 다음과 같이 설정하였다.

spring.mail.host=smtp.gmail.com
spring.mail.port=587

MUA로 JavaMailSender를, MTA로 Gmail-smtp 서버를 사용하는 것이다.

Config 설정

MailConfig는 다음과 같다.

package com.example.cs25.global.config;

import java.util.Properties;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;

@Configuration
public class MailConfig {
    @Value("${spring.mail.host}")
    private String host;

    @Value("${spring.mail.port}")
    private int port;

    @Value("${spring.mail.username}")
    private String username;

    @Value("${spring.mail.password}")
    private String password;

    @Value("${spring.mail.properties.mail.smtp.auth}")
    private boolean auth;

    @Value("${spring.mail.properties.mail.smtp.starttls.enable}")
    private boolean starttlsEnable;

    @Value("${spring.mail.properties.mail.smtp.starttls.required}")
    private boolean starttlsRequired;

    @Value("${spring.mail.default-encoding}")
    private String defaultEncoding;

    @Value("${spring.mail.properties.mail.smtp.connectiontimeout}")
    private int connectionTimeout;

    @Value("${spring.mail.properties.mail.smtp.timeout}")
    private int timeout;

    @Value("${spring.mail.properties.mail.smtp.writetimeout}")
    private int writeTimeout;

    @Bean
    public JavaMailSender javaMailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost(host);
        mailSender.setPort(port);
        mailSender.setUsername(username);
        mailSender.setPassword(password);
        mailSender.setDefaultEncoding(defaultEncoding);
        mailSender.setJavaMailProperties(getMailProperties());
        return mailSender;
    }

    private Properties getMailProperties() {
        Properties properties = new Properties();
        properties.put("mail.smtp.auth", auth);
        properties.put("mail.smtp.starttls.enable", starttlsEnable);
        properties.put("mail.smtp.starttls.required", starttlsRequired);
        properties.put("mail.smtp.connectiontimeout", connectionTimeout);
        properties.put("mail.smtp.timeout", timeout);
        properties.put("mail.smtp.writetimeout", writeTimeout);
        return properties;
    }
}

 

인증코드 생성

인증코드를 메일로 발송하기 위해서 먼저 인증코드를 생성한다.

난수를 생성하는 알고리즘은 Math.Random이 친숙했으나
보안에 취약하다는 문제점이 있다고 하여 SecureRandom을 사용하였다.

https://velog.io/@dudwls0505/%EC%9E%90%EB%B0%94%EC%9D%98-%EB%82%9C%EC%88%98%EC%83%9D%EC%84%B1%EA%B8%B0-Random-SecureRandom

 

자바의 난수생성기 Random, SecureRandom

이메일로 임시비밀번호를 전송하려고할때, 난수를 생성하여 전달해주려고하던찰나에 궁금증이생겼다.학부시절에 처음 난수를생성할때 Random함수를 가끔 썼던거같은데 이번에 SecureRandom 에대해

velog.io

 

private String create() {
    int length = 6;
    Random random;

    try {
        random = SecureRandom.getInstanceStrong();
    } catch (
        NoSuchAlgorithmException e) { //SecureRandom.getInstanceStrong()에서 사용하는 알고리즘을 JVM 에서 지원하지 않을 때
        random = new SecureRandom();
    }
    StringBuilder builder = new StringBuilder();
    for (int i = 0; i < length; i++) {
        builder.append(random.nextInt(10));
    }

    return builder.toString();
}

 

📦 Redis에 저장 및 관리

발급된 인증코드는 Redis에서 관리한다.

Key - VERIFY:이메일 주소
Value - 인증코드

    private static final String PREFIX = "VERIFY:";
    
    private void save(String email, String code, Duration ttl) {
        redisTemplate.opsForValue().set(PREFIX + email, code, ttl);
    }

 

인증코드를 검증할 때, Redis에서 이메일에 따른 인증코드를 확인한 후  TTL이 만료되어 키 값이 없으면 예외를 반환하고
데이터가 있다면 입력된 값과 일치 여부를 확인한다.

📨Thymeleaf 사용한 Html 형식의 이메일 내용 구성

추후 메일로 문제풀이 링크를 보내야 했기 때문에 Html 형식으로 이메일 내용을 구성할 필요가 있었다.

Html 형식을 지원하는 MimeMessage 객체를 사용하였다.

String htmlContent = templateEngine.process("mail-template", context);

MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

helper.setTo(subscription.getEmail());
helper.setSubject("[CS25] 오늘의 문제 도착");
helper.setText(htmlContent, true);

 

Thymeleaf은 다음과 같은 장점이 있어 메일 내용 구현에 적용하였다.

  • Spring과 호환성이 높다.
  • Context 메서드를 통해 쉽게 데이터 주입이 가능하다.
  • 동적 렌더링이 가능하다.
Context context = new Context();
context.setVariable("toEmail", subscription.getEmail());
context.setVariable("question", quiz.getQuestion());
context.setVariable("quizLink", generateQuizLink(subscription.getId(), quiz.getId()));

 

📝 구현 결과

인증코드 발급 api를 호출했을 때, 오른쪽과 같이 Html 형식의 이메일이 정상적으로 발송되는 것을 확인할 수 있다.

인증코드의 일치 여부 역시 정상적으로 검증하는 것을 확인할 수 있다.

인증 코드를 어떤 DB에서 관리할 것인가

이메일로 인증 코드를 발급하는 기능을 구현하기 전, 인증 코드를 어떤 DB에서 관리할 것인가에 대한 설계 과정이 필요하였다.


인증 코드는 휘발되어도 괜찮은 데이터인가?

개인적으로 인증 코드가 너무 자주 휘발되는 것은 당연히 프로그램의 신뢰성을 떨어뜨릴 것이다.

그러나, 인증코드는 어차피 짧은 시간 동안만 유효하고
혹여 서버 오류로 발급된 코드가 누락되더라도 다시 발급받으면 되는 데이터라고 생각했다.

따라서, 인증코드를 휘발되어도 괜찮은 데이터라고 판단하였다.


로컬 캐시 Vs 글로벌 캐시

인증코드가 휘발되어도 괜찮다는 판단을 내린 후 무엇으로 이를 관리할지 고민을 했을 때, Redis를 생각하였다.

  • 인메모리 기반으로 인증코드를 빠르게 조회할 수 있다.
  • TTL 기능으로 만료 키 삭제에 대한 별도의 로직이 필요 없다.

분산 구조에 대해 확장성이 뛰어나다는 점과 다중 서버 환경에서 일관성을 유지할 수 있다는 장점도 있지만 
현재 프로젝트는 분산 시스템은 고려하지 않고 있었기 때문에 우선 해당 장점은 고려하지 않았다.

 

??? : 로컬 캐시를 사용해 보는 건 어떤가요

인증코드를 Redis를 사용해서 관리하려고 한다는 계획을 밝혔을 때,
한 팀원 분이 그렇다면 로컬 캐싱을 사용하는 것은 어떤가라고 의견을 제시해 주셨다.

로컬 캐시(ehcache, caffeine cache)에 대한 내용을 찾아봤을 때,
Redis 선택 시 고려했던 TTL 같은 기능도 있었고 글로벌 캐시보다 조회속도가 더 빠르다는 장점이 있었다.
위와 같이 인증코드의 특성상 휘발되어도 괜찮기 때문에 로컬 캐시를 사용해도 괜찮을 것 같다는 생각이 들었다.

 

??? : 여러분은 CI 관점을 놓치고 있습니다

기술 사용에 대한 의사결정이 타당한지 튜터님의 의견을 구하였다.
튜터님께서는 우리가 고려한 내용이 모두 맞지만 지속적인 배포(CI) 관점을 놓치고 있다는 조언을 해주셨다.

 

지속적인 배포의 관점에서
로컬 캐시와 글로벌 캐시가 어떤 시점에 데이터가 휘발되는지를
놓치고 있다.


최근에는 지속적인 배포를 중시한다.
어떤 기업은 1시간에 6번을 배포하는 경우도 있다.

이때, 로컬 캐시를 사용하게 되면 배포를 할 때마다 데이터가 휘발된다.
반면, 글로벌 캐시는 Redis 서버가 다운되지 않는다면 배포를 할 때마다 데이터가 휘발되지는 않는다.


따라서 우리는 처음 생각했던 방법인 글로벌 캐시 Redis로 발급된 인증코드를 관리하기로 하였다.

 

크롤링한 문제 DB에 저장하기

✔️선행 데이터 등록 필요

이번 프로젝트에서는 문제의 카테고리 (frontend, backend 등)를 Enum이 아닌 엔티티로 관리한다.
Enum은 동적으로 데이터를 확장할 수 없기 때문에 엔티티로 관리하기로 결정하였다.

이런 설계의 특성상 Quiz 엔티티를 생성할 때, Enum인 QuizCategoryType 대신 이를 DB에서 관리하는 QuizCategory 엔티티가 필요로 하기 때문에 반드시 먼저 QuizCategory 데이터가 생성되어있어야 한다.

 

Quiz 등록 API

Json 파일을 입력받기 위해 MultipartFile을 사용하였다.

MultipartFile : 클라이언트로부터 전송된 파일을 서버에서 받기 위한 객체

    public void uploadQuizJson(MultipartFile file, QuizCategoryType categoryType, QuizFormatType formatType){
        try {
            QuizCategory category = quizCategoryRepository.findByCategoryType(categoryType)
                .orElseThrow(() -> new QuizException(QuizExceptionCode.QUIZ_CATEGORY_NOT_FOUND_EVENT));

            CreateQuizDto[] quizArray = objectMapper.readValue(file.getInputStream(), CreateQuizDto[].class);
            ...
        }
    }

getInputStream 메서드로 파일 내용을 Stream 형태로 읽고
ObjectMapper로 JSON 문자열을 Java 객체(여기선 CreateQuizDto)로 바꾼다.

            List<Quiz> quizzes = Arrays.stream(quizArray)
                .map(dto -> Quiz.builder()
                    .type(formatType)
                    .question(dto.question())
                    .choice(dto.choice())
                    .answer(dto.answer())
                    .commentary(dto.commentary())
                    .category(category)
                    .build())
                .toList();

            quizRepository.saveAll(quizzes);

빌더 패턴으로 생성하는 Quiz 엔티티에 매핑한 후 이를 리스트로 모아 한번에 저장한다.

퀴즈 등록 api를 실행하면 Json 파일이 정상적으로 읽히고 DB에 저장된다.

(DB에 저장한 결과 파일 첨부 필요)

웹 크롤링 (Web Crawling) 이란

정보의 바다 인터넷에 퍼진 수많은 정보들, 웹 문서를 웹 크롤러가 규칙에 따라 탐색 및 수집하는 것이다.

크롤러는 인터넷의 여러 웹 사이트에 접속하여,
해당 페이지의 내용과 링크의 복사본을 생성 및 다운로드해 요약본을 만든다.

+ 검색 시, 유용한 정보만을 노출하도록 검색 색인을 추가한다.

현재 구현하고자 하는 프로젝트는 사용자가 풀 수 있는 문제에 대한 데이터가 필요했다.
이 문제를 문제은행의 데이터를 크롤링해 오고자 하였다.


Java 기반 크롤링 라이브러리 JSoup

크롤링을 할 수 있는 라이브러리에는 여러 가지가 있으나 
현재 Java 기반의 프로젝트를 진행 중이니 Java 기반의 크롤링 라이브러리를 사용하면 좋을 것 같았다.

Java 기반 웹 크롤링 관련 라이브러리에는 Jsoup이 있다.

https://jsoup.org/cookbook/

 

Cookbook: jsoup Java HTML parser

 

jsoup.org

Jsoup는 HTML5 DOM 메서드와 CSS 선택자를 활용하여 URL을 가져오고 데이터를 추출 및 조작할 수 있는 기능을 제공한다.


Jsoup 원리

1) Parser로 HTML 문서를 Document로 파싱 하기

데이터를 크롤링하기 전에 먼저 해당 HTML형식이 올바른지 여부와 관계없이 이를 깔끔하게 정리한다.
이때 생성되는 객체가 Document이다.
HTML 전체 내용이 담겼다고 생각하면 된다.

Document doc = Jsoup.parse(html);

 


connect 메서드는 url 주소를 기반으로  새 Connection을 생성하고 get 메서드로 parse처럼 HTML 파일을 가져와서 파싱 한다.
* 오류 발생 시, IOException이 발생한다.

Document doc = Jsoup.connect("http://example.com/").get();

 

. html 파일을 기반으로 Document를 생성하고자 한다면 아래와 같이 사용할 수 있다.

File input = new File("/tmp/input.html");
Document doc = Jsoup.parse(input, "UTF-8", "http://example.com/");

 

메모리에 담기에는 큰 용량의 데이터는 StreamParser를 사용해야 한다.
StreamParser는 이벤트 기반 DOM + SAX 스타일로 문서를 파싱 할 수 있는 메서드이다.

https://jsoup.org/cookbook/input/streamparser-dom-sax

 

StreamParser: A hybrid Java SAX + DOM parser for large documents: jsoup Java HTML parser

Parse large documents efficiently with StreamParser Problem You need to parse an HTML or XML document that is too large to fit entirely into memory, or you want to process elements progressively as they are encountered. A typical use case is extracting spe

jsoup.org

 

 

2) Element 객체 뽑아오기

Document가 HTML의 전체 내용이라면,
Element는 HTML을 구성하고 있는 컨테이너라고 생각하면 좋다.

Document에서 데이터를 가져오는 방법은 매우 많다.
위에서 첨부했던 공식 문서의 Cookbook을 참고하길 바란다.

Element body = doc.body(); //body 영역 
Element content = doc.getElementById("content"); //Id 기반
Elements links = content.getElementsByTag("a");  //Tag 기반

 

Element를 복수형으로 표현하면 여러 개의 Element를 가져오고
CSS 선택자로 요소를 가져올 수도 있다.

Elements links = doc.select("a[href]"); // a with href
Elements pngs = doc.select("img[src$=.png]");

 

3) Element에서 필요한 데이터 추출하기

전체 내용의 일부 구역을 뽑아온 Element에서 필요한 값을 추출한다.

element.attr("href") //속성 값 추출
element.text() //내부 텍스트 추출
element.html() //HTML 문자열 추출

 


그럼 필요한 데이터를 뽑아 볼까요?

현재 문제와 관련된 ERD의 내용에 따라 추출해야 하는 내용은 다음과 같다.

  • 질문
  • 객관식 보기 (정답 포함)
  • 정답
  • 해설

 


 

회차별 문제 링크 추출하기

정보처리기사 회차별 문제 링크 HTML 구성

		//문제은행 도메인 주소
		final String domain = "https://cbtbank.kr";
		//문제은행에서 정보처리기사 문제 목록이 있는 페이지
		final String listCbtUrl = domain + "/category/정보처리기사";
        
		Document listPage = Jsoup.connect(listCbtUrl).get();
			Elements links = listPage.select("a[href^=/exam/]");
			List<String> examUrls = new ArrayList<>();
			for (Element link : links) {
				String relativeUrl = link.attr("href");
				String fullUrl = domain + relativeUrl;
				examUrls.add(fullUrl);
			}

정보처리기사 회차별 문제 링크 HTML 구성을 바탕으로 <a herf = "/exam/">에 속하는 요소들을 Element 객체로 가져와
내부 속성인 url만 추출하여 아래와 같은 링크 주소들을 리스트에 저장하였다.

 


 

문제 가져오기

	public static ParsedQuiz parseQuiz(Element box) {
		ParsedQuiz pq = new ParsedQuiz();

		String rawTitle = box.selectFirst("p.exam-title").text();
		pq.question = rawTitle.replaceFirst("^\\d+\\.", "").trim();

		Elements liItems = box.select("ol.circlednumbers > li");
		for (int i = 0; i < liItems.size(); i++) {
			String choiceText = liItems.get(i).text();
			pq.choices += choiceText;
			if (liItems.get(i).hasClass("correct")) {
				pq.answer = (i + 1) + "." + choiceText;
			}
		}

		Elements replies = box.select("li.reply-item");
		for (Element reply : replies) {
			if (reply.attr("data-info").contains("depth:0")) {
				Element comment = reply.selectFirst("div.reply-comment");
				if (comment != null) {
					pq.commentary = comment.text();
					break;
				}
			}
		}

		return pq;
	}

위와 같은 원리로 HTML 문을 분석하고 필요한 데이터를 추출하였다.
출력을 통해 데이터를 확인하면 아래와 같은 결과가 나온다.

 


크롤링한 데이터를 필요한 형태로 Json 파일 만들기

사실 크롤러를 프로젝트 내에 구현하여 크롤링한 데이터를 동일한 프로젝트 내에서 바로 적용할 수도 있었을 것이다.
그러나 나는 이를 별도의 프로젝트에서 구현하였다.
그 이유는 크롤링한 문제의 중복 제거에 있다.

중복된 문제 제거 방법 (feat. 고마워요 G선생)

프로젝트 설계 단계에서 우리 팀은 크롤링한 문제의 중복을 제거하는 로직을 어떻게 짜면 좋을까 하는 고민을 하였고,
이에 대해 튜터님께 조언을 구하였다.

중복 제거는 AI가 잘 제거해 줍니다.

그 결과, 크롤링은 자동으로 하고 중복은 AI가 한 후 중복이 제거된 파일을 DB에 등록하는 일종의 반자동 시스템이 된 것이다.


크롤링한 데이터를 Json 파일에 저장

Json 파일로 저장하기 위한 라이브러리를 찾아보니 gson과 Jackson이 있었다.
gson과 jackson은 Json 문자열과 java 객체 간의 변환을 쉽게 해 주어 Json 데이터 처리에 사용되는 대표적인 라이브러리이다.
gson은 Google이 개발하였고 jackson은 FasterXML이 개발하였다.

gson Vs Jackson

gson은 google이 개발한 라이브러리답게 쉽고 간단하다는 것이 주요 장점이다.

  • 장점
    • 쉽게 사용할 수 있다.
    • 코드가 간결하다.
    • 구성이 가볍다

그러나 Jackson에 비해 성능이 다소 떨어지고, 기능이 제한적이라는 단점이 있었다.

Jackson의 장점은 성능이 더 뛰어나고 Spring과 호환이 잘 된다는 것이 주요 장점이다.

  • 장점
    • 빠른 처리 속도와 높은 성능
    • 다양한 데이터 처리 방식
    • Spring과의 높은 호환성

그러나 초기 학습 곡선이 다소 높고 설정이 복잡할 수 있다는 단점이 있었다.

https://jframework.tistory.com/34

 

Jackson / GSON 라이브러리 차이점

jackson과 Gson은 Java에서 JSON 데이터를 처리하기 위해 널리 사용되는 두 가지 라이브러리입니다. 둘 다 JSON 문자열과 Java 객체 간의 변환을 쉽게 해주며, 각각의 라이브러리는 고유한 특징과 장단점

jframework.tistory.com

 

두 라이브러리의 장단점을 본 후, 현재 크롤러 프로젝트에서는 크게 중요하게 고려할 만한 부분은 없었지만
문제의 개수가 매우 많을 수 있다는 점과 추후 혹시 모를 Spring 사용을 고려할 때,
호환성이 높고 대용량 데이터 처리에 더 적합한 Jackson 라이브러리를 사용하기로 하였다.

 

Json 파일로 저장

어떤 라이브러리를 사용할지 고민한 것에 비해 실제 크롤러에서 Json 파일로 저장하는 코드는 굉장히 간단하다.

ObjectMapper mapper = new ObjectMapper();
			mapper.writerWithDefaultPrettyPrinter().writeValue(new File("quizzes.json"), quizzes);

 

그 결과 Json에 저장된 파일은 다음과 같다.

 

이렇게 Json 파일로 뽑아내어 GPT를 통해 중복을 제거한 문제를 어떻게 DB에 저장했는지는 다음 글에서 다루도록 하겠다!

 

+ Recent posts