본문 바로가기
멘토링/NEXTSTEP ATDD

깊이 있는 6주간의 기록 대방출

by Alex_beom 2024. 7. 22.
이 글은 필자의 이전 블로그인  https://velog.io/@beomdrive/NEXTSTEP-ATDD-Final에서 핵심 내용을 중심으로 요약한 후에, 내용을 추가 보완하였습니다.
※ ATDD 모든 과정이 정리되어 있는 Github 주소: https://github.com/giibeom/nextstep-atdd-final-summary

 

더욱 개운하고 뿌듯했던 6주간의 여정

2023년도 새로운 해를 맞이하고 1월부터 시작된 NEXTSTEP ATDD 과정이 3월 말이 돼서야 완주했다.

코드숨 과정을 듣고 NEXTSTEP 과정을 바로 듣게 된 계기는 이동욱(향로) 멘토님의 추천이었지만, 개인적으로도 필요하다고 느꼈던 수강 목적은 다음과 같다.

  • 복잡한 비즈니스 로직에서도 코드를 깔끔하게 유지하는 역량을 키우고 싶었다.
  • Mock 없는 테스트를 통해 테스트의 신뢰도를 높일 수 있는 전략을 학습하고 싶었다.
  • 블랙박스 테스트를 통해 레거시 코드를 안전하고 효율적으로 리팩터링하는 역량을 연습하고 싶었다.

 

사실 테스트 관련 멘토링을 또 듣는 것에 대해 약간 망설이기도 했었지만, 과정을 수료하고 위의 3가지 목적을 모두 달성한 것 같아서 정말 잘 들었다고 생각한다.

일단 가장 찜찜했었던 부분이 테스트 코드 관련 부분이었는데, 이번 과정을 통해 나만의 테스트 전략을 확실하게 정하게 된 계기가 되었다. 관련된 내용은 테스트 방탄복을 입고, 리팩터링 전쟁으로 -  멀고 험난한 리팩터링, 안전하게라도 하자 항목에 상세히 작성해놓았다.

 

지금까지 작성했던 NEXTSTEP의 각 주차 회고에 첫 주제 항목들은 항상 필자가 느낀 점들을 작성한 후, 학습한 내용들을 적어 내려갔다. 하지만 강의에 모든 내용을 적지는 않았으며, 강의 자료를 그대로 작성하기보다는 필자가 따로 추가 학습하면서 이해해나가는 일련의 생각의 흐름대로 써보았다.

실제로 강사님은 내가 정리해놓은 내용들 그 이상으로 꽤 많은 꿀팁들과 정보를 주셨다. 하지만 저작권 관련 문제도 있을 것이며, 그런 꿀팁들을 내가 아직 완벽하게 누구한테 설명할 정도로 습득하진 않은 상태라서 회고글에는 담아내지 못했다.

앞으로 개발을 계속해나가면서 배운 내용들을 체화하면 그 때 나의 관점에서 열심히 작성해서 공유해보려고 한다.

 

피드백과 문제 해결을 통한 또 한번의 성장

이제 코드숨 총 정리때 처럼 피드백을 받은 내용과 문제를 분석하면서 추가로 학습한 내용들을 모두 정리해보며 다시 한번 리마인드하는 시간을 가져보려고 한다.

 

피드백

1. import문을 통해 presentation 계층과 application 계층의 단방향 의존을 확인하자 (관련 리뷰)

  • import 문을 통해 패키지, 객체 간 의존을 확인하자
  • 의존성을 단방향으로 관리하는 방법 (Trade- off)
    • application 계층에 DTO를 두어 Service 메서드 결과를 해당 DTO로 반환해주는 방법
      • 이렇게 되면 컨트롤러 메서드와 1:1 매핑이 돼서 다른 곳에서 Service 메서드를 재사용할 수 없는 단점
    • application 계층에서 도메인 엔티티를 그대로 반환해주는 방법
      • 이렇게 되면 재사용은 가능하지만 트랜잭션을 웹 계층까지 물고가는 단점
      • OSIV 설정이 꺼져있는 경우 지연 로딩 관련 이슈가 발생될 수 있음

 

2. 일급 컬렉션을 사용하자 (관련 리뷰)

  • 일급 컬렉션은 하나의 컬렉션을 상태로 가지고, 그 상태만을 위한 행위들을 제공하는 커스텀 자료구조이다
  • 컬렉션의 final은 재할당만 금지하고, 불변을 만들어주지는 않는다.
  • 컬렉션의 값을 직접 변경할 수 있는 메서드(ex. getter)를 제공해주지 말자

 

