헥사고날 아키텍처로의 전환기

공병주(Chris)·2023년 3월 8일
0
post-thumbnail

헥사고날 아키텍처로의 전환기

2023 글로벌미디어학부 졸업 작품 프로젝트 Dandi의 아키텍처를 레이어드 아키텍처에서 헥사고날 아키텍처로 변경하면서 느낀 점을 공유하는 글입니다. 만들면서 배우는 클린 아키텍처 도서를 통해 학습했습니다.

이전의 우테코 프로젝트에서는 레이어드 아키텍처를 사용했습니다. 과거, 주변 지인에게 헥사고날 아키텍처에 대해 들었고 우아콘 영상을 통해 헥사고날 아키텍처에 대한 호기심이 생겼습니다.

이전 프로젝트와 똑같은 프로젝트를 하는 것은 유의미하지 않다고 생각했습니다. 따라서, 헥사고날 아키텍처를 적용해보려고 합니다.

사실, 헥사고날 아키텍처를 적용할 만한 사이즈의 프로젝트인가? 라는 질문에는 그렇지 않다는 대답을 하겠지만, 어떤 아키텍처인지 궁금하고 어떤 장단점이 존재할지 느껴보고 싶습니다.

기본 개념

헥사고날에 대해 다루는 글들이 많아 따로 간단하게만 설명하려 합니다.

제가 생각하기에 헥사고날 아키텍처의 가장 중요한 2개의 기본 컨셉은 아래와 같습니다.

  1. 의존성은 Core 바깥에서 Core 안쪽으로 흐른다. Core에서 외부로 향하는 의존성은 존재하지 않는다.
  2. 전통적인 레이어드 아키텍처의 DB 주도적인 설계에서 벗어난다.
  3. 변경 가능한 지점, 외부 환경에 의존하는 부분을 port(인터페이스) - adapter(구현체) 패턴으로 의존성을 역전시킨다.

아래에서부터는 전환에서의 느낀점을 기록해두었습니다.

1. 변경 감지 사용하지 못함

영속성과 관련된 부분(프로젝트에선 JPA)을 port - adapter로 분리합니다. core에는 PersistencePort를 선언하고 core 외부에서 PersistenceAdapter를 구현합니다.

따라서, core에는 DB와 관련된 의존성이 하나도 존재하지 않습니다.

따라서, 더 이상 domain이 JPA Entity가 아니기 때문에 변경감지를 사용하지 못하고 out port를 통해 DB에 update 쿼리가 실행되도록 해줘야합니다.

// 기존 레이어드 아키텍처 - Member는 JPA Entity
@Service
public class MemberService {

    private final MemberRepository memberRepository;

    // ...

    @Transactional
    public void updateNickname(Long memberId, NicknameUpdateRequest nicknameUpdateRequest) {
        Member member = findMember(memberId);
        member.updateNickname(nicknameUpdateRequest.getNewNickname());
    }
    // ...
}
// 변경된 헥사고날 아키텍처 - Member는 POJO
@Service
public class MemberService implements MemberUseCase {

    private final MemberPersistencePort memberPersistencePort;

    // ...

    @Override
    @Transactional
    public void updateNickname(Long memberId, NicknameUpdateCommand nicknameUpdateCommand) {
        Member member = findMember(memberId);
        memberPersistencePort.updateNickname(member.getId(), nicknameUpdateCommand.getNickname());
    }
    // ...
}

변경 감지는 조금 더 도메인 중심적인 방식이라고 생각합니다. 하지만, 더이상 도메인와 JpaEntity를 하나로 가져가지 않기 때문에 변경감지 사용이 불가능합니다.

과거에, 직접 실행할 쿼리를 정할 수 없고 쿼리가 실행되는 시점을 제어할 수 없다는 점때문에, 어떤 곳에서는 변경감지를 사용하고 어떤 곳에서는 변경감지를 사용하지 않았습니다. 때문에, 혼란을 야기할 때가 있었는데요.

모든 update 로직을 PersistencePort의 update 메서드를 실행하도록 통일함으로써, 보다 이해하기 명확한 코드가 되었다는 생각이 듭니다.

2. web → application 사이의 DTO와 입력 유효성 검증

테스트를 어떻게 할지에 대해 고민하면서 특정 객체에 대한 정의를 내린 과정입니다.

