영속성 컨텍스트를 알아야 하는 이유

  • jpa의 핵심인 영속성 컨텍스트에 대한 이해가 부족하면 sql을 직접 사용해서 개발하는 것보다 못한 상황이 벌어진다!
  • 뭔지도 모르면서 냅다 기술만 갖다 쓰는 최악의 경우가 된다는 것!!!

즉, jpa의 핵심 == 영속성 컨텍스트

 

ORM

자바의 객체와 데이터베이스의 데이터를 매핑해주는 방법

 

JPA

자바에서 orm 표준으로 사용하는 인터페이스

 

Hibernate

orm 프레임워크이자, jpa 구현체, 내부적으로는 jdbc api를 사용함

 

 

JPA의 핵심요소

1. Persistance Unit : 데이터베이스 연결을 위한 설정, jdbc 드라이버, url, 사용자 이름, 비밀번호 등, 스프링에서는 properties나 yml로 설정할 수도 있음

2. Entity Manager Factory : Entity Manager를 생성

3. Persistence Context : Entity를 관리하는 공간

4. Entity Manager : Entity를 관리함

5. Entity : DB에 매핑될 객체

 

 

Flush와 Commit의 차이

용어 의미
Flush em.flush() → 변경된 엔티티들을 SQL로 변환해 DB에 전송 (but 아직 확정 X)
Commit tx.commit() → 보낸 SQL들을 실제 DB에 반영 확정
Rollback tx.rollback() → 보낸 SQL도 모두 무효 처리

 

 

Entity Manager

  • 엔티티 매니저를 통해서 영속성 컨텍스트에 접근하고 관리함
  • 엔티티 매니저를 생성하면 그 안에 영속성 컨텍스트가 있음
  • 엔티티 매니저 팩토리를 통해 요청이 올 때마다 엔티티 매니저 생성
  • 엔티티 매니저는 내부적으로 connection을 사용하여 db 접근
  • 엔티티 저장 : EntityManager.persist(entity)
    • 데이터를 영속성 컨텍스트에 저장
    • 엔티티를 영속화한다는 의미
    • DB에 엔티티를 저장한다는 의미가 아님
  • 스프링에서 엔티티 매니저는 여러개고, 영속성 컨텍스트는 1개만 존재함
    • 엔티티 매니저:영속성 컨텍스트 = N : 1

 

Entity 생명주기

엔티티는 엔티티 매니저를 통해서 영속성 컨텍스트에 보관, 관리, 제거됨

  • 비영속(new)
    • 객체 생성
    • 엔티티가 영속성 컨텍스트에 없는 상태 (= 영속성 컨텍스트와 관계 x)
Member member = new Member(); // 엔티티(객체) 생성
  • 영속(managed)
    • 엔티티가 영속성 컨텍스트에 저장
    • 영속성 컨텍스트가 엔티티를 관리
em.persist(member);
  • 준영속(detached)
    • 엔티티가 영속성 컨텍스트에 저장되었다가 분리된 상태
      • 엔티티가 영속 상태에서 준영속 상태로 간 상태
    • 영속성 컨텍스트가 엔티티 관리하지 않음
      • 영속성 컨텍스트가 제공하는 기능 사용하지 못함
    • 준영속 상태로 만드는 법
      • em.detach(entity);
        • 특정 엔티티만 준영속 상태로 변환
      • em.clear()
        • 영속성 컨텍스트 초기화(다 지움)
      • em.close()
        • 영속성 컨텍스트 종료
  • 삭제(removed)
    • 영속성 컨텍스트에 있는 엔티티를 삭제
    • 이때 DB에서도 삭제
      • 트랜잭션이 끝나거나 EntityManger를 플러시하여 삭제 쿼리가 나가야 DB에서 삭제됨

 

영속성 컨텍스트 (Persistence Context)

  • 애플리케이션과 DB사이 객체를 보관하는 가상의 DB
  • 엔티티 매니저를 통해 엔티티를 영속성 컨텍스트에 보관, 관리함

 

엔티티를 영속성 컨텍스트에 보관하여 얻는 장점

  1. 1차 캐시
  2. 동일성 보장
  3. 트랜잭션을 지원하는 쓰기지연
  4. 변경감지
  5. 지연로딩

 

1차 캐시

  • 영속성 컨텍스트에 1차 캐시 존재
  • 캐시는 map 형태로 key-value 저장
    • key : DB의 PK(기본 키)
    • value : 객체

 

1차 캐시 동작 예시

  • member1이 PK가 member1인 엔티티 생성 후 영속화
  • 이 다음 PK가 member1인 엔티티 조회하면?
    • em.find(Member.class, "member1");
    • JPA는 영속성 컨텍스트에 1차 캐시 확인
    • key = member1 있으면 캐시에서 값을 가져옴

 

만약 1차 캐시에 찾는 엔티티가 없으면?

  • em.find(Member.class, "member2");
  • JPA는 영속성 컨텍스트에 1차 캐시 확인
  • 1차 캐시에 없다면 DB 조회
  • DB에 데이터 있으면 가져와 1차 캐시에 저장 후 데이터 반환

 

 

동일성 보장

같은 트랜잭션 안에서 같은 객체는 == 비교 보장

// 같은 PK 값 데이터 찾음
// 같은 데이터이지만 저장된 객체는 member1과 member2로 다름
Member member1 = em.find(Member.class, 1L);
Member member2 = em.find(Member.class, 1L);

// 이 때 == 비교를 하면?
System.out.println("result: "+(member1 == member2));

// 결과
result: true

 

쓰기 지연 (Write-behind / Write Delay)

  • 쓰기 작업을 즉시 DB에 반영하지 않고, 나중에 한 번에 처리하는 전략
  • JPA에서 영속성 컨텍스트에 저장만 해놓고, 실제 DB쿼리는 지연해서 나중에 실행함
  • 예: em.persist(entity)를 해도 바로 insert 쿼리 안 나감
  • 트랜잭션 커밋 시(tx.commit()), 플러시(em.flush()), JPQL 실행 등을 해야 쿼리가 나감
em.persist(member); // INSERT 안 나감
em.persist(team);   // INSERT 안 나감
em.flush();         // INSERT 2개 나감

 

트랜잭션을 지원하는 쓰기 지연

  • 쓰기 지연을 트랜잭션 커밋 시점에 한꺼번에 실행하는 것
  • 트랜잭션의 원자성을 보장하기 위해 DB에 쓰지 연산을 최종적으로 커밋 직전에 flush
  • 한 트랜잭션 안에서 DB에 보낼 쿼리문을 모았다가 한번에 보냄
    • 쿼리문 모아두는 곳 : 영속성 컨텍스트 안에 있는 쓰기 지연 SQL 저장소
    • 쿼리문 보내는 시점
      • 트랜잭션 끝나기 전 - transcation.commit()
      • EntityManger 플러시 - em.flush()
  • 네트워크 부하를 줄이기 위한 기능
  • em.persist(entity)를 하면?
    • 영속성 컨텍스트 1차 캐시에 저장
    • JPA가 엔티티 분석하여 INSERT 쿼리 생성하여 쓰기 지연 SQL 저장소에 쌓아둠

 

쓰기 지연 SQL 저장소에 쿼리문 쌓는 과정

  • em.persist(memberA);
    • memberA 객체 생성 후 영속화
    • 1차 캐시에 저장 및 INSERT 쿼리 쌓아둠
  • em.persist(memberB)
    • memberB 객체 생성 후 영속화

 

트랜잭션 커밋이 일어나면?

  • transcation.commit() - 트랜잭션 커밋
  • 커밋 시점에 EntityManager에서 flush() 호출
    • flush: 영속성 컨텍스트의 내용을 DB에 반영, 모아두었던 SQL이 DB에 날라감
  • 실제 데이터 저장

 

변경감지(Dirty checking)

  • 영속성 컨텍스트에서 보관하는 데이터에 변경이 일어났는지 확인
  • 데이터 변경이 일어났다면 JPA가 알아서 UPDATE 쿼리문 날려줌
  • 엔티티 삭제도 동일함

작동 순서

트랜잭션 커밋이 발생하면

  1. flush() 호출
  2. 1차 캐시에서 entity와 스냅샷 비교
    • 스냅샷 : 최초로 영속성 컨텍스트에 들어온 객체 상태
    • entity : 실제 값
  3. entity와 스냅샷이 다르다면 쓰기 지연 SQL 저장소에 update 쿼리문 추가
  4. DB에 update 쿼리문 반영 후 commit

Member member = em.find(Member.class, 1L); // name: 홍길동, age = 20
member.setAge(21); // 데이터 변경
Hibernate: 
    /* update
        jpabasic.hellojpa.Member */ 
        update
            Member 
        set
            name=? 
        where
            id=?

 

