공부/Project

배달 레전드 프로젝트 회고 (정적 팩토리 메서드, 전략 패턴, 퍼사드 패턴, QueryDSL @Query 차이, Page, JPA N+1 문제)

린구 2024. 9. 7. 16:41
반응형

 

 

 

배달 레전드 프로젝트가 끝이 났다

아쉬운 점도 많지만! 팀원들과 협업하면서 배우고 얻은 것이 많기에 회고하면서 정리하고자 한다.

1. 협업

협업은 기존대로 디스코드와 깃허브를 활용하여 진행했다.

깃허브에 issue, PR 을 발행하면 디스코드에 메시지가 오도록 설정하였다.

 

Github를 사용하여 프로젝트를 체계적으로 관리하고, 효율적으로 협업할 수 있도록 하였다.

`Project Board`를 통해 이슈들에 대한 전체적인 진행 상황을 파악하고 컨트롤할 수 있었다.

 

또 팀원 간 적극적인 코드 리뷰를 통해 코드 품질을 높이고 팀원 간 지식을 공유하였다.

 

2. 디자인 패턴

팀에 엄청난 실력자 분이 계셔서 다양한 디자인 패턴도 배울 수 있었다!

정적 팩토리 메서드

`정적 팩토리 메서드`는 객체 생성을 위해 정적(static) 메서드를 사용하는 기법이다.

 

이는 특정 디자인 패턴에 속하지는 않지만, 객체 생성 방식 중 하나로 `팩토리 패턴`의 변형으로 간주될 수 있다. 특히, 정적 팩토리 메서드는 객체 생성을 담당하면서도 객체의 생성을 `캡슐화`하여 더 유연하게 사용할 수 있는 방법을 제공한다.

 

정적 팩토리 메서드는 이름에서 알 수 있듯, 인스턴스를 생성하기 위해 인스턴스 메서드가 아닌 정적 메서드를 사용한다. 이 메서드는 생성자를 직접 호출하는 대신, `정적 메서드`를 통해 `객체 생성`을 제어하거나 특정 규칙에 따라 다양한 객체를 반환할 수 있다.

 

`특징`

- 클래스 외부에서 객체를 만들 때 여러 이름의 정적 메서드를 제공할 수 있음

- 반한 타입이 해당 클래스 자체가 아닌 해당 클래스의 하위 클래스일 수도 있음

- 싱글톤 패턴과 결합하여 이미 생성된 인스턴스를 반환할 수 있음

 

`장점`

- 생성자와 달리 `이름`을 가질 수 있어 목적을 명확하게 설명

- 자주 생성되는 객체는 정적 메서드에서 캐싱하여 성능 개선

- 싱글톤과의 결합 - 만약 미리 만들어둔 객체가 있다면 생성하지 않고 반환

 

`예시 코드`

public class Notification {
    private String message;

    // 1. private 생성자
    private Notification(String message) {
        this.message = message;
    }

    // 2. 정적 팩토리 메서드
    public static Notification createEmailNotification(String message) {
        return new Notification("Email: " + message);
    }

    public static Notification createSmsNotification(String message) {
        return new Notification("SMS: " + message);
    }
    
}

예시 코드를 보면 기존 생성자로 객체를 생성하는 방법과 달리, static 메서드를 내에서 객체를 생성한다.

 class Post {
     private static final Post post = new Post("", "");
 	  private String title;
 		private String writerName;
 
     private Post(String title, String writerName) {
         this.title = title;
         this.writerName = writerName;
     }
 
     static Post createBasicPost() {
         return post;
     }
 }

싱글톤과 결합한 예시 코드는 위와 같다.

 

`from() of() 메서드`

두 메서드 이름은 정적 팩토리 메서드에서 자주 사용하는 네이 컨벤션이다.

 

`of 메서드`

of 메서드는 여러 개의 인자를 받아 객체를 생성할 때 사용된다. 주로 입력된 값을 사용하여 객체를 빠르게 생성하는 경우에 적합하다.

public class Post {
    private String title;
    private String writerName;

    private Post(String title, String writerName) {
        this.title = title;
        this.writerName = writerName;
    }

    // 여러 인자를 받아서 객체를 생성하는 'of' 메서드
    public static Post of(String title, String writerName) {
        return new Post(title, writerName);
    }
}

public class Main {
    public static void main(String[] args) {
        Post post = Post.of("Hello World", "Alice");
        System.out.println(post);
    }
}

 

`from 메서드`

