Spring 프로젝트 기본 전략

강정우·2024년 1월 17일
0

Spring-boot

목록 보기
60/73

프로젝트 변수 명명 전략

DTO(data transfer object)

  1. 데이터 전송 객체
  2. DTO는 기능은 없고 데이터를 전달만 하는 용도로 사용되는 객체를 뜻한다.
    • 그럼 DTO에 기능이 있으면 안되는가? -> 아니다. 객체의 주 목적이 데이터를 전송하는 것이라면 DTO라 할 수 있다.
  3. 객체 이름에 DTO를 꼭 붙여야 하는 것은 아니다. 대신 붙여두면 용도를 알 수 있다는 장점은 있다.
    이전에 설명한 ItemSearchCond 도 DTO 역할을 하지만, 이 프로젝트에서 Cond 는 검색 조건으로 사용한다는 규칙을 정했다. 따라서 DTO를 붙이지 않아도 된다. ItemSearchCondDto 이렇게 하면 너무 복잡해진다. 그리고 Cond 라는 것만 봐도 용도를 알 수 있다.

참고로 이런 규칙은 정해진 것이 없기 때문에 해당 프로젝트 안에서 일관성 있게 규칙을 정하면 된다.

프로젝트 구조 전략

Repository 인터페이스

public interface ItemRepository {
    Item save(Item item);
    void update(Long itemId, ItemUpdateDto updateParam);
    Optional<Item> findById(Long id);
    List<Item> findAll(ItemSearchCond cond);
}

메모리 구현체에서 향후 다양한 데이터 접근 기술 구현체로 손쉽게 변경하기 위해 리포지토리에 인터페이스를 도입했다.

검색조건(Cond) class

@Data
public class ItemSearchCond {
    private String itemName;
    private Integer maxPrice;
    public ItemSearchCond() {
    }
    public ItemSearchCond(String itemName, Integer maxPrice) {
        this.itemName = itemName;
        this.maxPrice = maxPrice;
    }
}

앞선 포스팅에서 설명 했듯 Cond 접미사는 검색 조건을 뜻한다.
검색 조건으로 사용된다. 상품명, 최대 가격이 있다. 참고로 상품명의 일부만 포함되어도 검색이 가능해야 한다. (like 검색)

서비스 인터페이스

public interface ItemService {
    Item save(Item item);
    void update(Long itemId, ItemUpdateDto updateParam);
    Optional<Item> findById(Long id);
    List<Item> findItems(ItemSearchCond itemSearch);
}

서비스의 구현체를 쉽게 변경하기 위해 인터페이스를 사용했다.
참고로 서비스는 구현체를 변경할 일이 많지는 않기 때문에 사실 서비스에 인터페이스를 잘 도입하지는 않는다.

프로젝트 설정 전략

@EventListener(ApplicationReadyEvent.class)

@Slf4j
@RequiredArgsConstructor
public class TestDataInit {

    private final ItemRepository itemRepository;

    /**
     * 확인용 초기 데이터 추가
     */
    @EventListener(ApplicationReadyEvent.class)
    public void initData() {
        log.info("test data init");
        itemRepository.save(new Item("itemA", 10000, 10));
        itemRepository.save(new Item("itemB", 20000, 20));
    }

}

애플리케이션을 실행할 때 초기 데이터를 저장하는 코드이다.

@EventListener(ApplicationReadyEvent.class) : 스프링 컨테이너가 완전히 초기화를 다 끝내고, 실행 준비가 되었을 때 발생하는 이벤트이다. 스프링이 이 시점에 해당 애노테이션이 붙은 initData() 메서드를 호출해준다.

참고로 이 기능 대신 @PostConstruct 를 사용할 경우 AOP 같은 부분이 아직 다 처리되지 않은 시점에 호출될 수 있기 때문에, 간혹 문제가 발생할 수 있다. 예를 들어서 @Transactional 과 관련된 AOP가 적용되지 않은 상태로 호출될 수 있다.

