작업 환경

aws ec2 ubuntu 20.04

 

공식 문서

https://docs.docker.com/config/containers/logging/awslogs/

 

Amazon CloudWatch Logs logging driver

 

docs.docker.com

 

1. ec2 IAM에 CloudWatch 권한 역할 부여

ec2->인스턴스->작업->보안->IAM 역할 수정

기존에 IAM을 생성해두지 않은 상태로 새 IAM 역할 생성 -> 역할 만들기 -> AWS 서비스-> EC2

CloudWatchLogsFullAccess 권한 부여 -> 역할 생성

참조) docker 공식 문서에서는 아래와 같은 권한 2개 부여하라는데 권한 검색에 검색하면 안뜬다.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": [
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Effect": "Allow",
      "Resource": "*"
    }
  ]
}

구글링 통해 fullaccess 정책 권한 부여했다. 공식 문서 업데이트가 필요해보인다.

이후 ec2 -> 인스턴스 -> 관련 인스턴스 선택 -> 보안 -> IAM 역할 수정에서 방금 만든 역할을 선택해 연결한다.

 

2. CloudWatch 로그 그룹/로그 스트림 생성

로그를 보내는 역할 설정을 완료했으므로 이제 로그를 받을 수 있는 공간 설정을 해야한다.

AWS CloudWatch -> 로그 -> 로그 그룹 -> 로그 그룹 생성 

생성한 로그 그룹 클릭 -> 로그 스트림 -> 로그 스트림 생성

3. docker-compose에 로깅 관련 정보 추가

services:
  backend:
    image: 가나다
    container_name: 라마바사
    
    ##아래 부분 생성한 정보대로 내용 추가하시면 됩니다.
    logging:
      driver: awslogs
      options:
        awslogs-group: higoods-docker-log
        awslogs-region: ap-northeast-2
        awslogs-stream: backend

 

정상 작동 확인

참고사이트

https://docs.docker.com/config/containers/logging/awslogs/

https://kitty-geno.tistory.com/67

https://ch-visu4l.tistory.com/14

https://devnm.tistory.com/8

 

최근에 진행 중인 2개의 프로젝트에 jacoco와 sonar cloud를 적용했다.

sonar cloud만 적용할 경우 아래와 같이 테스트 커버리지 값은 노출되지 않는다.

AS IS

jacoco를 활용해 테스트 커버리지를 측정하고 해당 측정 값을 소나클라우드로 전송해 소나클라우드 상에서도 테스트 커버리지를 함께 다룰 수 있는 방법을 소개하려고 한다.

TO BE

1. 전체 흐름

먼저 작업을 들어가기 전에 전체 작업 맥락은 다음과 같다.

jacoco를 통해 테스트 커버리지를 측정하고 측정 값을 xml 파일로 저장한다.

프로젝트 test 실행시 jacoco 테스트 커버리지가 측정 되며 지정된 경로에 xml 파일로 결과가 저장된다.

저장한 xml 파일을 소나클라우드로 전송한다.

소나클라우드에서는 해당 값을 활용해 테스트 커버리지 값을 매핑하고 다른 정적 검사 지표들과 함께 노출시킨다.

필자는 ci 스크립트를 활용해 main, develop 브랜치로 풀리퀘를 보낼 때 소나클라우드 검사 결과가 comment와 같이 뜨도록 설정하고자한다.

또한, 예시 코드는 코틀린으로 사용 중인 언어에 맞게 아래 코드들을 수정해 사용하면 된다.

2. jacoco 연동

jacoco란?

자바 코드 커버리지 측정 오픈 소스 라이브러리

 

build.gradle에 관련 연동 정보 추가

plugins {
    id("jacoco")
}

멀티 모듈 사용 중으로 subprojects 블록에 아래 jacoco 관련 설정 내용을 추가해준다.

subprojects {
    apply(plugin = "jacoco")
    tasks.test { //test 실행 시 jacocoTestReport 실행
        finalizedBy(tasks.jacocoTestReport) // report is always generated after tests run
    }
    tasks.jacocoTestReport {
        dependsOn(tasks.test) // tests are required to run before generating the report
        reports {
            xml.required.set(true) // xml 설정
            xml.outputLocation.set(File("$buildDir/reports/jacoco.xml")) //xml로 output
        }
        classDirectories.setFrom(
            files(
                classDirectories.files.map {
                    fileTree(it) { // 테스트 커버리지 측정 제외 목록
                        exclude(
                            "**/*Application*",
                            "**/*Config*",
                            "**/*Dto*",
                            "**/*Request*",
                            "**/*Response*",
                            "**/*Interceptor*",
                            "**/*Exception*"
                            //필요한 제외 목록 더 추가하시면 됩니다.
                        )
                    }
                }
            )
        )
    }
    }

test 실행 시 jacocoTestReport가 실행된다.

해당 결과 레포트를 xml로 받으며 파일의 저장 위치를 지정해줬다.

이때, 테스트 커버리지 측정에서 제외할 목록들 또한 설정해줬다.

 

테스트 실행시 지정된 경로에 측정 값 잘 저장되는 거 확인 가능

3. 소나클라우드 가입 및 설정

소나클라우드에 가입하여 자신이 작업 중인 조직과 레포지토리를 생성한다.

 

quality gate 생성을 통해 팀원들과 test coverage 통과 비율과 같은 수치들을 논의해 생성 후 프로젝트와 매핑하면 된다.

생성안할시 sonar way가 기본 디폴트 quality gate이며 해당 값들은 아래와 같다.

소나클라우드는 기본적으로 자동 검사를 지원한다. 필자는 ci 스크립트를 통해 분석할 것이기 때문에 자동 검사 옵션을 꺼줘야한다.

소나클라우드 레포지토리->administration->analysis method에서 비활성화한다.

 

이때, 레포지토리에서 administration이 안뜬다면 조직에 대한 권한이 없는거다.

조직 권한자에 부탁해 권한을 받은 후 추가적으로 조직->administration->groups->owners에서 자신의 소나클라우드 계정을 추가해야지만 administration 설정창이 뜬다.

analysis method를 비활성화하지 않고 스크립트에서도 실행할 경우 충돌이 일어나며 깃헙 액션즈 fail이 발생하므로 옵션을 꼭!! 꺼줘야한다.

소나클라우드 security 페이지에서 토큰을 생성한다.

해당 토큰은 ci 스크립트에 들어가는 토큰으로 깃헙 액션즈에 넣어야하므로 잘 기억해두자.

4. sonar cloud 연동

build.gradle에 관련 연동 정보 추가

plugins {
    id("org.sonarqube") version "4.2.1.3168"
}

sonarqube {
    properties {
        property("sonar.projectKey", "프로젝트명")
        property("sonar.organization", "조직명")
        property("sonar.host.url", "https://sonarcloud.io")
        // sonar additional settings
        property("sonar.sources", "src")
        property("sonar.language", "Kotlin")
        property("sonar.sourceEncoding", "UTF-8")
        property("sonar.test.inclusions", "**/*Test.java")
        property("sonar.exclusions", "**/test/**, **/Q*.kt, **/*Doc*.kt, **/resources/** ,**/*Application*.kt , **/*Config*.kt, **/*Dto*.kt, **/*Request*.kt, **/*Response*.kt ,**/*Exception*.kt ,**/*ErrorCode*.kt")
        property("sonar.java.coveragePlugin", "jacoco")
    }
}

subprojects {
    sonarqube {
        properties {
            property("sonar.java.binaries", "$buildDir/classes")
            //아래에 jacoco 연동시 지정해줬던 경로를 넣어줘야한다.
            property("sonar.coverage.jacoco.xmlReportPaths", "$buildDir/reports/jacoco.xml")
        }
    }
}

5. ci 스크립트 작성

on:
  pull_request:
    branches: [ "main", "develop" ]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    - name: Set up JDK 11
      uses: actions/setup-java@v3
      with:
        java-version: '11'
        distribution: 'temurin'
        
      - name: Cache SonarCloud packages
        uses: actions/cache@v3
        with:
          path: ~/.sonar/cache
          key: ${{ runner.os }}-sonar
          restore-keys: ${{ runner.os }}-sonar

      - name: Cache Gradle packages
        uses: actions/cache@v3
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }}
          restore-keys: ${{ runner.os }}-gradle

    - name: test and analyze
      run: ./gradlew test sonar --info --stacktrace --no-daemon
      env:
        GITHUB_TOKEN: ${{ secrets.GIT_TOKEN }}
        SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

