전략패턴은 실행 중에 알고리즘을 선택할 수 있게 하는 행위 소프트웨어 디자인 패턴이다. - 위키백과

 

전략 패턴 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 전략 패턴(strategy pattern) 또는 정책 패턴(policy pattern)은 실행 중에 알고리즘을 선택할 수 있게 하는 행위 소프트웨어 디자인 패턴이다. 전략 패턴은 특정한 계열

ko.wikipedia.org

 

코드

- Duck

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를 통해 날기를 로켓 추진으로 바꾼뒤에는 로켓 추진으로 날아갑니다가 출력된다.

1. SPR(Single Responsibility Principle) - 단일 책임의 원칙

시스템의 모든 객체는 하나의 책임만을 가져야한다. 객체가 제공하는 모든 기능은 단 하나의 책임을 수행하는데 집중되어 있어야 한다.

 

2. OCP(Open Closed Principle) - 개방 폐쇄 원칙

기존에 개발된 클래스에 수정사항이 발생하면 그것을 사용하는 클래스에 사이드 이펙트가 발생할 수 있다.

이것을 방지하기 위해 기존 클래스를 수정하지 않고 대신 새로운 클래스나 기능을 만들어 확장해야 한다.

클래스는 기능 확장에 대해서는 열려있지만, 코드 수정에 대해서는 닫혀있어야 한다.

예시) 

  • JDBC와 Mybatis, Hibernate 등
  • JAVA의 Stream(Input, Out)

 

3. LSP(Liskov Substitution Principle) - 리스코프 치환 원칙

LSP란 '자식 클래스는 부모 클래스가 사용되는 곳에 대체될 수 있어야 한다'는 원칙이다.

어떤 클래스가 부모 클래스 또는 인터페이스를 의존한다면 해당 부모클래스를 상속한 자식클래스나 인터페이스를 구현한 클래스의 다형성으로 언제든 변경 가능하다.

 

4. ISP(Interface Segregation Principle) - 인터페이스 분리 원칙

ISP는 SRP와 비슷하다.

SRP는 클래스 관점에서 '클래스는 자신이 사용하지 않는 메소드에 의존하면 안된다'. 라는 인터페이스 사용 가이드라인을 제시한다.

ISP는 인터페이스 관점에서 '클래스는 자신이 사용하지 않는 메소드에 의존하면 안된다'. 라는 인터페이스 사용 가이드라인을 제시한다.

 

5. DRY(Don't Repeat Yourself) / DIP(Dependency Inversion Principle)

  • DRY : 공통되는 부분은 추출하여 추상화하고 한곳에 두어 중복코드를 피하라
  • DIP : 구체적인 클래스 대신 추상적인 클래스에 의존하라는 뜻이다. 
// 사용하지말기
ArrayList list = new ArrayList()

// 사용하기
List list = new ArrayList();

클라이언트 클래스에서는 부모 클래스만 의존하고 자식 클래스를 인자로 넘겨받아 저장한다

 

 

'개발 지식' 카테고리의 다른 글

동시성과 병렬성  (0) 2021.06.08
REST API  (0) 2021.05.19
웹 서버와 WAS 의 차이점  (0) 2021.05.19
응집도와 결합도  (0) 2021.05.06

안녕하세요 이번 시간에는 리팩토링을 해보도록 하겠습니다.

전체 코드는 아래에서 보실 수 있습니다.

https://github.com/sug5806/board-project-rest

 

sug5806/board-project-rest

Contribute to sug5806/board-project-rest development by creating an account on GitHub.

github.com

 

기존에 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으로 등록하게끔 설정 해줍니다.

 

- PostRepositoryImpl

@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;
    private final Map<String, OrderTypeStrategy> orderTypeStrategyMap;
    private final Map<String, SearchTypeStrategy> searchTypeStrategyMap;
    
    
    ..............
    
        private BooleanExpression postSearchQuery(SearchDTO searchDTO) {
        SearchType searchType = SearchType.convertToType(searchDTO.getSearchType());

        if (Strings.hasText(searchDTO.getQuery())) {
            SearchTypeStrategy searchTypeStrategy = searchTypeStrategyMap.get(searchType.getType());

            return searchTypeStrategy.search(searchDTO.getQuery());
        }

        return null;

    }
    
    ............
    
        private OrderSpecifier<?> sortingCondition(Sort sort) {
        OrderSpecifier<?> orderBy = null;

        if (!sort.isEmpty()) {
            List<Sort.Order> orders = sort.toList();
            Sort.Order order = orders.get(0);
            OrderType orderType = OrderType.convertToType(order.getProperty());

            OrderTypeStrategy orderTypeStrategy = orderTypeStrategyMap.get(orderType.getType());

            orderBy = orderTypeStrategy.getOrder();
        }

        return orderBy;
    }
    
}

 

Map으로 선언하여 스프링에서 자동으로 아까 지정한 컴포넌트의 이름으로 인터페이스 타입을 구현한 클래스들을 주입시켜 줍니다.

그리하여 검색 타입에따라 SearchTitle, SearchUser를 사용하고 정렬 조건에따라 OrderPopular, OrderCreate를 사용하게됩니다.

컬렉션의 저장요소를 하나씩 참조해서 람다식(함수적스타일)로 처리할 수 있도록 해주는 반복자이다.

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);
    }
}

 

중간 연산과 최종 연산

  • 중간 연산의 예 - filter(), map(), sorted() 등
    • 조건에 맞는 요소를 추출(filter)하거나 요소를 변환 함(map)
  • 최종 연산이 호출될 때 중간 연산이 수행되고 결과가 생성됨