레이어드 아키텍처에서는 presentation(controller) 레이어에서 Json → Java로 변환되는 RequestDto와 application(service)에서 web의 요청을 받아들이는 Request 객체가 동일한 데이터를 필요로 하기 때문에, presentation 계층의 Request Dto와 application의 Request Dto를 동일한 객체로 사용해왔습니다.

이를 헥사고날 아키텍처에 적용해도 controller에서 Json 역직렬화하는 객체와 service에서 요청을 처리하기 위해 필요한 값은 동일하기 때문에 헥사고날 아키텍처에서도 동일한 객체로 처리를 해보았습니다.

UseCase의 dto를 UseCase에서만 사용한다면 아래와 같은 구조가 나올 것입니다.

public class LocationUpdateRequest {

    private final Double latitude;

    private final Double longitude;

    public LocationUpdateRequest(Double latitude, Double longitude) {
        this.latitude = latitude;
        this.longitude = longitude;
    }

하지만, 이를 web 계층에서도 함께 사용하기 위해 Json 역직렬화 코드를 위한 코드로 변경됩니다.

public class LocationUpdateRequest {

    private Double latitude;

    private Double longitude;

    public LocationUpdateRequest() {
    }

    public Double getLatitude() {
        return latitude;
    }

    public Double getLongitude() {
        return longitude;
    }
}

실제로 application에서는 web에서 Json → Dto로 매핑된 객체를 그대로 받아서 사용하기 때문에, 아래와 같은 생성자가 필요가 없습니다.

public LocationUpdateRequest(Double latitude, Double longitude) {
    this.latitude = latitude;
    this.longitude = longitude;
}

따라서, application을 테스트하기가 힘듭니다. 요청 객체 값을 할당하기 위해서는 Reflection을 사용해야 하기 때문입니다.

또한, 헥사고날 아키텍처에서는 도메인을 위한 입력에 대한 검증은 단순 입력 검증일 뿐, 비즈니스 규칙에서 제외시킵니다.

따라서, web의 요청 값을 담는 UseCase(application)의 port.in의 객체에서 입력에 대한 검증이 이뤄져야 합니다. 비즈니스 규칙은 도메인의 현재 상태에 의거하는 것입니다. 반면, 입력 검증은 단순 구문상의 검증만을 하는 것입니다.

여러 UseCase에 따라 다른 유효성 검증 규칙들이 하나의 도메인에 존재한다면, 또한 유효성 검증 말고도 많은 비즈니스 로직이 혼재한다면 도메인 규칙을 파악하는데 어려움이 있을 것입니다.

따라서, 이들의 관심사를 분리하는 것입니다. 현재 상태에 의거한 검증은 도메인으로, 단순 입력에 대한 검증은 요청을 받아오는 객체로.

하지만, 아래처럼 UseCase와 Web의 Dto를 하나로 사용하면 생성자를 통해서 값을 초기화하지 않고 @Notnull과 같은 선언적 방식으로 검증 하기 때문에, 입력 검증 테스트를 진행하기가 까다롭습니다. 입력 유효성 검증이라는 중요한 로직이 존재하는데 말입니다. 따라서 이를 어떻게 해결해야할 것인가에 대한 고민을 해보았습니다.

public class LocationUpdateRequest {

    @Min
    @Max
    private Double latitude;

    @Min
    @Max
    private Double longitude;

    public LocationUpdateRequest() {
    }

    public Double getLatitude() {
        return latitude;
    }