지연 로딩

  • 엔티티를 DB에서 가져올 때 연관관계를 가진 다른 객체를 가지고 있다면?
    • 즉시 로딩 : 엔티티를 조회할 때 연관된 객체도 함께 DB에서 조회
    • 지연 로딩 : 연관된 객체는 조회 x, 나중에 필요하면 그 때 DB에서 가져옴
  • 각 연관 관계마다 즉시 로딩, 지연 로딩 설정 가능

 

작동 방식 정리

어떤 자바 객체가 방금 생성됨. 비영속 상태. 영속성 컨텍스트랑 Entity 매니저랑 아무런 관련이 없다. jpa는 아무런 작업을 하지 않음.

 

어떤 엔티티를 저장하거나 조회한다. 영속 상태. jpa가 엔티티의 변경사항을 추적한다. insert나 update문을 실행함

 

어떤 엔티티를 삭제함. 삭제상태. jpa는 delete문을 실행함

 

트랜젝션을 커밋한다. (@transactional 이 붙은 함수가 종료되면 ) 지연 저장소에 있는 쿼리문을 DB로 보내고 영속성 컨텍스트가 닫히고, 영속 객체가 준영속 상태가 됨. jpa는 더 이상 변경을 추적하지 않는다. 추가적인 sql을 실행하지도 않는다.

 

요청 1개마다 → 트랜잭션 1개 → EntityManager 1개

  • 1차 캐시가 있는 EntityManager는 트랜잭션 단위로 만들고 사라진다. 즉, 1차 캐시가 살아있는 시간은 매우 짧아 성능에 큰 효과는 없다.
  • 트랜잭션마다 각자 EntityManger를 사용한다. 즉, 각자 다른 영속성 컨텍스트와 1차 캐시를 가진다.
 
POST /members ← 새로운 요청

 

동작 순서

  1. Spring이 요청을 받음
  2. @Transactional 걸린 메서드 호출됨
    트랜잭션 시작
  3. Spring이 EntityManager를 하나 생성
    → PersistenceContext도 함께 생성
  4. 서비스/레포지토리에서 작업 중인 엔티티들은 영속 상태
  5. 메서드가 끝나면
    → flush()
    → commit()
    EntityManager 닫힘
  6. → 모든 엔티티는 준영속(detached)

 

다음 요청이 오면?

  1. 새로운 트랜잭션 시작
  2. 새로운 EntityManager 생성
  3. → 새로운 영속성 컨텍스트 시작
  4. 이전 요청에서 쓰던 엔티티 객체들은 이미 준영속 상태이기 때문에
    → 다시 쓰려면 merge() 등으로 재등록해야 함

 

트랜잭션 없는 요청일 때

@Transactional 없이 find() 가능 (읽기 연산은 DB와 바로 연결됨)
@Transactional 없이 persist()  예외 발생 또는 반영 안 됨
@Transactional(readOnly = true)  조회만 가능, 쓰기 금지 (안전모드)

 

근데  Spring Data JPA를 쓸 때는 @Tranjactional을 안 붙여도 쓰기 작업이 됐음!! 뭐지?!

Spring Data JPA는 내부적으로 save(), delete() 같은 메서드는 자동으로 @Transactional이 적용된 상태로 실행됨

@Transactional
public <S extends T> S save(S entity) {
    ...
}

 

flush/commit이 나가는 시점

  • @Transactional이 끝나는 시점 → 스프링이 트랜잭션 커밋 처리
  • 커밋 직전에 EntityManager.flush()가 자동으로 호출됨
  • 쓰기 지연 SQL들 → DB로 전송됨
  • DB에 확정 반영

새로운 사이드 프로젝트를 시작하면서 엔티티부터 처음부터 만들기 시작했다!!

그런데,,,!! 만들면서 다대일 관계나 엔티티에 붙이는 어노테이션, N+1 문제 같은 것들이 기억이 흐릿흐릿 해진 것이다 😳

엔티티를 구성하면서 기억이 가물가물했던 부분들을 다시 정리해보고자 한다

엔티티 만들기

애매모호 개념 1  : @NoArgsConstructor(access = AccessLevel.PROTECTED)를 왜 쓰는걸까?

protected 접근 범위

  • 같은 패키지 내의 클래스
  • 다른 패키지라도 상속한 자식 클래스

엔티티의 생성자를 protected로 제한을 걸었을 때 이점

  • JAP는 리플랙션을 사용해서 엔티티객체를 만들기 때문에 기본 생성자가 필요함
  • 저렇게 기본 생성자를 생성할 수 있는 범위를 정해주면 외부에서 실수로 new Entity()를 할 수 없게 해줌 ⇒ 안전하게 필요한 경우에만 객체를 생성할 수 있음

 

애매모호 개념 2 : 다대일 관계 테이블

ERD

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class UserCoupon extends BaseEntity {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "coupon_id")
    private Coupon coupon;

    @Enumerated(EnumType.STRING)
    private Status status;

}

 

@ManyToOne

  • 현재 이 컬럼을 가지고 있는 엔티티는 N 쪽이라는 뜻
  • 즉 user는 하나고, 여기에 대응되는 usercoupon은 여러개 라는 뜻

fetch = FetchType.LAZY

  • usercoupon을 조회할 때마다 user까지 같이 조회하는 것이 아니라 user까지 실제로 조회할 일이 있을 때 해당 객체까지 조회해오겠다
  • UserCoupon.getUser() 하기 전까지는 User 객체를 로딩하지 않음
  • 성능 최적화에 좋음

@JoinColumn(name = "user_id")

  • 외래키 컬럼으로 user_id를 만들겠다는 의미
  • user_id를 통해서 user엔티티와 연결됨

 

애매모호 개념 3 : 즉시로딩과 지연로딩의 차이

즉시 로딩 (FetchType.EAGER)

SELECT m.*, t.* 
FROM member m
JOIN team t ON m.team_id = t.id
WHERE m.id = 1;
  • 연관된 엔티티를 즉시 함께 조회
  • N+1 문제를 유발할 수 있으며, 불필요한 데이터 조회가 발생할 가능성이 큼
  • @ManyToOne이나 @OneToOne 기본값이 EAGER이므로 LAZY로 변경하는 것이 일반적

지연 로딩 (FetchType.LAZY)

-- 첫 번째 쿼리
SELECT * FROM member WHERE id = 1;

-- team을 실제로 사용할 때 쿼리 발생 (N+1의 원인)
SELECT * FROM team WHERE id = ?;
  • 연관된 엔티티를 실제로 사용할 때만 조회
  • 대부분의 경우 지연 로딩을 기본값으로 설정하는 것이 권장
  • @OneToMany는 기본값이 LAZY이므로 특별한 이유가 없다면 그대로 유지해야 함

 

양방향 연관관계

애매모호 개념 4 : 연관관계의 주인

@Entity
public class Team {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Member> members = new ArrayList<>();

    // 연관관계 편의 메서드
    public void addMember(Member member) {
        members.add(member);
        member.setTeam(this);
    }

	// 이렇게 서로 양쪽에서 끊어줘야 DB에서 DELETE 쿼리 날림
    public void removeMember(Member member) {
        members.remove(member);
        member.setTeam(null); // 고아 객체로 만들기 위해 반대쪽도 끊어줌
    }
}
@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "team_id")
    private Team team;

    public void setTeam(Team team) {
        this.team = team;
    }
}

 

연관관계의 주인

  • 외래키를 가지고 있는 쪽이 연관관계의 주인
  • 데이터베이스에서 외래키를 가지고 있는 테이블을 말함
  • 양방향 연관관계를 만들어도, 결국 데이터베이스 테이블에서 외래키는 한쪽만 가지고 있기 때문에 주인을 명시해줘야 함
@OneToMany(mappedBy = "team")
private List<Member> members;
  • mappedBy는 나는 연관관계의 주인이 아니라고 말하는 거임
  • members는 Member 엔티티의 team 필드에 의해 매핑된 것
  • 읽기 전용 필드라는 뜻

 

애매모호 개념 5 : Cascade 옵션 (cascade = CascadeType.ALL)

  • 부모 엔티티를 삭제할 때 자식 엔티티도 함께 삭제되도록 설정
  • 부모의 모든 생명주기를 자식도 따름
  • 실무에서는 CascadeType.ALL을 조심스럽게 사용해야 함

 

애매모호 개념 6 : 고아 객체 제거 (orphanRemoval = true)

  • member.setTeam(null) → 연관관계를 끊는 것 (Java 객체 기준)
  • orphanRemoval = true → JPA가 연관관계가 끊긴 객체를 DB에서 DELETE 하도록 명령하는 설정
  • JPA가 "이 객체를 삭제해야 한다"라고 인식하게 만드는 트리거
  • 부모 엔티티에서 리스트에서 제거하면, 해당 엔티티도 삭제되도록 설정할 수 있음

 

N + 1 문제

애매모호 개념 7 : N + 1 문제란?

  • 연관관계를 가진 엔티티를 조회할 때 해당 엔티티의 갯수만큼 연관관계를 조회하는 쿼리가 추가적으로 N번 나가는 것
