비즈니스 로직이 복잡해 지면서 Service 레이어(서비스 계층과 같은 말)의 코드가 복잡해지기 시작했다. 특히 DTO를 VO로 변환할 때 문제가 두드러졌다. 해당 문제는 로직의 가독성을 크게 떨어뜨렸다. DTO를 왜 VO로 바꿔 쓰는지는 이 글이 다루지 않는다. 이 글은 「바꾸기로 한 뒤, 그 변환 코드를 어디에 둘 것인가」만 다룬다.

처음에는 다음과 같이 단순하게 진행했다:

public class UserService {
    public void registerUser(UserDTO dto) {
        UserVO vo = new UserVO(
            dto.getEmail(),
            dto.getAge(),
            dto.getName(),
            dto.getPhoneNumber(),
            dto.getAddress(),
            LocalDateTime.now(),
            UserStatus.ACTIVE
        );
        // 비즈니스 로직...
    }
}

dto.getEmail() 같은 줄은 DTO에 담겨 온 값을 하나씩 꺼내 생성자에 넘긴다. LocalDateTime.now()는 지금 이 순간의 날짜와 시각을 돌려주는 메서드로, 가입한 시각을 채운다(LocalDateTime). UserStatus.ACTIVE는 회원 상태를 나타내는 값이다. 이 글은 UserStatus 선언을 싣지 않았지만, 정해 둔 상태 값 중 하나만 고르게 하는 enum 같은 타입이라고 보면 된다.

생성자로는 필요한 필드가 많아지면서 코드가 길어졌고, 파라미터 순서를 헷갈리기 쉬웠다. 그래서 빌더 패턴으로 전환했다. 빌더 패턴은 생성자에 값을 순서대로 늘어놓는 대신 .필드이름(값) 형태로 하나씩 이름을 붙여가며 객체를 채우는 방식이다. 자바에서는 이 메서드들을 직접 만들지 않고, Lombok의 @Builder 애너테이션을 클래스에 붙이면 자동으로 만들어 준다(그 클래스 선언은 뒤에서 다시 보인다).

public void registerUser(UserDTO dto) {
    UserVO vo = UserVO.builder()
        .email(dto.getEmail())
        .age(dto.getAge())
        .name(dto.getName())
        .phoneNumber(dto.getPhoneNumber())
        .address(dto.getAddress())
        .joinDate(LocalDateTime.now())
        .status(UserStatus.ACTIVE)
        .build();
    
    // 비즈니스 로직이 어디 시작하는지 불명확...
    processUserRegistration(vo);
    sendWelcomeEmail(vo);
    createUserProfile(vo);
}

한 줄씩 따라가기 — UserVO.builder()는 방금 설명한 @Builder가 만들어 준 메서드로, 호출하면 값을 채워 넣을 준비가 된 빌더 객체를 돌려준다. .email(...)부터 .status(...)까지는 그 빌더에 필드를 하나씩 채우는 호출이고, 마지막 .build()가 채워진 값으로 실제 UserVO 객체를 만들어 돌려준다. 그 아래 세 줄(processUserRegistration 등)이 가입 처리 · 환영 메일 · 프로필 생성을 맡는 비즈니스 로직이고, 내부는 이 글에 싣지 않았다.

문제가 발생했다:

  • VO 생성 코드가 7-8줄을 차지
  • 실제 비즈니스 로직이 어디서 시작하는지 불명확
  • 메소드를 읽을 때 집중력이 산만해짐
  • (아래 해결책을 적용한 뒤에 드러난 문제) 객체를 만드는 메서드를 여러 개 두자 같은 검증 코드가 메서드마다 반복됨 — 「심화」 절에서 다룬다

이 문제를 어떻게 해결할 수 있을까 고민하다가, 팩토리 메소드 패턴을 적극적으로 적용하기로 결정했다. 여기서 「팩토리」는 객체를 찍어 내는 공장이라는 뜻으로, 객체를 만드는 일만 맡는 메서드를 따로 두고 이름을 붙이는 방식이다. 「패턴」은 자주 만나는 문제에 대해 정리된 풀이의 모양을 가리킨다(디자인 패턴). 엄밀히 말하면 이 글이 쓰는 것은 GoF 책의 Factory Method 패턴이 아니라 정적 팩토리 메서드이다. 이 글에서는 제목 그대로 「팩토리 메소드」라고 부른다.