3. RequestDto에 단일 매개변수 생성자만 존재할 경우 데이터 바인딩(역직렬화)에 실패한다 (관련 리뷰)

  • @JsonCreator을 사용하거나 기본 생성자를 같이 생성해놓으면 된다.
    • @JsonCreator : 기본 생성자 대신 지정해준 생성자를 가지고 역직렬화를 진행하라고 알려주는 역할
  • Spring 내부에서 사용하는 Jackson 라이브러리를 통해 직렬화&역직렬화 시 ObjectMapper를 사용한다.
    • 이 때 jackson-module-parameter-names 모듈을 통해 기본 생성자가 없어도 인자가 있는 생성자를 통해 데이터를 바인딩해줌
    • 하지만 인자가 한개인 생성자만 있는 경우에는 HttpMessageNotReadableException이 발생하며 요청 데이터를 제대로 역직렬화 하지 못함

 

4. 일급 컬렉션에서의 디미터 법칙 (관련 리뷰)

 

[디미터 법칙에 대해 오해하고 있었던 부분을 다시 한번 되짚었던 계기]

A라는 한 엔티티 객체에서 @Embedded를 통해 일급 컬렉션 객체를 가지고 있었다. 그 일급 컬렉션 객체의 메서드를 사용하기 위해 Service 로직에서는 A.get일급컬렉션객체().메서드()를 사용하였다.

머릿속에서는 관련 데이터를 가지고 있는 객체에 직접 변경 요청을 하라는 Tell, Don't Ask 원칙이 생각났기 때문이다.

하지만 이렇게 A 객체의 내부 구현에 직접적으로 의존한다면 추후에 리팩토링을 할 때 문제가 생길 수 있다는 걸 알게되었다.

따라서 외부 모듈에서 일급 컬렉션의 메서드를 사용할 때는 A 객체의 메서드를 통해서만 메시지를 주고받도록 하고, 생성한 A 객체의 메서드는 그대로 일급컬렉션 메서드로 위임하도록 하였다.

 

[디미터 법칙 요약]

  • Don't Talk to Strangers (낯선 자에게 물어보지 말고, 내가 갖고 있는 것에만 집중하라)
    • 모듈은 자신이 호출하는 객체의 속사정은 몰라도 된다. 내가 가지고 있는게 아니라면 그냥 가지고 있는 주체에게 시키기만 해라.
  • 디미터 법칙을 준수하는 방법 4가지
    1. 객체 자기 자신의 메서드 사용
    2. 메서드의 파라미터로 넘겨받은 객체의 메서드 사용
    3. 메서드 내부에서 생성 및 초기화한 인스턴스(객체)의 메서드 사용
    4. 해당 객체가 인스턴스 변수로 가지고 있는 객체의 메서드 사용
      • 나는 위의 사례(디미터 법칙에 대해 오해하고 있었던 부분을 다시 한번 되짚었던 계기)를 해당 방식으로 수정하였다.
// 디미터 법칙을 준수하는 4가지 방법 예시
public class Member {
    private Gender gender;

    public void addMember(Member member) {
    }

    public void updateMember(Age age) {
        age.get(); // 2. 메서드의 파라미터로 넘겨받은 객체의 메서드 사용 (O)
    }

    public void Demeter_법칙을_잘지키기() {
        addMember(new Member()); // 1. 객체 자기 자신의 메서드 사용 (O)

        Name name = new Name("ALEX");
        name.getFullName(); // 3. 메서드 내부에서 생성 및 초기화한 객체의 메서드 사용 (O)

        gender.get(); // 4. 인스턴스 변수로 가지고 있는 객체가 소유한 메서드 사용 (O)
    }
}

 

 

5. Entity vs ValueObject vs DTO (관련 리뷰)

https://enterprisecraftsmanship.com/posts/entity-vs-value-object-the-ultimate-list-of-differences/
  • Entity : 식별자 동등성을 따라 ID(pk)가 같다면 동등한 객체로 판단
    • 나머지 멤버가 모두 같다 하더라도 ID(pk)가 다르면 동등하다고 판단하지 않음
    • 고유한 식별자(ID)가 있음
    • 엔티티는 항상 변경이 가능함 (불변 객체 X)
  • VO(값 객체) : 구조적 동등성을 따라 객체의 모든 멤버가 일치할 때 동등한 객체로 판단
    • 고유한 식별자(ID)가 없음
    • 모든 멤버 값이 같을 때만 동등하다고 판단
    • VO는 항상 하나 또는 여러개의 엔티티에 속해야 하며 단독으로 존재할 수 없음
    • 또한 값 객체는 불변이어야 한다
  • DTO : 주로 레이어 이동 간 바구니 역할을 담당
    • Entity나 VO 등 내부 레이어의 데이터 구조와 외부 영역의 클라이언트 인터페이스와의 관심사를 분리하기 위해 사용
    • DTO 동등성 비교는 일반적으로 잘 하지 않음

 

