#bash 쉘프롬프트 변경(필요시)
sudo su
echo 'PS1="[\u@\h \$PWD \D{%T}]\\$ "' >> /etc/bashrc && source /etc/bashrc


# Amazon Corretto JDK 21을 ~/apps 디렉토리 하위에 설치하는 명령어
# https://docs.aws.amazon.com/corretto/latest/corretto-21-ug/downloads-list.html 에서 다운로드 URL확이 ㄴ가능

# 디렉토리 생성(apps 디렉토리 하위에 생성 예정)
mkdir apps

# 다운로드
wget 'https://corretto.aws/downloads/latest/amazon-corretto-21-x64-linux-jdk.tar.gz' -O ~/apps/amazon-corretto-21-x64-linux-jdk.tar.gz


# 압축해제 및 삭제
cd ~/apps && tar -xzf amazon-corretto-21-x64-linux-jdk.tar.gz && rm -f amazon-corretto-21-x64-linux-jdk.tar.gz

# 심볼릭 링크 (필요시) 심볼릭 링크가 존재한다면, 삭제 후 재생성
cd ~/apps && rm jdk_21 && ln -s amazon-corretto-21.0.1.12.1-linux-x64 jdk_21

## DNS TTL 무제한 -> 10초로 수정 (어플리케이션마다 달라야 할 수 있음)
echo 'networkaddress.cache.ttl=10' >> ~/apps/jdk_21/conf/security/java.security

# 환경변수 및 디폴트 jdk 설정
echo 'export JAVA_21_HOME=~/apps/jdk_21' >> ~/.bashrc && echo 'export PATH=$JAVA_21_HOME/bin:$PATH' >> ~/.bashrc && source ~/.bashrc

# 기타 - jdk 버전 확인
$JAVA_HOME/bin/java -version
$JAVA_21_HOME/bin/java -version

메모 목적으로 생략한 내용이 많은 소스입니다.

 

  1. 목적/배경
    1. 구글 또는 애플 등의 스토어에 유저는 구매 후 스토어에 직접 취소할 수 있음
    2. 유저의 취소가 발생하면 개발자는 '권한'을 회수하거나 블럭 등을 해야함
      1. 유저 취소가 발생하면 실시간 알림도 이제는 받을 수 있음
      2. 아래 소스는 java로 간단히 작성한 과거 리스트를 조회하는 소스입니다.
  2. 소스
아래 구글 라이브러리 추가

<!-- https://mvnrepository.com/artifact/com.google.apis/google-api-services-androidpublisher -->
<dependency>
    <groupId>com.google.apis</groupId>
    <artifactId>google-api-services-androidpublisher</artifactId>
    <version>v3-rev20231012-2.0.0</version>
</dependency>

 

import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.services.androidpublisher.AndroidPublisher;
import com.google.api.services.androidpublisher.AndroidPublisherScopes;
import com.google.api.services.androidpublisher.model.VoidedPurchasesListResponse;
import lombok.extern.slf4j.Slf4j;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;
import java.util.Collections;

/**
 * 구글 스토어에 유저가 구매 취소한 리스트를 조회하는 API 사용 샘플 소스
 * - Lists the purchases that were canceled, refunded or charged-back
 *
 * @author
 */
@Slf4j
public class GoogleVoidedpurchasesListSample {

    /**
     * API 호출할때의 어플리케이션 명. 자체 정의해서 사용
     */
    public static final String APPLICATION_NAME = "TEST-VoidedpurchasesListSample";

    /**
     * 테스트대상 구글앱의 패키지명
     */
    public static final String PACKAGE_NAME = "입력필요";

    /**
     * 인증을 위한 인증파일
     * - 프로토타이핑 중에 임시로 코드저장소외 외부환경 요소로 파일 저장 후 사용, 보안을 위해서 GIT과 같은 VCS에 올라가면 안되며, 라이브환경에서는 AWS Secrets Manager 등을 활용
     */
    public static final String AUTH_FILE_PATH = "인증용 json파일 경로. 구글 Cloud 콘솔에서 다운로드";

    private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
    private static HttpTransport HTTP_TRANSPORT;