깃헙 토큰 발급 방법

프로필 사진 -> settings -> developer settings -> personal access tokens

레포지토리 settings에서 소나토큰과 깃헙 토큰을 넣어준다.

 

+ caused by: java.lang.outofmemoryerror: metaspace 에러 발생시

한 프로젝트에서는 ci 스크립트에 sonar 실행 시 oom 오류가 발생했고 한 프로젝트는 정상 작동했다.

프로젝트에 따라 heap 영역 메모리 사용량이 달라 발생했던 문제로

문제가 발생했던 프로젝트의 sonar 실행 명령어에 -Dorg.gradle.jvmargs="-Xmx2g" 옵션을 추가해 메모리를 늘려 해결했다.

ex. ./gradlew sonar -Dorg.gradle.jvmargs="-Xmx2g" --stacktrace --no-daemon

프로젝트 gradlew 설정에서 프로젝트의 -xmx -xms 값을 늘려 해결하는 방법도 있었는데 sonar task만을 위해 확장하는게 효율적이지 않다 생각하여 스크립트 상에서 soanr 실행시에만 메모리를 늘려 해결하는 방식을 택했다.

 

 

참고 사이트

https://docs.sonarcloud.io/advanced-setup/ci-based-analysis/github-actions-for-sonarcloud/

https://techblog.woowahan.com/2661/

https://hsik0225.github.io/sonarqube/sonarcloud/tool/2021/11/23/SonarCloud-SonarCloud-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0/

https://devnm.tistory.com/36

https://hoohaha.tistory.com/37

https://velog.io/@haerong22/Spring-Cloud-%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-MSA-6.-%EC%BD%94%EB%93%9C-%ED%92%88%EC%A7%88-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0jacoco-sonarqube

인트로

최근 개발 연합 동아리에서 새로운 프로젝트를 시작했다.

프로젝트 세팅부터 시작해야되는 상황이라 패키지 구조를 어떻게 가져갈까 고민하던 중 멀티 모듈을 적용해보기로 했다.

 

이전에 이미 멀티 모듈로 구성된 프로젝트에 들어가 개발을 해본 적은 있었으나 세팅부터 들어간 적은 처음이라

멀티 모듈 적용이 적절한 상황인지, 의미가 있을지에 대한 판단부터 필요했다.

그 과정에서 여러 레퍼런스들을 참고하며 많은 고민을 했는데,

입문자를 위한 레퍼런스가 적어 나와 같이 처음 멀티 모듈을 도입하는 사람들에게 내가 겪은 경험과 고민을 공유하고자 글을 쓰게 됐다.

 

멀티 모듈이란?

하나의 서비스나 프로젝트를 여러 개의 모듈로 나누어 개발하는 것

각 모듈을 독립적으로 빌드하고 배포할 수 있고, 다른 모듈과 의존성을 가지며 연결시킬 수 있다.

 

즉, 각각의 모듈들 중 필요한 모듈들만 의존성으로 연결해 원하는 서비스를 만들어낼 수도 있으며 여러 모듈이나 프로젝트에서 동일한 모듈을 중복 사용하며 코드의 중복을 피할 수 있다.

각각의 기준에 맞춰 모듈들이 분리돼있으므로 유지보수가 쉽다는 장점도 있다.

 

이 개념을 처음 알게 됐을 때 객체지향의 개념과 유사한 느낌을 받았다.

 

멀티 모듈 쓸까? 말까? 

멀티모듈은 보통 대규모 프로젝트에서 많이 쓰인다고 한다. 

모듈 단위로 개발을 분할하면 개발자들은 보다 집중적으로 각자 맡은 모듈을 개발할 수 있으며

모듈들을 쉽게 조합해 사용할 수 있기 때문에 프로젝트 확장성이 좋기 때문이다.

 

지금하고 있는 프로젝트가 3개월 안에 기획과 출시가 끝나야하는 말그대로 사이드 프로젝트라

멀티모듈을 적용할만한 사이즈인가 의문이 들었다.

처음에는 디렉토리만 잘 나누면되고 멀티 모듈까지 필요가 없다 생각해 적용을 안하려고 했다.

하지만, 기획 회의를 한 후 서비스가 점진적으로 더 규모가 커질 가능성이 있고

모듈로 분리돼있는 특성상 코드들의 응집성이 높아 개발할 때도 충분한 이점이 있다는 생각이 들어 도입하기로 했다.

 

모듈 분리 기준

그래, 멀티 모듈 좋네. 근데 이거 적용하려면 뭐 어떻게 나눠야하는거야? 

 

구글링으로 멀티 모듈 관련 글들을 쫙 봤는데 다들 멀티 모듈 적용하는 방법이나 적용 후 생겼던 문제점들을 해결하는 얘기만 있고 어떤 기준으로 모듈을 나눴는지에 대한 정보 찾기가 어려웠다.

그래서 멀티 모듈을 이미 구성한 글들, 깃헙 레포지토리들, chatgpt를 털어서 나름의 표준 기준을 역으로 분석했다.

 

결론부터 말하자면, 프로젝트의 특성과 개발자의 판단에 따라 다양한 기준이 존재했다.

정답은 없는 것 같으나 대체로 많이 보이는 구성은 존재했고 아래에서 설명한다.

 

먼저, 크게 도메인으로 나누거나 백엔드 개발의 관점에서 나누거나의 차이가 존재했다.

도메인 관점

예를 들어, 유저가 책을 주문하는 서비스의 경우 모듈을 유저, 책, 주문과 같은 형식으로 나누어 모듈을 분리한다.

도메인을 기준으로 나누는 것으로 DDD가 떠올랐다.

 

백엔드 개발 관점

이 경우 다시 또 2가지의 케이스가 존재했다.

 

첫 번째는 아키텍처를 기준으로 분리하는 것이다. 

예시로는 계층형 아키텍처를 사용할 경우 컨트롤러, 서비스, 레포지토리(+도메인)를 각각 다른 모듈로 분리한다.

 

두 번째는 관련된 서버(api 서버, 배치 서버 등)를 기준으로 분리하는 것이다.

예시로는 컨트롤러+서비스(api 서버), 레포지토리+도메인(db 서버), 배치 서버 등 관련된 서버를 기준으로 모듈을 분리한다.

 

이 중에서도 사이드 프로젝트에서 보통 많이 쓰는 구성은 백엔드 개발 관점에서 관련된 서버를 기준으로 분리하는 방식이었고

필자도 해당 방식을 택했다.

module-api (컨트롤러+서비스)

module-domain (레포지토리+엔티티)

module-infrastructure (외부 api)

module-batch (배치 서버)

 

여기서 좀 더 덧붙이면 core나 common과 같은 자주 쓰이는 모듈들이 존재하는데 이는 개발자의 판단에 따라 넣거나 다른 모듈에서 해당 기능들을 수행하면 될거라 판단한다.

얼마나 잘게쪼갤지도 개발자의 선택이었다.

 

common 모듈을 사용할 경우 아래 우아한 형제 블로그 글을 참고해 유의사항들을 인지 후 작업하면 좋을 것 같다.

https://techblog.woowahan.com/2637/

 

멀티모듈 설계 이야기 with Spring, Gradle | 우아한형제들 기술블로그

{{item.name}} 멀티 모듈 설계 이야기 안녕하세요. 배달의민족 프론트 서버를 개발하고 있는 권용근입니다. 멀티 모듈의 개념을 처음알게 되었을 때부터 현재까지 겪었던 문제점들과 그것을 어떻게

techblog.woowahan.com

 

멀티모듈 적용법은 구글링하면 수두룩빽빽하게 나와서 글 하단에 참고했던 괜찮은 글들 링크로 대체한다.

작업하면서 팁이 있다면 주인장의 경우 인텔리제이에서 new module 기능을 사용해 모듈을 생성하려고하니 language로 kotiln이 안뜨는 에러가 있었고 구글링해보니 같은 오류를 겪는 사람들이 존재했으며 이슈로 등록돼있는 상태였다.