문제: Service 메소드의 가독성 저하

Service 레이어에서는 DTO를 받아 VO로 변환한 뒤 비즈니스 로직을 처리한다. 문제는 빌더 패턴이나 생성자를 직접 사용할 때 발생한다. 아래 코드는 변환 전에 값의 모양을 맞추는 정규화(이메일 소문자화, 전화번호 숫자만 남기기 등)까지 Service 메서드 안에 들어간 모습이다.

public class UserService {
    public UserVO registerUser(UserDTO dto) {
        // 이메일 정규화 (소문자 + 양쪽 공백 제거)
        String normalizedEmail = dto.getEmail().toLowerCase().trim();
        
        // 휴대폰 형식 정규화
        String formattedPhone = dto.getPhoneNumber()
            .replaceAll("[^0-9]", "");
        
        // 주소 필터링
        String address = StringUtils.isNotBlank(dto.getAddress()) 
            ? dto.getAddress() : "미등록";
        
        // VO 생성 (여기서 많은 라인 차지)
        UserVO userVO = UserVO.builder()
            .email(normalizedEmail)
            .age(dto.getAge())
            .name(dto.getName())
            .phoneNumber(formattedPhone)
            .address(address)
            .joinDate(LocalDateTime.now())
            .status(UserStatus.ACTIVE)
            .role(determineUserRole(dto.getAge()))
            .accountLevel(calculateAccountLevel(dto))
            .emailVerified(false)
            .notificationEnabled(true)
            .marketingConsent(dto.isMarketingConsent())
            .lastLoginDate(null)
            .failedLoginCount(0)
            .build();
        
        // 실제 비즈니스 로직이 어디서 시작하는지 불명확...
        validateUserUniqueness(userVO);
        sendVerificationEmail(userVO);
        saveUserAuditLog(userVO, "REGISTRATION");
        notifyAdminIfPremiumUser(userVO);
        
        return userVO;
    }
    
    private UserRole determineUserRole(int age) {
        return age >= 20 ? UserRole.ADULT : UserRole.MINOR;
    }
    
    private int calculateAccountLevel(UserDTO dto) {
        // 복잡한 로직...
        return dto.getReferralCode() != null ? 2 : 1;
    }
}

한 줄씩 따라가기 — 이메일은 toLowerCase().trim()으로 소문자로 바꾸고 양쪽 공백을 지워 형식을 통일한다. 전화번호는 replaceAll("[^0-9]", "")로 숫자가 아닌 문자를 전부 지워 숫자만 남긴다. "[^0-9]"는 정규식으로, [0-9]가 「0부터 9까지 숫자 한 글자」이고 앞의 ^가 「그것이 아닌」을 뜻한다. 그래서 "010-1234 5678"은 "01012345678"이 된다. 주소는 Apache Commons Lang 라이브러리가 제공하는 StringUtils.isNotBlank(값)으로 확인하는데, 이 메서드는 값이 null이거나 빈 문자열이거나 공백만 있으면 false를 돌려준다(코드에서는 이 라이브러리를 가져오는 import 줄을 생략했다) — 그래서 값이 있으면 그 값을 쓰고, 없으면 "미등록"을 대신 채운다. 조건 ? A : B 모양은 조건이 참이면 A, 거짓이면 B를 고르는 삼항 연산자로, if-else를 한 줄 식으로 적은 것이다(determineUserRole의 age >= 20 ? ADULT : MINOR도 같다). calculateAccountLevel은 추천인 코드(referralCode)를 갖고 가입했으면 계정 등급 2, 아니면 1을 매기는 예시 규칙이다. 그 뒤 UserVO.builder()로 정규화한 값들을 채워 객체를 만드는 방식은 앞서와 같다. 마지막 네 줄이 이 메서드가 원래 하려던 비즈니스 로직이다 — 같은 이메일이 이미 있는지 확인하고, 확인 메일을 보내고, 가입 기록을 감사 로그(누가 언제 무엇을 했는지 남겨 두는 기록, audit log)에 저장하고, 프리미엄 회원이면 관리자에게 알린다. 네 메서드의 내부는 이 글에 싣지 않았다.

