반응형

Context

DI에 대한 이야기를 하기 전, 의존성 그 자체에 대한 이야기를 먼저 해보자.

의존성은 무엇이고, 언제 발생하는가?

  • 바리스타가 커피를 만들기 위해선 커피 머신의 협력이 필요하다. 즉 바리스타의 '커피 만들기' 라는 행위는 커피 머신에 의존적이다.
  • 어떠한 애플리케이션은 작업을 처리하기 앞서 설정 값을 참조한다. 이 때 애플리케이션은 설정 값에 의존적이다.

두 번째 예시를 코드로 옮겨보면 다음과 같다. Calculator는 CalculatorConfig의 값에 따라 동작 여부를 결정한다.

data class CalculatorConfig(
    val name: String,
    val status: Boolean = true
)

class Calculator {
    fun isAvailable(): Boolean {
        val config = CalculatorConfig("First Calculator", true)
        return config.status
    }
}

이 코드는 동작하는데 전혀 문제가 없다. 하지만 테스트 코드를 작성하거나 실제로 비즈니스 로직을 개발하다보면 문제가 있음을 쉽게 알 수 있다.

강결합으로 인해 발생하는 문제

위 예시에서 CalculatorConfig는 isAvailable() 함수의 로컬 변수로 선언되어 참조되고 있다. Config의 값이 isAvailable() 함수에 강하게 결합되어 있는 것이다.

특정 함수에 강결합이다 보니 값을 변경하기 위해선 함수 자체를 수정해야 하며, 같은 Calculator 클래스 내의 다른 함수에서도 사용할 수 없게 된다.

그리고 이 상황에서는 테스트 코드를 작성하기도 어렵다. 아래는 Config의 status가 true이면 true를 반환하고, false이면 false를 반환하는지 확인하는 테스트코드이다. 하지만 지금은 이 중 하나는 무조건 실패하게 되어있다.

class DiExampleTest {
    private val calculator = Calculator()

    @Test
    fun `config 의 status 가 false 이면 false 를 리턴한다`() {
        // when
        val actual = calculator.isAvailable()

        // then
        expectThat(actual) isEqualTo false
    }

    @Test
    fun `config 의 status 가 true 이면 true 를 리턴한다`() {
        // when
        val actual = calculator.isAvailable()

        // then
        expectThat(actual) isEqualTo true
    }
}

이 테스트를 통과하려면 Config 인스턴스를 isAvailable() 함수 안에서 만드는 것이 아닌, 외부에서 만들어 전달해주게끔 바꿔야 한다. 외부에서 isAvailable() 함수로 Config 인스턴스를 전달해주는 방법으로는 두 가지가 있다.

  • Calculator 클래스의 생성자로 전달
  • isAvailable() 함수의 인자로 전달

이 중 두 번째 방법으로 리팩토링을 해보겠다.

data class CalculatorConfig(
    val name: String,
    val status: Boolean = true
)

class Calculator {
    fun isAvailable(config: CalculatorConfig): Boolean {
        return config.status
    }
}

// ....

class DiExampleTest {
    private val calculator = Calculator()

    @Test
    fun `config 의 status 가 false 이면 false 를 리턴한다`() {
        // then
        val config = CalculatorConfig("First Calculator", false)

        // when
        val actual = calculator.isAvailable(config)

        // then
        expectThat(actual) isEqualTo false
    }

    @Test
    fun `config 의 status 가 true 이면 true 를 리턴한다`() {
        //then
        val config = CalculatorConfig("First Calculator", true)

        // when
        val actual = calculator.isAvailable(config)

        // then
        expectThat(actual) isEqualTo true
    }
}

외부에서 Config 인스턴스를 생성해 isAvailable() 함수로 전달해줌으로써 두 테스트 모두 통과할 수 있게 되었다. Config 인스턴스를 생성자로 전달 할지, 함수의 인자로 전달 할지는 의존 관계에 따라 달라지니 코드의 흐름에 따라 적절히 선택하면 된다.

DI의 필요성

이처럼 클래스나 함수에서 사용할 인스턴스의 결정권을 자신이 아닌, 외부에 위임함으로써 조금 더 유연한 구조를 갖게하는 방식을 DI라고 말할 수 있다.

유연한 구조를 가진 애플리케이션은 테스트가 용이하며, 최소한의 수정으로 확장할 수 있음을 의미하기도 한다.

하지만 현재의 방식은 외부에서 인스턴스를 직접 생성하여 생성자나 함수의 인자로 전달해줘야 하는 불편함이 존재한다.

이를 극복하고자 IoC(Inversion Of Control) 개념이 등장했으며, 다음에는 IoC의 개념과 Spring Framework의 IoC 동작 방식에 대한 이야기를 써보려 한다.

반응형

'Spring Framework' 카테고리의 다른 글

DI(Dependency Injection)는 왜 필요한가?  (2) 2021.08.29
잘못된 내용이 있다면, 다른분들에게 전달되지 않게끔 댓글로 지적 부탁드립니다!
  1. 잘읽었습니다 2021.09.03 14:37

    안녕하세요 DI 관련해서 읽어본 것 중에 가장 이해에 도움이 됐습니다.
    쉬운 예로 처음 공부하는 분들에게 많은 도움이 될 것 같네요.
    다음 ioc 포스팅도 기다리고 있겠습니다.

    • Aaron 2021.09.04 12:42

      감사합니다 ! 더 좋은 정보 공유드릴 수 있도록 노력하겠습니다.

반응형

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