728x90

개요

이전 블로그에 지연로딩, 즉시로딩에 대해서 살펴보았다.

JPA를 사용하다 보면 바로 N+1의 문제에 마주치고 바로 Fetch Join을 접하게 된다.
처음 Fetch Join을 접했을 때 왜 일반 Join으로 해결하면 안되는지에 대해 명확히 정리가 안된 채로 Fetch Join을 사용했다.
어떤 문제 때문에 일반 join으로 N+1을 해결하지 못하는지를 시작으로 해서 Fetch Join, Join의 차이점을 정리해보겠다.

 

 

Join, Fetch Join 차이점 요약

  • 일반 Join
    • Fetch Join과 달리 연관 Entity에 Join을 걸어도 실제 쿼리에서 SELECT 하는 Entity는 오직 JPQL에서 조회하는 주체가 되는 Entity만 조회하여 영속화
    • 조회의 주체가 되는 Entity만 SELECT 해서 영속화하기 때문에 데이터는 필요하지 않지만 연관 Entity가 검색조건에는 필요한 경우에 주로 사용됨

 

  • Fetch Join
    • 조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화
    • Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도 이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1문제가 해결됨

 

 

N+1 문제

단순 테이블 조회 쿼리를 하나 생성하였는데, 연관관계에 있는 데이터를 모두 불러오기 위해서 select 쿼리가 테이블의 레코드 수(혹은 호출한 데이터 수) 만큼 더 생성되는 문제를 의미한다.

 

즉시로딩은 최초 테이블 데이터를 모두 불러온 후 바로 연관관계가 있는 엔티티를 조회하는 방식을,

지연로딩은 최초 테이블 데이터를 모두 불러온 후 필요한 시점에 연관관계가 있는 엔티티를 조회하는 방식을 채택한다.

 

즉시로딩은 워딩 그대로 조회 시점에 연관관계에 있는 엔티티가 모두 조회되어 N+1 문제가 생긴다는 것을 직관적으로 알 수 있지만 지연로딩은 얼핏보면 로딩 시점에 쿼리가 더 나가지 않아 N+1 문제가 발생하지 않을 것으로 보인다. 하지만 지연로딩 N+1 문제를 피해갈 수는 없는데, 그 이유는 연관관계에 있는 엔티티가 조회 시점에 영속화되지 않았기 때문이다.

 

일반적으로 JPA는 JPQL 로 생성된 쿼리문을 데이터베이스와 connection 을 만든 후에 전송하고, 이에 대한 결과 데이터를 전달받아 실제 엔티티 객체를 생성하고 이를 영속화 한다.

 

 

 

예시

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

    @OneToMany(mappedBy = "post", fetch = FetchType.LAZY)
    List<Comment> cpmments = ArrayList()
}

@Entity
class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long id;
    
    String content;

    @ManyToOne(fetch = FetchType.LAZY)
    Post post;
}
@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;

    @Transactional(readOnly = true)
    public void getMockposts() {
        postRepository.findAll().forEach(post -> {
            post.getpPostComments().forEach(cpmments ->
                System.out.println(cpmments.getContent())
            );
        });
    }
}

위 코드에서 N+1 문제가 발생할 수 있다. 예를 들어, 100개의 게시글이 있다고 가정하면, PostRepository의 findAll() 메서드가 호출될 때 모든 게시글을 조회한다. 이때, 각 게시글의 댓글을 조회하기 위해 추가로 100번의 쿼리가 실행다. 이는 총 101번의 쿼리 실행으로 이어지며, 매우 비효율적인 방법이다. 즉, 다음과 같이 SQL이 실행되면서 N+1 문제가 발생한 것을 알 수 있다.

SELECT * FROM post;
SELECT * FROM comment WHERE post_id = 1;
SELECT * FROM comment WHERE post_id = 2;
SELECT * FROM comment WHERE post_id = 3;
...
SELECT * FROM comment WHERE post_id = 99;
SELECT * FROM comment WHERE post_id = 100;

 

 

해결 방법

1. Fetch Join (@Query 어노테이션 사용)

  • Fetch Join은 N+1 문제를 방지하고 성능을 최적화할 수 있는 수단으로서 데이터베이스에서 연관된 엔터티를 함께 로딩하는 방법이다.
  • Fetch Join을 사용하면 조회 성능을 최적화 할 수 있다.
  • 연관된 엔티티를 쿼리 시 함께 조회하는 방법이다.
  • SQL 쿼리에 직접 Join fetch를 명시한다.
  • 조회의 주체가 되는 엔티티 외에 Fetch Join이 걸린 연관 엔티티도 같이 영속화 시켜준다.
  •  
@Repository 
public interface PostRepository extends JpaRepository<Post, String> {
@Query("SELECT p from Post p JOIN FETCH p.comments")
List<Post> findAllPostsWithComments();
}
  •  "JOIN FETCH p.comments" 구문을 통해서 게시글을 조회할 때 댓글 엔티티도 함께 조회한다.
  • join fetch는 Fetch Join을 사용하여 연관된 데이터를 조회하겠다는 뜻이다.

 

2. Entity Graph

  • EntityGraph를 사용하면 Fetch Join를 사용한 경우와 마찬가지로 연관된 엔티티나 컬렉션을 같이 조회 할 수 있다.
  • JPQL과 함께 사용하며 attributePaths에 쿼리 수행 시 조회할 필드를 지정해준다.
  • 지정된 필드는 지연 로딩이 아닌 즉시 로딩으로 조회된다.
  • EntityGraph는 Fetch Join과는 다르게 Outer Join이 사용되어 동작한다
@Repository 
public interface PostRepository extends JpaRepository<Post, String> {

@EntityGraph(
        attributePaths = {"comments"},
        type = EntityGraph.EntityGraphType.LOAD
    )
@Query("SELECT p FROM post p")
List<Post> findAllPostsWithComments();

}

 

 

Fetch Join 및 EntityGraph 사용 시 발생하는 카테시안 곱 문제

Fetch Join 또는 EntityGraph를 사용한 경우엔 카테시안 곱(Cartesian Product)으로 인하여 중복 문제가 발생할 수 있습니다. 카테시안 곱은 연관관계의 엔티티에 Join 규칙을 사용하지 않아 결합 조건이 주어지지 않았을 때 모든 데이터의 경우의 수를 결합(M * N)하여 반환하는 문제입니다. 즉, 조인 조건이 없는 경우에 대한 두 테이블의 결합 결과를 반환해야 하기 때문에 존재하는 모든 행의 개수를 곱한 결과를 반환하게 됩니다. 카테시안 곱 문제를 해결하기 위해서는 다음과 같은 방법을 사용합니다.
 

 

 


1. JPQL에 DISTINCT 사용하기


JPQL을 이용한 쿼리를 사용할 때 다음과 같이 DISTINCT를 사용하여 중복을 제거합니다.

@Repository 
public interface PostRepository extends JpaRepository<Post, String> {