다른 방식으로 멀티모듈을 구성했고 아래 레퍼런스의 1, 2번째를 참고하면 된다.

 

다른 사람들이 이미 멀티 모듈을 구성한 레포지토리들을 보는 것도 많은 도움이 됐는데

주인장이 몇 개의 레포지토리를 특정해 올리는 것보단

깃헙에서 multimodule과 같은 검색어로 검색해 더 다양한 레포지토리를 비교해보시길 추천한다!

 

+기술 면접을 보면서 msa 아키텍처와 연관된 질문을 받았다.

멀티모듈은 무조건 msa이다 x 멀티모듈은 모놀리틱 아키텍처와 msa 둘다 적용가능하다. o

다만, 모놀리틱 아키텍처를 msa로 전환시 단일모듈보다 멀티모듈 구조가 전환에 더 친화적이라고한다.

 

+기술 면접을 보며 멀티모듈 얘기가 나왔는데 도메인으로 나누진않은거냐라는 질문을 받았다.

사이드프로젝트와 달리 해당 회사에선 도메인을 기준으로 분리하고있다는 뉘앙스로 질문을 주셨다.

참고하면 좋을 것 같다!

 

참고한 레퍼런스

https://www.youtube.com/watch?v=4dO2Wa2fAYI 

https://junuuu.tistory.com/627https://www.youtube.com/watch?v=4dO2Wa2fAYI

https://velog.io/@soyeon207/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0

https://velog.io/@tritny6516/Spring-Multi-Module

https://dkswnkk.tistory.com/691

https://velog.io/@msung99/%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88Multi-Module-%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%EC%99%9C-%EC%8D%A8%EC%95%BC%ED%95%A0%EA%B9%8C https://velog.io/@soyeon207/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8-%EB%A9%80%ED%8B%B0-%EB%AA%A8%EB%93%88-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EB%A7%8C%EB%93%A4%EA%B8%B0

 

 

 

 


💻 IT 연합 동아리 YAPP

https://www.yapp.co.kr/

 

YAPP

작은 아이디어로 세상을 크게 변화시키는 IT동아리, YAPP

www.yapp.co.kr

디자이너, PM, 개발자가 팀을 이뤄 프로젝트를 진행하는 기업형 it 연합 동아리입니다.

PM 직군이 있는 연합동아리는 흔치않죠ㅎㅎ

보통 기수당 4-6개월 정도가 활동기간으로 보이고 1년에 2번 모집합니다!

대학생뿐만아니라 현직자분들도 활동 가능하며 스터디, 소모임, 해커톤 등 다양한 행사가 열립니다.


🤔 지원 동기 

대학교 1학년 조무래기 시절부터 알고있던 규모가 큰 동아리고 yapp을 수료하신 분이 적극 추천하셔서 지원하게 됐습니다.

대학교 졸업 전에 it 연합 동아리에서 개발도 하고 해커톤 같은 행사에 참여해보는게 로망이었어요ㅎㅎㅎ

보통 현직자분들이 계신 it 연합 동아리는 경쟁률이 높고 들어가기 힘들어서 지원도 경험이라는 생각으로 썼습니다!


🧾 1차 서류

22기 서류 문항은 아래와 같았습니다.

문항

- YAPP에 지원하게 된 동기를 포함하여 자유롭게 자기소개를 해주세요. 500자

- 프로젝트를 진행할 때 어떤 방식으로 팀원과 소통하는지 경험을 바탕으로 작성해 주세요. 500자

- 개발 경력 혹은 경험을 알려주세요. 프로젝트 이름, 기간, 본인의 역할, 성과를 포함하여 작성해 주세요. 5000자

- 위 프로젝트 중 기억에 남는 것을 하나 선정해 기술적인 어려움을 겪었던 부분과 어떠한 과정으로 해당 문제를 해결했는지 작성해 주세요. 700자

 

 

지원서를 많이 써보신 분들이라면 자주 보는 질문들이라 어렵지 않게 쓰실 수 있을 것 같아요.

분량도 그렇게 많지 않아 부담없이 작성할 수 있어 좋았습니다!

 

최대한 제 이전 개발 경험을 녹여내려고 노력했고

질문들이 다 제가 이전에 고민했던 부분들에 대한 질문들이라 경험을 바탕으로 썼어요.

yapp에 대한 관심과 열정도 어필했던 것 같아요.

실제로 yapp 동아리에 관심있어서 한참 전부터 yapp 인스타만 팔로우를 해두고 소식들을 보고 있었는데

이런 부분도 녹여내 썼습니다 : )

 

저도 이번엔 소제목을 붙여 쓰진않고 두괄식으로만 썼는데 많은 지원서가 몰리는 특성상 두괄식은 필수고

소제목을 붙여 작성하는 것도 좋을 것 같아요ㅎㅎ

 

결과는 서류 합격..!  🎉🎉🎉


👂 2차 면접

서류 합격 메일에 포함된 링크로 면접 시간을 선착순으로 정했습니다!

 

1차 서류가 붙고 면접을 안본지 오래된 주인장은 생각이 많아집니다.

서류 발표가 난 그 주 주말 오후에 바로 면접을 보는데 같은 날 오전에 네이버 코테가 잡혀있었어요.

구글링해보니 기술 질문이 많이 나온다돼있어서 막판엔 선택과 집중으로 거의 면접 준비만 했습니다.

 

보통의 it 연합 동아리에서 나올만한 기술/인성 질문들+제 포폴 기반 기술 질문들+블로그 글 관련 기술 질문들

이렇게 세 파트로 나눠 준비했습니다.

 

면접은 45분 정도 소요됐고

제가 본 면접 방에서는 기술 질문이 많이 나왔어요!

우연히 아는 지인(최종합격함)이 동일한 백엔드 파트로 면접을 봤는데 그 방에서는 기술 질문이 많이 없었다고 합니다!

어떻게 될지모르니 공부할겸 인성 질문, 기술 질문 둘다 준비가 필요해보여요!

 

받았던 질문들을 간략하게 복기해봤습니다.

인성질문

자기소개

취준 중일텐데 시간 많이 쏟을 수 있는지

팀원과 갈등이 생겼을 때 어떻게 해결하고 소통했는지 등

 

기술질문

기억에 남는 프로젝트 및 거기서 발생한 기술적 이슈 설명 

거기에서 파생된 기술 꼬리 질문들

블로그 글 관련 기술 질문들 2개 정도

트랜잭션과 거기서 파생된 꼬리 질문들

stream api 등 

 

면접은 2:2였고 같은 시간에 동일한 파트 면접이 여러 면접관 팀에서 동시에 이루어졌습니다.

킹갓제너럴회사 현직자분과 같이 면접을 봤고 면접관분들도 딱봐도 내공이 느껴지셔서 면접 보는 와중에도 많이 배운 것 같네요ㅎㅎ

면접보고나서 더 붙고 싶었어요!


🎉 결과는 최종합격!

it 연합 동아리에서는 개발해본 적이 없어 기대됩니다ㅎㅎㅎ

 

 

차후에 공개된 경쟁률 보니 백엔드 파트는 18.5:1이더라구요. 

 

이미 팀빌딩이 끝난 상태인데 백엔드는 현직자1+대학생1로 팀이 구성됐어요.

같이 백엔드 개발하게된 팀원분도 너무 좋으시고 쟁쟁한 경쟁률을 뚫고 오신 분들이라 그런지 다른 동아리원분들도 너무 좋으시고 실력이 출중하십니다..👍👍

 

이 글을 읽고 계신 분들 중에 지원을 망설이고 계신 분이 있으시다면 꼭 지원해보셨으면 좋겠어요!

결론부터, 스프링 배치 버전마다 스키마가 동일하지않다.

스키마 ddl을 서칭 후 실행했는데 컬럼과 관련된 에러가 발생한다면 잘못된 스키마 ddl을 적용했을 확률이 높다.

 

스프링 배치 2,3,4 버전의 메타 데이터 스키마 ddl은 아래와 같으며

스프링 배치 4.3.7 기준 org.springframework.batch.core  하위 경로에서 여러 sql 파일들의 ddl을 확인할 수 있다.

