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

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

진행 중인 프로젝트에서 서버 성능을 향상 시키기위해 커넥션 풀을 늘리는 작업을 진행했다.

관련해 공부한 내용을 정리해봤다.


서버 성능을 향상시킬 때 중요한 지표는 처리량응답시간이다.

응답시간이 짧을수록 처리량이 높아질수록 성능이 좋아진다.

처리량을 높이는 방법 중에 커넥션 풀을 늘리는 방법이 있는데 오늘은 이에 대해 포스팅 해보려고한다.

 

DB connection?

WAS는 http 요청에 따라 쓰레드를 생성해 비즈니스 로직을 수행한다.

이때 비즈니스 로직 수행을 위해 DB 서버로부터 데이터를 얻어와야하는데

DB 접속을 위해 드라이버를 로드한 후 디비 커넥션 객체를 생성해 물리적으로 DB 서버에 접근하게된다.

자바는 주로 jdbc를 활용한다.

 

connection pool이란?

WAS가 실행될 때 미리 설정된 일정량만큼의 디비 커넥션된 객체들을 만들어 풀에 저장해둔다.

클라이언트 요청이 올 경우 이 디비 커넥션 객체를 빌려주었다가 요청이 완료되면 다시 반납받아 풀에 저장해둔다.

커넥션 풀이 커지면 메모리 소모가 커지지만 동시성이 좋아져 클라이언트 요청의 대기 시간이 감소한다.

 

왜 미리 DB connection 객체를 준비해둘까?

서버의 부하를 줄일 수 있다.

-> DB 커넥션을 맺는 과정의 부하가 크다. 미리 생성하고 재활용하는 방식을 통해 부하를 줄일 수 있다.

 

왜 미리 설정한 일정량 만큼만 DB connection 객체를 만들까? 많을수록 좋은 거 아닐까?

-서버는 한정적인 자원이기 때문에 디비 커넥션 수를 제한해야지만 서버 자원 고갈을 방지할 수 있다.

-커넥션을 활용하는 주체는 쓰레드로 커넥션이 많아도 쓰레드의 수가 뒷받침되지 않으면 의미없다.

->활용되는 커넥션이 많다는 의미는 쓰레드를 많이 사용한다는 의미로 이는 곧 context switching이 많이 일어나 오버헤드가 많이 발생한다는 의미이며 성능적인 한계가 존재하게 된다.

 

connection pool 설정 값

initialSize

:커넥션 풀 생성 시 최소 생성한 connection 객체의 수

minIdle

:최소한으로 유지할 connection 객체 수

maxIdle

:반납된 유휴 connection 유지할 최대 객체 수

maxActive

:동시 사용가능한 최대 connection 객체 수

 

maxActive>=initialSize

maxIdle>=minIdle 이어야하며

maxIdle = maxActive 이어야 좋다.

 

적절한 connection pool 크기

Thread Pool 크기 < Connection Pool 크기

 

적절한 connection 크기 = ((core_count) * 2) + effective_spindle_count)

core_count: 현재 사용하는 서버 환경에서의 CPU 개수

effective_spindle_count: DB 서버가 관리할 수 있는 동시 I/O 요청 수

 

RDS max_connections 디폴트 값: DBInstanceClassMemory/12582880

=>RDS 서버의 종류에 따라 다르다.

 

MySQL max_connections 현재 값 확인 명령어

SHOW GLOBAL VARIABLES LIKE 'max_connections';

 

HikariCP

스프링 부트 2.0부터 default JDBC connection pool

application.properties에서 간단하게 HikariCP의 설정 가능

 

스프링부트 yml 파일 HikariCP 설정 예시코드

spring:
 datasource:
   url: 주소주소
   username: root
   password: Password
   hikari:
     maximum-pool-size: 100 #최대 pool 크기
     minimum-idle: 10 #최소 pool 크기
     idle-timeout: 600000 #연결위한 최대 유후 시간
     max-lifetime: 1800000 #반납된 커넥션의 최대 수명

 

 

