테스트 더블(Test Doubles)

gimseonjin616·2023년 11월 3일
2
post-thumbnail

안녕하세요! 백엔드 개발자 Kerry 입니다.

오늘은 테스트 코드에 대해 학습하면서 익힌 테스트 더블(Test Double)의 개념에 대해 정리해보려고 합니다.

테스트 더블이란 무엇인가?

테스트 더블은 소프트웨어 개발 과정에서 사용되는 테스트 방법론 중 하나입니다. 이 용어는 영화 산업에서 위험한 장면을 대신 수행하는 스턴트 더블(stunt double)에서 차용한 것입니다. 즉, 테스트 중인 시스템의 일부분이 완전히 준비되지 않았거나 테스트하기 어려운 상황에서 그 대안으로 사용될 수 있는 '가짜' 컴포넌트를 의미합니다. 테스트 더블은 실제 객체의 행동을 모방하며, 테스트가 특정 조건과 상호작용을 쉽게 재현하고 검증할 수 있도록 합니다.

테스트 더블의 종류는 크게 더미 객체(Dummy Objects), 가짜 객체(Fake Objects), 스텁(Stubs), 스파이(Spies), 목(Mock) 등으로 나눌 수 있으며, 각각 테스트의 다른 측면을 지원합니다. 예를 들어, 스텁은 호출에 대한 미리 정의된 응답을 제공하는 반면, 목은 특정 메소드가 호출되었는지, 어떤 인자와 함께 호출되었는지 등을 검증할 때 사용합니다.

<스턴트 배우와 원래 배우>

왜 테스트 더블을 사용하는가?

테스트 더블은 여러 가지 이유로 유용합니다. 첫째로, 실제 객체가 아직 개발되지 않았거나 접근이 불가능한 경우에 대한 테스트를 가능하게 합니다. 이는 특히 큰 시스템이나 마이크로서비스 아키텍처에서 한 컴포넌트를 독립적으로 테스트하고 싶을 때 중요합니다.

둘째로, 테스트 더블을 사용하면 복잡한 환경이나 시나리오를 간단하게 모의할 수 있습니다. 예를 들어, 네트워크 오류, 서버 다운, 응답 지연 등의 상황을 재현하여 테스트 코드가 이러한 상황을 적절히 처리하는지 확인할 수 있습니다.

셋째로, 테스트 실행 속도를 크게 향상시킬 수 있습니다. 실제 데이터베이스나 외부 서비스와의 상호작용 없이 테스트 더블을 사용하면, 테스트 실행에 걸리는 시간을 단축시키고 개발 과정을 더 민첩하게 만듭니다.

마지막으로, 테스트의 독립성을 보장합니다. 테스트 더블을 사용하면 외부 시스템의 상태나 동작에 의존하지 않으므로, 어떤 외부 환경 변화에도 테스트 결과의 일관성을 유지할 수 있습니다.

결국 테스트 더블은 테스트가 더욱 예측 가능하고, 신뢰할 수 있으며, 유지보수가 용이하도록 만드는 중요한 도구입니다. 개발자는 테스트 더블을 적극 활용하여, 품질 높은 소프트웨어를 더 빠르고 효과적으로 개발할 수 있습니다.

테스트 더블의 종류

테스트 더블은 아래와 같이 크게 다섯 가지 유형으로 분류됩니다:

Dummy :

  • 이들은 실제로는 사용되지 않지만, 파라미터 리스트를 채우기 위해 필요한 객체들입니다. 그들은 단지 인터페이스를 충족시키는 데 사용되며, 실제 작동하지는 않습니다.

Fake :

  • Fake 객체들은 실제 객체의 간단한 버전으로, 예를 들어, 가벼운 데이터베이스 서버나 간단한 로직을 가진 컴포넌트로 작동할 수 있습니다. Fakes는 실제 동작을 가지지만, 테스트를 위해 설계된 간소화된 코드입니다.

Stubs:

  • Stubs는 테스트 중에 호출되면 미리 준비된 응답을 제공합니다. 예를 들어, 특정 메소드 호출에 대한 반환 값을 설정할 수 있으며, 외부 서비스나 컴포넌트를 대체하는 데 사용됩니다.

