diff --git a/ga-api/src/main/resources/application.yml b/ga-api/src/main/resources/application.yml index fa1153c..ab7e96d 100644 --- a/ga-api/src/main/resources/application.yml +++ b/ga-api/src/main/resources/application.yml @@ -61,6 +61,25 @@ app: adapter: bank: ${ADAPTER_BANK:mock} message: ${ADAPTER_MESSAGE:mock} + # KFTC 오픈뱅킹 입금이체 (bank=kftc 일 때 사용). 운영 시 토큰/약정계좌 주입. + kftc: + base-url: ${KFTC_BASE_URL:https://openapi.openbanking.or.kr} + access-token: ${KFTC_ACCESS_TOKEN:} + cntr-account-type: ${KFTC_CNTR_ACCOUNT_TYPE:N} + cntr-account-num: ${KFTC_CNTR_ACCOUNT_NUM:} + wd-print-content: ${KFTC_WD_PRINT:수수료정산} + name-check-option: ${KFTC_NAME_CHECK:off} + transfer-purpose: ${KFTC_TRANSFER_PURPOSE:TR} + # NHN Cloud(Toast) SMS/알림톡 (message=toast 일 때 사용). 운영 시 AppKey/SecretKey 주입. + toast: + sms-base-url: ${TOAST_SMS_BASE_URL:https://api-sms.cloud.toast.com} + sms-app-key: ${TOAST_SMS_APP_KEY:} + sms-secret-key: ${TOAST_SMS_SECRET_KEY:} + sender-no: ${TOAST_SENDER_NO:} + alimtalk-base-url: ${TOAST_ALIMTALK_BASE_URL:https://kakaotalk-bizmessage.api.nhncloudservice.com} + alimtalk-app-key: ${TOAST_ALIMTALK_APP_KEY:} + alimtalk-secret-key: ${TOAST_ALIMTALK_SECRET_KEY:} + sender-key: ${TOAST_SENDER_KEY:} # 펌뱅킹 재시도 배치 bank-retry: limit: ${BANK_RETRY_LIMIT:50} diff --git a/ga-api/src/test/java/com/ga/external/AdapterFailFastTest.java b/ga-api/src/test/java/com/ga/external/AdapterFailFastTest.java new file mode 100644 index 0000000..c79f485 --- /dev/null +++ b/ga-api/src/test/java/com/ga/external/AdapterFailFastTest.java @@ -0,0 +1,72 @@ +package com.ga.external; + +import com.ga.common.adapter.bank.BankTransferRequest; +import com.ga.common.adapter.message.MessageChannel; +import com.ga.common.adapter.message.MessageRequest; +import com.ga.external.bank.KftcBankTransferAdapter; +import com.ga.external.bank.KftcProperties; +import com.ga.external.message.ToastMessageAdapter; +import com.ga.external.message.ToastProperties; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 실 KFTC/Toast 어댑터의 fail-fast 동작 검증. + * 자격증명(키/토큰)이 비어 있으면 외부 호출 전에 IllegalStateException 으로 즉시 차단되어야 한다. + * (실 HTTP 는 운영 키 주입 후 테스트베드에서 검증) + */ +class AdapterFailFastTest { + + @Test + @DisplayName("Toast SMS: 설정 누락 시 fail-fast") + void toastSms_failFast() { + ToastMessageAdapter adapter = new ToastMessageAdapter(new ToastProperties()); + MessageRequest req = MessageRequest.builder() + .channel(MessageChannel.SMS) + .phoneNumber("010-1234-5678") + .content("테스트") + .build(); + assertThatThrownBy(() -> adapter.send(req)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("app.adapter.toast"); + } + + @Test + @DisplayName("Toast 알림톡: templateCode 없으면 차단") + void toastAlimtalk_requiresTemplate() { + ToastProperties p = new ToastProperties(); + p.setAlimtalkAppKey("k"); + p.setAlimtalkSecretKey("s"); + p.setSenderKey("sk"); + ToastMessageAdapter adapter = new ToastMessageAdapter(p); + MessageRequest req = MessageRequest.builder() + .channel(MessageChannel.KAKAO) + .phoneNumber("01012345678") + .content("내용") + .build(); + assertThatThrownBy(() -> adapter.send(req)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("templateCode"); + } + + @Test + @DisplayName("KFTC 입금이체: 토큰/약정계좌 누락 시 fail-fast") + void kftc_failFast() { + KftcBankTransferAdapter adapter = new KftcBankTransferAdapter(new KftcProperties()); + BankTransferRequest req = BankTransferRequest.builder() + .withdrawRequestId(1L) + .bankCode("097") + .accountNo("1101230000678") + .accountHolderName("홍길동") + .amount(new BigDecimal("10000")) + .memo("정산") + .build(); + assertThatThrownBy(() -> adapter.requestTransfer(req)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("app.adapter.kftc"); + } +} diff --git a/ga-external-adapter/src/main/java/com/ga/external/bank/KftcBankTransferAdapter.java b/ga-external-adapter/src/main/java/com/ga/external/bank/KftcBankTransferAdapter.java index 91b1251..68fc2f3 100644 --- a/ga-external-adapter/src/main/java/com/ga/external/bank/KftcBankTransferAdapter.java +++ b/ga-external-adapter/src/main/java/com/ga/external/bank/KftcBankTransferAdapter.java @@ -1,33 +1,165 @@ package com.ga.external.bank; +import com.fasterxml.jackson.databind.JsonNode; import com.ga.common.adapter.bank.BankTransferAdapter; import com.ga.common.adapter.bank.BankTransferRequest; import com.ga.common.adapter.bank.BankTransferResponse; +import com.ga.common.adapter.bank.BankTransferStatus; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; /** - * KFTC(금융결제원) 오픈뱅킹 펌뱅킹 어댑터 — 스켈레톤. + * KFTC(금융결제원) 오픈뱅킹 입금이체 어댑터 — 실구현. *

