Skip to main content

PostgreSQL 에서 SELECT FOR UPDATE 구문의 동작방식

· 10 min read

banner

SELECT FOR UPDATE 구문의 동작 방식

PostgreSQL의 FOR UPDATE 잠금은 트랜잭션 내에서 SELECT 쿼리를 수행하는 동안 테이블의 행을 명시적으로 잠그는 데 사용됩니다. 이 잠금 모드는 일반적으로 트랜잭션이 완료될 때까지 선택한 행이 변경되지 않도록 하여 다른 트랜잭션이 충돌하는 방식으로 해당 행을 수정하거나 잠그지 못하도록 하려는 경우에 사용합니다.

예를 들면 티켓 예매처럼 특정 고객이 티켓 예매 과정을 진행하는 동안 다른 고객이 데이터를 변경할 수 없도록 막기 위해 사용할 수 있어요.

이번 글에서 살펴볼 케이스들은 조금 특별합니다.

  • 잠금을 사용하는 읽기와 잠금을 사용하지 않는 읽기를 혼용하게 된다면 select for update 는 어떻게 동작할까요?
  • 애초에 잠금을 사용했는데 다른 트랜잭션에서 읽기가 가능하긴 한 걸까요?
  • 읽기 방식을 혼용해도 데이터의 일관된 읽기를 보장할 수 있을까요?

패킷으로 알아보는 3 Way Handshake With Termshark

· 10 min read

banner

네트워크 패킷이란

데이터를 네트워크로 전송하기 위해서는 어떻게 해야할까요? 상대방과 커넥션을 생성한 후, 데이터를 한 번에 보내는 방법이 가장 직관적인 방법일 겁니다. 하지만 이런 방법은 여러 요청을 처리해야할 때 비효율이 발생하는데요, 하나의 커넥션으로는 하나의 데이터 전송만 유지할 수 있기 때문입니다. 만약 큰 데이터가 전송되느라 커넥션이 길어진다면 다른 데이터들이 기다려야하겠죠.

네트워크는 데이터 전송 과정을 최대한 효율적으로 처리하기 위해 데이터를 여러 조각으로 나눈 후, 수신 측에서 조립하도록 했습니다. 이 조각난 데이터 구조체를 패킷이라고 부릅니다. 패킷에는 수신 측에서 데이터를 순서대로 조립할 수 있도록 여러 추가 정보를 포함하고 있습니다.

이렇게 여러 패킷으로 전송되면 패킷 스위칭을 통해 많은 요청을 효율적으로 처리할 수 있지만, 중간에 데이터가 유실되거나, 정확한 순서로 전달되지 않거나 하는 등의 다양한 에러를 만나게 될 수 있습니다. 우리는 이런 문제를 어떻게 디버깅해야 할까요? 🤔

AWS S3 를 사용한 env 관리 방법 및 자동화

· 8 min read

Situation

  • 코드 베이스가 늘어나며 스프링 애플리케이션 실행에 필요한 설정값이 늘어나고 있음
  • 대부분의 상황은 테스트 코드로 검증하지만, 로컬에서 bootRun 으로 테스트해보는 방법도 써야할 때가 있음

Complication

  • 설정값들을 env 로 분리하여 별도로 관리하고 싶음
  • .env 파일은 일반적으로 git ignore 대상이므로 버전 추적이 어렵고, 파편화되기 쉬움
    • 여러 머신에서 파일을 동기화할 수 있는 방법이 필요함

Question

  • 개발자들 간에 괴리가 덜하면서도, 편리하게 적용 가능한 방법이 있는가?
    • 가급적이면 익숙한 방법이어야 유지하기도 쉽다
  • .env 파일의 버전을 관리할 수 있는가?
  • 러닝커브가 낮은가?
    • 배보다 배꼽이 커지는 상황은 피하고 싶다
  • 운영환경에 바로 적용 가능한가?

Answer

AWS S3

  • .env 업데이트가 필요한 경우 AWS CLI 를 사용하면 편리하게 업데이트 가능
  • 스냅샷을 통해 .env 버전을 관리할 수 있음
  • AWS S3 는 대부분의 개발자에게 이미 익숙한 서비스고 러닝커브가 높지 않음
  • 서비스 운영 환경인 AWS ECS 는 S3 의 arn 을 사용하여 바로 시스템 변수 적용이 가능함

.

..

...

....

이걸로 끝?

이라면 글이 조금 심심하죠? 당연하게도 아직 몇가지 문제가 남아있어요.

그래서 어느 버킷에 있나

일반적으로 S3 를 쓰다보면 파일구조 최적화나 비즈니스에 따른 구분 등으로 인해 굉장히 많은 버킷이 생기기 마련이에요.

aws s3 cp s3://something.service.com/enviroment/.env .env

.env 파일이 없는 경우 AWS CLI 를 사용하여 위 명령어처럼 .env 파일을 내려받아야 합니다. 미리 버킷 이름을 누군가로부터 공유받지 않으면, 버킷을 모두 뒤져봐야하니 환경변수 파일을 찾기가 쉽지 않을 것 같네요. 공유하는 과정을 없애려고 한건데, 다시 뭔가를 공유 받아야하는 상황은 조금 불편하게 느껴지는 것 같아요.

버킷은 너무 많다. env 야 어딨니...?

S3 에서 버킷을 탐색하며 다운로드해야할 .env 파일을 찾는 과정을 자동화하면 편하겠죠? 이 부분은 fzf 나 gum 등으로 쉽게 스크립트를 작성하는 것도 가능합니다.

스프링부트는 시스템 환경변수가 필요하다. .env 가 아니라...

이미 눈치채신 분들도 계실지 모르겠지만, 스프링부트는 yaml 파일의 placeholder 에 시스템 환경변수를 읽어서 값을 채우게 되요. 하지만 .env 파일만으로는 시스템 환경변수 적용이 되지 않고, 따라서 스프링부트의 초기화 과정에 적용되지 않아요.

실제 동작을 잠깐 살펴볼게요.

# .env
HELLO=WORLD
# application.yml
something:
hello: ${HELLO} # OS 에서 HELLO 라는 환경변수를 찾아서 값을 얻는다.
@Slf4j
@Component
public class HelloWorld {

@Value("${something.hello}")
private String hello;

@PostConstruct
public void init() {
log.info("Hello: {}", hello);
}
}

SystemEnvironmentPropertySource.java

@Value 의 placeholder 를 해결하지 못하기 때문에 빈이 등록되지 못하고 에러가 발생하는걸 확인할 수 있어요.

.env 파일이 있다고 해서 시스템 환경변수로 등록되진 않는다

.env 파일을 적용하려면 export 명령을 실행하거나, 인텔리제이의 런 설정에 .env 파일을 등록하면 되요. 하지만 export 명령을 사용하는건, 로컬 머신에 너무 많은 변수가 글로벌로 등록되면서 덮어쓰기 등 의도치 않은 동작을 초래할 수 있으므로 인텔리제이의 GUI 를 통해 개별로 관리하는걸 추천해요.

인텔리제이는 .env 파일 설정을 GUI 를 통해 지원한다

placeholder 가 해결되고 정상 적용된걸 확인할 수 있다

Answer-최최종-이게진짜.final

휴, 길고 긴 문제 인식과 범위 확정의 과정이 끝났네요. 워크플로우를 마지막으로 한 번 정리해보고 스크립트를 소개할게요.

  1. 자동화 스크립트를 통해 s3 에서 적절한 .env 를 찾고 다운로드할 수 있도록 한다.
  2. .env 를 읽어서 시스템 환경변수로 설정하자.

쉘 스크립트는 gum 을 사용하여 최대한 단순하면서도 스타일링이 가능하도록 작성했습니다.

전체 코드

#!/bin/bash

S3_BUCKET=$(aws s3 ls | awk '{print $3}' | gum filter --reverse --placeholder "Select...") # 1.

# 배포 환경 선택
TARGET=$(gum choose --header "Select a environment" "Elastic Container Service" "EC2")
if [ "$TARGET" = "Elastic Container Service" ]; then
TARGET="ecs"
else
TARGET="ec2"
fi

S3_BUCKET_PATH=s3://$S3_BUCKET/$TARGET/

# search env file
ENV_FILE=$(aws s3 ls "$S3_BUCKET_PATH" | grep env | awk '{print $4}' | gum filter --reverse --placeholder "Select...") # 2.

# confirm
if (gum confirm "Are you sure you want to use $ENV_FILE?"); then
echo "You selected $ENV_FILE"
else
die "Aborted."
fi

ENV_FILE_NAME=$(gum input --prompt.foreground "#04B575" --prompt "Enter the name of the env file: " --value ".env" --placeholder ".env")
gum spin -s meter --title "Copying env file..." -- aws s3 cp "$S3_BUCKET_PATH$ENV_FILE" "$ENV_FILE_NAME" # 3.