from 메서드는 주로 하나의 입력값을 받아 그 값에서 객체를 생성한다. 특정 `타입`이나 `데이터`를 다른 형태의 객체로 변환한다.

public class Post {
    private String title;
    private String writerName;

    private Post(String title, String writerName) {
        this.title = title;
        this.writerName = writerName;
    }

    // 하나의 입력 데이터를 받아 객체를 생성하는 'from' 메서드
    public static Post from(PostDTO dto) {
        return new Post(dto.getTitle(), dto.getWriterName());
    }
}

// DTO 클래스
public class PostDTO {
    private String title;
    private String writerName;

    // constructor, getters and setters
}

public class Main {
    public static void main(String[] args) {
        PostDTO dto = new PostDTO("Post Title", "Bob");
        Post post = Post.from(dto); // DTO를 사용해 Post 객체 생성
        System.out.println(post);
    }
}

`of` 메서드는 `DTO`에서 엔티티를 받아 `DTO`로 반환할 때도 자주 사용된다.

 

나는 이번 프로젝트에서 `of` 메서드에 복잡한 로직을 추가하여 사용하기도 하였다.

public static SearchShopResponse of(Shop shop, String keyword) {
		return new SearchShopResponse(shop.getId().toString(), shop.getName(),
			shop.getDescription(), shop.getStatus().getDescription(),
			shop.getMenus().stream().map(menu -> menu.getName())
				.filter(name -> name.contains(keyword))
				.limit(3)
				.collect(Collectors.toList()));
	}

 

그런데 이는 좋은 설계가 아닌 것 같다.. 하나의 메서드에 너무 많은 작업을 수행하여 `단일 책임 원칙`을 위반한다!

정적 팩토리 메서드는 `객체 생성`에 집중하는 것이 좋다.

public static SearchShopResponse of(Shop shop, String keyword) {
    return new SearchShopResponse(shop.getId().toString(), shop.getName(),
        shop.getDescription(), shop.getStatus().getDescription(),
        getFilteredMenuNames(shop, keyword));
}

private static List<String> getFilteredMenuNames(Shop shop, String keyword) {
    return shop.getMenus().stream()
        .map(menu -> menu.getName())
        .filter(name -> name.contains(keyword))
        .limit(3)
        .collect(Collectors.toList());
}

이런 식으로 메서드를 분리하여 `of` 메서드를 객체 생성에만 집중하게 하고 가독성을 높일 수 있다!

 

전략 패턴

전략 패턴은 내가 직접 구현한 부분은 아니지만, 다른 팀원분이 사용하셔서 "아, 이런 게 있구나!" 하고 알게 되어 정리한다.

`전략 패턴`은 `행위 디자인 패턴` 중 하나로 특정 작업을 수행하는 `전략(알고리즘)`을 정의하고 알고리즘을 런타임에 변경할 수 있도록 만드는 패턴이다.

즉, 전략 패턴을 사용하면 객체 행위를 정의하는 부분을 별도로 분리해 관리하고, 상황에 따라 다른 전략을 적용할 수 있게 된다.

 

`전략 패턴의 개념`

  • `전략` 특정 동작을 정의한 인터페이스/ 추상 클래스
  • `구체적 전략` 전략 인터페이스를 구현한 여러 클래스 (각각 다른 알고리즘을 가짐)
  • `컨텍스트` 전략을 사용하는 클래스, 행위 실행

 

`예시 코드`

// 신용카드 결제 전략
public class CreditCardPayment implements PaymentStrategy {
    private String cardNumber;

    public CreditCardPayment(String cardNumber) {
        this.cardNumber = cardNumber;
    }

    @Override
    public void pay(int amount) {
        System.out.println(amount + "원 결제 완료 (신용카드: " + cardNumber + ")");
    }
}

// 페이팔 결제 전략
public class PaypalPayment implements PaymentStrategy {
    private String email;

    public PaypalPayment(String email) {
        this.email = email;
    }

    @Override
    public void pay(int amount) {
        System.out.println(amount + "원 결제 완료 (Paypal: " + email + ")");
    }
}

이런 식으로 `PaymentStrategry` 인터페이스를 `CreditCardPayment` `PaypalPayment` 클래스가 구현하여 같은 작업을 다른 전략으로 수행하도록 한다.

@RequiredArgsContructor
public class ShoppingCart {
    private PaymentStrategy paymentStrategy;
    
    // 결제 요청
    public void checkout(int amount) {
        paymentStrategy.pay(amount);
    }
}

