🙋 @Cacheable 캐싱 안 먹는 이유? (Spring AOP)

Spring에서 @Cacheable 써서 캐싱할 때,

같은 클래스 안에서 메서드 부르면 AOP가 안 먹힌다. 그 이유는 Spring AOP는 프록시 객체로 동작하는데, 클래스 내부에서 자기 자신을 호출하면 프록시가 아니라 해당 메서드를 불러버리기 때문이다.

프록시는 외부에서 해당 메서드가 호출될 때만 동작한다.

 

  @Service
  public class userService {
  
      public testResponse test(Long id) {
      ...
      	  // user 정보 얻기
          User user = getUser(id);
      ...
      }

	  // 캐싱 적용
      @Cacheable(value = "userCache", key = "#root.args[0]")
      public User getUser(id){
        return userRepository.findById(id)
      }
 
 }

 

 

✏️ 해결방법 첫번째, 서비스 분리

캐싱할 메서드를 다른 서비스(UserCacheService)로 빼버린다.

 

@Service
public class UserCacheService {
    @Autowired
    private UserRepository userRepository;
    
    @Cacheable(value = "userCache", key = "#root.args[0]")
    public User getUser(Long id){
        return userRepository.findById(id);
    }
}

 

✏️ 해결방법 두번째, 자기 자신을 빈으로 주입

서비스 나누기 싫다면? 자기 자신을 @Autowired을 통해 Bean으로 주입한다.

그러면 자기 자신 호출할 때도 프록시를 거치니까 AOP 적용되고 캐싱도 잘 된다.

 

@Service
public class UserService {

    @Autowired
    private UserService self;  // 자기 자신을 주입

    public TestResponse test(Long id) {
        User user = self.getUser(id);  // 프록시를 거쳐서 호출
    }

    @Cacheable(value = "userCache", key = "#root.args[0]")
    public User getUser(Long id){
        return userRepository.findById(id);
    }
}

 

💡 Redis로 캐시 확인하는 방법

해당 프로젝트는 Redis를 캐시 저장소로 설정했다. (Redis는 Docker로 실행)

따라서 캐싱이 잘 적용됐는지 확인하기 위해 Redis CLI 사용.

Redis Docker 컨테이너 실행하고 Spring에서 Redis와 연동됐다는 가정하에,

 

1) Redis CLI 접속

Docker로 실행 중인 Redis에 접속하려면, 터미널에서 Redis CLI에 들어가서 명령어를 입력할 수 있다.

docker exec -it [redis 컨테이너 이름] redis-cli

 

2) 캐시된 키 확인

현재 Redis에 저장된 모든 캐시 키를 볼 수 있다.

keys *

 

 

🙋 Spring Data JPA에서 Entity에 기본 생성자가 필요한 이유?

필요한 이유는 동적으로 객체 생성 시 Reflection API 를 활용하기 때문이다.

JPA는 DB 값을 객체 필드에 주입할 때 기본 생성자로 객체를 생성한 후 Reflection API를 사용하여 값을 매핑한다.

때문에 기본 생성자가 없다면 Reflection은 해당 객체를 생성 할 수 없기 때문에 JPA의 Entity에는 기본 생성자가 필요하다.

 

👀 Reflection API :

🙋 기본 생성자를 'public', 'protected'로 선언해야하는 이유?

Entity 기본 생성자의 접근 제어자는 private로 선언할 수 없다.

그 이유는, JPA의 Entity 조회 방식 중 하나인 '지연로딩' '프록시 객체'와 관련이 있다.

지연로딩 시 사용되는 프록시 객체는 원본 Entity를 상속해서 만들기때문에 원본 Entity의 기본생성자가 private일 수 없는것이다. 

 

👀 지연로딩, 즉시로딩 :

더보기

지연로딩 (Lazy Loading) :

지연 로딩은 연관된 엔티티를 실제로 사용할 때 쿼리를 실행하는 방식이다.

부모 엔티티를 조회할 때 연관된 자식 엔티티는 초기에는 로딩되지 않고, 필요한 순간에 쿼리를 실행하여 데이터를 가져온다.

이를 통해 불필요한 데이터 로딩을 최소화할 수 있다.