    static {
        try {
            HTTP_TRANSPORT = GoogleNetHttpTransport.newTrustedTransport();
        } catch (GeneralSecurityException e) {
            throw new RuntimeException(e);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 실행 메소드
     * - 구글 API 정의서: https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.voidedpurchases/list?=en
     *
     * @param args
     * @throws IOException
     */
    public static void main(String[] args) throws IOException {

        final AndroidPublisher apiClient = getApiClient(AUTH_FILE_PATH, APPLICATION_NAME);


        AndroidPublisher.Purchases.Voidedpurchases.List listRequest = apiClient.purchases().voidedpurchases().list(PACKAGE_NAME);

        //추가 조회 필터링 조건들 설정
        listRequest.setMaxResults(2L);
        //listRequest.setToken("넥스트페이징 토큰");
        VoidedPurchasesListResponse voidedpurchasesList = listRequest.execute(); //실행

        //구글 응답 필드들 참고
        // 1) https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.voidedpurchases/list#response-body
        // 2) https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.voidedpurchases#VoidedPurchase
        log.info("voidedpurchasesList : {}", voidedpurchasesList);

        //응답 결과 중 voidedSource와 voidedReason 등을 참고해서 스토어 취소 악용한 유저에 대해서 블럭과 같은 이용제한 기능을 구현하면 됨


    }

    /**
     * 구글 인증 후 API를 바로 사용 가능한 클라이언트 객체를 리턴
     *
     * @param authFilePath
     * @param applicationName
     * @return
     * @throws IOException
     */
    public static AndroidPublisher getApiClient(String authFilePath, String applicationName) throws IOException {

        // Authorization.
        final Credential credential = authorizeWithServiceAccount(authFilePath);

        log.debug("credential : {}", credential);

        // Set up and return API client.
        return new AndroidPublisher.Builder(HTTP_TRANSPORT, JSON_FACTORY, credential).setApplicationName(applicationName).build();
    }

    /**
     * 구글 Android Publisher 인증
     *
     * @param apiAuthFilePath
     * @return
     * @throws IOException
     */
    public static Credential authorizeWithServiceAccount(String apiAuthFilePath)
            throws IOException {

        InputStream inputStream = new FileInputStream(apiAuthFilePath);

        GoogleCredential credential = GoogleCredential.fromStream(inputStream, HTTP_TRANSPORT,
                JSON_FACTORY);
        credential = credential.createScoped(Collections.singleton(AndroidPublisherScopes.ANDROIDPUBLISHER));

        return credential;
    }

}

JDK 17로 버전업을 준비하면서 ZGC도 간략하게 정리해봤습니다.

기존에 알고 있던 내용도 있었지만, 리마인드차원에서 정리한 부분도 있습니다.

ZGC 간략한 정리 마인드맵 버전

-----

아래는 위 마인드맵의 아웃라인 텍스트 버전입니다.

 

 

ZGC 간략한 정리
ZGC란?
적용 버전
JDK 11에서 실험적 기능으로 추가
JDK 15에서 정식 GC로 인정
LTS 버전인 JDK 17에도 반영
ZGC 메모리 구조
메모리를 ZPage라는 논리적인 단위로 구분
G1 GC에서는 region 이라는 논리 단위 사용
ZPage는 3가지 타입 존재하고, 들어갈 수 있는 객체 크기가 제한됨
small
medium
large
주의: 단 하나의 객체만 할당 가능
colored pointer
Linux x86-64 아키텍쳐에서 가상 메모리 주소를 위해 48bit 사용해 256TB 대역의 가상 메모리를 사용
ZGC는 6bit적은 42bit를 사용
42~45번째 bit는 colored pointer로 사용해 GC처리에 활용
colored pointer에는 marked0, marked1, remapped, finalizable이 있다
marked0과 marked1, remapped는 각 포인터를 사용하는 CG 단계에서 마스킹을 통해 가상 메모리 주소를 가져오는 데 사용
그렇기 때문에 ZGC를 사용하는 환경에서는 RSS(resident set size)가 실제 메모리 사용량보다 3배 크게 관측
주의: 트래픽이 많은 환경에서 JVM heap 설정에 맞게 maxmapcount 값을 수정하지 않으면  JVM 크래시
이를 확보하지 않고 JVM을 실행해 OOM(out of memory)가 발생하게 하면 크래시가 발생한다
(max_capacity/ZGranuleSize) x 3 x 1.2'로 설정
sudo cat /proc/sys/vm/max_map_count
AWS와 같은 클라우드에서 OOM killers에 의해 강제 종료 되는걸 조심
예) -xmx=4G → RSS ~= 12G : 실제 메모리는 4G 까지 써도 RSS 사이즈는 12G정도까지 관측될 수 있음
load barrier
load barrier는 쉽게 말해 heap으로부터 참조가 일어날 때마다 실행되는 코드
참조전에 방어막처럼 막음
ref가 유효한지 체크해서 fast path, slow path 등
ZGC의 처리 방식
10단계를 거쳐서 처리
coloring
phase 1~phase 5
이전 세대 GC의 marking과 동일
Java heap에 있는 객체 중 reachable 객체와 unreachable 객체를 탐색해 표시(marking)하는 과정
unreachable 객체는 더 이상 참조가 일어나지 않는 객체이기 때문에 GC의 대상
relocation
phase 6~phase 10
coloring 단계를 거친 객체를 재배치하는 단계
특징
STW 상태를 10ms 아래로 가져가는 것
대기 시간이 짧은 Application에 적합한 GC
Thread가 실행중일때 동시 작업을 수행하기에 모든 작업을 동시에 수행 (병렬처리)
8MB~16TB까지의 heap 크기를 지원
적용 방법
JDK 17이상(가능하면) 사용
-XX:+UseZGC
참고 링크
https://d2.naver.com/helloworld/0128759
https://www.blog-dreamus.com/post/zgc%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C
https://ionutbalosin.com/2020/01/hotspot-jvm-performance-tuning-guidelines/

가끔 Jit 컴파일러 튜닝해야하는 경우 참고하는 내용
 - https://man.archlinux.org/man/java-openjdk11.1.en#Advanced_JIT_Compiler_Options

요즘 가장 관심이 있는 java쪽 기능/피쳐

모든 기존 프로덕트를 재 개발할수도 없고, 리액티브 스타일로 개발시 여러 허들과 트러블 슈팅도 있어서..

java virtual thread의 draft(2023/03/06 created)
https://openjdk.org/jeps/8303683

 

 

노이즈가 좀 있는 내용 같지만 추가 참고 글