참고 사이트

https://aws.amazon.com/ko/premiumsupport/knowledge-center/rds-mysql-max-connections/

https://linked2ev.github.io/spring/2019/08/14/Spring-3-%EC%BB%A4%EB%84%A5%EC%85%98-%ED%92%80%EC%9D%B4%EB%9E%80/

https://steady-coding.tistory.com/564

https://programmer93.tistory.com/74

https://juinthyme.tistory.com/70

https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=dnjsjd11&logNo=221270990625 

https://velog.io/@miot2j/Spring-DB%EC%BB%A4%EB%84%A5%EC%85%98%ED%92%80%EA%B3%BC-Hikari-CP-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0

코드 예시들은 실 사용 코드에서 관련된 내용들만 추려 간략화한 예시들입니다. 

 

특정 테이블 A가 있다고 했을 때 해당 테이블의 칼럼 C2를 group의 기준으로 행들을 뽑아내야했는데 이때 대표로 뽑아지는 행은 각 그룹의 칼럼 C1의 최대값을 가지고있는 행이어야했다. 이 결과값을 다시 C1을 기준으로 오름차순으로 정렬하는 게 필요한 결과 값이었다.

 

먼저 생각한 로직은 해당 테이블의 행들을 칼럼 C1 내림차순으로 정렬을 하고 그 결과를 C2 칼럼으로 groupby를 한 후 그 결과를 다시 C1 오름차순으로 정렬을 해야되는 로직이었다. 

이를 querydsl로 아래와 같이 직관적으로 코드를 짰는데 짜고 실행해보니 제대로 값이 나오지 않았다.

        return jpaQueryFactory.selectFrom(A)
                .where(A.id.in(JPAExpressions.select(A.id)
                        .from(A)
                        .orderBy(A.C1.desc())
                        .groupBy(A.C2)
                ))
                .orderBy(A.C1.asc())
                .fetch();

이후에 orderby와 groupby를 같이 연달아쓰면 안될 것 같다는 생각이 들어 구글링을 하던 중 아래와 같은 쿼리를 발견했다.

select *
from(
    select *
    from(
        select *
        from A ud
        order by ud.C1 DESC
        LIMIT 18446744073709551615
        )as ordered
    group by ordered.C2 )as grouped
order by grouped.C1;

예전에 부트캠프에서 수업을 들을 때 프로젝트에서 위와 같이 limit를 활용해 orderby와 groupby를 썼던 기억이 있는데 그때와 같은 방법이었다. limit을 지우면 DBMS가 성능 향상을 위해 자동으로 orderby를 무시한다.

-> 성능에 안좋은 쿼리라는 말,,

 

문제는 그때는 작은 프로젝트라 LIMIT 18446744073709551615를 지정해도 상관없었지만 지금 진행 중인 프로젝트는 규모가 크고 실사용자들이 많이 들어온다. 게다가 해당 쿼리와 연관된 api가 앱 핵심 기능 사용에 있어 필수적인 아주 중요한 api였다.

A 테이블 역시 한 사용자당 데이터양이 끝없이 늘어날 수 있는 데이터의 규모가 테이블들 중 특히 더 사이즈가 큰 테이블이었다.

 

18446744073709551615라는 숫자가 충분히 큰 숫자인지, 내가 일을 하고 있을 동안엔 문제가 될 것 같지 않은데 해당 서비스가 과거를 돌이켜봤을 때 단순한게 몇 년동안 돌아가고 끝날 서비스가 아닌데 차후에라도 문제가 생긴다면 그건 돈을 받고 개발을 하는 입장에서 잘못된 일이라는 생각이 들었다. 

차후에 문제가 생기지않는다하더라도 제한이 없는 코드가 더 좋은 코드라는 생각이 들어 다시 구글링을 통해 아래와 같이 코드를 수정했다.