스프링 배치 2 버전 meta data 스키마

CREATE TABLE BATCH_JOB_INSTANCE  (
	JOB_INSTANCE_ID BIGINT  NOT NULL PRIMARY KEY ,
	VERSION BIGINT ,
	JOB_NAME VARCHAR(100) NOT NULL,
	JOB_KEY VARCHAR(32) NOT NULL,
	constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION  (
	JOB_EXECUTION_ID BIGINT  NOT NULL PRIMARY KEY ,
	VERSION BIGINT  ,
	JOB_INSTANCE_ID BIGINT NOT NULL,
	CREATE_TIME DATETIME(6) NOT NULL,
	START_TIME DATETIME(6) DEFAULT NULL ,
	END_TIME DATETIME(6) DEFAULT NULL ,
	STATUS VARCHAR(10) ,
	EXIT_CODE VARCHAR(2500) ,
	EXIT_MESSAGE VARCHAR(2500) ,
	LAST_UPDATED DATETIME(6),
	constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID)
	references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION_PARAMS  (
	JOB_EXECUTION_ID BIGINT NOT NULL ,
	PARAMETER_NAME VARCHAR(100) NOT NULL ,
	PARAMETER_TYPE VARCHAR(100) NOT NULL ,
	PARAMETER_VALUE VARCHAR(2500) ,
	IDENTIFYING CHAR(1) NOT NULL ,
	constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID)
	references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION  (
	STEP_EXECUTION_ID BIGINT  NOT NULL PRIMARY KEY ,
	VERSION BIGINT NOT NULL,
	STEP_NAME VARCHAR(100) NOT NULL,
	JOB_EXECUTION_ID BIGINT NOT NULL,
	CREATE_TIME DATETIME(6) NOT NULL,
	START_TIME DATETIME(6) DEFAULT NULL ,
	END_TIME DATETIME(6) DEFAULT NULL ,
	STATUS VARCHAR(10) ,
	COMMIT_COUNT BIGINT ,
	READ_COUNT BIGINT ,
	FILTER_COUNT BIGINT ,
	WRITE_COUNT BIGINT ,
	READ_SKIP_COUNT BIGINT ,
	WRITE_SKIP_COUNT BIGINT ,
	PROCESS_SKIP_COUNT BIGINT ,
	ROLLBACK_COUNT BIGINT ,
	EXIT_CODE VARCHAR(2500) ,
	EXIT_MESSAGE VARCHAR(2500) ,
	LAST_UPDATED DATETIME(6),
	constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID)
	references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT  (
	STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY,
	SHORT_CONTEXT VARCHAR(2500) NOT NULL,
	SERIALIZED_CONTEXT TEXT ,
	constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID)
	references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT  (
	JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY,
	SHORT_CONTEXT VARCHAR(2500) NOT NULL,
	SERIALIZED_CONTEXT TEXT ,
	constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID)
	references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION_SEQ (
	ID BIGINT NOT NULL,
	UNIQUE_KEY CHAR(1) NOT NULL,
	constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_STEP_EXECUTION_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_STEP_EXECUTION_SEQ);

CREATE TABLE BATCH_JOB_EXECUTION_SEQ (
	ID BIGINT NOT NULL,
	UNIQUE_KEY CHAR(1) NOT NULL,
	constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_JOB_EXECUTION_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_JOB_EXECUTION_SEQ);

CREATE TABLE BATCH_JOB_SEQ (
	ID BIGINT NOT NULL,
	UNIQUE_KEY CHAR(1) NOT NULL,
	constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_JOB_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_JOB_SEQ);

 

스프링 배치 3 버전 meta data 스키마

CREATE TABLE BATCH_JOB_INSTANCE  (
    JOB_INSTANCE_ID BIGINT  NOT NULL PRIMARY KEY ,
    VERSION BIGINT ,
    JOB_NAME VARCHAR(100) NOT NULL,
    JOB_KEY VARCHAR(32) NOT NULL,
    constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION  (
    JOB_EXECUTION_ID BIGINT  NOT NULL PRIMARY KEY ,
    VERSION BIGINT  ,
    JOB_INSTANCE_ID BIGINT NOT NULL,
    CREATE_TIME DATETIME NOT NULL,
    START_TIME DATETIME DEFAULT NULL ,
    END_TIME DATETIME DEFAULT NULL ,
    STATUS VARCHAR(10) ,
    EXIT_CODE VARCHAR(2500) ,
    EXIT_MESSAGE VARCHAR(2500) ,
    LAST_UPDATED DATETIME,
    JOB_CONFIGURATION_LOCATION VARCHAR(2500) NULL,
    constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID)
    references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION_PARAMS  (
    JOB_EXECUTION_ID BIGINT NOT NULL ,
    TYPE_CD VARCHAR(6) NOT NULL ,
    KEY_NAME VARCHAR(100) NOT NULL ,
    STRING_VAL VARCHAR(250) ,
    DATE_VAL DATETIME DEFAULT NULL ,
    LONG_VAL BIGINT ,
    DOUBLE_VAL DOUBLE PRECISION ,
    IDENTIFYING CHAR(1) NOT NULL ,
    constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID)
    references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION  (
    STEP_EXECUTION_ID BIGINT  NOT NULL PRIMARY KEY ,
    VERSION BIGINT NOT NULL,
    STEP_NAME VARCHAR(100) NOT NULL,
    JOB_EXECUTION_ID BIGINT NOT NULL,
    START_TIME DATETIME NOT NULL ,
    END_TIME DATETIME DEFAULT NULL ,
    STATUS VARCHAR(10) ,
    COMMIT_COUNT BIGINT ,
    READ_COUNT BIGINT ,
    FILTER_COUNT BIGINT ,
    WRITE_COUNT BIGINT ,
    READ_SKIP_COUNT BIGINT ,
    WRITE_SKIP_COUNT BIGINT ,
    PROCESS_SKIP_COUNT BIGINT ,
    ROLLBACK_COUNT BIGINT ,
    EXIT_CODE VARCHAR(2500) ,
    EXIT_MESSAGE VARCHAR(2500) ,
    LAST_UPDATED DATETIME,
    constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID)
    references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT  (
    STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY,
    SHORT_CONTEXT VARCHAR(2500) NOT NULL,
    SERIALIZED_CONTEXT TEXT ,
    constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID)
    references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT  (
    JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY,
    SHORT_CONTEXT VARCHAR(2500) NOT NULL,
    SERIALIZED_CONTEXT TEXT ,
    constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID)
    references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION_SEQ (
    ID BIGINT NOT NULL,
    UNIQUE_KEY CHAR(1) NOT NULL,
    constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_STEP_EXECUTION_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_STEP_EXECUTION_SEQ);

CREATE TABLE BATCH_JOB_EXECUTION_SEQ (
    ID BIGINT NOT NULL,
    UNIQUE_KEY CHAR(1) NOT NULL,
    constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_JOB_EXECUTION_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_JOB_EXECUTION_SEQ);

CREATE TABLE BATCH_JOB_SEQ (
    ID BIGINT NOT NULL,
    UNIQUE_KEY CHAR(1) NOT NULL,
    constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_JOB_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_JOB_SEQ);

 

 

스프링 배치 4 버전 meta data 스키마

CREATE TABLE BATCH_JOB_INSTANCE  (
	JOB_INSTANCE_ID BIGINT  NOT NULL PRIMARY KEY ,
	VERSION BIGINT ,
	JOB_NAME VARCHAR(100) NOT NULL,
	JOB_KEY VARCHAR(32) NOT NULL,
	constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION  (
	JOB_EXECUTION_ID BIGINT  NOT NULL PRIMARY KEY ,
	VERSION BIGINT  ,
	JOB_INSTANCE_ID BIGINT NOT NULL,
	CREATE_TIME DATETIME(6) NOT NULL,
	START_TIME DATETIME(6) DEFAULT NULL ,
	END_TIME DATETIME(6) DEFAULT NULL ,
	STATUS VARCHAR(10) ,
	EXIT_CODE VARCHAR(2500) ,
	EXIT_MESSAGE VARCHAR(2500) ,
	LAST_UPDATED DATETIME(6),
	JOB_CONFIGURATION_LOCATION VARCHAR(2500) NULL,
	constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID)
	references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION_PARAMS  (
	JOB_EXECUTION_ID BIGINT NOT NULL ,
	TYPE_CD VARCHAR(6) NOT NULL ,
	KEY_NAME VARCHAR(100) NOT NULL ,
	STRING_VAL VARCHAR(250) ,
	DATE_VAL DATETIME(6) DEFAULT NULL ,
	LONG_VAL BIGINT ,
	DOUBLE_VAL DOUBLE PRECISION ,
	IDENTIFYING CHAR(1) NOT NULL ,
	constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID)
	references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION  (
	STEP_EXECUTION_ID BIGINT  NOT NULL PRIMARY KEY ,
	VERSION BIGINT NOT NULL,
	STEP_NAME VARCHAR(100) NOT NULL,
	JOB_EXECUTION_ID BIGINT NOT NULL,
	START_TIME DATETIME(6) NOT NULL ,
	END_TIME DATETIME(6) DEFAULT NULL ,
	STATUS VARCHAR(10) ,
	COMMIT_COUNT BIGINT ,
	READ_COUNT BIGINT ,
	FILTER_COUNT BIGINT ,
	WRITE_COUNT BIGINT ,
	READ_SKIP_COUNT BIGINT ,
	WRITE_SKIP_COUNT BIGINT ,
	PROCESS_SKIP_COUNT BIGINT ,
	ROLLBACK_COUNT BIGINT ,
	EXIT_CODE VARCHAR(2500) ,
	EXIT_MESSAGE VARCHAR(2500) ,
	LAST_UPDATED DATETIME(6),
	constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID)
	references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT  (
	STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY,
	SHORT_CONTEXT VARCHAR(2500) NOT NULL,
	SERIALIZED_CONTEXT TEXT ,
	constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID)
	references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT  (
	JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY,
	SHORT_CONTEXT VARCHAR(2500) NOT NULL,
	SERIALIZED_CONTEXT TEXT ,
	constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID)
	references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID)
) ENGINE=InnoDB;

CREATE TABLE BATCH_STEP_EXECUTION_SEQ (
	ID BIGINT NOT NULL,
	UNIQUE_KEY CHAR(1) NOT NULL,
	constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_STEP_EXECUTION_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_STEP_EXECUTION_SEQ);

CREATE TABLE BATCH_JOB_EXECUTION_SEQ (
	ID BIGINT NOT NULL,
	UNIQUE_KEY CHAR(1) NOT NULL,
	constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_JOB_EXECUTION_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_JOB_EXECUTION_SEQ);

CREATE TABLE BATCH_JOB_SEQ (
	ID BIGINT NOT NULL,
	UNIQUE_KEY CHAR(1) NOT NULL,
	constraint UNIQUE_KEY_UN unique (UNIQUE_KEY)
) ENGINE=InnoDB;

INSERT INTO BATCH_JOB_SEQ (ID, UNIQUE_KEY) select * from (select 0 as ID, '0' as UNIQUE_KEY) as tmp where not exists(select * from BATCH_JOB_SEQ);

참고 사이트

https://stackoverflow.com/questions/25882740/unknown-column-job-configuration-location-in-field-list-while-using-spring-b

2편에서 http client로 NCP 알림톡을 발송했다면 이번엔 feign를 활용해 알림톡을 발송하는 방법과 코드 예시를 소개할 예정이다.

 

기존 http client 코드를 활용하려고 했더니 feign과 안맞는 부분이 있어 해당 부분들을 수정해 코드를 작성했다.

원래는 jsonobject를 만들어 바디 값을 담았다면 feign에서는 dto를 만들어 바디 값을 담았고 이때 ncp에서 요구하는 형식에 맞게 바디 값 구조를 그대로 직관적으로 맞춰 설정하는게 중요했다.

또한, serviceId를 환경 변수로 받아와 해당 주소로 api 요청을 보낼때 ':'이 다른 값으로 자동 인코딩되며 이를 ncp에서 그대로 인식하고 오류가 발생했다. 구글링을 통해 ColonInterceptor을 만들어 해결했다.


 

먼저 흐름을 크게 보면 open feign 관련 의존성 추가와 공통 설정을 프로젝트에 해주고 ncp 알림톡 관련해 필요한 설정들을 따로 더 추가한다.

설정들을 끝냈으면 필요한 정보들을 담아 @FeignClient 인터페이스를 활용해 api 요청을 보내면 된다.

필자는 필요한 정보들을 담는 용도로 helper를 사용해 정보들을 세팅했다.

 

공식 문서 링크는 아래와 같다.

https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/

 

Spring Cloud OpenFeign

Feign is a declarative web service client. It makes writing web service clients easier. To use Feign create an interface and annotate it. It has pluggable annotation support including Feign annotations and JAX-RS annotations. Feign also supports pluggable

docs.spring.io

 

코드 예시

 

1. 의존성 추가

feign을 사용하기 위한 최소한의 의존성은 아래와 같다.

* Maven
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
    <version>3.1.4</version>
</dependency>

* Gradle
implementation group: 'org.springframework.cloud', name: 'spring-cloud-starter-openfeign', version: '3.1.4'

필자의 프로젝트에 들어간 의존성은 아래와 같다.

dependencies {
    api 'io.github.openfeign:feign-httpclient:12.1'
    api 'org.springframework.cloud:spring-cloud-starter-openfeign:3.1.4'
    api 'io.github.openfeign:feign-jackson:12.1'
}

 

2. 공통 설정

public interface BaseFeignClientPackage {}
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import feign.Logger.Level;
import feign.codec.Decoder;
import feign.jackson.JacksonDecoder;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableFeignClients(basePackageClasses = BaseFeignClientPackage.class)
public class FeignCommonConfig {
    @Bean
    public Decoder feignDecoder() {
        return new JacksonDecoder(customObjectMapper());
    }

    @Bean
    Level feignLoggerLevel() {
        return Level.FULL;
    }
}

 

3. ncp 관련 설정

에러 디코더 커스터마이징

(각자의 프로젝트에 맞게 에러 처리 넘겨주시면 됩니다!)

import feign.FeignException;
import feign.Response;
import feign.codec.ErrorDecoder;

public class NcpErrorDecoder implements ErrorDecoder {
    @Override
    public Exception decode(String methodKey, Response response) {
        if (response.status() >= 400) {
            switch (response.status()) {
                case 401 -> throw OtherServerUnauthorizedException.EXCEPTION;
                case 403 -> throw OtherServerForbiddenException.EXCEPTION;
                case 404 -> throw OtherServerNotFoundException.EXCEPTION;
                case 500 -> throw OtherServerInternalSeverErrorException.EXCEPTION;
                default -> throw OtherServerBadRequestException.EXCEPTION;
            }
        }

        return FeignException.errorStatus(methodKey, response);
    }
}

 

import feign.RequestInterceptor;
import feign.RequestTemplate;
import feign.codec.ErrorDecoder;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.*;

@Import(NcpErrorDecoder.class)
public class NcpConfig {
    @Bean
    @ConditionalOnMissingBean(value = ErrorDecoder.class)
    public NcpErrorDecoder commonFeignErrorDecoder() {
        return new NcpErrorDecoder();
    }

    @Bean
    public RequestInterceptor basicAuthRequestInterceptor() {
        return new ColonInterceptor();
    }

    public static class ColonInterceptor implements RequestInterceptor {
        @Override
        public void apply(RequestTemplate template) {
            template.uri(template.path().replaceAll("%3A", ":"));
        }
    }
}

 

4. 알림톡 발송 코드 작성

body 값 담는 dto 생성

import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

public class MessageDto {

    @Getter
    @Builder
    public static class AlimTalkBody {
        private String plusFriendId;
        private String templateCode;
        private List<AlimTalkMessage> messages;
    }

    @Getter
    @Builder
    @AllArgsConstructor
    public static class AlimTalkMessage {
        private String to;
        private String content;
    }
}

api 요청 정보들 세팅하는 helper 작성

import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Base64;
import org.springframework.beans.factory.annotation.Value;

@Helper
public class NcpHelper {
    private final NcpClient ncpClient;
    private final String serviceID;
    private final String ncpAccessKey;
    private final String ncpSecretKey;
    private final String plusFriendId;

    public NcpHelper(
            NcpClient ncpClient,
            @Value("${ncp.service-id}") String serviceID,
            @Value("${ncp.access-key}") String ncpAccessKey,
            @Value("${ncp.secret-key}") String ncpSecretKey,
            @Value("${ncp.plus-friend-id}") String plusFriendId) {
        this.ncpClient = ncpClient;
        this.serviceID = serviceID;
        this.ncpAccessKey = ncpAccessKey;
        this.ncpSecretKey = ncpSecretKey;
        this.plusFriendId = plusFriendId;
    }

    public void sendNcpAlimTalk(String to, String templateCode, String content) {
        // signature 생성
        String alimTalkSignatureRequestUrl = "/alimtalk/v2/services/" + serviceID + "/messages";
        String[] signatureArray =
                makePostSignature(ncpAccessKey, ncpSecretKey, alimTalkSignatureRequestUrl);
        // 바디 생성
        MessageDto.AlimTalkBody body = makeBody(templateCode, to, content);
        ncpClient.sendAlimTalk(
                serviceID,
                "application/json; charset=UTF-8",
                ncpAccessKey,
                signatureArray[0],
                signatureArray[1],
                body);
    }

    public MessageDto.AlimTalkBody makeBody(String templateCode, String to, String content) {
        MessageDto.AlimTalkMessage alimTalkMessage =
                MessageDto.AlimTalkMessage.builder().to(to).content(content).build();
        List<MessageDto.AlimTalkMessage> alimTalkMessages = new ArrayList<>();
        alimTalkMessages.add(alimTalkMessage);

        return MessageDto.AlimTalkBody.builder()
                .plusFriendId(plusFriendId)
                .templateCode(templateCode)
                .messages(alimTalkMessages)
                .build();
    }

    public String[] makePostSignature(String accessKey, String secretKey, String url) {
        String[] result = new String[2];
        try {

            String timeStamp =
                    String.valueOf(Instant.now().toEpochMilli()); // current timestamp (epoch)
            String space = " "; // space
            String newLine = "\n"; // new line
            String method = "POST"; // method

            String message =
                    new StringBuilder()
                            .append(method)
                            .append(space)
                            .append(url)
                            .append(newLine)
                            .append(timeStamp)
                            .append(newLine)
                            .append(accessKey)
                            .toString();

            SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256");
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(signingKey);

            byte[] rawHmac = mac.doFinal(message.getBytes("UTF-8"));
            String encodeBase64String = Base64.encodeBase64String(rawHmac);

            result[0] = timeStamp;
            result[1] = encodeBase64String;

        } catch (Exception ex) {
            throw new DuDoongDynamicException(0, "400", ex.getMessage());
        }
        return result;
    }
}

feign client 생성

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;

@FeignClient(
        name = "NcpClient",
        url = "https://sens.apigw.ntruss.com",
        configuration = NcpConfig.class)
public interface NcpClient {
    @PostMapping(
            path = "/alimtalk/v2/services/{serviceId}/messages",
            consumes = "application/json; charset=UTF-8")
    void sendAlimTalk(
            @PathVariable("serviceId") String serviceId,
            @RequestHeader("Content-Type") String contentType,
            @RequestHeader("x-ncp-iam-access-key") String ncpAccessKey,
            @RequestHeader("x-ncp-apigw-timestamp") String timeStamp,
            @RequestHeader("x-ncp-apigw-signature-v2") String signature,
            @RequestBody MessageDto.AlimTalkBody alimTalkBody);
}

 

참고 사이트

https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/

https://techblog.woowahan.com/2630/

https://vmpo.tistory.com/109

https://velog.io/@haron/Feign-client-%EC%A0%81%EC%9A%A9%EA%B8%B0

https://wildeveloperetrain.tistory.com/172

https://forkyy.tistory.com/10

https://bepoz-study-diary.tistory.com/414

https://stackoverflow.com/questions/40262132/how-to-add-a-request-interceptor-to-a-feign-client

https://stackoverflow.com/questions/61830167/disable-feign-encoding-of-pathvariables

https://github.com/Youngiyong/gofield-backend/blob/d05a2850fe15d22e987e4e7b26d1d9a3c551403c/gofield-infrastructure/gofield-infrastructure-external/src/main/java/com/gofield/infrastructure/external/api/naver/NaverSnsApiClient.java

https://github.com/kakakoo/tsid-core/blob/ffff29fe6e61a25d1f624d50e8ac91ea585c239d/tsid-external/src/main/java/com/tsid/external/api/naver/dto/req/NaverSmsRequest.java

 

이번 편에서는 1편에서 세팅한 내용들을 바탕으로 스프링 부트 프로젝트에서 알림톡을 발송 코드 작성을 다룹니다.


NCP 알림톡 API 가이드

https://api.ncloud-docs.com/docs/ai-application-service-sens-alimtalkv2

 

알림톡 API

 

api.ncloud-docs.com

공식 docs 링크는 위와 같으며 위 문서를 참고하여 작성했습니다!

 

1. 메시지 발송 API 기본 정보

얼핏보면 내용이 많아 복잡해보일 수 있지만 해당 요청 주소로 필수로 요구하는 정보들을 채워 보내면 됩니다!

아래는 필요한 정보들을 정리한 것으로 마지막에 코드 예시로도 보여드리겠습니다.

 

요청 URL

POST https://sens.apigw.ntruss.com/alimtalk/v2/services/{serviceId}/messages

필수 path variables

serviceId String

 

필수 API header 

Content-Type 요청 Body Content Type을 application/json으로 지정 (POST)
x-ncp-apigw-timestamp - 1970년 1월 1일 00:00:00 협정 세계시(UTC)부터의 경과 시간을 밀리초(Millisecond)로 나타냄
- API Gateway 서버와 시간 차가 5분 이상 나는 경우 유효하지 않은 요청으로 간주
x-ncp-iam-access-key 포탈 또는 Sub Account에서 발급받은 Access Key ID
x-ncp-apigw-signature-v2 - 위 예제의 Body를 Access Key Id와 맵핑되는 SecretKey로 암호화한 서명
- HMAC 암호화 알고리즘은 HmacSHA256 사용

요청 body (필수+옵션)

{
    "plusFriendId":"string",
    "templateCode":"string",
    "messages":[
        {
            "countryCode":"string",
            "to":"string",
            "title":"string",
            "content":"string",
            "headerContent":"string",
            "itemHighlight":{
                "title":"string",
                "description":"string"
            },
            "item":{
                "list":[
                    {
                        "title":"string",
                        "description":"string"
                    }
                ],
                "summary":{
                    "title":"string",
                    "description":"string"
                }
            },
            "buttons":[
                {
                    "type":"string",
                    "name":"string",
                    "linkMobile":"string",
                    "linkPc":"string",
                    "schemeIos":"string",
                    "schemeAndroid":"string"
                }
            ],
            "useSmsFailover": "boolean",
            "failoverConfig": {
                "type": "string",
                "from": "string",
                "subject": "string",
                "content": "string"
            }
        }
    ],
    "reserveTime": "yyyy-MM-dd HH:mm",
    "reserveTimeZone": "string",
    "scheduleCode": "string"
}

 

plusFriendId Mandatory  String 카카오톡 채널명
((구)플러스친구 아이디)
 
templateCode Mandatory String 템플릿 코드  
messages Mandatory Object 메시지 정보 - 아래 항목 참조 (messages.XXX)
- 최대 100개
messages.countryCode Optional String 수신자 국가번호  
messages.to Mandatory String 수신자번호  
messages.title Optional String 알림톡 강조표시 내용 강조 표기 유형의 템플릿에서만 사용 가능
messages.content Mandatory String 알림톡 메시지 내용  
messages.headerContent Optional String 알림톡 헤더 내용 - 아이템 리스트 유형의 템플릿에서만 사용 가능
- 최대 16자까지 입력 가능
messages.itemHighlight Optional Object 아이템 하이라이트 아이템 리스트 유형의 템플릿에서만 사용 가능
messages.itemHighlight.title Mandatory String 아이템 하이라이트 제목 - 아이템 리스트 유형의 템플릿에서만 사용 가능
이미지가 없는 경우
- 최대 30자까지 입력 가능 (2줄)
- 1줄은 15자까지 입력 가능
이미지가 있는 경우
- 최대 21자까지 입력 가능 (2줄)
- 1줄은 10자까지 입력 가능
- 2줄 초과 시 말줄임 처리
messages.itemHighlight.description Mandatory String 아이템 하이라이트 설명 - 아이템 리스트 유형의 템플릿에서만 사용 가능
이미지가 없는 경우
- 최대 19자까지 입력 가능 (1줄)
이미지가 있는 경우
- 최대 13자까지 입력 가능 (1줄)
- 1줄 초과 시 말줄임 처리
messages.item Optional Object 아이템 리스트 아이템리스트 유형의 템플릿에서만 사용 가능
messages.messages.item.list Mandatory Array of Object 아이템 리스트 - 아이템리스트 유형의 템플릿에서만 사용 가능
- 최소 2개 이상, 최대 10개
messages.messages.item.list.title Mandatory String 아이템 리스트 제목 - 아이템리스트 유형의 템플릿에서만 사용 가능
- 최대 6자까지 입력 가능
messages.messages.item.list.description Mandatory String 아이템 리스트 설명 - 아이템리스트 유형의 템플릿에서만 사용 가능
- 최대 23자까지 입력 가능
messages.messages.summary Optional Object 아이템 요약 정보 아이템리스트 유형의 템플릿에서만 사용 가능
messages.messages.summary.title Mandatory String 아이템 요약 제목 - 아이템리스트 유형의 템플릿에서만 사용 가능
- 최대 6자까지 입력 가능
messages.messages.summary.description Mandatory String 아이템 요약 설명 - 아이템리스트 유형의 템플릿에서만 사용 가능
- 허용되는 문자: 통화기호(유니코드 통화기호, 元, 円, 원), 통화코드 (ISO 4217), 숫자, 콤마, 소수점, 공백
- 소수점 2자리까지 허용
- 최대 23자까지 입력 가능
messages.buttons Optional Array of Object 알림톡 메시지 버튼 아래 템플릿 버튼 정보 참조
messages.buttons.type Mandatory String 버튼 Type 아래 템플릿 버튼 정보 참조
messages.buttons.name Mandatory String 버튼명 아래 템플릿 버튼 정보 참조
messages.useSmsFailover Optional Boolean SMS Failover 사용 여부 - Failover가 설정된 카카오톡 채널에서만 사용 가능
- 기본: 카카오톡 채널의 Failover 설정 여부를 따름
messages.failoverConfig Optional Object Failover 설정 아래 항목 참조
messages.failoverConfig.type Optional String Failover SMS 메시지 Type - SMS 또는 LMS
- 기본: content 길이에 따라 자동 적용 (90 bytes 이하 SMS, 초과 LMS)
messages.failoverConfig.from Optional String Failover SMS 발신번호 - 기본: Failover 설정 시 선택한 발신번호
- 승인되지 않은 발신번호 사용시 Failover 동작 안함
messages.failoverConfig.subject Optional String Failover SMS 제목 - LMS type으로 동작할 때 사용
- 기본: 카카오톡 채널명
messages.failoverConfig.content Optional String Failover SMS 내용 기본: 알림톡 메시지 내용 (버튼 제외)
reserveTime Optional String 예약 일시 메시지 발송 예약 일시 (yyyy-MM-dd HH:mm)
reserveTimeZone Optional String 예약 일시 타임존 - 예약 일시 타임존 (기본: Asia/Seoul)
- 지원 타임존 목록
* TZ database name 값 사용
scheduleCode Optional String 스케줄 코드 등록하려는 스케줄 코드

응답 status

202 Accepted (발송 요청 완료)
400 Bad Request
401 Unauthorized
403 Forbidden
404 Not Found
500 Internal Server Error

응답 body

{
    "requestId":"string",
    "requestTime":"string",
    "statusCode":"string",
    "statusName":"string",
    "messages":[
        {
            "messageId":"string",
            "countryCode":"string",
            "to":"string",
            "content":"string",
            "requestStatusCode":"string",
            "requestStatusName":"string",
            "requestStatusDesc":"string",
            "useSmsFailover":"boolean"
        }
    ]
}
requestId Mandatory String 발송 요청 아이디  
requestTime Mandatory DateTime 발송 요청 시간  
statusCode Mandatory String 요청 상태 코드 - 성공: 202
- 실패: 그 외
- HTTP Status 규격을 따름
statusName Mandatory String 요청 상태명 - 성공: success
- 처리 중: processing
- 예약 중: reserved
- 스케줄 중: scheduled
- 실패: fail
messages.messageId Mandatory String 메시지 아이디  
messages.countryCode Optional String 수신자 국가번호  
messages.to Mandatory String 수신자 번호  
messages.content Mandatory String 알림톡 메시지 내용  
messages.requestStatusCode Mandatory String 발송요청 상태 코드 - 성공: A000
- 실패: 그 외 코드(Desc 항목에 실패 사유가 명시)
messages.requestStatusName Mandatory String 발송 요청 상태명 - 성공: success
- 실패: fail
messages.requestStatusDesc Mandatory String 발송 요청 상태
내용
 
messages.useSmsFailover Mandatory Boolean SMS Failover
사용 여부

2. 메시지 발송 코드 작성 예시

크게 4가지 단계로 나눠볼 수 있습니다.

(1) 서명 생성

(2) 헤더 생성 

(3) 바디 생성

(4) api 요청

 

전체 코드 예시

변수들 설명 및 유의 사항

plusFriendId: 카톡플러스친구채널 id. @를 붙여서 넣어주세요.

                        ex) @chaech2929

