필요한 일이 생겨서 간단히 프로토타이핑한 구글 수익보고서 다운로드하는 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));
	}
}

1. 목적

 - Mysql은 날짜/시간과 관련된 여러 데이터 타입을 제공하는데, 글로벌 서비스를 개발시 여러 시간대 정보가 포함되었을때를 위해서 DB데이터 타입에 대한 확인

 

2. 확인을 위한 테스트 및 결과

 

-- 테스트 데이터 저장용 테이블 생성
CREATE TABLE `timezone_test` (
  `datetime` DATETIME DEFAULT NULL COMMENT 'datetime필드',
  `date` DATE DEFAULT NULL COMMENT 'date필드',
  `timestamp` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'timestamp필드',
  `int_unixTS` INT(10) UNSIGNED DEFAULT NULL COMMENT 'unix TS를 저장하기 위한 int(고정값 저장)'
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 COMMENT='타임존 테스트';

-- 테스트 데이터 insert
INSERT INTO timezone_test VALUES (NOW(), NOW(), NOW(), UNIX_TIMESTAMP());

-- 타임존 변경 전 저장된 데이터 확인
SELECT t.*, UNIX_TIMESTAMP(t.datetime) AS convertUNIX_TS FROM timezone_test AS t;

-- 현재 타임존 정보 확인
SELECT @@GLOBAL.time_zone, @@SESSION.time_zone, @@system_time_zone;

-- 세션의 타임존 변경(예. +1시로 변경)
SET time_zone='+01:00';

-- 타임존 정보 변경 후 현재시간 확인하여 변경되었는지 확인
SELECT NOW();

-- 타임존 변경 후 저장된 값의 내용 조회
-- datetime, date필드는 DB타임존이 변경되어도 기존 값이 변경안됨, timestamp는 DB내부적으로 UTC로 저장 후 DB타임존 설정정보를 이용해서 변환하여 보여줌 -> 즉, timestmap만 타임존이 변경되었을때 영향을 받음
-- 여러 시간대를 사용한다면, 가능하면 timestamp타입으로 데이터를 저장하는게 좋음
SELECT t.*, UNIX_TIMESTAMP(t.datetime) AS convertUNIX_TS FROM timezone_test AS t;

 

 

3. 주의

 - timestamp는 2038년까지만 표시되기에 주의(예. admin시스템 만들때 무한이라는 미래값 처리할때 2999년 과 같은 값으로 처리 불가)

 

 


샘플: 저장된 동일 데이터를 DB타임존 변경 전과 후 비교

 - 타임존 변경 전

DB타임존 변경전의 데이터 확인

 - 타임존 변경 후

DB 타임존을 +9에서 +1로 변경 후 확인 내용. 기존 대비 timestamp만 8시간 전으로 표시됨

 

 

참고

 - https://dev.mysql.com/doc/refman/5.7/en/datetime.html

 

MySQL :: MySQL 5.7 Reference Manual :: 11.2.2 The DATE, DATETIME, and TIMESTAMP Types

11.2.2 The DATE, DATETIME, and TIMESTAMP Types The DATE, DATETIME, and TIMESTAMP types are related. This section describes their characteristics, how they are similar, and how they differ. MySQL recognizes DATE, DATETIME, and TIMESTAMP values in several f

dev.mysql.com

 

참고
 - 타임존 변환 사이트 : https://savvytime.com/converter/pdt-to-kst-utc/aug-1-2021/3am

java 8버전 이상 필요


import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

/**
 * 타임존 변환 샘플 소스
 *
 * @author
 */
public class TimeZoneConvert {

	public static void main(String[] args) {
		pstData();
	}

	/**
	 * PST로 변환 샘플
	 *  - 참고: mysql 기준 쿼리 샘플 SELECT CONVERT_TZ( NOW(), 'UTC', 'Asia/Seoul') AS kst, CONVERT_TZ(NOW(), 'UTC', 'America/Los_Angeles') AS PST
	 */
	public static void pstData() {

		DateTimeFormatter strFMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); //문자 출력용 포맷

		//PST(Pacific Standard Time). 태평양표준시로 UTC-8. PST는 흔히 LA타임으로 불리며, ZoneID는 'America/Los_Angeles' 임
		//PST와 KST는 17시간 차이가 남(PST가 17시간 늦음). KST->PST는 17시간 빼면됨
		//String targetStrPST = "Mar 1, 2021 11:42:23 PM PST"; //KST 기준으로 2021-03-02 16:42:23
		String targetStrPST = "Mar 4, 2021 05:32:33 PM PST"; //KST 기준으로 2021-03-02 16:42:23

		DateTimeFormatter targetFMT = DateTimeFormatter.ofPattern("MMM d, yyyy hh:mm:ss a z", Locale.ENGLISH); //대상 문자 PST의 포맷
		LocalDateTime targetPstDT = LocalDateTime.parse(targetStrPST, targetFMT);

		ZonedDateTime pstZDT = targetPstDT.atZone(ZoneId.of("America/Los_Angeles")); //PST는 ZoneId가 'America/Los_Angeles' 임
		System.out.println("PST ymdt=> " + pstZDT.toLocalDateTime().format(strFMT));

		ZoneId kstZoneId = ZoneId.of("Asia/Seoul");
		LocalDateTime kstDT = pstZDT.withZoneSameInstant(kstZoneId).toLocalDateTime(); //KST로 변환
		String kstStr = kstDT.format(strFMT);

		System.out.println("KST ymdt => " + kstStr);
		System.out.println("KST ymdt toEpochSecond(유닉스 타임스탬프) => " + kstDT.toEpochSecond(kstZoneId.getRules().getOffset(kstDT)));

	}
    
    /**
	 * PDT로 변환 샘플
	 */
	public static void pdtData() {

		final ZoneId pdtZoneId = ZoneId.of("GMT-07:00"); //PDT는 GMT-07:00
		final DateTimeFormatter strFMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); //문자 출력용 포맷

		//참고링크: https://savvytime.com/converter/pdt-to-kst-utc/aug-1-2021/3am
		String targetStrPDT = "Aug 1, 2021 12:03:00 AM PDT"; //KST 기준으로 2021-08-01 16:03:00이고, UTC기준으로는 2021-08-01 07:03:00

		DateTimeFormatter targetFMT = DateTimeFormatter.ofPattern("MMM d, yyyy hh:mm:ss a z", Locale.ENGLISH); //대상 문자 포맷
		LocalDateTime targetPdtDT = LocalDateTime.parse(targetStrPDT, targetFMT); //PDT의 LocalDateTime 객체가 생성됨

		// @formatter:off
		System.out.println(
			String.format(
				"PDT 테스트 문자열: %s\n"
				+ "PDT LocalDateTime: %s\n"
				+ "PDT EpochSecond: %s\n"
				+ "PDT -> UTC LocalDateTime: %s\n"
				+ "PDT -> KST LocalDateTime: %s",
				targetStrPDT,
				targetPdtDT.format(strFMT),
				targetPdtDT.atZone(pdtZoneId).toEpochSecond(),
				targetPdtDT.atZone(pdtZoneId).withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime().format(strFMT),
				targetPdtDT.atZone(pdtZoneId).withZoneSameInstant(ZoneId.of("Asia/Seoul")).toLocalDateTime().format(strFMT)
			)
		);

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

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.List;

/**
 * CSV파일을 읽어서 파싱하여 DB(Mysql)에 저장하는 프로그램 샘플
 *  - apache commons-csv를 이용해서 CSV파일을 파싱(참고: https://mvnrepository.com/artifact/org.apache.commons/commons-csv/1.9.0 )
 *
 * @author
 */
@Slf4j
public class CsvFileParseSaveToDB {

	private static String targetFilePath = "CSV파일 전체 경로";

	public static void main(String[] args) {

		//한글 깨지 방지를 위해서 characterEncoding=UTF-8 처리
		final String jdbcURL = "jdbc:mysql://DB주소:3306/논리DB명?characterEncoding=UTF-8";
		final String username = "DB ID";
		final String password = "DB 암호";

		final int batchSize = 2_000; //bulk insert시 커밋 갯수

		Connection connection = null;

		try {

			connection = DriverManager.getConnection(jdbcURL, username, password);
			connection.setAutoCommit(false);

			String sql = "insert  into `temp_test`(`name_1`,`name_2`) " + "VALUES (?, ??)";

			PreparedStatement statement = connection.prepareStatement(sql);

			int columnSize = 2; //CSV 데이터 필드 컬럼 갯수

			List<CSVRecord> records = getCsvRecords();
			for (int row = 0; row < records.size(); row++) {

				CSVRecord data = records.get(row);
				for (int fieldIndex = 0; fieldIndex < columnSize; fieldIndex++) {
					statement.setString(fieldIndex + 1, data.get(fieldIndex));
				}

				statement.addBatch();
				if (row % batchSize == 0) {
					statement.executeBatch();
					System.out.println(String.format("statement.executeBatch ing row ==> %s", row));
					connection.commit(); //DB서버 부하분산을 원하는 대용량 처리시 중간중간 커밋

					sleep(1); //부하 분산
				}

			}

			//남아있는 데이터 처리
			System.out.println("나머지 데이터도 executeBatch ");
			statement.executeBatch();
			connection.commit();

			connection.close();

		} catch (IOException ex) {
			System.err.println(ex);
		} catch (SQLException ex) {
			ex.printStackTrace();

			try {
				connection.rollback();
			} catch (SQLException e) {
				e.printStackTrace();
			}
		}

	}

	private static void sleep(long millis) {

		try {
			Thread.sleep(millis);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}

	private static List<CSVRecord> getCsvRecords() throws IOException {

		File targetFile = new File(targetFilePath);

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

			CSVParser parser = CSVFormat.EXCEL.withFirstRecordAsHeader().withQuote('"').parse(bufferedReader); //엑셀타입 & 쌍따옴표 escape처리
			List<CSVRecord> records = parser.getRecords();

			log.debug("\nCSV 헤더\n\t{}\n데이터 샘플\n\t{}\n", parser.getHeaderMap(), records.get(sampleDataRow));
			log.info("\n\t헤더 필드 갯수 :{}\n\t데이터 갯수 :{}\n\t{}번째 row의 데이터 필드 갯수:{}\n\n", parser.getHeaderMap().size(), records.size(), sampleDataRow,
				records.get(sampleDataRow).size());

			return records;
		}
	}

}

1. 필요사항(maven dependency추가)

<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-csv -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-csv</artifactId>
            <version>1.9.0</version>
        </dependency>

 

2. 간단한 java CSV파일 파싱 샘플 소스

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

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.List;

/**
 * CSV파일을 읽어서 파싱
 *  - apache commons-csv를 이용해서 CSV파일을 파싱: https://mvnrepository.com/artifact/org.apache.commons/commons-csv/1.9.0
 *
 * @author 엄승하
 */
@Slf4j
public class CsvFileParser {

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

		//파싱할 CSV파일
		File targetFile = new File("CSV파일 경로");

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

			CSVParser parser = CSVFormat.EXCEL.withFirstRecordAsHeader().withQuote('"').parse(bufferedReader); //엑셀타입 & 쌍따옴표 escape처리
			List<CSVRecord> records = parser.getRecords();

			log.debug("\nCSV 헤더\n\t{}\n데이터 샘플\n\t{}", parser.getHeaderMap(), records.get(sampleDataRow));

			//샘플 데이터의 필드 데이터를 개행하여 출력
			//			for (String field : records.get(sampleDataRow)) {
			//				System.out.println(field);
			//			}

			log.info("헤더 필드 갯수 :{} | {}번째 row의 데이터 필드 갯수:{}", parser.getHeaderMap().size(), sampleDataRow, records.get(sampleDataRow).size());
			log.info("헤더: {} ", parser.getHeaderMap());

		}

	}
}

 