    @Query("SELECT DISTINCT p from Post p JOIN FETCH p.comments")
	List<Post> findAllPostsWithComments();
    
    @EntityGraph(
        attributePaths = {"comments"},
        type = EntityGraph.EntityGraphType.LOAD
    )
	@Query("SELECT DISTINCT p FROM post p")
	List<Post> findAllPostsWithComments();
}


 

2. 연관관계의 필드 타입에 Set 사용하기

  • Set은 중복을 허용하지 않기 때문에 중복 데이터가 들어가지 않지만 순서가 보장되지 않는다.
  • 이러한 경우 다음과 같이 연관관계의 필드에 LinkedHashSet을 사용하여 중복을 제거하고 순서를 보장 해줄 수 있다.
@Getter
@NoArgsConstructor
@Entity(name = "posts")
public class post {

    @OneToMany(mappedBy = "post")
    private Set<Comment> comments = new LinkedHashSet<>();

}

 

 

728x90
728x90

개요 

스프링 JPA를 활용한 프로젝트 리팩토링 과정에서 JPA N+1 문제가 발생하여 정리해볼려고 한다.

 

Fetch 전략

특정 엔티티를 조회할 때, 연관된 다른 엔티티를 [언제] 불러오는지에 대한 옵션이다. 위와 같이 연관관계 매핑 어노테이션의 옵션을 통해 전략을 명시한다. 옵션에는 두 가지의 전략이 있다. 

 

 

1. 즉시로딩

  • 즉시로딩이란 연관된 모든 엔티티를 함께 즉시 로드하는 방식이다.
  • 즉, 부모 엔티티를 조회할 때 연관된 자식 엔티티들도 함께 한번에 가져온다. 
  • 현재 엔티티를 조회한 [직후], 연관된 엔티티까지 조회한다.
@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.EAGER) // 즉시 로딩
    private User user;
}

 

위 Post 엔티티는 User와 다대일(@ManyToOne) 관계를 맺고 있으며, 즉시 로딩(EAGER)으로 설정되었다. 이 경우 Post를 조회할 때 User 정보도 함께 조회된다. 즉, Post 엔티티를 가져오면 바로 User 엔티티도 DB에서 쿼리되어 로드된다.

장점으로는 쿼리가 한 번만 실행되므로 사용 시 추가적인 데이터베이스 접근이 필요 없다. 이 방법으로 인해 N+1 문제를 해결할 수 있지만, 불필요하게 많은 데이터를 한 번에 로드할 수 있어 성능에 영향을 줄 수 있다.  

 

 

 

 

2. 지연로딩

  • 지연로딩이란 연관된 엔티티를 즉시 로드하지 않고 실제로 해당 엔티티에 접근할 때 데이터를 로드하는 방식이다.
  • 즉 부모 엔티티만 먼저 조회하고 자식 엔티티는 실제로 필요할 때 쿼리가 발생하는 식이다.
  • 현재 엔티티를 조회하고, [추후 필요할 때] 연관된 엔티티를 조회하는 쿼리를 날린다.
@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY) // 지연 로딩
    private User user;
}

위 Post 엔티티는 User와 지연 로딩(LAZY) 관계를 맺고 있다. 이 경우 Post 엔티티를 조회할 때는 User 엔티티를 바로 로드하지 않고, getUser() 메서드를 호출해서 User 정보를 실제로 사용할 때 DB에서 쿼리가 실행되어 데이터를 로드한다. 장점으로는 연관된 엔티티가 많아도 불필요한 데이터는 로드하지 않아 성능을 향상시킬 수 있다. 하지만 엔티티에 접근할 때마다 추가적인 데이터베이스 쿼리가 발생할 수 있다. 이를 잘못 사용할 경우 N+1 문제를 일으킬 수 있다.

 

 

 

기본 로딩 전략 (디폴트)

  • @OneToMany, @ManyToMany: 기본적으로 지연 로딩(LAZY)으로 설정
  • @ManyToOne, @OneToOne: 기본적으로 즉시 로딩(EAGER)으로 설정
@Entity
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @ManyToOne // 기본 즉시 로딩 (EAGER)
    private User user;

    @OneToMany(mappedBy = "post") // 기본 지연 로딩 (LAZY)
    private List<Comment> comments;
}
728x90
728x90

🧐 문제 발생 상황

OAuth2 소셜로그인을 하면 생일, 성별, 연락처에 대한 정보는 가져올 수가 없어서 추가 정보 기입 폼을 만들어서 디비에 저장되도록 구현했다.

그래서 생각한게 어느 블로그에서 본건데 Setter보다 @Builder 패턴을 사용하는게 좋다해서 소셜로그인 후 추가 정보 입력에 적용해보았다. 그래서 아래와 같은 코드로 실행을 했더니

// entity
@Getter 
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Oauth2LoginDto {

    private String phoneNumber; // 연락처
    private LocalDate birthdate; // 생년월일
    private String gender; // 성별
}
    // service
    
    /**
     * Oauth2 로그인 시 추가 정보 입력
     */
    @Override
    public Optional<User> updateOauth2(String username, Oauth2LoginDto oauth2LoginDto) {
        
        User user = User.builder()
        			.phoneNumber(oauth2LoginDto.getPhoneNumber())
				.gender(oauth2LoginDto.getGender())
				.birthdate(oauth2LoginDto.getBirthdate())
				.build();
        userRepository.save(user);
        return Optional.of(user);
    }

아래와 같은 오류가 발생했다.

org.springframework.dao.DataIntegrityViolationException: not-null property references a null or transient value : com.example.omg_project.domain.user.entity.User.nam

저 오류에 대해서 검색하니

 DataIntegrityViolationException 예외는 JPA가 엔티티를 저장하는 도중 User 엔티티의 name 필드가 null로 설정되어 있는 것을 감지했기 때문에 발생한 것이다. name 필드는 not-null 제약 조건이 설정되어 있어서 이로 인해 Hibernate가 엔티티를 데이터베이스에 저장할 수 없었다.

라고 한다.

 DataIntegrityViolationException 예외는 JPA가 엔티티를 저장하는 도중 User 엔티티의 name 필드가 null로 설정되어 있는 것을 감지했기 때문에 발생한 것이다. name 필드는 not-null 제약 조건이 설정되어 있어서 이로 인해 Hibernate가 엔티티를 데이터베이스에 저장할 수 없었다.

 

빌더 패턴을 사용해서 구현할 거면 아래와 같이 기존 사용자가 notnull로 갖고 있는 필드들에 대해서 다 값을 복사해줘야 한다는 것이다.. 그래서 아니 세터보다 빌더를 사용하라면서 왜 저렇게 굳이 저래야 하나..? 했는데 열심히 구글링을 해서 빌더와 세터의 차이에 대해서 확실히 이해가 갔다.

/**
 * Oauth2 로그인 시 추가 정보 입력
 */