@Entity
public class Owner {
    @Id
    private Long id;

    private String name;

    @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    private List<Cat> cats = new ArrayList<>();
}

@Entity
public class Cat {
    @Id
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "owner_id")
    private Owner owner;
}

 

애매모호 개념 8 : N + 1 문제 해결 방법

Eager Loading으로 N+1문제를 해결하려고 하면 안됨

  • Eager Loading은 연관된 엔티티 객체를 한번에 조회하도록 하는 기능으로 특정 경우에 N+1문제를 부분적으로 해결해줄 수 있지만 사용하지 않는 것이 좋음
  • 어떤 entity 연관관계 범위까지 join 쿼리로 조회해올지 예상하기 힘들어지기 때문에 오히려 필요없는 데이터까지 로딩하여 비효율적
  • entity 관계가 복잡해지면 n+1 문제가 해결되지 않는 경우가 많음

 

N + 1 문제 해결 방법 1 : @BatchSize(size = N)

SELECT * FROM cat WHERE owner_id IN (1, 2, 3, 4, 5);
@Entity
public class Owner {
    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    @BatchSize(size = 5)
    private Set<Cat> cats = new LinkedHashSet<>();
}
spring:
  jpa:
    properties:
        default_batch_fetch_size: 100
  • Hibernate가 비슷한 엔티티 ID들을 모아서 IN 쿼리로 조회
  • 페이징 사용 가능
  • 연관관계의 데이터 사이즈를 정확하게 알 수 있다면 최적화할 수 있는 size를 구할 수 있겠지만 사실상 연관 관계 데이터의 최적화 데이터 사이즈를 알기는 쉽지 않음

 

N + 1 문제 해결 방법 2 : EntityGraph

  • Cat과 Owner를 즉시 로딩하므로, Cat과 Owner를 함께 한 번의 쿼리로 조회됨
  • outer join으로 호출
@EntityGraph(attributePaths = "cats")
@Query("select o from Owner o")
List<Owner> findAllEntityGraph();

 

N + 1 문제 해결 방법 3 : Fetch join

  • Cat과 Owner를 즉시 로딩하므로, Cat과 Owner를 함께 한 번의 쿼리로 조회됨
  • FetchType을 Lazy로 해놓는것이 무의미
  • 하나의 쿼리문으로 가져오다 보니 페이징 단위로 데이터를 가져오는것이 불가능하므로 페이징 쿼리를 사용할 수 없음
  • inner join으로 호출
@Query("select o from Owner o join fetch o.cats")
List<Owner> findAllJoinFetch();

 

Fetch join 주의 사항

  • Distinct 절을 사용해야 함
  • Collection Fetch Join은 하나까지만 가능
    • 여러 Collection에 대해서 Fetch Join을 하게 되면 잘못된 결과과 발생하기 때문에 꼭 하나까지만 Fetch Join해야 함

 

Fetch join은 Paging을 해서는 안됨(Out Of Memory 발생 가능) = 양방향연관관계에서만

 

데이터 구성 (1:N 관계)

Article 1: Opinion 101, Opinion 102  
Article 2: Opinion 201, Opinion 202  
Article 3: Opinion 301
article.id opinion.id
1 101
1 102
2 201
2 202
3 301

경우 1 : 일반 페이징 쿼리

사용법

@Query("SELECT a FROM Article a")
Page<Article> findAll(Pageable pageable); // PageRequest.of(0, 2)

 

DB 조회 결과

SELECT * FROM article LIMIT 2
article.id
1
2

 

JPA 결과

[
  Article(id=1, opinions=[❌LAZY]),
  Article(id=2, opinions=[❌LAZY])
]

 

연관관계 조회 시 2번의 쿼리 추가로 발생 → 성능은 떨어지지만, 페이징은 정확하게 적용됨

SELECT * FROM opinion WHERE article_id = 1;
SELECT * FROM opinion WHERE article_id = 2;

 

경우 2 : Fetch Join + 페이징

사용법

@Query("SELECT a FROM Article a JOIN FETCH a.opinions")
Page<Article> findAllWithOpinions(Pageable pageable); // PageRequest.of(0, 3)

 

DB 조회 결과

-> JOIN이라서 article 1개가 여러 줄로 나옴

SELECT a.*, o.*
FROM article a
JOIN opinion o ON o.article_id = a.id

 

 

 

쿼리 결과

article.id opinion.id
1 101
1 102
2 201

 

JPA는 이걸 보고 “Article 1에 Opinion 1, 2 있고, Article 2엔 Opinion 1개 있다”고 착각하게 됨

 

JPA 결과

데이터가 누락된 상태로 영속화됨

[
  Article(id=1, opinions=[101, 102]),
  Article(id=2, opinions=[201])  // ❗ 원래는 201, 202여야 하는데, 1개만 포함됨
]

 

이러한 이유로 Hibernate는 아예 DB 페이징 안 하고 모든 row를 읽은 후, Java 메모리에서 중복 제거 후 페이징 처리를 강제함 → 큰 데이터면 OOM(OutOfMemory) 가능성 발생

 

애매모호 개념 9 : 페이징 + n+1 문제를 해결하고 싶다면? (@OneTomany에서)

@Entity
public class Article {
    @Id @GeneratedValue
    private Long id;

    private String title;

    @OneToMany(mappedBy = "article", fetch = FetchType.LAZY)
    @BatchSize(size = 100) // 💡 이게 핵심
    private List<Opinion> opinions = new ArrayList<>();
}
@Entity
public class Opinion {
    @Id @GeneratedValue
    private Long id;

    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    private Article article;
}

 

방법 1 : @BatchSize + 일반 페이징 사용

  • Article만 페이징으로 가져오고
  • Opinion은 지연 로딩(LAZY)이지만, 한 번에 IN 절로 묶어서 가져오게
public interface ArticleRepository extends JpaRepository<Article, Long> {
    Page<Article> findAll(Pageable pageable); // 일반 페이징
}

 

동작 : 2번의 쿼리 발생

  1. SELECT * FROM article LIMIT 20
  2. 이후 opinions 조회 할 때 in 절로 조회조건을 묶은 쿼리 발생
SELECT * FROM opinion WHERE article_id IN (1, 2, ..., 20)

 

방법 2 : ID 기반 2단계 조회 전략 (Fetch Join + 페이징)

  • 1단계 : 페이징 기준 엔티티의 ID만 먼저 조회
  • 2단계 : 해당 ID들을 기준으로 fetch join 쿼리 실행
public interface ArticleRepository extends JpaRepository<Article, Long> {

    // Step 1: ID만 페이징 조회
    @Query("SELECT a.id FROM Article a")
    Page<Long> findPagedIds(Pageable pageable);

    // Step 2: ID 목록으로 fetch join
    @Query("SELECT DISTINCT a FROM Article a JOIN FETCH a.opinions WHERE a.id IN :ids")
    List<Article> findArticlesWithOpinions(@Param("ids") List<Long> ids);
}
public Page<Article> getPagedArticles(Pageable pageable) {
    Page<Long> pagedIds = articleRepository.findPagedIds(pageable);
    List<Article> articles = articleRepository.findArticlesWithOpinions(pagedIds.getContent());
    return new PageImpl<>(articles, pageable, pagedIds.getTotalElements());
}

 

동작 : 2번의 쿼리 발생

  1. SELECT id FROM article LIMIT 10
  2. SELECT a.*, o.* FROM article a JOIN opinion o ON ... WHERE a.id IN (...)

 

애매모호 개념 10 : Fetch Join과 EntityGraph 주의할 점 (@OneToMany, @ManyToMany 관계에서)

  • ⭐️@OneToMany, @ManyToMany 관계⭐️에서 연관된 컬렉션을 한번에 조회하려고 할 때 둘 다 JPQL을 사용하여 JOIN문을 호출
  • 둘 다 공통적으로 카테시안 곱이 발생하여 중복된 엔티티 결과가 생길 수 있음
// Owner와 Cat을 Fetch Join으로 가져옴
SELECT o FROM Owner o JOIN FETCH o.cats
Owner ID Owner Name Cat ID Cat Name
1 철수 10 나비
1 철수 11 치즈

 

JPA는 주인이 1명인데 2행이 나왔기 때문에 List<Owner>로 받을 경우에 Owner가 중복 저장될 수 있음

 

해결방안 1 : DISTINCT 사용

SELECT DISTINCT o FROM Owner o JOIN FETCH o.cats
  • JPA는 DISTINCT를 보면 중복된 Owner 객체를 메모리 차원에서도 제거
  • 실제 SQL 쿼리에는 DISTINCT가 들어가고
  • 결과 리스트에는 Owner 객체가 1개만 들어옴
  • 페이징 불가

해결 방안 2 : FetchMode.SUBSELECT

SELECT * FROM owner;