echo "Done."
  1. gum filter 를 사용하여 원하는 s3 bucket 을 선택합니다.
  2. env 라는 단어가 포함된 항목만 검색하고 ENV_FILE 이라는 이름의 변수로 할당합니다.
  3. .env 파일의 objectkey 를 확정하고 다운로드를 진행합니다.

이 스크립트의 실행 과정을 간단하게 데모영상으로 만들어봤어요.

Demo

이후에는 현재 디렉토리로 복사된 .env 파일을 먼저 소개했던 방식으로 인텔리제이에 적용해주면 됩니다.

tip

direnv 와 인텔리제이의 direnv plugin 을 활용하면 더 편하게 적용이 가능합니다.

Conclusion

  • 스크립트가 단순하기 때문에 유지보수 편리
  • 팀원 반응 매우 좋음
  • 개발자도 예쁜걸 좋아한다
  • 민감한 Credentials 이라면 AWS Secret Manager 를 이용하자

Spatial index 를 활용한 공간 데이터 조회 최적화

· 7 min read

banner

매우 비효율적이였던 기존 구현 방식을 설명하고, 개선하기 위해 시도한 방법들을 기록합니다.

기존 문제점

한 번의 쿼리로 여러 DB 에 흩어진 테이블을 join 하는 것은 불가능하진 않지만 어려웠다...

  1. 특정 좌표가 a 라는 영역에 포함되어 있는가?
  2. 물리적으로 다른 서버에 존재하는 테이블로 인해 join 쿼리를 작성하기 어려웠음
    1. 왜 한방 쿼리여야 하는가? 조회해야하는 데이터의 사이즈가 매우 크기 때문에 애플리케이션 메모리로 로드되는 양을 최대한 줄이고 싶었음
  3. DB 조인이 안되기 때문에 애플리케이션 조인을 해야 했고 60000 * 40000 = 24억 정도의 루프가 발생했음
    1. 파티션 처리를 통해 최대한 처리시간을 줄였으나, 여전히 루프로 인해 CPU 부하가 매우 심했다
  4. 물리적으로 다른 데이터베이스를 마이그레이션 과정을 통해 하나로 합치게 되었고, 조인이 가능해지면서 고대하던 쿼리 최적화의 기회를 얻게 됨

해결 방향

그동안 데이터베이스의 조인을 사용하지 못했던 가장 큰 원인이 해결된 상황이였기 때문에 적극적으로 인덱스 스캔을 활용한 geometry 처리 방법을 고민해봤다.

  • PostGIS 의 GIST index 를 사용하면 R-tree 와 유사한 공간 인덱스를 생성할 수 있고, 인덱스 스캔을 통해 쿼리에서 바로 조회할 수 있을 것이다.
  • 공간 인덱스를 사용하기 위해서는 geometry 타입의 컬럼이 필요하다.
  • 기존에는 위경도 좌표는 있었지만 geometry 타입은 없었기에, 위경도를 사용하여 geometry POINT 값을 먼저 생성해줘야 한다.

위 과정을 모의하기 위해 실제 운영 중인 DB 와 완전히 같은 데이터를 준비하고 실험을 진행했다.

인덱스를 먼저 생성해주고,

CREATE INDEX idx_port_geom ON port USING GIST (geom);

PostGIS 의 contains 함수를 실행해봤다.

SELECT *
FROM ais AS a
JOIN port AS p ON st_contains(p.geom, a.geom);

Awesome...

적용 결과

spatial index 적용 전

1m 47s ~ 2m 30s

spatial index 적용 후

0.23ms ~ 0.243ms

캡쳐를 준비하진 못했지만, 인덱스를 적용하기 전에는 조회에만 1분30초 이상이 소요됐었다.

결론부터 설명했다. 왜 이런 결과가 나오는지 살펴보자.

GiST (Generalized Search Tree)

복합적인 지리(geometric) 데이터를 조회하는데 매우 유용한 인덱스이며 내부 구조 예시는 아래와 같다.

R-tree의 아이디어는 평면을 직사각형으로 분할하여 색인되는 모든 점을 전체적으로 포괄하는 것이다. 인덱스 행은 직사각형을 저장하며 다음과 같이 정의할 수 있다.

"찾고자 하는 점은 주어진 사각형 안에 있다".

R-트리의 루트에는 여러 개의 가장 큰 사각형(교차하는 사각형일 수도 있음)이 포함된다. 자식 노드는 부모 노드에 포함된 더 작은 크기의 직사각형을 포함하며, 전체적으로 모든 기본 포인트를 포함한다.

이론적으로 리프 노드에는 인덱싱되는 포인트가 포함되어야 하지만 모든 인덱스 행에서 데이터 유형이 동일해야 하므로 포인트로 축소된 직사각형이 반복적으로 저장된다.

이러한 구조를 시각화하기 위해 세 가지 수준의 R-tree 에 대한 이미지를 살펴보자. 점은 공항의 좌표이다.

Level one: two large intersecting rectangles are visible.

교차하는 두 직사각형이 표시된다.

Level two: large rectangles are split into smaller areas.

큰 직사각형이 작은 영역으로 분할된다.

Level three: each rectangle contains as many points as to fit one index page.

각 직사각형에는 하나의 색인 페이지에 맞는 만큼의 점들이 포함된다.

이후 영역들은 트리로 구성되고, 조회시 트리를 스캔한다. 더 자세한 정보가 필요하다면 다음 글을 살펴보시는걸 추천한다.

결론

지금까지 특정 조건 하에서, 어떤 문제가 있었고 해결하기 위해 어떤 노력을 해왔는지, 이 문제를 해결하기 위해 필요한 기본 개념에 대해서 간단하게 소개하는 글을 적어보았다. 내용을 정리해보면 아래와 같다.

  • 물리적으로 분리된 데이터베이스에서는 인덱스를 활용한 효율적인 조인을 수행할 수 없었다
  • 마이그레이션을 통해 물리적 조인을 수행할 수 있게 변경했다
  • 인덱스 스캔을 활용할 수 있게 되면서 전체적인 퍼포먼스가 크게 개선되었다
  • 애플리케이션 메모리에 데이터를 불필요하게 로드할 필요가 없어졌다
  • 루프로 인한 CPU 부하가 해소되었다

Reference

테스트를 쉽고 편리하게, Fixture Monkey

· 11 min read

"Write once, Test anywhere"

네이버에서 오픈소스로 개발되고 있는 테스트 객체 생성 라이브러리이다. 아마도 이름은 넷플릭스의 오픈소스, Chaos Monkey 에서 따온 듯하다. 랜덤으로 테스트 픽스처를 생성해주기 때문에, 실제로 카오스 엔지니어링을 하는 체험을 할 수 있다.

약 2년 전 처음 접한 이후, 가장 좋아하는 오픈소스 라이브러리 중 하나가 되었다. 어쩌다보니 글도 2편이나 썼다.

이후로 버전이 변할 때마다 변경점이 너무 많아 추가적인 글을 안적고 있다가, 최근 1.x 가 릴리즈되었기에 새로운 마음으로 다시 소개글을 써본다.

이전 글에서는 Java 를 기준으로 글을 작성했지만, 최근 추세에 맞춰서 Kotlin 으로 작성한다. 글 내용은 공식 문서를 기반으로 실제 사용 후기를 좀 섞었다.

왜 Fixture Monkey 가 필요한가

아래 같은 코드를 보면서 기존 방식에서 어떤 점이 문제인지 살펴보자.

info

Java 개발자들에게 익숙한 JUnit5 를 사용하여 예제를 작성했다. 하지만 개인적으로 Kotlin 환경에서는 Kotest를 사용하는 것을 추천한다.

data class Product (
val id: Long,

val productName: String,

val price: Long,

val options: List<String>,

val createdAt: Instant,

val productType: ProductType,

val merchantInfo: Map<Int, String>
)

enum class ProductType {
ELECTRONICS,
CLOTHING,
FOOD
}
@Test
fun basic() {
val actual: Product = Product(
id = 1L,
price = 1000L,
productName = "productName",
productType = ProductType.FOOD,
options = listOf(
"option1",
"option2"
),
createdAt = Instant.now(),
merchantInfo = mapOf(
1 to "merchant1",
2 to "merchant2"
)
)

// 테스트 목적에 비해 준비 과정이 길다
actual shouldNotBe null
}

테스트 객체 생성의 어려움

테스트 코드를 살펴보면 assertion 을 위해 객체를 생성하기 위해 작성해야 하는 코드가 너무 많다고 느껴진다. 구현 내용상 프로퍼티를 설정하지 않으면 컴파일 에러가 발생하기 때문에, 무의미한 프로퍼티라도 반드시 작성해줘야 한다.

이렇게 테스트 코드에서 assertion 을 위해 준비해야하는 부분이 길어지면, 코드에 테스트 목적에 대한 의미가 불분명해질 수 있다. 처음 이 코드를 읽는 사람은 아무 의미 없는 프로퍼티여도 숨은 의미가 있는지 살펴봐야하기 때문이다. 이 과정은 개발자들의 피로감을 높인다.

엣지 케이스 인식의 어려움

