Instagram API 이관, Open API(Instagram) 캐싱처리

2020년 06월 29일 부터 기존 Instagram API 사용 금지!!

Instagram API 가 Facebook 쪽으로 넘어가면서 아주 살짝 사용법이 달라졌다.

Instagram Developer Documentation 여기서 client ID/Secret, callback URL 를 관리했었다면,
이제는 Facebook 개발자 문서 | Facebook API, SDK 및 가이드 여기에 가서 Facebook App 을 만들고 그 안에서 Instagram 제품을 추가하여 관리해야된다.
뭐 아무튼.

OAuth flow 는 똑같다. 별거 없음. 심지어 Endpoint 가 똑같다.
바뀐게 있다면, 처음 받는 Access Token 은 Short Lived Token 이라 1시간 밖에 유효하지 않아서, 아래와 같은 방법으로 Long Lived Token (60일따리) 로 교환해야 된다는 것 정도?
교환 하고 나서 사용자 미디어 에지 쿼리 로 질의를 하면 된다.

curl -k https://graph.instagram.com/access_token?grant_type=ig_exchange_token&client_secret=<YOUR-APP-SECRET>&access_token=<SHORT-LIVED-TOKEN>

그래, 별거 업네. 근데 쿼리 제한이 시간당 240???

원래는 5,000 이었던가 그런데..
보안 문제가 터지고나서 Facebook 에서 민감하게 반응하는 것 같다.
아무튼 우린 써야하잖아? 캐싱처리를 하자.
직접 구현해도 되지만, 잘 구현된걸 사용해도 좋다.

참고로, 클라이언트에 개인이 접속하고, Instagram 으로 계정을 연동하는 서비스에는 아래 캐싱이 의미가 없을 수 있다. 기업 홈페이지처럼 미리 발급받은 토큰으로 불특정 다수에게 기업의 Instagram 미디어를 보여주어야할 경우에는 캐싱이 의미가 있을 것이다. 회사에서 써먹으려고 만들었는데, 고객쪽에서 쿨하게 Instagram 서비스를 없애자고해서 적용하진 못했다.

Google Guava 를 사용했다.

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>28.0-jre</version>
</dependency>

이미 Access Token 를 발급 받은 상태에서 Media Query 결과만 캐싱하는 소스다.

일단 Acess Token 발급 받았다 치고,

Access Token 을 발급 받을 때, json 을 Deserialize 하기 위한 AccessToken

public static class AccessToken {
    String access_token;
    String user_id;
    //setter, getter 생략
}

발급 후에는 미리 넣어 두도록 하자.
Optional 객체로 AccessToken Class 를 받았기 때문에, 쓸데 없이 .get 이 한번 더 들어가 있다.

snsCacheService
  .setAccessToken(
    SnsCacheService.SNS_TYPE.INSTAGRAM,
    accessToken.get().getAccess_token()
  );

화면에서는 그냥 GET 호출만 하면된다.
이렇게 호출하면,

axios({
  method: 'get',
  url: '/test/instagram/media',
  data: {},
}).then(function(response){
  ...
})...
;

이렇게 받자.

@GetMapping("/instagram/media")
@ResponseBody
public SnsCacheService.Instagram.Media instagramMedia() {
    return snsCacheService.getData(
      SnsCacheService.SNS_TYPE.INSTAGRAM,
      SnsCacheService.Instagram.Media.class
    );
}

실제 Cache Service

HttpUtils 은 그냥 각자 만들자. 중요한건 그냥 url 로 요청하는 거다.

package com.harm.service;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.harm.util.HttpUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

@Service
public class SnsCacheService {
    Logger logger = LoggerFactory.getLogger(SnsCacheService.class);
    public enum SNS_TYPE {
        INSTAGRAM
    }
    private Map<SNS_TYPE, SnsModel> snsModelMap = null;
    private LoadingCache<SNS_TYPE, Object> snsDataCacheRepository;

    public SnsCacheService() {
        snsModelMap = new HashMap<>();
        snsModelMap.put(SNS_TYPE.INSTAGRAM, new Instagram.Model(4L)); //fetch 사이즈는 그냥 넣어봤다.
        snsDataCacheRepository = CacheBuilder.newBuilder()
                .maximumSize(SNS_TYPE.values().length) //SNS TYPE 별 캐싱 사이즈 설정
                .expireAfterWrite(1L, TimeUnit.MINUTES) //1분 캐싱
                .build(new CacheLoader<SNS_TYPE, Object>() {
                    @Override
                    public Object load(SNS_TYPE snsType) throws Exception {
                        logger.debug("{} load data", snsType);
                        SnsModel snsModel = snsModelMap.get(snsType);
                        String data = HttpUtils.sendAndRecv(
                                snsModel.getResourceUrl(),
                                snsModel.getResourceMethod(),
                                snsModel.getResourceParam()
                        );
                        logger.debug("loaded data -> {}", data);
                        return snsModel.parseResource(data);
                    }
                });
    }