select A.*
from A inner join
(select max(ud.C1) as updateAt
from A ud
group by ud.C2
) b on A.C1 = b.updateAt
order by A.C1 ASC;

아마 코드를 보면 알겠지만 로직자체가 약간 다르다

원래는 orderby를 한 후 그 결과를 groupby를 하려고했는데 max를 통해 그룹에서 필요한 값을 뽑아냈다.

이 방법이 훨씬 더 깔끔하고 좋은 코드라는 생각이 들었고 성능측면에서도 훨씬 더 좋은 코드인 것 같다.

다른 사람들 코드를 많이 보는 게 중요하다는 걸 다시 한 번 느꼈다..

 

위 쿼리는 JPQL을 활용해 프로젝트에 적용했다. 위 코드대로 하면 오류가 발생해서 네이티브쿼리로 처리하면 된다는 글을 발견해 네이티브 쿼리로 처리 후 정상 작동 확인했다.

    @Query(value=" select A.*\n" +
            "from A inner join\n" +
            "(select\n" +
            "max(ud.C1) as updateAt\n" +
            "from A ud\n" +
            "group by ud.C2\n" +
            ") b on A.C1 = b.updateAt\n" +
            "order by A.C1 ASC",nativeQuery = true)
    List<A> findLatestDevices(@Param("userIdx") Long userId);

 

참고사이트 

https://okky.kr/article/237434

https://simp1e.tistory.com/entry/MYSQL-order-by-%ED%9B%84%EC%97%90-group-%ED%95%98%EB%8A%94-%EB%B0%A9%EB%B2%95

https://mingggu.tistory.com/115

https://stackoverflow.com/questions/54136211/spring-boot-jpa-how-to-find-entity-with-max-value

https://jaenjoy.tistory.com/39

    public UserDevice findLatestDevice (User user){
        return jpaQueryFactory.selectFrom(userDevice)
                .where(userDevice.id.eq(
                        JPAExpressions.select(userDevice.id.max())
                                .from(userDevice)
                                .where(userDevice.user.id.eq(user.getId()))
                                .where(userDevice.deviceCategory.name.eq("핸드폰"))
                ))
                .fetchFirst();
    }

인자로 받은 유저의 핸드폰이라는 이름의 유저기기 정보 테이블들의 행 중 가장 id 값이 큰 엔티티 반환

해당 에러로그의 원인을 결론부터 말하자면 필요한 key 값이 저장된 파일이 제대로 된 위치에 존재하지 않고 있었다.

 

현재 진행 중인 프로젝트에서 솔라피를 통해 고객들에게 문자를 보내는 유료 서비스를 사용 중이다.

배포 중인 서버에서는 정상 작동하는데 개발용으로 운영 중인 서버들에서 문자가 제대로 전송되지 않고있다는 이슈를 전달받아 디버깅을 진행했다.

 

이전 개발자 분이 작성한 코드라 코드 분석과 함께 먼저 상황을 파악해보니 문자 전송이 되는 api를 스웨거로 실행해보면 api가 정상적으로 요청 성공했다는 response가 오고있지만 문자는 오고 있지 않았다.

 

배포 중인 서버와 문자가 오고 있지 않은 서버들의 코드또한 동일한 코드로 코드상으로 큰 특이점이 보이지 않았다.

코드에서 다른 점이 없다면 solapi내 설정이나 key 값과 같은 설정 값들에 문제가 있을 거라 생각하고 좀더 깊게 로그들과 설정 정보들을 뜯어볼 필요가 생겼다.

 

먼저, 솔라피 사이트에 로그인하여 정보들을 확인해봤는데 별다른 특이사항이 없었다.

 

다음으로, 관련 자료를 찾아보던 중 솔라피에서 제공해주는 java 예시 코드들 깃헙을 발견했고 해당 레포에서 아래 링크의 문자 전송관련 정보를 출력할 수 있는 코드를 발견했다. 

