해당 템플릿을 만들고, 실제 프로젝트에 적용해서 사용해보았다.
직접 사용해보니 불편한 점이나 보완해야할 점이 여러 곳에서 보였다.
1. QueryDSL
다소 높은 접근 난이도로 인한 유지보수성에 대한 우려
쿼리 DSL을 사용한 Join 쿼리 일부이다.
@Override
public List<GraphData> get2dGraphData(long masterId, long processId) {
QEMIScannerData emiScannerData = QEMIScannerData.eMIScannerData;
QEMIHPrediction hPred = QEMIHPrediction.eMIHPrediction;
QEMIVPrediction vPred = QEMIVPrediction.eMIVPrediction;
return jpaQueryFactory
.select(Projections.constructor(
GraphData.class,
emiScannerData.frequency,
emiScannerData.nv_data.as("nvData"),
emiScannerData.nv_max.as("scannerNvMax"),
vPred.fv.as("vPredictionNvMax"),
vPred.gt_v.as("vChamberNvMax"),
hPred.fh.as("hPredictionNvMax"),
hPred.gt_h.as("hChamberNvMax"),
hPred.processId,
emiScannerData.masterId)
)
.from(emiScannerData)
.join(hPred)
.on(emiScannerData.frequency.eq(hPred.frequency)
.and(emiScannerData.masterId.eq(hPred.masterId)))
.join(vPred)
.on(emiScannerData.frequency.eq(vPred.frequency)
.and(emiScannerData.masterId.eq(vPred.masterId)))
.where(emiScannerData.masterId.eq(masterId)
.and(hPred.processId.eq(processId))
.and(vPred.processId.eq(processId))
)
.orderBy(emiScannerData.frequency.asc())
.fetch();
}
직접 사용해보니, 고객사에 코드를 넘길 때 QueryDSL을 사용해보지 않은 사람이 대부분이였다. 따라서 코드를 유지보수할 수 있는 사람이 없어 추후 이슈가 될 가능성도 존재했다.
Mybatis를 사용한다면 직접 쿼리를 입력하기 때문에 쿼리가 잘 이해되지 않는 부분이 있더라도 구글링을 통해 어떻게든 작업할 수 있지만 QueryDSL 은 개념을 어느정도 공부하지 않으면 작업 자체가 어렵다는 단점이 있었다.
새로운 기술이 가져다주는 장점도 존재하지만, 접근성에 대한 생각도 다시 해볼 필요가 있겠다는 생각이 들었다.
2. Exception 처리
CustomException : null 문제
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
Cookie[] cookies = request.getCookies();
String accessToken = null;
String refreshToken = null;
if (cookies != null) {
// 1. Bearer 제거한 토큰 값 가져오기
for (Cookie cookie : cookies) {
if (cookie == null) {
continue;
}
if (cookie.getName().equals("Authorization")) {
accessToken = jwtTokenProvider.resolveToken(cookie);
}
if (cookie.getName().equals("Refresh")) {
refreshToken = jwtTokenProvider.resolveToken(cookie);
}
}
}
// 토큰이 유효하면 인증객체 생성 후 SecurityContext -> SecurityContextHolder 에 저장
if (accessToken != null) {
if (jwtTokenProvider.validateToken(accessToken) == TokenState.VALID) {
setAuthentication(jwtTokenProvider.getUserInfoFromToken(accessToken).getSubject());
} else if (jwtTokenProvider.validateToken(accessToken) == TokenState.EXPIRED) {
// Access Token 만료 시,
// Access Token Cookie 삭제
ResponseCookie responseCookie = ResponseCookie.from(Constants.AUTHORIZATION_HEADER, null).
path("/").
// httpOnly(true).
sameSite("None").
secure(true).
maxAge(1).
build();
response.addHeader("Set-Cookie", responseCookie.toString());
throw new CustomException(ExceptionStatus.TokenExpiredJwtException);
}
} else if (refreshToken != null) {
if (jwtTokenProvider.validateRefreshToken(refreshToken)) {
setAuthentication(jwtTokenProvider.getUserInfoFromToken(refreshToken).getSubject());
}
}
filterChain.doFilter(request, response);
}
프로젝트 내에서 예외처리를 CustomException을 구현하여 사용하고 있었다. Security FilterChain 내부에서도 위와같이 커스텀 Exception을 선언하여 사용했는데, 예외 발생시 null이 출력되었다.
FilterChain 이 언제 동작하는지 전혀 생각하지 않고 로직을 구성한 탓이였다. Security 는 애플리케이션 단이 동작하기 전에 먼저 실행되는데, 이때는 CustomException에서 사용하는 enum 클래스의 변수들이 아직 초기화되기 전이기 때문에 null로 출력되는 것이였다.
따라서 아래와 같이 Enum 클래스를 사용하지 않는 방법으로 수정했다.
// throw new CustomException(ExceptionStatus.TokenExpiredJwtException); 삭제
throw new RuntimeException("만료된 JWT token 입니다.");
빈번한 Internal Server Error
CustomException을 직접 구현하여 에러가 발생할 수도 있다고 예상되는 부분에 예외가 발생하도록 처리하였지만, 빈번하게 Internal Server Error가 발생했다.
추가로 발생하는 에러마다 CustomException Case를 추가하자는 의도로 예외처리를 기획했지만, 파일 업로드나 파이썬 모듈 실행 등 코드가 도는 순서를 쉽게 예측할 수 없는 부분에 있어서 예외처리가 어려웠다.
Internal Server Error가 발생할 경우 따로 메세지가 반환되지 않아 어디서 에러가 발생했는지 확인할 방법이 없었다. 따라서 백그라운드 실행 중인 서버를 종료하고, 다시 실행하여 로그를 직접 봐야했다.
서버로그를 확인해서 어떤 예외가 발생하는지 확인하고, GlobalExceptionHandler에서 해당 예외가 발생할 때 stackTrace를 출력하도록 Case를 추가할 수는 있지만 예외가 한번은 발생한 뒤에 조치해야하는 방법이기 때문에 이게 과연 예외처리의 목적에 부합할까 라는 의문이 들었다.
상위의 Exception이나 RuntimeException을 Case로 지정하여 예외처리 하게되면 모든 Case에 대해서 미리 처리가 가능하겠지만, CustomException 이 RuntimeException을 확장하고 있기 때문에 세부 예외 Case에 대한 조작이 불가능했다.
어떤 방법이 좋은 예외처리인지 조금 더 고민해볼 필요가 있을 것 같다.
3. 무수히 많은 DTO 클래스들
@Override
public MasterInfoToSolutionPage getMasterInfoDetail(long masterId) {
QPredictionMaster mt = QPredictionMaster.predictionMaster;
QMember mb = QMember.member;
return jpaQueryFactory
.select(Projections.constructor(MasterInfoToSolutionPage.class,
mt.id.as("masterId"),
mt.inputFilePath.as("inputFilePath"),
mt.createdAt.as("createdAt"),
mb.email.as("email"),
mt.modelName.as("modelName"),
mt.memo.as("memo"),
mt.inch.as("inch")
))
.from(mt)
.join(mb).on(mt.memberId.eq(mb.id))
.where(mt.id.eq(masterId))
.fetchOne();
}
필요에 의해 다양한 테이블에서 데이터를 Join 하여 사용하곤 한다. QueryDSL에서 데이터를 바인딩 할 때 Projections 를 사용했는데 각 쿼리마다 별도의 DTO를 생성해주어야하는 번거로움이 있었다.
그 결과 수많은 DTO 클래스들이 만들어졌고, 나중에는 네이밍으로 구분하기 모호한 수준에 이르렀다.
MyBatis에는 결과를 Map 형태로 Key : Value 로 바인딩하여 리턴하는 방법이 있던데, QueryDSL에도 그런 기능이 있지 않을까? 잘 모르고 사용한 경향이 있어 조사해볼 필요를 느꼈다.
후기
내가 직접 구성한 템플릿으로 프로젝트를 직접 진행해보니 디버깅 하나하나가 의미가 있고 보람있었던 것 같다. 하지만 아직 보완할 부분이 많고, 인지 자체를 하지 못해서 비어있는 취약점이 존재할 거라는 생각이 든다.
앞으로도 많이 공부해보고, 하나씩 보완해가면서 다른 사람에게도 자신있게 내보일 수 있는 템플릿을 만들어보고 싶다.
'Spring' 카테고리의 다른 글
[Spring Boot] 프로젝트 템플릿 만들기(5) - 인증 (0) | 2024.07.31 |
---|---|
[Spring Boot] 프로젝트 템플릿 만들기(4) - 공통 사용 클래스 (0) | 2024.07.30 |
[Spring Boot] 프로젝트 템플릿 만들기(3) - 로깅 (0) | 2024.07.30 |
[Spring Boot] 프로젝트 템플릿 만들기(2) - JPA 관련 세팅 (0) | 2024.04.10 |
[Spring Boot] 프로젝트 템플릿 만들기(0) (1) | 2024.04.08 |