6. public & private 메서드 추상화 및 위치 (관련 리뷰1리뷰2리뷰 3)

  • public 메서드 작성 시 적절하게 작업들을 추상화하여 private 메서드로 분리하여 가독성을 증가시키는 것이 좋음
  • 코드를 추상화할 때 상위 레벨에서 도메인 로직을 얼마나 잘 표현하는지가 중요
    1. 도메인 로직을 너무 심하게 추상화하여 세부 사항이 오히려 덜 드러나진 않은지 체크
    2. 또한 메서드 내부 코드의 추상화 레벨이 서로 동일한지, 다른지 체크
  • 위의 방법대로 private 메서드를 통해 추상화를 진행한 후, 관련 로직들을 순서대로 배치하여 위에서 아래로 메서드들이 읽힐 수 있게 배치하는 것이 좋음
  • 또한 하나의 public 메서드에 너무 많은 private 메서드로 추상화한다면 역할이 너무 많을 수도 있다는 것을 확인해봐야함

 

7. 테스트에 필요한 데이터를 공통적으로 세팅해주는 방법 (관련 리뷰)

  • ApplicationListener<ContextRefreshedEvent>를 구현(implements)하여 onApplicationEvent() 메서드에 데이터를 생성하는 로직 호출
  • 각 테스트마다 @BeforeEach메서드에 데이터를 생성하는 로직 호출
  • 테스트 격리 방법에 따라 사용하는 방법을 결정하면 될 듯 함

 

8. application 계층의 Service 메서드 관리 (관련 리뷰)

  • OSIV를 끄는 방식 vs OSIV를 키는 방식
    • Spring Boot에서 OSIV는 기본적으로 활성화되어있다.
  • OSIV를 끄는 방식
    • 영속성 컨텍스트의 생존 범위는 트랜잭션 범위에서만 유효
    • 트랜잭션이 종료되는 시점(Service 메서드에서의 반환)에서 해당 엔티티 객체 멤버는 참조가 가능하지만, 객체 그래프 탐색은 불가능함(LazyInitializationException)
    • 따라서 Service 메서드에서 Entity를 그대로 반환하지 않고, 필요한 DTO를 생성해서 데이터를 옮겨담아서 반환
    • 이렇게 되면 하나의 Controller에 의존되기에 다른 곳에서 Service 메서드 재사용이 불가능함
  • OSIV를 키는 방식
    • 영속성 컨텍스트의 생존 범위는 요청부터 응답까지 전 범위에서 유효
    • 하지만 트랜잭션 범위를 벗어난 영역에서는 영속 상태이지만, 데이터 수정은 불가능
      (즉 모든 변경은 트랜잭션 안에서만 이루어져야함)
    • 트랜잭션 없이 읽기(Nontransactional reads)가 가능하므로 Service 메서드에서 Entity를 그대로 반환하고, Controller에서 응답 DTO로 변환하여 클라이언트로 응답
    • 이렇게 되면 여러 Controller에서 Service 메서드를 재사용할 수 있는 장점이 있음
    • 하지만 Presentation 계층에서 지연 로딩에 의한 SQL이 실행되므로, 성능 튜닝/이슈 파악 시 관리 포인트가 넓어지는 단점이 있음
  • 실무에서는 보통 OSIV를 끄는 방식으로 많이 진행된다. Service 메서드끼리 내부에서 서로 같은 도메인을 조회한다고 해도, 서로 다른 요구사항을 가지는 경우가 많기 때문이다.

 

9. 객체 간 직접 참조 vs 간접 참조 (관련 리뷰)

  • 엔티티를 직접 참조한 경우
    • 객체간의 결합도가 높아져 의도치 않은 동작이 일어날 수 있음
    • 다른 객체로 탐색이 가능해짐 (객체 그래프 탐색)
      • 잘 쓰면 편한 만큼 잘 못 쓰면 리스크가 있음 (의도치 않은 사이드 이펙트)
    • 디비에서 조회하거나 ORM 사용 시 복잡도가 중가됨
  • 엔티티를 간접 참조한 경우
    • 필요한 관계의 객체 id만 가지므로 객체간 결합도는 낮아져 사이드 이펙트의 확률은 줄어듬
    • 하지만 특정 요청에서 생애주기가 겹칠 경우 비즈니스 로직보다 애플리케이션의 로직이 더 커질 수 있는 단점
  • 같은 생애주기로 관리될 경우에는 직접 참조가 유용하고, 같이 사용되는 빈도가 낮은 관계의 객체들은 간접 참조가 좀 더 유리할 수 있다.
    • 관련된 개념으로 애그리거트 루트 <-> 하위 도메인 이 있는데, 이건 추후 따로 포스팅 할 생각이다.

 