SELECT * FROM cat 
WHERE owner_id IN (SELECT id FROM owner);
@OneToMany(mappedBy = "owner", fetch = FetchType.EAGER)
@Fetch(FetchMode.SUBSELECT)
private Set<Cat> cats;
  • 두번의 쿼리로 해결하는 방법
  • 해당 엔티티를 조회하는 쿼리는 그대로 발생하고 연관관계의 데이터를 조회할 때 서브 쿼리로 함께 조회하는 방법
  • 페이징 사용 불가
  • FethType을 EAGER로 설정해야하는 단점을 가짐

해결 방안 3 : @BatchSize(size = N)

@OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    @BatchSize(size = 5)
    private Set<Cat> cats = new LinkedHashSet<>();
  • Hibernate가 비슷한 엔티티 ID들을 모아서 IN 쿼리로 조회

 

결론

  • 사실 @ManyToOne관계만 사용한다면 fetchjoin + 페이징을 사용할 수 있기 때문에 저런 주의사항들을 신경쓰지 않아도 됨!
  • 속편하게 @ManyToOne을 쓰자

Docconneting프로젝트에서 알람 전송기능을 모두 구현했다

하지만 알람을 전송하고 실패하는 경우가 분명히 발생하고, 이 경우에 사용자가 알람을 받지 못한다면 이건 꽤나 큰 문제라는 생각이 들었다

그래서 이를 해결하기 위해서 알람 재시도를 구현해야겠다고 마음을 먹었다!

문제 상황

@Async("fcmExecutor")
    public void sendMulticastAlarm(List<String> fcmTokenBatche, String content) {
        try {
            MulticastMessage message = MulticastMessage.builder()
                    .setNotification(Notification.builder()
                            .setTitle("Docconneting")
                            .setBody(content)
                            .build())
                    .addAllTokens(fcmTokenBatche)
                    .build();

            BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message);

            int successCount = response.getSuccessCount();
            int failureCount = response.getFailureCount();

            log.info("알림 전송 완료 - 성공횟수: {}, 실패횟수: {}", successCount, failureCount);
        } catch (FirebaseMessagingException e) {
            throw new RuntimeException(e);
        }
    }
  • 기존 코드에서는 알람 전송시에 재시도 로직이 존재하지 않아, 알람을 1만건 보냈을 때 성공한 횟수, 실패한 횟수만 볼 수 있었음
  • 알람 전송 실패에 대한 여러 케이스들에 대한 처리가 되어있지 않아 이를 해결할 수 있는 방법이 없었음

 

가장 먼저 FCM에서 알람 전송에 실패하는 경우 에러 코드를 보자

FCM 에러 종류

코드 의미 처리 전략
INTERNAL (HTTP 500) FCM 서버 내부 오류 재시도 권장
UNAVAILABLE (HTTP 503) FCM 서버 일시적 장애 재시도 권장
INVALID_ARGUMENT (HTTP 400) 메시지 내용/형식 또는 토큰이 잘못됨 해당 토큰 제거
UNREGISTERED (HTTP 404) 토큰이 만료됐거나 앱 삭제됨 해당 토큰 제거
QUOTA_EXCEEDED (HTTP 429) 메시지 전송 한도 초과 일반적으로 재시도 가능, but 조심
SENDER_ID_MISMATCH (HTTP 403) 토큰 발급 시 사용된 sender ID와 현재 sender ID가 다름 서버 설정 문제 (보통 Firebase 프로젝트 설정 오류)
THIRD_PARTY_AUTH_ERROR (HTTP 401) APNs 키 or 웹푸시 키 오류 서버 인증서/키 설정 확인 필요

 

여기서 오류 코드에 따라서 처리하는 경우를 크게 3가지로 분리했다

 

INTERNAL , UNAVAILABLE

  • FCM 서버 내부 오류와 일시적 장애이기 때문에 지수 백오프 전략을 이용하여 1초, 2초, 4초의 딜레이를 주며 최대 3회까지 FCM 알람 서버로 알람 전송 재시도 수행
  • FCM 공식 문서의 경우에도 해당 에러는 지수 백오프 전략을 사용하여 재시도를 구현하라고 적혀 있었음

FCM 공식 문서

@Retryable(
            retryFor = ServerException.class,
            maxAttempts = 3,
            backoff = @Backoff(delay = 1000, multiplier = 2)
    )
    public void sendAlarm(String fcmToken, String content) {
        try {
            Message message = Message.builder()
                    .setToken(fcmToken)
                    .setNotification(Notification.builder()
                            .setTitle("Docconneting")
                            .setBody(content)
                            .build())
                    .build();

            String response = FirebaseMessaging.getInstance().send(message);
            log.info("알림 전송 완료 - 메시지 ID: {}", response);
        } catch (FirebaseMessagingException exception) {
            MessagingErrorCode errorCode = exception.getMessagingErrorCode();

            if (errorCode.equals(INTERNAL) || errorCode.equals(UNAVAILABLE)) {
                log.error("FCM 서버 내부 오류 발생 - 알람 전송 재시도");
                throw new ServerException(ErrorCode.FCM_SEND_FAILED);
            }
            
            // 나머지 오류 처리들...
          }
    }

 @Recover
 public void recover(ServerException exception, String fcmToken, String content) {
       log.info("FCM 알림 재시도 3회 실패 - token : {}, content : {}", fcmToken, content);
  }

 

여기서 잠깐!

지수 백오프란?

  • 재시도 할 때마다 대기 시간을 지수적으로 늘려서 요청하는 전략
  • 점점 천천히 시도해서 서버 부하를 줄이고 실패 상황에서 자원 낭비를 방지하는 전략
  • 지수적으로 늘리는 이유 => 네트워크 오류, 서버 다운 같은 일시적인 장애는 시간이 지나면 회복될 가능성이 있음

 

INVALID_ARGUMENT, UNREGISTERED

  • 메시지 내용이나 토큰자체가 잘못됐을 때 발생하는 에러이기 때문에 토큰을 삭제
  • 로그로 에러 상황 기록
if (errorCode.equals(INVALID_ARGUMENT) || errorCode.equals(UNREGISTERED)) {
                log.error("FCM 토큰 이상 발생 - 토큰 제거");
                fcmTokenService.deleteFcmToken(fcmToken);
            }

 

THIRD_PARTY_AUTH_ERROR, SENDER_ID_MISMATCH

  • 서버의 인증서나 설정과 관련된 에러이기 때문에 서버의 코드를 직접확인해야 하므로 로그로 에러 상황 기록
if (errorCode.equals(THIRD_PARTY_AUTH_ERROR) || errorCode.equals(SENDER_ID_MISMATCH)) {
                log.error("서버 설정/인증서 문제 발생 - 서버 확인 필요");
            }

 

위의 3가지의 경우로 에러 처리를 해주었다

그런데 여기서 로그를 찍을 때 log.info가 아니라 에러가 나는 경우에 log.error을 왜 써줘야 하는지 의문이 들었다

 

로그 레벨

찾아보니 실제 운영 환경에서는 로그를 기록하고 수집하면서 서버에 대한 모니터링을 수행한다고 하는데, 로그를 저장할 때 설정파일에서 지정한 특정 레벨까지만 저장하고 하위 레벨은 로그를 저장하지 않는다고 한다

에러가 발생한 경우 무조건 log.error()로 지정하고, 이렇게 지정한 로그는 항상 기록하고 모니터링 해야하므로 로그를 레벨별로 저장하는것이 중요하다!

  • 실제 운영 환경에서는 로그를 레벨별로 수집하고 모니터링하기 때문에 레벨별로 지정해야 함
메서드 로그 레벨 의미
log.trace() TRACE 가장 상세한 디버깅 로그
log.debug() DEBUG 개발자용 디버깅 정보
log.info() INFO 일반적인 정보 로그 (성공 메시지 등)
log.warn() WARN 경고 (문제 될 수 있음)
log.error() ERROR 실제 예외, 오류 상황 (운영 이슈)

 

다건 알람 재전송 로직에서의 고민 (sendEachForMulticast)

다건의 경우, 실패한 토큰 리스트만 가지고 재시도를 해야 할 텐데, 그러면 @Retryable과 @Recover을 가지고 재시도를 할 수는 없을 것 같다는 생각이 들었다

그래서, while문을 만들고 최대 재시도 할 수 있는 횟수를 정해서 실패한 토큰 리스트를 뽑아서 계속 반복하는 식으로 구현했다