그 뒤 `Context` 클래스에서 전략을 설정하고

public class Main {
    public static void main(String[] args) {
        ShoppingCart cart = new ShoppingCart();

        // 신용카드 결제 설정
        cart.setPaymentStrategy(new CreditCardPayment("1234-5678-9876-5432"));
        cart.checkout(10000); // 10000원 결제 완료 (신용카드)

        // Paypal 결제 설정
        cart.setPaymentStrategy(new PaypalPayment("user@example.com"));
        cart.checkout(20000); // 20000원 결제 완료 (Paypal)
    }
}

사용할 때 알맞은 구체적 전략 클래스를 생성하여 설정한다.

너무 재미있다.. 이런 식으로 인터페이스와 구현체를 활용할 수 있다니! ㅠㅠ 디자인 패턴이 이렇게 재밌는 것일 줄야.

 

우리 팀에서는 아래 방식처럼 전략 패턴을 사용했다.

`실제로 사용한 코드`

@Component
@RequiredArgsConstructor
public class SignUpFacade {

	private final UserRepo userRepo;
	private final List<CreateUserService> createUserServices;

	public Long signUp(SignUpRequest signUpRequest) {
		validate(signUpRequest);

		return createUserServices.stream()
			.filter(it -> it.isMatched(signUpRequest.signUpType()))
			.findAny()
			.orElseThrow()
			.createUser(signUpRequest)
			;
	}
}

`SignUpFacade` 클래스는 전략 패턴을 활용하여 다양한 사용자 생성 서비스를 동적으로 실행한다.

`SignUpFacade`는 전략 패턴의 컨텍스트 역할을 한다. `createUserServices` 리스트에 주입된 여러 전략 객체 중에서, `signUpRequest`의 `SignUpType`에 맞는 전략을 찾아 사용자 생성 로직을 위임한다.

 

`findAny()`는 필터링된 결과 중 아무거나 하나를 찾는다.

일반적으로 여러 서비스 중 조건에 맞는 서비스가 하나만 존재할 것이기에, 해당 조건을 만족하는 서비스 중 하나를 반환한다.

조건에 맞는 서비스가 없을 경우 `orElseThrow()`로 예외를 던진다!

 

public interface CreateUserService {

	Long createUser(SignUpRequest signUpRequest);
	boolean isMatched(Role role);

}

`CreateUserService` 전략 인터페이스는 `isMatched`를 통해 주어진 `Role`에 맞는 전략인지 판별하여 `createUser` 메서드로 사용자를 생성한다.

 

정리하자면 `CreateUserService`로 전략 인터페이스를 정의하고

각기 다른 알고리즘을 구현한 여러 구체적 전략 클래스를 구현하여

`SignUpFacade` 컨텍스트 클래스에서 요청에 맞는 전략을 선택하여 실행한다.

 

이를 통해 사용자 생성 로직을 전략 패턴으로 효과적으로 관리할 수 있다.

 

다음 프로젝트에서도 역할에 따라 서비스 로직을 정의할때 이런 `전략 패턴`을 사용하면 좋을 것 같다!

 

퍼사드 패턴

위에 클래스명을 보면 `SignUpFacade`인데.. `Facade` 즉 `퍼사드 패턴`은 무엇일까?

`퍼사드 패턴`은 복잡한 시스템을 간단하게 사용할 수 있도록 단일 인터페이스를 제공하는 디자인 패턴이다. 클라이언트는 퍼사드 인터페이스를 통해 복잡한 서브시스템과 상호작용하며, 복잡성을 감추고 시스템을 단순하게 사용할 수 있다.

 

`퍼사드 패턴의 개념`

  • `복잡한 서브시스템`
  • `퍼사드` 복잡한 서브 시스템을 감싸는 단순한 인터페이스를 제공하는 클래스
  • `클라이언트` 퍼사드를 사용하는 객체

 

위의 `SignUpFacade`는 퍼사드 패턴을 잘 구현한 예이다.

클라이언트는 `SignUpFacade`의 `signUp` 메서드를 호출하기만 하면, 회원가입에 대한 모든 로직이 내부적으로 처리된다.

실제로 회원가입을 처리하는 것은 여러 전략의 `CreateUserService`이지만 `SignUpFacade`는 이러한 여러 전략들을 추상화한 인터페이스로 감싸서 클라이언트에게 노출하지 않는다. 

 

