참고 링크 

 - https://sas-study.tistory.com/410

 - 

 

코드 샘플

import org.springframework.security.core.annotation.AuthenticationPrincipal;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : account")
public @interface CurrentUser {

}
  1. 신규 개발자 입사 또는 성능 문제 발생시 공유하는 java 코딩 방법
  2.  모두 지킬 수는 없지만 알고 있으면 좋은 내용


p.s. 생각날때 개인 의견 추가 및 링크들 추가 예정

AWS에서 Redis(Elastic cache)사용시 모니터링 등에 사용하는 커맨드
 - 예) 리얼 환경에서 사용하면 안되는 커맨드가 가끔 개발자 실수로 반영될 수 있음(예. keys)

-- redis에 keys 커맨드가 실행되고 있는지 모니터링하는 커맨드
 redis-cli -h 도메인혹은IP monitor | grep keys
  - 예) redis-cli -h 블라블라.apn2.cache.amazonaws.com monitor


-- redis에 접속되어있는 유니크한 클라이언 IP 리스트 확인 커맨드
redis-cli -h redis서버호스트 CLIENT LIST | awk '{print $2}' | sed s/:.*//g | sort -u

 


AWS ec2에 redis-cli설치하는 방법

# make 하기 위해서 gcc 다운
sudo yum install -y gcc

# redis-cli 설치 및 make
wget http://download.redis.io/redis-stable.tar.gz && tar xvzf redis-stable.tar.gz && cd redis-stable && make

#src/redis-cli 에서 사용 가능
예) ./redis-cli -h 'redis 호스트 도메인' -p '포트번호'

 

git 링크

 - https://github.com/oshnew/study-modern-java/blob/master/src/main/java/stream/StreamSorted.java

 

import lombok.AllArgsConstructor;
import lombok.Data;

import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 내부 개발자 교육을 위한 간단한 java Stream 샘플 소스
 *  - 정렬
 *
 * @author
 */
public class StreamSorted {

	public static void main(String[] args) {

		//테스트 데이터 셋팅
		List<StatVO> statList = Arrays.asList(new StatVO("a", 3), new StatVO("b_a", 2), new StatVO("b_c", 1), new StatVO("d", 7));

		System.out.println("=== cnt 오름차순 정렬");
		statList.stream().sorted(Comparator.comparing(StatVO::getCnt)).forEach(System.out::println);

		System.out.println("\n\n");
		System.out.println("=== cnt 내림차순 정렬");
		statList.stream().sorted(Comparator.comparing(StatVO::getCnt).reversed()).forEach(System.out::println);

		System.out.println("\n\n");
		System.out.println("=== stat name 오름차순 후에 cnt 내림차순정렬"); //다수 필드 정렬
		statList.stream().sorted(Comparator.comparing(StatVO::getStat).thenComparing(StatVO::getCnt).reversed()).forEach(System.out::println);

		System.out.println("\n\n");
		List<StatVO> descList = statList.stream().sorted(Comparator.comparing(StatVO::getCnt)).collect(Collectors.toList());
		System.out.println("=== 오름차순 정렬하여 collect한 리스트");
		descList.stream().forEach(System.out::println);

	}

	@Data
	@AllArgsConstructor
	public static class StatVO {
		private String stat;
		private int cnt;
	}

}
  1. 인증
    1. 서비스 계정으로 인증: https://cloud.google.com/docs/authentication/production?hl=ko