객체 생성에만 15줄 이상이 할당되고, 여러 데이터 정규화 로직이 섞여있어서 실제 비즈니스 로직의 흐름을 따라가기 어렵다. 코드를 읽는 사람이 “어디가 변환이고 어디가 실제 로직인가?” 헷갈리는 문제가 발생한다. 이것이 우리가 해결하려는 문제다.


해결책: 팩토리 메소드 패턴

팩토리 메소드를 활용하면 객체 생성 로직을 의미 있는 이름의 메소드로 분리할 수 있다.

@Builder
public class UserVO {
    private String email;
    private int age;
    private String name;
    private String phoneNumber;
    private String address;
    private LocalDateTime joinDate;
    private UserStatus status;
    
    // 팩토리 메소드: 의미 있는 이름
    public static UserVO createFromUserDTO(UserDTO dto) {
        return UserVO.builder()
            .email(dto.getEmail())
            .age(dto.getAge())
            .name(dto.getName())
            .phoneNumber(dto.getPhoneNumber())
            .address(dto.getAddress())
            .joinDate(LocalDateTime.now())
            .status(UserStatus.ACTIVE)
            .build();
    }
    
    public static UserVO createFromAdminDTO(AdminDTO dto) {
        return UserVO.builder()
            .email(dto.getEmail())
            .age(dto.getAge())
            .name(dto.getName())
            .phoneNumber(dto.getPhoneNumber())
            .address(null)  // 관리자는 주소 미입력
            .joinDate(LocalDateTime.now())
            .status(UserStatus.ADMIN)
            .build();
    }
}

한 줄씩 따라가기 — 클래스 위의 @Builder가 UserVO.builder()를 만들어 준다. 두 메서드는 public static이다. static이라서 UserVO 객체가 아직 없어도 UserVO.createFromUserDTO(dto)처럼 클래스 이름으로 바로 부를 수 있고, 이 메서드가 바로 그 객체를 만들어 돌려주는 일을 한다. 메서드를 Service가 아니라 UserVO 안에 둔 이유는 「VO를 어떻게 만드는가」를 VO 자신이 알게 해서, Service에는 「무엇으로부터 만드는가」만 이름으로 남기기 위해서다. createFromAdminDTO가 받는 AdminDTO는 관리자 계정을 만들 때 들어오는 DTO이다. 관리자는 주소를 받지 않으므로 address를 null로 두고, 상태를 UserStatus.ADMIN으로 채운다.

앞 절의 정규화(이메일 소문자화, 전화번호 숫자만 남기기, 주소 “미등록” 처리)와 role · accountLevel 같은 나머지 필드는 짧게 보이려고 이 예시에서 뺐다. 실제로는 그 줄들도 이 팩토리 메소드 안으로 옮긴다. 그래야 Service에는 변환 코드가 한 줄도 남지 않는다.

이제 Service 메소드는 훨씬 깔끔해진다.

public class UserService {
    public void registerUser(UserDTO dto) {
        UserVO userVO = UserVO.createFromUserDTO(dto);
        // 비즈니스 로직에만 집중 가능
        processUserRegistration(userVO);
    }
    
    public void createAdminUser(AdminDTO dto) {
        UserVO adminVO = UserVO.createFromAdminDTO(dto);
        // 의도가 명확함
        grantAdminPrivileges(adminVO);
    }
}

각 메서드는 팩토리 메소드 한 줄로 VO를 얻고, 바로 다음 줄부터 자기 일을 한다. processUserRegistration(가입 처리)과 grantAdminPrivileges(관리자 권한 부여)는 UserService 안의 다른 메서드로 보면 되고, 내부는 싣지 않았다.


심화: 팩토리 메소드와 검증 로직

팩토리 메소드를 여러 개 만들다 보면 새로운 문제가 생긴다. 검증 로직 중복이다.

문제 상황

public static UserVO createFromUserDTO(UserDTO dto) {
    validateEmail(dto.getEmail());      // 검증1
    validateAge(dto.getAge());          // 검증2
    return UserVO.builder()...build();
}

public static UserVO createFromAdminDTO(AdminDTO dto) {
    validateEmail(dto.getEmail());      // 같은 검증 반복
    validateAge(dto.getAge());          // 중복
    return UserVO.builder()...build();
}