Spies:

  • Spies는 Stubs와 유사하지만, 호출되었을 때의 정보를 기록합니다. 그래서 테스트에서 그 기록을 검사할 수 있어서 어떤 함수가 어떻게 호출되었는지, 몇 번 호출되었는지 등을 확인할 수 있습니다.

Mocks:

  • Mocks는 Spies와 비슷하지만 더 엄격합니다. Mocks는 예상된 호출의 명세(specification)를 정의하며, 테스트에서 이 명세가 충족되지 않으면 테스트가 실패합니다. 이를 통해 객체 간의 상호작용을 정확히 검증할 수 있습니다.

각각의 테스트 더블 유형은 특정 테스트 시나리오에 적합하며, 테스트의 목적에 따라 선택되어야 합니다.

Test Double의 실제 사용 예시

Dummy

Dummy는 이름 그대로 '가짜' 객체입니다. 테스트를 작성할 때, 종종 실제로는 사용되지 않는 객체가 필요한 경우가 있습니다. 이러한 객체들은 테스트 대상 코드에서 요구는 하지만, 실제 실행 로직에는 영향을 주지 않는 파라미터나 객체들입니다. 이때 사용되는 것이 Dummy입니다.

아래의 예시에서는 JUnit5를 사용하여 Dummy를 활용하는 방법입니다. 우리가 테스트하고자 하는 EmailService 클래스에는 Logger 인터페이스가 필요하지만, 테스트에서는 실제 로깅을 수행할 필요가 없을 때 Dummy를 사용할 수 있습니다.


import org.junit.jupiter.api.Test;

// Logger 인터페이스는 실제 구현이 필요 없으므로 Dummy로 대체
class DummyLogger implements Logger {
    @Override
    public void log(String message) {
        // 아무 것도 하지 않는다.
    }
}

class EmailService {
    private Logger logger;

    public EmailService(Logger logger) {
        this.logger = logger;
    }

    public void sendEmail(String message) {
        // 로깅은 이 메서드의 핵심 기능이 아니므로 Dummy Object를 사용한다.
        logger.log(message);
        // 이메일 보내는 로직...
    }
}

public class EmailServiceTest {

    @Test
    void testSendEmailWithDummyLogger() {
        // Given
        Logger dummyLogger = new DummyLogger();
        EmailService emailService = new EmailService(dummyLogger);

        // When
        emailService.sendEmail("Hello, World!");

        // Then
        // 여기서는 EmailService가 실제로 이메일을 보냈는지 확인하면 되고, 로그가 기록되었는지는 중요하지 않다.
        // 따라서 Dummy Logger를 사용하여 로거의 의존성을 해결한다.
    }
}

위 코드에서 DummyLogger는 Logger 인터페이스를 구현하지만, log 메서드 내부는 비어 있습니다. 이는 EmailService의 sendEmail 메서드에서 로그 기능이 주된 기능이 아니기 때문입니다. EmailServiceTest에서는 실제 로깅을 검증할 필요가 없으므로, DummyLogger를 주입하여 의존성을 충족시키고 테스트의 진행을 돕습니다.

이처럼 Dummy는 테스트가 깔끔하게 진행될 수 있도록 돕고, 불필요한 부분에 대한 구현 없이 의존성을 간단히 해결할 수 있습니다.

Fake

Fake는 실제 동작을 간단한 방식으로 모방하는 테스트 더블입니다. 이들은 테스트 환경에서 복잡한 로직이나 외부 서비스의 실제 구현을 대체하기 위해 사용됩니다. 주요 역할은 외부 인터페이스의 가벼운 구현을 제공하여 테스트가 더 빠르고 예측 가능하도록 만드는 것입니다. 이를 통해 데이터베이스 호출, 네트워크 요청, 파일 시스템 접근과 같이 비용이 많이 드는 작업을 모방할 수 있습니다.

아래의 예시에서는 JUnit5를 사용하여 Fake 객체를 사용하는 방법입니다. 테스트 하고자 하는 부분은 결제가 제대로 수행 되는 지입니다. 그러나 PaymentGateway를 그대로 사용하게 되면 네트워크를 통해 실제 결제 요청이 발생합니다. 따라서 이 부분을 Fake 객체를 사용해서 대체할 수 있습니다.

