반응형

JVM Memory는 스택과 힙 영역으로 구분된다. 이들은 어떻게 다르고 멀티 스레드에서는 어떻게 동작하는걸까?

Context

코틀린 프로그래밍에서 클래스의 인스턴스를 생성할 때 비용이 발생한다. 인스턴스를 생성하고 더 이상 사용하지 않을 경우 가비지 콜렉션 과정을 통해 메모리에서 해제하는 과정 또한 비용이 발생한다.따라서 인스턴스를 매번 생성할 필요가 없는 경우 매번 인스턴스를 생성하지 않는 것이 성능 측면에서 더 유리하다.

이 때문에 개발자는 요청마다 매번 인스턴스를 생성해야 하는지, 생성하지 않고 이미 생성된 인스턴스를 재사용할 것인지를 판단해야 한다. 이에 대한 기준은 인스턴스가 상태 값을 유지해야 하는지에 따라 구분된다.

송금 관리 시스템

예를 들어 은행 송금 시스템의 경우엔 송금을 관리하는 하나의 인스턴스를 만들어 놓고 송금 금액, 보내는 사람, 받는 사람 등의 정보만 입력 받아 처리해주면 될 것이다. (썩 좋은 예시는 아니지만 넘어가자)

data class Customer(
    val name: String,
    val accountNumber: String
)

data class Transfer(
    val customer: Customer,
    val quantity: Long
)

object TransferManager {
    lateinit var transfer: Transfer
    lateinit var transferTarget: Customer

    fun transfer() {
        println("Quantity: ${transfer.quantity}, ${transfer.customer} -> $transferTarget")
    }
}

송금 정보(금액, 보내는 사람)와 받는 사람을 필드로 갖는 TransferManager를 한 개 만들었다.

JVM은 코드를 실행하기 위해 메모리를 스택과 힙 영역으로 나눠서 관리한다. 스택 영역은 각 메소드가 실행될 때 메소드의 인자, 로컬 변수 등을 관리하는 메모리 영역으로 각 스레드마다 서로 다른 스택 영역을 가진다. 힙 영역은 클래스의 인스턴스 상태 데이터를 관리하는 영역이다. 힙 영역은 스레드가 서로 공유할 수 있다.

위 예시에서 TransferManager의 transfer와 transferTarget 필드는 JVM Heap에 적재되어 모든 스레드에서 공동으로 접근할 수 있게 된다.

발생할 수 있는 문제

현재 TransferManager는 애플리케이션 상에 단 한개의 인스턴스만 존재한다. 그리고 transfer와 transferTarget이 Heap영역에 적재되어 여러 스레드에서 동일한 transfer, transferTarget을 바라보고 있게 된다.

fun main() {
    // 첫 번째 송금 요청: 송금이 지연 됨
    thread {
        val transferCustomer = Customer(
            name = "Aaron Jin",
            accountNumber = "0000-0000-0000"
        )

        val transferTarget = Customer(
            name = "Martin",
            accountNumber = "1111-1111-1111"
        )

        TransferManager.transfer = Transfer(transferCustomer, 50000)
        TransferManager.transferTarget = transferTarget

        Thread.sleep(1000)

        println("첫 번째 송금 요청")
        TransferManager.transfer()
    }

    // 두 번째 송금 요청: 송금 요청이 바로 처리 됨
    thread {
        val transferCustomer = Customer(
            name = "Wanda Morton",
            accountNumber = "2222-2222-2222"
        )

        val transferTarget = Customer(
            name = "Philip Adams",
            accountNumber = "3333-3333-3333"
        )

        TransferManager.transfer = Transfer(transferCustomer, 1000000)
        TransferManager.transferTarget = transferTarget

        println("두 번째 송금 요청")
        TransferManager.transfer()
    }
}

두 개의 스레드 안에서 각각 송금 요청을 하고 있는 케이스다. (단, 첫 번째 요청은 1초가 소요되게끔 코드를 작성했다.)

  • 첫 번째 요청: Aaron Jin -> Martin (50,000원)
  • 두 번째 요청: Wanda Morton -> Philip Adams (1,000,000원)