public static UserVO createFromExternalAPI(ExternalDTO dto) {
    validateEmail(dto.getEmail());      // 또 반복...
    validateAge(dto.getAge());
    return UserVO.builder()...build();
}

한 줄씩 따라가기 — validateEmail과 validateAge는 이메일과 나이가 올바른지 검사하고 틀리면 예외를 던지는 메서드로, 정의는 싣지 않았다. UserVO.builder()...build()의 가운데 ...는 자바 문법이 아니라 앞에서 본 .email(...) 같은 줄들을 줄여 적은 표시다. 세 번째 메서드의 ExternalDTO는 우리 서비스가 아닌 다른 서비스의 API(프로그램끼리 데이터를 주고받는 창구)에서 받아 온 회원 정보를 담는 DTO이다. 세 메서드가 같은 두 줄을 똑같이 반복한다. 나이 기준이 바뀌면 세 곳을 다 고쳐야 하고, 하나를 빠뜨리면 생성 경로마다 다르게 검사한다.

최적 해결책: 공통 검증 + 팩토리별 추가 검증

공통 검증은 VO 내부의 메소드로 분리하고, 팩토리별 추가 검증은 각 메소드에서 진행한다.

@Builder
public class UserVO {
    private String email;
    private int age;
    private String password;
    private UserStatus status;
    
    // 공통 검증: 모든 VO에 필수인 것들
    private void validateCommon() {
        if (email == null || email.isBlank()) {
            throw new IllegalArgumentException("Email is required");
        }
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("Invalid age");
        }
    }
}

한 줄씩 따라가기 — 클래스 위에 붙은 @Builder가 바로 앞에서 설명한 .builder()·.build() 메서드를 Lombok이 대신 만들어 주게 하는 애너테이션이다. validateCommon()은 이 클래스가 직접 선언한 평범한 private 메서드로, 이메일과 나이처럼 모든 생성 경로에 공통으로 필요한 검증만 모아 둔다. 값이 틀리면 IllegalArgumentException(「메서드에 잘못된 값이 들어왔다」는 자바 기본 예외)을 던진다. 이 예외는 RuntimeException 계열이라 부르는 쪽에 try-catch를 강제하지 않는다(Checked · Unchecked 예외). Service 쪽에서 이 예외를 누가 잡아 어떻게 응답하는지는 이 글에서 다루지 않는다.

아래 세 메서드도 위 UserVO 클래스 안에 들어가는 메서드다. 지면을 아끼려고 클래스 선언을 빼고 메서드만 보였다. 같은 클래스 안이라서 private인 validateCommon()을 부를 수 있다.

public static UserVO createFromUserDTO(UserDTO dto) {
    // 팩토리별 추가 검증
    if (dto.getPassword() == null || dto.getPassword().length() < 8) {
        throw new IllegalArgumentException("Password must be at least 8 characters");
    }
    
    UserVO vo = UserVO.builder()
        .email(dto.getEmail())
        .age(dto.getAge())
        .password(dto.getPassword())
        .status(UserStatus.ACTIVE)
        .build();
    
    vo.validateCommon();  // 공통 검증 실행
    return vo;
}

public static UserVO createFromAdminDTO(AdminDTO dto) {
    // 관리자 생성은 비밀번호 검증 생략
    if (dto.getAdminLevel() == null) {
        throw new IllegalArgumentException("Admin level is required");
    }
    
    UserVO vo = UserVO.builder()
        .email(dto.getEmail())
        .age(dto.getAge())
        .password("ADMIN_DEFAULT")  // 관리자는 기본 비밀번호
        .status(UserStatus.ADMIN)
        .build();
    
    vo.validateCommon();
    return vo;
}

public static UserVO createFromExternalAPI(ExternalDTO dto) {
    // 외부 API는 최소한의 검증만
    UserVO vo = UserVO.builder()
        .email(dto.getEmail())
        .age(dto.getAge())
        .password("EXTERNAL_USER")
        .status(UserStatus.EXTERNAL)
        .build();
    
    vo.validateCommon();
    return vo;
}