@Override
public Optional<User> updateOauth2(String username, Oauth2LoginDto oauth2LoginDto) {

    User user = User.builder()
				.name(user.getName()) // 기존에 있던 값 추가
				.phoneNumber(oauth2LoginDto.getPhoneNumber())
				.gender(oauth2LoginDto.getGender())
				.birthdate(oauth2LoginDto.getBirthdate())
				.build();
    userRepository.save(user);
    return Optional.of(user);
}

 

이러한 문제가 발생된 원인은 내가 빌더패턴을 정확하게 이해하지 못해서 발생한 대참사이다. 빌더는 객체를 수정하는 것이 아니라 생성자처럼 객체를 생성하는 기능을 수행한다고 한다.

 

 

정확하게 알아야 할 개념

  • 빌더패턴은 객체의 생성을 담당하는 패턴으로, 일반적으로 빌더를 사용하여 객체를 새로 생성한다. 빌더를 사용하여 객체를 생성할 때, 이전 객체의 값을 수정하는 것이 아니라 새로운 객체를 생성하는 것이다.
  • 이것이 빌더 패턴의 주요 특징 중 하나이며, 이를 이해하는 것이 중요하다.
  • 빌더 패턴은 객체 생성 시에 여러 속성을 가진 객체를 편리하게 생성하기 위해 사용된다. 따라서 객체의 속성을 일부 변경하거나 해당 속성을 지정하여 새로운 객체를 생성해야한다고 한다.
  • 따라서 내가 dto로 받아온 값을 User 엔티티의 빌더 패턴으로 엔티티 객체로 변환하려고 하는 행위(?)는 잘못되었다고 할 수 있다.
  • 하지만 빌더 패턴을 사용해서 객체의 데이터를 수정하는 것이 아예 불가능한 것은 아니라고 한다.

 

 

그렇다면 빌더 패턴으로 어떻게 값을 수정할 수 있는가?

// entity
@Getter  @Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true) // 기존 인스턴스를 기반으로 새로운 객체를 빌드할 수 있게 함
public class Oauth2LoginDto {

    private String phoneNumber; // 연락처
    private LocalDate birthdate; // 생년월일
    private String gender; // 성별
}

이처럼 toBuilder 속성을 true 로 설정해줄 경우 toBuilder() 메서드를 사용할 수 있게되는데

이 메서드를 사용하면 이미 생성되어있는 객체의 값을 toBuilder() 메서드를 활용하여 특정 필드의 값만을 변경해줄 수 있다고 한다.

 

 

결론

toBuilder 메서드를 활용하면 객체의 값을 일부 수정할 수는 있지만 내가 구현하려는 로직에서는 사용하기에 적합하지 않은 것 같다는 생각이 들었다.

어쨌든 빌더패턴이라는 것이 매개변수가 많아 생성자에 정의된 순서대로 데이터를 넣기 어려워 이를 편하게 하기 위한 디자인패턴인 건데 즉, 객체를 생성하는 것이 주된 목적인데 나는 객체의 값을 수정하는 로직을 만들어야 하므로 객체의 값을 부분적으로 수정하기 위해서는 굳이 엔티티 객체로 바꿀 필요는 없을 것 같고, dto에서 값을 꺼내서 entity에 setter 를 사용해서 update를 해주는 방식으로 구현하는 것이 적합한 것 같다.

 

 

✅ 정리

  • 세터 사용 방법: 기존 사용자 객체를 업데이트하는 방식이므로, 코드가 간결하고 오류 발생 가능성이 적다. 또한, 필수적으로 설정해야 하는 필드를 실수로 놓칠 가능성도 적다.
  • 빌더 사용 방법: 만약 객체의 다른 필드를 변경하거나 새로운 객체로 교체해야 하는 상황이 아니라면, 굳이 빌더 패턴을 사용할 필요는 없다. 이 방법은 필요 이상으로 복잡할 수 있으며, 잘못된 필드 값을 복사하거나 누락할 위험이 있다.
  • 즉, 빌더 패턴은 새로운 객체를 생성하거나 객체의 불변성을 유지해야 할 때 더 유용하다. 하지만 여기서는 기존 객체에 OAuth2 유저의 성별, 생일, 연락처의 정만 업데이트하는 것이므로 세터방식을 사용하는 것이 더 자연스럽고 안전하다.
728x90
728x90

개요

스프링 어노테이션에 대해서 정리할려고 한다.

 

@ModelAttribute

  • 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어주어야 한다.
  • 스프링에서는 위 과정을 자동화해주는 @ModelAttribute 기능을 제공한다.
  • 먼저, 요청 파라미터를 바인딩받을 객체를 만든다.

Item 객체

package hello.itemservice.domain.item;
import lombok.Data;
@Data
public class Item {
    private Long Id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
} 
  • @Data : @Getter , @Setter , @ToString , @EqualsAndHashCode , @RequiredArgsConstructor를 자동으로 적용해준다.
  • 하지만 개인 프로젝트에서만 사용하고 실제 협업에서는 사용하지 말자.

 

View 의 일부분

  • th:action="@{/basic/items/add}":
    - 버튼 sumbit 이 발생하면 해당 컨트롤러를 호출해준다.
  • input 의 이름들은 각각 itemName, price, quantity 로 위에 작성한 Item 객체의 필드 네임과 일치한다.
    <form action="item.html" th:action="@{/basic/items/add}" method="post">
        <div>
            <label for="itemName">상품명</label>
            <input type="text" id="itemName" name="itemName" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price">가격</label>
            <input type="text" id="price" name="price" class="form-control"
                   placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity">수량</label>
            <input type="text" id="quantity" name="quantity" class="form-control" placeholder="수량을 입력하세요">
        </div>
        <hr class="my-4">
        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">상품 등록</button>
            </div>
        </div>
    </form>

 

@RequestParam 과 @ModelAttribute

  • Request 에서 ModelAttribute 로 적용해보는 과정을 정리해본다.

 

 

@RequestParam 으로 요청 파라미터 가져오기

  • form 에 input으로 정의된 데이터들을 가져온다
    @PostMapping("/add")
    public String addItemV1(@RequestParam String itemName ,
                      		@RequestParam int price,
                       		@RequestParam int quantity,
                       		Model model) {

        Item item = new Item();
        item.setItemName(itemName);
        item.setPrice(price);
        item.setQuantity(quantity);

        itemRepository.save(item);
        model.addAttribute("item", item);

        return "basic/item";
    }
  • ModelAttribute를 사용하지 않는 경우 다음과 같은 과정을 통해 코드를 작성해야 한다.
  • @RequestParam을 통해 요청 파라미터를 받는다.
  • 만약 가져와야 하는 데이터가 많다면 코드가 너무 길어질 것이다.

 

 @ModelAttribute 적용1

  • 각각 RequestParam 으로 받았던 데이터를 객체로 받을수 있다.
  • 객체의 필드이름과 form 의 input 타입의 이름만 같으면 된다.
    @PostMapping("/add")
    public String addItemV2(@ModelAttribute("item") Item item) {
        itemRepository.save(item);
        return "basic/item";
    }

 

 @ModelAttribute 적용2

  • ModelAttribute 의 name을 생략할수있다.
    @PostMapping("/add")
    public String addItemV3(@ModelAttribute Item item) {
        itemRepository.save(item);
        return "basic/item";
    }

 

 @ModelAttribute 적용3

  • 아예 ModelAttribute  자체도 생략이 가능하다.
   @PostMapping("/add")
    // 아예 생략도 가능하다.
    public String addItemV4(Item item) {
        itemRepository.save(item);
        return "basic/item";
    }
 

 

 