문자열 리스트에서 문자열의 길이가 5이상인 요소만 출력하기

 String[] stringList = {"qwerty", "Longest", "Substring", "add", "divide", "multiple", "sub"};
 Arrays.stream(stringList).filter(s -> s.length() >= 5).forEach(System.out::println);

filter()는 중간 연산이고, forEach는 최종 연산이다.

중간 연산과 최종 연산에 대한 구현은 람다식을 활용한다.

public class ArrayListStreamTest {
    public static void main(String[] args) {
        List<String> stringList = new ArrayList<>();
        stringList.add("Tomas");
        stringList.add("Edward");
        stringList.add("Jack");

        Stream<String> stream = stringList.stream();
        stream.forEach(System.out::println);

        stringList.stream().sorted().forEach(string -> System.out.print(string + "\t"));
        System.out.println();
        stringList.stream().map(String::length).forEach(len -> System.out.print(len + "\t"));
    }
}

'JAVA' 카테고리의 다른 글

POJO Java  (0) 2021.05.09
Java I/O Stream - 4 성능 향상 보조 스트림  (0) 2021.05.07
Java I/O Stream -2 파일 입출력  (0) 2021.05.07
Java I/O Stream -3 보조 스트림  (0) 2021.05.07
Java I/O Stream -1 기본 스트림  (0) 2021.05.07

흔히들 설계를 할때 응집도는 높게 결합도는 낮게 라는 말을 많이 쓴다. 

응집도가 뭐길래 높은게 좋은거며 결합도는 또 뭐길래 낮은게 좋다고 하는걸까?

응집도

응집도란 모듈 내부의 기능적인 응집 정도를 나타낸다. 

예를 들면 File과 관련된 모듈이 있는데 이 모듈이 제공하는 것은 File 읽기, 쓰기, 만들기, 삭제하기와 같은 File과 관련된 작업만 있다면 응집도가 높다고 볼 수 있고 만약 File과 관련이 없는 작업(파일에서 읽어서 Array로 바꾸기 등)이 있다면 이러한 작업은 File 자체와는 관련이 없으므로 응집도가 낮다고 볼수 있다고 생각한다.

결합도

모듈(클래스)간의 상호 의존 정도를 나타내는 지표로써 결합도가 낮으면 모듈간의 상호 의존성이 줄어들어서 객체의 재사용 및 유지보수가 유리하다.

 

'개발 지식' 카테고리의 다른 글

동시성과 병렬성  (0) 2021.06.08
REST API  (0) 2021.05.19
웹 서버와 WAS 의 차이점  (0) 2021.05.19
객체지향 SOLID 5계명  (0) 2021.05.08

IoC(Inversion of Control)

IoC는 Inversion of Control의 약자로 제어의 역전이라는 의미이다. 기존에는 프로그래머가 직접 Object를 생성하고 관리하였으나 IoC에서는 프로그래머가 관리하지 않는다. 모든 제어 권한을 자신이 아닌 다른 대상에게 위임하기 때문이다.

스프링 IoC 컨테이너('스프링 컨테이너' 라고도 한다)는 스프링 애플리케이션에서 애플리케이션에 존재하는 객체를 생성하고 의존 관계를 주입하는 일을 담당한다. 스프링 컨테이너가 생성하고 관리하는 애플리케이션 객체들을 Bean이라고 부른다.

의존 관계를 만들고 주입하는 책임은 애플리케이션의 객체가 아닌 스프링 컨테이너에 있기 때문에 DI를 제어의 역전(IoC)이라고도 부른다. 그리고 객체를 생성 ~ 생명주기 관리를 컨테이너가 도맡아서 하게 된다.

스프링은 기본적으로 별다른 설정을 하지 않으면 내부에서 생성하는 빈 오브젝트를 모두 싱글톤으로 만든다.


BeanFactory

BeanFactory는 스프링의 IoC를 담당하는 핵심 컨테이너이다. BeanFactory를 직접 사용할 일은 거의 없으며, 부가기능이 포함된 ApplicationContext를 사용한다.


 

ApplicationContext

applicationContext는 BeanFactory를 상속받아 확장한 IoC 컨테이너이며 BeanFactory 이외의 여러 가지 부가기능을 사용할 수 있다.


Dependency Injection

DI는 말 그대로 의존적인 객체를 직접 생성하거나 제어하는 것이 아니라, 특정 객체에 필요한 객체를 외부에서 결정해서 연결시키는 것을 의미한다. 

인터페이스에 대해서만 의존관계를 만들어두면 인터페이스 구현 클래스와의 관계는 느슨해지면서 변화에 영향을 덜 받는다(결합도가 낮음).의존관계란 한쪽의 변화가 다른 쪽에 영향을 주는 것인데, 인터페이스를 통해 의존 관계를 제안해주면 그만큼 변화로부터 자유로워질 수 있다.

인터페이스를 통해 설계 시점에 느슨한 의존관계를 갖는 경우에는 런타임 시에 사용할 오브젝트가 어떤 클래스로 만든 것인지 미리 알 수 없다. 프로그램이 시작되고 오브젝트가 만들어지고 나서 런타임 시에 의존 관계를 맺는 대상을 의존 오브젝트라고 한다.

즉, 의존 관계 주입은 의존 오브젝트와 그것을 사용할 주체 오브젝트를 런타임 시에 연결해주는 작업을 말한다.

의존관계 주입을 사용하면 정적인 클래스 의존관계를 변경하지않고, 동적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있다.