한 줄씩 따라가기 — 각 메서드는 먼저 자기 경로에만 필요한 검사를 한다. 회원 가입은 비밀번호가 8자 이상인지, 관리자 생성은 관리자 등급이 있는지 보고, 외부 API 경로는 추가 검사가 없다. 그다음 빌더로 객체를 만들고 vo.validateCommon()으로 공통 검사를 돌린 뒤 돌려준다. 객체를 먼저 만들고 검사하는 이유는 validateCommon()이 객체의 필드(email, age)를 읽는 메서드라서 객체가 있어야 부를 수 있기 때문이다. 검사에 걸리면 예외가 던져져 return vo;까지 가지 않으므로, 잘못된 값으로 만든 객체가 메서드 밖으로 나가지는 않는다.

vo.validateCommon() 호출 한 줄은 여전히 세 메서드에 다 있다. 그래도 중복이 풀렸다고 보는 이유는, 반복되던 것이 호출이 아니라 검사 내용(어떤 조건이면 어떤 메시지로 막는가)이었기 때문이다. 이제 나이 기준을 바꾸려면 validateCommon() 한 곳만 고치면 된다. 대신 새 팩토리 메소드를 만들 때 이 한 줄을 빠뜨리면 공통 검사가 아예 돌지 않는다는 점은 남는다.

이 구조의 장점:

DRY 원칙 - 공통 로직은 한 번만 구현
유연성 - 각 팩토리에서 필요한 검증만 추가
명확성 - 어디서 뭐를 검증하는지 분명함
유지보수성 - 공통 검증 수정 시 한 곳만 변경


실무 적용 예제

전체 구조

// VO 클래스
@Getter
@Builder
public class ProductVO {
    private Long id;
    private String name;
    private BigDecimal price;
    private int quantity;
    private ProductStatus status;
    
    // 공통 검증
    private void validateCommon() {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Product name is required");
        }
        if (price == null || price.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Price must be non-negative");
        }
        if (quantity < 0) {
            throw new IllegalArgumentException("Quantity must be non-negative");
        }
    }
    
    // 팩토리 메소드: 신규 상품 등록
    public static ProductVO createNewProduct(CreateProductDTO dto) {
        if (dto.getName() == null || dto.getName().length() < 3) {
            throw new IllegalArgumentException("Product name must be at least 3 characters");
        }
        
        ProductVO vo = ProductVO.builder()
            .name(dto.getName())
            .price(dto.getPrice())
            .quantity(dto.getQuantity())
            .status(ProductStatus.ACTIVE)
            .build();
        
        vo.validateCommon();
        return vo;
    }
    
    // 팩토리 메소드: 재고 조회
    public static ProductVO createFromEntity(Product entity) {
        ProductVO vo = ProductVO.builder()
            .id(entity.getId())
            .name(entity.getName())
            .price(entity.getPrice())
            .quantity(entity.getQuantity())
            .status(entity.getStatus())
            .build();
        
        vo.validateCommon();
        return vo;
    }
    
    // 팩토리 메소드: 외부 마켓플레이스 동기화
    public static ProductVO createFromMarketplace(MarketplaceDTO dto) {
        if (dto.getExternalId() == null) {
            throw new IllegalArgumentException("External ID is required");
        }
        
        ProductVO vo = ProductVO.builder()
            .name(dto.getProductName())
            .price(dto.getMarketPrice())
            .quantity(dto.getMarketInventory())
            .status(ProductStatus.MARKETPLACE)
            .build();
        
        vo.validateCommon();
        return vo;
    }
}

한 줄씩 따라가기 — @Getter는 모든 필드의 getName() 같은 조회 메서드를 Lombok이 만들어 주는 애너테이션이다(@Getter). 가격 price는 BigDecimal로 둔다. double은 0.1 + 0.2가 0.30000000000000004로 나올 만큼 소수에 오차가 있어서, 돈에는 오차 없는 BigDecimal을 쓴다. price.compareTo(BigDecimal.ZERO) < 0은 「price가 0보다 작으면」이라는 뜻이다 — compareTo는 작으면 -1, 같으면 0, 크면 1을 돌려준다. 팩토리 메소드는 상품이 들어오는 길마다 하나씩이다. createNewProduct는 새 상품 등록, createFromEntity는 DB에 이미 저장된 상품을 읽어 VO로 바꿀 때(주석의 「재고 조회」가 이 경우다), createFromMarketplace는 외부 마켓플레이스(여러 판매자가 상품을 올려 파는 다른 회사의 쇼핑몰)에서 받은 상품을 들여올 때 쓴다. createFromEntity가 받는 Product는 상품 테이블과 짝인 Entity 클래스로, 선언은 싣지 않았다. 세 메서드 모두 앞 절과 같은 「추가 검사 → 빌드 → 공통 검사」 순서다. createNewProduct만 .id(...)를 채우지 않는 것은 새 상품이 아직 DB에 저장되기 전이라 id가 없기 때문이다. 저장할 때 id를 누가 채우는지는 이 글에 싣지 않았다(DB가 번호를 매겨 주는 방식이 AUTO_INCREMENT이다). createFromEntity는 DB에서 읽은 값에도 공통 검사를 똑같이 돌리는데, 이 글은 그렇게 한 이유를 따로 적지 않았다.