to: 알림톡을 보낼 전화번호 String 타입

templateCode: 알림톡 템플릿 등록 시 설정했던 식별 코드

content: 알림톡 템플릿 등록 시 설정했던 글 내용과 동일하게 넣어주세요. 변수 값은 코드에서 사용하던 변수에 맞춰 알맞은 걸로 채워서                  넣어주시면 됩니다.

                ex) String content = userName + "님, 두둥에 가입하신 것을 환영합니다!\n"

 

signature 생성 시 사용하는 timestamp 값과 api 요청시 헤더에 넣는 timestamp 값이 동일해야합니다.

 

가장 기본 옵션으로만 이루어진 메시지를 기준으로 작성했습니다. 

package band.gosrock.infrastructure.config.AlilmTalk;


import java.io.IOException;
import java.sql.Timestamp;
import java.time.Instant;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.apache.commons.codec.binary.Base64;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.util.EntityUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.configurationprocessor.json.JSONArray;
import org.springframework.boot.configurationprocessor.json.JSONObject;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;

@Component
@Configuration
public class AlimTalk {
    private final String serviceID;
    private final String ncpAccessKey;
    private final String ncpSecretKey;
    private final String plusFriendId;

    public AlimTalk(
            @Value("${ncp.service-id}") String serviceID,
            @Value("${ncp.access-key}") String ncpAccessKey,
            @Value("${ncp.secret-key}") String ncpSecretKey,
            @Value("${ncp.plus-friend-id}") String plusFriendId) {
        this.serviceID = serviceID;
        this.ncpAccessKey = ncpAccessKey;
        this.ncpSecretKey = ncpSecretKey;
        this.plusFriendId = plusFriendId;
    }