public void sendMulticastAlarm(List<String> fcmTokenBatche, String content) {
        List<String> targets = new ArrayList<>(fcmTokenBatche);
        int maxAttempts = 3;
        int attempt = 1;

        while (attempt <= maxAttempts && !targets.isEmpty()) {
            MulticastMessage message = MulticastMessage.builder()
                    .setNotification(Notification.builder()
                            .setTitle("Docconneting")
                            .setBody(content)
                            .build())
                    .addAllTokens(targets)
                    .build();

            try {
                BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message);

                List<String> failedTokens = new ArrayList<>();
                List<SendResponse> responses = response.getResponses();

                for (int i = 0; i < responses.size(); i++) {
                    SendResponse sendResponse = responses.get(i);
                    String fcmToken = targets.get(i);

                    if (!sendResponse.isSuccessful()) {
                        FirebaseMessagingException exception = (FirebaseMessagingException) sendResponse.getException();
                        MessagingErrorCode errorCode = exception.getMessagingErrorCode();

                        if (errorCode.equals(INTERNAL) || errorCode.equals(UNAVAILABLE)) {
                            log.error("FCM 서버 내부 오류 발생 - 알람 전송 재시도 리스트에 추가");
                            failedTokens.add(fcmToken);
                        }

                        if (errorCode.equals(INVALID_ARGUMENT) || errorCode.equals(UNREGISTERED)) {
                            log.error("FCM 토큰 이상 발생 - 토큰 제거");
                            fcmTokenService.deleteFcmToken(fcmToken);
                        }

                        if (errorCode.equals(THIRD_PARTY_AUTH_ERROR) || errorCode.equals(SENDER_ID_MISMATCH)) {
                            log.error("서버 설정/인증서 문제 발생 - 서버 확인 필요");
                        }
                    }
                }

                if (failedTokens.isEmpty()) {
                    log.info("알림 전송 완료 - 성공횟수 : {}, 실패횟수 : {}", response.getSuccessCount(), response.getFailureCount());
                    return;
                }

                targets = failedTokens;
                attempt++;

            } catch (FirebaseMessagingException e) {
                log.error("알람 전체 전송 실패 - {}", e.getMessagingErrorCode());
                return;
            }

        }

        if (!targets.isEmpty()) {
            log.error("알람 전송 최종 실패 명수 - {}", targets.size());
        }
    }

 

도입 전후 비교

  • 시나리오 : 1만건의 알람을 sendEachForMulticast 메서드를 사용하여 100개씩 Batch 전송한다고 가정

📌 도입 전 성능 테스트 결과

 

☑️ 알람 전송 초반에 실패하는 경우 발생

 

📌  도입 후 성능 테스트 결과

 

☑️ 알람 전송 초반에도 재시도 로직을 통해서 무사히 누락되는 알람 없이 잘 전송됨

 

1만건의 알람을 sendEachForMulticast 메서드를 통해서 보냈을 때, 오류율이 3.01% 였으나, 재시도 로직을 도입하고 오류율을 0%로 감소했다!! 🥳

  • @SpringBootTest를하면 모든것이 다 빈으로 등록이 되니까 자연스럽게 테스트가 됨
  • @WebMvcTest 사용하면 컨트롤러만 빈으로 등록이되서 ArgumentResolver랑 JwtFilter 를 수동으로 등록을 해줘야 작동을 한다!
  • JpaMetamodelMappingContext : Spring Data JPA의 자동 설정 중 하나인 JpaMetamodelMappingContext가 없어 생기는 빈 등록 예외를 방지하기 위해서 주입
  • 또한 컨트롤러는 Argument Resolver랑 Converter 같은걸 거치니까 새로운 객체가 생겨서 eq로 내용이 동일한지 확인해줘야함

 

기존에 @WebMvcTest를 사용했는데 수동으로 등록하지않았다면 AuthUser에 필드가 다 null이었음 ⇒ any()를 써버리니까 통과가 되었던 것!

 

결론 : 그래서 Filter랑,Argument Resolver를 등록하고 하니까 AuthUser가 정상적으로 만들어지고 컨트롤러 테스트가 잘 통과 된다!

@WebMvcTest(AlarmController.class)
@TestPropertySource(properties = {
        "jwt.secret.key=5Gk6hibHDtKLFVk4NdBX039rvehSLNjfKsdXpm/pHsU="
})
@Import({JwtUtil.class, AuthUserArgumentResolver.class, JwtFilter.class})
class AlarmControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    JpaMetamodelMappingContext jpaMetamodelMappingContext;

    @MockitoBean
    AlarmService alarmService;

    @Autowired
    JwtUtil jwtUtil;

    AlarmHistories histories1;
    AlarmHistories histories2;

    @BeforeEach
    void setUp() {
        histories1 = AlarmHistories.of(
                "회원님의 글에 댓글이 달렸습니다",
                1L,
                AlarmType.COMMENT
        );
        ReflectionTestUtils.setField(histories1, "id", 1L);
        ReflectionTestUtils.setField(histories1, "createdAt", LocalDateTime.now());

        histories2 = AlarmHistories.of(
                "회원님의 글에 댓글이 달렸습니다",
                1L,
                AlarmType.COMMENT
        );
        ReflectionTestUtils.setField(histories2, "id", 2L);
        ReflectionTestUtils.setField(histories2, "createdAt", LocalDateTime.now());
    }

    @Test
    public void 알람_내역을_페이징을_이용하여_조회한다() throws Exception {
        // given
        int page = 0;
        int size = 10;
        int totalElement = 2;
        int totalPages = 1;

        Pageable pageable = PageRequest.of(page,size);

        Long userId = 1L;
        UserRole userRole = UserRole.PATIENT;
        AuthUser authUser = AuthUser.of(userId, userRole);

        String accessToken = jwtUtil.createToken(userId, userRole);

        List<AlarmHistories> alarmHistories = new ArrayList<>();
        alarmHistories.add(histories1);
        alarmHistories.add(histories2);
        List<AlarmResponse> alarmResponses = AlarmResponse.toAlarmResponse(alarmHistories);

        PageInfo pageInfo = PageInfo.builder()
                .pageNum(page)
                .pageSize(size)
                .totalElement(totalElement)
                .totalPage(totalPages)
                .build();

        PageResult<AlarmResponse> pageResult = new PageResult<>(alarmResponses, pageInfo);

        given(alarmService.findAlarms(refEq(authUser) // 필드값 비교
                , argThat(p -> p.getPageNumber() == pageable.getPageNumber() && p.getPageSize() == pageable.getPageSize()))) // 페이징 객체 값만 비교
                .willReturn(pageResult);

        // when & then
        mockMvc.perform(get("/api/v1/notifications")
                        .param("page", "0")
                        .param("size", "10")
                        .header("Authorization", accessToken))
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].id").value(histories1.getId()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].content").value(histories1.getContent()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].alarmType").value(histories1.getAlarmType().name()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[1].id").value(histories2.getId()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[1].name").value(histories2.getContent()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[1].alarmType").value(histories2.getAlarmType().name()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.pageNum").value(page))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.pageSize").value(size))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.totalElement").value(totalElement))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.totalPage").value(totalPages));
    }

}

FCM 구조

무료로 메시지를 보낼 수 있는 교차 플랫폼 메시징 클라우드 서버

 

장점

  • Firebase 플랫폼과 통합되어 있어 설정과 사용이 간편
  • Firebase 콘솔에서 설정을 쉽게 관리할 수 있음
  • 안드로이드, iOS, 웹을 포함한 여러 플랫폼에서 사용 가능
  • Google의 인프라를 기반으로 하여, 대량의 메시지를 안정적으로 처리할 수 있음
  • FCM은 무료로 제공되며, 추가 비용 없이 사용할 수 있음
  • 주제 기반 메시징, 메시지 우선순위 설정, 다양한 메시지 타입 지원(알림 메시지, 데이터 메시지) 등 다양한 기능을 제공

단점

  • 메시지 전송 및 전달 상태에 대한 세부적인 제어가 제한적 ex) 재시도 로직에 대한 상세한 제어가 어려움
  • FCM은 Google 서비스에 의존적이므로, Google 서비스가 제한된 지역에서는 사용이 어려울 수 있음
  • 사용자 기기에서 푸시 알림을 비활성화하면, 메시지를 전달할 수 없음
  • 고급 기능을 구현하기 위해 서버 측에서 추가적인 로직을 처리해야 할 수 있음

 

FCM 아키텍쳐

  1. Firebase용 Cloud Functions, App Engine 또는 자체 앱 서버(내가 만든 서버)에서 메시지가 작성되고 메시지 요청이 FCM 백엔드로 전송
  2. FCM 백엔드는 메시지 요청을 수신하고 메시지 ID와 기타 메타데이터를 생성하여 플랫폼별 전송 레이어로 보냄
  3. 기기가 온라인 상태면 메시지가 플랫폼별 전송 레이어를 통해서 기기로 전송됨
  4. 기기에서 클라이언트 앱이 메시지 또는 알림을 수신

 

알람 보내는 과정

 

  1. 토큰 : Client App을 구분하는 토큰, 어떤 디바이스에 정보를 보낼지 구분하는 토큰, 이 토큰을 파이어베이스로부터 발급받음
  2. 클라이언트는 해당 토큰을 서버에 DTO를 통해서 전달
  3. 서버는 DB나 Redis에 해당 FCM 토큰을 저장
  4. 메시지를 전달해야하는 상황이 오면 서버는 발급받은 토큰을 이용해 메시지를 만들어 파이어베이스에게 전달
  5. 파이어베이스는 전달받은 토큰이 정상인지 확인하고 맞다면 모바일 디바이스에 메시지 전달

 

