5. 주요 기능 - LikeLionTeam/BootHouse GitHub Wiki
- 등록된 부트 캠프 모두 조회
- 각 부트캠프에서 모집하는 여러 Course들 조회
- 특정 부트캠프 기관에서 모집하는 여러 Course들 조회
- 모집 중인 Course들을 조회
- 선택된 카테고리별, 필터링 기준(비교항목선택)별, 정렬기준별, 검색기능별로 course를 필터링
-Course 상세 보기
- SNS 공유하기 기능
- 모든 리뷰 보기
- 리뷰 검색, 정렬
- 리뷰 상세 보기
- 리뷰 작성하기
- 리뷰 수정/삭제하기
실시간 메시지 교환과 효율적인 메시지 저장/조회입니다. 이를 위해 WebSocket을 통한 실시간 통신과 Redis를 활용한 캐싱 시스템을 구현했습니다.
@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();
}
}@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에 저장되어 빠른 조회가 가능하며, 그 이전의 메시지는 데이터베이스에서 조회됩니다. 이는 실시간 성능과 장기 데이터 보존을 모두 만족시킵니다.
채팅 시스템은 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;
}이 접근 방식은 실시간 메시지 접근의 지연 시간을 최소화하면서도, 모든 대화 기록을 안전하게 보존합니다.
채팅 시스템은 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;
}프론트엔드에서는 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);
}모든 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;
}
}네이버 클라우드 : https://www.ncloud.com/product/applicationService/maps clientKey, ClientSecretKey 발급 후 ,
application.yml 에 추가
naver:
client:
id: 클라이언트 키
secret: 시크릿 키
web Dynamic Map, Static Map , Geocoding 선택!
-
Web Dynamic Map (동적 지도)
: 실시간으로 지도를 표시하고 상호작용할 수 있게함
→ 드래그, 확대/축소 , 마커 등 표시 O
-
Static Map (정적 지도)
: 고정된 이미지를 생성하는 서비스로 사용자와 상호작용 X
→ 특정 위치의 이미지를 불러오는 방식
-
Geocoding
: 주소를 위경도 좌표로 변환 / 위경도 좌표를 주소로 변환
→ 주소를 통하여 위경도를 얻어내, 지도에 표시


- 네이버 공식문서
헤더에 네이버가 지정한 형식의 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;
→ 응답예시를 토대로 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)
);지도의 크기를 조정하여 배치
<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);
}
});: 웹 페이지의 콘텐츠가 자바스크립트에 의해 동적으로 변화할 때 사용하는 도구
의존성 추가
implementation 'org.seleniumhq.selenium:selenium-java'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);
}-
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는 요소 내 특정 텍스트를 찾는 작업에 유리 (속도 느림)
크롤링을 진행하며 생겼던 오류 & 해결방법
-
부트텐트 페이지 창 크기 조절 → 내부 레이아웃이 변하여
지정해 준 cssSelector가 제대로 작동하지않아 오류 발생
➤ 옵션을 통해 창 크기를 고정하여 해결
-
각각의 페이지에 접속할때, 로딩시간이 충분하지않아 데이터들을 제대로 불러오지 못함
➤
Thread.sleep(2000);명령어를 통해 로딩 시간 확보하여 해결