728x90

 

 다음 세 가지로 나눠서 JVM에게 메모리를 어떻게 할당되는지 살펴 볼 것이다.

 

  • 클래스 변수
  • 인스턴스 변수
  • 지역 변수

 

 

다음 코드를 예시로 그림으로 가시화 하여 실제로 메모리가 어떻게 올라가는지 살펴보겠습니다.

 

class My_Obj {
	Integer value;
	static Integer classVar;
}

public class Main {
	public static void main(String[] args) {
		My_Obj obj = new My_Obj();
		obj.value = 2;
		obj.classVar = 1;

		int a = 10;

		My_Obj obj2 = new My_Obj();
		obj2.value = 10;
		obj2.classVar = 9;
	}
}

 

 

들어가기 앞서 코드 설명을 하자면,

 

  • obj 객체를 생성한다.
  • 이 객체의 맴버 변수인 value, classVar 에 각각 2, 1를 할당한다.
  • obj 객체의 맴버 변수를 출력한다. 그 다음에, obj2 객체를 생성한다.
  • 그리고 이 객체의 맴버 변수인 value, classVar 에 각각 10, 11을 할당한다. 그 다음 obj2 객체의 맴버 변수를 출력한다.

 

그림으로 어떻게 변수들이 할당되는지 살펴보자.

 

 

크게 3가지 영역으로 나누어본다. Method, Heap, Stack 영역 으로 나뉜다. 이 중에서 클래스 변수는 클래스 파일을 읽어들이고 로더가 JVM 메모리에 올릴 때 메모리에 올라간다. 그리고 프로그램이 종료될 때 사라진다. 힙에 있는 인스턴스들은, GC의 대상이 될 때 처리가 된다. 마지막으로 지역 변수는 자신이 선언된 블록이 끝났을 때 사라진다.

 

 

처음 obj 객체의 출력 까지만 그림으로 표현해보았다. Heap 영역에는 GC가 있기 때문에 대상이 되는 인스턴스들과 Constant Pool 안에 있는 것들은 사용되지 않으면 정리가 된다. 변수에 할당된 모든 리터럴 값들은 Constant Pool 에 있고 Method 영역이나 Stack 영역에서 각 객체들은 Constant Pool 안의 해당 값의 참조 주소를 갖고 있다.

그 다음 obj2 객체가 생성이 되면 이렇게 된다.

 

classVar 클래스 변수가 갱신이 되면서 Constant Pool 내에 리터럴 값 9의 참조 주소를 참조하는 것으로 갱신된 것을 볼 수 있다.

 

그러면 My_Obj 객체의 value 필드의 타입을 참조타입에서 원시타입으로 변경 해서 살펴보자.

class My_Obj {
	int value; << 원시 타입으로 변경
}

public class Main {
	public static void main(String[] args) {
		My_Obj obj = new My_Obj();
		obj.value = 2;

		int a = 11;  << 새로 추가

		My_Obj obj2 = new My_Obj();
		obj2.value = 10;
	}
}

 

내부 메모리상으로는 다음과 같을 것이다.

 

 

여기서 알아가야 할게 Integer는 내부적으로 캐시가 된다는 점이다.

 

Integer 객체는 범위 -128에서 127 사이의 값일 경우 Constant Pool에서 캐싱된 값을 사용하겠지만, 그렇지 않다면 새로운 객체가 힙에 생성됨. 증거 자료는 다음과 같다. (Integer 래퍼 클래스 내부에 정적 프라이빗 클래스인 IntegerCachevalueOf 메소드이다.)

 

 

 

그러므로 예를 들어,

Integer i1 = 100;
Integer i2 = 100;
Integer i3 = 200;
Integer i4 = 200;

System.out.println(i1 == i2);  // true (캐싱된 객체를 재사용)
System.out.println(i3 == i4);  // false (새로운 객체가 각각 생성됨)

 

 

  • i1 == i2는 true를 반환함. 왜냐하면 100은 -128 ~ 127 사이에 있으므로 캐싱된 같은 객체를 사용하기 때문.
  • 반면, i3 == i4는 false를 반환함. 왜냐하면 200은 캐싱 범위 밖이므로 새로운 객체가 각각 생성되었기 때문.

 

 

