5. 주요 기능 - LikeLionTeam/BootHouse GitHub Wiki

1. Course 등록 및 관리


  • 등록된 부트 캠프 모두 조회
스크린샷 2024-09-04 오후 3 18 41
  • 각 부트캠프에서 모집하는 여러 Course들 조회
스크린샷 2024-09-04 오후 3 20 20
  • 특정 부트캠프 기관에서 모집하는 여러 Course들 조회
스크린샷 2024-09-04 오후 3 21 24
  • 모집 중인 Course들을 조회
    • 선택된 카테고리별, 필터링 기준(비교항목선택)별, 정렬기준별, 검색기능별로 course를 필터링
스크린샷 2024-09-04 오후 3 27 47

-Course 상세 보기

스크린샷 2024-09-04 오후 3 29 23
  • SNS 공유하기 기능
스크린샷 2024-09-04 오후 3 28 33

2. Review


  • 모든 리뷰 보기
    • 리뷰 검색, 정렬
스크린샷 2024-09-04 오후 3 46 48
  • 리뷰 상세 보기
스크린샷 2024-09-04 오후 3 47 16
  • 리뷰 작성하기
스크린샷 2024-09-04 오후 3 50 01
  • 리뷰 수정/삭제하기
스크린샷 2024-09-04 오후 3 48 23 스크린샷 2024-09-04 오후 3 48 38

3. Chatting

3.1 실시간 메시지 교환과 Redis 캐싱

실시간 메시지 교환과 효율적인 메시지 저장/조회입니다. 이를 위해 WebSocket을 통한 실시간 통신과 Redis를 활용한 캐싱 시스템을 구현했습니다.

WebSocket 설정

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws").withSockJS();
    }
}

Redis를 활용한 메시지 캐싱

@Service
public class ChatService {
    private final RedisTemplate<String, ChatMessage> redisTemplate;

    public void saveMessage(ChatMessage chatMessage) {
        String redisKey = "chat:room:" + chatMessage.getChatroomId();
        redisTemplate.opsForList().leftPush(redisKey, chatMessage);
        redisTemplate.expire(redisKey, 7, TimeUnit.DAYS);
    }

    public List<ChatMessage> getRecentMessagesFromRedis(Long chatroomId, LocalDateTime since) {
        String redisKey = "chat:room:" + chatroomId;
        List<ChatMessage> allMessages = redisTemplate.opsForList().range(redisKey, 0, -1);
        long sinceTimestamp = since.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
        return allMessages.stream()
                .filter(msg -> msg.getTimestamp() > sinceTimestamp)
                .collect(Collectors.toList());
    }
}

이 구현을 통해 최근 7일간의 메시지는 Redis에 저장되어 빠른 조회가 가능하며, 그 이전의 메시지는 데이터베이스에서 조회됩니다. 이는 실시간 성능과 장기 데이터 보존을 모두 만족시킵니다.

3.2 하이브리드 메시지 저장 및 조회 시스템

채팅 시스템은 Redis와 관계형 데이터베이스를 결합한 저장 시스템을 사용합니다. 이를 통해 고성능 실시간 메시지 처리와 장기적인 데이터 보존을 동시에 달성합니다.

public List<MessageEntity> getChatroomMessages(Long chatroomId) {
    LocalDateTime oneWeekAgo = LocalDateTime.now().minus(7, ChronoUnit.DAYS);

    // Redis에서 최근 1주일 메시지 가져오기
    List<ChatMessage> recentMessages = getRecentMessagesFromRedis(chatroomId, oneWeekAgo);

    // DB에서 1주일 이전 메시지 가져오기
    List<MessageEntity> olderMessages = messageRepository.findByChatroomAndRegistrationDateBeforeOrderByIdAsc(chatroom, oneWeekAgo);

    // Redis 메시지를 MessageEntity로 변환 및 병합
    List<MessageEntity> allMessages = new ArrayList<>(olderMessages);
    allMessages.addAll(convertChatMessagesToEntities(recentMessages, chatroom));

    // 시간순으로 정렬
    allMessages.sort(Comparator.comparing(MessageEntity::getRegistrationDate));

    return allMessages;
}

이 접근 방식은 실시간 메시지 접근의 지연 시간을 최소화하면서도, 모든 대화 기록을 안전하게 보존합니다.

3.3 확장 가능한 채팅방 관리

