이 글은 프로젝트를 진행하다가 겪은 문제와 그 문제를 해결하는 과정에 대한 글입니다.
설명에서 사용되는 코드는 프로젝트 코드가 아니라 해당 문제를 설명하기 위해 만든 단순한 예제 코드입니다.
저는 진행하던 프로젝트의 프론트에서 vue lifecycle의 created시점에 axios로 여러 통계 정보를 요청했을 때 간헐적으로 요청이 실패하는 문제가 발생하는 상황이었습니다.
전체 코드는 깃허브에 공유되어 있습니다.
A와 B 두 엔티티가 있습니다. A와 B는 OneToOne
관계이며 B가 연관관계의 주인으로 매핑되어 있습니다.
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class A {
@Id
private Long id;
@OneToOne(mappedBy = "a")
private B b;
private int var1;
}
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class B {
@Id
private Long id;
@OneToOne
@JoinColumn(name = "a_id")
private A a;
private int var1;
}
A와 B 둘 다 사용할 수 있는 Dto도 하나 존재합니다.
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Data
public class Dto {
private Long id;
private int var1;
}
Repository에는 A와 B의 값을 하나 가져오는 findAFetchFirst와 findBFetchFirst가 존재합니다. 둘 모두 Projections.constructor
를 이용해 Dto를 반환합니다.
public interface Repository {
Dto findAFetchFirst();
Dto findBFetchFirst();
}
@Repository
public class RepositoryImpl implements Repository {
private final JPAQueryFactory queryFactory;
public RepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public Dto findAFetchFirst() {
return queryFactory
.select(Projections.constructor(Dto.class,
a.id,
a.var1
))
.from(a)
.fetchFirst();
}
@Override
public Dto findBFetchFirst(){
return queryFactory
.select(Projections.constructor(Dto.class,
b.id,
b.var1
))
.from(b)
.fetchFirst();
}
}
Repository의 메서드를 그대로 사용합니다.
@Service
@RequiredArgsConstructor
public class Service {
private final Repository repo;
@Transactional
public Dto getAData(){
return repo.findAFetchFirst();
}
@Transactional
public Dto getBData(){
return repo.findBFetchFirst();
}
}
[초기 데이터]
DB에는 다음과 같은 데이터가 들어있습니다.
insert into a values(1,1); // id, var1
insert into b values(1,99,1); // id, var1, a_id
쓰레드 A에서 A값을 가져오는 querydsl을, 쓰레드 B에서 B값을 가져오는 querydsl을 실행합니다.
@SpringBootTest
@Slf4j
@Transactional
public class finalTest {
@Autowired
Service service;
@Test
void occurDeadlock(){
Runnable userA = () -> {
log.info("thread A start");
Dto dto = service.getAData();
log.info("dto = {}",dto);
};
Thread threadA = new Thread(userA);
threadA.start();
Runnable userB = () -> {
log.info("thread B start");
Dto dto = service.getBData();
log.info("dto = {}",dto);
};
Thread threadB = new Thread(userB);
threadB.start();
Assertions.assertTimeoutPreemptively(Duration.ofMillis(4000),()->{
threadA.join();
threadB.join();
});
}
}
Assertions.assertTimeoutPreemptively
를 사용했습니다. assertTimeout
은 테스트가 완료될때까지 기다리지만 assertTimeoutPreemptively
는 테스트 타임아웃이 지나면 즉시 테스트를 종료합니다. 문제를 해결하기 전에 A와 B를 같은 쓰레드에서 모두 실행
, Hikari CP의 leak에 대한 테스트
, A쓰레드와 B쓰레드 모두 A데이터를 호출함
, A쓰레드와 B쓰레드 모두 B데이터를 호출함
, A데이터와 B데이터를 쓰레드가 없이 같이 호출함
, A데이터를 A쓰레드에서 호출하고 B쓰레드는 아무런 호출을 하지 않음
, 모든 작업을 querydsl로직이 아니라 Qtype호출에 대한 작업으로 변경해서 실행
등등 정말 많은 테스트를 진행했습니다. 테스트가 많았지만 문제를 이해하는데 방해가 될 거 같아 작성을 제외했습니다.
대신 (당시에) 가장 의문스러웠던 테스트 하나만 소개드리겠습니다. RepositoryImpl의 findBFetchFirst()메서드를 아래 코드로 바꾸면 데드락이 발생하지 않습니다.
@Override
public Dto findBFetchFirst(){
System.out.println(a.getClass()); // 이 부분만 새롭게 추가됐습니다.
return queryFactory
.select(Projections.constructor(Dto.class,
b.id,
b.var1
))
.from(b)
.fetchFirst();
}
querydsl공식문서에 다음과 같은 내용이 있습니다.
OneToOne을 양방향 매핑하는 과정에서 A는 B를, B는 A를 참조하고 있었기 때문에 순환 의존을 가질 가능성이 충분하다고 판단했습니다. 공식문서의 내용처럼 단일 쓰레드에서 클래스를 초기화하기 위해 테스트코드를 다음과 같이 변경했습니다.
@Test
void avoidDeadlock() throws IOException {
ClassPathUtils.scanPackage(Thread.currentThread().getContextClassLoader(),
"com.example.demo.domain");
Runnable userA = () -> {
log.info("thread A start");
Dto dto = service.getAData();
log.info("dto = {}",dto);
};
Thread threadA = new Thread(userA);
threadA.start();
Runnable userB = () -> {
log.info("thread B start");
Dto dto = service.getBData();
log.info("dto = {}",dto);
};
Thread threadB = new Thread(userB);
threadB.start();
Assertions.assertTimeoutPreemptively(Duration.ofMillis(4000),()->{
threadA.join();
threadB.join();
});
}
ClassPathUtils
를 사용하기 위해서는 의존성을 추가해줘야 합니다.implementation group: 'com.querydsl', name: 'querydsl-codegen', version: '5.0.0'
추가실제 프로젝트에서는 @PostConstruct
시점에 클래스를 초기화 해 문제를 해결했습니다.
@RestController
public class Controller {
...
@PostConstruct
public void init() throws Exception{
ClassPathUtils.scanPackage(Thread.currentThread().getContextClassLoader(),
"com.example.demo.domain");
}
}