직접 프로퍼티를 설정하여 객체를 생성할 경우, 프로퍼티가 고정되기 때문에 다양한 시나리오에서 나타날 수 있는 엣지 케이스를 놓치는 경우가 많다.

val actual: Product = Product(
id = 1L, // 만약 id 가 음수가 되면 어떨까?
// ...생략
)

엣지 케이스를 찾기 위해서는 하나하나 개발자가 프로퍼티를 설정해가며 검증해줘야 하는데, 실제로는 런타임에 에러가 발생한 이후에나 엣지 케이스에 대해 눈치채게 되는 일이 부지기수다. 에러가 발생하기 전에 엣지 케이스를 수월하게 발견하기 위해서는 객체의 프로퍼티가 어느 정도 랜덤성을 갖고 설정되야 한다.

오브젝트 마더 패턴의 문제점

테스트 객체를 재사용하기 위해 팩토리 클래스를 생성하고 해당 클래스에서 객체를 생성하여 테스트 코드를 실행하는 패턴을 오브젝트 마더(Object mother) 패턴이라고 부른다.

하지만 이런 방법은 테스트 코드 뿐만이 아니라 팩토리까지 지속적으로 관리해야 하기 때문에 별로 좋아하는 방법이 아니다. 또한 여전히 엣지 케이스를 찾아내는데에는 전혀 도움이 되지 않는다.

Fixture Monkey 를 사용해보자

Fixture Monkey 는 재사용성, 랜덤성을 통해 상술했던 문제점을 우아하게 해결한다. 지금부터 어떻게 문제를 해결하는지 살펴보자.

먼저 의존성을 추가해준다.

testImplementation("com.navercorp.fixturemonkey:fixture-monkey-starter-kotlin:1.0.13")

KotlinPlugin() 을 적용하여 Kotlin 환경에서도 Fixture Monkey 가 원활하게 동작하도록 한다.

@Test
fun test() {
val fixtureMonkey = FixtureMonkey.builder()
.plugin(KotlinPlugin())
.build()
}

위에서 사용했던 Product 클래스를 가지고 다시 테스트를 작성해보자.

data class Product (
val id: Long,

val productName: String,

val price: Long,

val options: List<String>,

val createdAt: Instant,

val productType: ProductType,

val merchantInfo: Map<Int, String>
)

enum class ProductType {
ELECTRONICS,
CLOTHING,
FOOD
}
@Test
fun test() {
val fixtureMonkey = FixtureMonkey.builder()
.plugin(KotlinPlugin())
.build()

val actual: Product = fixtureMonkey.giveMeOne()

actual shouldNotBe null
}

불필요한 프로퍼티 설정 과정 없이 순식간에 Product 인스턴스를 생성하여 테스트할 수 있다. 모든 프로퍼티 값은 기본적으로 랜덤하게 채워진다.

image 여러 프로퍼티들을 잘 채워준다

Post Condition

하지만 대부분의 경우, 특정 조건에 맞는 프로퍼티 값이 필요하다. 예를 들어 예시에서는 id 가 음수로 생성되었지만, 실제로 id 는 양수로 사용하는 경우가 많을 것이다. 예를 들면 아래처럼 검증 로직이 있을 수 있겠다.

init {
require(id > 0) { "id should be positive" }
}

이 후 몇 번 테스트를 돌려보니, id 가 음수로 생성되는 경우 테스트가 실패한다. 이처럼 모든 값이 랜덤하게 생성된다는 점은, 미처 생각하지 못한 엣지 케이스를 찾는데 특히 유용하다.

image

랜덤 속성을 유지하되, 검증 로직은 통과할 수 있도록 범위를 조금 제한해보자.

@RepeatedTest(10)
fun postCondition() {
val fixtureMonkey = FixtureMonkey.builder()
.plugin(KotlinPlugin())
.build()

val actual = fixtureMonkey.giveMeBuilder<Product>()
.setPostCondition { it.id > 0 } // 생성 객체의 프로퍼티 조건을 지정한다
.sample()

actual.id shouldBeGreaterThan 0
}

값이 랜덤하게 생성되기 때문에, @RepeatedTest 를 사용하여 10번 반복하여 테스트를 실행해줬다.

image

모두 통과하는걸 볼 수 있다.

다양한 프로퍼티 설정

postCondition 을 사용할 때는 주의해야할 점이 있는데, 만약 생성 조건을 너무 좁게 설정할 경우 객체 생성 비용이 너무 비싸질 수 있다. 이는 조건에 맞는 객체가 생성될 때까지 내부적으로 생성을 반복하기 때문이다. 이럴 때는 setExp 을 사용하여 특정 값을 고정하는 것이 훨씬 좋다.

val actual = fixtureMonkey.giveMeBuilder<Product>()
.setExp(Product::id, 1L) // 고정값을 제외한 나머지는 랜덤이 된다
.sample()

actual.id shouldBe 1L

프로퍼티가 컬렉션일 경우는 sizeExp 를 사용하여 컬렉션의 크기를 지정해줄 수도 있다.

val actual = fixtureMonkey.giveMeBuilder<Product>()
.sizeExp(Product::options, 3)
.sample()

actual.options.size shouldBe 3

maxSize, minSize 를 사용하면 컬렉션의 최대 최소 사이즈 조건을 간단하게 지정할 수 있다.

val actual = fixtureMonkey.giveMeBuilder<Product>()
.maxSizeExp(Product::options, 10)
.sample()

actual.options.size shouldBeLessThan 11

이 외에도 다양한 프로퍼티 지정 메서드들이 있으니, 필요할 때 살펴보시길 권한다.

Conclusion

Fixture Monkey 는 단위테스트를 작성하면서 불편했던 부분들을 정말 잘 해소해준다. 이 글에서 예제로 언급하지는 않았지만, 빌더에 원하는 조건을 만들어놓고 재사용할 수 있고 프로퍼티에 랜덤성을 부여하여 개발자가 미처 찾지 못한 엣지 케이스들을 찾을 수 있게 도와준다. 덕분에 테스트 코드가 매우 짧아지고 오브젝트 마더같은 부가적인 코드가 필요없기 때문에 유지보수하기 편해지는 것은 덤이다.

Fixture Monkey 의 1.x 버전이 릴리즈되기 전에도 운영 환경에 도입해보며 테스트 코드 작성에 많은 도움을 받고 있었다. 이제는 안정화 버전이 된만큼 부담없이 도입해서 즐거운 테스트 코드 작성이 되시기를 바란다.

Reference

Java 에서 Hello World 를 출력하기까지 3

· 21 min read

banner

앞선 챕터에서는 Java 를 컴파일해보며 바이트코드 구조에 대해 살펴봤다. 이번 챕터에서는 JVM 이 실행되면서 'Hello World' 코드 블록을 어떻게 동작시키는지 살펴본다.

Chapter 3. Java 를 실행하는 JVM

  • Class Loader
  • Java Virtual Machine
  • Java Native Interface
  • JVM 메모리 적재 과정
  • Hello World 가 어떤 메모리 영역과 상호작용하게 되는지

Class Loader

Java 의 클래스들이 언제, 어디서, 어떻게 메모리에 올라가고 초기화가 일어나는지 알기 위해서는 우선 JVM 클래스 로더(Class Loader) 에 대해 살펴볼 필요가 있다.

클래스 로더는 컴파일된 자바의 클래스 파일(.class)을 동적으로 로드하고, JVM 의 메모리 영역인 Runtime Data Area 에 배치하는 작업을 수행한다.

클래스 로더에서 class 파일을 로딩하는 순서는 다음과 같이 3단계로 구성된다.

  1. Loading: 클래스 파일을 가져와서 JVM 의 메모리에 로드한다.
  2. Linking: 클래스 파일을 사용하기 위해 검증하는 과정이다.
  3. Initialization: 클래스 파일을 적절한 값으로 초기화한다.

유의할 점은, 클래스 파일은 한 번에 메모리에 올라가는 것이 아니라 애플리케이션에서 필요할 경우 동적으로 메모리에 적재된다는 점이다.

많이들 착각하는 부분은 클래스나 클래스에 포함된 static 멤버들이 메모리에 올라가는 시점이다. 소스를 실행하자마자 메모리에 모두 올라가는줄 착각하는데, 언제 어디서 사용될지 모르는 static 멤버들을 시작 시점에 모두 메모리에 올려놓는다는 것은 비효율적이다. 클래스 내의 멤버를 호출하게 되면 그제서야 클래스가 동적으로 메모리에 로드된다.

verbose 옵션을 사용하면 메모리에 올라가는 동작과정을 엿볼 수 있다.

java -verbose:class VerboseLanguage

image

'Hello World' 가 출력되기 전에 VerboseLanguage 클래스가 먼저 로드되는걸 확인할 수 있다.

info

Java 1.8 과 Java 21 은 컴파일 결과물부터 로그 출력 포맷도 다르다. 버전이 올라감에 따라 최적화가 많이 이루어지고 컴파일러 동작도 약간씩 변하므로, 버전을 잘 확인하자. 이 글에서는 Java21 을 기본으로 사용하고 다른 버전의 경우 별도로 명시한다.