(*프록시 객체 : 실제 사용될 때까지 조회를 지연하기위해 가짜 객체가 필요하다. 이때 프록시 객체를 사용한다.)

 

즉시로딩 (Eager Loading):

부모 엔티티를 조회할 때 연관된 자식 엔티티도 함께 조회된다.

이 경우, 쿼리는 부모 엔티티를 조회할 때 실행된다.

 

 

🙋 JPA에서 제공하는 여러가지 쿼리 생성 방법을 알아보자.

 

1. 쿼리 메소드(Query Methods) :

  • 메소드 이름을 통해 JPQL 쿼리를 생성하는 방법
  • Spring Data JPA에서 자동으로 메소드 이름을 분석하여 JPQL을 생성

메서드 이름을 특정한 규칙에 맞게 작성하면, Spring Data JPA는 해당 메서드를 실행할 때 자동으로 쿼리를 생성하여 데이터베이스에 전달한다.

public interface PostRepository extends JpaRepository<Post, Long> {
    
    // 제목에 특정 키워드가 포함된 게시글 검색
    List<Post> findByTitleContaining(String keyword);

예를 들어, findByTitleContaining 메서드는 다음과 같은 역할을 수행한다.

  • findBy : 메서드 이름의 시작 부분으로, "findBy" 다음에 오는 속성명으로 쿼리를 생성한다.
  • Title : 엔터티 클래스의 속성 중 하나이다. 여기서는 title이라는 속성을 의미한다.
  • Containing : 속성에 대한 조건을 나타내며, 해당 속성이 특정 키워드를 포함하는지를 확인한다.

따라서, findByTitleContaining은 "title" 속성에서 특정 키워드를 포함하는 게시글을 찾는 쿼리를 생성한다.

Spring Data JPA는 이 메서드를 실행할 때, 자동으로 쿼리를 생성하고 실행한다.

예를 들어, 메서드를 호출할 때 findByTitleContaining("Java")와 같이 호출하면, Spring Data JPA는 "title에 'Java'를 포함하는 게시글을 찾아라"는 쿼리를 생성하고 실행한다.

이렇게 메서드 이름을 통해 동적으로 쿼리를 생성할 수 있어서 편리하게 사용할 수 있다.

 

2. JPQL (Java Persistence Query Language) : 

 

쿼리 메소드는 특정한 규칙을 따르며, 이를 통해 자주 사용되는 CRUD 기능을 편리하게 제공한다.

하지만 복잡한 쿼리를 표현하기에는 한계가 있을 수 있다. 이런 경우에는 @Query 어노테이션을 사용하여 직접 JPQL을 작성한다.

  • 객체지향 쿼리 언어로, 엔티티 객체를 대상으로 쿼리를 작성할 수 있다.
  • SQL과 유사하지만 테이블이 아닌 엔티티에 대해 쿼리를 작성한다.
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface PostRepository extends JpaRepository<Post, Long> {

    @Query("SELECT p FROM Post p WHERE p.title LIKE %:keyword%")
    List<Post> findByTitleContainingCustom(@Param("keyword") String keyword);

    // 다른 사용자 정의 쿼리 메서드들...
}
  • SELECT p : 엔티티 객체인 Post를 선택합니다.
  • FROM Post p : Post 엔터티를 대상으로 쿼리를 실행합니다. (p : Post의 별칭)
  • WHERE p.title LIKE %:keyword%: title 속성이 특정 키워드를 포함하는 경우를 선택합니다.

JPQL은 엔티티 객체를 기반으로 작성되기 때문에 테이블과 컬럼의 이름이 아닌 엔터티 클래스와 그 속성명을 사용합니다. 이를 통해 데이터베이스 종속성을 줄일 수 있습니다.

 

💡 만약 네이티브 SQL을 사용하고 싶다면.. nativeQuery 속성을 true로 설정

@Query(value = "SELECT * FROM post WHERE title LIKE %:keyword%", nativeQuery = true)
List<Post> findByTitleContainingNative(@Param("keyword") String keyword);

이렇게 하면 직접 SQL을 작성하여 원하는 쿼리를 실행할 수 있다.

다만 주의할 점은 네이티브 SQL을 사용할 때는 데이터베이스 종속성이 생길 수 있으므로(보안 문제 발생 가능성도 있음),

가능한 JPQL을 사용하여 데이터베이스에 독립적인 쿼리를 작성하는 것이 좋다.

 

3. QueryDSL(Domain Specific Language) : 

 

@Query 어노테이션은 명시적인 JPQL을 작성할 수 있게 해주지만, 쿼리가 복잡해지면 가독성이나 유지보수 측면에서 한계가 있을 수 있다. 이런 경우에 QueryDSL을 사용하면 좀 더 강력하고 유연한 쿼리를 작성할 수 있다.
QueryDSL은 JPQL을 자바 코드로 작성할 수 있도록 도와주는 라이브러리로, 코드 자체에서 쿼리를 구성할 수 있게 해준다. 이를 통해 컴파일 시점에서 타입 안정성을 확보하고, 복잡한 쿼리를 더 직관적으로 작성할 수 있다.

 

💡  QueryDSL 예시 코드를 보며 전체적인 흐름을 확인해보자.

 

1) 엔티티 클래스 (Post) :