    public Double getLongitude() {
        return longitude;
    }
}

어떻게 해야할까?

1. 테스트 생성자 열어주기

첫번째로 떠오른 생각은 DTO에 Test를 위한 생성자를 열어주는 것입니다. Json → Java 역직렬화가 Reflection이라는 기술을 사용해서 객체를 생성한다는 특이점 때문에 이런 문제가 발생한다고 생각합니다.

하지만, 테스트를 위해서 프로덕션 코드가 변경되는 것은 주객이 전도된 것이라 선호하지 않습니다. 또한, Reflection의 특성때문에 생성자에서 값에 대한 검증을 하는 것이 아니라, javax.validation의 어노테이션들을 통해 값을 검증하기 때문에 테스트를 위한 생성자를 열어준다고 해도 본질적인 문제는 해결할 수 없습니다.

2. web과 application의 DTO를 분리

사실 가장 쉬우면서 정석적인 방법이라고 생각합니다. 계층간의 값을 전달하는데는 계층 마다의 dto를 사용하는 것이 정석이라고 생각하기 때문입니다.

web의 dto에서 역직렬화한 값을 usecase의 dto 생성자로 넘겨서 생성자에서 검증을 진행하면 된다.

하지만, 요청을 처리하기 위한 client → web, web → usecase의 request dto를 모두 만들어줘야하기 때문에 기존의 방식보다 많은 dto를 관리해야할 수 있다.

위에서 기존의 방식보다 많은 dto를 관리해야 한다는 것에 부담을 느끼는 이유는, 동일한 일을 할 수 있는데 2개의 객체로 관리를 해야하기 때문이라고 생각합니다. 과연 동일한 일을 하는 것일까?에 대한 고민을 해보았습니다.

client → web 과정에서의 DTO는 Json을 Java 객체로 매핑하여 web으로 전달하는 역할을 수행합니다. web → application의 DTO는 둘 사이에서 값을 전달하는 역할을 가집니다. 거기에 더해 web에서 들어온 값의 입력 유효성 검증 역할도 합니다. 제 개인적인 생각으로는 web → application의 DTO를 더이상 DTO로 정의할 수 없다는 생각이 들었습니다. 해당 객체에서 유효성 검증에 대한 책임을 지녔기 때문입니다.

따라서, 저는 web과 application의 더 이상 client → web, web → application 사이의 객체를 하나로 가져가지 않기로 결정했습니다.

3. 입력 검증과 도메인

이전에는 아래처럼 domain 객체에 규칙들을 검증하는 코드들이 많이 존재했습니다.

@Embeddable
public class PushNotificationTime {

    private static final int MINUTE_UNIT = 10;

    // ...

    @Column(name = "push_notification_time")
    private LocalTime value;

    private PushNotificationTime() {
    }

    private PushNotificationTime(LocalTime value) {
        validateZeroSecond(value);
        this.value = value;
    }

    private void validateZeroSecond(LocalTime value) {
        if (value.getSecond() != 0) {
            throw new IllegalArgumentException("푸시 알림 시간은 10분 단위입니다.");
        }
    }

    public static PushNotificationTime from(LocalTime value) {
        validateMinuteUnit(value);
        return CACHE.computeIfAbsent(value, ignored -> new PushNotificationTime(value));
    }

    private static void validateMinuteUnit(LocalTime value) {
        if (value.getMinute() % MINUTE_UNIT != 0) {
            throw new IllegalArgumentException("푸시 알림 시간은 10분 단위입니다.");
        }
    }

    // ...
}

하지만, 아래처럼 입력에 대한 검증을 application의 port.in에 존재하는 객체에서 진행함으로써, 도메인에서 중요한 로직들이 많이 빠졌습니다.

package dandi.dandi.pushnotification.application.port.in;

import dandi.dandi.common.validation.SelfValidating;
import java.time.LocalTime;
import javax.validation.constraints.NotNull;

public class PushNotificationTimeUpdateCommand extends SelfValidating<PushNotificationTimeUpdateCommand> {

    private static final String NULL_PUSH_NOTIFICATION_TIME_EXCEPTION_MESSAGE = "푸시 알림 변경 시간이 존재하지 않습니다.";
    private static final String INVALID_PUSH_NOTIFICATION_TIME_UNIT_EXCEPTION_MESSAGE = "푸시 알림 시간은 10분 단위입니다.";
    private static final int MINUTE_UNIT = 10;

    @NotNull
    private final LocalTime pushNotificationTime;

    public PushNotificationTimeUpdateCommand(LocalTime pushNotificationTime) {
        this.pushNotificationTime = pushNotificationTime;
        this.validateSelf(NULL_PUSH_NOTIFICATION_TIME_EXCEPTION_MESSAGE);
        validateZeroSecond();
        validateMinuteUnit();
    }

    private void validateZeroSecond() {
        if (pushNotificationTime.getSecond() != 0) {
            throw new IllegalArgumentException(INVALID_PUSH_NOTIFICATION_TIME_UNIT_EXCEPTION_MESSAGE);
        }
    }

    private void validateMinuteUnit() {
        if (pushNotificationTime.getMinute() % MINUTE_UNIT != 0) {
            throw new IllegalArgumentException(INVALID_PUSH_NOTIFICATION_TIME_UNIT_EXCEPTION_MESSAGE);
        }
    }