주의할 점

  • @RequestParam도 생략할 수 있어 혼란이 발생할 수 있다.
  • 스프링에서는 이러한 혼란을 방지하기 위해 다음과 같은 규칙을 적용하고 있다.
  • String, int, Integer와 같은 단순 타입의 경우 : @RequestParam
  • argument resolver로 지정해둔 타입 외의 나머지 : @ModelAttribute
728x90
728x90

개요

프로젝트 중

@Transactional 을 안 붙여서 오류가 나는 경우가 종종 있어서 이번 기회에 정리해놓을려고 한다.


@Transactional

 

Q. @Transactional 어노테이션 붙이는 이유?

  • 데이터베이스의 상태를 변경하는 메서드나 복수의 데이터베이스 작업을 묶어서 처리할 때 사용한다.
  • 데이터를 데이터베이스에 저장하거나 삭제, 수정하는 메서드처럼 데이터베이스의 상태를 변경하는 경우에 사용한다.

 

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public void registerUser(String username, String email, String password, String passwordCheck, String usernick) throws IOException {
        
        Role role = roleRepository.findByRoleName(RoleName.ROLE_USER)
                    .orElseThrow(() -> new UserNotFoundException("User 역할이 없습니다."));
 
        User user = new User();
        user.setRole(Collections.singleton(role));  // singleton -- 단일 역할 설정
        user.setUserName(username);
        user.setEmail(email);
        user.setPassword(passwordEncoder.encode(password));
        user.setUserNick(usernick);

        userRepository.save(user);
    }

 @Transactional

  • 메서드가 실행되는 동안 트랜잭션을 시작하고, 메서드가 정상적으로 완료되면 커밋(commit)을 하고, 예외가 발생하면 롤백(rollback)을 수행한다.
  • 이 어노테이션을 사용하여 데이터베이스 관련 작업을 안전하게 처리할 수 있다.
  • 기본 값 : @Transactional(readOnly = false)
    • 특징: 위의 예시에서는 registerUser 메서드에 적용되어 있으며, readOnly = false로 설정되어 있어 해당 메서드는 읽기/쓰기 모두 가능한 트랜잭션을 사용합니다.

 

readOnly 속성의 차이점

  1. readOnly = true
    • 이 옵션을 설정하면, 트랜잭션 동안 데이터를 수정하는 쿼리(INSERT, UPDATE, DELETE 등)를 수행할 수 없다.
    • 오직 읽기 전용 쿼리(SELECT)만 가능
    • 데이터를 조회만 할때, 전체 사용자, 게시글 조회 등등
    • 이는 데이터베이스에게 현재 트랜잭션이 데이터를 변경하지 않을 것임을 알려 성능 최적화를 도와준다.
    • 읽기 전용 트랜잭션은 데이터베이스의 트랜잭션 로그를 기록하지 않아도 되어 I/O 부하를 줄일 수 있다.
  2. readOnly = false (기본값)
    • 읽기 및 쓰기 작업 모두를 수행할 수 있는 트랜잭션
    • 데이터베이스에 변경 사항이 발생하면 트랜잭션 로그 기록
    • 쓰기 작업이 필요한 경우에는 이 옵션을 사용
    • 삭제, 등록, 수정 등 데이터베이스의 상태를 변경할 때 사용한다.

 

결론 

데이터베이스에서 삽입, 삭제, 수정 등 데이터베이스의 내용이 변하게 된다면 @Transactional 붙여야 한다.!!

728x90
728x90

개요

이전 글에 이어서 [멋쟁이사자처럼 백엔드 TIL] Spring : 알림 기능 구현 종류 :: 미정 (tistory.com)

DB 알림함을 만들어서 알림기능을 관리하기로 결정했다.

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

 

 


1. 엔티티 생성

 

Notification.java

@Entity
@Getter @Setter
@JsonInclude(JsonInclude.Include.NON_NULL)
public class Notification {
    
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String username;
    private String message;
    private boolean isRead;

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @PrePersist
    public void prePersist() {
        if (createdAt == null) {
            createdAt = LocalDateTime.now();
        }
    }
}

알림을 담을 엔티티를 하나 생성했다. isRead는 읽었는지 안읽었는지 확인하는 칼럼이다.

이때 username은 알림을 받을 사용자이다.

 

 

2. 레포지토리

NotificationRepository.java

public interface NotificationRepository extends JpaRepository<Notification, Long> {
    List<Notification> findByUsername(String username);

    List<Notification> findByUsernameAndIsRead(String username, boolean isRead);

    // '읽음'으로 표시된 알림 중 특정 시간 이전에 생성된 알림을 찾는 메서드
    List<Notification> findByIsReadAndCreatedAtBefore(boolean isRead, LocalDateTime cutoffTime);
}

DB에 저장하기 위한 레포지토리이다. 그리고 스케쥴링을 통해 관리를 하기 위해 메서드를 하나 추가한다.

 

 

 

3. 서비스

NotificationService.java

@Service
public class NotificationService {

    private final NotificationRepository notificationRepository;
    public NotificationService(NotificationRepository notificationRepository) {
        this.notificationRepository = notificationRepository;
    }

	// 알림을 생성
    @Transactional
    public void createNotification(String username, String message) {
        Notification notification = new Notification();
        notification.setUsername(username);
        notification.setMessage(message);
        notification.setRead(false);  // 기본값: 읽지 않음
        notificationRepository.save(notification);
    }

    // 알림 리스트 목록에서 버튼을 누르면 알림을 읽은 상태로 표시
    public void markAsRead(Notification notification) {
        notification.setRead(true);
        notificationRepository.save(notification);
    }

	// 알림이 보낼 때 상태
    @Transactional
    public void markAsRead(Long notificationId) {
        Notification notification = notificationRepository.findById(notificationId)
                .orElseThrow(() -> new RuntimeException("알림을 찾을 수 없습니다."));
        notification.setRead(false);
        notificationRepository.save(notification);
    }

    // 특정 ID로 알림을 가져옴
    public Notification getNotificationById(Long id) {
        return notificationRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("알림을 찾을 수 없습니다."));
    }

    // 사용자에게 보낸 알림 목록을 반환
    public List<Notification> getUnreadNotificationsByUsername(String username) {
        return notificationRepository.findByUsernameAndIsRead(username, false);
    }
}