의존관계 주입(DI)의 핵심은 설계 시점에 알지 못했던 두 오브젝트의 관계를 맺도록 도와주는 제3의 존재가 있다는 것이다. 여기서 제 3의 존재는 바로 애플리케이션 컨텍스트, 빈 팩토리, IoC 컨테이너라고 볼 수 있다.

스프링의 DI를 사용하면 "기존 코드를 전혀 손대지 않고, 설정만으로 구현 클래스를 변경"할 수 있다.

 

참고 : IoC - spring (gitbook.io)

 

IoC

 

dahye-jeong.gitbook.io

 

String 클래스

  • String 선언하기
  • 힙 메모리는 생성될때마다 다른 주소값을 가지지만, 상수 풀의 문자열은 모두 같은 주소 값을 가짐
// 힙 메모리에 생성
String str1 = new STring("abc");
// 상수풀에 있는 주소를 참조
String str2 = "abc";
public class StringTest {
	public static void main(String[] args) {
    	String str1 = new String("abc");
        String str2 = new String("abc");
        
        // false
        System.out.println(str1 == str2);
        
        String str3 = "abc";
        String str4 = "abc";
        
        // true
        System.out.println(str3 == str4);
    }
}
        

한번 생성된 String은 불변(immutable)

String을 연결하면 기존의 String에 연결되는 것이 아닌 새로운 문자열이 생성된다.(메모리 낭비 발생 가능)

public class StringTest {
    public static void main(String[] args) {
        String java = new String("java");
        String android = new String("android");

        System.out.println(System.identityHashCode(java));

        java = java.concat(android);

        System.out.println(java);
        System.out.println(System.identityHashCode(java));
        
        // 6번째와 11번째의 해쉬코드가 서로 다르다
    }
}

StringBuilder, StringBuffer

String의 위와같은 문제점으로 인해 StringBuilder 또는 StringBuffer의 사용을 권장한다.

내부적으로 가변적인 char[]를 멤버 변수로 가져 문자열을 연결하거나 변경해도 새로운 인스턴스를 생성하지 않고 내부의 char[]를 변경한다.

StringBuffer는 멀티 쓰레드 프로그래밍에서 동기화를 보장한다.

- StringBuilder 사용

public class StringBuilderTest {
    public static void main(String[] args) {
        System.out.println("StringBuilderTest");

        String java = new String("java");
        String android = new String("android");

        StringBuilder builder = new StringBuilder(java);
        System.out.println("빌더 처음 해시코드: " + System.identityHashCode(builder));
        builder.append(android);
        System.out.println("빌더에 android 추가한 뒤 해시코드 : " + System.identityHashCode(builder));
        System.out.println("builder = " + builder.toString());
    }
}

 

-StringBuffer 사용

public class StringBufferTest {
    public static void main(String[] args) {
        System.out.println("StringBufferTest");

        String java = new String("java");
        String android = new String("android");

        StringBuffer buffer = new StringBuffer(java);
        System.out.println("버퍼 처음 해시코드: " + System.identityHashCode(buffer));
        buffer.append(android);
        System.out.println("버퍼에 android 추가한 뒤 해시코드 : " + System.identityHashCode(buffer));
        System.out.println("buffer = " + buffer.toString());
    }
}

'JAVA' 카테고리의 다른 글

Java Stream  (0) 2021.05.07
Java I/O Stream -2 파일 입출력  (0) 2021.05.07
Java I/O Stream -3 보조 스트림  (0) 2021.05.07
Java I/O Stream -1 기본 스트림  (0) 2021.05.07
인터페이스와 추상클래스의 차이  (0) 2021.05.05

추상 클래스 

클래스는 일반 클래스와 추상 클래스로 나뉘는데 추상 클래스는 클래스 구현부 내부에 추상 메서드가 하나 이상 포함되거나 클래스가 abstract로 정의된 경우를 말합니다. 추상 메서드가 있다면 이를 상속한 자식 클래스에서 부모의 추상 메서드를 반드시 완성해야 합니다. 상속을 위한 클래스이기 때문에 따로 인스턴스화 할 수 없습니다.

일반 메서드를 선언하고 구현하면 이를 상속한 자손 클래스에서 따로 재정의할 필요없이 일반 메서드를 가져다 쓸 수 있습니다.

abstract class 클래스이름 {
	...
    public abstract void 메서드이름();
}

인터페이스

인터페이스는 추상클래스처럼 다른 클래스를 작성하는데 도움을 주는 목적으로 작성하고 클래스와 다르게 다중상속(구현)이 가능합니다.

interface 인터페이스 이름{
	public static final 상수이름 = 값;
    public void 메서드이름();
}

 

 

추상클래스와 인터페이스의 공통점과 차이점

공통점

  • 둘다 추상 메서드를 가지고 있어 상속 or 구현 받아 재정의 해야합니다.

차이점

  • 추상클래스 : IS - A 관계일때 사용 -> ~는 ~이다.
  • 인터페이스 : HAS - A 관계일때 사용 -> ~는 ~를 할 수 있다.

 

만약 모든 클래스가 인터페이스를 사용해서 기본 틀을 구성한다면 공통으로 필요한 기능들도 모든 클래스에서 재정의해야하는 번거로움이 있습니다. 이렇게 공통된 기능이 필요하다면 추상 클래스를 이용하여 일반 메서드를 작성하여 자식 클래스에서 사용할 수 있도록 하면 됩니다. 그러나 자바는 하나의 클래스만 상속이 가능하기때문에 만약 각각 다른 추상클래스를 상속하고 있지만 공통된 기능이 필요하다면 해당 기능을 인터페이스로 작성해서 구현해야 합니다.

 

