SpingBoot를 활용한 보험 중개 서비스
보험 설계사와 보험 가입자를 연결해주는 웹 서비스
- 보험에 대해 무지하여 자신에게 필요한 보험의 카테고리에 대해 잘 모르는 사람이 많음
- 기존 보험 설계는 다양한 설계사를 비교할 수 없어 설계사 선택에 제약 사항이 많음
- 다양한 보험 회사에서 제공하는 보험 설계 서비스를 한 곳에서 볼 수 있는 서비스가 부재함
- 가입자 입장에서 자신이 필요한 보험이 무엇인지 손쉽게 파악하고 인기 설계사를 골라서 상담할 수 있는 중립적인 플랫폼에 대한 필요성이 대두되어 해당 프로젝트 기획
- Chat GPT와의 대화를 통해 자신에게 맞는 보험 카테고리를 알 수 있음
- 카테고리를 통해 보험 가입자가 자신에게 맞는 다양한 설계사를 확인 및 상담 요청할 수 있음
- 상담을 마친 후 남긴 리뷰를 통해 다음 가입자에게 설계사에 대한 정보를 남길 수 있음
- 2024.01.02 ~ 2024.01.25
- Language :
Java 17
- IDE :
IntelliJ
- Framework :
SpringBoot 3.2.1
- Featured Library :
OAuth 2.0
+JWT 0.9.1
+ModelMapper 3.1.1
- Servlet Engine :
Apache Tomcat 10.1.17
- Database :
Postgresql 16
- ORM :
Hibernate 6.4.1
+JPA 3.2.1
- Web :
HTML
(+thymeleaf
) +JavaScript
(+JQuery
) +CSS
(+bootstrap
)
- 보험 가입자
- Chat GPT를 활용하여 가입하고자 하는 보험 카테고리 확인
- 보험 카테고리를 활용하여 설계사 검색 및 설계사 홍보글 확인
- 자신에게 맞는 보험 설계사와 연결
- 상담 후 설계사에 대한 리뷰 작성
- 보험 설계사
- 자신을 나타낼 수 있는 홍보글 작성
- 가입자가 첨부한 AI 채팅 내역 확인 후 상담
- 인기 상담사 확인
- 최근 리뷰 확인



- 설계사 신청 내역 확인 및 등록
- 유저 관리
- 리뷰 관리
- 카테고리 관리

- 요청 상담자 AI채팅 내역 확인
- 홍보글 관리

- 내 정보 수정
- 내가 쓴 리뷰 관리
- 회원 탈퇴

- Chat GPT 기반 보험 정보 획득


- 가입자의 상황에 맞는 설계사 확인 및 상담 신청
- 상담사 카카오톡 채널 연결