// 우선, 인터페이스를 정의
public interface PaymentGateway {
    PaymentResponse requestPayment(PaymentRequest request);
}

// Fake PaymentGateway 구현체를 생성
public class FakePaymentGateway implements PaymentGateway {
    @Override
    public PaymentResponse requestPayment(PaymentRequest request) {
        // 실제 결제 과정을 모방하는 간단한 로직
        if ("valid".equals(request.getCreditCardNumber())) {
            return new PaymentResponse(true);
        } else {
            return new PaymentResponse(false);
        }
    }
}

// 테스트 클래스에서 Fake 객체를 사용

import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;

public class PaymentServiceTest {
    @Test
    public void whenPaymentIsProcessedUsingFake_thenSuccess() {
        // Given
        PaymentGateway fakePaymentGateway = new FakePaymentGateway();
        PaymentService paymentService = new PaymentService(fakePaymentGateway);
        PaymentRequest request = new PaymentRequest("valid");

        // When
        PaymentResponse response = paymentService.processPayment(request);

        // Then
        assertTrue(response.isSuccessful());
    }
}

//실제 서비스 클래스

public class PaymentService {
    private final PaymentGateway paymentGateway;

    public PaymentService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public PaymentResponse processPayment(PaymentRequest request) {
        // 결제 요청을 처리합니다. 실제 환경에서는 여기에 복잡한 비즈니스 로직이 포함될 수 있습니다.
        return paymentGateway.requestPayment(request);
    }
}

이 예시에서 FakePaymentGateway는 실제 결제 과정을 모방하여, 신용카드 번호가 "valid"라는 문자열일 때만 성공적인 결제를 나타내는 PaymentResponse를 반환합니다. 이는 실제로 네트워크를 통한 결제 요청을 하지 않고, 간단하게 테스트 환경에서 결제 요청의 성공과 실패를 테스트 할 수 있게 됐습니다.

Stub

Stub 객체는 테스트 중인 코드에 대한 간단한 반응을 제공하는 데 사용됩니다. 일반적으로 테스트가 필요로 하는 데이터를 제공하거나 특정 메소드 호출이 예상대로 이루어지는지를 검증하는 데 사용됩니다. 이들은 테스트가 예측 가능한 방식으로 실행될 수 있도록 특정 상황에서 미리 정의된 응답을 반환합니다. 일반적으로 외부 시스템이나 복잡한 로직이 필요한 경우에 Stub을 사용하며 단위 테스트에서 외부 의존성을 제거하여 테스트가 해당 단위에만 집중할 수 있도록 합니다.

아래 예시는 JUnit5를 사용하여 Stub 객체를 사용하는 방법입니다. ExternalService는 복잡한 연산을 수행하는 가정된 외부 서비스를 나타냅니다. SystemUnderTest는 이 외부 서비스에 의존하는 시스템입니다. 테스트에서는 Mockito 프레임워크를 사용하여 ExternalService의 Stub을 생성하고, complexOperation 메서드가 호출되었을 때 "Stubbed Response"라는 미리 정의된 응답을 반환하도록 설정합니다. 그런 다음 SystemUnderTest의 performAction 메서드를 실행하고 결과가 예상치와 일치하는지 검증합니다.

import org.junit.jupiter.api.Test;
import static org.mockito.Mockito.*;

// 가정된 외부 서비스 인터페이스
interface ExternalService {
    String complexOperation();
}

// 테스트 대상 클래스
class SystemUnderTest {
    private ExternalService externalService;

    public SystemUnderTest(ExternalService service) {
        this.externalService = service;
    }

    public String performAction() {
        return "Result: " + externalService.complexOperation();
    }
}

// 테스트 클래스
public class StubExampleTest {

    @Test
    public void testPerformAction_UsesStubbedService() {
        // Stub 객체 생성
        ExternalService stubService = mock(ExternalService.class);

        // Stub 객체에 대한 행동 정의: complexOperation() 메서드 호출 시 "Stubbed Response"를 반환하도록 설정
        when(stubService.complexOperation()).thenReturn("Stubbed Response");

        // 테스트 대상 객체 생성, Stub 객체 주입
        SystemUnderTest systemUnderTest = new SystemUnderTest(stubService);

        // performAction() 실행
        String result = systemUnderTest.performAction();

        // assert를 사용하여 예상되는 결과 확인
        assertEquals("Result: Stubbed Response", result);

        // Stub 객체의 complexOperation()이 한 번 호출되었는지 검증
        verify(stubService).complexOperation();
    }
}