Runtime Data Area

Runtime Data Area 는 프로그램이 동작하는 동안 데이터들이 저장되는 공간이다. 크게는 Shared Data Area 와 Per-thread Data Area 로 나누어진다.

Shared Data Areas

JVM 에는 JVM 안에서 동작하는 여러 스레드 간 데이터를 공유할 수 있는 여러 영역이 존재한다. 따라서 다양한 스레드가 이러한 영역 중 하나에 동시에 접근할 수 있다.

Heap

VerboseLanguage 클래스의 인스턴스가 존재하는 곳

Heap 영역은 모든 자바 객체 혹은 배열이 생성될 때 할당되는 영역이다. JVM 이 실행되는 순간에 만들어지고 JVM 이 종료될 때 함께 사라진다.

자바 스펙에 따라서, 이 공간은 자동으로 관리되어져야 한다. 이 역할은 GC 라고 알려진 도구에 의해 수행된다.

Heap 사이즈에 대한 제약은 JVM 명세에 존재하지 않는다. 메모리 처리도 JVM 구현에 맡겨져 있다. 그럼에도 불구하고 Garbage Collector 가 새로운 객체를 생성하기에 충분한 공간을 확보하지 못한다면 JVM 은 OutOfMemory 에러를 발생시킨다.

Method Area

Method Area 는 클래스 및 인터페이스 정의를 저장하는 공유 데이터 영역이다. Heap 과 마찬가지로 JVM 이 시작될 때 생성되며 JVM 이 종료될 때만 소멸된다.

클래스 전역 변수와 static 변수는 이 영역에 저장되므로 프로그램이 시작부터 종료될 때까지 어디서든 사용이 가능한 이유가 된다. (= Run-Time Constant Pool)

구체적으로는 클래스 로더는 클래스의 바이트코드(.class)를 로드하여 JVM 에 전달하는데, JVM 은 객체를 생성하고 메서드를 호출하는 데 사용되는 클래스의 내부 표현을 런타임에 생성한다. 이 내부 표현은 클래스 및 인터페이스에 대한 필드, 메서드, 생성자에 대한 정보를 수집한다.

사실 Method Area 는 JVM 명세에 따르면 구체적으로 '이래야 한다' 는 명확한 정의가 없는 영역이다. 논리적 영역이며, 구현에 따라서 힙의 일부로 존재할 수도 있다. 간단한 구현에서는 힙의 일부이면서도 GC 나 압축이 발생하지 않도록 할 수도 있다.

Run-Time Constant Pool

Run-Time Constant Pool 은 Method Area 의 일부로 클래스 및 인터페이스 이름, 필드 이름, 메서드 이름에 대한 심볼릭 참조를 포함한다. JVM 은 Run-Time Constant Pool 을 통해 실제 메모리상 주소를 찾아서 참조할 수 있다.

앞서 바이트코드를 분석하며 클래스 파일 내부에 constant pool 이 있는 것을 확인했었다. 런타임에는 클래스파일 구조의 일부였던 constant pool 을 읽고 클래스로더에 의해 메모리에 적재되게 된다.

String Constant Pool

"Hello World" 문자열이 저장되는 곳

앞 문단에서 Run-Time Constant Pool 이 Method Area 에 속한다고 했었다. Heap 에도 Constant Pool 이 하나 존재하는데 바로 String Constant Pool 이다.

이전, String 을 설명하며 Heap 을 잠깐 언급했다. new String("Hello World") 을 사용하여 문자열을 생성할 경우, 문자열을 객체로 다루게 되므로 Heap 영역에서 관리된다. 아래 케이스를 한 번 보자.

String s1 = "Hello World";
String s2 = new String("Hello World");

생성자 내에서 사용된 문자열 리터럴은 String Pool 에서 가져온 것이지만, new 키워드는 새롭고 고유한 문자열 생성을 보장해준다.

0: ldc           #7                  // String Hello World
2: astore_1
3: new #9 // class java/lang/String
6: dup
7: ldc #7 // String Hello World
9: invokespecial #11 // Method java/lang/String."<init>":(Ljava/lang/String;)V
12: astore_2
13: return

바이트코드를 확인해보면 invokespecial 을 통해 문자열이 '생성' 되는걸 확인할 수 있다.

invokespecial 은 객체 초기화 메서드가 직접 호출된다는걸 의미한다.

왜 Method Area 에 존재하는 Run-Time Constant Pool 과는 달리 String Constant Pool 은 Heap 에 존재할까? 🤔

  • 문자열은 굉장히 큰 객체에 속한다. 또한 얼마나 생성될지 알기 어렵기 때문에, 메모리 공간을 효율적으로 사용하기 위해서는 사용되지 않는 문자열을 정리하는 과정이 필요하다. 즉, Heap 영역에 존재하는 GC 가 필요하다는 의미다.
    • 스택에 저장한다면 공간을 찾기 힘들어서 문자열 선언 자체가 실패할 수 있다.
    • 스택의 크기는 32bit 에서는 320kb1MB, 64bit 에서는 1MB2MB 정도를 기본값으로 가진다.
  • 문자열은 불변으로 관리된다. 수정은 허용되지 않으며, 항상 새롭게 생성된다. 이미 생성된 적이 있다면 재활용함으로써 메모리 공간을 절약한다(=interning). 하지만 참조되지 않는 문자열이 생길 수 있으며, 애플리케이션의 생명 주기동안 계속해서 쌓여갈 것이다. 메모리를 효율적으로 활용하기 위해 참조되지 않는(unreachable) 문자열을 정리할 필요가 있고, 이 말은 다시 한 번 GC 가 필요하다는 말로 귀결된다.

결국 String Constant Pool 은 GC 의 영향력 아래에 놓이기 위해 Heap 영역에 존재해야할 필요가 있다.

문자열 비교 연산은 길이가 N 이라면 완벽하게 일치하기 위한 판단에 N 번의 연산이 필요하다. 반면 풀을 사용한다면, equals 비교로 ref 체크만 하면 되므로 O(1)O(1) 의 비용이 든다.

new 로 문자열을 생성하여 String Constant Pool 외부에 있을 문자열을 String Constant Pool 로 보내는 것도 가능하다.

String greeting = new String("Hello World");
greeting.intern(); // constant pool 사용

// SCP 에 있는 문자열 리터럴과 동등 비교가 가능해진다.
assertThat(greeting).isEqualTo("Hello World"); // true

과거에는 메모리를 절약하기 위한 일종의 트릭으로 제공됐지만, 이제는 이런 트릭을 사용할 필요가 없으니 참고만 하자. 문자열은 그냥 리터럴로 사용하면 된다.

다소 설명이 길었다. 요약해보자.

  1. 숫자들은 최댓값이 제한되어 있는 반면에 문자열은 그 특성상 최대 크기를 고정하기 애매하다.
  2. 매우 커질 수 있고, 생성 이후 자주 사용될 가능성이 다른 타입에 비해 높다
  3. 자연스럽게 메모리 효율성이 높을 것이 요구된다. 그러면서도 사용성을 높이기 위해 전역적으로 참조될 수 있어야 한다.
  4. Per-Thread Date Area 중 Stack 에 있을 경우는 다른 스레드에서 재활용할 수 없고, 크기가 크면 할당 공간을 찾기 어렵다
  5. Shared Date Area 에 있는게 합리적 + Heap 에 있어야 하지만 JVM 레벨에서 불변으로 다뤄야하므로 전용 Constant Pool 을 Heap 내부에 별도로 생성하여 관리하게 되었다
tip

생성자 내부의 문자열 리터럴은 String Constant Pool 에서 가져오지만 new 키워드는 독립된 문자열 생성을 보장한다. 결국, String Constant Pool 에 하나, Heap 영역에 하나씩 총 2개의 문자열이 존재하게 된다.

Per-thread Data Areas

Shared Data Area 외에도 JVM 은 개별 스레드 별로 데이터를 관리한다. JVM 은 실제로 꽤 많은 스레드의 동시 실행을 지원한다.

PC Register

각 JVM 스레드는 PC(program counter) register 를 가진다.

PC register 는 CPU 가 명령(instruction)을 이어서 실행시킬 수 있도록 현재 명령어가 어디까지 실행되었는지를 저장한다. 또한 다음으로 실행되어야할 위치(메모리 주소)를 가지고 명령 실행이 최적화될 수 있도록 돕는다.

PC 의 동작은 메서드의 특성에 따라 달라진다.

  • non-native method 라면, PC register 는 현재 실행 중인 명령의 주소를 저장한다.
  • native method 라면, PC register 는 undefined 를 가진다.

PC register 의 수명 주기는 기본적으로 스레드의 수명주기와 같다.

JVM Stack