각 메서드의 역할은 위에 주석으로 설명했다.

markAsRead는 매개변수 값이 달라서 혼동하지 않아야 한다.

 

 

4. 컨트롤러

NotificationRestController.java

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/notifications")
public class NotificationRestController {

    private static final Logger log = LoggerFactory.getLogger(NotificationRestController.class);
    private final NotificationService notificationService;
    private final UserService userService;

    private final CopyOnWriteArrayList<SseEmitter> emitters = new CopyOnWriteArrayList<>();

    @GetMapping("/stream")
    public SseEmitter streamNotifications(Authentication authentication) {
        SseEmitter emitter = new SseEmitter(60000L); // 60초 타임아웃 설정
        emitters.add(emitter);

        emitter.onCompletion(() -> emitters.remove(emitter));
        emitter.onTimeout(() -> {
            emitters.remove(emitter);
            emitter.complete();
        });
        emitter.onError((e) -> {
            emitters.remove(emitter);
            emitter.completeWithError(e);
        });

        // 사용자 인증 확인
        if (authentication == null) {
            emitter.complete();
            return emitter;
        }

        String username = authentication.getName();
        Optional<User> userOptional = userService.findByUserName(username);

        if (!userOptional.isPresent()) {
            emitter.complete();
            return emitter;
        }

        String user = userOptional.get().getUserName();

        // 새로운 쓰레드에서 알림을 전송
        new Thread(() -> {
            try {
                while (true) {
                    if (emitters.contains(emitter)) {
                        List<Notification> notifications = notificationService.getUnreadNotificationsByUsername(user);

                        if (!notifications.isEmpty()) {
                            for (Notification notification : notifications) {
                                if (!notification.isSent()) { // 알림이 전송되지 않았는지 확인
                                    emitter.send(SseEmitter.event()
                                            .name("notification")
                                            .data(notification.getMessage()));

                                    // 알림을 읽음 상태로 업데이트 및 전송됨 상태로 설정
                                    notificationService.markAsRead(notification.getId());
                                }
                            }
                        }
                    } else {
                        break; // emitter가 이미 완료된 경우 반복문 종료
                    }
                    Thread.sleep(10000); // 10초마다 알림을 체크
                }
            } catch (Exception e) {
                log.info(e.getMessage());
                // emitter.completeWithError(e);
            }
        }).start();
        return emitter;
    }
}

서버가 클라이언트로 실시간 데이터를 전송할 수 있게 해주는 코드이다.

메서드를 보면 SseEmitter 객체를 생성하여 반환한다. SseEmitter는 서버가 클라이언트에게 비동기적으로 데이터를 전송할 수 있도록 해준다. SSE 방식으로 알림을 구현한다면 사용되는 객체이다.

  • new SseEmitter(60000L): 60초 타임아웃을 설정하여 SseEmitter를 생성한다.
  • notificationService를 통해 사용자의 읽지 않은 알림 목록을 가져온다.
  • 읽지 않은 알림이 있으면, emitter.send() 메서드를 사용하여 각 알림을 클라이언트에게 전송한다.
  • 전송 후에는 알림을 읽음 상태로 업데이트한다.
  • 만약 알림이 한번 전송된 후에는 다시 전송되지 않도록 관리할 수 있다.

이 코드를 통해 사용자는 새로운 알림이 있을 때마다 즉시 알림을 받을 수 있고 알림은 한번만 울리도록 설정했다.

 

 

 

NotificationController.java

@Controller
@RequiredArgsConstructor
@RequestMapping("/notifications")
public class NotificationController {
    private final NotificationService notificationService;

    /**
     * 로그인한 사용자의 읽지 않은 알림 리스트
     */
    @GetMapping
    public String getNotifications(Model model, Authentication authentication) {
        if (authentication == null || !authentication.isAuthenticated()) {
            return "redirect:/loginform"; // 로그인 페이지로 리다이렉트
        }
        String username = authentication.getName();
        List<Notification> notifications = notificationService.getUnreadNotificationsByUsername(username);
        model.addAttribute("notifications", notifications);
        return "notifications";
    }

    // 알림을 읽음 상태로 변경하는 메서드
    @PostMapping("/api/notifications/mark-as-sent/{id}")
    @ResponseBody
    public ResponseEntity<String> markAsRead(@PathVariable("id") Long id) {
        Notification notification = notificationService.getNotificationById(id);
        if (notification != null) {
            notificationService.markAsRead(notification);
            return ResponseEntity.ok("Notification marked as read");
        } else {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Notification not found");
        }
    }
}

 

알림 목록을 받을 코드와 알림 리스트에서 읽음 버튼을 눌렀을 때 읽음 처리가 될 수 있도록 처리할 메서드를 생성한다.

 

 

5. 특정 행위에 대한 알림을 받을 곳에 코드 추가

@Service
@RequiredArgsConstructor
public class CommentService {

    private final PostRepository postRepository;
    private final CommentRepository commentRepository;
    private final UserRepository userRepository;
    private final NotificationService notificationService;

    /**
     * 댓글 추가
     */
    @Transactional
    public Comment addComment(Long postId, String content) {
        String username = getCurrentUsername();
        if (username == null) {
            throw new IllegalStateException("로그인된 사용자만 댓글을 작성할 수 있습니다.");
        }

        User user = userRepository.findByUserName(username)
                .orElseThrow(() -> new UserNotFoundException("해당 사용자를 찾을 수 없습니다. username: " + username));
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new PostNotFoundException("해당 포스트를 찾을 수 없습니다. postId: " + postId));

        Comment comment = new Comment();
        comment.setPost(post);
        comment.setUser(user);
        comment.setContent(content);
        Comment savedComment = commentRepository.save(comment);

        // 댓글 작성 후 알림 전송
        sendCommentNotification(post, user, content);
        
        return savedComment;
    }

    /**
     * 댓글 작성 시 알림 전송
     */
    private void sendCommentNotification(Post post, User commenter, String content) {
        User postAuthor = post.getUser();        // 게시글 작성자 정보 가져오기
        // 댓글을 작성한 사용자와 게시글 작성자가 동일하지 않을 때만 알림을 보냄
        if (!commenter.getUserId().equals(postAuthor.getUserId())) {
            notificationService.createNotification(postAuthor.getUserName(), commenter.getUserName() + "님이 댓글을 달았습니다: "
            );
        }
    }
}

일단 나는 댓글을 달 때 알림을 받기를 원하니깐 CommentService에서 알림 로직을 추가한다.

위와 같이 알림서비스의 createNotification 메서드를 호출하여 댓글이 달렸을 때 해당 게시글의 작성자한테 알림을 보낸다.

 

 

7. HTML

메인 홈 HTML

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>velog</title>
  <link href="/css/home.css" rel="stylesheet"/>
</head>
<body>