엔티티 클래스는 데이터베이스의 테이블을 나타내는데 사용된다.

QueryDSL은 이 엔티티 클래스를 기반으로 Query 타입을 생성한다.

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    // Getter, Setter, 기타 메서드...
}

 

2) QueryDSL Query 타입 (QPost):

QPost 클래스는 QueryDSL을 사용하여 JPA 엔티티인 Post에 대한 타입 안전한(Query 타입) 표현을 정의한 클래스이다.

이 클래스를 사용하면 코드에서 엔티티의 속성에 대한 오타나 오류를 컴파일 시간에 확인할 수 있다.

import com.querydsl.core.types.dsl.NumberPath;
import com.querydsl.core.types.dsl.StringPath;
import com.querydsl.core.types.dsl.EntityPathBase;
import com.querydsl.core.types.dsl.Expressions;

public class QPost extends EntityPathBase<Post> {
    public final StringPath title = createString("title");
    public final NumberPath<Long> id = createNumber("id", Long.class);

    public QPost(String variable) {
        super(Post.class, variable);
    }
}
  • QPost extends EntityPathBase<Post> : EntityPathBase는 QueryDSL에서 사용하는 기본 클래스로, 엔티티에 대한 QueryDSL Query 타입을 생성하기 위한 메타 모델 클래스를 정의하는 데 사용된다.
  • public final StringPath title = createString("title"); : 이 부분은 title 필드에 대한 QueryDSL Query 타입을 정의한다. StringPath는 문자열에 대한 QueryDSL 타입을 나타내며, createString("title")은 title이라는 필드에 대한 QueryDSL 표현식을 생성한다.
  • public final NumberPath<Long> id = createNumber("id", Long.class); : 이 부분은 id 필드에 대한 QueryDSL Query 타입을 정의한다. NumberPath<Long>는 숫자(Long)에 대한 QueryDSL 타입을 나타내며, createNumber("id", Long.class)은 id라는 필드에 대한 QueryDSL 표현식을 생성한다.
  • public QPost(String variable) { super(Post.class, variable); } : 생성자는 QPost 클래스의 인스턴스를 초기화한다. Post.class는 이 QueryDSL Query 타입이 어떤 엔티티를 대상으로 하는지를 나타내며, variable은 QueryDSL에서 사용될 변수명을 지정한다.

이 클래스를 사용하면 QueryDSL을 활용하여 동적으로 쿼리를 작성할 수 있다.

예를 들어, QPost.post.title.eq("제목")와 같은 형태로 사용하여 title 필드가 "제목"과 같은지를 나타내는 조건을 만들 수 있다. QueryDSL을 사용하면 타입 안정성(type safety)을 확보하면서 동적 쿼리를 생성할 수 있다.

 

3) Repository 인터페이스 (PostRepository):

PostRepository 인터페이스는 Spring Data JPA의 CrudRepository를 확장하고, QueryDSL을 사용하여 동적인 쿼리를 처리하기 위한 QuerydslPredicateExecutor를 구현한다. (Spring Data JPA의 기본 기능 정의)

import org.springframework.data.querydsl.QuerydslPredicateExecutor;
import org.springframework.data.repository.CrudRepository;