@EventListener(ApplicationReadyEvent.class) 는 AOP를 포함한 스프링 컨테이너가 완전히 초기화 된 이후에 호출되기 때문에 이런 문제가 발생하지 않는다.

Import, scanBasePackages, Profile

@Import(MemoryConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Bean
	@Profile("local")
	public TestDataInit testDataInit(ItemRepository itemRepository) {
		return new TestDataInit(itemRepository);
	}

}

@Import(특정.class)

import 어노테이션으로 위에서 작성한 특정.class를 빈으로 등록한다.

scanBasePackages = "특정.경로.지정"

여기서는 컨트롤러만 컴포넌트 스캔을 사용하고, 나머지는 직접 수동 등록한다. 그래서 컴포넌트 스캔 경로를 hello.itemservice.web 하위로 지정했다.

@Profile("local")

특정 프로필의 경우에만 해당 스프링 빈을 등록한다. 여기서는 local 이라는 이름의 프로필이 사용되는 경우에만 testDataInit 이라는 스프링 빈을 등록한다. 이 빈은 앞서 본 것인데, 편의상 초기 데이터를 만들어서 저장하는 빈이다.

application.properties

spring.profiles.active=local

application.properties에 위와같이 작성이 되어있다면 local이라는 profile을 활성화하는 것이다.

언제 사용하나? -> 사용 환경에 따라 설정을 다르게 세팅해야할 때

스프링은 로딩 시점에 application.properties 의 spring.profiles.active 속성을 읽어서 프로필로 사용한다.
이 프로필은 로컬(나의 PC), 운영 환경, 테스트 실행 등등 다양한 환경에 따라서 다른 설정을 할 때 사용하는 정보이다.

예를 들어서 로컬PC에서는 로컬 PC에 설치된 데이터베이스에 접근해야 하고, 운영 환경에서는 운영 데이터베이스에 접근해야 한다면 서로 설정 정보가 달라야 한다. 심지어 환경에 따라서 다른 스프링 빈을 등록해야 할 수 도 있다. 프로필을 사용하면 이런 문제를 깔끔하게 해결할 수 있다.

main 프로필

/src/main/resources 하위의 application.properties 에 설정은 /src/main 하위의 자바 객체를 실행할 때 ( 주로 main() ) 동작하는 스프링 설정이다. spring.profiles.active=local 이라고 하면 스프링은 local 이라는 프로필로 동작한다. 따라서 직전에 설명한 @Profile("local") 가 동작하고, testDataInit 가 스프링 빈으로 등록된다.

참고로 프로필을 지정하지 않으면 디폴트( default ) 프로필이 실행된다.

No active profile set, falling back to 1 default profile: "default"

test 프로필

만약 local -> test로 지정하였다면 스프링은 test 라는 프로필로 동작한다.

spring.profiles.active=test 

이 위치의 application.properties 는 /src/test 하위의 자바 객체를 실행할 때 동작하는 스프링 설정이다.
주로 테스트 케이스를 실행할 때 동작한다.

프로필 기능을 사용해서 스프링으로 웹 애플리케이션을 로컬( local )에서 직접 실행할 때와 테스트로 돌릴 때, default로 돌릴 때에 따라서 등록한 초기화 데이터를 편리하게 확인할 수 있다.

프로젝트 테스트 코드 작성 전략

테스트 코드 작성

@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @AfterEach
    void afterEach() {
        //MemoryItemRepository 의 경우 제한적으로 사용
        if (itemRepository instanceof MemoryItemRepository) {
            ((MemoryItemRepository) itemRepository).clearStore();
        }
    }

    ...

    @Test
    void findItems() {
        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);

        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);

        //둘 다 없음 검증
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);

        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);

        //maxPrice 검증
        test(null, 10000, item1);

        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }

    void test(String itemName, Integer maxPrice, Item... items) {
        List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
        assertThat(result).containsExactly(items);
    }
}

assertThat(결과값).containsExactly(어떠한 값)