https://github.com/solapi/solapi-java/blob/main/app/src/main/java/solapi/app/SendJsonLMS.java

 

GitHub - solapi/solapi-java: SOLAPI SDK for Java

SOLAPI SDK for Java. Contribute to solapi/solapi-java development by creating an account on GitHub.

github.com

위 링크의 코드를 참고해 아래와 같은 코드를 넣어 디버깅을 해보고자 시도했다.

                    System.out.println("statusCode : " + response.code());
                    System.out.println("groupId : " + body.getGroupId());
                    System.out.println("messageId : " + body.getMessageId());
                    System.out.println("to : " + body.getTo());
                    System.out.println("from : " + body.getFrom());
                    System.out.println("type : " + body.getType());
                    System.out.println("statusCode : " + body.getStatusCode());
                    System.out.println("statusMessage : " + body.getStatusMessage());
                    System.out.println("customFields : " + body.getCustomFields());

로컬에서 돌려 실험해봤는데 해당 정보들에는 문제가 없었다.

 

마지막으로 무중단 서비스를 돌리고 있었기 때문에 nohup.out 파일을 서버에 접속해 로그를 확인해봤다.

 

api 요청의 응답은 api 요청이 성공했다고 오고있었지만 위 파일에서 로그를 보니 문자 전송을 진행하는 과정에서 에러가 발생하고있었다.

에러가 발생한 경우 슬랙과 연동된 웹훅으로 에러 메시지를 받고있는데 해당 에러 로그는 웹훅 메시지가 오고 있지 않았다.

그래서 제일 마지막에 확인하게 된건데 아마 가끔씩 웹훅이 바로 안뜨고 엄청 늦게 연기되다 올때가 있는데 그런 케이스였던 것 같다..

12:31:01.726 ERROR [File:SolapiMessageSender.java] [Func:onResponse] [Line:40] [Message:{"errorCode":"Unauthorized","errorMessage":"권한이 없습니다."}]
12:38
java.io.FileNotFoundException: 파일경로임당 파일명임당 (No such file or directory)

 

해당 로그를 통해 문자 전송에 필요한 특정 파일이 존재하지 않고있다는 걸 알게됐다.

해당 파일은 키값과 같은 중요한 값들이 담긴 파일이었다.

빌드 파일만 서버로 옮겨 서버에서 돌리면 문자가 안오고 있었는데 빌드할 때 프로젝트 내에 해당 파일이 존재하고 있었지만 이와 상관없이 운영중인 서버에 해당 파일이 특정한 위치에 존재해야지만 문자 전송이 정상적으로 이루어지는 케이스였다. 

 

배포 중인 서버에서 문자가 가고있던 건 이전 개발자분이 서버의 관련된 위치에 파일을 넣어두셨었다!

운영 중인 서버의 개발 서버 관련 폴더에 접근해 해당 파일을 정해진 경로에 위치시킨 후 다시 api를 실행해보니 정상작동됐다.

 

필자는 주로 키값을 다룰 때 프로젝트 내에 파일을 위치시키고 깃에서 제외시킨 후 빌드만 해도 바로 사용할 수 있도록 사용하고 있었는데 이전 개발자분은 프로젝트 빌드와 상관없는 위치에 따로 키값 정보가 담긴 파일을 위치시키고 해당 파일의 경로를 통해 값을 가져오는 방식을 사용하신 것 같다. 

이런 방법은 생각해본 없었는데 서버에 접속해서 해당 파일을 열어봐야지만 키값을 확인할 수 있기때문에 조금 더 신경써야되는 부분이 많지만 유출에 있어서 더 안전한 방법인 것 같다!

아래와 같은 443 포트 서버 블록의 root와 index 값을 지정하고 index 파일을 띄우려고 했는데 계속 404 에러가 떴다.