고민점 1 : FCM 토큰을 어떤 데이터베이스에 저장하여 관리할 것인가?

도입배경

  • FCM을 사용하여 서버에서 사용자들에게 알림을 전송하도록 결정
  • 클라이언트가 먼저 FCM 서버로부터 토큰을 발급받고, 이를 로그인할 때 서버에 전송하는 식으로 관리하도록 구현을 진행함
  • FCM토큰은 특정 기기로 푸시 알림을 보내기 위해 Firebase가 발급하는 고유 식별자이므로 이를 어떤 데이터베이스에 넣어서 저장하고 관리할지 고민하게 됨

선택지

  • Redis
    • FCM 토큰은 결국 사용자를 식별하는 토큰이라고 생각이 들어서 Refresh 토큰처럼 사용자가 로그인을 할 때 저장하고, 사용자가 로그아웃을 할 때 삭제해주는 식으로 관리하면 좋을 것이라고 생각함
    • 또한 인메모리 데이터베이스이기 때문에 RDB보다 빠르고 TTL을 지정해 줄 수 있기 때문에 사용자가 로그아웃을 하지 않는다고 가정해도, 로그인 할 때마다 토큰을 받아오기 때문에 토큰의 최신화를 관리하기 쉽다고 판단함
    • Redis가 다운된다면 사용자의 모든 FCM토큰이 사라지게 되고, RDB에 비해서 적은 용량의 데이터를 저장할 수밖에 없음
    • 비용 측면에서 RDB보다 훨씬 비쌈
  • RDB
    • Redis보다 조회 속도가 느리지만 훨씬 많은 양의 데이터를 저장할 수 있음
    • 디스크 기반 저장이기 때문에 데이터베이스가 다운되더라도 안전하게 복구할 수 있음
    • FCM토큰의 최신화 관리의 경우 사용자가 로그인할 때 기존 토큰과 다르다면 업데이트하고 동일하다면 유지하면서 관리할 수 있음

최종결정

  • Redis는 인메모리 데이터베이스이기 때문에 용량이 적고 비용이 비싸기 때문에 유저의 모든 FCM 토큰을 저장하는 것은 불가능하다고 판단
  • 또한 Refresh 토큰과 다르게 FCM 토큰은 자주 호출되고 손실되어서는 안 되는 중요한 정보이기 때문에 RDB에 넣어서 관리하는 것이 적절하다고 판단
  • 이러한 이유로, FCM 토큰 관리 데이터베이스로 RDB 채택

 

고민점 2 : 어떤 메서드를 사용하여 FCM 서버에 전송 요청을 보낼 것인가?

현재 세팅

  • 최대 만건의 알림을 동시에 보낸다고 가정했을 때 설정한 값
@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(40);      // I/O 바운드 작업이므로 코어 수 * 10 가능
        executor.setMaxPoolSize(60);      // 버스트(폭발) 처리 여유
        executor.setQueueCapacity(10000);  // 큐를 넉넉하게 설정
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("FCM-Async-");
        executor.initialize();
        executor.setRejectedExecutionHandler((r, exec) -> {
            throw new IllegalArgumentException("더 이상 요청을 처리할 수 없습니다.");
        });
        executor.initialize();
        return executor;
    }
}

 

테스트 케이스 1 : send(message);

전체 10000건 메시지 전송 완료 시간: 41초

@Async
    public void sendAsyncTest(String fcmToken, String content, CountDownLatch latch, AtomicInteger counter, Stopwatch stopwatch) {
        try {
            Message message = Message.builder()
                    .setNotification(Notification.builder()
                            .setTitle("Docconneting")
                            .setBody(content)
                            .build())
                    .setToken(fcmToken)
                    .build();

            String response = FirebaseMessaging.getInstance().send(message); // 블로킹
            int done = counter.incrementAndGet();
            log.info("✅ [{}] 알림 전송 성공: {}", done, response);

        } catch (Exception e) {
            log.error("❌ 알림 전송 실패 (token: {})", fcmToken, e);
        } finally {
            latch.countDown(); // 성공/실패 상관없이 count 감소
        }
    }

 

 

테스트 케이스 2 : sendAsync(message).get()

전체 10000건 메시지 전송 완료 시간: 43초

@Async
    public void sendAsyncTest(String fcmToken, String content, CountDownLatch latch, AtomicInteger counter, Stopwatch stopwatch) {
        try {
            Message message = Message.builder()
                    .setNotification(Notification.builder()
                            .setTitle("Docconneting")
                            .setBody(content)
                            .build())
                    .setToken(fcmToken)
                    .build();

            String response = FirebaseMessaging.getInstance().sendAsync(message).get(); // 블로킹
            int done = counter.incrementAndGet();
            log.info("✅ [{}] 알림 전송 성공: {}", done, response);

        } catch (Exception e) {
            log.error("❌ 알림 전송 실패 (token: {})", fcmToken, e);
        } finally {
            latch.countDown(); // 성공/실패 상관없이 count 감소
        }

 

테스트 케이스 3 : .sendEachForMulticast(message);

BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message);

 

java.lang.OutOfMemoryError: unable to create native thread: 에러 발생

관련 이슈 : https://github.com/firebase/firebase-admin-java/issues/950

  • 10000개의 토큰을 500개씩 잘라서 스레드 20개로 사용했더니 이렇게 됨
  • sendEachForMulticast ⇒ 토큰이 500개 있다면 최악의 경우 스레드가 500개가 생성됨
  • 현재 나는 1만개의 토큰을 500개씩 잘라서 비동기로 저 메서드를 호출하는 메서드를 20개 호출했고 그 내부에서 또 500개에 가깝게 스레드를 실행해서 거의 1만개가 된 거임

 

이를 해결하기 위해서 가장 적절한 컴퓨터가 감당할 수 있는 스레드 풀로 설정하고 다시 시도해봤다

약 1분 46.25초

@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);     
        executor.setMaxPoolSize(6);      // 버스트(폭발) 처리 여유
        executor.setQueueCapacity(1000);  // 큐를 넉넉하게 설정
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("FCM-Async-");
        executor.initialize();
        executor.setRejectedExecutionHandler((r, exec) -> {
            throw new IllegalArgumentException("더 이상 요청을 처리할 수 없습니다.");
        });
        executor.initialize();
        return executor;
    }
}

 

 

1차 결론

  • sendAll, sendMulticast 같이 batch로 보내는 메서드는 지워짐
  • sendAsync를 사용하면 sendAsync를 호출하는 메서드(비동기) + 그 메서드 내부에서 또 비동기로 sendAsync가 스레드를 만듦 ⇒ 스레드 못 만들겠다는 오류가 남
    • 그래서 send를 사용해야 할 것 같다 ⇒ 어차피 이 메서드를 호출하는 메서드는 비동기로 동작하니까
  • sendEachForMulticast를 해주려면 서버 자원에 대해서 많이 신경을 써 줘야 함 → 만약 한번에 500개씩 보내면 내부적으로 최대 500개의 스레드가 비동기로 또 생성 될 수 있다고 한다
    • 이렇게 테스트를 했을 때 컴퓨터에서 감당한 결과는 100개씩 보내면서 core스레드는 4개
    • 1만건의 스레드를 전송했을 때 걸린 시간 약 1분 46.25초
  • 찾아보니 입출력의 경우 코어스레드 수를 코어 cpu의 3배-4배 정도까지는 해도 괜찮다고 해서, 코어 스레드를 40개로 하고 send(message);를 사용해봤음
    • 1만건의 스레드를 전송했을 때 걸린 시간 약 50초

⇒ 그래서 차라리 입출력 전용 스레드 풀을 설정해서 코어 스레드를 40개로 하고 send(message);를 사용하는게 낫다는 생각이 들었음

 

하지만 이건 바보같은 생각이었다!

 

2차 결론 (찐결론)

sendEachForMulticast 즉, batch는 서버의 안정성, 무결성, 정합성을 위해서 사용하는 것이기 때문에 저 send를 비동기로 호출해서 사용하는 것보다 당연히 속도가 느릴 수 밖에 없음 ⇒ 항상 최대 500개씩 잘라서 보내기 때문에 서버의 비용측면에서도 이점이 있음 안정적임

 

send(message);를 사용하면서 스레드 풀을 지정해주면 되긴 하지만, 너무 많은 요청이 반복되면 서버 트래픽, 로깅 비용 등의 부수 비용이 발생하기도 하고, 서버는 알람만 보내는게 아니라 다른 일을 하기 때문에 부담이 커짐, 또한 성공/실패 결과를 토큰 하나하나 추적하려면 따로 처리가 필요함

 