참고 :[JAVA] 추상클래스 VS 인터페이스 왜 사용할까? 차이점, 예제로 확인 :: 마이자몽 (tistory.com) 

 

[JAVA] 추상클래스 VS 인터페이스 왜 사용할까? 차이점, 예제로 확인 :: 마이자몽

추상클래스 인터페이스 왜... 사용할까? 우리는 추상클래스와 인터페이스에 대해서 알고 있냐고 누가 물어본다면 알고 있다고 대답을 하고있습니다. 그런데 이론적인 내용 말고 정작 "왜 사용하

myjamong.tistory.com

 

'JAVA' 카테고리의 다른 글

Java Stream  (0) 2021.05.07
Java I/O Stream -2 파일 입출력  (0) 2021.05.07
Java I/O Stream -3 보조 스트림  (0) 2021.05.07
Java I/O Stream -1 기본 스트림  (0) 2021.05.07
String, StringBuilder, StringBuffer 클래스  (0) 2021.05.06

안녕하세요 이번시간에는 이미지 업로드를 만들어 보도록 하겠습니다.

전체 코드는 여기 에서 볼 수 있습니다.

 

sug5806/board-project-rest

Contribute to sug5806/board-project-rest development by creating an account on GitHub.

github.com

build.gradle, application.yml 파일을 수정하고

FileUploadDTO, FileController, FileService 을 새로 만들어야 합니다.

 

- build.gradle

// 확장자 구하기용 라이브러리
    compile group: 'commons-io', name: 'commons-io', version: '2.6'

    // aws
    compile group: 'org.springframework.cloud', name: 'spring-cloud-starter-aws', version: '2.2.1.RELEASE'

 

- application.yml

cloud:
  aws:
    s3:
      bucket: hese-board
    region:
      static: ap-northeast-2
    credentials:
      accessKey: ${accessKey}
      secretKey: ${secretKey}
      use-default-aws-credentials-chain: false
      instance-profile: true
    stack:
      auto: false

accessKey와 secretKey에 ${} 이렇게 되어있는데 노출을 피하고자 외부에서 주입하는 것입니다.

이것을 IDE에서 적용하기 위해선

Program arguments에 넣어주시면 읽어서 사용할 수 있습니다.

 

 

- FileUploadDTO

@Data
@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class FileUploadDTO {
    private String url;
}

 

- FileController

@RestController
@RequiredArgsConstructor
public class FileController {

    private final FileService fileService;

    @PostMapping("/image/upload")
    @PreAuthorize("isAuthenticated()")
    public SuccessResponse<FileUploadDTO> imageUpload(@RequestPart MultipartFile file, Principal principal) throws IOException {
        FileUploadDTO fileUploadDTO = fileService.fileUpload(file, principal.getName());
        return SuccessResponse.success(fileUploadDTO);
    }

}

@RequestPart를 통해 Multipart를 받는것을 적용해주고 file이라는 키값으로 파일을 받습니다. 만약 이 이름이 file이 아니라 image라면 파일을 보낼때 키값을 image로 바꿔 보내주면 됩니다.

 

- FileService

@Service
@RequiredArgsConstructor
@Slf4j
public class FileService {

    private final AmazonS3Client amazonS3Client;

    @Value("${cloud.aws.s3.bucket}")
    private String bucket;

    public FileUploadDTO fileUpload(MultipartFile file, String email) throws IOException {

        File uploadFile = convert(file).orElseThrow(() -> new IllegalArgumentException("파일 업로드에 실패하였습니다."));

        String dir = "static/images/".concat(email);

        return upload(uploadFile, dir);
    }

    private FileUploadDTO upload(File uploadFile, String dir) {
        String sourceName = uploadFile.getName();
        String sourceExt = FilenameUtils.getExtension(sourceName).toLowerCase();

        String fileName = dir + "/" + LocalDateTime.now().toString().concat(".").concat(sourceExt);
        String uploadImageUrl = putS3(uploadFile, fileName);
        removeNewFile(uploadFile);

        return FileUploadDTO.builder()
                .url(uploadImageUrl)
                .build();
    }

    private void removeNewFile(File targetFile) {
        if (targetFile.delete()) {
            log.info("파일이 삭제되었습니다.");
        } else {
            log.info("파일이 삭제되지 못했습니다.");
        }
    }

    private String putS3(File uploadFile, String fileName) {
        try {
            amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
                    .withCannedAcl(CannedAccessControlList.PublicRead));
        } catch (Exception e) {
            log.error("이미지 s3 업로드 실패");
            log.error(e.getMessage());
            removeNewFile(uploadFile);
            throw new RuntimeException();
        }

        return amazonS3Client.getUrl(bucket, fileName).toString();
    }

    private Optional<File> convert(MultipartFile file) throws IOException {
        File convertFile = new File(Objects.requireNonNull(file.getOriginalFilename()));
        if (convertFile.createNewFile()) {
            try (FileOutputStream fos = new FileOutputStream(convertFile)) {
                fos.write(file.getBytes());
            }

            return Optional.of(convertFile);
        }

        return Optional.empty();
    }

}

AWS S3에 이미지를 올릴때는 유저가 올린 이미지를 구분하기 위해 이메일로 경로를 만들어 하였습니다.

해당 코드의 자세한 내용은 https://jojoldu.tistory.com/300여기에 자세히 나와있습니다.

 

안녕하세요 이번 시간에는 게시물 좋아요 기능을 만들어 보도록 하겠습니다.

수정할 파일 : PostController, PostService, PostEntity, UserEntity

생성할 파일 : PostLikeEntity, PostLikeRepository