 - https://news.hada.io/topic?id=9250

 

 

jjwt를 사용해서 보통 JWT 처리를 하는데 혹시 secretKey 기존에 대충 만들었다면(길이가 짧게) 0.10.0 버전부터 에러가 발생함

 - https://github.com/jwtk/jjwt/issues/334

https://github.com/jwtk/jjwt/blob/6b980553cfb4435ec14b7842af5b529735acbb2d/impl/src/main/java/io/jsonwebtoken/impl/DefaultJwtParser.java#L364

 

 

이에 간단히 사용하는 SecretKey 생성 소스

import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Encoders;
import io.jsonwebtoken.security.Keys;

import javax.crypto.SecretKey;

public class JwtSecretKeyMaker {

	/**
	 * JJWT secretKey를 생성할때 알고리즘에 맞는 length로 만듬
	 *  - https://github.com/jwtk/jjwt#jws-key-create
	 *  - jjwt 0.10.0 부터는 length가 작으면 에러가 발생함: https://github.com/jwtk/jjwt/issues/334
	 *
	 * @param args
	 */
	public static void main(String[] args) {

		//Creating Safe Keys(length: 256bit))
		SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256); // HS256: HMAC-SHA256
		String secretString = Encoders.BASE64.encode(key.getEncoded());

		System.out.println(secretString);

	}
}
  1. 사전 필요사항
    1. AWS CloudFront 설정
1. 메뉴
 - AWS 웹 콘솔 -> CloudFront -> 대상 CloudFront ID(도메인에 맵핑된) -> Behaviors -> 해당 Behaviors 선택 -> Edit

2.  Cache key and origin requests 영역 설정
 1) Cache policy and origin request policy (recommended) 선택
 2) Cache policy 를  CachingDisabled 로 설정
  - CloudFront를 동적 컨텐츠 전송 목적과 위치정보 헤더 값등 필요 헤더를 획득할 목적이기 때문에
  - 참고: https://aws.amazon.com/ko/cloudfront/dynamic-content/
  - 참고: https://docs.aws.amazon.com/ko_kr/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html
 3) Origin request policy - optional 에 커스텀 policy추가해서 반영
  - 샘플) CloudFront에서 추가되는 헤더를 모두 선택한 경우

설정샘플(주의: 뒷단 was까지 Query Strings와 Cookies를 넘겨줘야하기 때문에 ALL로 설정)

  1. Java 샘플소스