<nav class="navbar navbar-expand-lg navbar-light bg-light shadow-sm">
  <div class="container">
    <a class="navbar-brand text-white" href="#">NewsFeed</a>
    <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
      <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarNav">
      <ul class="navbar-nav me-auto mb-2 mb-lg-0">
        <!-- 알림 링크 추가 -->
        <li class="nav-item" th:if="${username != null and username != ''}">
          <a class="nav-link text-white notification-dot" th:href="@{/notifications}">📪</a>
        </li>
      </ul>
      <div th:if="${username != null and username != ''}" class="d-flex align-items-center ms-auto">
        <a class="btn btn-outline-light me-3" th:href="@{/posts/create}">새 글 작성</a>
        <div th:unless="${username == 'admin'}" class="dropdown">
          <a href="#" class="d-flex align-items-center text-decoration-none" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-expanded="false">
            <img class="profile-image" th:src="@{${profileImage}}" alt="Profile Image">
            <i class="bi bi-caret-down-fill text-white"></i>
          </a>
          <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="dropdownMenuButton">
            <li><a class="dropdown-item" th:href="@{/mypage}">내 벨로그</a></li>
            <li><a class="dropdown-item" th:href="@{/api/logout}">로그아웃</a></li>
          </ul>
        </div>
      </div>
      <div th:if="${username == null or username == ''}" class="d-flex">
        <a class="btn btn-outline-dark me-2" th:href="@{/loginform}">로그인</a>
      </div>
    </div>
  </div>
</nav>

<main class="container my-5">

  <!-- 알림 배너 -->
  <div id="notification-banner" class="alert alert-info d-none position-fixed top-0 end-0 m-3" role="alert">
    <span id="notification-message">새 알림</span>
    <button type="button" class="btn-close" aria-label="Close"></button>
  </div>
</main>
<script>
  (function(){var w=window;if(w.ChannelIO){return w.console.error("ChannelIO script included twice.");}var ch=function(){ch.c(arguments);};ch.q=[];ch.c=function(args){ch.q.push(args);};w.ChannelIO=ch;function l(){if(w.ChannelIOInitialized){return;}w.ChannelIOInitialized=true;var s=document.createElement("script");s.type="text/javascript";s.async=true;s.src="https://cdn.channel.io/plugin/ch-plugin-web.js";var x=document.getElementsByTagName("script")[0];if(x.parentNode){x.parentNode.insertBefore(s,x);}}if(document.readyState==="complete"){l();}else{w.addEventListener("DOMContentLoaded",l);w.addEventListener("load",l);}})();

  ChannelIO('boot', {
    "pluginKey": "{channerTalk key}"
  });
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
  document.addEventListener('DOMContentLoaded', function() {
    const eventSource = new EventSource('/api/notifications/stream');
    const notificationDot = document.querySelector('.notification-dot');

    eventSource.addEventListener('notification', function(event) {
      try {
        // 수신된 데이터를 문자열로 받음
        const notificationMessage = event.data;

        // 메시지와 배너를 업데이트
        const notificationElement = document.getElementById('notification-message');
        notificationElement.textContent = notificationMessage;

        const notificationBanner = document.getElementById('notification-banner');
        notificationBanner.classList.remove('d-none');

        // 빨간색 점 활성화
        if (notificationDot) {
          notificationDot.classList.add('active');
        }

        // 5초 후 배너를 숨김
        setTimeout(() => {
          notificationBanner.classList.add('d-none');
        }, 5000);

      } catch (error) {
        console.error('SSE 메시지 처리 오류:', error);
      }
    });

    eventSource.onerror = function(event) {
      console.error('SSE 연결 오류:', event);
      if (event.readyState === EventSource.CONNECTING) {
        console.error('연결 재시도 중...');
      } else if (event.readyState === EventSource.CLOSED) {
        console.error('연결이 닫혔습니다.');
      }
      console.error('상태:', event.target.readyState);
    };

    document.getElementById('notification-banner')
            .querySelector('.btn-close')
            .addEventListener('click', function() {
              const notificationBanner = document.getElementById('notification-banner');
              notificationBanner.classList.add('d-none');
            });

    window.addEventListener('beforeunload', function() {
      eventSource.close();
    });
  });
</script>
</body>
</html>

메인 홈 화면 폼이다. 여기에서는 알림이 오면 우측 상단에 메시지가 뜨며 📪 여기에 빨간 점이 표시된다. 알림을 읽지 않은 상태에서 표시되고 알림을 읽으면 사리지도록 구현했다. 아래는 위에 폼에 대한 css 코드이다.

body {
    font-family: 'Noto Sans KR', sans-serif;
    background-color: #1E1E1E;
    color: #E0E0E0;
}
.navbar {
    background-color: #121212 !important;
}
.navbar-brand, .navbar-nav .nav-link {
    color: #FFFFFF !important;
}
.btn-outline-light {
    color: #FFFFFF;
    border-color: #FFFFFF;
}
.btn-outline-light:hover {
    background-color: #007bff;
    color: #FFFFFF;
}
.btn-outline-dark {
    color: #FFFFFF;
    border-color: #FFFFFF;
}
.btn-outline-dark:hover {
    background-color: #007bff;
    color: #FFFFFF;
}
.profile-image {
    width: 40px;
    height: 40px;
    border-radius: 50%;
}
.profile-link {
    display: flex;
    align-items: center;
    text-decoration: none;
    color: white;
}
.profile-info {
    display: flex;
    align-items: center;
    margin-bottom: 20px;
}
.profile-info img {
    width: 100px;
    height: 100px;
    border-radius: 50%;
    margin-right: 20px;
}
.profile-info .username {
    font-size: 24px;
    font-weight: bold;
}
.profile-info .greeting {
    font-size: 18px;
    color: #AAAAAA;
}
.tags {
    margin-bottom: 20px;
}
.tags a {
    color: #E0E0E0;
    text-decoration: none;
    margin-right: 10px;
}
.post-title {
    font-size: 24px;
    font-weight: bold;
    margin-bottom: 10px;
}
.post-content {
    color: #AAAAAA;
    margin-bottom: 20px;
}
.emphasized {
    font-weight: bold;
}
.card-img {
    width: 40%;
    height: auto;
    object-fit: cover;
}
.space-between {
    margin-right: 280px;
}
.card {
    height: 400px; 
    overflow: hidden;
    display: flex;
    flex-direction: column;
}
.card-body {
    display: flex;
    flex-direction: column;
    overflow: hidden;
}
.card-text {
    flex-grow: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    white-space: normal;
}
.card-footer {
    margin-top: auto;
}
/* home.css */
.notification-dot {
    position: relative;
    display: inline-block;
}

.notification-dot::after {
    content: '';
    position: absolute;
    top: 0;
    right: 0;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background-color: red;
    transform: translate(50%, -50%);
    display: none;
}

.notification-dot.active::after {
    display: block;
}

 