- 전체 리뷰 확인
- 문제 파악
- 인기 설계사 선정
- 단순 리뷰 점수의 합으로 나열하기에는 최근에 시작한 설계사가 불리한 면이 있음
- 리뷰 평균 점수로 나열하기에는 보험 가입자의 피드백을 수정받아 더 좋은 서비스를 제공해도 과거의 낮은 점수의 리뷰가 발목을 잡을 수 있음
- 인기 설계사 계산 함수 호출
- 메인 페이지에 접속할 때마다 인기 설계사 계산 함수 호출시 서버에 과부하가 걸릴 가능성이 있음
- 인기 설계사 선정
- 문제 해결
- 인기 설계사 선정
최근 1년 리뷰만 반영 후 평균 점수로 나열-> 최근의 리뷰와 1년 전 리뷰가 같은 가치를 갖는 것은 2번 문제점을 해결하지 못함기간별로 가중치를 두어 최근의 리뷰가 더 높은 점수를 받도록 조정-> 리뷰 100개 평균 점수 4.8인 설계사보다 리뷰 1개 평균 점수 5.0인 설계사가 더 높은 점수를 받는 문제 발생- 기간별로 가중치를 두고 평균 점수를 구한 후 전체 리뷰 수에 대한 점수 추가 부여 -> 최근 리뷰에 대한 가치 상승 및 평균의 함정 해결 가능
- 인기 설계사 계산 함수 호출
리뷰가 추가될 때마다 점수 계산-> 실시간으로 점수 반영이 가능하나 리뷰 작성 사용자 수가 많을 경우 서버 과부하 문제 해결 불가- 최소 1시간마다 한번씩 점수 계산 -> 실시간 점수 반영이 불가능하나 한 상담사가 1시간에 많은 상담을 진행하지 못한다는 상황을 고려하여 점수 실시간 반영보다 과부하 방지가 더 큰 이점이 있다고 판단
- 인기 설계사 선정
- 해결 과정
- 인기 설계사 선정
기간에 따른 선형적 가중치 값 활용-> 단순하여 합리적인 점수 부여가 불가능하다고 판단- 가중치 값으로 망각 곡선의 망각률 활용 -> 상담에 대한 기억(리뷰)은 인간의 기억과 유사할 것이라 판단
- (1달(58점)+3달(44점)+6달(33점)+1년(20점))*리뷰 점수/전체 리뷰수+리뷰 1개당(30점)
public int getRecommendRating(){ int score = 0; if(review != null && !review.isEmpty()){ int cnt = 0; for(Review reviewScore : review){ Timestamp before = reviewScore.getWrite(); Timestamp now = new Timestamp(System.currentTimeMillis()); Instant beforeIns = before.toInstant(); Instant nowIns = now.toInstant(); Duration duration = Duration.between(beforeIns, nowIns); long day = Math.abs(duration.toDays()); if(day > 365) continue; //1년 이상 0점 else if(day > 180){ score+=reviewScore.getRate()*20; //6개월~1년 20점 cnt++; } else if(day > 90){ score+=reviewScore.getRate()*33; //3개월~6개월 33점 cnt++; } else if(day > 30){ score+=reviewScore.getRate()*44; //1개월~3개월 30점 cnt++; } else{ score+=reviewScore.getRate()*58; //1개월 이하 58점 cnt++; } } score = (score/cnt)+(cnt*30); //가중치 점수 평균 + 리뷰 1개당 30점점 } return score; }
- 인기 설계사 계산 함수 호출
- 최소 1시간 마다 계산한 내용을 DB에 저장 후 호출 -> 짧은 시간에 발생하는 동일한 계산을 방지하여 서버 과부하 방지
-
public RecommendDto recommendPlanner(){ List<PlannerDto> plannerDtos = new ArrayList<>(); RecommendPlanner recommendPlanner = recommendPlannerRepository.findFirstByOrderByTimeDesc(); //가장 최근에 저장된 인기 설계사 리스트 호출 Timestamp time = null; if(recommendPlanner != null){ //저장된 내용이 있는 경우 1시간 이내의 데이터를 호출 time = recommendPlanner.getTime(); Timestamp now = new Timestamp(System.currentTimeMillis()); Instant beforeIns = time.toInstant(); Instant nowIns = now.toInstant(); Duration duration = Duration.between(beforeIns, nowIns); long hour = Math.abs(duration.toHours()); if(hour < 1){ String [] s = recommendPlanner.getList().split(","); for(int i = 0; i < 5; i++){ //최대 5개까지 호출 if(s[i].equals("A")) break; //A는 설계사 정보가 없는 경우 Planner planner = plannerRepository.findById(Long.parseLong(s[i])).orElseThrow(IllegalArgumentException::new); plannerDtos.add(modelMapper.map(planner, PlannerDto.class)); } } else recommendPlanner = null; } if(recommendPlanner == null) { //저장된 내용이 없는 경우나 저장된지 1시간이 초과한 경우 새로운 계산 후 DB에 저장 List<Planner> planners = plannerRepository.findAllPermitPlanner(); List<Recommend> recommends = new ArrayList<>(); for (Planner planner : planners) { recommends.add(new Recommend(planner.getReview().size(), planner.getRecommendRating(), modelMapper.map(planner, PlannerDto.class))); } Collections.sort(recommends); String s = ""; for (Recommend recommend : recommends) { plannerDtos.add(recommend.getPlannerDto()); s+=recommend.getPlannerDto().getId(); s+=","; //설계사 ID간 ,를 활용하여 데이터 분리 } s+="A,A,A,A,A"; //설계사가 0명인 경우를 대비하여 항상 빈 데이터를 5개 추가 time = new Timestamp(System.currentTimeMillis()); RecommendPlanner created = RecommendPlanner.builder() .time(time) .list(s) .build(); recommendPlannerRepository.save(created); } RecommendDto recommendDto = RecommendDto.builder() .list(plannerDtos) .time(time) .build(); return recommendDto; }
-
- 최소 1시간 마다 계산한 내용을 DB에 저장 후 호출 -> 짧은 시간에 발생하는 동일한 계산을 방지하여 서버 과부하 방지
- 인기 설계사 선정
- 문제 파악
- JWT 사용 시 LazyInitializationException 발생.
- 문제 해결
- ModelMapper 수정
- Converter com.example.InsureConnect.Config.AppConfig$$Lambda$1365/0x000000b8017ef260@389a7e23 failed to convert org.hibernate.collection.spi.PersistentBag to java.util.List.
-
modelMapper.createTypeMap(User.class, UserDto.class) .addMappings(mapping -> { mapping.map(src -> src.getPlanner().getId(), UserDto::setPlannerId); mapping.using(convertReviewToLongList()).map(User::getReview, UserDto::setReviewId); mapping.using(convertChatRoomToLongList()).map(User::getChatRoom, UserDto::setChatRoomId); }); private Converter<List<ChatRoom>, List<Long>> convertChatRoomToLongList() { return context -> context.getSource() .stream() .map(ChatRoom::getId) .collect(Collectors.toList()); } private Converter<List<Review>, List<Long>> convertReviewToLongList() { return context -> context.getSource() .stream() .map(Review::getId) .collect(Collectors.toList()); }
-
- ModelMapper Config에서 convertChatRoomToLongList,convertReviewToLongList에서 오류 발생을 확인, User,UserDto에서 Review,ChatRoom을 사용하는 코드가 없기때문에 매핑에서 제외하니 정상적으로 작동.
-
modelMapper.createTypeMap(User.class, UserDto.class) .addMappings(mapping -> { mapping.map(src -> src.getPlanner().getId(), UserDto::setPlannerId); mapping.skip(User::getReview, UserDto::setReviewId); mapping.skip(User::getChatRoom, UserDto::setChatRoomId); });
-
- Converter com.example.InsureConnect.Config.AppConfig$$Lambda$1365/0x000000b8017ef260@389a7e23 failed to convert org.hibernate.collection.spi.PersistentBag to java.util.List.
- 즉시로딩으로 변경
- 현재는 User,UserDto에서 Review,ChatRoom을 사용하지 않지만 추후 기능 추가시 필요할수도 있기때문에 ModelMapper 수정을 Rollback, 즉시로딩 사용
- 정상적으로 작동됨을 확인했으나 즉시로딩 사용 시 필요하지 않은 엔티티도 함께 조회, 예상치 못한 SQL 발생등 성능상의 문제가 존재하기때문에 즉시로딩 사용방안은 폐기.
- UserService 수정
- 지연로딩이 아니라 즉시로딩 + 변경 전 modelmaaper사용시 정상적으로 작동하는것으로 보아 modelmapper 설정 변경은 정상적인 해결방법이 아님을 인지
- JWT 도입이후부터 오류가 발생하였기 때문에 TokenService를 확인 -> userService.findById를 호출
-
public String createNewAccessToken(String refreshToken) { if(!tokenProvider.validToken(refreshToken)) { throw new IllegalArgumentException("Unexpected token"); } UUID userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId(); User user = modelMapper.map(userService.findById(userId), User.class); return tokenProvider.generateToken(user, Duration.ofHours(2)); } public UserDto findById(UUID id){ Optional<User> byId = userRepository.findById(id); return byId.map(user -> modelMapper.map(user, UserDto.class)) .orElseThrow(() -> new IllegalArgumentException("User not found with id: " + id)); }
-
- findById메서드에서 planner 혹은 review,chatroom 설정이 없으니 프록시가 전부 생성되어야하지만 chatRoom과 review에서 LazyInitializationException: failed to lazily initialize a collection of role: could not initialize proxy - no Session라는 예외가 발생하고 planner만 정상적으로 프록시 생성이 진행.
- findById메서드가 트랜잭션을 열어주면, Planner,Review,ChatRoom 조회가 같은 트랜잭션 범위에서 이루어져야하지만 planner는 프록시 초기화에 성공, review,chatroom은 실패하였기때문에 별개의 트랜잭션에서 진행된다는 것을 확인 후 userService를 다시 한번 검토하니 @Transactional 어노테이션을 사용하지않아 범위가 지정되지않았다는 것을 인지. @Transactional 어노테이션을 추가하여 트랜잭션 범위를 설정하니 정상적으로 작동하였다.
-
@RequiredArgsConstructor @Service @Transactional(readOnly = true) public class UserService { private final UserRepository userRepository; private final ModelMapper modelMapper; public UserDto findById(UUID id){ Optional<User> byId = userRepository.findById(id); return byId.map(user -> modelMapper.map(user, UserDto.class)) .orElseThrow(() -> new IllegalArgumentException("User not found with id: " + id)); } .....
-
- ModelMapper 수정
- 황인수
- 늘 사용하기만 했던 웹 서비스와 서버를 직접 구현해보며 보이지 않는 부분에서 조회나 검증 등 처리해야할 내용이 많다는 것을 알게 됨
- 인기 설계사 계산 함수를 구현하며 평소 단순하게 생각했던 정보에 대해 좀 더 깊고 합리적인 고민을 하는 계기가 됨
- 다양한 기업에서 다양한 API를 제공한다는 것을 알게 되었고 이를 직접 사용해보며 더 많은 기능을 사용자에게 제공할 수 있음을 알게 됨
- OAuth2.0 + JWT를 직접 구현해보며 사용자 정보를 가져오는 방법, 세션 방식과 토큰 방식의 차이점에 대해 배움
- 학부 시절 Spring을 접한 경험이 없었으나 취업 준비를 하며 다양한 기업에서 Spring에 대한 지식을 요구한다는 것을 알게 되었고 이에 충족하고자 학습 및 실습하는 과정을 통해 늘 새로운 것을 접하고 활용하는 개발자의 태도에 대해 배움
- 이한얼
- 문제가 발생했을 때 그 원인을 정확하게 파악하는 것이 제일 중요하다. 이번 JWT 지연 로딩 문제의 경우에도 원인을 정확하게 알았다면 시간을 아끼고 정확한 조치를 취할 수 있었을 것이다.
- 기반이 얼마나 중요한지 깨달음. 복잡한 문제에 대한 해결은 항상 기초 지식에서 출발한다. 프로그래밍, 데이터베이스, 백엔드 시스템 등의 기본 개념을 잘 이해하는 것은 문제 해결에 필수적이라는 것을 다시 한번 상기하게 됨.
- 복잡한 문제의 시작은 종종 사소한 곳에서 비롯된다. 작은 설정 오류, 미처 고려하지 못한 부분이 큰 문제의 근본이 될 수 있다.