배달 레전드 프로젝트가 끝이 났다
아쉬운 점도 많지만! 팀원들과 협업하면서 배우고 얻은 것이 많기에 회고하면서 정리하고자 한다.
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 로딩을 명시적으로 설정할 수 있지만, 복잡한 경우에는 사용이 어려울 수 있다.
자세하게 정리한 내용은 여기서! 확인할 수 있다.
진짜!! 의미가 컸던 프로젝트였던 것 같다.
능력자 팀원분께서 다양한 템플릿, 편리한 기능, 개발 방법.. 등등을 제공해주셔서 편하게 개발할 수 있었고 덕분에 다양한 것들을 배우고 알 수 있었다.
이제 내가 배운 것들을 내 것으로 만들기 위해, 틈틈이 복습하는 것도 중요하겠다는 생각이 든다.
완성도가 낮아서 아쉬운 부분도 있었지만, 나머지 프로젝트가 끝나고 나서 이 프로젝트를 다시 완성시키고 싶을 만큼 팀원분들이 너무 좋았다! 열정 가득한 팀원들과 함께할 수 있어서 정말 감사하고 기뻤다!
'공부 > Project' 카테고리의 다른 글
Spring Cloud FeignClient N+1 호출 문제 해결하기 (0) | 2024.09.23 |
---|---|
Gateway, Spring Security, JWT 사용하여 인증 및 인가 처리하기 (0) | 2024.09.22 |
Springboot 키워드 검색 API 구현하기 (QueryDSL) (1) | 2024.09.01 |
Springboot JPA + QueryDSL 사용 시 no property found for type 오류 해결 (0) | 2024.08.31 |
JPA 게시물 조회 시 N+1 문제 해결방법 (feat. FetchType.LAZY) (0) | 2024.07.16 |