매일 지정된 시간에 새로운 ‘오늘의 질문’에 대한 푸시알림을 보내는 기능에서 알림 시간이 저장된 테이블을 전체 조회한 후, 각각의 객체에 대한 작업을 예약하는 방식으로 구현하고자 한다.
해당 기능을 구현하며 다양한 고려사항과 오류가 있었는데, 오류를 해결하기 위해 시도한 방법과 공부한 내용을 정리해보았다.
먼저 간단히 정리하자면 아래와 같은 상황에 있었다.
cron을 초기화해두고 → 각 엔티티마다 저장된 푸시알림 시간으로 작업을 예약하는 방식
이 예약한 작업 내에서 트랜잭션 처리 및 DB 변경사항 반영이 되지 않는 상황
오류가 발생하기 시작한 시점은 예약한 작업 안에서 어제에서 오늘의 질문으로 넘어가기 위해 인덱스를 증가시키는 과정에서 DB 조회 및 업데이트하는 코드를 추가한 뒤부터 였다.
오늘의 질문 알림이 갈 때마다 질문 인덱스를 증가시켜야 하는데, 알림이 정상적으로 가도 DB 상으로는 인덱스 필드의 업데이트가 반영되지 않고 있는 문제가 발생한 것이다.
이러한 문제가 발생한 원인을 생각해볼 때 다음과 같은 원인을 의심해볼 수 있었다.
따라서, 이에 관한 내용을 찾아보고 정리해보았다.
📸 `@Scheduled` 어노테이션으로 스케줄링 작업을 다룰 때, DB에 적용이 안 되는 경우트랜잭션 범위
@Scheduled
어노테이션으로 실행되는 작업 - 별도의 트랜잭션 범위에서 실행
⇒ 스케줄링 작업 내에서 발생하는 DB 업데이트나 변경은 트랜잭션 커밋이 이루어진 후에 실제로 DB에 반영이 된다. 하지만, 로그 상으로 업데이트가 된 듯 하지만 실제 DB에는 값이 반영되지 않았고 이는 커밋이 아직 이루어지지 않음을 의미한다.
💡 트랜잭션 커밋을 하는 방법@Transactional
어노테이션 사용 → WHERE? 스케줄링 작업을 호출하는 서비스 클래스 단 or 작업이 이루어지는 메서드트랜잭션 격리 수준
특정 작업이 동시에 실행되는 경우,
→ 우리 서비스 상으로는 같은 푸시알림으로 지정된 부모자식 유저에 오늘의 질문을 전송하는 메서드가 실행된 상황
트랜잭션 격리 수준이 충돌하여 DB에 반영이 지연된다. ➡️ 이러한 이유 때문에 아래와 같이 지정해줘야 한다.
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "parentchild_id")
private List<QnA> qnaList;
따라서 위와 같이 지연로딩이 아닌 즉시로딩으로 조정하여 격리수준이 충돌하는 동시성 문제를 해결할 수 있다. 이는 쿼리가 실행되는 즉시 연관된 객체를 로딩하므로 격리 수준 충돌로 인한 동시성 문제는 해결할 수 있겠지만, 지연로딩의 장점을 잃는다는 단점이 있다.
또는, 트랜잭션의 격리 수준을 높이거나 낮추는 등의 설정을 변경하는 방법도 있다.
영속성 컨텍스트 관리
작업 내에서 DB 업데이트가 발생하는 경우, 영속성 컨텍스트가 관리하는 엔티티의 상태를 영속 상태로 관리되게 유지해야 한다.
이렇게 하려면 작업 메서드 내에서 DB 업데이트가 발생하는 경우에 다음과 같은 시도를 해볼 수 있다.
EntityManager
를 사용하여 작업 수행예외 처리
작업 실행 중 예외가 발생하는 경우, 트랜잭션이 롤백되고 DB에 변경이 적용되지 않을 수 있다. ⇒ 예외 처리 로직을 추가하여 예외 발생 시 트랜잭션 롤백을 제대로 처리하고, 예외가 발생해도 DB에 변경이 반영되도록 해야 한다.
트랜잭션 관리 설정
스프링에서 @EnableTransactionManagement
어노테이션을 사용하여 트랜잭션 기능을 활성화할 수 있지만, 반드시 필요한 것은 아니다.
하지만 트랜잭션 활성이 제대로 이루어지고 있는지 명확히 하기 위해서는 해당 어노테이션을 설정하여 확인해볼 수 있다.
트랜잭션 범위
트랜잭션의 범위를 올바르게 설정했는지 확인해볼 필요가 있다.
@Transactional
어노테이션을 메서드 레벨 VS 클래스 레벨 어디에 적용하느냐에 따라 범위를 설정할 수 있다.
트랜잭션 경계
트랜잭션 경계를 정확하게 설정하여 시작과 종료가 적절한 위치에서 수행하도록 해야 한다. 수동으로 필요한 부분에 tx.begin() - tx.commit() - tx.rollback()으로 범위를 지정해줄 수 있다. → @Transactional
을 사용하면 이를 자동적으로 처리해준다.
ex. 서비스 레이어에서 트랜잭션을 시작하고 컨트롤러 레이어에서 트랜잭션을 종료하면 트랜잭션 범위가 적절하게 설정된다.
트랜잭션 롤백 처리
예외발생 시에는 트랜잭션이 롤백되어야 한다.
@Transactional
어노테이션에 rollbackFor
또는 noRollbackFor
속성을 사용하여 롤백 조건을 설정하거나 수동으로 rollback 시점을 지정해줘야 한다.
데이터베이스 설정
데이터베이스 연결 풀 설정, 트랜잭션 격리 수준, 자동 커밋 설정 등에 문제가 있어도 트랜잭션 커밋 반영이 안 될 수 있다.
왜 트랜잭션이 적용되지 않을까요?
이후 혼자 다음과 같은 시도를 하며 삽질을 꽤 오래했다..
오류의 원인을 추적하기 위해 로그를 찍어보며 다양한 케이스로 시도를 해보았다.
@Transactional
어노테이션을 이용해 어디에 예약할지 지정하는 초기식에 붙이기해당 메서드는 실질적으로 주기적 실행되는 부분이 아니다.
로그로 확인했을 때, 테이블을 전체 조회해서 지정된 알림시간을 초기화한 cron식에 일일이 변경하여 작업을 예약한다. 따라서 시간을 지정해주는 로직 자체는 단 한 번만 실행되고 메서드의 호출을 다시 명시하지 않는 한 따로 작업이 이루어지지 않으므로 @Transactional
이 필요없는 것이다.
→ 여기서는 findAll()의 조회 로직만 존재
@Transactional
어노테이션을 이용해 예약할 스케줄링 작업의 메서드에 붙이기실질적으로 여러 번 실행되는 로직이 이 부분이며, 여기서 이루어지는 DB 변경사항이 적용되지 않는 문제를 해결하고자 한 것이다. @Transactional
은 해당 트랜잭션 범위 내에서 작업이 정상적으로 처리되면 자동으로 DB에 변경사항을 커밋하여 반영해준다. 트랜잭션 범위 내에서 이루어진 작업의 스냅샷을 찍어두고 기존 DB 내용과의 비교를 통해 변경된 내용이 있으면 자동 커밋이 되는 것이다.
하지만 이는 영속성 컨텍스트의 관리 대상에 속할 때만 해당하는 내용이다. 만약, Parentchild가 영속 상태였다면 관리 대상으로서 업데이트 내용이 적용될테지만, 준영속 상태였기 때문에 DB에 반영이 되지 않았다.
스케줄링 작업을 수행하면서 시작 시점과 종료 시점이 명확하지 않아 트랜잭션 처리가 모호했으며, 수동으로 준영속 상태의 변경 사항을 적용하려면 @Transactional
어노테이션을 붙여서는 안 된다.
@Autowired
private PlatformTransactionManager transactionManager;
public void scheduledTask() {
TransactionDefinition transactionDefinition = new DefaultTransactionDefinition();
TransactionStatus transactionStatus = transactionManager.getTransaction(transactionDefinition);
try {
// DB 작업 수행
transactionManager.commit(transactionStatus);
} catch (Exception e) {
transactionManager.rollback(transactionStatus);
throw e;
}
}
→ Exception 클래스를 catch로 잡아버리면 수행이 제대로 이루어지든, 안되든 이전과 동일하게 정상 실행되는 듯 보이므로 해당 부분은 제외하고 에러 로그를 찍어보았다.
둘 다 답변이 이루어졌는지를, 답변 입력하기 API에서 처리하고 해당 값에 대한 boolean 필드 등으로 스케줄링 작업에서 구분하는 방식을 이용해야할 듯…
결론적으로는, 아래와 같은 이유라고 판단하였다.
결국 문제 원인이 무엇이었는가 ?!
@Transactional
어노테이션의 사용
영속성 컨텍스트의 관리 대상 여부
Parentchild 가 스케줄링 작업 내에서 준영속 상태로 존재하여 자동 커밋이 적용되지 않는 문제점
영속성 컨텍스트의 관리 대상이 아니기에 더티체킹이 일어나지 않는 것이라고 의심할 수 있다! 따라서 준영속 상태의 객체를 업데이트 시점에 강제로 커밋시켜주는 작업이 필요했고, 이를 위해 PlatformTransactionManager
를 이용해 별도의 트랜잭션 매니저를 사용하였다.
🤨 어떻게 해결할까? - 영속성 컨텍스트의 관리 대상으로 만들어주자! - 준영속 → 영속 상태로 바꿔줘야지! - 이후 수동으로 변경내용을 DB에 커밋하여 반영하자!
외래키 제약조건에 의한 업데이트 쿼리 미적용
외래키 무결성 제약조건 때문에 업데이트 반영 X
show_sql: true로 설정하여 실행중인 쿼리를 보았을 때 update 쿼리는 없고 온통 select 뿐이었다.
👨👩👧👦 Parentchild - count → 변경되어야 하는 필드 - User에서 외래키로 갖고 있으므로 제약조건 위반으로 인식업데이트 쿼리가 날라가도 반영안됨
@ManyToOne
@JoinColumn(name = "parentchild_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) // 외래 키 제약조건 제거
private Parentchild parentChild;
위 코드처럼 @ForeignKey(ConstraintMode.NO_CONSTRAINT)
를 이용해 제약조건을 제거해주었다.
Parentchild 객체 자체를 파라미터로 받아 Call By Value로 넘어감
아직 명확하지는 않지만 이 부분이 영속 상태로 관리되지 않는 주범이거나 트랜잭션 커밋을 수동으로 해줘도 적용되지 않는 문제였을 것이다.
addCount()가 실행되지 않는 문제 → 👤 휴먼 이슈
QnAList의 size()가 7 미만일 때만 값이 변경되도록 if문이 걸려있어 해당 부분을 지워주었다.
transactionManager.commit(transactionStatus);
@Transactional
은 어떤 곳에도 붙지 않는다.@ForeignKey(ConstraintMode.NO_CONSTRAINT))