    public LocalTime getPushNotificationTime() {
        return pushNotificationTime;
    }
}
public class PushNotificationTime {

    private static final Map<LocalTime, PushNotificationTime> CACHE = new HashMap<>();

    private static final PushNotificationTime INITIAL = PushNotificationTime.from(LocalTime.MIN);

    public static PushNotificationTime initial() {
        return INITIAL;
    }

    private final LocalTime value;

    private PushNotificationTime(LocalTime value) {
        this.value = value;
    }

    public static PushNotificationTime from(LocalTime value) {
        return CACHE.computeIfAbsent(value, ignored -> new PushNotificationTime(value));
    }

    public LocalTime getValue() {
        return value;
    }
}

아직 개발 초기 단계라서 현재 상태에 기반한 도메인 로직이 많이 존재하지 않아서, 더욱 도메인이 빈약하다고 느껴지는 것 같습니다.

하지만, UseCase(application)까지가 사용자가 원하는 요청 흐름입니다. 따라서, 비즈니스 로직이 UseCase에 담겨도 상관없다고 생각한다. 그렇다고, 도메인의 값을 꺼내서 UseCase에서 처리하는 것은 당연히 안된다고 생각합니다.

반대로 생각하면, 입력에 대한 검증이 Command쪽으로 분리되어서 오히려 도메인 규칙들을 파악하기가 쉬울 수도 있다는 생각이 들었습니다. 만약 도메인에 입력에 대한 검증과 현재 상태에 기반한 규칙이 혼재한다면 오히려 파악이 어려울 수도 있겠다는 생각이 들었습니다. 물론 검증만을 위한 객체를 분리할 수도 있지만, 이는 선택의 영역이라고 생각합니다.

4. 생각보다 코드가 많이 생긴다.

1. controller에서 Json을 역직렬화하는 request 객체와 UseCase에서 입력을 검증하는 객체 분리에 따른 객체 개수 증가

2. web → application 사이의 DTO와 입력 유효성 검증 에서 이유는 충분히 다뤘습니다.

2. Port-Adapter Pattern에 따른 객체 개수 증가

외부 기술에 대한 의존은 원래도 interface로 분리해두었다. 많은 port-adapter에 따른 객체 개수 증가를 가장 많이 느끼는 부분은 DB 영속화와 관련된 부분입니다.

원래는 UseCase에서 JPARepository를 상속하는 인터페이스를 그대로 사용했습니다. JPA 의존성이 제거되고 다른 방식으로 변경되었을 때, extends JPARepository만 제거하고 변경된 방식의 구현체를 구현하면 된다고 생각했기 때문입니다.

하지만, 헥사고날 아키텍처에서는 이런 기술에 대해 경계를 강력하게 두어서 core는 변경에 안전하게 두고, adapter 쪽만 갈아끼우는 방식입니다.

따라서, 기존에는 JPARepository를 extends하는 객체 하나만으로 DB를 제어할 수 있었다면, 헥사고날 아키텍처에서는 Port를 통해 필요한 DB 영속화 메서드들을 선언하고 이를 구현하는 Adapter에서 JPARepository를 extends하는 객체를 사용해서 DB 관련 처리를 해줘야합니다.

관리하는 객체가 1개에서 3개로 증가했습니다. 사실 이부분은 제가 레이어드 아키텍처를 사용할 때, JPA 의존성과의 경계를 강하게 하지 않아서 느끼는 것일 수도 있습니다.

5. 코어가 가지는 Spring 의존성에 대하여

위에서 말했듯이, 강력한 Clean Architecture를 지향하는 사람들은 core에 Spring 의존성이 들어오면 안된다고 생각합니다. 그렇다면 core에 Spring 의존성을 제거하면 어떻게 될까요?

core에 Spring의 의존성을 제거한다면?

생산성을 올려주는 Spring의 기능들을 Core에서 하나도 사용할 수 없습니다.

가장 쉬운 예가, Spring의 @Transactional 사용 불가입니다. 보통, UseCase에서 하나의 public 메서드를 하나의 Transaction으로 처리할 것입니다. UseCase는 core에 속하기 때문에 Spring의 @Transactional을 사용할 수 없습니다.

그렇다고 사용자의 요청을 받는 Controller 메서드에 @Transactional을 선언하기엔, 불필요하게 Transaction을 쥐고 있다고 생각합니다. 그렇다면 Controller와 UseCase를 중재하는 객체를 만들고 거기에 @Transactional 어노테이션을 붙혀야할까요? @Transactional 어노테이션만을 위한 계층이 생기는 것이 저는 과하다고 생각합니다. 물론 강력한 클린아키텍처를 지향할 것이면 이것도 방법이라고 생각합니다.

또한, 아래처럼 의존 관계를 맺고 있는 Spring Bean 들을 @Configuration을 통해 모두 직접 생성해주어야합니다. 스프링의 @Autowired를 사용할 수 없기 때문이죠.

@Configuration
@EnableJpaRepositories
class PersistenceAdapterConfiguration {
    @Bean
    AccountPersistenceAdapter accountPersistenceAdapter(
            AccountRepository accountRepository,
            ActivityRepository activityRepository,
            AccountMapper accountMapper
    ) {
        return new AccountPersistenceAdapter(
                accountRepository,
                activityRepository,
                accountMapper
        );
    }