알림 리스트 HTML

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>내 알림</title>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<div class="container">
    <h1>내 알림</h1>
    <div>
        <ul>
            <li th:each="notification : ${notifications}" class="notification-card">
                <p class="notification-message" th:text="${notification.message}"></p>
                <div class="notification-actions">
                    <button class="btn btn-outline-dark mark-as-read-button"
                            data-id="[[${notification.id}]]"
                            th:text="${notification.isRead ? '읽음' : '읽음으로 표시'}"></button>
                </div>
            </li>
        </ul>
    </div>
    <a href="/trending" class="home-link">홈으로 돌아가기</a>
</div>
<script>
    $(document).ready(function() {
        $('.mark-as-read-button').click(function() {
            var notificationId = $(this).data('id');
            var button = $(this);

            $.ajax({
                url: '/api/notifications/mark-as-sent/' + notificationId,
                type: 'POST',
                success: function(response) {
                    alert('알림이 읽음으로 표시되었습니다.');
                    button.text('읽음');
                    button.attr('disabled', true);  // 버튼 비활성화
                },
                error: function(xhr) {
                    alert('알림 상태를 변경하는 중 오류가 발생했습니다.');
                }
            });
        });
    });
</script>
</body>
</html>

나한테 울린 알림 목록을 보여주는 폼이다. 여기서 저 버튼을 누르면 읽음 표시가 되어서 디비에서 읽은 상태로 바뀌게 된다.

 

하지만 나는 레디스를 사용하지 않아서 RDB에 그대로 알림이 쌓여져 간다. 그렇기 때문에 스케줄링을 통해 알림을 관리할려고 한다.

SchedulingConfig.java

@Configuration
@EnableScheduling
public class SchedulingConfig {
    // 스케줄링 관련 설정
}

@EnableScheduling 을 붙임으로써 스케쥴링을 하겠다는 의미이다.

@Component
@RequiredArgsConstructor
public class NotificationBatchProcessor {

    private final NotificationRepository notificationRepository;

    /**
     * 주기적으로 호출되어 10분 이상 경과한 읽지 않은 알림을 삭제합니다.
     * 예: 생성된 지 10분 이상 경과한 읽지 않은 알림을 삭제
     */
    @Scheduled(cron = "0 0/5 * * * ?") // 매 5분마다 실행
    public void deleteUnReadNotifications() {
        // 현재 시간 기준으로 10분 이상 경과한 알림을 조회
        LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(10);
        List<Notification> notificationsToDelete = notificationRepository.findByIsReadAndCreatedAtBefore(false, cutoffTime);

        // 알림 삭제
        notificationRepository.deleteAll(notificationsToDelete);
    }
}

스케쥴링같은 경우는 cron 표현을 사용해서 구현한다. [Cron] 크론(cron) 표현식 정리 (tistory.com)

테스트용이라서 5분마다 메서드를 실행하는데, 현재 시간에서 10분 전 시간을 계산하여 cutoffTime에 저장한다.

10분 전에 생성된 알림을 고려해서 삭제한다는 의미이다.

  • notificationRepository.findByIsReadAndCreatedAtBefore(false, cutoffTime)
    • 이 부분에서 isRead가 false(읽지 않은)이고, createdAt이 cutoffTime보다 이전인 알림들을 조회한다.
    • 따라서, 10분 이상 경과한 false인 읽지 않은 알림을 조회하는 것이다.

 

 

이렇게 알림 기능 구현 완료~ 약간 복잡해서 어려웠다.

 

 

728x90
728x90

개요

어떤 특정 행위에 대한 알림 기능은 유저의 요청 없이도 실시간으로 서버의 변경 사항을 웹 브라우저에 갱신해줘야 하는 서비스이다. 하지만 전통적인 Client-Server 모델의 HTTP 통신에서는 이런 기능을 구현하기가 어렵다. 클라이언트의 요청이 있어야만 서버가 응답을 할 수 있기 때문이다. HTTP 기반으로 해당 문제를 해결하려면 다음과 같은 방식들이 있다.

 

 

일단 알림 기능을 위해 사용되는 방법 4가지를 간략하게 정리해보았다. 

 

실시간 통신의 방법

Polling

  • 특징
    • 일정 주기로 서버 API 호출하는 방법
    • 클라이언트에서 5초마다 한 번씩 알림 목록을 호출한다면, 업데이트 내역이 5초마다 갱신되며 변경 사항을 적용할 수 있다.
      • 주기가 길다면? : 데이터의 실시간성을 보장할 수 없다.
      • 주기가 짧다면? : 갱신된 내용 없어도 불필요한 요청이 자꾸 들어오기 때문에 불필요한 서버 부하가 발생한다.

 

Long-Polling

  • 특징
    • Polling과 비슷하나, 업데이트 발생 시에만 응답을 보내는 방법
    • 서버에 요청 들어왔으면 일정 시간 대기하다가 업데이트 된 내용이 있을 경우 웹 브라우저에 응답을 보낸다.
      • 불필요한 응답을 주는 경우를 줄일 수 있다. (장)
      • 연결이 되어있는 경우 실시간으로 데이터가 올 수 있다. (장)
      • 데이터 업데이트가 빈번할 경우, 연결을 위한 요청이 계속 발생하기에 서버 부하 발생한다. (단)

 

SSE (Server-Sent Event)

  • 특징
    • 웹브라우저에서 서버 쪽으로 특정 이벤트를 구독하면, 서버에서 해당 이벤트 발생 시에 웹 브라우저로 이벤트를 보내주는 방법
    • 따라서 한 번만 연결 요청을 보내면, 연결이 종료될 때까지 재연결 과정 없이 서버에서 웹 브라우저로 데이터를 계속해서 보낼 수 있다.
      • 서버에서 웹 브라우저로만 데이터 전송 가능(단방향)
      • 최대 동시 접속 횟수가 제한되어있다.
      • 한번만 연결요청 보내면, 연결 종료 전까지는 재연결 없이 계속 데이터를 전송할 수 있다. (안끊김)

 

Web Socket

  • 특징
    • 서버와 웹브라우저 사이 양방향 통신이 가능한 방법이다.
    • 변경 사항에 빠르게 반응해야하는 채팅이나, 리소스 상태에 대한 지속적 업데이트가 필요한 문서 동시 편집과 같은 서비스에 많이 사용되는 방식이다.
      • 양방향 통신이 지속적으로 이루어진다.
      • 연결을 유지하는 것 자체가 비용이 들기에, 트래픽 많으면 서버에 부담이 된다.

 

결론

  • Polling 방식은 실시간성을 높이려면 그 주기를 짧게 해야 하는데, 트래픽이 많아질 경우 서버에 걸리는 부하가 커지기 때문에 알림 서비스에는 부적합하고 할 수 있다.
  • Long-Polling 역시 마찬가지로, 트래픽이 많아지면 요청도 그만큼 많아지므로 부적합하다.
  • 그렇다면 HTTP 연결 방식에 대한 부담이 적은 SSE와 WebSocket 방식이 남는데, 알림 서비스의 경우 클라이언트에서 서버로 데이터를 전송하지 않아도 되어서 단방향 통신만으로도 구현할 수 있으므로, SSE 방식을 택하는 것이 좋겠다.

 