// Service 클래스
@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductRepository productRepository;
    private final ProductMapper productMapper;
    
    // 신규 상품 등록
    public void registerNewProduct(CreateProductDTO dto) {
        ProductVO productVO = ProductVO.createNewProduct(dto);
        
        // 비즈니스 로직에만 집중
        Product entity = productMapper.toEntity(productVO);
        productRepository.save(entity);
    }
    
    // 상품 조회 및 가공
    public ProductVO getProduct(Long productId) {
        Product entity = productRepository.findById(productId)
            .orElseThrow(() -> new ProductNotFoundException("Product not found"));
        
        return ProductVO.createFromEntity(entity);
    }
    
    // 마켓플레이스 동기화
    public void syncFromMarketplace(MarketplaceDTO dto) {
        ProductVO productVO = ProductVO.createFromMarketplace(dto);
        
        // 마켓플레이스 특화 로직
        applyMarketplaceDiscount(productVO);
        updateInventory(productVO);
    }
}

한 줄씩 따라가기 — @Service는 이 클래스가 Service 역할을 맡는다고 스프링에 알리는 애너테이션이고, @RequiredArgsConstructor는 final로 선언한 필드(productRepository, productMapper)만 받는 생성자를 Lombok이 대신 만들어 주는 애너테이션이다 — 그 생성자로 스프링이 두 객체를 찾아 넣어 준다(의존성 주입). ProductRepository는 DB에 데이터를 저장하고 꺼내는 일만 맡는 객체다(Repository). registerNewProduct는 팩토리 메소드로 VO를 만든 뒤, Entity 변환을 전담하는 ProductMapper(이 글에는 내부 코드를 싣지 않았다)로 VO를 Entity로 바꾸고 productRepository.save(entity)로 DB에 저장한다. Repository는 DB 테이블과 짝인 Entity를 받아 저장하므로 VO를 Entity로 한 번 바꾼다. VO와 Entity를 왜 따로 두는지는 이 글의 범위 밖이다. getProduct가 부르는 findById는 결과가 없을 수도 있어 Optional로 값을 감싸 돌려주므로, .orElseThrow(...)로 값이 있으면 꺼내 쓰고 없으면 괄호 안 예외를 던지게 했다. 괄호 안의 () -> new ProductNotFoundException(...)은 「값이 없을 때 이 예외를 만들어라」는 동작을 짧게 적은 람다이다. syncFromMarketplace도 같은 흐름으로 VO를 만든 뒤 마켓플레이스 전용 로직 applyMarketplaceDiscount와 updateInventory를 호출한다. 두 메서드의 내부는 이 글에 싣지 않아서, VO의 값을 바꾸는지 바뀐 값으로 새 객체를 만드는지는 이 코드만으로는 알 수 없다.


정리: 패턴 적용 체크리스트

이 패턴을 프로젝트에 적용할 때 다음을 확인한다:

  • 팩토리 메소드명은 의도를 명확히 표현하는가? (createFromUserDTO, createNewProduct 등)
  • 공통 검증을 VO 내부 메소드로 분리했는가?
  • 각 팩토리에서 필요한 추가 검증만 하는가?
  • Service 메소드에서 비즈니스 로직만 남았는가?
  • 변환과 검증 로직이 한 곳에 모여있는가?

마치며

팩토리 메소드 패턴은 단순해 보이지만, Service 레이어의 가독성을 획기적으로 개선한다. 특히 다양한 출처(DTO, Entity, 외부 API)에서 VO를 생성할 때 그 가치가 극대화된다.


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기