    @Bean
    AccountMapper accountMapper() {
        return new AccountMapper();
    }
}

진짜 Spring에서 다른 걸로 변경 될 것 같아?

그렇다면 여기서,

제가 개발중인 프로젝트는 Spring이 아닌 다른 프레임워크로 전환이 될 가능성이 매우 낮다고 생각합니다. 따라서, Spring 의존성 까지는 core에 두고 Spring의 생산성을 얻기로 결정했습니다. 또한, 조립도 @Autowired 방식으로 하려합니다.

내 프로젝트에서의 성격상의 문제

1. Swagger 관련 처리

문제를 해결했지만, 개발적으로 해결한 내용은 아닙니다.

현재, application에서 web요청의 반환 값을 담은 DTO를 그대로 web에서 직렬화해서 Json으로 응답합니다.
web에서 Json 직렬화를 위한 DTO 객체를 만들어도 API 50개 중에 49개는 application에서 반환한 객체와 동일한 필드들을 가지기 때문입니다.

(AService) —— AResponseDto.java 반환 ——> AController

AController에서 AResponseDto를 직렬화해서 Json으로 반환

문서화를 위해 Swagger를 사용하고 있는데, application의 반환 객체를 그대로 web에서 그대로 사용하기 때문에, 아래와 같이 application의 응답 DTO에 Swagger 관련 문서화 코드가 들어갑니다.

// application.port.in 패키지 객체

public class MemberInfoResponse {

    @Schema(example = "memberNickname")
    private String nickname;

    @Schema(example = "37.5064393")
    private double latitude;

    @Schema(example = "126.963687")
    private double longitude;

    @Schema(example = "profileDir/profileImage.jpg")
    private String profileImageUrl;