public interface PostRepository extends CrudRepository<Post, Long>, QuerydslPredicateExecutor<Post> {
    // 다른 메서드들...

    // QueryDSL을 사용한 동적인 쿼리
    List<Post> findAll(Predicate predicate);
    
    // Spring Data JPA가 제공하는 메서드 활용
    List<Post> findByTitle(String title);
}

 

4) Custom Repository 인터페이스 (CustomPostRepository) :

QueryDSL을 사용한 동적 쿼리를 직접 구현하는 인터페이스이다.

사용자가 정의한 쿼리 메서드를 추가한 인터페이스.

import java.util.List;

public interface CustomPostRepository {
    // QueryDSL을 사용한 동적인 쿼리
    List<Post> findPostsByTitle(String keyword);
}

 

5) Custom Repository 구현 클래스 (PostRepositoryImpl):

CustomPostRepository 인터페이스에서 정의된 메서드를 구현하는 클래스.

여기에서 QueryDSL을 사용하여 동적인 쿼리를 작성하고 실행한다.

import com.querydsl.jpa.impl.JPAQueryFactory;

import javax.persistence.EntityManager;

public class PostRepositoryImpl implements CustomPostRepository {

    private final JPAQueryFactory queryFactory;
    private final QPost qPost = QPost.post;

    public PostRepositoryImpl(EntityManager entityManager) {
        this.queryFactory = new JPAQueryFactory(entityManager);
    }

    @Override
    public List<Post> findPostsByTitle(String keyword) {
        return queryFactory.selectFrom(qPost)
                .where(qPost.title.like("%" + keyword + "%"))
                .fetch();
    }
}

 

 

✏️ 정리

이 외에  Named Query, Criteria API 방법도 존재한다.

여러 가지 방법을 적절히 조합하여 사용하는 것이 중요하며, 간단한 쿼리에는 메서드 이름 규칙을, 복잡한 쿼리에는 @Query 어노테이션을 또는 QueryDSL을 활용하는 것이 좋다.

🙋 Springboot, JPA를 활용하여 프로젝트를 만들 때 어떤 순서로 생성할지 참고해보기.

 

1. 프로젝트 생성 :

  • IntelliJ IDEA에서 새로운 스프링 부트 프로젝트를 생성한다. IntelliJ IDEA에서는 "File" -> "New" -> "Project..."를 선택한 후, "Spring Initializr"를 선택하여 프로젝트를 생성할 수 있다.
  • (또는 https://start.spring.io/ 가서 Spring Initializr 직접 사용하여 가져오기는 방법도 있다.)

2.프로젝트 구성 :

  • 프로젝트 구성에서는 사용할 언어, 스프링 부트 버전, 그리고 프로젝트의 기본 설정을 지정한다. JPA를 사용하기 위해 "Spring Data JPA"를 선택하고, 필요에 따라 다른 의존성도 추가한다.(ex. Spring Web)

3. 데이터베이스 설정 :

  • application.properties 또는 application.yml 파일을 이용하여 데이터베이스 연결 정보를 설정한다. 데이터베이스 종류, URL, 사용자명, 암호 등을 지정한다.

4. 엔티티 클래스 생성 :

  • JPA를 사용하여 데이터베이스와 상호작용하기 위해 엔티티 클래스를 생성한다. 이 클래스는 데이터베이스의 테이블과 매핑되어야 한다.
  • 예시 코드(ex. 게시글을 나타내는 Post 엔티티)
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;
    private String content;

    // Getters and setters, constructors, and other methods

}

 

5. 리포지토리 인터페이스 생성 :

  • JPA 리포지토리 인터페이스를 생성하여 데이터베이스 조작을 위한 메서드를 정의한다. 스프링 부트의 Spring Data JPA는 이 인터페이스를 구현해주는 프록시 객체를 생성한다.
  • 예시 코드
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface PostRepository extends JpaRepository<Post, Long> {
    
    // 제목에 특정 키워드가 포함된 게시글 검색
    List<Post> findByTitleContaining(String keyword);

    // 사용자명으로 게시글 찾기
    List<Post> findByAuthor(String author);

    // 제목과 내용에 특정 키워드가 포함된 게시글 찾기
    List<Post> findByTitleContainingOrContentContaining(String title, String content);
}
  • 여기서 PostRepository는 JpaRepository를 확장하고 있어서, 별도의 구현체 없이도 기본적인 CRUD 기능을 사용할 수 있습니다. JpaRepository는 기본적인 CRUD 외에도 다양한 메서드들을 제공합니다.
  • 위의 코드에서 Post는 엔터티 클래스이며, Long은 엔터티의 식별자(ID)의 타입입니다. 