그러면, 이점을 응용을 해보자. 다음 코드를 보자.

 

class My_Obj {
	int value;
}

public class Main {
	public static void main(String[] args) {
		My_Obj obj = new My_Obj();
		obj.value = 10;
        
        int a = 10;
        
        System.out.println(obj.value == a) // true 가 나온다.
	}
}

 

 

이런 그림이 될 것 이다.

 

결론

메모리 구조를 한번 더 정리하는 계기가 되었다.

 

 

 

👉🏻 참조

https://codingdog.tistory.com/entry/java-클래스-변수-어떻게-메모리에-올라갈까

'언어 > 자바' 카테고리의 다른 글

List<?> 와 List 의 차이  (0) 2023.05.03
[JAVA] List<?> 와 List<Object>의 차이  (2) 2022.09.21
728x90

람다 SnapStart 를 적용하며 

람다에 자바로 된 함수를 띄었다. 자바 파일은 아무래도 node.js에 비해 사이즈가 컸다. 
기존에 회사 넥서스에 올라가 있는 라이브러리를 사용하려다 보니 자바로 어쩔 수 없이 해야 했던 상황이다. (다시 js로 구현 해도 되지만 알림톡쪽은 급하게 개선 해야 하는 비즈니스 로직이라 그건 추후로 미뤘다)
그래서 50mb 정도 되는 자바 코드를 그대로 올려서 cold start 로 실행 시켰다.
그러다 보니. 람다 자체내에서 코드를 다운로드 하고, 컨테이너를 띄우고, 전역 코드를 실행 후 내부 함수를 실행하는 순서 까지가 일반적인 방식으로 40000~50000ms(4~50s) 정도가 걸렸다.

처음에 ColdStart로 40초 정도 걸리니 방법을 모색 해보았다. 처음에는 다음과 같은 방법을 찾았다.
- 타임아웃 늘리기
- node.js 로 다시 구현
- 프로비저닝 동시성 구성
- 해당 함수 실행 예상 시간 전에 health check를 통해 warm up 해놓기

 

타임아웃 늘리기

근본적인 해결책은 아닌 것 같았다. 해당 함수가 띄어질 때 까지 40~50초 가량 걸리는데, 빌드될 때 까지 기다리고 처리될 때 까지 함수가 내려지지 않을 정도의 시간을 주는 것이었다.


node.js 로 다시 구현

위에서 말했듯이 다시 js로 구현 해도 되지만 알림톡쪽은 급하게 개선 해야 하는 비즈니스 로직이라 그건 추후로 미뤘다. 그리고 해당 라이브러리에 은근 로직들이 많아 새로 다른 언어로 로직을 만드는데 리스크를 안아야 하는점도 있었다.


프로비저닝 동시성 구성

항상 함수를 띄어 놓으면 그만큼 과금이 된다. 하루에 특정 시간대에 한 두번 도는 함수인데, 함수가 무거워서 컨테이너가 올라갈 때 시간이 많이 걸린다는 이유만으로 해당 금액을 지불하기엔 돈이 아까웠다.


해당 함수 실행 예상 시간 전에 health check를 통해 warm up 해놓기

해당 로직을 처리 해야 하는 예상 시간을 추측 해서 5분 마다 헬스체크 하면서 warm up 시켜놓는 방식도 괜찮았지만, 근본적인 해결책이라고는 볼 수 없었다. 헬스체크를 보내야 하는데, 이를 보내는 주체에 의존하는 모양이 되기 때문에, 주체에 문제가 생기면 warm up이 안되기 때문이다.


지금까지의 고민을 SnapStart이 모든것을 해결해 주었다.

 

SnapStart 란 해당 버전의 빌드 파일의 스냅샷을 따서 캐싱 하고(스냅샷은 Amazon EBS 에 저장), Cold Start를 할 때 이를 활용하여 실행하는 기능이다. 그래서 람다 컨테이너를 올릴 때 아무리 큰 함수라도 빠르게 올릴 수 있다.

 

스냅샷은 구체적으로 함수 코드가 로드되고 초기화된 상태를 저장한다.

 