해결 과정

  • 알림의 경우 전통적인 client-server 모델의 http 통신에서는 구현하기 어렵다.
  • 해당 방식은 클라이언트의 요청이 있어야만 서버가 응답하는데, 알림은 ‘클라이언트의 요청이 없이’ 실시간으로 서버의 변경 사항이 웹 브라우저에 갱신 되어야하기 때문이다.
  • 제시된 4가지 방법 중 http 연결에 대한 부담이 적은 방식은 SSE, Web socket 이며 실시간성도 보장이 된다.
  • 이 중 알림은 단방향 통신만 해도 된다. (서버 → 클라이언트) 그래서 보통 알림은 SSE 방식을 많이 채택한다.

 

알아보니 Spring에서 제공하는 SSE 프레임워크 사용하면 알람 기능이 쉽게 구현될 것 같아 SSE 방식의 알림 기능을 구현하기로 정했다.

 

크게 보면 작업은 아래와 같이 진행될 것이라 예상된다.

  1. 웹 브라우저와 서버 연결하기 (한번 연결하면 재연결 자주 안해도 된다.)
    1. 연결 관련해서 설정해주기 (연결 , 알림 전송 메소드 생성 포함)
  2. 생성해둔 알림 전송 메소드를 각 이벤트가 발생하는 서비스 메소드 내에 추가한다.
    1. 크루장 승인의 경우, 승인 메소드 내에 알림 전송 메소드 포함하여 알림 전송될 수 있도록 한다.

 

이 때, 고민했던 내용들

 

Q. SSE는 실시간 접속 중일 때만 알림을 보낼 수 있다. 사용자가 오프라인일 때는 어떻게 할지?

 

위의 질문에 대해 3가지 해결 방법을 생각해보았다. 

 

A. 

  1. 서버가 사용자의 접속여부를 파악하여서 따로 처리한다. 이 때, 지금 프로젝트는 토큰 방식으로 인증을 하기에, 서버에서 사용자의 접속 여부 알 수 없다. 따라서 접속여부 파악하는 방법?
    1. 접속여부 DB 생성하고, 클라이언트에서 heartbeat 5초에 한번씩 서버로 보내서 접속 여부 파악 → 주로 프론트 개발자가 해야할 일들이라서 패스
  2. 푸시 알람 전송하기.
    SSE의 경우 오프라인일 경우 실시간 전송이 안됨. 물론 푸시 알림으로 처리할 수 있지만 이 부분은 프론트 개발자가 처리해야해서 일단 패스
  3. 알림함 만들기.
    특정 이벤트가 발생했을 때, 아래의 a,b 두 가지를 모두 실행한다.
    1. SSE로 실시간 알림을 전송한다 →
      1. 만약 오프일 경우, 그냥 알림 못받는 것으로 처리
      2. 온라인일 경우, 알림함 빨간 표시 + 팝업창 띄우기 정도
    2. 알림함 DB에 알림 내용을 저장한다. → 온, 오프 상관 없이 모두 DB에 저장하고, t/f로 읽음 여부 판단하기
      1. 알림함은 최초 접속 1회일 때만 조회해서 안읽은 알람 여부 판단하고, 이 후 실시간 알람 왔을 때에만 알림함 한번씩 조회하기. (실시간 알림 안왔으면 알림 조회할 이유 없으니까)

⇒ 결론으로는 3번째 방법으로 진행하기로 했다. 

 

 

 

 

 

 

참고 링크 

[백엔드|스프링부트] 알림 기능은 어떻게 구현하는게 좋을까? | TaemHam

728x90
728x90

개요

프로젝트 중 관리자 부분도 하기로 해서 구현해보고자 한다.

 

일단 관리자와 일반 유저를 나눴기 때문에 권한에 따라 접근할 수 있는 경로는 
1. SecurityConfig.java 에서 hasRole or hasAuthority 를 통해 접근 경로를 제한한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // 모든 유저 허용 페이지
    String[] allAllowPage = new String[] {
            "/",  "/de",      // 메인페이지
            "/signup", // 회원가입 페이지
            "/login", // 로그인 페이지
    };

    // 관리자 페이지
    String[] adminAllowPage = new String[] {
            "/admin",
            "/admin/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(allAllowPage).permitAll()
                        .requestMatchers(adminAllowPage).hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new RedisJwtFilter2(jwtUtil, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class)
                .formLogin(form -> form.disable());
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
 
    }

코드가 길어져서 불필요한 코드는 삭제했다. 

일단 나는 DB에
ROLE_ADMIN, ROLE_USER 형태로 사용자를 나눠서 저장했기 때문에 hasRole를 사용했다.

 

hasRole  과 hasAuthority 의 차이

  • hasAuthority
    • 권한을 부여할 때 역할이 아니라 권한 자체를 기준으로 접근을 제어
    • 직접 권한을 설정
    • hasAuthority("ROLE_ADMIN") 형태로 권한을 정확하게 명시해줘야 한다.
  • hasRole
    • 내부적으로 ROLE_ 접두사를 자동으로 붙인다.
    •  HTTP 요청 URL 패턴에 대해 접근 권한을 설정
    • 그래서 DB에 ROLE_ADMIN이라고 명시되어 있기 때문에 hasRole("ADMIN")이라고 명시를 한다.
@Controller
@RequiredArgsConstructor
public class AdminController {

    private final UserService userService;
    private final JwtTokenizer jwtTokenizer;

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/userboard")
    public String adminPageAllUserForm(Model model, HttpServletRequest request) {

        model.addAttribute("users", userService.findAllUsers());
        String accessToken = jwtTokenizer.getAccessTokenFromCookies(request);
        if (accessToken != null) {
            String username = jwtTokenizer.getUsernameFromToken(accessToken);
            User user = userService.findByUsername(username).orElse(null);
            model.addAttribute("user", user);
        }
        return "user/admin-all-user";
    }

또한 이미 configure 메서드에서 권한을 제한했지만 더욱 명확하게 하기위해 메서드 호출 전에 다시한번 확인차 

 @PreAuthorize("hasRole('ADMIN')")

어노테이션을 붙여주는 것이다.

  • @PreAuthorize("hasRole('ADMIN')")
    • 주로 메소드에서 사용
    • 특정 메소드에 접근할 수 있는 권한을 세밀하게 설정할 때 사용
    • 이 어노테이션은 메소드 호출 전에 현재 사용자의 권한을 확인하여, 지정된 역할(ADMIN)이 있는지 체크
    • 해당 역할이 없으면 메소드 호출이 거부

 

이렇게 권한을 제한했고 일반 사용자가 "/admin/**") url에 접근할 시 접근이 제한되게 된다. 이제 본격적으로 관리자 페이지를 구현해보겠다.

728x90

+ Recent posts