    public void sendAlimTalk(String to, String templateCode, String content) {
        String alimTalkSendRequestUrl =
                "https://sens.apigw.ntruss.com/alimtalk/v2/services/" + serviceID + "/messages";
        String alimTalkSignatureRequestUrl = "/alimtalk/v2/services/" + serviceID + "/messages";
        CloseableHttpClient httpClient = null;
        try {
            // signature 생성
            String[] signatureArray =
                    makePostSignature(ncpAccessKey, ncpSecretKey, alimTalkSignatureRequestUrl);

            // http 통신 객체 생성
            httpClient = HttpClients.createDefault(); // http client 생성
            HttpPost httpPost = new HttpPost(alimTalkSendRequestUrl); // post 메서드와 URL 설정

            // 헤더 설정
            httpPost.setHeader("Content-Type", "application/json; charset=UTF-8");
            httpPost.setHeader("x-ncp-iam-access-key", ncpAccessKey);
            httpPost.setHeader("x-ncp-apigw-timestamp", signatureArray[0]);
            httpPost.setHeader("x-ncp-apigw-signature-v2", signatureArray[1]);

            // body 설정
            JSONObject msgObj = new JSONObject();
            msgObj.put("plusFriendId", plusFriendId);
            msgObj.put("templateCode", templateCode);

            JSONObject messages = new JSONObject();
            messages.put("to", to);
            messages.put("content", content);

            JSONArray messageArray = new JSONArray();
            messageArray.put(messages);
            msgObj.put("messages", messageArray);

            // api 전송 값 http 객체에 담기
            httpPost.setEntity(new StringEntity(msgObj.toString(), "UTF-8"));
            // api 호출
            CloseableHttpResponse httpResponse = httpClient.execute(httpPost);

            // 응답 결과
            String result = EntityUtils.toString(httpResponse.getEntity(), "UTF-8");
            System.out.println(result);

        } catch (Exception ex) {
            // TODO: 에러 처리
            ex.printStackTrace();
        } finally {
            try {
                httpClient.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public String[] makePostSignature(String accessKey, String secretKey, String url) {
        String[] result = new String[2];
        try {
            String timeStamp = String.valueOf(Instant.now().toEpochMilli()); // current timestamp (epoch)
            String space = " "; // space
            String newLine = "\n"; // new line
            String method = "POST"; // method

            String message =
                    new StringBuilder()
                            .append(method)
                            .append(space)
                            .append(url)
                            .append(newLine)
                            .append(timeStamp)
                            .append(newLine)
                            .append(accessKey)
                            .toString();

            SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256");
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(signingKey);

            byte[] rawHmac = mac.doFinal(message.getBytes("UTF-8"));
            String encodeBase64String = Base64.encodeBase64String(rawHmac);

            result[0] = timeStamp;
            result[1] = encodeBase64String;

        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return result;
    }

    public String[] makeGetSignature(String accessKey, String secretKey, String url) {
        String[] result = new String[2];
        try {
            String timeStamp = String.valueOf(Instant.now().toEpochMilli()); // current timestamp (epoch)
            String space = " "; // space
            String newLine = "\n"; // new line
            String method = "GET"; // method

            String message =
                    new StringBuilder()
                            .append(method)
                            .append(space)
                            .append(url)
                            .append(newLine)
                            .append(timeStamp)
                            .append(newLine)
                            .append(accessKey)
                            .toString();

            SecretKeySpec signingKey = new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA256");
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(signingKey);

            byte[] rawHmac = mac.doFinal(message.getBytes("UTF-8"));
            String encodeBase64String = Base64.encodeBase64String(rawHmac);

            result[0] = timeStamp;
            result[1] = encodeBase64String;
        } catch (Exception ex) {
            ex.printStackTrace();
        }
        return result;
    }
}

 

 

참고 사이트

https://api.ncloud-docs.com/docs/ai-application-service-sens-alimtalkv2

https://honeystorage.tistory.com/191

https://wildeveloperetrain.tistory.com/77

https://blog.naver.com/PostView.naver?blogId=n_cloudplatform&logNo=222475388473&parentCategoryNo=&categoryNo=12&viewDate=&isShowPopularPosts=false&from=postView 

현재 진행 중인 사이드 프로젝트에서 카카오 채널로 알림톡 보내는 기능을 맡아 개발하게 됐다.

sms 보내는 기능 관련 글들은 많은데 알림톡 관련 자료가 많지 않아 게시물로 남기기로 했다.

 

1편은 알림톡을 사용하기 위한 세팅을 다룹니다.

틀린 내용이 있다면 댓글로 지적 환영합니다!


SENS?

SENS(Simple & Easy Notification Service)는 별도의 메시지 서버 구축 없이 다양한 메시지 알람 기능을 구현할 수 있는 서비스

(출처: NCP 사이트)

카톡 알림톡 서비스를 제공해주는 biz-message가 있으며 

카톡 API보다 더 사용하기 간편하단 이점이 있다!

 

알림톡 vs 친구톡

알림톡: 해당 채널의 친구가 아니어도 전화번호를 통해 알림을 보내는 서비스

친구톡: 해당 채널의 친구에게 홍보성 알림을 보내는 서비스

 

알림톡 비용 = 건당 7.5원

 

<알림톡 세팅 과정> 

1. 카카오톡에서 카카오 채널 개설

 

2. NCP(Naver Cloud Platform) 회원 가입

 

3. Biz Message 신규 프로젝트 생성 후 서비스 id 키 확인

Simple & Easy Notification Service -> Project에서 생성 가능

https://console.ncloud.com/sens/project

생성 후 열쇠 모양 키 누르면 서비스 id 확인 가능

 

4. 카카오톡 채널 등록

Simple & Easy Notification Service -> Biz Message -> KakaoTalk Channel

https://console.ncloud.com/sens/kakao-talk-channel

 

5. 키 생성

우측 상단 내 프로필 -> 계정 설정 -> 로그인 -> 계정 관리 -> 인증 키 관리 -> 신규 키 생성 및 확인 

 

6. 알림톡 템플릿 등록 및 검수 받기

Simple & Easy Notification Service -> Biz Message -> AlimTalk Template

https://console.ncloud.com/sens/kakao-alimtalk-template

 

*여기서 알림톡 템플릿 등록 왜 하나요??

변수가 포함된 글을 알림톡 템플릿으로 미리 등록한 후 해당 템플릿의 식별 코드를 프로젝트 코드에서 입력해 알림톡을 보내는 로직입니다.

즉, 프로젝트 코드에서 알림톡의 내용을 작성하는 것이 아니라 미리 변수가 들어간 글 내용을 NCP 사이트에서 템플릿으로 등록해둔 후

변수만 프로젝트 코드에서 그때그때 끌고와서 미리 등록한 템플릿 내용과 동일한 내용으로만 알림을 보낼 수 있습니다.

템플릿 등록: 평균 2-3일의 검수 과정을 통과한 후 등록 가능하다합니다. (필자는 검수 통과 3시간 걸려서 2-3일보다 빨리 되는 것 같습니다.)

템플릿 수정: 검수 대기/요청/반려 과정에서만 수정 가능하며 카카오톡 채널 및 템플릿 코드는 변경 불가합니다.

템플릿 삭제: 검수 요청/대기/반려 상태의 템플릿만 삭제 가능합니다.

 

알림톡 템플릿 변수가 들어간 내용 예시

#{name}님, 두둥에 가입하신 것을 환영합니다!
두둥은 누구나 자유롭게 공연을 홍보하고 인원을 모집할 수 있는 서비스입니다.
호스트가 되어 밴드, 뮤지컬, 버스킹등의 공연을 열고 홍보하세요

변수 꼴 : #{변수명]

 

템플릿 작성 가이드

https://kakaobusiness.gitbook.io/main/ad/bizmessage/notice-friend/content-guide

 

알림톡 제작가이드 - kakao business 비즈니스 가이드

광고성 요소와 동시에 사용 가능하며, 부가정보 500자, 광고성 80자로 각각 제한되며, 본문과 합쳐 총 1,000자를 넘을 수 없습니다.

kakaobusiness.gitbook.io

템플릿 등록 가이드

https://guide.ncloud-docs.com/docs/ko/sens-sens-1-5

 

웹 콘솔 사용 가이드

 

guide.ncloud-docs.com

 

7. 발급 받은 키들 프로젝트에 넣기

yml 파일, .env 파일 등 프로젝트에서 키를 관리하는 곳에 키들을 넣어 준비해주세요.

access 키, secret 키, service id가 필요합니다.

변수명은 원하는대로 지정하셔도 됩니다.

NCP_ACCESS_KEY=dfdj~~~~~~
NCP_SECRET_KEY=LkhU0P~~~~
NCP_SERVICE_ID=ncp:~~~~

 

참고 사이트

https://guide.ncloud-docs.com/docs/ko/sens-sens-1-5

https://honeystorage.tistory.com/188

https://kakaobusiness.gitbook.io/main/ad/bizmessage/notice-friend/content-guide

+ Recent posts