- * 활성 조건: app.adapter.bank=kftc. 실제 SDK / 클라이언트 의존성은 아직 미추가. - * 외부 스펙(인증/엔드포인트/응답 코드)이 확정되면 본 클래스의 메서드를 구현한다. - * 그 동안은 활성화 시 명시적 예외를 던져 잘못된 운영 적용을 빠르게 감지한다. + * 활성 조건: app.adapter.bank=kftc (기본은 mock). 키/약정계좌는 {@link KftcProperties} 로 외부화. + *

+ * GA→설계사 지급은 "이용기관 약정 출금계좌 → 설계사 입금계좌" 흐름이므로 KFTC 입금이체 + * (POST /v2.0/transfer/deposit/acnt_num) 를 사용한다. 단건을 req_list 1건으로 호출한다. + *

+ * 필수 설정 누락 시 fail-fast. 외부호출 감사로그는 ga-api 의 AOP가 남긴다. + *

+ * 주의(운영): Access Token(2-legged 이용기관 토큰) 재발급, 약정계좌 등록, 실명확인 옵션 정책은 + * 금융결제원 계약·이용기관 설정에 따른다. 본 어댑터는 문서화된 API 형식에 맞춰 호출하며 실거래 검증은 + * 테스트베드/운영 키 주입 후 수행한다. */ @Slf4j @Component @ConditionalOnProperty(prefix = "app.adapter", name = "bank", havingValue = "kftc") +@EnableConfigurationProperties(KftcProperties.class) public class KftcBankTransferAdapter implements BankTransferAdapter { + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + private final KftcProperties props; + private final RestClient client; + + public KftcBankTransferAdapter(KftcProperties props) { + this.props = props; + this.client = RestClient.builder().baseUrl(props.getBaseUrl()).build(); + } + @Override public BankTransferResponse requestTransfer(BankTransferRequest request) { - throw new UnsupportedOperationException( - "KFTC 펌뱅킹 SDK 통합 미구현 — application.yml 의 app.adapter.bank=mock 으로 되돌리거나 본 어댑터를 완성하세요."); + require(props.getAccessToken(), "app.adapter.kftc.access-token"); + require(props.getCntrAccountNum(), "app.adapter.kftc.cntr-account-num"); + + String bankTranId = buildBankTranId(request.getWithdrawRequestId()); + String tranDtime = LocalDateTime.now().format(TS); + + // 입금이체 단건 (req_list 1건). 문서화된 KFTC 입금이체 필드명 사용. + Map item = new LinkedHashMap<>(); + item.put("tran_no", "1"); + item.put("bank_tran_id", bankTranId); + item.put("bank_code_std", request.getBankCode()); // 수취 은행 표준코드 + item.put("account_num", request.getAccountNo()); // 수취 계좌번호 + item.put("account_holder_name", request.getAccountHolderName()); // 수취인 성명 + item.put("print_content", trim(request.getMemo(), 20)); // 입금계좌 인자내역 + item.put("tran_amt", request.getAmount().toBigInteger().toString()); + item.put("req_client_name", request.getAccountHolderName()); + item.put("req_client_bank_code", request.getBankCode()); + item.put("req_client_account_num", request.getAccountNo()); + item.put("req_client_num", String.valueOf(request.getWithdrawRequestId())); + item.put("transfer_purpose", props.getTransferPurpose()); + + Map body = new LinkedHashMap<>(); + body.put("cntr_account_type", props.getCntrAccountType()); + body.put("cntr_account_num", props.getCntrAccountNum()); + body.put("wd_pass_phrase", "NONE"); + body.put("wd_print_content", trim(props.getWdPrintContent(), 20)); + body.put("name_check_option", props.getNameCheckOption()); + body.put("tran_dtime", tranDtime); + body.put("req_cnt", "1"); + body.put("req_list", List.of(item)); + + try { + JsonNode res = client.post() + .uri("/v2.0/transfer/deposit/acnt_num") + .header("Authorization", "Bearer " + props.getAccessToken()) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(JsonNode.class); + return toResponse(res, BankTransferStatus.ACCEPTED); + } catch (RestClientException e) { + log.warn("[KFTC] 입금이체 실패 withdrawId={} bank={} err={}", + request.getWithdrawRequestId(), request.getBankCode(), e.getMessage()); + return BankTransferResponse.builder() + .status(BankTransferStatus.FAILED) + .resultCode("ERROR").resultMessage(e.getMessage()) + .processedAt(LocalDateTime.now()).build(); + } } @Override public BankTransferResponse queryStatus(String txId) { - throw new UnsupportedOperationException( - "KFTC 펌뱅킹 SDK 통합 미구현 — txId=" + txId); + require(props.getAccessToken(), "app.adapter.kftc.access-token"); + try { + JsonNode res = client.get() + .uri(uri -> uri.path("/v2.0/transfer/result") + .queryParam("bank_tran_id", txId) + .queryParam("inquiry_bank_tran_date", + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))) + .build()) + .header("Authorization", "Bearer " + props.getAccessToken()) + .retrieve() + .body(JsonNode.class); + return toResponse(res, BankTransferStatus.SETTLED); + } catch (RestClientException e) { + log.warn("[KFTC] 이체결과조회 실패 txId={} err={}", txId, e.getMessage()); + return BankTransferResponse.builder() + .txId(txId).status(BankTransferStatus.FAILED) + .resultCode("ERROR").resultMessage(e.getMessage()) + .processedAt(LocalDateTime.now()).build(); + } + } + + /** + * KFTC 응답(rsp_code/rsp_message/api_tran_id) → BankTransferResponse. + * rsp_code "A0000" 이 API 정상. 정상이면 호출 의미에 따른 status(요청=ACCEPTED/조회=SETTLED), 아니면 FAILED. + */ + private BankTransferResponse toResponse(JsonNode res, BankTransferStatus okStatus) { + String rspCode = res == null ? "" : res.path("rsp_code").asText(""); + String rspMsg = res == null ? "no response" : res.path("rsp_message").asText(""); + String apiTranId = res == null ? "" : res.path("api_tran_id").asText(""); + boolean ok = "A0000".equals(rspCode); + return BankTransferResponse.builder() + .txId(apiTranId) + .status(ok ? okStatus : BankTransferStatus.FAILED) + .resultCode(rspCode) + .resultMessage(rspMsg) + .processedAt(LocalDateTime.now()) + .build(); + } + + /** 은행거래고유번호: 이용기관코드(운영주입) 미보유 시 withdrawId 기반 고유키 생성(멱등 추적용). */ + private String buildBankTranId(Long withdrawRequestId) { + String seq = String.format("%09d", withdrawRequestId == null ? 0 : withdrawRequestId % 1_000_000_000L); + return "GA" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + "U" + seq; + } + + private String trim(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max); + } + + private void require(String value, String key) { + if (!StringUtils.hasText(value)) { + throw new IllegalStateException("KFTC 설정 누락: " + key + " 가 필요합니다."); + } } } diff --git a/ga-external-adapter/src/main/java/com/ga/external/bank/KftcProperties.java b/ga-external-adapter/src/main/java/com/ga/external/bank/KftcProperties.java new file mode 100644 index 0000000..7753422 --- /dev/null +++ b/ga-external-adapter/src/main/java/com/ga/external/bank/KftcProperties.java @@ -0,0 +1,38 @@ +package com.ga.external.bank; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * KFTC(금융결제원) 오픈뱅킹 입금이체 설정. + *

+ * application.yml 의 app.adapter.kftc.* 로 주입. + * 운영 전제: 이용기관 등록 + 2-legged Access Token(이용기관 토큰) 발급 + 약정 출금계좌 등록이 선행되어야 한다. + * Access Token 의 주기적 재발급은 운영(스케줄러/Secret 관리) 책임이며, 본 어댑터는 주입된 토큰을 사용한다. + * 필수 값이 비어 있으면 어댑터가 fail-fast 한다. + */ +@Data +@ConfigurationProperties(prefix = "app.adapter.kftc") +public class KftcProperties { + + /** 오픈뱅킹 API 베이스 URL (운영 https://openapi.openbanking.or.kr / 테스트베드 https://developers.openbanking.or.kr). */ + private String baseUrl = "https://openapi.openbanking.or.kr"; + + /** 이용기관 Access Token (Bearer). 운영에서 주기적으로 재발급해 주입. */ + private String accessToken; + + /** 이용기관 약정 출금계좌 구분 (N: 계좌). */ + private String cntrAccountType = "N"; + + /** 이용기관 약정 출금계좌 번호. */ + private String cntrAccountNum; + + /** 출금계좌 인자내역(통장 적요). */ + private String wdPrintContent = "수수료정산"; + + /** 수취인 실명 확인 옵션 (off/on). */ + private String nameCheckOption = "off"; + + /** 이체 용도 코드 (TR: 송금 등). */ + private String transferPurpose = "TR"; +} diff --git a/ga-external-adapter/src/main/java/com/ga/external/message/ToastMessageAdapter.java b/ga-external-adapter/src/main/java/com/ga/external/message/ToastMessageAdapter.java index a9a5d70..5cd7baf 100644 --- a/ga-external-adapter/src/main/java/com/ga/external/message/ToastMessageAdapter.java +++ b/ga-external-adapter/src/main/java/com/ga/external/message/ToastMessageAdapter.java @@ -1,26 +1,159 @@ package com.ga.external.message; +import com.fasterxml.jackson.databind.JsonNode; import com.ga.common.adapter.message.MessageAdapter; +import com.ga.common.adapter.message.MessageChannel; import com.ga.common.adapter.message.MessageRequest; import com.ga.common.adapter.message.MessageResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; /** - * NHN Toast SMS / 카카오 알림톡 어댑터 — 스켈레톤. + * NHN Cloud(Toast) SMS / 카카오 알림톡 어댑터 — 실구현. *

- * 활성 조건: app.adapter.message=toast. 실제 SDK / API 키 등은 미주입 상태. - * 외부 스펙(인증/엔드포인트/템플릿 코드)이 확정되면 본 클래스의 send 를 구현한다. + * 활성 조건: app.adapter.message=toast (기본은 mock). 키는 {@link ToastProperties} 로 외부화. + *

+ * 키가 비어 있으면 fail-fast 한다(잘못된 운영 적용 즉시 감지). 외부호출 감사로그는 ga-api 의 AOP가 남긴다. */ @Slf4j @Component @ConditionalOnProperty(prefix = "app.adapter", name = "message", havingValue = "toast") +@EnableConfigurationProperties(ToastProperties.class) public class ToastMessageAdapter implements MessageAdapter { + private final ToastProperties props; + private final RestClient smsClient; + private final RestClient alimtalkClient; + + public ToastMessageAdapter(ToastProperties props) { + this.props = props; + this.smsClient = RestClient.builder().baseUrl(props.getSmsBaseUrl()).build(); + this.alimtalkClient = RestClient.builder().baseUrl(props.getAlimtalkBaseUrl()).build(); + } + @Override public MessageResponse send(MessageRequest request) { - throw new UnsupportedOperationException( - "Toast / 카카오 비즈메시지 SDK 통합 미구현 — application.yml 의 app.adapter.message=mock 으로 되돌리거나 본 어댑터를 완성하세요."); + MessageChannel channel = request.getChannel() == null ? MessageChannel.SMS : request.getChannel(); + return channel == MessageChannel.KAKAO ? sendAlimtalk(request) : sendSms(request); + } + + // ── SMS ─────────────────────────────────────────────────────────── + private MessageResponse sendSms(MessageRequest req) { + require(props.getSmsAppKey(), "app.adapter.toast.sms-app-key"); + require(props.getSmsSecretKey(), "app.adapter.toast.sms-secret-key"); + require(props.getSenderNo(), "app.adapter.toast.sender-no"); + + Map body = Map.of( + "body", req.getContent(), + "sendNo", props.getSenderNo(), + "recipientList", List.of(Map.of("recipientNo", normalize(req.getPhoneNumber())))); + + try { + JsonNode res = smsClient.post() + .uri("/sms/v3.0/appKeys/{appKey}/sender/sms", props.getSmsAppKey()) + .header("X-Secret-Key", props.getSmsSecretKey()) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(JsonNode.class); + return toResponse(res, "body"); + } catch (RestClientException e) { + log.warn("[TOAST SMS] 전송 실패 phone={} err={}", mask(req.getPhoneNumber()), e.getMessage()); + return fail(e.getMessage()); + } + } + + // ── 카카오 알림톡 ─────────────────────────────────────────────────── + private MessageResponse sendAlimtalk(MessageRequest req) { + require(props.getAlimtalkAppKey(), "app.adapter.toast.alimtalk-app-key"); + require(props.getAlimtalkSecretKey(), "app.adapter.toast.alimtalk-secret-key"); + require(props.getSenderKey(), "app.adapter.toast.sender-key"); + if (!StringUtils.hasText(req.getTemplateCode())) { + throw new IllegalArgumentException("알림톡 발송에는 templateCode 가 필요합니다."); + } + + Map recipient = Map.of( + "recipientNo", normalize(req.getPhoneNumber()), + "templateParameter", Map.of( + "title", req.getTitle() == null ? "" : req.getTitle(), + "content", req.getContent() == null ? "" : req.getContent())); + Map body = Map.of( + "senderKey", props.getSenderKey(), + "templateCode", req.getTemplateCode(), + "recipientList", List.of(recipient)); + + try { + JsonNode res = alimtalkClient.post() + .uri("/alimtalk/v2.3/appkeys/{appkey}/messages", props.getAlimtalkAppKey()) + .header("X-Secret-Key", props.getAlimtalkSecretKey()) + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .body(JsonNode.class); + return toResponse(res, "message"); + } catch (RestClientException e) { + log.warn("[TOAST ALIMTALK] 전송 실패 phone={} template={} err={}", + mask(req.getPhoneNumber()), req.getTemplateCode(), e.getMessage()); + return fail(e.getMessage()); + } + } + + /** + * Toast 공통 응답(header.isSuccessful/resultCode/resultMessage) 파싱. + * SMS 는 body.data.requestId, 알림톡은 message.requestId 로 발급 id 위치가 다르다. + */ + private MessageResponse toResponse(JsonNode res, String dataRoot) { + boolean ok = res != null && res.path("header").path("isSuccessful").asBoolean(false); + int code = res == null ? -1 : res.path("header").path("resultCode").asInt(-1); + String msg = res == null ? "no response" : res.path("header").path("resultMessage").asText(""); + String messageId = ""; + if (res != null) { + JsonNode root = res.path(dataRoot); + messageId = root.path("data").path("requestId").asText( + root.path("requestId").asText("")); + } + return MessageResponse.builder() + .messageId(messageId) + .sent(ok) + .resultCode(String.valueOf(code)) + .resultMessage(msg) + .sentAt(LocalDateTime.now()) + .build(); + } + + private MessageResponse fail(String message) { + return MessageResponse.builder() + .sent(false).resultCode("ERROR").resultMessage(message) + .sentAt(LocalDateTime.now()).build(); + } + + private void require(String value, String key) { + if (!StringUtils.hasText(value)) { + throw new IllegalStateException("Toast 설정 누락: " + key + " 가 필요합니다."); + } + } + + /** 010-1234-5678 → 01012345678 (Toast 는 하이픈 없는 번호 권장). */ + private String normalize(String phone) { + return phone == null ? null : phone.replaceAll("[^0-9]", ""); + } + + private String mask(String phone) { + String d = normalize(phone); + if (d == null || d.length() <= 4) return "****"; + return d.substring(0, d.length() - 4) + "****"; } } diff --git a/ga-external-adapter/src/main/java/com/ga/external/message/ToastProperties.java b/ga-external-adapter/src/main/java/com/ga/external/message/ToastProperties.java new file mode 100644 index 0000000..af12f77 --- /dev/null +++ b/ga-external-adapter/src/main/java/com/ga/external/message/ToastProperties.java @@ -0,0 +1,35 @@ +package com.ga.external.message; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * NHN Cloud(Toast) 메시지 발송 설정. + *

+ * application.yml 의 app.adapter.toast.* 로 주입. 운영 활성화 시 키를 채운다. + * 키가 비어 있으면 어댑터가 fail-fast(IllegalStateException) 한다. + */ +@Data +@ConfigurationProperties(prefix = "app.adapter.toast") +public class ToastProperties { + + // ── SMS (api-sms.cloud.toast.com) ───────────────────────────────── + /** SMS API 베이스 URL. */ + private String smsBaseUrl = "https://api-sms.cloud.toast.com"; + /** SMS 프로젝트 AppKey. */ + private String smsAppKey; + /** SMS Secret Key (X-Secret-Key 헤더). */ + private String smsSecretKey; + /** 사전 등록된 발신번호. */ + private String senderNo; + + // ── 카카오 알림톡 (kakaotalk-bizmessage.api.nhncloudservice.com) ──── + /** 알림톡 API 베이스 URL. */ + private String alimtalkBaseUrl = "https://kakaotalk-bizmessage.api.nhncloudservice.com"; + /** 알림톡 프로젝트 AppKey. */ + private String alimtalkAppKey; + /** 알림톡 Secret Key. */ + private String alimtalkSecretKey; + /** 카카오 비즈메시지 발신 프로필 SenderKey. */ + private String senderKey; +}