메모리상태
- Java 런타임에 의해 로드되고 초기화된 모든 클래스
- 정적 변수
- 초기화된 객체
- JIT 컴파일된 코드
- 의존성
- 캐시된 데이터
- 연결 풀
- 스레드 상태


디스크 상태
- 함수 코드와 의존성 라이브러리들이 로드된 상태
- 런타임 환경에 의해 생성된 파일 시스템 구조


그러나 SnapStart에 제약사항은 존재한다.


런타임 제약사항

- 자바 전용이며 Java 11, 17을 지원.
- x86 아키텍처만 동작.
- 프로비저닝된 동시성 불가.
- EFS(Amazon Elastic File System) 연동 불가
- 512MB 이상의 임시 스토리지 불가
- 배포시간 증가 - 개발 생산성에 영향을 줌 (배포 파이프라인에 영향)
- 고유성에 영향을 줌 - 글로벌 변수에 난수 생성은 스냅샷에 포함되어 동일한 값이 사용되게 되기 때문이다. (함수에서 생성하거나 hook을 통해 처리 해야 함)

 

참고자료

 

728x90

들어가며

개인 프로젝트에서 유닛 테스트를 짜고 있었다. SMS, 이메일 인증 로직을 테스트 하려고 하다가 고민에 빠졌다. 해당 로직은 외부 캐시 서비스를 사용 해서 인증번호 대조를 진행한다. 이를 테스트 하기 위한 방법론에 대한 고민에 빠진 것이다. 고민은 Spy 객체를 만들어서 코드로 로직을 검증할까, 아니면 실제 데이터를 가지고 검증을 할까 고민이었다. 해당 서비스는 외부 서비스를 의존하는 서비스라서 자바 코드로 검증하기엔 단지 flow만 검증하는 느낌이었다. 그래서 flow 뿐만 아니라 데이터도 검증 해서 더 정확히 검증 할 수 있게 실제로 Redis를 붙여서 검증하기로 결정 하였다. Mockist인 나로서는 고민이 되는 부분이었다.

 

해결

애플리케이션을 전부 올리는 SpringBootTest를 사용하기엔 테스트 시간을 너무 소비하는 느낌이어서 생산성이 떨어질 것 같았다. 그래서 test profile 전용 application yaml 파일을 만들고 테스트에 쓰일 Redis config 파일을 만들었다. 그 다음으로 Redis를 실행 시켜야 하는데 배포 전에 CI 가 실행 되는 github actions VM 에 매번 이러한 테스트가 생길 때마다 해당 하는 이미지를 올리는 등 이러한 관리 공수가 들것이라고 생각했다. 매번 CI script 코드도 수정 해야 하는데, 테스트 코드와 script 싱크를 맞추는 것도 추후 서비스가 더 커지면 귀찮은 일이라고 느껴질 수 있겠다고 생각했다. 그래서 이것도 자동화 하고싶었다. 그래서 github actions VM에 도커 환경을 세팅 해준 후 ./gradlew build 을 해주면 자동으로 @Testcontainer가 붙은 클래스에서 필요한 이미지를 세팅 해주는 테스트 환경을 구성했다.

 

기대할 수 있는 점

Testcontainer를 활용하면 앞으로 다양하게 테스트를 운영 환경이랑 최대한 비슷하게 해볼 수 있을거라고 기대한다. 지금까지 integration test는 H2 in-memory 디비로 했었지만 한계가 있었다. 호환하지 않은 문법 등 많아서 테스트를 위해 관리 해야 할 점들이 많았었다. 그런점에서 운영 환경이랑 비슷하다고 생각이 안되었다. (그렇다고 해서 도커를 띄워서 운영 환경을 만들 수 없다는건 아니긴 하다. 만들 수는 있지만 억지로 만들어주는 느낌으로 운영 코드랑 필요한 이미지를 싱크를 CI script로 맞춰 줘야 하는 단점이 있다.) 그러나 Testcontainer는 필요한 이미지를 코드단에서 관리가 가능하기 때문에 테스트에 더 집중할 수 있다고 생각한다. 그리고 코드로 운영 환경을 최대한 구성할 수 있어서 테스트를 실제 운영환경이랑 비슷하게 할 수 있다는 장점이 있었다.

+ Recent posts