• NCP(Naver Cloud Platform) - Outbound Mailer 를 통한 SMS 발송하기

    2026. 6. 8.

    by. Daramu

    이전에는 SMTP 방식을 사용하여 "spring-boot-starter-mail" 을 의존성으로 사용하여 직접 Gmail과 같은 메일서버에 앱 비밀번호로 로그인하여 발송하는 방법을 사용했었다.

     

    하지만 위와같은 방법은 몇가지 문제점이 있는데,

     

    1. 스팸과 필터링과 IP 신뢰도

    Google이나 Outlook 같은 대형 메일 서비스 공급자(ESP)는 보안이 검증되지 않은 개인/중소 서버의 IP에서 대량의 메일이 들어오면 스팸으로 판단하여 차단해 버린다.

     

    2. 보안 및 계정 종속성 리스크

    기업 환경에서 특정 개인의 계정이나 고정된 관리자 계정의 앱 비밀번호를 소스코드나 설정 파일(Vault, AWS Secrets Manager를 쓰더라도)에 관리하는 것 자체가 보안 취약점이 될 수 있다.

     

    3. 동기식 발송으로 인한 성능 저하

    표준 자바 JavaMailSender를 사용해 직접 SMTP 서버와 핸드셰이킹을 하고 메일을 보내는 과정은 생각보다 무겁고 오래 걸린다.

     

    그 외에 여러가지 이유로 사용하지 않는데, 그 대안으로 CSP사에서 제공해주는 Email 서비스가 있다.

     

    이 중 NCP의 Outbound Mailer를 통해 직접 메일 발송하는 방법에 대해 알아볼 것이다.

     

    우선 NCP 콘솔에서 Outbound Mailer를 활성화해 준다.

     

    그리고 자신의 도메인에 대한 인증이 필요한데, Mailer 에 들어가 좌측 네비게이션 바에서 "Domain Management" 로 들어가 도메인 인증을 진행해 준다. 예를 들어 daramu.co.kr이라면 해당 도메인으로 생성해주면 된다.

     

    그럼 아래 그림처럼 인증하기 위한 여러 절차가 시작된다.

     

    여기서 DNS 서비스 제공자(본 포스팅의 경우에는 클라우드 플레어)의 DNS설정에 가서, TXT형식으로 "보기"를 누르면 나오는 값을 넣으며 된다.

     

    Type: TXT

    Name: @

    Content: 인증 토큰과 SPF에서 보기 클릭시 나오는 값.

     

    DKIM의 경우 Name 값이 리전이나 사이트 마다 다르다.

     

    만약 공공 환경이라면 ncpcomgov_~~~ 하고 나오는 값이 Name이 되고, 밑에 우르르 있는 값이 Content가 된다.

     

    각 값을 집어넣은 후 인증 버튼을 클릭시 인증이 활성화 된다.

    이럼 도메인 인증은 끝났으니, 다음으로 메일 발송에 대한 설정을 진행할 차례이다.

     

    Spring Boot기준으로 "https://guide-gov.ncloud-docs.com/docs/email-email-1-3" 이곳에 예시 파일과 의존성 파일이 있는 공식 페이지이다.

     

    파일을 다운로드 하면 2026년 06월 기준으로 "nes-client-1.6.2G.jar" 파일이 있다.

    이 파일은 완성된 JAR파일이 아닌 소스코드 상태다. Maven Central 같은 공개 저장소에 있지 않기에 Gradle이나 Maven 이 자동으로 가져올 수 없다.

     

    그래서 "mvn package -DskipTests" 명령어를 통해 사용 가능한 jar 상태로 만들 필요성이 있었다.

     

    그런데 위 명령어를 치니 이런 에러가 떨어졌다.
    "[ERROR] package javax.activation does not exist

    [ERROR] cannot find symbol: class MimetypesFileTypeMap"

     

    제공해준 파일의 ApiClient.java에 이런 라인이 있었다.

     

    javax.activation은 원래 Java SE에 포함되어있던 패키지지만, java 9부터는 모듈 시스템이 도입되면서 이 패키지가 jdk에서 제거 되었다. 참고로 본 포스팅에서 사용한 java는 26버전이다.....

    즉, NCP에서 제공해주는 SDK는 java 8기준에서 작성 되었다는 의미다.

     

    그래서 pom.xml에서 컴파일러 버전을 올려버렸다.

    <!-- 수정 전 -->
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.0</version>
        <configuration>
            <source>8</source>
            <target>8</target>
        </configuration>
    </plugin>
    
    <!-- 수정 후: properties로 일괄 적용 -->
    <properties>
        ...
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
    </properties>

     

    11로 올려서 안정적으로 빌드 되도록 수정하고, 다음으로 pom.xml에서 javax.activation 을 추가했다.

    jdk에서 빠진 패키지를 외부 라이브러리로 다시 제공하기 위함이다. "com.sun.activation:javax.activation" 는 oracle이 JDK에서 분리해 별도로 배포한 공식 라이브러리이다. 패키지명이 jvax.activation.* 이라ㅁ서 코드 수정없이 컴파일이 통과된다.

     

    mvn package -DskipTests

    그리고 다시 컴파일 함으로 Jar안에 NCP SDK의 컴파일된 클래스들을 사용할 수 있게 되었다.

     

    이제 notification 서비스에 만들어진 jar를 libs 폴더를 만들어 복사하고, build.gradle.kts에서 로컬 파일 경로를 직접 추가하여 사용할 수 있게 하였다.

     

    단, jar에는 sdk 코드만 들어있고 sdk가 내부적으로 쓰는 의존성은 포함되어 있지 않다.

    그래서 pom.xml에 선언된 것들을 gradle에 직접 추가할 필요성이 있었다.

       // NCP Cloud Outbound Mailer SDK
        implementation(files("libs/nes-client-1.6.2G.jar"))
        implementation("com.squareup.okhttp3:okhttp:3.9.1")
        implementation("com.squareup.okhttp3:logging-interceptor:3.9.1")
        implementation("com.fasterxml.jackson.core:jackson-databind")
        implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-xml")
        implementation("commons-codec:commons-codec:1.11")
        implementation("org.apache.commons:commons-lang3")
        implementation("com.sun.activation:javax.activation:1.2.0")

    진짜 AI의 도움을 받긴 했지만 개고생했다...

     

     

    위와 같이 수정한다면, 이제 메일을 HTTPS REST API 방식으로 보낼 수 있게 되었다.

     

    코드 내부에 NCP에서 인증키를 가져오는 과정이 필요하다.

     x-ncp-apigw-timestamp: 
     x-ncp-iam-access-key:
     x-ncp-apigw-signature-v2:

     등의 정보가 필요하며, 별도의 추가적인 서비스 없이 오로지 회원가입 메일 발송 용이기에 EmailService안에 다 때려 넣었다.

     

    보통 Spring Boot는 @Autowired나 @RequiredArgsConstructor 로 Bean을 자동 주입한다.

    근데 NCP SDK는 Spring Bean이 아니기에, 직접 객체를 만들어야 해서 생성자 안에 수동으로 조립(?)할 필요성이 있다.

     

    생성자 파라미터:

    public EmailService(
            @Value("${notification.ncp.access-key}") String accessKey,
            @Value("${notification.ncp.secret-key}") String secretKey,
            TemplateEngine templateEngine,
            EmailVerificationRepository verificationRepository)

     

    @Value로 알겠지만, Key값은 application.yaml에 있고, 그 값은 secret으로 관리하고 있으니 아무튼 환경변수에서 값을 가져온다.

     

    그리고 ApiClient를 조립해준다.

          ApiClient apiClient = new ApiClient.ApiClientBuilder()
                    .addMarshaller(JsonMarshaller.getInstance())
                    .addMarshaller(XmlMarshaller.getInstance())
                    .addMarshaller(FormMarshaller.getInstance())
                    .setCredentials(new NcpIamCredentials(accessKey, secretKey))
                    .build();

     

    여기서 Marshaller는 HTTP 요청/응답을 Java 객체 <-> 데이터 형식으로 변환하는 역할을 해준다.

    세가지 형식을 등록해주면, API마다 맞는 형식으로 자동 선택한다.

     

    NcpIamCredentials은 HTTP 요청을 보낼 때 마다 NCP 인증 헤더를 자동으로 붙여주는 친구다.

    위에 언급한 x-ncp-~~ 를 통해 서명값을 만들어 준다.

     

            this.v1Api = new V1Api(apiClient);
            this.templateEngine = templateEngine;
            this.verificationRepository = verificationRepository;

     

    그리고 V1Api는 NCP Outbound Mailer의 모든 API 엔드포인트를 메서드로 묶어놓은 킹-갓 클래스이다.

    apiClient를 품고 있어서 메서드 호출시 자동으로 HTTP 요청을 만들고 인증 헤더를 붙여서 보내준다.

     

    v1Api.mailsPost(request, null);
        → POST https://mail.apigw.gov-ntruss.com/api/v1/mails
        → 헤더: x-ncp-apigw-timestamp, x-ncp-iam-access-key, x-ncp-apigw-signature-v2
        → 바디: {"senderAddress":"...","title":"...","body":"<html>..."}

     

    즉, 생성자에서 NCP 서버와 통신할 준비가 된 HTTP 클라이언트(v1Api)를 한번만 만들고, 메일 발송이 필요할때마다 재사용하는 구조가 된다.

     

    전체코드:

    @Slf4j
    @Service
    public class EmailService {
    
        private final V1Api v1Api;
        private final TemplateEngine templateEngine;
        private final EmailVerificationRepository verificationRepository;
    
        @Value("${notification.mail.from}")
        private String from;
    
        @Value("${notification.mail.from-name}")
        private String fromName;
    
        @Value("${notification.verification.expire-minutes:5}")
        private int expireMinutes;
    
        @Value("${notification.verification.cleanup-minutes:30}")
        private int cleanupMinutes;
    
        private static final SecureRandom RANDOM = new SecureRandom();
    
        public EmailService(
                @Value("${notification.ncp.access-key}") String accessKey,
                @Value("${notification.ncp.secret-key}") String secretKey,
                TemplateEngine templateEngine,
                EmailVerificationRepository verificationRepository) {
    
            ApiClient apiClient = new ApiClient.ApiClientBuilder()
                    .addMarshaller(JsonMarshaller.getInstance())
                    .addMarshaller(XmlMarshaller.getInstance())
                    .addMarshaller(FormMarshaller.getInstance())
                    .setCredentials(new NcpIamCredentials(accessKey, secretKey))
                    .build();
    
            this.v1Api = new V1Api(apiClient);
            this.templateEngine = templateEngine;
            this.verificationRepository = verificationRepository;
        }
    
    (비지니스 로직 생략)
        private void sendHtmlMail(String to, String subject, String htmlContent) {
            try {
                EmailSendRequestRecipients recipient = new EmailSendRequestRecipients();
                recipient.setAddress(to);
                recipient.setType("R");
    
                EmailSendRequest request = new EmailSendRequest();
                request.setSenderAddress(from);
                request.setSenderName(fromName);
                request.setTitle(subject);
                request.setBody(htmlContent);
                request.setRecipients(List.of(recipient));
    
                ApiResponse<EmailSendResponse> response = v1Api.mailsPost(request, null);
                log.debug("NCP 메일 발송 완료: statusCode={}", response.getHttpStatusCode());
            } catch (Exception e) {
                log.error("이메일 발송 실패: to={}, subject={}", to, subject, e);
                throw new RuntimeException("이메일 발송에 실패했습니다.", e);
            }
        }

    댓글