순서도 정확히 다 맞아야하는 테스드 메서드

빈문자열도 테스트

@Repository
public class MemoryItemRepository implements ItemRepository {

    private static final Map<Long, Item> store = new HashMap<>(); //static
    private static long sequence = 0L; //static

    ...
    
    @Override
    public Optional<Item> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }
    
    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();
        return store.values().stream()
                .filter(item -> {
                    if (ObjectUtils.isEmpty(itemName)) {
                        return true;
                    }
                    return item.getItemName().contains(itemName);
                }).filter(item -> {
                    if (maxPrice == null) {
                        return true;
                    }
                    return item.getPrice() <= maxPrice;
                })
                .collect(Collectors.toList());
    }
}

둘 다 없음 검증 주석에서 사용자 입장에서 null""는 동일한 의미를 지니도록 코드를 작성하고 또 이를 테스트해야한다.

프로젝트 DB 설정 전략

권장하는 식별자 선택 전략

데이터베이스 기본 키는 다음 3가지 조건을 모두 만족해야 한다.

  1. null 값은 허용하지 않는다.
  2. 유일해야 한다.
  3. 변해선 안 된다.

테이블의 기본 키를 선택하는 전략은 크게 2가지가 있다.

  1. 자연 키(natural key)
    비즈니스에 의미가 있는 키
    예: 주민등록번호, 이메일, 전화번호

  2. 대리 키(surrogate key)
    비즈니스와 관련 없는 임의로 만들어진 키, 대체 키로도 불린다.
    예: 오라클 시퀀스, auto_increment, identity, 키생성 테이블 사용

자연 키보다는 대리 키를 권장한다

자연 키와 대리 키는 일장 일단이 있지만 될 수 있으면 대리 키의 사용을 권장한다.

예를 들어 자연 키인 전화번호를 기본 키로 선택한다면 그 번호가 유일할 수는 있지만, 전화번호가 없을 수도 있고 전화번호가 변경될 수도 있다. 따라서 기본 키로 적당하지 않다.

문제는 주민등록번호처럼 그럴듯하게 보이는 값이다. 이 값은 null 이 아니고 유일하며 변하지 않는다는 3가지 조건을 모두 만족하는 것 같다. 하지만 현실과 비즈니스 규칙은 생각보다 쉽게 변한다. 주민등록번호 조차도 여러 가지 이유로 변경될 수 있다.

비즈니스 환경은 언젠가 변한다

영한쌤의 경험인데 요약하자면 결국 ID값 처럼 보이는 주민등록번호도 결국은 개인사이든 정부의 정책이 변하든 변하게 된다는 것이다.
그리고 이를 염두하지 않고 코드를 짜면 외부 요인으로 인하여 주민등록번호가 더 이상 ID값으로 사용할 수 없을 때 데이터베이스 테이블은 물론이고 수많은 애플리케이션 로직을 수정 해야만 한다는 것이다.

이때, 자연 키인 주민등록번호 대신에 비즈니스와 관련 없는 대리 키를 사용했다면 수정할 부분이 많지는 않았을 것이다.

기본 키의 조건을 현재는 물론이고 미래까지 충족하는 자연 키를 찾기는 쉽지 않다. 대리 키는 비즈니스와 무관한 임의의 값이므로 요구사항이 변경되어도 기본 키가 변경되는 일은 드물다.
대리 키를 기본 키로 사용하되 주민등록번호나 이메일처럼 자연 키의 후보가 되는 컬럼들은 필요에 따라 유니크 인덱스를 설정해서 사용하는 것을 권장한다.
비즈니스 요구사항은 계속해서 변하는데 테이블은 한 번 정의하면 변경하기 어렵다. 그런면에서 외부 풍파에 쉽게 흔들리지 않는 대리 키가 일반적으로 좋은 선택이라 생각한다.

참고로 JPA는 모든 엔티티에 일관된 방식으로 대리 키 사용을 권장한다.

profile
智(지)! 德(덕)! 體(체)!

0개의 댓글