이 코드를 실행한다면 어렵지 않게 두 번째 요청이 먼저 처리되고 약 1초 뒤 첫 번째 요청이 처리될 것이라는 것을 추측해볼 수 있다. 그리고 여기서 문제가 발생한다.

<실행 결과>
두 번째 송금 요청
Quantity: 1000000, Customer(name=Wanda Morton, accountNumber=2222-2222-2222) -> Customer(name=Philip Adams, accountNumber=3333-3333-3333)
첫 번째 송금 요청
Quantity: 1000000, Customer(name=Wanda Morton, accountNumber=2222-2222-2222) -> Customer(name=Philip Adams, accountNumber=3333-3333-3333)

첫 번째 송금 요청이 Wanda Morton -> Philip Adams (1,000,000원)으로 처리되었다.

이는 두 요청에서 사용되는 transfer, transferTarget이 힙 영역에 적재되어있기 때문이다. 이러한 문제를 발생시키지 않기 위해선 이러한 데이터를 스택 영역에 적재해야만 한다.

해결

package com.example.blog

import kotlin.concurrent.thread

data class Customer(
    val name: String,
    val accountNumber: String
)

data class Transfer(
    val customer: Customer,
    val quantity: Long
)

object TransferManager {
    fun transfer(transfer: Transfer, transferTarget: Customer) {
        println("Quantity: ${transfer.quantity}, ${transfer.customer} -> $transferTarget")
    }
}

fun main() {
    // 첫 번째 송금 요청: 송금이 지연 됨
    thread {
        val transferCustomer = Customer(
            name = "Aaron Jin",
            accountNumber = "0000-0000-0000"
        )

        val transferTarget = Customer(
            name = "Martin",
            accountNumber = "1111-1111-1111"
        )

        Thread.sleep(1000)

        println("첫 번째 송금 요청")
        TransferManager.transfer(
            transfer = Transfer(transferCustomer, 50000),
            transferTarget = transferTarget
        )
    }

    // 두 번째 송금 요청: 송금 요청이 바로 처리 됨
    thread {
        val transferCustomer = Customer(
            name = "Wanda Morton",
            accountNumber = "2222-2222-2222"
        )

        val transferTarget = Customer(
            name = "Philip Adams",
            accountNumber = "3333-3333-3333"
        )

        println("두 번째 송금 요청")
        TransferManager.transfer(
            transfer = Transfer(transferCustomer, 1000000),
            transferTarget = transferTarget
        )
    }
}
<실행 결과>
두 번째 송금 요청
Quantity: 1000000, Customer(name=Wanda Morton, accountNumber=2222-2222-2222) -> Customer(name=Philip Adams, accountNumber=3333-3333-3333)
첫 번째 송금 요청
Quantity: 50000, Customer(name=Aaron Jin, accountNumber=0000-0000-0000) -> Customer(name=Martin, accountNumber=1111-1111-1111)

 

반응형

'Kotlin&Java' 카테고리의 다른 글

스택과 힙 메모리, 그리고 멀티 스레드  (0) 2021.08.22
잘못된 내용이 있다면, 다른분들에게 전달되지 않게끔 댓글로 지적 부탁드립니다!
반응형

Java를 이용해 웹 서버를 구현하던 중 신기한 현상(?)을 발견해 그 내용과 해결 방법을 공유해보려 한다.

Context

Java에서 서버소켓을 생성하기 위해선 ServerSocket 클래스가 사용된다.

int port = 8080;
try (ServerSocket listenSocket = new ServerSocket(port)) {
    log.info("Web Application Server started {} port.", port);

    // 클라이언트가 연결될때까지 대기한다.
    Socket connection;
    while ((connection = listenSocket.accept()) != null) {
        // Do something ..
    }
}

그리고 해당 서버에 액세스하는 방법은 크게 3가지 존재한다

  1. http://localhost:8080
  2. http://127.0.0.1:8080
  3. http://[::1]:8080

2번은 IPv4, 3번은 IPv6이다. 그럼 1번은 둘 중 어떤걸로 동작하는걸까? 시스템의 기본 값?