1. 목적/배경

 - 가끔 대용량 데이터 처리(예. DB에 100만건 insert values로 bulk insert할때) 부하 분산을 위해 적절한 데이터 사이즈로 분할하여 처리할 필요가 있음

   (간단한 수학 계산인데 필요할때마다 다시 코딩하는게 번거롭고 헷갈려서 메모 목적으로 작성해둡니다.

 

2. 처리 방법

 - 적절한 데이터 사이즈만큼 잘라서 loop처리

 

 

샘플 소스

/**
	 * N개의 데이터 리스트를 자르는 간단한 소스
	 *   - 대용량 DB 저장 등의 기능 구현때 부하 분산 목적
	 *
	 * @param args
	 */
	public static void main(String[] args) {

		//테스트 데이터 셋팅
		int dataSize = 103;
		ArrayList<Integer> dataList = new ArrayList<>(dataSize);
		for (int i = 1; i <= dataSize; i++) {
			dataList.add(i);
		}

		//처리 시작
		final int dataCnt = dataList.size();
		final int PER_CNT = 10; //1회 처리 건수

		int fromIndex = 0;
		int toIndex = 0;

		boolean isNeedNextLoop = true;
		int loopCompleteCnt = 0; //loop처리 완료된 횟수
		do {

			fromIndex = loopCompleteCnt * PER_CNT;  //최초 시작은 0, 이후 PER_SAVE_CNT개 만큼씩 fromIndex는 증가
			toIndex = fromIndex + PER_CNT; //from index에서 1회 최대 건수만큼까지 toIndex는 증가
			if (toIndex >= dataCnt) { //증가한 toIndex가 데이터 전체 갯수보다 클때는 toIndex 값을 변경(마지막 loop에서는 데이터 element갯수가 1회 처리량보다 적거나 같음)
				toIndex = dataCnt;
				isNeedNextLoop = false;
			}		

			//비즈니스 로직 작성
			System.out.println(String.format("%s회 data subList: %s", loopCompleteCnt+1, dataList.subList(fromIndex, toIndex)));
            loopCompleteCnt++;

		} while (isNeedNextLoop);

	}

 

샘플소스 결과 

1회 data subList: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
2회 data subList: [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
3회 data subList: [21, 22, 23, 24, 25, 26, 27, 28, 29, 30]
4회 data subList: [31, 32, 33, 34, 35, 36, 37, 38, 39, 40]
5회 data subList: [41, 42, 43, 44, 45, 46, 47, 48, 49, 50]
6회 data subList: [51, 52, 53, 54, 55, 56, 57, 58, 59, 60]
7회 data subList: [61, 62, 63, 64, 65, 66, 67, 68, 69, 70]
8회 data subList: [71, 72, 73, 74, 75, 76, 77, 78, 79, 80]
9회 data subList: [81, 82, 83, 84, 85, 86, 87, 88, 89, 90]
10회 data subList: [91, 92, 93, 94, 95, 96, 97, 98, 99, 100]
11회 data subList: [101, 102, 103]


필요한 일이 생겨서 간단히 프로토타이핑한 구글 수익보고서 다운로드하는 java프로그램
 - 2024-07-22 간단히 업데이트 진행

 

필요 라이브러리(기타 추가 import가 필요할 수도 있음)

<!-- https://mvnrepository.com/artifact/com.google.apis/google-api-services-storage -->
<dependency>
    <groupId>com.google.apis</groupId>
    <artifactId>google-api-services-storage</artifactId>
    <version>v1-rev20240706-2.0.0</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.google.api-client/google-api-client -->
<dependency>
    <groupId>com.google.api-client</groupId>
    <artifactId>google-api-client</artifactId>
    <version>2.6.0</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.google.http-client/google-http-client-jackson2 -->
<dependency>
    <groupId>com.google.http-client</groupId>
    <artifactId>google-http-client-jackson2</artifactId>
    <version>1.44.2</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.jackson2.JacksonFactory;
import com.google.api.services.storage.Storage;
import com.google.api.services.storage.StorageScopes;
import com.google.api.services.storage.model.Objects;
import com.google.api.services.storage.model.StorageObject;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;

/**
 * 구글 재무보고서 중에서 '수익'보고서를 다운로드하는 샘플 프로그램
 *  - 참고: https://support.google.com/googleplay/android-developer/answer/6135870#zippy=%2C%EC%88%98%EC%9D%B5
 *
 *  - 추가 참고사항
 *  1) 수익 보고서를 사용하여 판매 대금과 거래 내역를 파악할 수 있습니다.
 *     보고서의 각 행은 거래 유형(예: 고객에게 대금을 청구하거나 Google에 수수료를 지급하는 시기)과 함께 원래 금액 및 변환된 금액을 나타냅니다.
 *  2) 수익 보고서에는 이전 달에 발생한 인보이스가 포함됩니다. 최소 지급액에 도달하면 인보이스를 받게 됩니다. 수익 보고서를 사용할 수 있게 되면 몇 주 후에판매 대금을 수령할 수 있습니다.
 *  3) 수익 보고서는 한 달에 한 번 생성되며 일반적으로 다음 달 5일에 제공됩니다.
 *  	경우에 따라 Google에서 계산 오류를 바로잡기 위해 수익을 조정할 수 있습니다.
 *  	이 경우 Google에서 문제에 관해 알려 드리며 개발자 기록용으로 조정된 거래만 포함되어 있는 추가 수입 파일을 생성합니다.
 *	4) Google은 유럽 경제 지역(EEA)의 사용자에게 판매되는 상품의 등록된 판매자이므로, 영향을 받는 국가에서 이루어진 판매가 주문당 한 줄씩 표시됩니다.
 *		또한 거래 유형은 '청구'로 표시됩니다. 다른 국가에서 이루어진 판매에는 'Google 수수료' 거래 유형도 포함됩니다.
 * 	5) 수익 보고서에는 지불 거절이 포함되지 않습니다
 *
 * @author
 */
@Slf4j
public class GoogleEarningsReportDownloadTest {

  //다운로드 저장 디렉토리 경로
  final static String saveDirStr = ".temp_google_earnings";

  //Google Cloud Platform에서 생성한 Service Account의 키 파일. 해당 키 파일의 유저(이메일주소) Google Play Console에 초대 후 권한 지급이 되어 있어야 함
  //일괄 보고서에 액세스하려면 '앱 정보 보기' 권한이 '전체'로 설정되어 있어야 합니다.
  //재무 보고서를 다운로드하려면 '재무 데이터 보기' 권한이 '전체'로 설정되어 있어야 합니다.
  //참고: https://support.google.com/googleplay/android-developer/answer/6135870#zippy=%2C%EC%88%98%EC%9D%B5%2C%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%B0%8F-%EC%84%9C%EB%B9%84%EC%8A%A4-%EA%B3%84%EC%A0%95%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EC%97%AC-%EB%B3%B4%EA%B3%A0%EC%84%9C-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C%ED%95%98%EA%B8%B0
  final static String SERVICE_ACCOUNT_KEY_FILE_PATH = "구글 Service Account 인증 파일 경로.json";

  //버킷
  final static String BUCKET_NAME = "pubsite_prod_블라블라"; //버킷명(Google Play Console -> 보고서 다운로드 -> 재무 -> Cloud Storage URI복사를 통해서 확인 가능)

  //다운로드할 수익보고서 object name
  final static String DOWNLOAD_OBJECT_NAME = "earnings/earnings_202406_블라블라.zip"; //object name 샘플. earnings/earnings_202110_블라블라숫자-숫자.zip
  final static String applicationName = "test-app-0.0.1";

  //인증 스콥
  final static String GoogleCredential_SCOPE = StorageScopes.CLOUD_PLATFORM_READ_ONLY;


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

    LocalDateTime start = LocalDateTime.now();
    log.info("\n\n==== Start: {} ====", start);

    HttpTransport httpTransport = GoogleNetHttpTransport.newTrustedTransport();
    JsonFactory jsonFactory = JacksonFactory.getDefaultInstance();

    Credential credential = authorizeWithServiceAccount(httpTransport, jsonFactory);
    Storage storage = new Storage.Builder(httpTransport, jsonFactory, credential).setApplicationName(applicationName).build();

    //테스트로 prefixName 기반으로 리스트 조회
    final String namePreifx = "earnings/earnings_2024"; //2024년 prefix로 전체 조회
    listObject(storage, BUCKET_NAME, namePreifx).forEach(storageObject -> {
      log.info("버킷의 {} prefix storageObject:{}", namePreifx, storageObject);
    });

    //버킷의 모든 object를 조회하는 경우: 오버헤드 주희
//    storage.objects().list(BUCKET_NAME).execute().getItems().forEach(storageObject -> {
//      log.info("storageObject:{}", storageObject);
//    });

    final Storage.Objects.Get downloartTargetObject = storage.objects().get(BUCKET_NAME, DOWNLOAD_OBJECT_NAME);

    final StorageObject storageObject = downloartTargetObject.execute();
    log.info("storageObject 정보 ==>\n\t{}", storageObject.toPrettyString());

    FileUtils.forceMkdir(new File(saveDirStr)); //디렉토리 생성
    log.info("저장 디렉토리:{}", saveDirStr);

    String objectNameSplitArray[] = StringUtils.split(DOWNLOAD_OBJECT_NAME, "/");
    String saveFileFullPathStr = saveDirStr + File.separator + objectNameSplitArray[1];

    log.info("저장 파일 full path:{}", saveFileFullPathStr);

    final File saveFile = new File(saveFileFullPathStr);
    FileUtils.deleteQuietly(saveFile); //기존 다운로드된 파일이 존재할 수 있어서 선 삭제 진행

    FileOutputStream out = new FileOutputStream(saveFile);
    downloartTargetObject.getMediaHttpDownloader().setDirectDownloadEnabled(true); //true 설정 필요
    downloartTargetObject.executeMediaAndDownloadTo(out);

    LocalDateTime end = LocalDateTime.now();
    log.info("\n\n==== End: {} ==== 소요시간: {}(second)", end, Duration.between(start, end).getSeconds());
  }

  /**
   * 서비스 account 인증
   *
   * @param httpTransport
   * @param jsonFactory
   * @return
   * @throws IOException
   */
  private static Credential authorizeWithServiceAccount(HttpTransport httpTransport, JsonFactory jsonFactory) throws IOException {

    final String privateKey = FileUtils.readFileToString(new File(SERVICE_ACCOUNT_KEY_FILE_PATH), "UTF-8");

    InputStream inputStream = new ByteArrayInputStream(privateKey.getBytes(StandardCharsets.UTF_8));

    GoogleCredential credential = GoogleCredential.fromStream(inputStream, httpTransport, jsonFactory);
    credential = credential.createScoped(Collections.singleton(GoogleCredential_SCOPE));

    return credential;
  }

  /**
   * Storage의 object들 중에서, name prefix로 필터링하여 가져옴
   *
   * @param storage
   * @param bucketName
   * @param namePrefix
   * @return
   * @throws IOException
   */
  public static Iterable<StorageObject> listObject(Storage storage, String bucketName, String namePrefix) throws IOException {

    List<List<StorageObject>> pagedList = Lists.newArrayList();
    Storage.Objects.List listObjects = storage.objects().list(bucketName).setPrefix(namePrefix); //name prefix로 필터링해서 가져오도록 설정 -> 속도 개선됨
    Objects objects;
    do {
      objects = listObjects.execute();
      List<StorageObject> items = objects.getItems();
      if (items != null) {
        pagedList.add(objects.getItems());
      }
      listObjects.setPageToken(objects.getNextPageToken());
    } while (objects.getNextPageToken() != null);

    return Iterables.concat(pagedList);
  }

}

구글에서는 개발자에게 재무보고서를 제공하고 있습니다.

 

'예상판매실적 보고서', '수익 보고서', '대한민국 Play 잔액 차감 보고서'를 제공하는데, 이 중 실제 회계처리는 수익 보고서를 이용해야합니다.

 

그런데 수익 보고서 CSV파일을 분석해보니 조금 귀찮은 문제(타임존 처리, 필드 포맷 등)가 있습니다.

이에 간단히 프로그램을 만들었습니다.

 

프로그램은 한국 개발자에게 익숙한 java로 만들었습니다.

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord;
import org.apache.commons.lang3.StringUtils;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
 * 구글 재무보고서 중에서 '수익'보고서 CSV파일을 읽어서 파싱
 *  - 구글 '재무보고서 수익'에 대한 설명: https://support.google.com/googleplay/android-developer/answer/6135870#zippy=%2C%EC%88%98%EC%9D%B5
 *  - apache commons-csv를 이용해서 CSV파일을 파싱( https://mvnrepository.com/artifact/org.apache.commons/commons-csv/1.9.0 )
 *
 * @author eomsh
 */
@Slf4j
public class EarningsReportFileParser {

	private static String targetFilePath = "파싱할 CSV파일 경로"; //파싱할 CSV파일

	private static DateTimeFormatter parseFMT = DateTimeFormatter.ofPattern("MMM d, yyyy hh:mm:ss a z", Locale.ENGLISH); //CSV 일시 파싱을 위한 포맷(예. Sep 1, 2021 12:00:20 AM PST)

	//private static ZoneId kstZoneId = ZoneId.of("Asia/Seoul");
	private static ZoneId pstZoneId = ZoneId.of("America/Los_Angeles"); //PST는 ZoneId가 'America/Los_Angeles' 임
	private static ZoneId pdtZoneId = ZoneId.of("GMT-07:00"); //PDT는 ZoneId가 'GMT-07:00' 임.

	private static Map<String, Integer> csvHeadMap = null; //CSV 헤더 맵

	/**
	 * 구글 개발자콘솔에서 다운로드한 재무보고서-수익 CSV파일을 파싱하는 프로그램
	 *
	 * @param args
	 * @throws Exception
	 */
	public static void main(String[] args) throws Exception {

		LocalDateTime start = LocalDateTime.now();
		log.info("\n\n==== Start: {} ====", start);

		List<CSVRecord> records = getCsvRecords(new File(targetFilePath)); //파싱하여 데이터 records를 얻음

		log.info("CSV 데이터 records 수: {}", records.size());
		//log.debug("records 0번째 데이터:{}", records.get(0));

		for (CSVRecord record : records) {

			//Transaction 일시를 유닉스타임스탬프로 변환(CSV문서의 데이터는 PDT, PST 등의 일치하지 않는 타임존 정보로 되어 있기에 내부 처리의 용이함을 위해 유닉스타임스탬프로 변환
			getUnixTs(record.get("Transaction Date"), record.get("Transaction Time"));
		}

		LocalDateTime end = LocalDateTime.now();
		log.info("\n\n==== End: {} ==== 소요시간: {}(second)", end, Duration.between(start, end).getSeconds()); //로컬 PC에서 50만건 대략 10초 소요됨

	}

	/**
	 * 수익 CSV파일을 파싱하여 List데이를 얻음
	 *
	 * @param targetFile
	 * @return
	 * @throws IOException
	 */
	private static List<CSVRecord> getCsvRecords(File targetFile) throws IOException {

		//구글 개발자콘솔의 재무보고서 기능을 통해서 내려받은 '수익.csv파일'

		int sampleDataRow = 0; //샘플 데이터 row번호
		try (BufferedReader bufferedReader = new BufferedReader(new FileReader(targetFile))) {

			
            //구글 수익보고서를 분석하여 필요에 맞게 적절하게 CSV포맷 지정
			//@formatter:off
			CSVFormat csvFormat = CSVFormat.EXCEL.builder() //기본 엑셀타입 포맷
				.setHeader().setSkipHeaderRecord(true) //헤더 포함&skip
				.setQuote('"') //쌍따옴표 escape처리
				.setNullString("") //empty는 null처리
				.build();
			//@formatter:on
			CSVParser parser = csvFormat.parse(bufferedReader); //파서 처리
            
			//CSVParser parser = CSVFormat.EXCEL.withFirstRecordAsHeader().withQuote('"').parse(bufferedReader); //엑셀타입 & 쌍따옴표 escape처리
			List<CSVRecord> records = parser.getRecords();

			csvHeadMap = parser.getHeaderMap();

			// @formatter:off
			log.debug("\n"
					+ "CSV 데이터 records 수: {}\n"
					+ "CSV 헤더 정보\n\t"
						+ "CSV헤더 필드수: {}\n\t"
						+ "헤더 필드리스트: {}\n"
					+ "{}번째 row 데이터 정보\n\t"
						+ "데이터 필드수: {}\n\t"
						+ "데이터: {}\n",
						records.size(),
						parser.getHeaderMap().size(),
						parser.getHeaderMap(),
						sampleDataRow,
						records.get(sampleDataRow).size(),
						records.get(sampleDataRow)
			);

			// @formatter:on

			return records;
		}
	}

	/**
	 * transaction 일시 정보를 유닉스타임스탬프로 변환하여 리턴
	 *
	 * @param transactionDate CSV의 Transaction Date 필드의 값
	 * @param transactionTime CSV의 Transaction Time 필드의 값
	 * @return
	 */
	private static long getUnixTs(String transactionDate, String transactionTime) {

		String timeField = transactionTime;

		String timeArray[] = StringUtils.split(transactionTime, ":");
		if (timeArray[0].length() != 2) { //1:01:30 AM PDT 와 같이 시간필드에 0이 누락된 경우는 0을 붙여줘서 파싱 포맷에 맞게 변환
			timeField = "0" + timeField;
		}

		final String targetStr = transactionDate + " " + timeField; // 예) Sep 1, 2021 12:00:20 AM PDT

		if (StringUtils.contains(transactionTime, "PST")) {

			LocalDateTime targetDT = LocalDateTime.parse(targetStr, parseFMT); //파싱
			ZonedDateTime pstZDT = targetDT.atZone(pstZoneId);
			LocalDateTime pstDT = pstZDT.withZoneSameInstant(pstZoneId).toLocalDateTime();

			return pstDT.toEpochSecond(pstZoneId.getRules().getOffset(pstDT));

		} else if (StringUtils.contains(transactionTime, "PDT")) {

			LocalDateTime targetDT = LocalDateTime.parse(targetStr, parseFMT); //파싱
			ZonedDateTime pdtZDT = targetDT.atZone(pdtZoneId);
			LocalDateTime pdtDT = pdtZDT.withZoneSameInstant(pdtZoneId).toLocalDateTime();

			return pdtDT.toEpochSecond(pdtZoneId.getRules().getOffset(pdtDT));
		}

		throw new IllegalStateException(String.format("Not supported timezone data. transactionDate:'%s' | transactionTime:'%s'", transactionDate, transactionTime));
	}
}

+ Recent posts