/**
	 * 클라이언트 요청자의 IP를 가져옴(WAS 앞단에 로드밸런서 등이 존재할 수 있음을 감안한 메소드)
	 *  - AWS CloudFront -> ALB(LB) -> Nginx-> 톰캣을 사용하는 케이스를 감안한 메소드
	 *  - AWS CloudFront사용시 해당 헤더의 IP정보를 최우선으로 신뢰함(즉, IP를 얻어오는 순서에 의미가 있음)
	 *  - 주의: Spring webflux는 해당 메소드를 사용불가(대신 ServerWebExchange exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() 같은 메소드를 사용해야함)
	 *
	 * @param request HttpServletRequest
	 * @return
	 */
	public static String getClientIp(HttpServletRequest request) {

		//참고 https://docs.aws.amazon.com/ko_kr/AmazonCloudFront/latest/DeveloperGuide/using-cloudfront-headers.html#cloudfront-headers-viewer-location
		//참고 https://docs.aws.amazon.com/ko_kr/AmazonCloudFront/latest/DeveloperGuide/WhatsNew.html
		final String awsCfAddress = request.getHeader("CloudFront-Viewer-Address"); //ex) 123.45.67.89:46532
		if (StringUtils.isNotBlank(awsCfAddress)) {
			log.trace("\nIP획득 출처: AWS CloudFront-Viewer-Address\n\t요청 http header => {}", HttpRequestUtil.getRequestHeaderToMap(request));
			return awsCfAddress.split(":")[0].trim(); //구분자 :
		}

		//프록시 레이어를 감안한 XFF값
		//TODO: XFF는 spoofed(속일 수 있음)가 가능하기 때문에 오른쪽부터 IP를 가져오도록 수정해야함. 이때 신뢰하는 프록시IP들은 제외하는 로직 작업도 필요 https://news.hada.io/topic?id=6098
		//https://en.wikipedia.org/wiki/X-Forwarded-For
		//https://blog.lael.be/post/8989
		final String xForwardedFor = request.getHeader("X-Forwarded-For"); //ex) 123.456.78.99,15.162.1.11
		if (StringUtils.isNotBlank(xForwardedFor)) {
			log.trace("\nIP획득 출처: X-Forwarded-For\n\t요청 http header => {}", HttpRequestUtil.getRequestHeaderToMap(request));
			return xForwardedFor.split(",")[0].trim(); //구분자 ,
		}

		final String realIp = request.getHeader("X-Real-IP"); //일반적으로는 인프라(프록시) 설정 들이 잘되어 있다면 다른 언어 및 프레임웤에서는 보안문제 때문에 Real IP가 XFF보다 우선순위 높음
		if (StringUtils.isNotBlank(realIp)) {
			log.trace("\nIP획득 출처: X-Real-IP\n\t요청 http header => {}", HttpRequestUtil.getRequestHeaderToMap(request));
			return realIp;
		}

		log.trace("\nIP획득 출처: request.getRemoteAddr\n\t요청 http header => {}", HttpRequestUtil.getRequestHeaderToMap(request));
		return request.getRemoteAddr();
	}

자동화를 위해서 애플 앱스토어의 여러 기능을 개발할 수 있습니다.(애플에서 API일부를 제공해줘서)

 

그 중 앱의 IAP 리스트를 조회하는 API샘플입니다.

 - 주의: API는 호출양 제한이 걸려있습니다.(링크 참고)

 

List All In-App Purchases for an App

  - https://developer.apple.com/documentation/appstoreconnectapi/list_all_in-app_purchases_for_an_app

 

java 샘플 소스

 - git 주소: https://github.com/oshnew/spring-boot-ver2-study/commit/b04e4293e983bf7425be17a97aaac6c80f70a26b

import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import sample.apple.AppStoreConnectApi.AppStoreConnectApiJWT;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

/**
 * 특정 애플 앱의 IAP로 등록된 상품 리스트 조회
 *  - 참고: https://developer.apple.com/documentation/appstoreconnectapi/list_all_in-app_purchases_for_an_app
 *
 * @author
 */
public class ListInAppPurchasesForAnApp {