확인해보기 위해서는 실제로 요청을 보내볼 필요가 있다. 그리고 조금 더 편하게 요청을 보내기 위해 Postman을 사용했다.

Postman GET Request

그런데 분명 요청을 1번 보냈음에도 서버에는 2번의 요청이 수신되었다. 크롬 브라우저처럼 /favicon.ico에 대한 요청을 보내는 것인가? 라는 생각이 들어 RequestHeader를 파싱하여 확인해보았는데...

첫 번째 요청. RequestHeader가 null이다
두 번째 요청. 내가 보냈던 요청에 대한 RequestHeader이다.

첫 번째는 요청에는 Header가 담겨있지 않았으며, 두 번째 요청은 내가 의도했던 Header가 담겨있었다.

그렇다면  첫 번째 요청은 무엇인가?

의문의 첫 번째 요청

내가 구현한 서버의 문제인지, Postman의 요청인지 알 수 없어 우선 nc로 port를 열고 Postman으로 동일한 요청을 보내봤다. 그런데 정상적으로 잘 동작한다. (내가 서버를 잘못 구현한건가?)

이해가 되지 않아 와이어샤크를 이용해 전송되는 패킷을 확인해보았다.

응? SYN...? IPv6...?

문제의 발견

그렇다. Postman은 localhost로 요청을 보낼 때 IPv6로 SYN을 먼저 보내고 그 후 IPv4로 Connection하는 방식을 사용하는 것이다.

도대체 왜? 그냥 시스템의 기본 설정으로 동작하게끔 해주면 안되나? 검색을 하다보니 나와 비슷한 생각을 가진 사람이 있었다.

https://github.com/pocoproject/poco/issues/2605

 

Default ServerSocket uses IPv4 only, StreamSocket defaults to IPv6 · Issue #2605 · pocoproject/poco

Expected behavior When I create a server (based on TCPServer) and client (using StreamSocket), I expected that they can connect without any issues or further configuration required. Actual behavior...

github.com

ServerSocket 을 이용해 접속을 대기하는데, IPv6 요청이 들어와 Connection Fail이 발생한다는 내용이다.

댓글에는 대부분의 Api Client가 IPv6로 SYN을 보내고 IPv4 Connection 하고 있으며 이를 해결하기 위해선 ServerSocket 생성자에 바인드에 사용할 IP를 명시적으로 작성해주라는 조언이 남겨져 있었다.


아니, 이렇게까지 처리를 해줘야 한다고? 그럼 다른 웹 서버 프레임워크는 어떻게 동작하는거지? 

스프링 프레임워크의 동작을 한번 살펴보자.

아.. 얘네도 명시적으로 IP를 지정해주는구나.. 근데 localhost로 지정해주네? 이건 InetAddress.getByName 메소드가 시스템에서 사용하는 IP 버전을 가져오게끔 설정되어 있기 때문이다. 즉 localhost의 IPv4에 대응되는 127.0.0.1으로 설정되는 것이다.

그래서 나도 내 서버의 구현을 위와 같은 형태로 수정했고, 정상적으로 IPv4에 대응되는 요청만 받아낼 수 있었다.


추측으로는 IPv6로 SYN을 보내 연결이 가능한지 확인한 후 가능하다면 IPv6로 연결하고 그렇지 않다면 IPv4로 연결하는 것 같다.

그래도 확실하지 않고 여전히 의문이기에 관련된 내용을 Postman App Support Issues에 질문으로 작성해두었다. 언젠가 답변이 도착하면 업데이트 해두려 한다.

https://github.com/postmanlabs/postman-app-support/issues/10221

 

When i requested to 'localhost' host, the Postman sends SYN to IPv6, and then connects to IPv4. · Issue #10221 · postmanlabs/p

Is there an existing issue for this? I have searched the existing issues Describe the Issue When I send a requested to 'localhost' host, it attempts to connect without having a Request Head...

github.com

 

반응형

'Web' 카테고리의 다른 글

localhost는 IPv6와 IPv4 어떤걸로 동작할까?  (0) 2021.08.16
잘못된 내용이 있다면, 다른분들에게 전달되지 않게끔 댓글로 지적 부탁드립니다!

+ Recent posts