- PostController

   @PostMapping("/post/{id}/like")
    @PreAuthorize("isAuthenticated()")
    public SuccessResponse<String> postLike(@PathVariable(name = "id") Long postId, Principal principal) {
        postService.postLike(postId, principal.getName());
        return SuccessResponse.success(null);
    }

좋아요 할 게시물의 id와 현재 로그인 중인 유저의 email을 Service에 넘겨줍니다.

 

 

- PostService

   public void postLike(Long postId, String email) {
        Post post = getPostInService(postId);
        User user = getUserInService(email);
        Optional<PostLike> byPostAndUser = postLikeRepository.findByPostAndUser(post, user);

        byPostAndUser.ifPresentOrElse(
		// 좋아요 있을경우 삭제
                postLike -> {
                    postLikeRepository.delete(postLike);
                    post.discountLike(postLike);
                },
                // 좋아요가 없을 경우 좋아요 추가
                () -> {
                    PostLike postLike = PostLike.builder().build();

                    postLike.mappingPost(post);
                    postLike.mappingUser(user);
                    post.updateLikeCount();

                    postLikeRepository.save(postLike);
                }
        );
    }

ifPresentOrElse 구문은 게시물과 유저로 찾아 좋아요을 했을 경우와 하지 않았을 경우를 나눠 로직을 작성할수 있습니다.

좋아요가 있을경우 테이블에서 삭제를 한 후 Post에서 LikeCount를 하나 줄여줍니다.

좋아요가 없다면 user와 post를 매핑해 준 후 LikeCount를 업데이트 해줍니다.

 

- PostEntity

@OneToMany(fetch = LAZY, mappedBy = "post", cascade = CascadeType.REMOVE)
    private List<PostLike> postLikeList = new ArrayList<>();
    
    
public void mappingPostLike(PostLike postLike) {
        this.postLikeList.add(postLike);
    }

    public void updateLikeCount() {
        this.likeCount = (long) this.postLikeList.size();
    }

    public void discountLike(PostLike postLike) {
        this.postLikeList.remove(postLike);

    }

 

- UserEntity

 @OneToMany(fetch = FetchType.LAZY, mappedBy = "user", cascade = CascadeType.REMOVE)
    private List<PostLike> postLikeList = new ArrayList<>();
    
  public void mappingPostLike(PostLike postLike) {
        this.postLikeList.add(postLike);
    }

 

- PostLikeEntity

@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);
}

게시물과 유저로 좋아요를 찾습니다.

JPA(Java Persistence API)

자바 진영의 ORM 기술 표준

 

ORM?

- Object-relational mapping(객체 관계 매핑)

- 객체는 객체대로 설계

- 관계형 데이터베이스는 관계형 데이터베이스대로 설계

- ORM 프레임워크가 중간에서 매핑

- 대중적인 언어에는 대부분 ORM 기술이 존재한다.

 

JPA는 애플리케이션과 JDBC 사이에서 동작한다.

개발자가 직접 JDBC API를 쓰는게 아니라 개발자가 JPA를 사용하면 JPA 내부에서 JDBC API를 사용해 DB와 통신한다.

 

JPA 저장 과정

예를 들어 MemberDAO에서 데이터를 저장하고 싶다면

  1. JPA에게 Member 객체를 넘긴다.
  2. JPA가 Member 객체를 분석한다.
  3. JPA가 Insert Query를 사용
  4. JPA가 JDBC API를 사용하여 DB에 보낸다.

 

JPA 조회 과정

MemberDAO에서 멤버를 찾고싶다면

  1. JPA에게 멤버의 ID값을 넘긴다.
  2. JPA가 ID값으로 SQL query를 만든다.
  3. JDBC API 통해 DB에 보내고 결과를 받는다.
  4. 결과를 가지고 Member Object에 매핑을 해준다.

 

JPA는 표준 명세이다. 내부 코드는 거의 인터페이스로 되어있다. JPA 표준 명세를 구현한 3가지가 있는데 하이버네이트, EclipseLink, DataNucleus가 있다.

 

JPA를 왜 사용해야 할까?

  1. SQL 중심적인 개발에서 객체 중심으로 개발
  2. 생산성
  3. 유지보수
  4. 패러다임의 불일치 해결
  5. 성능
  6. 데이터 접근 추상화와 벤더 독립성
  7. 표준

생산성 - CRUD 만들기가 매우 편하다.

  • 저장 : jpa.persist(member)
  • 조회 : Member member = jpa.find(memberId)
  • 수정 : member.setName("변경할 이름")
  • 삭제 : jpa.remove(member)

유지보수 - SQL을 사용하면 필드가 추가, 제거되면 모든 SQL을 수정 해야한다. 그러나 JPA를 사용한다면 필드만 추가하면 JPA가 알아서 해준다.


성능 

  • 1차 캐시와 동일성 보장

  • 트랜잭션을 지원하는 쓰기지연 - Insert
    • 트랜잭션을 커밋할 때까지 INSERT SQL을 모아둔다.

 


하이버 네이트란?

하이버네이트는 자바 환경에서의 ORM이다.

ORM은 객체와 데이터베이스 테이블간에 데이터 타입이나 관계를 자동으로 매핑시켜 해결해준다.

 


Spring Data JPA란?

JPA를 쓰기 편하게 만들어 놓은 모듈로 개발자가 JPA를 더 쉽고 편하게 사용할 수 있도록 도와준다. 

Repository라는 인터페이스를 사용하여 정해진 규칙대로 메소드를 입력하면, Spring이 알아서 해당 메소드 이름에 적합한 쿼리를 날리는 구현체를 만들어 Bean으로 등록해준다.

 