Spy

Spy 객체는 테스트 대상의 실제 구현을 사용하면서 호출 정보를 기록하는 데 사용됩니다. 즉, Spy는 실제 객체의 동작을 감시하는 데에 초점을 둡니다. 이를 통해 실제 메소드가 얼마나 자주 호출되었는지, 어떤 매개변수로 호출되었는지 등의 정보를 알 수 있으며, 필요한 경우 실제 메소드의 반환 값을 변경하거나 특정 메소드 호출을 가로챌 수도 있습니다.

아래 예시는 JUnit5를 사용해서 Spy 객체를 사용하는 예시입니다. 예시에서 Calculator 클래스의 add 메소드는 두 개의 정수를 더하는 간단한 메소드입니다. CalculatorTest 클래스에서는 Calculator의 인스턴스를 Spy로 만들어 메소드 호출을 감시합니다. 테스트 메소드 testAddMethod는 add 메소드를 호출하고, Mockito의 verify 메소드를 사용하여 실제로 해당 메소드가 원하는 매개변수로 호출되었는지, 그리고 몇 번 호출되었는지를 검증합니다. 그 후 실제 반환 값이 예상과 일치하는지 확인합니다.

import org.junit.jupiter.api.Test;
import org.mockito.Spy;
import org.mockito.Mockito;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.junit.jupiter.api.Assertions.assertEquals;

class Calculator {
    public int add(int a, int b) {
        return a + b;
    }
}

public class CalculatorTest {
    @Spy
    Calculator calculator = new Calculator();

    @Test
    void testAddMethod() {
        // 실제 메소드 호출
        int result = calculator.add(10, 20);

        // 검증하기: 메소드가 실제로 호출되었는지
        verify(calculator).add(10, 20);
        // 검증하기: 메소드가 정확히 한 번 호출되었는지
        verify(calculator, times(1)).add(10, 20);

        // 반환 값 검증
        assertEquals(30, result);
    }
}

Spy 객체의 사용은 테스트 대상이 내부적으로 어떻게 작동하는지 이해하고, 외부로부터의 인터랙션을 정확히 기록하는 것에 유용합니다. 하지만 주의할 점은 Spy 객체가 실제 메소드를 호출하기 때문에, 사이드 이펙트가 있을 수 있다는 것입니다.

Mock

Mock 객체는 실제 객체를 모방한 객체로, 테스트 환경에서 실제 의존성을 대체하기 위해 사용됩니다. 이들은 주로 외부 시스템과의 상호작용이나 어떤 서비스의 응답을 모방할 때 유용하게 쓰입니다. Mock 객체는 특정 메소드 호출에 대한 기대값을 설정할 수 있게 해주며, 실제 로직을 수행하지 않기 때문에 테스트의 속도를 높이고, 여러 복잡한 시나리오를 단순화하여 테스트할 수 있도록 도와줍니다.

아래는 JUnit5와 Mockito라는 프레임워크를 사용한 Mock 객체의 간단한 예시입니다. UserServiceTest 클래스는 UserService의 테스트를 위한 것입니다. @Mock 어노테이션을 사용하여 UserRepository의 Mock 객체를 생성하고, @InjectMocks로 실제 UserService에 이 Mock 객체를 주입합니다. 테스트 메소드에서는 when-thenReturn 구문을 사용하여 findById 메소드가 호출될 때 Mock 객체가 반환할 객체를 설정합니다. assertThrows를 사용하여 예외 상황도 테스트할 수 있습니다.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

interface UserRepository {
    User findById(Long id);
}

class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User getUserById(Long id) {
        return userRepository.findById(id);
    }
}