즉, 클라이언트는 여러 `CreateUserService` 구현체의 존재나 구체적인 동작 방식을 알 필요가 없고, 그저 `signUp()` 메서드를 통해 회원가입을 요청할 수 있다. `SignUpFacade`는 이러한 복잡성을 모두 감추고, 간단한 방식으로 복잡한 로직을 실행하는 역할을 수행한다.

 

 

`예시 코드` 홈 씨어터 시스템

이런 식으로 활용할 수 있다.

class TV {
    public void on() {
        System.out.println("TV가 켜졌습니다.");
    }

    public void off() {
        System.out.println("TV가 꺼졌습니다.");
    }
}

class DVDPlayer {
    public void play() {
        System.out.println("DVD가 재생됩니다.");
    }

    public void stop() {
        System.out.println("DVD 재생이 중지되었습니다.");
    }
}

class SoundSystem {
    public void setVolume(int volume) {
        System.out.println("사운드 시스템 볼륨이 " + volume + "으로 설정되었습니다.");
    }
}

다양한 서브시스템 클래스들이 존재한다.

class HomeTheaterFacade {
    private TV tv;
    private DVDPlayer dvdPlayer;
    private SoundSystem soundSystem;

    public HomeTheaterFacade(TV tv, DVDPlayer dvdPlayer, SoundSystem soundSystem) {
        this.tv = tv;
        this.dvdPlayer = dvdPlayer;
        this.soundSystem = soundSystem;
    }

    public void startMovie() {
        System.out.println("영화 시작 준비...");
        tv.on();
        dvdPlayer.play();
        soundSystem.setVolume(10);
    }

    public void endMovie() {
        System.out.println("영화 종료...");
        dvdPlayer.stop();
        tv.off();
    }
}

`퍼사드 클래스`는 복잡한 서브 시스템을 감싸는 단일 진입점 역할을 하는 인터페이스를 클라이언트에게 제공하여 단순하게 처리할 수 있도록 한다.

public class Client {
    public static void main(String[] args) {
        TV tv = new TV();
        DVDPlayer dvdPlayer = new DVDPlayer();
        SoundSystem soundSystem = new SoundSystem();

        // Facade를 통해 복잡한 시스템을 간단하게 제어
        HomeTheaterFacade homeTheater = new HomeTheaterFacade(tv, dvdPlayer, soundSystem);
        homeTheater.startMovie();  // 영화 시작
        homeTheater.endMovie();    // 영화 종료
    }
}

실제 클라이언트에서는 간단한 메서드 호출만으로 복잡한 로직을 수행할 수 있다.

3. QueryDSL @Query 차이

이번 프로젝트에서 `QueryDSL`을 사용해보았다.

처음 사용해봐서 우여곡절이 있었지만.. 잘 해낸 것 같다 ^^ 다음에도 활용해 보고 싶당

 

`QueryDSL`과 `@Query` 특징과 차이점을 정리해보고자 한다.

QueryDSL

  • `타입 안전성` QueryDSL은 타입 안전한 쿼리를 작성할 수 있게 해준다. 컴파일 타임에 쿼리의 유효성을 검사할 수 있어, 문법 오류나 잘못된 필드 접근을 미리 확인할 수 있다. 
  • `자바 코드 기반` 쿼리를 자바 코드로 작성하므로, 동적 쿼리 생성이나 복잡한 쿼리 로직을 자바 코드로 표현할 수 있다. 이는 조건문, 반복문 등을 사용하여 유연하게 쿼리를 구성할 수 있음을 의미한다. 
  • `자동 코드 생성` QueryDSL은 메타 모델 클래스를 자동으로 생성한다. 이 메타 모델 클래스는 엔티티 클래스의 필드와 메서드를 기반으로 쿼리를 작성할 때 사용된다. 
  • `설정 필요` QueryDSL을 사용하려면 추가적인 설정과 의존성 추가가 필요하며, 메타 모델 클래스를 생성하는 과정이 필요하다.

@Query

  • `문자열 기반 쿼리` @Query 애노테이션을 사용하면 JPQL 또는 네이티브 SQL 쿼리를 문자열로 직접 작성할 수 있다. 이 방법은 쿼리를 명시적으로 작성하므로 쿼리 작성이 직관적일 수 있다. 
  • `동적 쿼리` 기본적으로 @Query는 동적 쿼리 생성에 적합하지 않다. 하지만, Spring Data JPA의 @Quer와 SpEL (Spring Expression Language)을 조합하여 동적 쿼리를 작성할 수 있다. 
  • `설정 필요 없음` QueryDSL과 달리 별도의 설정이나 메타 모델 클래스를 필요로 하지 않다. 간단하게 쿼리를 직접 작성할 수 있어 설정이 간편하다. 
  • `문자열 오류` 쿼리를 문자열로 작성하기 때문에 문법 오류가 컴파일 타임이 아닌 런타임에 발생할 수 있다. 이로 인해 쿼리 오류를 찾기 어려울 수 있다.