기존에도 나름의 기준이 있었는데, 최근에 정리가 되어 텍스트로 메모해봅니다.

 

1. 호기심

 - 업무적인 호기심이 있는 사람을 채용

   예) 도메인지식, 실무지식(개발/기획)에 대한 호기심이 있어야 찾아보는 노력을 하고 업무 역량이 빠르게 발전

2. 책임감

 - 담당 업무에 대한 책임감이 있는 사람을 채용

   예) 장애나 버그가 발생했는데 확인/대응 안 하는 개발자는 안됨

3. 컬쳐핏

 - 팀/조직과 잘 어울릴 수 있는 지원자를 채용

 - 혼자서만 일하는게 아님. 우리의 서비스는 혼자서 만들 수 있는 규모가 아니며 점점 복잡해지는데 컬쳐핏이 안 맞는 사람은 채용이 불가

 - 실무능력이 아무리 뛰어나도 컬쳐핏이 안 맞는 사람은 채용 불가

 

P.s. 시점/상황에 따라서 기준이 조금씩 변경은 되는 것 같습니다.

2020년경에 tokumx-> mongo 3.4 -> mongo 4.2로 DB서버 버전업시 어플리케이션 수정을 위해 정리했던 내용입니다.

 - java드라이버의 호환성내용입니다.

 - API는 4.0.4버전을 사용했고, admin은 하위버전의 DB까지 지원해야해서 3.4.3버전을 사용했습니다.

 


당시 조건을 간단히 정리하자면 아래와 같습니다.

  1. 분당 1만건/100Mb 이상의 좀 큰 사이즈의 트래픽이 저장요청 API 어플리케이션으로 몽고DB에 저장됨
  2. mongoDB는 10여개 이상의 셋트(replica형태로)로 aws ec2에 설치해서 사용 중
    1. mongoDB가 자체 개선되기전부터 특정 기능(tokumx의 압축, 파티션 등)을 사용했어야해서 tokumx, mongo 3.4가 혼재되어 있었음
    2. 여러 이슈로 mongo 4.2로 버전업 필요한 상황
  3. 저장 API는 1개의 소스-> 1개의 DB만 커넥션
  4. admin시스템은 1개의 소스->N개의 DB커넥션

 

참고

 - https://docs.mongodb.com/drivers/java/sync/current/compatibility/

 

구)

+ Recent posts