@ExtendWith(MockitoExtension.class)
public class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @Test
    public void shouldReturnUserWhenUserIdIsProvided() {
        // Given
        Long userId = 1L;
        User mockUser = new User(userId, "John Doe", "john@example.com");
        when(userRepository.findById(userId)).thenReturn(mockUser);

        // When
        User result = userService.getUserById(userId);

        // Then
        assertNotNull(result);
        assertEquals("John Doe", result.getName());
        verify(userRepository).findById(userId);
    }
    
    @Test
    public void shouldThrowExceptionWhenUserNotFound() {
        // Given
        Long userId = 2L;
        when(userRepository.findById(userId)).thenThrow(new UserNotFoundException("User not found"));

        // When & Then
        assertThrows(UserNotFoundException.class, () -> userService.getUserById(userId));
        verify(userRepository).findById(userId);
    }
}

Stub vs Mock(상태 검증 vs 행위 검증)

지금까지 정리한 내용을 보면 Stub와 Mock의 역할이 겹치는 것으로 보입니다. 둘 다 실제 객체 대신 테스트 객체로 동작할 수 있으며 내부 행위에 대해 개발자가 관리할 수 있습니다. 그러나 둘은 사용하기 위한 역할이 다르기 때문에 주의해서 사용해야합니다. 그리고 역할의 차이를 알기 위해선 상태 검증과 행위 검증이라는 개념을 알아야합니다.

상태 검증 (State Verification)

상태 검증은 코드가 특정 작업을 수행한 후에 시스템의 상태가 예상대로 변경되었는지를 확인합니다. 예를 들어, 물건을 장바구니에 추가하는 기능을 테스트한다면, 상태 검증은 실제로 장바구니에 해당 물건이 추가되었는지를 검사합니다. 이 방식은 객체의 상태가 올바르게 유지되고 변화하는지 확인하는데 주로 사용됩니다.

예시를 들어보면, 당신이 은행 계좌에 돈을 입금한 후 계좌의 잔액을 확인하는 것과 유사합니다. 입금 동작 후에 계좌 잔액이 올바르게 증가했는지를 확인하는 것입니다.

행위 검증 (Behavior Verification)

행위 검증은 시스템의 상태보다는 특정 작업을 수행하는 과정에서 발생하는 행위에 더 관심을 둡니다. 이는 객체가 예상대로 다른 객체와 상호작용(예: 메소드 호출)하는지를 검증하는데 사용됩니다. Mock 객체가 주로 이 방식으로 사용됩니다.

예를 들어, 메시지를 보내는 기능을 테스트할 때, 실제로 메시지가 전송되었는지를 확인하기보다는 메시지 서비스의 send 메소드가 적절한 매개변수와 함께 호출되었는지를 검증합니다. 마치 청소기가 정해진 경로를 따라 청소했는지 확인하는 것처럼, 청소기가 특정 지점에서 청소 기능을 '실행했는지'가 중요합니다.

Mock과 Stub의 역할 차이

Mock Objects는 주로 행위 검증에 사용됩니다. Mock 객체를 사용하는 테스트는 대상 코드가 다른 컴포넌트와 예상대로 상호작용하는지를 검증합니다. 예를 들어, 어떤 서비스의 메소드가 호출되었는지, 특정 이벤트가 발생했는지 등의 상호작용을 검사합니다.

Stub Objects는 상태 검증에 더 자주 사용됩니다. Stub는 예상된 결과를 반환하거나 특정 상태를 시뮬레이션하여 대상 코드가 이 상태를 기반으로 올바르게 작동하는지 확인할 수 있도록 합니다.

상태 검증과 행위 검증은 서로 보완적이며, 때로는 동일한 테스트 케이스 내에서 함께 사용될 수 있습니다. 예를 들어, 특정 메소드의 호출 결과로 객체의 상태가 변경되었는지 확인하는 경우에는 둘 다 사용됩니다. 이 두 방법을 이해하고 적절히 활용하면, 보다 견고하고 신뢰할 수 있는 소프트웨어 테스트를 구현할 수 있습니다.

나만의 테스트 더블 선택 가이드

어떤 테스트 더블을 언제 사용해야 하는가?

테스트 더블은 강력하지만 잘못 사용하면 오버엔지니어링이나 테스트 복잡성을 높힙니다. 따라서 아래 표와 같이 각 테스트 더블의 사용 시점을 정리하여 저만의 사용 가이드를 만들었습니다.