정리하면!

  • `QueryDSL` 타입 안전하고 동적 쿼리 작성에 적합, 자바 코드로 쿼리 작성, 추가 설정 필요
  • `@Query` 문자열로 직접 쿼리 작성, 설정이 간편하지만 문법 오류가 런타임에 발견될 수 있음

 

5. Page 객체 사용

 

프로젝트에 페이징 처리를 위해 Page 객체를 사용하면서 많은 것을 알게 되었다. 

특히, Page 객체가 `stream` 타입으로 동작하여 `stream()` 메서드를 호출하지 않아도 `map()` 메서드를 사용할 수 있다는 점이 신기했다.

 

가장 충격적이었던 것은 PageImpl<>을 생성할 때 데이터의 총 개수를 잘못 설정했던 부분이다.

나는 처음에 페이지당 데이터 개수, 즉 size만큼의 데이터를 전체 데이터 개수로 넣었는데, 이는 완전히 잘못된 방법이었다. 정확히는 PageImpl<>을 생성할 때 전체 데이터의 총 개수를 넣어줘야 했다.

 

전체 데이터 개수를 알아야 페이지 수를 정확히 계산할 수 있고, 현재 어떤 페이지를 불러와야 하는지도 알 수 있기 때문이다. 페이지당 데이터 개수만으로는 전체 데이터의 양을 알 수 없기 때문에 페이지 수를 제대로 계산할 수 없다는 것을 깨달았다.

 

근데 생각해보니 당연하다.. ㅋㅋㅋ 그래야 페이지당 데이터 개수를 기준으로 몇 페이지가 필요한지 알고, 현재 페이지를 정확히 불러올 수 있게 된다. 이번 경험 덕분에 페이징 처리에 대한 이해가 확실히 깊어졌다. 

public Page<SearchShopResponse> search(PageRequest pageRequest, String keyword) {
		Page<Shop> shops = shopRepository.search(pageRequest, keyword);
		return shops.map(shop -> SearchShopResponse.of(shop, keyword));
	}

그냥 PageImpl<> 만들지 말고 Page 바로 넘겨주는 게 짱인 것 같다 ㅋㅋ

아 그리고 Page 객체의 `map()` 메서드는 기존의 객체를 새로운 형태의 Page 객체로 변환한다 짱이다!!!!!!!!!!

 

6. JPA N+1 문제

JPA N+1 문제에 대해서도 정리할 수 있었다.

`N+1 문제`는 JPA에서 데이터베이스 쿼리 최적화와 관련된 성능 문제로, 연관된 엔티티를 조회할 때 다수의 추가 쿼리가 발생하는 현상이다. 

예를 들어, Shop-Menu 관계에서 특정 Shop의 Menu를 조회할 때, Shop 엔티티를 조회한 후 각 Shop에 대해 Menu를 가져오는 추가 쿼리가 N번 실행된다. 

 

해결 방법으로는 `fetch join`을 사용하여 조인 쿼리를 직접 작성하거나, Hibernate의 `@BatchSize` 어노테이션으로 쿼리 수를 줄이는 방법이 있다. 또한, JPA의 `EntityGraph`를 사용하여 EAGER 로딩을 명시적으로 설정할 수 있지만, 복잡한 경우에는 사용이 어려울 수 있다.

 

자세하게 정리한 내용은 여기서! 확인할 수 있다.

 


 

진짜!! 의미가 컸던 프로젝트였던 것 같다.

능력자 팀원분께서 다양한 템플릿, 편리한 기능, 개발 방법.. 등등을 제공해주셔서 편하게 개발할 수 있었고 덕분에 다양한 것들을 배우고 알 수 있었다.

이제 내가 배운 것들을 내 것으로 만들기 위해, 틈틈이 복습하는 것도 중요하겠다는 생각이 든다.

 

완성도가 낮아서 아쉬운 부분도 있었지만, 나머지 프로젝트가 끝나고 나서 이 프로젝트를 다시 완성시키고 싶을 만큼 팀원분들이 너무 좋았다! 열정 가득한 팀원들과 함께할 수 있어서 정말 감사하고 기뻤다!

 

 

 

 

반응형