10. 변수명과 메서드명에 type을 이용하는 명명하는 방법을 지양하자 (관련 리뷰)

  • 리스트를 반환하는 의미로 getxxxxList()를 사용하면 추후에 반환 타입 변경이 일어날 시 수정 포인트가 늘어날 수 있음
  • 따라서 type을 사용하기보다는 복수형으로 사용할 것을 권장
    • getFavoriteList() → favorites()

 

문제 해결 + 기타 학습 내용

1. CascadeType.REMOVE vs orphanRemoval = true

https://tecoble.techcourse.co.kr/post/2021-08-15-jpa-cascadetype-remove-vs-orphanremoval-true/
  • CasecadeType.REMOVEorphanRemoval = true 모두 부모 엔티티를 삭제하면 자식 엔티티도 삭제한다.
    • 따라서 여러 부모 엔티티와 관계를 맺는 자식 엔티티에는 해당 설정 사용을 조심해야 한다.
  • 부모 엔티티를 삭제하는것이 아닌, 부모 엔티티에서 자식 엔티티를 제거(관계 끊기) 할 경우의 동작이 서로 다르다.
    • CasecadeType.REMOVE : 부모 엔티티에서 자식 엔티티의 관계를 제거하더라도 DELETE 문이 나가지 않는다.
    • orphanRemoval = true : 부모 엔티티에서 자식 엔티티의 관계를 제거하면 자식은 고아로 취급되어 그대로 사라진다
      (DELETE  쿼리 발생)

 

2. @ElementCollection & @CollectionTable

  • 엔티티의 일부 속성을 컬렉션으로 매핑할 때 사용
  • 값 타입(VO)의 라이프 사이클은 엔티티를 따라간다
    • 값 타입(VO)은 독립적인 라이프 사이클을 가질 수 없다
    • 따라서 별도로 persist()를 해주지 않아도 엔티티의 값이 변경되면 알아서 자동 반영된다
    • 영속성 전이 + 고아객체 기능을 기본적으로 가져간다
  • @CollectionTable 옵션
    • name : 값 타입(VO)을 저장할 별도의 테이블 명
    • joinColumns : FK 컬럼명 (보통 소속된 엔티티의 PK값)
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(
        name = "MEMBER_ROLE", // 값 타입 용 별도 테이블 명
        joinColumns = @JoinColumn(name = "id", referencedColumnName = "id") // id랑 fk
)
@Column(name = "role")
private List<String> roles;
  • 생성되는 테이블 관계

 

 

3. 설정 파일 (properties vs yml)

https://docs.spring.io/spring-boot/reference/features/external-config.html#features.external-config.files.profile-specific
  • properties와 yml 설정 파일이 모두 같은 위치에 있는 경우 properties 파일이 우선권을 갖는다.
  • application-{profile}.yml 형식으로 로드하려고 시도한다.
  • placeholder({xxxx})의 속성 이름은 kebab-case(항상 소문자만 사용)를 사용하여 참조해야 Spring Boot가 relaxed binding 기능을 사용해서 센스 있게 프로퍼티 이름을 자동으로 변환해줄 수 있음
    • @Value나 @ConfigurationProperties로 설정값을 바인딩 할 때
    • kebab-case
      • demo.item-price -> O
      • demo.itemPrice -> X
    • Relaxed Binding: 값을 바인딩할 때 몇가지 완화된 규칙을 사용 - 관련 다큐먼트 링크

 

4. 프로퍼티의 키 값과 placeholder 값이 같으면 예기치 못한 동작이 발생한다

  • key={$key} 형태가 되면 안됨 → db.url=${db.url} : 오류
  • 만약 프로퍼티 key와 value를 같은 이름으로 선언할 경우
    • 프로퍼티 값(db.url)을 해석하려고 할 때, 그 값은 동일한 키와 연관된 값인 ${db.url}로 대체
    • db.url의 속성 값을 확인하려고 할 때 ${db.url}을 동일한 키와 연결된 값인 ${db.url}로 대체
    • (db.url ←→ ${db.url} : 무한 루프 발생)