ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SMTP 이메일 인증 시스템
    프로젝트, 트러블슈팅 2025. 7. 11. 17:14

     

    스터디 매칭 서비스 Stitch를 개발하면서 대학교 이메일 인증을 위해 univcert API를 사용하고 있었다. 하지만 프로젝트를 진행하던 중 univcert 서비스가 유지보수의 어려움으로 인해 종료된다는 소식을 접하게 됐다.


    💡 해결책: 자체 SMTP 이메일 인증 시스템 구축

    SMTP란 무엇인가?

    SMTP(Simple Mail Transfer Protocol)는 이메일을 전송하기 위한 인터넷 표준 프로토콜이다. 하루 제한량(500개)이 있지만 무료이다. 아래의 설명에는 코드 중 일부를 넣어놨다. 전체 코드를 확인은 https://github.com/Sti-tch/backend 에서 확인 가능하다. 

     

    설계 목표

    1. 외부 의존성 최소화: 서드파티 API 대신 직접 구현
    2. 기존 API 호환성: 프론트엔드 수정 없이 사용 가능
    3. 안정성 확보: Gmail SMTP 활용으로 높은 가용성
    4. 확장성: 향후 기능 추가 용이

     

    핵심 컴포넌트 

     

    아키텍처 설명

     

    • Controller: REST API 엔드포인트, 요청 검증
    • Service: 비즈니스 로직, 코드 생성/검증
    • EmailSender: JavaMailSender를 통한 Gmail SMTP 연동
    • CodeStorage: 메모리 기반 인증코드 저장소 (Redis 없이 구현)

     


     

    1. 인증코드 저장소 (VerificationCodeStorage)

    @Component
    public class VerificationCodeStorage {
        
        private final Map<String, VerificationCode> codeStorage = new ConcurrentHashMap<>();
        private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
        
        public void storeCode(String email, int code) {
            VerificationCode verificationCode = new VerificationCode(code, System.currentTimeMillis());
            codeStorage.put(email, verificationCode);
            
            // 5분 후 자동 삭제
            scheduler.schedule(() -> codeStorage.remove(email), 5, TimeUnit.MINUTES);
        }
        
        public boolean verifyCode(String email, int code) {
            VerificationCode stored = codeStorage.get(email);
            if (stored == null) return false;
            
            // 5분 만료 체크
            if (System.currentTimeMillis() - stored.getTimestamp() > 300000) {
                codeStorage.remove(email);
                return false;
            }
            
            return stored.getCode() == code;
        }
    }

     

     

    특징

    Redis 없이도 동작하는 경량화 설계를 하였고, 5분 후 자동 삭제로 메모리 효율성을 확보하였다. 그리고 스레드 안전을 위해  ConcurrentHashMap으로 동시성 보장함. 

     

     

    2. 이메일 발송 서비스

     

    @Service
    @RequiredArgsConstructor
    public class UnivCertService {
    
        private final JavaMailSender mailSender;
        private final VerificationCodeStorage codeStorage;
        
        public Map<String, Object> sendVerificationEmail(String email, String univName) {
            try {
                // 6자리 랜덤 코드 생성
                int verificationCode = 100000 + random.nextInt(900000);
                
                // 이메일 발송
                SimpleMailMessage message = new SimpleMailMessage();
                message.setTo(email);
                message.setSubject("[Stitch] 대학교 이메일 인증");
                message.setText(createEmailContent(univName, verificationCode));
                message.setFrom("qwer@gmail.com");
                
                mailSender.send(message);
                
                // 인증 코드 저장
                codeStorage.storeCode(email, verificationCode);
                
                return createSuccessResponse("인증 메일이 발송되었습니다.");
                
            } catch (Exception e) {
                return createErrorResponse(500, "메일 인증 발송에 실패했습니다.");
            }
        }
    }

     

    특징

    6자리 숫자의 랜덤 코드를 생성, STMP 발송 실패시 안전한 예외처리, 인증 성공시 코드 즉시 삭제로 재사용 방지함.

     

     

     

    3. Gmail SMTP 설정

    @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;
    
        @Bean
        public JavaMailSender javaMailSender() {
            JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
            mailSender.setHost(host);
            mailSender.setPort(port);
            mailSender.setUsername(username);
            mailSender.setPassword(password);
    
            Properties props = mailSender.getJavaMailProperties();
            props.put("mail.transport.protocol", "smtp");
            props.put("mail.smtp.auth", "true");
            props.put("mail.smtp.starttls.enable", "true");
            
            return mailSender;
        }
    }

     

     

    # Gmail SMTP 설정
    spring.mail.host=smtp.gmail.com
    spring.mail.port=587
    spring.mail.username=your-email@gmail.com
    spring.mail.password=your-app-password
    spring.mail.properties.mail.smtp.auth=true
    spring.mail.properties.mail.smtp.starttls.enable=true

     

    특징

    properties 파일로 환경별 설정 관리를 하고 앱 비밀번호를 통해 2단계 인증과 별개의 전용 비밀번호 사용함.

    또한 JavaMailSender Bean으로 의존성 주입 간편화.

     

     

    4. REST API 컨트롤러

    @RestController
    @RequestMapping("/api/auth")
    @RequiredArgsConstructor
    public class UnivCertController {
        
        private final UnivCertService univCertService;
        private final CampusRepository campusRepository;
        private final UserCamInfoService userCamInfoService;
        private final UserRepository userRepository;
    
        @PostMapping("/university/verify")
        public ResponseEntity<Map<String, Object>> sendVerificationEmail(
                @RequestBody EmailVerificationRequest request) {
    
            // 1. 현재 로그인한 사용자 확인 (OAuth2)
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            CustomOAuth2User oAuth2User = (CustomOAuth2User) auth.getPrincipal();
            String userEmail = oAuth2User.getEmail();
    
            User user = userRepository.findByEmail(userEmail)
                    .orElseThrow(() -> new UserException.UserNotFoundException());
    
            // 2. 이미 인증된 사용자인지 확인
            if (user.isCampusCertified()) {
                throw new UserException.UserAlreadyCertifiedException();
            }
    
            // 3. 이메일 도메인 검증
            String emailDomain = extractDomain(request.getEmail());
            Campus campus = campusRepository.findByName(request.getUnivName())
                    .orElseThrow(() -> new UserException.CampusNotFoundException());
    
            if (!emailDomain.endsWith(campus.getDomain())) {
                throw new UserException.InvalidCampusEmailDomainException();
            }
    
            // 4. 기존 인증 상태 초기화 후 이메일 발송
            univCertService.clearVerificationStatus(request.getEmail());
            Map<String, Object> response = univCertService.sendVerificationEmail(
                    request.getEmail(), request.getUnivName()
            );
    
            return ResponseEntity.ok(response);
        }
    
        @PostMapping("/university/verify-code")
        public ResponseEntity<Map<String, Object>> verifyCode(
                @RequestBody CodeVerificationRequest request) {
            
            // 코드 검증 후 UserCamInfo 생성
            Map<String, Object> response = univCertService.verifyCode(
                    request.getEmail(), request.getUnivName(), request.getCode()
            );
    
            if ((int)response.get("code") == 200) {
                Authentication auth = SecurityContextHolder.getContext().getAuthentication();
                CustomOAuth2User oAuth2User = (CustomOAuth2User) auth.getPrincipal();
                String userEmail = oAuth2User.getEmail();
    
                User user = userRepository.findByEmail(userEmail)
                        .orElseThrow(() -> new UserException.UserNotFoundException());
    
                userCamInfoService.createUserCamInfo(
                        user.getId(), request.getEmail(), request.getUnivName()
                );
            }
            
            return ResponseEntity.ok(response);
        }
    }

    특징

     

    • OAuth2 통합: 소셜 로그인 정보 활용한 사용자 식별
    • 다층 검증: 사용자 → 도메인 → 대학교 순차적 확인
    • 트랜잭션 처리: 인증 성공시에만 UserCamInfo 생성
    • API 호환성: 기존 univcert와 동일한 엔드포인트 유지

     


    결론

    첫 프로젝트 다 보니 외부 api를 가져와 사용해보고 싶었는데 갑작스러운 서비스 종료로 당황하게 됐다. 하지만 이를 통해 SMTP를 직접 구현하며 다양한 경험을 쌓을 수 있는 좋은 기회가 되었다. 이번 경험은 외부 api는 언제든지 서비스가 종료 될 수 있다라는 점을 생각하고 사용해야한다는 점을 알게 됐다. 

     

Designed by Tistory.