    //Access Token 발급 후 저장하는 함수
    public void setAccessToken(SNS_TYPE snsType, String accessToken) {
        snsModelMap.get(snsType).setAccessToken(accessToken);
    }

    //Cache Repository 에서 Caching 된 SNS Data 를 가져오는 함수
    public <T> T getData(SNS_TYPE snsType, Class<T> clazz) {
        T data = null;
        try {
            data =  (T)snsDataCacheRepository.get(snsType);
        } catch (ExecutionException e) {
            logger.error(e.getMessage());
        }
        return data;
    }

    //SNS interface
    public static interface SnsModel<T> {
        String getResourceUrl();
        HttpMethod getResourceMethod();
        String getResourceParam();
        void setAccessToken(String accessToken);
        <T> T parseResource(String data);
    }

    //Instagram 만 했지만, 같은 구조로 Facebook, Goole, Naver 에 맞는 class 를 만들면된다.
    //구조화 하려고 static class depth 가 길어진 건 좀 맘에 안든다.
    public static class Instagram {

        public static class Model implements SnsModel<Media> {
            final long fetchLimit;
            long fetchCount = 0;
            String accessToken;
            public Model(long fetchLimit) {
                this.fetchLimit = fetchLimit;
            }
            @Override
            public void setAccessToken(String accessToken) {
                this.accessToken = accessToken;
            }
            @Override
            public String getResourceUrl() {
                return "https://graph.instagram.com/me/media";
            }
            @Override
            public HttpMethod getResourceMethod() {
                return HttpMethod.GET;
            }
            @Override
            public String getResourceParam() {
                StringBuffer sb = new StringBuffer();
                sb
                .append("fields=id,caption,media_url,permalink")
                .append("&access_token=" + accessToken)
                ;
                return sb.toString();
            }

            @Override
            public Media parseResource(String data) {
                Media media = null;
                ObjectMapper objectMapper = new ObjectMapper();
                try {
                    media = objectMapper.readValue(data, Media.class);
                    fetchCount++;
                    if(media != null && media.getPaging().getNext() != null && media.getPaging().getNext().length() > 0 && fetchCount < fetchLimit) {
                        Media innerMedia = parseResource(HttpUtils.sendAndRecv(media.getPaging().getNext(), HttpMethod.GET, null));
                        Media.Data[] prevDatas = media.getData();
                        Media.Data[] nextDatas = innerMedia.getData();
                        Media.Data[] mergeDatas = new Media.Data[prevDatas.length + nextDatas.length];
                        System.arraycopy(prevDatas, 0, mergeDatas, 0, prevDatas.length);
                        System.arraycopy(nextDatas, 0, mergeDatas, prevDatas.length, nextDatas.length);
                        media.setData(mergeDatas);
                    } else {
                        fetchCount = 0;
                    }
                } catch (IOException e) {
                    LoggerFactory.getLogger(Instagram.Media.class).debug("parse error -> {}", e.getMessage());
                }
                return media;
            }
        }
        public static class Media {
            Data[] data;
            Paging paging;
            public Data[] getData() { return data; }
            public void setData(Data[] data) { this.data = data; }
            public Paging getPaging() { return paging; }
            public void setPaging(Paging paging) { this.paging = paging; }

            public static class Data {
                String id;
                String caption;
                String media_url;
                String permalink;
                public String getId() { return id; }
                public void setId(String id) { this.id = id; }
                public String getCaption() { return caption; }
                public void setCaption(String caption) { this.caption = caption; }
                public String getMedia_url() { return media_url; }
                public void setMedia_url(String media_url) { this.media_url = media_url; }
                public String getPermalink() { return permalink; }
                public void setPermalink(String permalink) { this.permalink = permalink; }
            }//Data

            public static class Paging {
                Cursors cursors;
                String next;
                String previous;
                public Cursors getCursors() { return cursors; }
                public void setCursors(Cursors cursors) { this.cursors = cursors; }
                public String getNext() { return next; }
                public void setNext(String next) { this.next = next; }
                public String getPrevious() { return previous; }
                public void setPrevious(String previous) { this.previous = previous; }
                public static class Cursors {
                    String before;
                    String after;
                    public String getBefore() { return before; }
                    public void setBefore(String before) { this.before = before; }
                    public String getAfter() { return after; }
                    public void setAfter(String after) { this.after = after; }
                }//Cursors
            }//Paging

        }//Media
    }//Instagram
}

누가 볼진 모르겠으나, 코드를 보면 fetch Limit 만큼만 쿼리하여 캐싱한다.
Fetch Limit 없이 모든 데이터를 캐싱하면 너무 많을 수도 있으므로, 제한했다.
Fetch Limit 이전의 Media 는 가져올 수 없는 구조다.