채팅 시스템은 1:1 대화부터 그룹 채팅까지 다양한 유형의 대화를 지원합니다. 채팅방 생성, 사용자 초대, 나가기 등의 기능을 제공합니다.

@Transactional
public ChatroomEntity createChatroomWithUserIds(List<Long> userIds) {
    List<UserEntity> users = userRepository.findAllById(userIds);
    ChatroomEntity chatroom = ChatroomEntity.builder()
            .name(users.stream().map(UserEntity::getName).collect(Collectors.joining(", ")))
            .build();
    users.forEach(chatroom::addUser);
    chatroom = chatroomRepository.save(chatroom);

    users.forEach(user -> {
        ChatListEntity chatList = ChatListEntity.builder()
                .user(user)
                .chatroom(chatroom)
                .build();
        chatListRepository.save(chatList);
    });

    return chatroom;
}

3.4 실시간 사용자 경험 최적화

프론트엔드에서는 WebSocket을 통해 실시간으로 메시지를 주고받으며, 사용자 인터페이스를 즉시 업데이트합니다.

function initWebSocket() {
    const socket = new SockJS('/ws');
    stompClient = Stomp.over(socket);

    const headers = {};
    const token = getCookie('userTokenCode');
    if (token) {
        headers['Authorization'] = 'Bearer ' + token;
    }

    stompClient.connect(headers, onConnected, onError);
}

function onConnected() {
    console.log('WebSocket Connected');
    stompClient.subscribe('/topic/messages/' + chatroomId, onMessageReceived);
    stompClient.send("/app/chat.addUser",
        {},
        JSON.stringify({chatroomId: chatroomId, sender: username, type: 'JOIN'})
    );
    fetchLatestMessages(chatroomId);
}

3.5 보안 및 인증

모든 WebSocket 연결은 JWT 토큰을 통해 인증됩니다. CustomHandshakeInterceptor를 통해 구현됩니다.

@Component
public class CustomHandshakeInterceptor implements HandshakeInterceptor {
    @Override
    public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response,
                                   WebSocketHandler wsHandler, Map<String, Object> attributes) {
        if (request instanceof ServletServerHttpRequest) {
            ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
            String token = extractToken(servletRequest.getServletRequest());
            if (token != null) {
                try {
                    Long userId = tokenService.validationToken(token);
                    attributes.put("userId", userId);
                    return true;
                } catch (Exception e) {
                    return false;
                }
            }
        }
        return false;
    }
}

4. Naver Map API

4.1 Naver API 준비

네이버 클라우드 : https://www.ncloud.com/product/applicationService/maps clientKey, ClientSecretKey 발급 후 ,

application.yml 에 추가

naver:
  client:
    id: 클라이언트 
    secret: 시크릿 

image

web Dynamic Map, Static Map , Geocoding 선택!

  • Web Dynamic Map (동적 지도)

    : 실시간으로 지도를 표시하고 상호작용할 수 있게함

    → 드래그, 확대/축소 , 마커 등 표시 O

  • Static Map (정적 지도)

    : 고정된 이미지를 생성하는 서비스로 사용자와 상호작용 X

    → 특정 위치의 이미지를 불러오는 방식

  • Geocoding

    : 주소를 위경도 좌표로 변환 / 위경도 좌표를 주소로 변환

‼️ 이번 프로젝트에서는 각각의 오프라인 부트캠프틀의 장소를 지도에 마킹하는 방식 사용

→ 주소를 통하여 위경도를 얻어내, 지도에 표시

4.2 Geocode


image

image

  • 네이버 공식문서

헤더에 네이버가 지정한 형식의 clientID, ClientSecret 을 넣기위한 코드

HttpHeaders headers = new HttpHeaders();

headers.add("X-NCP-APIGW-API-KEY-ID", NAVER_CLIENT_ID);
headers.add("X-NCP-APIGW-API-KEY", NAVER_CLIENT_SECRET_ID);

네이버가 지정한 엔드포인트 지정하기 위한 코드로(addresss: 오프라인 부트캠프의 주소)

String urlTemplate = "https://naveropenapi.apigw.ntruss.com/map-geocode/v2/geocode?query=" + address;

image

→ 응답예시를 토대로 dto 생성

