public class Duck {
public FlyBehavior flyBehavior;
public QuackBehavior quackBehavior;
public void performQuack() {
this.quackBehavior.quack();
}
public void performFly() {
this.flyBehavior.fly();
}
public void changeFlyBehavior(FlyBehavior flyBehavior) {
this.flyBehavior = flyBehavior;
}
public void changeQuackBehavior(QuackBehavior quackBehavior) {
this.quackBehavior = quackBehavior;
}
}
오리가 "소리를 내는 행동"과 "나는 행동"의 구현이 분리되어 있고, change메서드를 통해 바꿀 수도 있다는 것을 알 수 있다.
즉 소리를 내는 알고리즘과 나는 알고리즘이 위임되어 있는 것이다.
날기 인터페이스를 만들고 구현체를 2개 만들었다.
- FlyBehavior
public interface FlyBehavior {
void fly();
}
- FlyRocketPowered
public class FlyRocketPowered implements FlyBehavior {
@Override
public void fly() {
System.out.println("로켓 추진으로 날아갑니다.");
}
}
- FlyWithWings
public class FlyWithWings implements FlyBehavior {
@Override
public void fly() {
System.out.println("날개로 날아갑니다.");
}
}
소리를 내는 인터페이스와 구현체를 1개 만들었다.\
- QuackBehavior
public interface QuackBehavior {
void quack();
}
- KoreanQuack
public class KoreanQuack implements QuackBehavior {
@Override
public void quack() {
System.out.println("꽤괙");
}
}
Duck 클래스를 상속받은 NormalDuck을 만든다.
public class NormalDuck extends Duck {
public NormalDuck() {
this.quackBehavior = new KoreanQuack();
this.flyBehavior = new FlyWithWings();
}
}
이제 메인에서 실행시키면 아래와 같다.
public class Main {
public static void main(String[] args) {
Duck myDuck = new NormalDuck();
myDuck.performFly();
myDuck.performQuack();
System.out.println("=========================");
myDuck.changeFlyBehavior(new FlyRocketPowered());
myDuck.performFly();
myDuck.performQuack();
}
}
처음에는 날개로 날아갑니다와 꽤괙을 한다. changeFlyBehavior를 통해 날기를 로켓 추진으로 바꾼뒤에는 로켓 추진으로 날아갑니다가 출력된다.
기존에 QueryDSL을 사용하여 검색 유형(게시물 제목으로 검색, 유저 닉네임으로 검색)을 할때 if - else 구문을 통해 코드를 작성하였는데요 이렇게 될경우 코드가 지저분해짐과 동시에 코드를 수정할때 깜빡하고 까먹거나 철자 하나가 틀려 에러남에도 찾기 힘들어 질 수도 있습니다. 그래서 이것을 전략 패턴을 사용하여 바꿔보도록 하겠습니다.
코드 수정 : PostRepositoryImpl
생성 파일 :
정렬 관련 (OrderTypeStrategy, OrderCreate, OrderPopular)
검색 키워드관련 (SearchTypeStrategy, SearchTitle, SearchUser)
정렬 관련 코드
- OrderTypeStrategy
public interface OrderTypeStrategy {
OrderSpecifier<?> getOrder();
}
- OrderCreate
@Component("create")
public class OrderCreate implements OrderTypeStrategy {
@Override
public OrderSpecifier<?> getOrder() {
return post.date.createdAt.desc();
}
}
- OrderPopular
@Component("popular")
public class OrderPopular implements OrderTypeStrategy {
@Override
public OrderSpecifier<?> getOrder() {
return post.likeCount.desc();
}
}
구현 클래스에 @Component("컴포넌트명") 을 붙여줘 스프링에서 자동으로 bean으로 등록하게끔 설정 해줍니다.
검색 키워드관련 코드
- SearchTypeStrategy
public interface SearchTypeStrategy {
BooleanExpression search(String query);
}
- SearchTitle
@Component("title")
public class SearchTitle implements SearchTypeStrategy {
@Override
public BooleanExpression search(String query) {
return QPost.post.title.contains(query);
}
}
- SearchUser
@Component("user")
public class SearchUser implements SearchTypeStrategy {
@Override
public BooleanExpression search(String query) {
return QUser.user.nickname.eq(query);
}
}
구현 클래스에 @Component("컴포넌트명") 을 붙여줘 스프링에서 자동으로 bean으로 등록하게끔 설정 해줍니다.
컬렉션의 저장요소를 하나씩 참조해서 람다식(함수적스타일)로 처리할 수 있도록 해주는 반복자이다.
List<String> list = Arrays.asList("홍길동", "신용권", "김자바");
Stream<String> stream = list.stream();
stream.forEach( name -> System.out.println(name); }
//forEach 메소드는 다음과 같이 Consumer 함수적 인터페이스 타입의 매개값을 가지므로
// 컬렉션의 요소를 소비할 코드를 람다식으로 기술할 수 있다.
void forEach(Consumer<T> action)
스트림의 연산은 기존 자료를 변경하지 않는다. 자료에 대한 스트림을 생성하면 스트림이 사용하는 메모리 공간은 별도로 생성되므로 연산이 수행되도 기존 자료에 대한 변경은 발생하지 않는다.
스트림은 중간 처리와 최종 처리를 할 수 있다.
스트림은 컬렉션의 요소에 대해 중간 처리와 최종 처리를 수행할 수 있는데, 중간 처리에서는 매핑, 필터링, 정렬을 수행하고 최종 처리에서는 반복, 카운팅, 평균, 총합 등의 집계 처리를 수행한다.
스트림에 대해 중간 연산은 여러 개의 연산이 적용될 수 있지만 최종 연산은 마지막에 한 번만 적용된다.
그러므로 중간 연산에 대한 결과를 연산 중에 알수 없다.
최종처리가 시작되기 전까지 중간 처리는 지연(lazy)된다.
스트림 생성하고 사용하기
정수 배열에 스트림 생성하여 연산을 수행하기
public class StreamTest {
public static void main(String[] args) {
int[] arr = {1, 2, 3, 4, 5};
for (int i : arr) {
System.out.println("i = " + i);
}
// forEach : 배열의 인자들을 하나씩 꺼냄
Arrays.stream(arr).forEach(number -> System.out.println("number = " + number));
// 스트림은 사용하고 나면 항상 재생성해야한다.
int sum = Arrays.stream(arr).sum();
long count = Arrays.stream(arr).count();
System.out.println("sum = " + sum);
System.out.println("count = " + count);
}
}
예를 들면 File과 관련된 모듈이 있는데 이 모듈이 제공하는 것은 File 읽기, 쓰기, 만들기, 삭제하기와 같은 File과 관련된 작업만 있다면 응집도가 높다고 볼 수 있고 만약 File과 관련이 없는 작업(파일에서 읽어서 Array로 바꾸기 등)이 있다면 이러한 작업은 File 자체와는 관련이 없으므로 응집도가 낮다고 볼수 있다고 생각한다.
결합도
모듈(클래스)간의 상호 의존 정도를 나타내는 지표로써 결합도가 낮으면 모듈간의 상호 의존성이 줄어들어서 객체의 재사용 및 유지보수가 유리하다.
클래스는 일반 클래스와 추상 클래스로 나뉘는데 추상 클래스는 클래스 구현부 내부에 추상 메서드가 하나 이상 포함되거나 클래스가 abstract로 정의된 경우를 말합니다. 추상 메서드가 있다면 이를 상속한 자식 클래스에서 부모의 추상 메서드를 반드시 완성해야 합니다. 상속을 위한 클래스이기 때문에 따로 인스턴스화 할 수 없습니다.
일반 메서드를 선언하고 구현하면 이를 상속한 자손 클래스에서 따로 재정의할 필요없이 일반 메서드를 가져다 쓸 수 있습니다.
abstract class 클래스이름 {
...
public abstract void 메서드이름();
}
인터페이스
인터페이스는 추상클래스처럼 다른 클래스를 작성하는데 도움을 주는 목적으로 작성하고 클래스와 다르게 다중상속(구현)이 가능합니다.
interface 인터페이스 이름{
public static final 상수이름 = 값;
public void 메서드이름();
}
추상클래스와 인터페이스의 공통점과 차이점
공통점
둘다 추상 메서드를 가지고 있어 상속 or 구현 받아 재정의 해야합니다.
차이점
추상클래스 : IS - A 관계일때 사용 -> ~는 ~이다.
인터페이스 : HAS - A 관계일때 사용 -> ~는 ~를 할 수 있다.
만약 모든 클래스가 인터페이스를 사용해서 기본 틀을 구성한다면 공통으로 필요한 기능들도 모든 클래스에서 재정의해야하는 번거로움이 있습니다. 이렇게 공통된 기능이 필요하다면 추상 클래스를 이용하여 일반 메서드를 작성하여 자식 클래스에서 사용할 수 있도록 하면 됩니다. 그러나 자바는 하나의 클래스만 상속이 가능하기때문에 만약 각각 다른 추상클래스를 상속하고 있지만 공통된 기능이 필요하다면 해당 기능을 인터페이스로 작성해서 구현해야 합니다.
@Entity
@Getter
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PostLike {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "FK_PostLike_User"))
private User user;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "post_id", foreignKey = @ForeignKey(name = "FK_PostLike_Post"))
private Post post;
public static boolean isVotedPost(Optional<PostLike> optionalPostLike) {
return optionalPostLike.isPresent();
}
public void mappingUser(User user) {
this.user = user;
user.mappingPostLike(this);
}
public void mappingPost(Post post) {
this.post = post;
post.mappingPostLike(this);
}
}
Post와 User를 직접적으로 N : M 로 매핑하지 않고 중간 테이블을 만들어 1 : N - M : 1 로 매핑한 이유는 나중에 좋아요을 한 날짜나 다른 정보들이 필요한 경우 이렇게 중간 테이블을 만들어 놓는다면 중간 테이블에 관련된 데이터를 추가 하면 되지만 직접 적으로 연결 할 경우 등록하기 어렵기 때문입니다.
- PostLikeRepository
@Repository
public interface PostLikeRepository extends JpaRepository<PostLike, Long> {
Optional<PostLike> findByPostAndUser(Post post, User user);
}
여기서 눈여겨 보셔야 할 점이 PostRepository에 PostRepositoryCustom을 새롭게 상속받았다는 것입니다. 그 다음 PostRepositoryCustom을 구현하는 PostRepositoryImpl 클래스에서 postListQueryDSL 메서드를 구현하고 있는점입니다.
이렇게 하게되면 ServiceLayer에서는 새롭게 PostRepositoryCustom을 추가적으로 의존할 필요없이 기존의 PostRepository를 통해 postListQueryDSL 메서드를 사용 할 수 있습니다.
이제 postListQueryDSL 코드를 살펴보도록 하겠습니다.
getPostCategoryQuery(String category) 메서드는 동적 쿼리입니다.
API를 요청할때 Query Parameter에 category를 명시해준다면 좋겠지만 명시하지 않을경우 카테고리와 상관없이 모든글을 가져와야 합니다. 이런 경우를 생각하면 있을 경우와 없을경우, 카테고리가 무엇이냐에 따라 분기문으로 처리해야하는데 그것을 방지하기 위해 동적 쿼리를 사용하였습니다.
QueryDSL에서는 where절에 null이 들어갈경우 자동으로 무시하기 때문에 동적쿼리를 처리하기 좋습니다.
이제 본 메서드의 post, user, postCategory는 아까 gradle로 만들어준 QueryDSL 디렉토리 내부에 있는 Q엔티티 입니다. QueryDSL 에서는 이것을 이용하여 쿼리를 작성합니다.
join을 통해 게시물을 작성한 유저와 카테고리를 한꺼번에 가져오고 fetchJoin()을 통해 N + 1 문제를 해결하였습니다.
@RestController
@RequiredArgsConstructor
public class CommentController {
private final CommentService commentService;
@PostMapping("/post/{id}/comment")
@ResponseStatus(value = HttpStatus.OK)
@PreAuthorize("isAuthenticated()")
@ApiOperation(value = "댓글 쓰기", notes = "id에 해당하는 게시글에 댓글을 작성합니다.")
public SuccessResponse<String> createComment(@PathVariable(name = "id") Long postId,
@Valid @RequestBody CommentDTO commentDTO,
Principal principal) {
CommentDTO comment = commentService.createComment(postId, commentDTO, principal.getName());
return SuccessResponse.success(null);
}
}
- CommentService
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CommentService {
private final CommentRepository commentRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;
@Transactional
public CommentDTO createComment(Long postId, CommentDTO commentDTO, String email) {
Optional<Post> byId = postRepository.findById(postId);
Post post = byId.orElseThrow(() -> new PostNotFound("게시물이 삭제되었거나 존재하지 않습니다."));
Optional<User> byEmail = userRepository.findByEmail(email);
User user = byEmail.orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다."));
Comment comment = Comment.builder()
.contents(commentDTO.getContents())
.build();
comment.mappingPostAndUser(post, user);
Comment saveComment = commentRepository.save(comment);
return CommentDTO.convertToCommentDto(saveComment);
}
}
- CommentEntity
@Entity
@Getter
@Builder
@AllArgsConstructor(access = PROTECTED)
@NoArgsConstructor(access = PROTECTED)
public class Comment {
@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String contents;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "post_id", foreignKey = @ForeignKey(name = "FK_user_comment"))
private Post post;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "FK_post_comment"))
private User user;
public void mappingPostAndUser(Post post, User user) {
this.post = post;
this.user = user;
post.mappingComment(this);
user.mappingComment(this);
}
}
- CommentRepository
@Repository
public interface CommentRepository extends JpaRepository<Comment, Long> {
}
이전 Json으로 로그인하기를 했을때 UserController의 login 메서드에서 email, password를 받아 UsernamePasswordAuthenticationToken을 만들어주고 이것을 UserAuthenticationProvider에 넘겨주었습니다. Provider에서 넘겨준 토큰으로 email과 password를 꺼내 DB에서 유저 정보를 조회해 로그인이 성공하면 UsernamePasswordAuthenticationToken으로 Authentication을 리턴하였습니다. 그로인해 로그인이 Principal를 통해 현재 로그인한 유저의 정보를 가져올 수 있습니다.
- PostService
public PostDTO createPost(PostDTO postDTO, String email) {
Optional<User> byEmail = userRepository.findByEmail(email);
User user = byEmail.orElseThrow(() -> new UsernameNotFoundException("게시글 작성 권한이 없습니다."));
Post post = Post.builder()
.title(postDTO.getTitle())
.contents(postDTO.getContents())
.createAt(LocalDateTime.now())
.build();
post.mappingCategory(postCategoryRepository.findByName(postDTO.getCategory()));
post.mappingUser(user);
Post savePost = postRepository.save(post);
return PostDTO.builder()
.id(savePost.getId())
.build();
}
받은 email로 DB에서 유저를 찾은 다음에 post에 매핑시켜 줍니다.
- UserEntity, PostEntity
- UserEntity
@Entity
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String password;
private String nickname;
private LocalDateTime createdBy;
@Builder.Default
private String role = "ROLE_USER";
private String authority;
private boolean enabled = true;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "user")
private List<Post> postList = new ArrayList<>();
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public void encryptPassword(String password) {
this.password = BCrypt.hashpw(password, BCrypt.gensalt());
}
public void mappingPost(Post post) {
postList.add(post);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<GrantedAuthority> auth = new HashSet<>();
auth.add(new SimpleGrantedAuthority(authority));
return auth;
}
@Override
public String getUsername() {
return this.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return this.enabled;
}
}
- PostEntity
@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String contents;
private LocalDateTime createAt;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "post_category_id")
private PostCategory category;
@Builder.Default
private Long viewCount = 0L;
public void addViewCount() {
this.viewCount += 1;
}
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
////////////////////////////////////////////////////////////////////////////////////////////////
public void changeTitle(String title) {
this.title = title;
}
public void changeContents(String contents) {
this.contents = contents;
}
public void mappingCategory(PostCategory postCategory) {
this.category = postCategory;
postCategory.mappingPost(this);
}
public void mappingUser(User user) {
this.user = user;
user.mappingPost(this);
}
}
유저는 포스트를 여러개 작성 할수 있고 포스트는 유저 1명에게 속해 있어야하니 유저 : 포스트 는 1 : N 관계 입니다. 그러므로 User Entity에는 @ManyToOne 으로 매핑 시켜 주고, 포스트에는 @OneToMany로 매핑 시켜줍니다. N인 Post가 매핑관계의 주인이 되는게 좋으므로 mappedBy를 통해 Post의 user 필드를 지정해 주고 Post Entity에 유저를 매핑시켜줄 연관관계 메서드를 작성해줍니다.
여기 까지 진행한 후 게시물을 만들고 getPost를 통해 게시물을 조회하게 되면 쿼리가 2개가 발생하게 됩니다. Post를 가져올때 한번, Post에 매핑된 User를 가져올때 한번 일어나게 되는데요 이것을 쿼리 한번에 조회하기 위해 PostRepository를 수정해줍니다.