JVM 스레드는 독립된 스택을 가진다. JVM 스택은 메서드 호출 정보를 저장하는 데이터 구조다. 각 메서드가 호출될 때마다 스택에 메서드의 지역 변수와 반환 값의 주소를 가지고 있는 새로운 프레임이 생성된다. 만약 primitive type 이라면 스택에 바로 저장되고, wrapper type 이라면 Heap 에 생성된 인스턴스의 참조를 갖게 된다. 이로 인하여 int 나 double 이 Integer, Double 보다 근소하게 성능상 이점을 갖게 된다.

JVM 스택 덕분에 JVM 은 프로그램 실행을 추적하고 필요에 따라 스택 추적을 기록할 수 있다.

  • stack trace 라고 한다. printStackTrace 가 이것이다.
  • 한 작업이 스레드를 넘나드는 webflux 의 이벤트루프에서 stack trace 가 의미를 갖기 어려운 이유

JVM 구현에 따라 스택의 메모리 사이즈와 할당 방식이 결정될 수 있다. 일반적으로는 1MB 남짓의 공간이 스레드가 시작될 때 할당된다.

JVM 의 메모리 할당 에러는 stack overflow error 를 수반할 수 있다. 그러나 만약 JVM 구현이 JVM 스택 사이즈의 동적 확장을 허락한다면, 그리고 만약 메모리 에러가 확장 도중에 발생한다면 JVM 은 OutOfMemory 에러를 던지게 될 수 있다.

스레드마다 분리된 stack 영역을 갖는다. Stack 은 호출되는 메서드 실행을 위해 해당 메서드를 담고 있는 역할을 한다. 메서드가 호출되면 새로운 Frame 이 Stack 에 생성된다. 이 Frame 은 LIFO 로 처리되며, 메서드 실행이 완료되면 제거된다.

Native Method Stack

Native Method 는 자바가 아닌 다른 언어로 작성된 메서드를 말한다. 이 메서드들은 바이트코드로 컴파일될 수 없기 때문에(Java 가 아니므로 javac 를 사용할 수 없다), 별도의 메모리 영역이 필요하다.

  • Native Method Stack 은 JVM Stack 과 매우 유사하지만 오직 native method 전용이다.
  • Native Method Stack 의 목적은 native method 의 실행을 추적하는 것이다.

JVM 구현은 Native Method Stack 의 사이즈와 메모리 블록을 어떻게 조작할 것인지를 자체적으로 결정할 수 있다.

JVM Stack 의 경우, Native Method Stack 에서 발생한 메모리 할당에러의 경우 스택오버플로우 에러가 된다. 반면에 Native Method Stack 의 사이즈를 늘리려는 시도가 실패한 경우 OutOfMemory 에러가 된다.

결론적으로, JVM 구현은 Native Method 호출을 지원하지 않기로 결정할 수 있고, 이러한 구현은 Native Method Stack 이 필요하지 않다는 점을 강조한다.

Java Native Interface 의 사용 방법에 대해서는 별도의 글로 다룰 예정이다.

Execution Engine

로딩과 저장하는 단계가 끝나고 나면 JVM 은 마지막 단계로 Class File 을 실행시킨다. 다음과 같은 세 가지 요소로 구성된다.

  • Interpreter
  • JIT Compiler
  • Garbage Collector

Interpreter

프로그램을 시작하면 Interpreter 는 Bytecode 를 한 줄씩 읽어가며 기계가 이해할 수 있도록 기계어로 변환한다.

일반적으로 Interpreter 의 속도는 느린 편이다. 왜 그럴까?

컴파일 언어는 실행 전에 컴파일 과정을 통해 프로그램이 실행되기 위해 필요한 자원이나 타입 등을 미리 정의할 수 있다. 하지만 인터프리터 언어는 실행되기 전까지는 필요한 자원이나 변수의 타입을 알 수 없기 때문에 최적화 과정이 어렵기 때문이다.

JIT Compiler

Just In Time Compiler 는 Interpreter 의 단점을 극복하기 위해 Java 1.1 부터 도입되었다.

JIT 컴파일러는 런타임 시에 바이트코드를 기계어로 컴파일하여 자바 애플리케이션의 실행 속도를 향상시킨다. 전체 코드를 한 번에 기계어로 컴파일하는 것은 아니고, 자주 실행되는 부분(핫 코드)를 감지하여 컴파일한다.

아래 키워드를 사용하면 JIT 관련 동작을 확인할 수 있으니 필요하다면 사용해보자.

  • -XX:+PrintCompilation: JIT 관련 로그 출력
  • -Djava.compiler=NONE: JIT 비활성화. 성능 하락을 확인할 수 있다.

Garbage Collector

별개의 문서로 다뤄야할만큼 매우 중요한 컴포넌트며 이미 정리한 글이 있어서 이번에는 생략한다.

  • GC 를 최적화해야하는 경우는 흔하지 않다.
    • 하지만 GC 동작으로 500ms 이상 처리가 지연되는 경우는 종종 있고, 많은 트래픽을 다루거나 캐시의 TTL 이 타이트한 곳이라면 500ms 의 지연은 충분히 문제가 될 수 있다.

Conclusion

Java 는 분명 어려운 언어다.

면접을 보다보면 종종 이런 질문을 받는다.

Java 에 대해서 얼마나 알고 계신다고 생각하시나요?

이젠 좀 확실히 대답할 수 있을 것 같다.

음... 🤔 Hello World 정도요.

Reference

Java 에서 Hello World 를 출력하기까지 2

· 14 min read

이전 글 에 이어서 "Hello World" 를 출력하기 위해 코드가 어떻게 변해가는지 살펴봅니다.

Chapter 2. Compile 과 Disassemble

프로그래밍 언어에는 레벨이 있다.

프로그래밍 언어가 인간의 언어와 가까울수록 고수준 언어(high-level language), 컴퓨터가 이해할 수 있는 언어(=기계어)에 가까울수록 저수준 언어(low-level language)라고 한다. 고수준 언어로 프로그램을 작성하면 인간이 이해하기 쉽기에 높은 생산성을 얻을 수 있지만, 그만큼 기계어와의 괴리가 심해지니 이 간극을 메우기 위한 과정이 필요하다.

고수준 언어가 저수준으로 내려오는 과정, 이걸 컴파일(compile) 이라고 부른다.

Java 또한 저수준 언어는 아니므로, 컴파일 과정이 존재한다. 자바에서는 이 컴파일 과정이 어떻게 동작하는지 살펴보자.

Compile

앞서 설명했던 것처럼 Java 코드를 컴퓨터가 바로 실행할 순 없다. Java 코드의 실행을 위해서는 작성된 코드를 컴퓨터가 읽고 해석할 수 있는 형태로 변환해줘야하는데, 이를 위해 크게는 아래와 같은 과정을 거치게 된다.

컴파일의 결과물인 .class 파일은 바이트 코드로 되어 있다. 하지만 여전히 컴퓨터가 실행할 수 있는 기계어는 아닌데, JVM 이 이 바이트 코드를 읽어서 기계어로 변환하는 작업을 마저 처리해준다. JVM 이 어떻게 처리해주는지는 마지막 챕터에서 다룬다.

우선, .java 파일을 컴파일해서 .class 파일을 만들어보자. javac 명령어를 사용하면 컴파일할 수 있다.