즉, sendEachForMulticast는 알람 속도를 빠르게 보내려고 batch로 보내는게 아니라, 최대 500개의 요청을 제한을 두고 fcm에 요청을 보내면서 서버의 안정성을 위해서 사용하는 것이다. 또한 토큰별 성공/실패 결과를 한 번에 반환해서 관리하기 좋다

 

그렇기 때문에 서버의 안정성을 위한다면 sendEachForMulticast를 사용하는 것이 맞고, 단지 성능과 속도를 위한다면 send(message);를 비동기로 호출하는 것이 맞다고 볼 수 있음!

 

⇒ 일단 우리 프로젝트에서는 안정성이 더 중요하다고 생각해서 sendEachForMulticast를 사용해서 보내는걸로 결정!

 


부가적인 개발관련 조언 (꼭 기억하기)😼

  1. gpt가 해줬는데요, 블로그에서 이렇게 해서 이렇게했는데 이게 맞나요 이런 말투 개발자적으로 안 좋음 고쳐야 함
  2. 차라리 이렇게 생각했고 이게 적절한 것 같아서 이런 방식으로 했습니다 라고 말해야 함
  3. 구조같은걸 알면서 코드를 짜야 한다, 코드를 알고 짜야 하고 설명도 구조와 관련되서 할 수 있어야 함 ⇒ 공식문서 보면서 공부
  4. 블로그 말도 믿지 말고 아무도 믿지 말아야 한다. 걍 자기가 스스로 정의내리고 결론지을 수 있어야 함 ⇒ 정답은 없음

 

 

이번에 우리 팀에서 기획한 프로젝트는 docconneting이라는 의사와의 채팅과 유료 질문이 가능한 프로젝트였다

 

알람 전송 시나리오

  • 채팅 결제가 성공하면 해당 의사에게 알람이 간다
  • 등록이 성공하면 해당 카테고리 의사에게 알람이 간다
  • 등록이 성공하면 해당 카테고리 의사에게 알람이 간다
  • 알람 조회

시나리오는 이런식이다!

여기서 나는 알람 전송 기능을 담당하게 되어서 알람을 구현하기 위해서 어떤기술을 사용해야 할지 고민을 해보게 됐다

 

알림 구현 기술들

폴링

어떤 값을 확인하거나 갱신하는 과정

  • 정기적 폴링(Regular Polling) : 일정한 간격 또는 주기적으로 데이터를 확인하거나 갱신
  • 이벤트 기반 폴링(Event-based Polling) : 특정 이벤트가 발생했을 때만 데이터를 확인하거나 갱신

 

1) 짧은 폴링(Short Polling)

  • 정기적 폴링
  • 클라이언트가 일정한 짧은 주기로 지속해서 요청을 보내고 서버에서 줄 데이터가 없는 경우엔 빈 응답, 줄 데이터가 있는 경우에는 데이터를 담은 응답을 보내주는 방식
  • 서버에서 줄 데이터가 없음에도 요청과 응답 작업을 반복하기 때문에 불필요한 트래픽이 많이 발생
  • 단순히 일정 주기로 요청과 응답이 오가기 때문에 이벤트 발생을 실시간으로 반영하지 못함

 

2) 긴 폴링(Long Polling)

  • 서버에서 클라이언트에게 줄 데이터가 없는 경우 곧바로 응답하지 않고 커넥션을 계속 유지하며 서버에서 줄 데이터가 생겼을 때 응답을 보내는 방식
  • 짧은 폴링에 비해서 이벤트의 실시간성이 잘 보장됨
  • 클라이언트와 연결을 계속 유지하는 동안 서버 자원을 지속해서 소모하고 있게 됨
    • 스프링 부트로 만든 애플리케이션과 연결 할 때 데이터베이스 커넥션 풀을 할당해주는데 100만명의 회원에게 긴 폴링 방식으로 알림을 제공하면 100만개의 커넥션 풀을 소모하게 되는 것
  • 서버를 여러개 사용한다면 문제가 발생
    • 클라이언트 A가 서버 1에 연결됨
    • 서버 1에서 Long Polling 요청을 대기 중
    • 클라이언트가 다시 요청했는데, 이번에는 서버 2로 요청이 분산됨
    • 서버 2는 이전 요청이 서버 1에서 대기 중이었는지 모름
    • 결과적으로 일관성 문제가 발생함
  • 결국 클라이언트가 요청을 보내야만 데이터를 받을 수 있음

 

3) 웹 소켓

  • 클클라이언트와 서버가 한 번 연결을 맺고 나면 그 연결이 쭉 유지되고, 이 연결을 통해서 클라이언트와 서버가 양방향을 통신할 수 있게 해줌
  • 서버에서 이벤트가 발생한 경우 서버에서 먼저 데이터를 발송하는 서버 푸시가 가능하기때문에 실시간 알림기능을 구현하기에 매우 적합한 프로토콜
  • 클라이언트와의 연결을 계속 유지하고 있다는 점에서 롱 폴링 방식이 가지고 있는 서버 자원 소모 문제점을 똑같이 가지고 있음
  •  알림은 서버에서 클라이언트로의 전송만이 필요하지 양방향 통신일 필요는 없음

 

4) 서버 전송 이벤트 (Server Sent Events)

  • 클라이언트에서 서버로 요청을 보내면 일정 시간 동안 연결을 유지하면서 서버에서 이벤트가 발생했을 때 실시간으로 클라이언트에게 데이터를 넘겨주는 서버 → 클라이언트로의 실시간 단방향 통신 방법
  • 롱 폴링은 서버에서 이벤트가 발생해 데이터를 응답하면 연결이 끊어지고 다시 연결해야 하지만 sse는 서버에서 데이터를 응답하든 말든 일정 시간 동안은 연결을 끊지 않고 계속 유지함
  • 일정 시간동안 커넥션 연결을 쭉 해주는 것이 보장됐기에 서버에서 클라이언트로 데이터나 알림을 마음껏 줄 수 있음
  • 웹소켓처럼 별도의 프로토콜을 사용하는 것이 아니라 우리에게 익숙한 http프로토콜을 사용함
  • 스프링에서 개발한다면 별도의 라이브러리없이 sse를 지원하는 도구를 제공해줌

 

5) Firebase Cloud Messaging

  • 무료로 메시지를 보낼 수 있는 교차 플랫폼 메시징 클라우드 서버
  • 운영체제마다 전자기기에 푸시 알림을 보내는 방법이 다름
    • 안드로이드는 GCM
    • 애플은 IOS
    • => FCM은 애플리케이션에서 FCM을 이용해 알림 기능을 개발해두기만 하면 디바이스별 실제 알림 발송 방법은 FCM에서 알아서 작업해 알림을 발송해 줌
  • 클라이언트가 알림 정보를 받기 위해 서버와 직접 연결하기 때문에 Long Polling, Web Socket 그리고 SSE→클라이언트와 서버가 연결을 유지하는 동안 서버의 자원을 지속해서 소모
  • 이 문제를 해결하기 위해서 클라이언트와 서버 사이에 메시지 전용 클라우드 서버를 하나 두고 클라이언트는 클라우드 서버와 연결을 유지함
  • → 서버에서는 알림 발송이 필요한 그 순간에만 fcm서버로 요청을 보내고 응답도 즉시 받는 무상태 상호작용을 함
  • → 서버 : 서버자원 고갈 문제 해결 / 클라이언트 : 최적화 기능을 제공하는 fcm클라우드 서버와 연결하기 때문에 비교적 적은 배터리와 네트워크 사용만으로 알람을 수신

 

이렇게 총 5가지의 후보들을 고민해본 결과!

내가 생각하는 기술선택의 기준은 아래와 같이 결정했다

알람의 경우 자주 호출되는 기능이고, 대량의 알람이 사용자가 유료 게시물을 올릴 때마다 전송되기 때문에 서버에서 많은 리소스를 소모할 수 밖에 없기 때문에 서버의 자원을 제일 덜 소모하는 기술을 기준으로 잡자!

 

 

최종 결정

  • 해당 서비스에서는 알람을 전송하는 경우가 총 3가지가 있고, 그 중에서 대량 알람을 전송하는 요구사항이 있었기 때문에 클라이언트와 서버가 연결을 유지하는 동안 서버의 자원을 지속해서 소모하는 Long Polling, Web Socket, SSE는 적절하지 않다고 판단
  • 또한, 환자가 유료 질문 게시물을 올리고 48시간 이내에 의사가 한명이라도 댓글을 달아주지 않는다면 환불이 되기 때문에 실시간성이 중요하므로 Short Polling은 적절하지 않다고 판단
  • 이러한 이유로, 알람 전송 기술로 FCM 채택

 

알람 전송 기술로 FCM을 결정~!😃

 


참고 자료 : https://velog.io/@idonymyeon/%EC%95%8C%EB%A6%BC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-1-%EC%95%8C%EB%A6%BC%EC%9D%84-%EA%B5%AC%ED%98%84%ED%95%98%EB%8A%94-%EB%8B%A4%EC%96%91%ED%95%9C-%EB%B0%A9%EB%B2%95