하이버네이트와 Spring Data JPA의 차이점

하이버네이트는 JPA의 구현체이고 Spring Data JPA는 JPA에 대한 데이터 접근의 추상화라고 할 수 있다.

Spring Data JPA는 항상 하이버네이트와 같은 JPA 구현체가 필요하다.

 

참고 : 자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런 | 강의 (inflearn.com)

 

자바 ORM 표준 JPA 프로그래밍 - 기본편 - 인프런 | 강의

JPA를 처음 접하거나, 실무에서 JPA를 사용하지만 기본 이론이 부족하신 분들이 JPA의 기본 이론을 탄탄하게 학습해서 초보자도 실무에서 자신있게 JPA를 사용할 수 있습니다., 본 강의는 자바 백엔

www.inflearn.com

JPA란? - spring (gitbook.io)

 

JPA란?

 

dahye-jeong.gitbook.io

 

안녕하세요 저번 시간에는 게시물의 카테고리로 검색하는 기능을 추가하였습니다.

이번 시간에는 유저 닉네임, 게시물의 제목으로 검색하는 기능을 만들어 보도록 하겠습니다.

코드는 크게 달라진것은 없으며 SearchType이 추가되고, PostRepositoryImpl 만 수정 하면 됩니다.

- SearchType, PostRepositoryImpl

- SearchType

@RequiredArgsConstructor
public enum SearchType {
    TITLE("title", "포스트 제목으로 검색"),
    USER("user", "유저명으로 검색");

    @Getter
    private final String type;

    @Getter
    private final String description;

    public static SearchType convertToType(String stringType) {
        return Arrays.stream(values())
                .filter(searchType -> searchType.type.equals(stringType))
                .findAny()
                .orElse(TITLE);
    }
}

convertToType 메서드는 values()로 SearchType의 enum값을 배열로 가져온뒤 filter로 stringType과 enum의 type이 동일 하면 해당 enum을 리턴하고 일치하는 enum이 없을시 TITLE enum을 리턴하는 메서드 입니다.

 

- PostRepositoryImpl

@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<Post> postListQueryDSL(SearchDTO searchDTO) {
        BooleanExpression postCategoryQuery = postCategoryQuery(searchDTO.getCategory());
        BooleanExpression postSearchQuery = postSearchQuery(searchDTO);

        return jpaQueryFactory
                .selectFrom(post)
                .where(postCategoryQuery, postSearchQuery)
                .join(post.user, user).fetchJoin()
                .join(post.category, postCategory).fetchJoin()
                .fetch();
    }

    private BooleanExpression postCategoryQuery(String category) {
        if (StringUtils.hasLength(category)) {
            return post.category.eq(getPostCategory(category));
        }

        return null;
    }

    private BooleanExpression postSearchQuery(SearchDTO searchDTO) {
        SearchType searchType = SearchType.convertToType(searchDTO.getSearchType());

        if (searchType == SearchType.USER) {
            return user.nickname.eq(searchDTO.getQuery());
        }
        return post.title.contains(searchDTO.getQuery());

    }

    private PostCategory getPostCategory(String category) {
        return jpaQueryFactory
                .selectFrom(postCategory)
                .where(postCategory.name.eq(category))
                .fetchOne();
    }
}

기존의 코드에서 postSearchQuery가 추가되었습니다. 이 메서드는 searchType에 따라 유저로 검색하는지, 게시물로 검색하는지에 따라where문에 들어갈 쿼리가 달라지게 됩니다.

안녕하세요 이번 시간에는 QueryDSL을 사용하여 게시물 카테고리로 게시물을 검색하는 기능을 만들어 보겠습니다.

QueryDSL을 사용하기위해 build.gradle에 라이브러리를 추가해주도록 하겠습니다.

plugins {
    id 'org.springframework.boot' version '2.4.5'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'

    //querydsl 추가
    id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}

group = 'hose'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

    // test용 assertj 추가
    testCompile 'org.assertj:assertj-core:3.19.0'

    // spring security 추가
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // jwt
    compile group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
    runtime group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
    runtime group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

    // swagger
    compile('io.springfox:springfox-swagger2:2.7.0')
    compile('io.springfox:springfox-swagger-ui:2.7.0')

    //querydsl 추가
    implementation 'com.querydsl:querydsl-jpa'
}

test {
    useJUnitPlatform()
}

// querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}
sourceSets {
    main.java.srcDir querydslDir
}
configurations {
    querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
    options.annotationProcessorPath = configurations.querydsl
}
// querydsl 추가 끝

 

설치를 하고 나서 IntelliJ를 사용하신다면 오른쪽 Gradle에서 cleanQuerydslSourcedDir을 눌러 줍니다.

 

그 후 other에서 compileQuerydsl을 해줍니다 그러고 나면 build > generated > querydsl 디렉토리가 생기며 내부에 Q가 붙은 엔티티가 생깁니다.

 

만약 IntelliJ가 없다면 ./gradlew cleanQuerydslSourcesDir 과 ./gradlew compileQuerydsl 을 명령어를 실행시켜 줍니다.

 

그 다음 PostController, SearchDTO,  PostRepositoryCustom, PostRepositoryImpl 을 만들어주고 PostRepository를 수정해줍니다.

- PostController

@GetMapping("/board/post-list")
    @ResponseStatus(value = HttpStatus.OK)
    @ApiOperation(value = "게시물 목록 조회", notes = "게시물 목록을 조회합니다.")
    public SuccessResponse<List<PostDTO>> getPostList(SearchDTO searchDTO) {
        List<PostDTO> postList = postService.getPostList(searchDTO);

        return SuccessResponse.success(postList);
    }

 