// VerboseLanguage.java
public class VerboseLanguage {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
javac VerboseLanguage.java

클래스 파일이 생성된 것을 확인할 수 있다. java 명령어를 사용해서 클래스 파일을 실행시킬 수 있으며, 여기까지가 자바로 작성한 프로그램을 실행시키는 기본 흐름이다.

java VerboseLanguage
// Hello World

클래스 파일이 어떤 내용으로 이루어졌는지 궁금하지 않은가? 도대체 컴퓨터는 어떻게 생긴 언어를 읽고 실행하는지 신경쓰이지는 않는가? 이 파일에는 무슨 비밀이 들어있을까? 마치 판도라의 상자처럼 느껴진다.

기대를 안고 열어보면,

어림도 없지

바이너리(binary)라는 짤막한 내용만 표시된다.

아니 지금까지 컴파일의 결과물은 바이트 코드라며...?

그렇다, 바이트 코드다. 동시에 바이너리 코드이기도 하다. 이쯤에서 바이트 코드와 바이너리 코드의 차이점을 간략하게 짚어보고 넘어가자.

바이너리 코드 : 0과 1 로만 구성된 코드. 기계어는 바이너리 코드로 이루어져 있지만, 모든 바이너리 코드가 기계어인 것은 아니다.

바이트 코드 : 0과 1 로만 구성된 코드. 하지만 바이트 코드는 기계(machine)을 위한 것이 아닌 VM 을 위한 것이다. VM 에서 JIT compiler 등을 통해 기계어로 변환된다.

그래도 나름 이 글의 주제가 Deep-dive 를 표방하고 있는만큼 꾸역꾸역 변환하여 읽어봤다.

다행히 우리들의 판도라의 상자 안에는 0 과 1 이 들어있을 뿐, 별 다른 고난이나 역경은 들어있지 않다.

읽어내는데는 성공했지만, 0 과 1 만 가지고는 도저히 내용을 알기 어렵다 🤔

이제, 이 암호를 풀어보자.

Disassemble

컴파일 과정을 진행하면 0과 1로 구성된 바이트 코드로 변환된다. 위에서 살펴봤듯이 바이트 코드를 그대로 해석하기는 무척 어렵다. 다행히도 JDK 에는 개발자가 컴파일된 바이트 코드를 읽을 수 있게 도와주는 도구가 포함되어 있어서 디버깅 등의 목적으로 활용할 수 있다.

바이트 코드를 개발자가 해석하기 편한 형태로 변환하는 과정을 역어셈블(disassemble) 이라고 한다. 가끔 이 과정을 역컴파일(decompile)과 혼동할 수 있는데, 역컴파일은 변환 결과가 어셈블리어가 아니라 고수준 프로그래밍 언어라는 점에 차이가 있다. 또한 javap 문서에는 명확하게 disassemble 이라고 표현하고 있으므로 이를 따르도록 하겠다.

info

역컴파일의 경우는 말 그대로 바이너리를 컴파일 하기 전처럼, 상대적으로 고수준의 언어로 표현하는 것을 말한다. 반면, 역어셈블은 바이너리를 사람이 읽을 수 있는 최소한의 형식(assembler language)으로 표현해주는 것을 말한다.

Virtual Machine Assembly Language

javap 를 사용해서 바이트코드를 변환(disassemble)해보자. 0, 1 보다는 훨씬 읽을만한 결과가 출력된다.

javap -c VerboseLanguage.class
Compiled from "VerboseLanguage.java"
public class VerboseLanguage {
public VerboseLanguage();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
}

이걸 보고 무엇을 알 수 있을까?

먼저, 이 언어는 virtual machine assembly language 라고 불린다.

The Java Virtual Machine code is written in the informal “virtual machine assembly language” output by Oracle's javap utility, distributed with the JDK release. - JVM Spec

format 은 아래와 같다.

<index> <opcode> [ <operand1> [ <operand2>... ]] [<comment>]

index : JVM code 바이트 배열의 인덱스. 메서드 시작 오프셋으로 생각할 수도 있다.

opcode : 명령어(instruction) 집합 opcode 의 연상 기호(mnemonic). 우리는 무지개의 색상 순서를 '빨주노초파남보'라는 단어로 기억한다. 무지개의 색상이 명령어 집합이라면, '빨주노초파남보' 각각의 음절은 이를 구별하기 위해 정의된 연상 기호라고 할 수 있다.

operandN : 명령어의 피연산자. 컴퓨터 명령어의 피연산자는 주소 필드이다. constant pool 에서 처리할 데이터가 저장되어 있는 장소를 가리킨다.

출력된 역어셈블의 결과에서 main 메서드 부분만 좀 더 살펴보자.

Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
  • invokevirtual: 인스턴스 메서드 호출
  • getstatic: 클래스에서 static field 를 가져온다
  • ldc run-time constant pool 에 데이터를 적재한다.

3번째 줄의 3: ldc #13 은 13번 인덱스에 아이템을 넣으라는 의미이며, 넣는 아이템이 무엇인지는 주석으로 친절하게 표시되어 있다.

Hello World

참고로 getstatic, invokevirtual 같은 바이트 코드 명령어 opcode 들은 1바이트의 바이트 번호로 표현된다. getstatic=0xb2, invokevirtual = 0xb6 등이다. 1바이트는 256가지 종류의 수를 표현할 수 있으므로, 자바 바이트 코드 명령어 opcode 역시 최대 256개라는 점을 알 수 있다.

JVM Instruction Set 에 명시된 invokevirtual 의 바이트 코드

main method 의 바이트 코드만 hex 로 보면 다음과 같다.

b2 00 07 12 0d b6

아직은 눈치채기 어려울 수도 있을 것 같다. 힌트를 주자면, 좀 전에 opcode 앞의 숫자는 JVM array 의 index 라고 했었다. 표현 방식을 살짝 바꿔보자.

arr = [b2, 00, 07, 12, 0d, b6]
  • arr[0] = b2 = getstatic
  • arr[3] = 12 = ldc
  • arr[5] = b6 = invokevirtual

index 가 어떤 의미였는지 조금은 명확하게 보인다. 인덱스를 건너뛰는 이유는 꽤나 단순한데, getstatic 은 2바이트의 피연산자가 필요하고 ldc 는 1바이트의 피연산자가 필요하다. 따라서 0번째에 있는 getstatic 다음 명령어인 ldc 는 1, 2 를 건너뛴 3번째에 기록된다. 같은 이유로 4를 건너뛰고 invokevirtual 이 5번째에 기록된다.

마지막으로 4번째 줄에 보면 (Ljava/lang/String;)V 라는 주석이 눈에 띈다. 이 주석을 통해 자바 바이트 코드에서 클래스는 L; void 는 V 로 표현되는걸 알 수 있다. 다른 타입들도 고유의 표현이 있는데 이를 정리하면 다음과 같다.

자바 바이트코드타입설명
Bbytesigned byte
CcharUnicode character
Ddoubledouble-precision floating-point value
Ffloatsingle-precision floating-point value
Iintinteger
Jlonglong integer
L<classname>;referencean instance of class <classname>
Sshortsigned short
Zbooleantrue or false
[referenceone array dimension

-verbose 옵션을 주면 constant pool 을 포함한 역어셈블 결과를 자세히 볼 수 있다. operand 와 constant pool 을 함께 살펴보는 것도 재밌을 것이다.

  Compiled from "VerboseLanguage.java"
public class VerboseLanguage
minor version: 0
major version: 65
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #21 // VerboseLanguage
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello World
#14 = Utf8 Hello World
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // VerboseLanguage
#22 = Utf8 VerboseLanguage
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 VerboseLanguage.java
{
public VerboseLanguage();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: "VerboseLanguage.java"

Conclusion

전 챕터에서는 Hello World 를 출력하기 위해 왜 말많은 과정이 필요한지에 대해 살펴봤었다면, 이번 챕터에서는 Hello World 를 출력하기 전 어떤 과정이 진행되는지 컴파일과 역어셈블 과정을 통해 살펴봤다. 다음으로는 드디어 JVM과 함께 Hello World 출력 메서드의 실행 흐름을 살펴본다.

Reference

Java 에서 Hello World 를 출력하기까지 1

· 17 min read

banner

프로그래밍 세계에서는 항상 Hello World 라는 문장을 출력하면서 시작한다. 그게 국룰 암묵적인 규칙이다.

# hello.py
print("Hello World")
python hello.py
// Hello World

Python? 훌륭하다.

// hello.js
console.log("Hello World");
node hello.js
// Hello World

JavaScript? 나쁘지 않다.

public class VerboseLanguage {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
javac VerboseLanguage.java
java VerboseLanguage
// Hello World

그런데 Java 는 마치 다른 세계에서 온 것 같다. class 이름과 파일 이름이 같아야 하는 점은 아직 언급도 안했다.

public 은 무엇이고 class 는 무엇이고, static 은 또 무엇이며, void, main, String[], System.out.println 을 거쳐야 드디어 "Hello World" 라는 문자열에 도달한다. 이제 다른 언어를 배우러 가자.1

단순한 "Hello World" 를 출력하는 것임에도 Java 는 꽤 많은 배경 지식을 요구한다. Java 는 도대체 왜 이리 말 많은(verbose) 과정이 필요할까?

이번 시리즈는 3개의 챕터로 구성되어 있다. 목표는 "Hello World" 라는 2단어를 출력하기 위해 뒤에서는 무슨 일이 일어나는지 자세하게 살펴보는 것이다. 구체적인 챕터의 내용은 아래와 같다.

  • 첫 번째 챕터에서는 의문의 시작이 되는 Hello World 를 살펴보면서 간단하게 이유를 소개한다.
  • 두 번째 챕터에서는 실제로 컴파일된 class 파일을 살펴보며 컴퓨터가 java 코드를 어떻게 해석하고 실행하는지 살펴본다.
  • 마지막으로 public static void mainJVM 이 어떻게 메모리에 어떻게 적재하고 실행할 수 있는지 그 동작 원리에 대해 살펴본다.

3개의 챕터 내용을 조합하면 그제서야 "Hello World" 에 대해 그림이 그려진다. 꽤 긴 여정이니, 호흡을 가다듬고 출발해보자.

Chapter 1. Why?

Java 에서 Hello World 를 출력하기 전까지 살펴봐야할 몇가지 why moment 가 있다.

왜 클래스 이름이 파일명이 되어야 하는가?

정확하게는 public 클래스의 이름이 파일명이어야 하는 것이다. 왜 그럴까?

Java 로 된 프로그램은 기본적으로 컴퓨터가 해석할 수 없다. JVM 이라는 가상 머신이 컴퓨터가 프로그램을 실행할 수 있도록 도와준다. 자바 프로그램을 컴퓨터가 실행할 수 있도록 하려면 몇 가지 과정을 통해 기계어로 변환해주어야 하는데, 그 시작이 컴파일러를 사용해 JVM 이 해석할 수 있는 바이트코드로 변환하는 것이다. 변환된 바이트코드는 JVM 내부에 존재하는 인터프리터(interpreter) 를 거쳐서 기계어로 변환되고, 실행된다.

우선 컴파일 과정을 간단하게 살펴보자.

public class Outer {
public static void main(String[] args) {
System.out.println("This is Outer class");
}

private class Inner {
}
}
javac Outer.java
Permissions Size User   Date Modified Name
.rw-r--r-- 302 haril 30 Nov 16:09 Outer$Inner.class
.rw-r--r-- 503 haril 30 Nov 16:09 Outer.class
.rw-r--r-- 159 haril 30 Nov 16:09 Outer.java

위처럼 Java 는 컴파일 시점에 모든 class 를 .class 파일로 생성한다.

이제 JVM 은 프로그램의 실행을 위해 main 메서드를 찾아야 한다. 어디에 main 메서드가 있는지 어떻게 알 수 있을까?

왜 하필 main 을 찾아야하냐고? 조금만 기다려주시라.

Java 파일 이름이 public class 와 동일하지 않다면 Java interpreter 는 모든 class 파일을 읽어서 main 메서드를 찾아야 한다. 파일 이름과 public class 의 이름이 같다면 Java interpreter 는 해석해야하는 파일을 더 잘 식별할 수 있다.

Java1000 이라는 파일이 있고, 이 파일 내부에 1000개의 클래스가 존재한다고 생각해보자. 1000 개의 클래스 중 어디에 main() 이 있는지 식별하기 위해서는 모든 클래스 파일을 살펴봐야 한다.

하지만 파일 이름과 public class 이름이 같다면 main() 에 더 빠르게 접근할 수 있고(main 은 public class 에 존재하므로), 모든 로직이 main() 에서부터 시작하기 때문에 쉽게 다른 클래스로 접근할 수 있다.

왜 public 이어야 할까?

JVM 은 클래스 안에 존재하는 main 메서드를 찾아야 한다. 클래스 외부에서 접근하는 JVM 이 클래스 내부의 메서드를 찾아야한다면 그 메서드는 public 이어야 할 것이다. 실제로 접근제어자를 private 으로 바꾸면 mainpublic 으로 선언하라는 에러 메세지가 출력된다.

Error: Main method not found in class VerboseLanguage, please define the main method as:
public static void main(String[] args)

왜 static 이어야 할까?

public main() 이라는 메서드는 찾았다. 하지만 이 메서드를 호출시키기 위해서는 먼저 객체를 생성해야 한다. JVM 입장에서 이 객체는 필요한 객체일까? 아니다, main 을 호출할 수 있기만 하면 된다. static 으로 선언함으로서 JVM 은 불필요한 객체를 생성할 필요가 없고, 메모리를 절약할 수 있다.

왜 void 여야 할까?

main 메서드의 종료는 Java 의 실행종료를 의미한다. JVM 은 main 메서드의 반환값으로 아무 것도 할 수 없으며, 따라서 반환값의 존재가 무의미하다. 그렇다면 void 로 선언하는게 자연스러울 것이다.

왜 main 이어야 할까?

main 이라는 메서드 이름은 JVM 이 애플리케이션을 실행하기 위해 찾는 진입점으로 설계되어 있다.

설계라는 거창한 표현을 썼지만, 실제로는 main 이라는 method 를 찾도록 하드코딩 되어 있을 뿐이다. OpenJDK 8 의 java.c 를 살펴보면 C 언어로 작성된 아래 코드를 발견할 수 있다.

mainClassName = GetMainClassName(env, jarfile);
mainClass = LoadClass(env, classname);

// main 메소드의 아이디를 찾는다.
mainID = (*env)->GetStaticMethodID(env, mainClass, "main", "([Ljava/lang/String;)V");

jbject obj = (*env)->ToReflectedMethod(env, mainClass, mainID, JNI_TRUE);

찾아야 하는 이름이 main 이 아니라 haril 이였다면, haril 이라는 메서드를 찾았을 것이다. 물론 Java 창시자 입장에서는 main 이라는 단어를 선택한 이유가 있겠지만, 단지 그 뿐이다.

args 의 존재 이유?

지금까지 생략하여 표현했지만, main() 에는 String[] args 라는 arguments 를 명시해야 한다. 이 인자(arguments)는 명령행 인자(command-line arguments)라고 한다. 왜 문자열 배열로 선언되어 있고 명시하지 않으면 에러가 발생할까?

public static void main(String[] args) 이 자바 애플리케이션의 실행지점인 이상, 이 인자는 반드시 자바 외부에서 들어오게 된다.

표준 입력을 통해 입력하는 모든 타입은 문자열로 입력된다.

이것이 args 가 문자열 배열로 선언된 이유이다. 생각해보면 당연하다. 자바 애플리케이션이 실행되지도 않았는데, 직접 정의한 객체 타입을 생성할 수 있을까? 🤔

그럼 왜 args 가 있어야할까?

args 들을 단순한 방식으로 외부에서 내부로 넘겨줌으로써 자바 애플리케이션의 동작 방식을 바꿔줄 수 있고, 이런 메커니즘은 C 프로그램의 초창기부터 프로그램의 동작을 제어하기 위해 널리 쓰이던 방식이였다. 특히 간단하게 구현된 애플리케이션은 이 방법이 매우 효과적이다. Java 는 단순히 널리 쓰이던 방식을 채택했을 뿐이다.

String[] args 를 생략할 수 없는 이유는 Java 의 진입 지점으로 public static void main(String[] args) 단 하나만 허용되기 때문이다. Java 의 창시자들은 사용하지 않는 args 를 생략할 수 있게 하는 것보다 선언하고 사용하지 않는 방식이 덜 헷갈린다고 생각했던 것 같다.

System.out.println

드디어 출력과 관련된 메서드에 대해 이야기를 시작할 수 있다.

굳이 다시 언급하자면, Python 은 print("Hello World") 였다.2

자바 프로그램은 OS 에서 바로 실행되는 것이 아니라 JVM 이라는 가상 머신 위에서 실행된다. 이 점은 JVM 을 사용하는 언어라면 OS 에 상관없이 어디서나 애플리케이션을 실행할 수 있다는 장점이 된다.

동시에 OS 가 제공하는 특정 기능을 JVM 에서 사용하기 어렵다는 단점이 된다. Java 로 CLI 를 만들거나 OS 메트릭을 수집하는 등의 시스템 레벨의 코딩이 어렵다고 하는 이유가 이 때문이다.

하지만 제한적이나마 OS 기능을 빌려쓸 수 있는데(JNI), 이 기능을 제공하는 것이 바로 System 이다. 대표적인 기능은 아래와 같은 것들이 있다.

  • 표준 입력
  • 표준 출력
  • 환경변수 설정
  • 수행 중인 응용프로그램 종료하고 status 코드를 반환

Hello World 를 출력하기 위해 System 의 표준 출력 기능을 빌려 사용하는 것이다.

실제로 System.out.println 의 흐름을 따라가다보면 native 키워드가 달려있는 writeBytes 메서드를 만나게 되는데, 이 메서드 이후 C언어로 작성된 코드에 동작이 위임되며 표준 출력으로 넘어가게 된다.

// FileOutputStream.java
private native void writeBytes(byte b[], int off, int len, boolean append)
throws IOException;

native 키워드가 붙은 메서드의 호출은 Java Native Interface(JNI) 를 통해 동작한다. 이에 대해서는 이후 챕터에서 다룬다.

String

Java 에서 문자열은 조금 특별하다. 아니, 많이 특별한 것 같다3. 메모리 레벨에서 별도의 공간을 할당 받을 정도니 분명히 특별취급을 받고 있다. 왜 그럴까?

문자열은 아래 속성을 가지고 있다는 점에 주목할 필요가 있다.

  • 크기가 매우 커질 수 있다.
  • 비교적 재사용 빈도가 높다.

따라서 문자열은 한 번 생성한 이후 어떻게 재사용할 것인가에 주안점을 두고 설계되어 있다. 크기가 큰 문자열 데이터를 어떻게 관리하는지에 대해 완벽하게 이해하기 위해서는 이후 챕터에서 다룰 내용에 대한 이해가 필요하다. 지금은 간단하게 메모리 공간 절약의 관점에서만 짚고 넘어가보자.

먼저 자바에서 문자열을 선언하는 방식에 대해 살펴보자.

String greeting = "Hello World";

내부적으로는 아래처럼 동작한다.

문자열은 String Constant Pool 이라는 곳에 생성되며, 불변 속성을 지니고 있다. 한 번 생성된 문자열은 변하지 않으며, 이 후 문자열을 생성하려고 할 때 같은 문자열이 Constant Pool 에 있다면 재활용하게 된다.

JVM Stack, Frame, Heap 에 관해서는 다음 챕터에서 다룬다

문자열을 선언하는 또 다른 방법은, 인스턴스화 하는 방식이다.

String greeting = new String("Hello World");

일반적으로 이 방법은 거의 사용되지 않는다. 내부 동작에 차이가 있기 때문인데 아래와 같다.

new 키워드 없이 문자열을 직접 사용했을 때에는 String Constant Pool 에 생성되어 재사용이 가능했다. 하지만 new 키워드를 통해 인스턴스화하면 Constant Pool 에 생성되지 않는다. 이 말은 같은 문자열을 몇 번이고 생성할 수 있다는 뜻이고, 메모리 공간을 쉽게 낭비하게 될 수 있다.

정리

이번 챕터를 통해 다음과 같은 질문에 대답해봤다.

  • .java 파일과 class 이름이 같아야할까?
  • public static void main(String[] args) 이어야 할까?
  • 출력 동작의 흐름
  • 문자열의 특징과 생성 및 사용 기초 원리

다음 챕터에서는 자바를 직접 컴파일해보며 바이트코드가 어떤 식으로 생성되고 메모리 영역과 어떤 상관이 있는지를 다뤄본다.

Reference

Footnotes

  1. 생활코딩 파이썬

  2. 생활코딩 파이썬

  3. https://www3.ntu.edu.sg/home/ehchua/programming/java/J3d_String.html

1대의 서버 애플리케이션은 최대 몇 개의 동시 요청을 감당할 수 있을까?

· 28 min read

banner

Overview

Spring MVC 웹 애플리케이션은 동시 사용자를 몇 명까지 수용할 수 있을까? 🤔

자신이 만든 서버가 어떤 상태여야 많은 유저를 수용하면서 안정적인 서비스를 제공할 수 있을지에 대한 대략적인 수치를 가늠하기 위해 Spring MVC 의 tomcat 설정을 중심으로 네트워크의 변화를 살펴봅니다.

이후는 작성의 편의를 위해 문어체를 사용합니다 🙏

info

기술적인 오류나 오타 등의 잘못된 내용이 있다면 댓글로 알려주시면 감사하겠습니다 🙇‍♂️

[대규모 시스템 설계 기초] 직접 구현해보는 URL 단축기

· 8 min read

banner

info

코드는 GitHub 에서 확인하실 수 있습니다.

Overview

URL 길이를 줄이는 것은 이메일 또는 SMS 전송에서 URL 이 단편화되는 것을 방지하기 위해 시작되었습니다. 하지만 요즘에는 트위터나 인스타그램 등 SNS 에서 특정 링크 공유를 위해서 더 활발하게 사용되고 있습니다. 장황하게 보이지 않기 때문에 가독성이 개선되고 URL 로 이동하기 전에 사용자 통계를 수집하는 등 부가적인 기능을 제공할 수도 있습니다.

이번 글에서는 URL 단축기를 직접 구현해보며 동작 원리를 살펴봅니다.

URL 단축기?

먼저 결과물을 살펴보고 시작할게요.

아래 명령을 통해서 이번 글에서 구현할 url 단축기를 바로 실행시킬 수 있습니다.

docker run -d -p 8080:8080 songkg7/url-shortener

사용법은 아래와 같습니다. 단축시킬 긴 url 을 longUrl 의 값으로 넣어주시면 됩니다.

curl -X POST --location "http://localhost:8080/api/v1/shorten" \
-H "Content-Type: application/json" \
-d "{
\"longUrl\": \"https://www.google.com/search?q=url+shortener&sourceid=chrome&ie=UTF-8\"
}"
# tN47tML, 임의의 값이 반환됩니다.

이제 웹페이지에서 http://localhost:8080/tN47tML 로 접근해보면,

image

기존 url 로 잘 접근하는 것을 볼 수 있습니다.

단축 전

단축 후

그러면 이제 어떻게 URL 을 단축시킬 수 있는지 알아볼게요.

대략적인 설계

URL 단축하기

  1. longUrl 을 저장하기 전에 id 를 채번
  2. ID 를 base62 encode 하여 shortUrl 을 생성
  3. DB 에 id, shortUrl, longUrl 을 저장

메모리는 유한하며 비용이 상대적으로 비싼 편입니다. RDB 는 인덱스를 통해 빠르게 조회 가능하며 메모리에 비해 상대적으로 저렴하므로 RDB 를 사용하여 URL 을 관리하도록 하겠습니다.

URL 을 관리하기 위해 ID 생성 전략을 먼저 확보해야 합니다. ID 생성에는 다양한 방법이 있는데 여기서 다루기에는 내용이 다소 길어질 수 있어서 생략하겠습니다. 저는 간단하게 현재 시간에 대한 타임스탬프를 사용할 것 입니다.

Base62 변환

ULID 를 사용하면 시간이 포함된 유일한 ID 를 생성할 수 있습니다.

val id: Long = Ulid.fast().time // ex) 3145144998701, pk 로 사용

이 숫자를 62진법으로 변환하면 다음과 같은 문자열을 얻을 수 있습니다.

tN47tML

이 문자열을 shortUrl 로써 DB 에 저장합니다.

idshortlong
3145144998701tN47tMLhttps://www.google.com/search?q=url+shortener&sourceid=chrome&ie=UTF-8

조회는 아래 순서로 이루어지게 됩니다.

  1. localhost:8080/tN47tML 로 get 요청이 발생
  2. tN47tML 을 base62 decoding
  3. 3145144998701 이라는 pk 를 얻어낸 후 DB 에 조회
  4. longUrl 로 요청을 Redirect

간단하게 살펴봤으니 구현해보면서 조금 더 디테일하게 살펴봅시다.

구현

지난 번 안정 해시 처럼 직접 구현해볼게요. 다행인 점은 URL 단축 구현은 그렇게 어렵지 않다는 것입니다.

Model

먼저 유저에게 요청을 받기 위해 모델을 구현합니다. 구조를 최대한 단순화시켜서 단축시킬 URL 만 받았습니다.

data class ShortenRequest(
val longUrl: String
)

POST 요청을 통해 처리할 수 있도록 Controller 를 구현해줍니다.

@PostMapping("/api/v1/shorten")
fun shorten(@RequestBody request: ShortenRequest): ResponseEntity<ShortenResponse> {
val url = urlShortenService.shorten(request.longUrl)
return ResponseEntity.ok(ShortenResponse(url))
}

Base62 변환

드디어 가장 핵심적인 부분이네요. ID 를 생성하면 해당 아이디를 base62 인코딩하여 단축합니다. 이렇게 단축된 문자열이 shortUrl 이 됩니다. 반대의 경우는 shortUrl 을 디코딩하여 ID 를 알아내고 이 ID 로 DB 에 질의하여 longUrl 을 알아내는데 사용합니다.

private const val BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

class Base62Conversion : Conversion {
override fun encode(input: Long): String {
val sb = StringBuilder()
var num = BigInteger.valueOf(input)
while (num > BigInteger.ZERO) {
val remainder = num % BigInteger.valueOf(62)
sb.append(BASE62[remainder.toInt()])
num /= BigInteger.valueOf(62)
}
return sb.reverse().toString()
}

override fun decode(input: String): Long {
var num = BigInteger.ZERO
for (c in input) {
num *= BigInteger.valueOf(62)
num += BigInteger.valueOf(BASE62.indexOf(c).toLong())
}
return num.toLong()

}
}

단축된 URL 의 길이는 아이디의 숫자 크기에 반비례합니다. 생성된 ID 의 숫자가 작을수록 URL 도 짧게 만들 수 있습니다.

단축 URL 의 길이가 8자리를 넘지 않게 하고 싶다면, ID 의 크기가 62^8 을 넘지 않도록 생성하면 됩니다. 따라서 ID 를 어떤 방식으로 생성하느냐도 굉장히 중요합니다. 앞서 설명했듯 이번 글에서는 내용을 단순화시키기 위해서 해당 부분을 시간값으로 처리했습니다.

Test

curl 로 POST 요청을 보내서 임의의 URL 을 단축시켜 보겠습니다.

curl -X POST --location "http://localhost:8080/api/v1/shorten" \
-H "Content-Type: application/json" \
-d "{
\"longUrl\": \"https://www.google.com/search?q=url+shortener&sourceid=chrome&ie=UTF-8\"
}"

http://localhost:8080/{shortUrl} 로 접근해보면 정상적으로 리다이렉트 되는 것을 확인할 수 있습니다.

Conclusion

몇가지 개선해볼 수 있는 사항들 입니다.

  • ID 생성 전략을 더 정밀하게 제어하면 shortUrl 을 더 단축시킬 수 있습니다.
    • 트래픽이 많다면 동시성에 대한 문제를 반드시 고민해야할 것입니다.
    • Snowflake
  • host 부분도 DNS 를 사용하면 더 단축시킬 수 있습니다.
  • Persistence Layer 에 Cache 를 적용하면 더 빠른 응답을 구현할 수 있습니다.