구현할 기능

이번에 구현할 기능은 의사 다건 조회였다.

이때 검색 조건은 총 3가지였다.

1. 아무 조건없이 그냥 페이징만 적용하여 조회하기

2. 의사 전공별로 검색하기

3. 의사 이름으로 검색하기

3. 의사 이름 + 전공으로 검색하기

 

인덱스  고려 사항

  • WHERE 에서 OR을 사용할 때는 or 연산자는 비교해야할 ROW가 더 늘어나기 때문에 풀 테이블 스캔이 발생할 확률이 높기에 걸어도 의미가 없을 수 있음
  • 복합 인덱스는 카디널리티(중복도)가 높은순에서 낮은순으로 할 것
  • 복합 인덱스에 사용되는 컬럼은 가급적 UPDATE가 안되는 값을 선정해야 함 -> 업데이트 되는 순간 인덱스를 무수히 다시 업데이트 하기 때문
  • 인덱스는 가급적 테이블 당 3-5개를 넘지 않도록 하는 것이 좋음

 

첫 번째 고민 : 조건이 3가지나 있기 때문에 그럼 그 경우의 수 별로 인덱싱을 만드는 것이 맞는가?

결론은 맞다 이다! 검색 조건별로 각각 만들어야 한다

그래서 인덱스를 처음엔 여러개로 만들게 됐다

@Entity
@Table(
        name = "users",
        indexes = {
                @Index(name = "idx1", columnList = "isDeleted"),
                @Index(name = "idx2", columnList = "major, isDeleted")
                @Index(name = "idx3", columnList = "name, major, isDeleted")
                @Index(name = "idx3", columnList = "name, isDeleted")
        }
)

 

중복도가 낮은 순에서 높은 순서대로 걸러야 하기 때문에 위와 같이 인덱스를 생성했다!

또한 의사 정보도 우리 프로젝트에서는 소프트 딜리트를 사용하기로 약속했기 때문에 삭제 여부도 인덱스에 같이 걸어줬다

 

Like 검색 인덱스 안 걸림

그런데 문제는 name(Like 검색임)이 들어있는 복합 인덱스는 jpa단에서 아예 생성이 안 되는 것이다

-> 하지만 name은 결국 Like 검색이기 때문에 결국 인덱스가 생성이 되어도 (물론 나의 경우 jpa가 자동으로 생성해주지 않았지만) 이걸 탈 수가 없다는 것이다

그렇기에 이러한 문제를 해결하기 위해서는 Elasticsearch나 mysql ngram을 사용해야 한다는 결론에 도달했다

 

그렇다면 아직 Elasticsearch를 도입하지 않았기 때문에 먼저 인덱스는 아래와 같이 구성했다

@Entity
@Table(name = "users",
        indexes = { @Index(name = "idx_is_deleted", columnList = "isDeleted"),
                    @Index(name = "idx_major_is_deleted", columnList = "major, isDeleted") }
)

 

또한 인덱스와 where절의 순서는 중요하기 때문에 (일치해야 걸림)

현재 인덱스가 의미없는 name는 where절에서 마지막으로 지정하고 작성했다

 

그렇다면 이제 인덱스 걸기 전과 후 결과를 측정해보자!

 

의사 다건 조회 성능 인덱스 성능 측정

조건

60초 동안 1000번의 요청을 2번 반복

데이터는 10만개, 의사 전공(카테고리)는 4개

 

 

의사 다건조회시 카테고리 조건가지고 검색하는 경우

 

적용된 인덱스

@Index(name = "idx2", columnList = "major, isDeleted")

 

인덱스 안 건 경우

 

인덱스 건 경우

 

의사 다건조회시 이름으로(Like) 검색하는 경우

 

=> 추후 Elasticsearch를 도입하여 적용할 예정

-> 인덱스를 걸어서 오히려 더 느려진 경우도 있었다 

 

 

의사 다건조회시 카테고리 + 이름 조건가지고 검색하는 경우

 

인덱스 안 건 경우

 

인덱스 건 경우

 


의사 다건조회 하는 경우 (아무런 조건 없이 페이징만 적용)

 

인덱스 안 건 경우

 

인덱스 건 경우

 

 

결론

  • 검색 조회 조건이 여러 개라면 그 조건마다 인덱스를 거는 것이 맞음 -> 하지만 테이블 당 인덱스가 5개 초과하면 오히려 성능이 떨어지고 자주 업데이트 되는 값이면 안 됨
  • 복합 인덱스를 걸 때는 중복성이 낮은 컬럼 순으로 걸어야 함
  • like검색의 경우에는 인덱스가 걸려도 full scan을 해야 하기 때문에 조회 성능 개선을 위해서는 Elasticsearch나 mysql ngram이 필요함

기능 구현을 완료한 다음 컨트롤러 부분 테스트 코드를 짜는 중이었다...

컨트롤러 부분만 테스트 코드를 짜면 되니까 이제 금방 끝날 줄 알고 행복한 마음으로 룰루랄라 짜고 있던 도중 생각지도 못한 난관에 부딪히게 됐다

@WebMvcTest(DoctorController.class)
class DoctorControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private DoctorService doctorService;


    @Test
    public void 의사_단건_조회() throws Exception {
        // given
        long userId = 1L;
        String username = "kimdoctor";
        String major = "INTERNAL_MEDICINE";
        String imageUrl = "https://example.com/image.jpg";
        LocalTime startTime = LocalTime.of(9, 0);
        LocalTime endTime = LocalTime.of(18, 0);

        BDDMockito.given(doctorService.findDoctor(userId)).willReturn(new DoctorResponse(userId, username, major, imageUrl, startTime, endTime));

        // when & then
        mockMvc.perform(get("/api/v1/doctors/{id}", userId))
                .andExpect(status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].id").value(userId))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].name").value(username))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].imageUrl").value(imageUrl));
    }

 

이렇게 짜고 실행을 했는데!!!!

 

문제 1 : JPA metamodel must not be empty

Caused by: java.lang.IllegalArgumentException: JPA metamodel must not be empty at org.springframework.util.Assert.notEmpty(Assert.java:398)

 

오류가 뜨는 것이다!!

 

찾아보니 JPA 메타모델 클래스가 없거나 빈 경우에 발생하는 에러라는데 컨트롤러 테스트인데 도대체 왜 이 에러가 발생하는 것인지 곰곰히 생각을 해봤는데

 

처음에 생성일이랑 수정일 업데이트를 위해서 @EnableJpaAuditing를 붙였던 것이다!!

즉, @EnableJpaAuditing은 jpa속성이기 때문에 이와 관련된 설정을 @MockitoBean으로 넣어줘야 한다

@SpringBootApplication
@EnableJpaAuditing
@EnableSpringDataWebSupport(pageSerializationMode = VIA_DTO)
public class DocconnetingApplication {

 

이걸 추가해줬더니 이 문제는 해결이 됐다

    @MockitoBean
    JpaMetamodelMappingContext jpaMetamodelMappingContext;

 

문제 2 : 응답 객체 값이 비었다고 뜸

 

작성한 코드를 실행시켜봤더니

@Test
    public void 의사_다건_조회() throws Exception {
    	// 테스트 코드..
        // when & then
        mockMvc.perform(get("/api/v1/doctors?page=1&size=10&category=INTERNAL_MEDICINE")
                .andExpect(status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].id").value(user1.getId()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].name").value(user1.getUsername()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[1].id").value(user2.getId()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[1].name").value(user2.getUsername()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.pageNum").value(page))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.pageSize").value(size))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.totalElement").value(totalElement))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.totalPage").value(totalPages));
    }

 

음? PageResult의 content값이 비었다고 하는 에러가 발생했다

하지만 아무리 생각해봐도 코드단에서는 문제가 전혀 없었는데 왜 이런가 찾아보니..

쿼리 파라미터 값도 명시적으로 작성해야 하는 것이었다!!

이렇게 파라미터를 명시적으로 작성해주면 해결된다

// when & then
        mockMvc.perform(get("/api/v1/doctors")
                .param("page", String.valueOf(page))
                .param("size", String.valueOf(size))
                .param("category", "")
                .param("name", ""))
                .andExpect(status().isOk())
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].id").value(user1.getId()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[0].name").value(user1.getUsername()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[1].id").value(user2.getId()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.data[1].name").value(user2.getUsername()))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.pageNum").value(page))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.pageSize").value(size))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.totalElement").value(totalElement))
                .andExpect(MockMvcResultMatchers.jsonPath("$.page.totalPage").value(totalPages));
    }

 

결론

  • 컨트롤러 테스트를 할 때는 jpa관련 설정을 한게 있는지 항상 주의하고 또 주의하자!
  • 쿼리 파라미터가 들어간 경우에는 param으로 항상 명시를 해주자!

+ Recent posts