- SearchDTO

@Builder
@Data
public class SearchDTO {
    private String category;
    private String searchType;
    private String query;

    @ConstructorProperties({"category", "search_type", "query"})
    public SearchDTO(String category, String searchType, String query) {
        this.category = category;
        this.searchType = searchType;
        this.query = query;
    }
}

@ConstructorProperties를 넣은 이유는 현재 QueryParameter를 어노테이션으로 받는게 아니라 Object로 받고 있습니다.

왜냐하면 Json은 snake_case를, 자바에서는 cameCase를 쓰고있기 때문에 통일이 되지않기 때문에

필드중 searchType이 실제 QueryParameter를 넘겨줄때도 searchType으로 적어줘야 입력받는 문제점이 있습니다.

그렇기 때문에 @ConstructorProperties에 적어줌으로써 필드 순서에 맞게 매핑이 되는것 같습니다.

 

 

- PostRepositoryCustom

public interface PostRepositoryCustom {
    List<Post> postListQueryDSL(SearchDTO searchDTO);
}

- PostRepositoryImpl

@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {
    private final JPAQueryFactory jpaQueryFactory;

    @Override
    public List<Post> postListQueryDSL(SearchDTO searchDTO) {
        BooleanExpression postCategoryQuery = getPostCategoryQuery(searchDTO.getCategory());

        List<Post> postList = jpaQueryFactory
                .selectFrom(post)
                .where(postCategoryQuery)
                .join(post.user, user).fetchJoin()
                .join(post.category, postCategory).fetchJoin()
                .fetch();

        return postList;
    }

     private BooleanExpression postCategoryQuery(String category) {
        if (StringUtils.hasLength(category)) {
            return post.category.eq(getPostCategory(category));
        }

        return null;
    }

    private PostCategory getPostCategory(String category) {
        return jpaQueryFactory
                .selectFrom(postCategory)
                .where(postCategory.name.eq(category))
                .fetchOne();
    }
}

여기서 JPAQueryFactory를 주입받고 있는데 Spring boot에서 기본적으로 주입시켜주는게 아니기 때문에 자동으로 주입시켜줄수 있게 작성해야 합니다.

- QueryDslConfig

@Configuration
public class QueryDslConfig {

    @Bean
    public JPAQueryFactory jpaQueryFactory(EntityManager em) {
        return new JPAQueryFactory(em);
    }

}

QueryDslConfig를 만들어 JPAQueryFactory을 자동으로 주입시켜줄 수 있게 Bean으로 등록시켜줍니다.

 

- PostRepository

@Repository
public interface PostRepository extends JpaRepository<Post, Long>, PostRepositoryCustom {
    @EntityGraph(attributePaths = {"category", "user", "commentList.user"})
    @Override
    Optional<Post> findById(Long postId);

    @EntityGraph(attributePaths = {"user", "category"})
    @Override
    List<Post> findAll(Sort sort);
}

여기서 눈여겨 보셔야 할 점이 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 문제를 해결하였습니다.

 

전체 코드는 여기에서 볼 수 있습니다.

오늘은 게시물에 댓글 기능을 적용해보도록 하겠습니다.

- Controller, Service, Entity, Repository, DTO

- CommentController

@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> {
}

- CommentDTO

@Builder
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@RequiredArgsConstructor(access = AccessLevel.PROTECTED)
@Data
public class CommentDTO {
    private Long id;

    @NotBlank
    @Length(max = 100)
    private String contents;
    private UserDTO user;

    public static CommentDTO convertToCommentDto(Comment comment) {
        return CommentDTO.builder()
                .id(comment.getId())
                .contents(comment.getContents())
                .user(UserDTO.convertToUserDTO(comment.getUser()))
                .build();
    }

    public static List<CommentDTO> convertToCommentDtoList(List<Comment> commentList) {
        Stream<Comment> stream = commentList.stream();

        return stream.map(CommentDTO::convertToCommentDto).collect(Collectors.toList());

    }
}

 

여기 까지가 Comment 관련 코드이고 이제 Comment와 Post, User와 관계를 지정해주어야 합니다.

Comment와 Post는 N : 1 관계, Comment와 User는 N : 1 관계 입니다. 그러므로 PostEntity와 UserEntity에 연관관계및 연관관계 메서드를 설정해줍니다.

- PostEntity

    @OneToMany(fetch = LAZY, mappedBy = "post")
    private List<Comment> commentList = new ArrayList<>();
    
        public void mappingComment(Comment comment) {
        this.commentList.add(comment);
    }

- UserEntity

@OneToMany(fetch = FetchType.LAZY, mappedBy = "user")
    private List<Comment> commentList = new ArrayList<>();
    
    public void mappingComment(Comment comment) {
        commentList.add(comment);
    }

 

그리고 이제 게시물에 댓글을 달고 게시물을 조회하고 나면 쿼리가 무언가 이상합니다. 

1번 게시물에 1번 유저, 2번 유저, 3번 유저가 댓글을 달경우 1번 게시물을 조회할때 1, 2, 3번 유저를 조회하는 쿼리가 나가기 때문입니다.

이를 해결하기 위해 저번에 작업한 PostRepository에 최적화 작업을 하겠습니다.

- PostRepository

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    @EntityGraph(attributePaths = {"category", "user", "commentList.user"})
    @Override
    Optional<Post> findById(Long postId);

    @EntityGraph(attributePaths = {"user", "category"})
    @Override
    List<Post> findAll(Sort sort);
}