다른 서버 블록들에서는 다 잘 적용됐는데 443 포트를 바라보고 있는 서버 블록만 계속 index가 적용이 안되는 문제가 발생했다.

server {
        # SSL configuration
        listen 443 ssl default_server;
        listen [::]:443 ssl default_server ipv6only=on;
        server_name 도메인명;
        root 루트폴더경로;
        index i.html;

        ssl_certificate 키경로;
        ssl_certificate_key 키경로;
        ssl_prefer_server_ciphers on;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
        
        location / {
          		proxy_pass http://localhost:포트번호;
                proxy_read_timeout 6000;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header Host $http_host;
                }
}

 

구글링을 통해 443 포트 서버 블록의 location = / {} 블록을 통해  root와 index를 다시 지정한 후  해당 도메인명 주소로 들어올 때만 지정된 html 파일을 띄우려고 했는데 계속 404 에러가 발생했다.

다른 서버 블록들에선 다 정상 작동하는데 해당 블록에서만 발생한 문제로 구글링을 통해 계속 여러 방식들을 적용해봤지만 404 에러가 사라지지 않았다.

 

그러다 우연히 root 대신 alias를 사용한 코드를 보게 됐고 해당 문법을 활용해 404 에러를 해결했다.

root는 상대 경로를 사용하고 alias는 절대경로를 사용하는데 alias를 사용했을 때 404 에러가 사라진 걸 보니 기본으로 설정된 상대 경로에서 다시 동일한 위치 상대 경로로 접근을 하려고 해서 잘못된 주소로 접근을 했던게아닌가싶다.

 

server {
        # SSL configuration
        listen 443 ssl default_server;
        listen [::]:443 ssl default_server ipv6only=on;
        server_name 도메인명;
        root 루트폴더경로;
        index i.html;

        ssl_certificate 키경로;
        ssl_certificate_key 키경로;
        ssl_prefer_server_ciphers on;
        ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
 		location = / {
             default_type "text/html"; //해당 코드가 없으면 html이 띄워지지 않고 다운된다고 한다.
             alias 루트폴더경로;
             index i.html;
        }
        location / {
          		proxy_pass http://localhost:포트번호;
                proxy_read_timeout 6000;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header Host $http_host;
                }
}

해당 코드를 통해 404 에러를 없애는 것 까지는 성공했는데 이후 403 에러가 발생했다.

 

관련해서 다시 구글링도 해보고 nginx 에러 로그도 들어가서 보니 권한 문제라고 한다.

해결법을 찾아보니 권한을 가진 nginx 설정 파일의 사용자와 html 파일의 사용자가 동일한지 체크해야하고 그래도 안되면 권한 설정 명령어를 통해 해당 root 폴더의 권한을 변경해줘야한다고 돼있었다. 권한 설정을 바꾸는 건 보안상 좋은 해결법은 아닌 것 같다.

 

띄우고자 하는 html 파일 경로에 들어가서 아래 명령어를 치면 권한이 있는 사용자와 권한 허용영역을 알 수 있다.

ls -al

사용자의 이름이 ubuntu임을 확인한 후 nginx 설정 파일에서 사용자 권한을 찾아봤다.

cd /etc/nginx
vi nginx.conf

해당 설정 파일의 첫줄에 user 사용자명;이라고  적힌 부분을 통해 권한이 있는 사용자명을 확인할 수 있는데 html 파일 권한자의 이름과 일치 하지 않았다.

nginx.conf 파일에서 사용자명을 html 파일의 사용자명으로 바꾼후 저장을 하고 nginx를 재시작하면 해결된다고 한다.

다른 서버 블록들은 알아서 잘 띄어지던데 해당 서버 블록만 왜 적용이 안됐던 건지 nginx 설정에 관해 더 공부를 해보면 좋을 것 같다.

 

참고 사이트

https://mik-a.com/m/88

https://ganbarujoy.tistory.com/113

https://bing-su-b.tistory.com/7

+ Recent posts