@Data
public class Address {
    private String roadAddress;
    private String jibunAddress;
    private String englishAddress;
    private List<AddressElement> addressElements;
    private String x;
    private String y;
    private double distance;
}
@Data
public class AddressElement {
    private List<String> types;
    private String longName;
    private String shortName;
    private String code;
}
@Data
public class Meta {
    private int totalCount;
    private int page;
    private int count;
}
@Data
@Builder
@AllArgsConstructor
public class NaverMapRes {
    private String status;
    private Meta meta;
    private List<Address> addresses;
    private String errorMessage;
}

네이버로 형식에 맞추어 요청을 보낸 후 , response 받는 코드

ResponseEntity<NaverMapRes> response = restTemplate.exchange(
                urlTemplate,
                HttpMethod.GET,
                new HttpEntity<>(headers), //요청 헤더에 시크릿키 넣기
                NaverMapRes.class // 응답을 받을 타입 (dto)
        );

Web Dynamic Map


지도의 크기를 조정하여 배치

<div id="map" class="course-map" style="width: 80%; height: 400px;"></div>

부트캠프의 장소에 마커를 찍고,

정보창을 보여줌 (클릭하면 정보창 사라지도록 이벤트 처리)

const infowindow = new naver.maps.InfoWindow({
            content: `
                <div class="iw_inner custom_info_window">
                    <h3>${window.courseName}</h3>
                    <p>${window.courseLocation}</p>
                </div>`
        });
 
 naver.maps.Event.addListener(marker, "click", () => {
            if (infowindow.getMap()) {
                infowindow.close();
            } else {
                infowindow.open(map, marker);
            }
        });

4. Crawling

4.1 Selenium

: 웹 페이지의 콘텐츠가 자바스크립트에 의해 동적으로 변화할 때 사용하는 도구

의존성 추가

implementation 'org.seleniumhq.selenium:selenium-java'

4.3 webDriver 설치

https://developer.chrome.com/docs/chromedriver/downloads?hl=ko

→ 크롬 정보(버전) 에 맞는 chromeDriver 설치 후 , 사용할 위치로 옮겨줌

  • application.yml 파일
webdriver:
  id: "webdriver.chrome.driver"
  path: "webDriver를 사용할 위치"
❗❗확인할수없는 다운로드 ~~ 오류시 (맥)❗❗

sudo xattr -r -d com.apple.quarantine <크롬드라이버 경로>

// 크롤링을 진행할 서비스에 넣어줌

private WebDriver webDriver;

@Value("${webdriver.id}")
private String WEB_DRIVER_ID;

@Value("${webdriver.path}")
private String WEB_DRIVER_PATH;

@PostConstruct
    public void init(){
        System.setProperty(WEB_DRIVER_ID, WEB_DRIVER_PATH);

        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless"); // 브라우저를 창 없이 실행하기 위한 옵션
        options.addArguments("--window-size=1000,800"); //창 크기 고정
        this.webDriver = new ChromeDriver(options);

    }

4.3 셀레니움을 이용하여 데이터를 크롤링 해오는 방법

  • cssSelector 사용

    : 크롤링 하려는 페이지의 원하는 부분 우클릭 → 검사 클릭 → cssSelector 복사

    findElements(By.cssSelector("복사해온 cssSelector"));

    ex) elements = webDriver.findElements(By.cssSelector("body > main > section > div:nth-child(5) > div > section > ul > li"));

    → cssSelector는 특정 태그나 단순한 클래스/ID를 이용하여 데이터를 찾아올때 유리 (속도 빠름)

  • xPath 사용

    : 크롤링 하려는 페이지의 원하는 부분 우클릭 , 검사 → xPath 복사

    findElements(By.xpath("복사해온 xpath"));

    ex) testExists = webDriver.findElement(By.xpath("//*[contains(text(), '선발절차')]"));

    → xpath는 요소 내 특정 텍스트를 찾는 작업에 유리 (속도 느림)

크롤링을 진행하며 생겼던 오류 & 해결방법

  1. 부트텐트 페이지 창 크기 조절 → 내부 레이아웃이 변하여

    지정해 준 cssSelector가 제대로 작동하지않아 오류 발생

    ➤ 옵션을 통해 창 크기를 고정하여 해결

  2. 각각의 페이지에 접속할때, 로딩시간이 충분하지않아 데이터들을 제대로 불러오지 못함

    Thread.sleep(2000); 명령어를 통해 로딩 시간 확보하여 해결

⚠️ **GitHub.com Fallback** ⚠️