@EntityGraph에 "commentList.user"를 추가해줌으로써 CommentList와 여기에 댓글을 작성한 유저의 정보까지 한번에 가져오므로 쿼리 1번에 모두 조회가 가능합니다.

전체 코드는 여기에서 보실 수 있습니다.

먼저 코드를 보여드리도록 하겠습니다.

Controller, Service, PostEntity

- PostController

@PostMapping("/post")
    @ResponseStatus(value = HttpStatus.CREATED)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "게시물 생성", notes = "게시물을 생성합니다.")
    public SuccessResponse<PostDTO> createPost(@Valid @RequestBody PostDTO postDTO, Principal principal) {
        log.info(principal.getName());
        PostDTO post = postService.createPost(postDTO, principal.getName());

        return SuccessResponse.success(post);
    }

 

이전 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를 수정해줍니다.

PostRepository

@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
    @EntityGraph(attributePaths = {"category", "user"})
    Optional<Post> findById(Long postId);
}

지난시간 postCategory 쿼리를 최적화 하기위해 @EntityGraph에 category를 지정해줬는데 user를 추가시켜주어 쿼리 1번에 유저까지 가져올수 있도록 합니다.

그 후 게시물 조회를 해보면 유저까지 한번에 가져오는걸 알 수 있습니다.

전체 코드는 여기에서 확인할 수 있습니다.

 

안녕하세요 이번에는 API를 테스트 할 수 있는 Swagger 문서를 추가 해보도록 하겠습니다.

build.gradle 파일에 추가해줍니다.

// swagger
    compile('io.springfox:springfox-swagger2:2.7.0')
    compile('io.springfox:springfox-swagger-ui:2.7.0')

그 다음 SwaggerConfig 파일을 만들어 줍니다.

@Configuration
public class SwaggerConfig {

    @Bean
    public Docket api() {
        return new Docket(DocumentationType.SWAGGER_2)
                .apiInfo(apiInfo())
                .select()
                .apis(RequestHandlerSelectors.any())
                .paths(PathSelectors.any())
                .build();
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("Spring Boot REST Sample with Swagger")
                .description("API 문서")
                .termsOfServiceUrl("")
                .contact(new Contact("user", "url", "user@example.com"))
                .version("1.0")
                .build();

    }
}

그 다음으로 Controller에 코드를 추가 해줍니다. 저는 PostController에 추가 해주도록 하겠습니다.

@RestController
@RequiredArgsConstructor
@Api(tags = {"게시물 관련 API"})
public class PostController {
    private final PostService postService;

    @GetMapping("post/{id}")
    @ResponseStatus(value = HttpStatus.OK)
    @ApiOperation(value = "게시물 조회", notes = "게시물을 조회합니다.")
    public SuccessResponse<PostDTO> getPost(@PathVariable(name = "id") Long id) {
        PostDTO post = postService.getPost(id);

        return SuccessResponse.success(post);
    }

    @PostMapping("/post")
    @ResponseStatus(value = HttpStatus.CREATED)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "게시물 생성", notes = "게시물을 생성합니다.")
    public SuccessResponse<PostDTO> createPost(@Valid @RequestBody PostDTO postDTO) {
        PostDTO post = postService.createPost(postDTO);

        return SuccessResponse.success(post);
    }

    @PutMapping("post/{id}")
    @ResponseStatus(value = HttpStatus.OK)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "게시물 수정", notes = "게시물을 수정합니다.")
    public SuccessResponse<PostDTO> updatePost(@Valid @RequestBody PostDTO postDTO, @PathVariable(name = "id") Long postId) {
        PostDTO postDTOResponse = postService.updatePost(postId, postDTO);

        return SuccessResponse.success(postDTOResponse);
    }

    @DeleteMapping("post/{id}")
    @ResponseStatus(value = HttpStatus.OK)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "게시물 삭제", notes = "게시물을 삭제합니다.")
    public SuccessResponse<String> deletePost(@PathVariable(name = "id") Long postId) {
        postService.deletePost(postId);
        return SuccessResponse.success(null);
    }

    @GetMapping("/post-category")
    @ResponseStatus(value = HttpStatus.OK)
    @PreAuthorize("isAuthenticated()")
    @ApiOperation(value = "게시물 카테고리 조회", notes = "게시물 카테고리를 조회합니다.")
    public SuccessResponse<List<PostCategoryDTO>> postCategory() {
        List<PostCategoryDTO> postCategoryDTOS = postService.postCategoryList();

        return SuccessResponse.success(postCategoryDTOS);
    }
}

@Api : 해당 컨트롤러가 어떤 API 그룹인지 적어주세요.

@ApiOperation : value -> api의 제목? 을 적어주세요.

@ApiOperation : notes -> api에 대한 설명을 적어주세요.

그 후 Main 실행 파일에 가셔서 @EnableSwagger2 를 붙여주세요.

@SpringBootApplication
@EnableSwagger2
public class BoardRestApiApplication {

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

}

 

저 같은 경우에는 Spring Security를 사용하고 있기 때문에 swagger 관련 주소를 제외시켜주어야 합니다. SecurityConfig에 추가시켜줍니다.

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring()
                .antMatchers("/webjars/**")
                .antMatchers("/v2/api-docs/**")
                .antMatchers("/configuration/**")
                .antMatchers("/swagger*/**");
    }

 

이제 실행하신다음 http://localhost:8080/swagger-ui.html 에 접속하시면 아래와 같은 화면이 나오며 api를 테스트 할 수 있습니다.

+ Recent posts