테스트 더블 타입사용 시점사용 예시
Dummy객체가 필요하지만 실제 사용되지 않을 때함수 인자로만 사용되는 객체
Fake실제 동작하는 간단한 버전이 필요할 때인메모리 데이터베이스 구현
Stubs특정 결과를 반환해야 할 때API 호출 결과를 하드코딩
Spy메소드 호출을 감시해야 할 때이벤트 리스너가 호출되는지 확인
Mock복잡한 상호작용을 검증할 때메소드 호출의 순서, 횟수, 인자 검증

테스트 시나리오에 맞는 테스트 더블 선택하기

위의 가이드라인 표을 기반으로 간단한 예시 시나리오를 정리해봤습니다.

심플한 가입 양식 검증

시나리오: 사용자 인터페이스에서의 간단한 이메일 유효성 검증 테스트.

  • 적합한 더블: Stub.
  • 이유: 이메일 검증 로직이 외부 서비스를 호출하는 경우, 이 서비스의 응답을 Stub을 사용하여 고정된 유효성 결과로 설정합니다.

이벤트 시스템 감사:

시나리오: 사용자 이벤트가 데이터베이스에 올바르게 기록되는지 확인하는 테스트.

  • 적합한 더블: Spy.
  • 이유: 데이터베이스에 대한 호출이 실제로 발생했는지, 그리고 올바른 데이터로 이루어졌는지를 Spy를 통해 확인할 수 있습니다.

주문 처리 플로우 검증:

시나리오: 주문 서비스가 여러 시스템과의 복잡한 상호작용을 거쳐 주문을 처리하는 플로우 테스트.

  • 적합한 더블: Mock.
  • 이유: 다수의 메소드 호출이 특정 순서와 조건으로 발생해야 하므로 Mock을 사용하여 이러한 상호작용을 정확히 검증할 수 있습니다.

주의해야할 점

테스트 더블은 테스트 코드에서 빼놓을 수 없는 중요한 요소입니다. 단위 테스트가 특정한 조건과 상호작용을 정확히 시뮬레이션할 수 있도록 해줌으로써, 실제 운영 환경에서 발생할 수 있는 문제들을 미리 포착하고, 견고한 코드를 작성할 수 있도록 해줍니다. 그러나 테스트 더블이 강력한 만큼 사용할 때 몇 가지 주의해야할 점이 있습니다.

  • 과도한 모킹 주의: 테스트 더블, 특히 모킹은 강력하지만 과도하게 사용될 경우, 실제 코드의 동작과 동떨어진 테스트를 만들어낼 수 있습니다. 이는 코드의 실제 동작을 정확히 반영하지 못하는 오해를 불러일으킬 수 있으므로, 실제로 필요한 경우에만 사용해야 합니다.
  • 현실성 있는 테스트 데이터 사용: 테스트 더블이 반환하는 데이터는 가능한 한 실제 운영 데이터와 유사해야 합니다. 현실에서 발생할 수 없는 값이나 상황을 시뮬레이션하는 것은 잘못된 테스트 결과를 낳을 수 있습니다.
  • 테스트 유지보수 고려: 테스트도 유지보수가 필요한 코드입니다. 테스트 더블이 너무 복잡하다면 테스트 코드의 가독성과 유지보수성이 저하될 수 있습니다. 이때 테스트 코드를 업데이트하는 데 드는 노력은 테스트가 주는 이점을 상쇄할 수 있으므로, 테스트 더블의 구현은 심플하고 명확하게 유지해야 합니다.

마무리

지금까지 테스트 더블에 대해 학습한 내용을 정리했습니다. 테스트 더블이 어떤 내용이고 어떤 상황에서 어떻게 사용해야할 지 간단한 가이드라인을 세워봤습니다. 테스트에 대해 공부하면 할수록 점점 더 어려워지지만 너무 재미있고 어떻게 마무리 해야할지 모르겠어서 그냥 이대로 끝내겠습니다!!

profile
to be data engineer

1개의 댓글

comment-user-thumbnail
2025년 1월 21일

좋은 글 감사합니다!

답글 달기

관련 채용 정보