    // ...
}

이렇게 된다면, example 값을 부여하기 위한 @Schema 어노테이션 때문에 core에 Swagger에 대한 의존성이 생깁니다.

해결방안

지금 (service에서 controller로의 응답 DTO)를 controller에서 그대로 Json으로 직렬화해서 응답하는 방식을 사용하고 있는데요.이렇게 하니까, service -> controller 응답 DTO에 스웨거 어노테이션 @Schema가 들어가게 되는 상황인데요.해결방안으로 여러가지를 생각해보았는데요.

1. core에 swagger 의존성을 부여

가장 간단한 방법입니다.

하지만, 헥사고날 아키텍처에서 swagger와 같은 외부 의존은 adapter에 위치하게 하고 언제든 갈아끼울 수 있도록 하는 것이 기본 개념입니다.

2. application → web DTO와 web → client DTO 분리

service -> controller의 DTO에는 swagger 어노테이션을 제거하고 이를 web에서 Swagger 어노테이션이 포함된 DTO로 생성해서 Json으로 직렬화 시킨다. 이렇게 하면 Swagger의 어노테이션 하나때문에 API당 DTO를 2개씩 만들어줘야해서 DTO 객체들이 너무 많아질 것이라고 생각합니다. 개발 리소스가 너무 많이 들 것 같습니다.

3. Reflection으로 Swagge의 @Schema 어노테이션 선언

일단 불가능한 방법입니다.

application에서는 일반 DTO를 반환하는 구조를 가져가고, core 바깥에서 컴파일 시점에 application의 DTO들에 @Schema 어노테이션으로 Reflection으로 주입하는 방식입니다. 하지만, java의 reflection 라이브러리로는 Annotation을 get 할 수 있을 뿐, set할 순 없습니다.

가능한 2가지 방법 모두 내키지 않았습니다. 이를 클라이언트 개발자에게 얘기를 해보았는데, swagger 문서에 example 유무 여부가 크게 중요하지 않고, 세부사항이 궁금하다면 Swagger의 schema 란을 보면 충분하다고 해서 example 값을 위한 @Schema 사용하지 않기로 했습니다.

하지만, 관리해야 할 객체는 많아지겠지만 만약 example을 사용해야한다면, 클린 아키텍처의 관점에서 2번 방식을 채택해야 할 것 같습니다.

헥사고날 아키텍처에 대한 전반적인 느낀 점

1. 상당히 이상적이다.

아키텍처의 주 관심사는 관심사의 분리와 변경에 쉽게 대응할 수 있는 구조라고 생각합니다.
그러한 관점에서 헥사고날 아키텍처 라는 것은 상당히 좋은 아키텍처라고 느꼈습니다. 이론적으로는 말이죠.

core에 spring을 제외한 의존성을 제거하고 비즈니스 로직에만 집중시킴으로써 관심사를 확실히 분리했습니다.

또한, port-adapter를 통해 비즈니스 이외의 환경적, 기술적인 의존성을 잘 제거하고 변경에 쉽게 대응할 수 있는 구조인 것을 느꼈습니다.

하지만, 관리해야 할 객체들이 많아짐을 느꼈습니다. 저는 공부하는 학생이고 서비스의 로직이 간단하기 때문에 버겁다는 생각이 들지는 않습니다. 물론, 지금의 리소스를 투자하는 것이 나중에 찾아올 더 큰 리소스를 방지하는 것이라는 사실도 인지하고 있습니다.

하지만, 실무에서는 어떨지가 의문입니다. 배포 마감 기한이 있고 할 일이 많은 상황에서 가능할 것인가.. 가 의문입니다.

또한, 저는 기술 스택에 대한 변경, 외부 서비스에 의한 변경 등을 겪지 못했습니다. 따라서, 이상에 대한 투자 대비 아키텍처 적인 효율이 얼마나 큰 지에 대해 체감하지 못했습니다. 효율이 낮다는 말은 아닙니다.

변경은 어디서 일어날지 예측하기가 힘듭니다. 그렇다고 모든 경우를 다 변경에 유연하게 대처할 수 있도록 추상화시키는 것도 힘들 것이라고 생각합니다. 본인의 프로젝트에 맞게 적절하게 판단하는 것이 중요해보입니다.

2. 변경 가능성에 대한 정의가 필요하다.

바로 위의 이야기와 연결되는 내용입니다. 만약 이상을 추구하는 것이 힘들다면, 어떤 것이 변경될 수 있는지 정의하는 것이 중요한 것 같습니다. 물론, 다가올 변경을 예상하는 것은 상당히 힘든 일입니다.

하지만, 특정 기술은 절대 변경되지 않을 것이라고 판단이 된다면 core에서 의존하도록 하는 것도 방법일 것 같습니다. 물론, 추후에 core에서 사용하는 특정 기술이 변경된다면 그 파급 효과는 감당해야 할 일이겠습니다.

쓰다보니, 안일한 생각에 대한 파급 효과가 무서워서 아키텍처 경계를 철저히 하는 것이 좋다는 생각이 듭니다.

3. 도메인 로직이 풍부한 어플리케이션에는 참 좋을 것 같다.

3.입력검증과 도메인에서 살펴봤듯이, 게시판 기반의 서비스이기 때문에 현재 상태에 기반해서 처리하는 도메인 규칙들이 비교적으로 적습니다. 따라서, 과한 것일 수도 있습니다.

반면, 도메인 모델에 로직이 풍부한 경우에는 도메인 로직을 포함하는 core 부분을 변경에 방어적으로 가져갈 수 있을 것 같습니다.

4. 재밌다.

새로운 아키텍처를 공부하고, 개념을 이해하고 제 프로젝트에 적절히 적용시키는 과정이 흥미로웠습니다.

profile
self-motivation

1개의 댓글

comment-user-thumbnail
2023년 3월 10일

잘 읽고 갑니다~^^

답글 달기