6. 서비스 및 컨트롤러 생성 :

  • 서비스 클래스에서는 비즈니스 로직을 정의하고, 컨트롤러에서는 클라이언트 요청에 대한 핸들링을 수행한다.

7. 웹 페이지 작성 :

  • Thymeleaf, FreeMarker 등을 사용하여 웹 페이지를 작성하고, 컨트롤러에서 이를 렌더링하여 사용자에게 보여준다.

8. 프로젝트 실행 및 테스트 :

  • IntelliJ IDEA에서 스프링 부트 애플리케이션을 실행하고, 브라우저 또는 API 테스트 도구를 사용하여 게시판이 제대로 작동하는지 확인한다.

9. 기능 확장 :

  • 기본적인 CRUD(Create, Read, Update, Delete) 기능을 구현한 후, 검색, 정렬, 페이징과 같은 추가적인 기능을 구현해본다.

✏️ 웹브라우저에 url을 입력했을 때, 스프링 부트 동작 및 환경

< 1 >

@Controller 
public class HelloController {

    @GetMapping("hello")
    public String hello(Model model) {
        model.addAttribute("data", "hello!!"); //hello.html에서 {data}부분에 hello!!들어감
        return "hello";
    }

1. 웹브라우저에 localhost:8080/hello url을 입력한다.

2. 스프링부트는 '톰캣'이라는 웹서버를 내장하는데, 이 서버에서 url을 받아 스프링컨테이너에 보낸다.

3. 스프링컨테이너는 url의 hello와 @GetMapping("hello")를 보고 매핑하여 해당 메서드(hello)를 실행한다.

4. 스프링이 model을 만들어서 매개변수 부분에 넣어준다.

5. 그 model에 addAttribute. (key는 data 값은 "hello!!")

6. return "hello" : hello.html을 찾아서 위에 실행한 것들을 렌더링한다. (viewResolver가 hello 화면을 찾아서 처리해줌)


< 2 >

 @GetMapping("hello-string")
    @ResponseBody
    public String helloString(@RequestParam("name") String name){
        return "hello" + name;
    }

💡 @ResponseBody : http의 header, body 중 body 부분에 직접 내용을 넣어서 전달하게 해줌.

1. localhost:8080/hello-string?name=spring url 입력

2. 이 메서드 부분은 html없이 hello spring 그대로 출력된다. (페이지 소스보기 해도 html없이 hello spring 뜸)


< 3 >

@GetMapping("hello-api")
    @ResponseBody
    public Hello helloApi(@RequestParam("name") String name) {
        Hello hello = new Hello();
        hello.setName(name);
        return hello;
    }

    static class Hello {
        private String name;

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }
    }

💡 url 입력하면 json( {키:값}) 형태로 확인해볼수있다.

1. url 입력받는다.

2. 컨테이너에서 hello-api 찾는다.

3. 찾았는데 메서드에 @ResponseBody가 있다.

4. <2>번 예시에서(helloString) return이 문자면 문자 그대로 http에 던짐.

helloApi에서는 객체를 반환한다. (return hello)

5. 객체가 오면 기본적으로 json 방식으로 데이터를 만들어서 http응답에 반환하는것이 기본 정책. 따라서 json방식으로 응답한다.

* 기존에 @ResponseBody가 없을때는(<1>번예시) viewResolver가 동작하지만 @ResponseBody가 존재하면 HttpMessageConverter가 동작된다. 

6. 단순 문자라면?  StringConverter : StringHttpMessageConverter 동작

객체라면? JsonConverter : MappingJackson2HttpMessageConverter 동작

(💡 jackson이란 객체를 json으로 바꿔주는 라이브러리. 스프링은 jackson을 기본으로 탑재한다.)

해당 Converter에 의해서 객체가 json으로 변환된다.

 

+ Recent posts