	public static void main(String[] args) throws IOException {

		final String issuer = "입력필요";
		final String keyIdentifier = "입력필요"; //전달받은 사설키의 키 ID
		final String privateKeyFilePath = "입력필요"; //사설키 경로(제품개발 권한 액세스). 애플 앱스토어 웹 콘솔에서 다운로드 받음. 키에 허용권한(ACCOUNT_HOLDER, ADMIN, APP_MANAGER 중 1가지) 필요

		int expireAfterMinutes = 20; //20분 넘으면 안됨

		AppStoreConnectApiJWT clzJWT = new AppStoreConnectApiJWT();
		final String jwt = clzJWT.makeApiAuthJwt(issuer, keyIdentifier, expireAfterMinutes, privateKeyFilePath); //인증용 JWT생성

		System.out.println("인증용 JWT: " + jwt);

		OkHttpClient client = new OkHttpClient.Builder().readTimeout(4, TimeUnit.SECONDS).connectTimeout(1, TimeUnit.SECONDS).writeTimeout(5, TimeUnit.SECONDS).build();

		final String id = "입력필요"; //애플 앱스토어 웹 콘솔에서 'Apple ID'로 표시되는 부분, 앱의 구분용ID로 추정됨
		String prefixUri = String.format("https://api.appstoreconnect.apple.com/v1/apps/%s/inAppPurchasesV2", id);

		HttpUrl.Builder httpBuilder = HttpUrl.get(prefixUri).newBuilder();
		httpBuilder.addQueryParameter("limit", "200"); //최대 200건까지 API에서 제약됨
		httpBuilder.addQueryParameter("filter[inAppPurchaseType]", "NON_CONSUMABLE"); //비 소모성만 조회하기 필터링

		final String fullUrl = httpBuilder.build().toString();
		System.out.println("fullrUrl: " + fullUrl);

		Request request = new Request.Builder().addHeader("Authorization", "Bearer " + jwt).url(fullUrl).build();
		Response response = client.newCall(request).execute();

		if (response.isSuccessful()) {
			String rtnMsg = response.body().string();
			System.out.println(String.format("성공. 응답 msg => '%s", rtnMsg));
		} else {
			System.out.println(String.format("응답에러(200이 아님). 응답 code:'%s'\n errMsg:'%s'", response.code(), response.body().string()));
		}

	}
}

 

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.apache.tomcat.util.codec.binary.Base64;

import java.io.BufferedReader;
import java.io.FileReader;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

/**
 * 애플 App Store Connect API에서 사용할 인증용 JWT토큰을 생성
 *
 * @author
 */
public class AppStoreConnectApiJWT {

	/**
	 * 애플 App Store Connect API에서 사용할 인증용 JWT토큰을 생성하는 샘플 소스
	 * - 참고: https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests
	 *
	 * @param issuer Your issuer ID from the API Keys page in App Store Connect; for example, 57246542-96fe-1a63-e053-0824d011072a.
	 * @param keyIdentifier
	 * @param expireAfterMinutes
	 * @param privateKeyFilePath
	 * @return
	 */
	public String makeApiAuthJwt(String issuer, String keyIdentifier, int expireAfterMinutes, String privateKeyFilePath) {

		if (expireAfterMinutes > 20) {
			throw new IllegalArgumentException(
				"expireAfterMinutes는 20보다 작아야합니다. 참고: https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests");
		}

		final String strPrivateKey = readPrivateKeyBody(privateKeyFilePath);
		PrivateKey privateKeyObj = convertToPrivateKeyObj(strPrivateKey);

		Map<String, Object> header = new HashMap<>();
		header.put("alg", SignatureAlgorithm.ES256);
		header.put("kid", keyIdentifier);
		header.put("typ", "JWT");

		final Date now = new Date();

		//주의: 일반적으로 만료시간이 20분보다 크면 애플에서 거부함. https://developer.apple.com/documentation/appstoreconnectapi/generating_tokens_for_api_requests#3878467
		LocalDateTime expLdt = LocalDateTime.ofInstant(now.toInstant(), ZoneId.systemDefault()).plusMinutes(10);
		Date expiredAt = java.sql.Timestamp.valueOf(expLdt);

		// @formatter:off
		String jwt =  Jwts.builder()
		.setHeader(header)
		.setIssuer(issuer)
		.setAudience("appstoreconnect-v1") //애플 API정의서
		.setIssuedAt(now)  //발행 시간
		.setExpiration(expiredAt) //만료시간
		.signWith(SignatureAlgorithm.ES256, privateKeyObj)
		.compact();

		// @formatter:on

		return jwt;
	}

	private static PrivateKey convertToPrivateKeyObj(String strPrivateKey) {

		try {
			byte[] encodedKey = Base64.decodeBase64(strPrivateKey);
			return KeyFactory.getInstance("EC").generatePrivate(new PKCS8EncodedKeySpec(encodedKey));
		} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
			throw new RuntimeException(e.getMessage(), e);
		}
	}

	/**
	 * private key 내용을 얻어옴
	 *  -  -----BEGIN PRIVATE KEY----- 또는 -----END PRIVATE KEY----- 와 같은 가이드라인 줄은 제외하고 실제 사용하는 부분만 파일에서 가져옴
	 *
	 * @param filePath
	 * @return
	 */
	private String readPrivateKeyBody(String filePath) {

		try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {

			String line;
			StringBuilder sb = new StringBuilder();
			while ((line = br.readLine()) != null) {
				if (line.contains("PRIVATE KEY")) { //guard line은 pass
					continue;
				}
				sb.append(line);
			}

			return sb.toString();

		} catch (Exception e) {
			throw new RuntimeException(e.getMessage(), e);
		}
	}
}

 

+ Recent posts