현재 여행 관련 서비스를 제공하는 웹 프로젝트를 진행 중이다.
서블릿과 jsp를 사용하여 틀을 만들었다.
우선 지금까지 만든 정보를 기록하려고 한다.
웹 서블릿과 jsp를 중점으로 플젝을 진행한 적은 처음이라 아직도 어색하고 부족한 부분이 많았다.
그래도 확실히 로우레벨로 개발을 하다 보니 기초가 많이 늘었다고 생각한다.
이후 이걸 프레임워크를 사용하여 더욱 발전시킬 생각이다.
프로젝트 내용이 많아서 전체적으로 요약하며 설명할 예정이다.
주요 기능
header와 footer를 사용하여 중복되는 화면을 재사용하였다.
다른 jsp에서 include를 사용하여 그대로 가져갈 수 있다.
또한 로그인과 회원가입의 경우 간단하게 modal 창을 사용하였다.
이전 프로젝트는 백엔드만 맡아서 진행하였기에 검증의 경우도 백만 신경 쓰면 되었는데, 이번에는 풀스택으로 진행하다 보니 프론트 단에서 검증을 하기에 작업량이 배로 늘었다.
로그인 창도 간단하게 작업하였다.
우선 카카오맵을 활용하여 관광지를 검색할 수 있는 기능을 적용했다.
관광지 정보는 공공데이터를 활용하였으며 그 데이터들을 DB에 넣고 관리하였다.
실제 사용 예시이다.
카카오 맵에 있는 기능을 많이 사용하였는데, 우선 맵 처음 보이는 마크 클러스터러를 사용하였다.
클러스터러는 데이터들을 해당 지역에 몇 개가 모여있는지 확인할 수 있다.
클러스터러는 카카오 맵 공식 문서에서 확인할 수 있다.
이후에 검색한 결과를 비동기통신을 이용하여 데이터를 가져왔다.
그 결과를 지도에 표시해 주었다.
대략적인 코드를 보여주면 아래와 같다.
비동기 통신을 통해 데이터를 가져오고 이를 지도에 표시하는 작업을 수행한다.
document.getElementById("btn-search").addEventListener("click", () => {
let baseUrl = `http://localhost:8080/enjoytrip/trip?action=search`;
let areaCode = document.getElementById("search-area").value;
let gugun = document.getElementById("search-gugun-id").value;
let contentTypeId = document.getElementById("search-content-id").value;
let keyword = document.getElementById("search-keyword").value;
let queryString = ``;
if (parseInt(areaCode)) queryString += `&areaCode=\${areaCode}`;
if (parseInt(gugun)) queryString += `&gugun=\${gugun}`;
if (parseInt(contentTypeId)) queryString += `&contentTypeId=\${contentTypeId}`;
queryString += `&keyword=\${keyword}`;
let searchUrl = baseUrl + queryString;
console.log(searchUrl);
fetch(searchUrl)
.then((response) => response.json())
.then((data) => makeList(data));
});
function makeList(data) {
bounds = new kakao.maps.LatLngBounds();
if (clusterer) {
clusterer.clear();
}
// 클러스터 마커를 클릭했을 때 클릭된 클러스터 마커의 위치를 기준으로 지도를 1레벨씩 확대합니다
clusterer = new kakao.maps.MarkerClusterer({
map: map, // 마커들을 클러스터로 관리하고 표시할 지도 객체
averageCenter: true, // 클러스터에 포함된 마커들의 평균 위치를 클러스터 마커 위치로 설정
minLevel: 10, // 클러스터 할 최소 지도 레벨
});
for (var i = 0; i < mapPositions.length; i++) {
mapPositions[i].setMap(null);
}
console.log(data);
document.querySelector("table").setAttribute("style", "display: ;");
let trips = data;
let tripList = ``;
positions = [];
trips.forEach((area) => {
if(area.firstImage === ""){
area.firstImage = `assets/img/icons/noimage.jpg`
}
tripList += `
<tr onclick="moveCenter(\${area.mapY}, \${area.mapX});">
<td><img src="\${area.firstImage}" width="100px"></td>
<td>\${area.title}</td>
<td>\${area.addr1} \${area.addr2}</td>
</tr>
`;
let markerInfo = {
addr: area.addr1 + area.addr2,
imageUrl: area.firstImage,
contentType: area.contentType,
title: area.title,
latlng: new kakao.maps.LatLng(area.mapY, area.mapX),
contentId: area.contentId
};
bounds.extend(markerInfo.latlng);
positions.push(markerInfo);
});
document.getElementById("trip-list").innerHTML = tripList;
displayMarker();
}
function setBounds() {
// LatLngBounds 객체에 추가된 좌표들을 기준으로 지도의 범위를 재설정합니다
// 이때 지도의 중심좌표와 레벨이 변경될 수 있습니다
map.setBounds(bounds);
map.setLevel(12); // 10은 원하는 확대 수준입니다.
}
function displayMarker() {
var imageSrc = {
12: "./assets/img/icons/eiffel-tower.png", // 관광지 SVG 파일 경로
14: "./assets/img/icons/museum.png", // 문화시설
15: "./assets/img/icons/music.png", // 축제
25: "./assets/img/icons/road.png", //여행코스
28: "./assets/img/icons/kitesurfing.png", // 레포츠
32: "./assets/img/icons/room.png", // 숙박
38: "./assets/img/icons/trolley-cart.png", //쇼핑
39: "./assets/img/icons/restaurant.png", // 음식
};
for (var i = 0; i < positions.length; i++) {
var imageSize = new kakao.maps.Size(24, 35);
var markerImage = new kakao.maps.MarkerImage(imageSrc[positions[i].contentType], imageSize);
var marker = new kakao.maps.Marker({
map: map,
position: positions[i].latlng,
title: positions[i].title,
image: markerImage,
contentId: positions[i].contentId
});
clusterer.addMarker(marker);
// 마커 클릭 이벤트 처리
(function (marker, title, imageUrl, addr, contentId) {
kakao.maps.event.addListener(marker, "click", function () {
var isFavorite = favoritePlaces.some(function (place) {
return place.title === title;
});
var favoriteButton = "";
if (isFavorite) {
favoriteButton =
"<button class=\"btn btn-primary btn-md mb-3 mt-3 border-0\" style=\"width:105px; font-size:12px\" onclick=\"removeFromFavorites('" +
title +
"')\">즐겨찾기 제거</button>";
} else {
favoriteButton =
"<button class=\"btn btn-primary btn-md mb-3 mt-3 border-0\" style=\"width:105px; font-size:12px\" onclick=\"addToFavorites('" +
title +
"', '" +
imageUrl +
"', '" +
addr +
"', '" +
contentId +
"')\">즐겨찾기 추가</button>";
}
var detailButton = "<button class=\"btn btn-primary btn-md me-md-2 mb-3 mt-3 border-0\" data-bs-whatever=\"" + contentId + "\" data-bs-toggle=\"modal\" data-bs-target=\"#detailModal\" style=\"width:90px; font-size:12px\">자세히 보기</button>";
var content = `
<div style="padding: 20px; background-color: #fff; border: 1px solid #ccc; border-radius: 5px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); width: 250px;">
<img src="\${imageUrl}" style="width: 100%; margin-bottom: 10px;">
<h3 style="font-size: 18px; margin-bottom: 5px;">\${title}</h3>
<h2 style="font-size: 16px; color: #666;">\${addr}</h2>
\${favoriteButton}
\${detailButton}
</div>
`;
var infowindow = new kakao.maps.InfoWindow({
content: content,
removable: true,
});
infowindows.push(infowindow)
infowindows.forEach(i => i.close());
infowindow.open(map, marker);
});
})(marker, positions[i].title, positions[i].imageUrl, positions[i].addr, positions[i].contentId);
mapPositions.push(marker);
}
setBounds();
}
검색 버튼에 클릭 이벤트를 걸어두어 검색 버튼을 눌렀을 때, 해당 정보들을 가지고 서버에 Get 요청을 하였다.
이후에는 마커를 만드는 작업을 수행한다.
마커를 만들 때도 각각의 마커에 클릭이벤트를 걸어두어 클릭 시 위의 사진처럼 정보를 확인할 수 있다.
또한 마커도 관광지 유형에 따라 다르게 보이도록 설정해주었다.
서버에서 데이터를 보내는 코드
TripController
import com.enjoytrip.member.model.dto.MemberDto;
import com.enjoytrip.trip.model.dto.*;
import com.enjoytrip.trip.model.service.TripService;
import com.enjoytrip.trip.model.service.TripServiceImpl;
import com.enjoytrip.util.ChangeParam;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@WebServlet("/trip")
public class TripController extends HttpServlet {
private ObjectMapper mapper;
private TripService tripService;
@Override
public void init(ServletConfig config) throws ServletException {
tripService = TripServiceImpl.getTripService();
mapper = new ObjectMapper();
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
process(request, response);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
request.setCharacterEncoding("utf-8");
process(request, response);
}
private void process(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String action = request.getParameter("action");
String path = "";
try {
if ("search".equals(action)) {
doSearch(request, response);
}
/**
다른 작업은 생략
*/
else {
redirect(request, response, path);
}
} catch (Exception e) {
e.printStackTrace();
forward(request, response, path);
}
}
private void doSearch(HttpServletRequest request, HttpServletResponse response) throws SQLException, IOException {
int areaCode = ChangeParam.changeNumericValue(request.getParameter("areaCode"));
int gugun = ChangeParam.changeNumericValue(request.getParameter("gugun"));
int contentType = ChangeParam.changeNumericValue(request.getParameter("contentTypeId"));
String keyword = ChangeParam.changeStringValue(request.getParameter("keyword"));
SearchRequestDto requestDto = new SearchRequestDto(areaCode, gugun, contentType, keyword);
List<SearchResponseDto> result = tripService.search(requestDto);
tripService.increaseSearchCount(result.size(), requestDto);
mappingJson(response, result);
}
private void forward(HttpServletRequest request, HttpServletResponse response, String path)
throws ServletException, IOException {
RequestDispatcher dispatcher = request.getRequestDispatcher(path);
dispatcher.forward(request, response);
}
private void redirect(HttpServletRequest request, HttpServletResponse response, String path) throws IOException {
response.sendRedirect(request.getContextPath() + path);
}
private void mappingJson(HttpServletResponse response, Object object) throws IOException {
String json = mapper.writeValueAsString(object);
response.setContentType("application/json");
PrintWriter writer = response.getWriter();
writer.println(json);
}
}
위 요청을 단순히 url로 실행하면 아래와 같은 정보를 준다.
이때 비동기 통신은 페이지가 변화하는 것이 아니기에 단순히 데이터를 json으로 파싱 해서 넘겨주었다.
TripDAO
원래 서비스를 통해 dao를 호출하는 코드지만 서비스는 단순 dao 호출이므로 생략하였다.
import com.enjoytrip.trip.model.dto.*;
import com.enjoytrip.util.DBUtil;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class TripDaoImpl implements TripDao {
public static final TripDao tripDao = new TripDaoImpl();
private final DBUtil dbUtil = DBUtil.getInstance();
private TripDaoImpl() {
}
public static TripDao getTripDao() {
return tripDao;
}
@Override
public List<SearchResponseDto> search(SearchRequestDto requestDto) throws SQLException {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
List<SearchResponseDto> resultList = new ArrayList<>();
try {
conn = dbUtil.getConnection();
StringBuilder sql = new StringBuilder();
sql.append("select content_id, content_type_id, title, addr1, addr2, first_image, latitude, longitude\n");
sql.append("from attraction_info\n");
sql.append("where 1 = 1 ");
if (requestDto.getAreaCode() != 0) {
sql.append("and sido_code = ? ");
}
if (requestDto.getGugun() != 0) {
sql.append("and gugun_code = ? ");
}
if (requestDto.getContentType() != 0) {
sql.append("and content_type_id = ? ");
}
if (!requestDto.getKeyword().isEmpty()) {
sql.append("and title like ? ");
}
pstmt = conn.prepareStatement(sql.toString());
int parameterIndex = 1;
if (requestDto.getAreaCode() != 0) {
pstmt.setInt(parameterIndex++, requestDto.getAreaCode());
}
if (requestDto.getGugun() != 0) {
pstmt.setInt(parameterIndex++, requestDto.getGugun());
}
if (requestDto.getContentType() != 0) {
pstmt.setInt(parameterIndex++, requestDto.getContentType());
}
if (!requestDto.getKeyword().isEmpty()) {
pstmt.setString(parameterIndex++, "%" + requestDto.getKeyword() + "%");
}
rs = pstmt.executeQuery();
while (rs.next()) {
SearchResponseDto dto = new SearchResponseDto();
dto.setContentId(rs.getInt("content_id"));
dto.setContentType(rs.getInt("content_type_id"));
dto.setTitle(rs.getString("title"));
dto.setAddr1(rs.getString("addr1"));
dto.setAddr2(rs.getString("addr2"));
dto.setFirstImage(rs.getString("first_image"));
dto.setMapY(rs.getBigDecimal("latitude"));
dto.setMapX(rs.getBigDecimal("longitude"));
resultList.add(dto);
}
} finally {
dbUtil.close(rs, pstmt, conn);
}
return resultList;
}
/**
다른 기능 생략
*/
}
Connection을 받고 쿼리를 작성하여 실행하는 코드이다.
이때 DB에 연결하는 과정이 반복되기에 이를 따로 static으로 빼두었다.
DBUtil
앞서 말했듯이 DB에 연결하는 과정이 반복되는데 이를 따로 분리하여 관리하였다.
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class DBUtil {
private static final String DRIVER = "com.mysql.cj.jdbc.Driver";
private static final String URL = "jdbc:mysql://localhost:3306/데이터베이스?serverTimezone=UTC";
private static final String DB_ID = "아이디";
private static final String DB_PWD = "비밀번호";
private static DBUtil instance = new DBUtil();
private DBUtil() {
try {
Class.forName(DRIVER);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
public static DBUtil getInstance() {
return instance;
}
public Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, DB_ID, DB_PWD);
}
public void close(AutoCloseable... autoCloseables) {
for (AutoCloseable ac : autoCloseables) {
if (ac != null) {
try {
ac.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
close부분을 보면 가변 파라미터를 사용하였다.
이유는 select의 경우에만 ResultSet이 필요하기에 닿아야 하는 객체의 개수가 동적으로 변하기 때문이다.
즐겨찾기를 누르면 옆의 화면에 추가된다.
그리고 입력 칸을 사용하여 제목과 내용을 적을 수 있다.
또한 이걸 다른 사람에게 공개 혹은 비공개 설정을 통해 접근을 컨트롤할 수 있게 만들었다.
이처럼 공개로 담기를 누르면 다른 사람도 즐겨찾기 목록에서 확인해 볼 수 있다.
이처럼 내가 작성한 부분은 star 표시를 통해 구분할 수 있고, 다른 사람이 공개로 즐겨찾기 한 목록은 볼 수 있다.
반대로 비공개는 보이지 않는다.
즐겨찾기 목록에서 해당 즐겨찾기 글을 누르면 담아둔 정보를 볼 수 있다.
즐겨찾기 담는 코드
<script>
document.getElementById("favorite-button").addEventListener("click", function () {
var isLoggedIn = <%=sessionValue != null%>;
// 만약 로그인되지 않은 경우 알림창 표시 후 로직 무시
if (!isLoggedIn) {
alert('로그인이 필요합니다.');
return;
}
var subject = document.getElementById("subject").value;
var content = document.getElementById("content").value;
var openOk = document.querySelector('input[name="open"]:checked').value;
var body = {
subject: subject,
content: content,
contentIds: contentIds,
open: openOk
};
fetch("${root}/trip?action=addFavorite", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
.then(response => {
if (!response.ok) {
throw new Error("서버 오류 발생");
}
return response.json();
})
.catch(error => {
console.error("에러 발생:", error);
// 오류 처리를 수행합니다.
});
alert('즐겨찾기에 등록되었습니다.');
console.log(JSON.stringify(contentIds));
location.reload(true);
});
</script>
Controller
private void doAddFavorite(HttpServletRequest request, HttpServletResponse response) throws IOException, SQLException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
reader.close();
String jsonString = sb.toString();
HttpSession session = request.getSession();
MemberDto memberDto = (MemberDto) session.getAttribute("login");
if (memberDto != null) {
ObjectMapper objectMapper = new ObjectMapper();
List<FavoritePlaceDto> favoritePlaces = new ArrayList<>();
Map<String, Object> jsonMap = objectMapper.readValue(jsonString, new TypeReference<>() {
});
List<String> contentIds = (List<String>) jsonMap.get("contentIds");
if (contentIds != null) {
for (String contentId : contentIds) {
FavoritePlaceDto favoritePlace = new FavoritePlaceDto();
favoritePlace.setContentId(contentId);
favoritePlaces.add(favoritePlace);
}
}
FavoriteDetailDto detailDto = new FavoriteDetailDto();
detailDto.setTitle((String) jsonMap.get("subject"));
detailDto.setContent((String) jsonMap.get("content"));
detailDto.setOpen(Integer.parseInt((String) jsonMap.get("open")));
tripService.addFavorite(favoritePlaces, detailDto, memberDto.getId());
}
}
마커를 누르면 자세히 보기라는 버튼이 있는 것을 볼 수 있다.
이를 누르면 모달창을 통해 상세 정보를 볼 수 있도록 만들었다.
조회수가 존재하는데, 모달창이 열리는 순간 비동기 통신을 통해 조회수를 늘리도록 설정하였다.
현재 평점이 0점인 이유는 리뷰가 1개도 없기 때문이다.
이후 여러 리뷰를 작성하면 아래와 같이 변하게 된다.
평점의 경우에는 round를 사용하여 소수점 1자리까지 나타내었다.
DetailModal
자세히 보기를 할 경우 조회수를 증가하고, 평점을 가져오고, 나머지 정보를 가져와서 화면을 동적으로 수행하는데, 이때 비동기통신이 3번 일어나게 된다. 이는 추후 최적화가 필요해 보인다.
<div class="modal" id="detailModal" data-bs-backdrop="static"
data-bs-keyboard="false" tabindex="-1"
aria-labelledby="staticBackdropLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" style="max-width: 80%">
<div class="modal-content"
style="background-color: aliceblue; position: relative; padding-right: 30px">
<button type="button" class="btn" data-bs-toggle="modal"
data-bs-whatever="" data-bs-target="#commentWriteModal" id="back"
data-bs-backdrop="false"
style="position: absolute; top: 20px; right: 190px; border: 1px black solid;">리뷰
작성</button>
<button type="button" class="btn" data-bs-toggle="modal"
data-bs-whatever="" data-bs-target="#commentViewModal"
id="viewModal"
style="position: absolute; top: 20px; right: 90px; border: 1px black solid;">리뷰
보기</button>
<button type="button" class="btn-close"
style="position: absolute; top: 20px; right: 20px"
data-bs-dismiss="modal"></button>
<div class="row">
<div class="col" id="detailList">
<script>
var modal = document.getElementById('detailModal');
var contentId;
modal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
contentId = button.getAttribute('data-bs-whatever');
var button2 = document.getElementById('back');
button2.setAttribute('data-bs-whatever', contentId);
var button3 = document.getElementById('viewModal');
button3.setAttribute('data-bs-whatever', contentId);
fetch("${root}/trip?action=increaseReadCount&contentId=" + contentId);
var score = 0;
fetch("${root}/trip?action=getAvgScore&contentId=" + contentId)
.then(response => response.json()) // JSON 형식으로 변환
.then(data => {
score = data.score; // 가져온 데이터에서 score 값 추출
console.log("평균 점수: " + score);
})
.catch(error => {
console.error('데이터 가져오기 실패:', error);
});
fetch("${root}/trip?action=getDetail&contentId=" + contentId)
.then((response) => response.json())
.then((data) => makeDetail(data, score));
})
function makeDetail(data, score) {
document.getElementById("detailList").innerHTML = '';
console.log(data);
// containers
const detailList = document.getElementById("detailList");
detailList.setAttribute("value", data.contentId);
const imgContainer = document.createElement("div");
const infoContainer = document.createElement("div");
const title = document.createElement("h3");
const addr = document.createElement("p");
const readcount = document.createElement("p");
const star = document.createElement("p");
const img = document.createElement("img");
const content = document.createElement("p");
// 아이콘 이미지 생성
const locationIcon = document.createElement("img");
locationIcon.src = "assets/img/icons/location.png";
locationIcon.style.width = "1.2rem";
const contentIcon = document.createElement("img");
contentIcon.src = "assets/img/icons/content.png";
contentIcon.style.width = "1.2rem";
const eyeIcon = document.createElement("img");
eyeIcon.src = "assets/img/icons/eye.png";
eyeIcon.style.width = "1.2rem";
const starIcon = document.createElement("img");
starIcon.src = "assets/img/icons/star.png";
starIcon.style.width = "1.2rem";
// 아이콘과 텍스트를 감싸는 div
const addrContainer = document.createElement("div");
addrContainer.appendChild(locationIcon);
addrContainer.appendChild(addr);
addrContainer.style.display = "flex";
addrContainer.style.alignItems = "flex-start";
const contentContainer = document.createElement("div");
contentContainer.appendChild(contentIcon);
contentContainer.appendChild(content);
contentContainer.style.display = "flex";
contentContainer.style.alignItems = "flex-start";
const readcountContainer = document.createElement("div");
readcountContainer.appendChild(eyeIcon);
readcountContainer.appendChild(readcount);
readcountContainer.style.display = "flex";
readcountContainer.style.alignItems = "flex-start";
const scoreContainer = document.createElement("div");
scoreContainer.appendChild(starIcon);
scoreContainer.appendChild(star);
scoreContainer.style.display = "flex";
scoreContainer.style.alignItems = "flex-start";
detailList.style.display = "flex";
detailList.style.flexDirection = "column";
detailList.style.padding = "20px";
// 자식 요소들 가운데 정렬
title.style.textAlign = "left";
addr.style.textAlign = "left";
addr.style.margin = "0px 0px 15px 0px";
readcount.style.textAlign = "left";
readcount.style.margin = "0px 0px 15px 0px";
// 이미지 좌우 여백 주기
img.style.margin = "20px 20px 40px 40px";
img.style.width = "35rem";
// title
title.textContent = data.title;
// address
addr.textContent = "주소: " + data.addr1 + data.addr2;
// readcount
readcount.textContent = "조회수: ";
if (data.readCount === undefined) {
readcount.textContent += 0;
} else {
readcount.textContent += data.readCount;
}
star.textContent += "평점: " + score + " / 5 ";
// image
img.src = data.firstImage;
// content
content.textContent = "설명 : " + data.content;
content.style.margin = "0px 0px 15px 0px";
detailList.style.display = "flex";
detailList.style.flexDirection = "row";
imgContainer.style.cssFloat = "left";
infoContainer.style.cssFloat = "right";
detailList.appendChild(imgContainer);
detailList.appendChild(infoContainer);
imgContainer.appendChild(img);
infoContainer.appendChild(title);
infoContainer.appendChild(addrContainer);
infoContainer.appendChild(readcountContainer);
infoContainer.appendChild(scoreContainer);
infoContainer.appendChild(contentContainer);
}
function passValueToModal() {
var detailList = document.getElementById('detailList');
var value = detailList.getAttribute('value');
console.log(value);
var button = document.getElementById('back');
button.setAttribute('data-bs-whatever', value);
}
</script>
</div>
</div>
</div>
</div>
</div>
index 페이지를 이쁘게 만들고 싶었다.
그래서 기능을 고민하다가 랜덤 하게 6개의 관광지 정보를 가져오도록 비동기 통신을 하였다.
새로고침을 하면 다음과 같이 랜덤으로 변한다.
다만 이러한 작업을 수행하면 index 페이지에서 계속해서 데이터베이스 요청이 일어나기에 비효율적이라고 생각하였다.
이는 이후 하루단위로 관광지 6개를 받아온 뒤 쿠키를 사용하여 캐싱하는 방법으로 수정할 예정이다.
또한 랜덤으로 나온 6개를 누르면 그에 대한 자세한 정보가 모달창으로 나오게 된다.
우선 현재 6개를 화면에 보여주는 코드이다.
index.jsp
<!-- 중간 생략 -->
<div style="height: 200px"></div>
<section class="pt-5" id="destination">
<div class="container">
<div
class="position-absolute start-100 bottom-0 translate-middle-x d-none d-xl-block ms-xl-n4">
<img src="assets/img/dest/shape.svg" alt="destination" />
</div>
<div class="mb-7 text-center">
<h5 class="text-secondary">TODAY</h5>
<h3 class="fs-xl-10 fs-lg-8 fs-7 fw-bold text-capitalize">오늘의
국내 관광지</h3>
</div>
<div class="container">
<div id="cardContainer" class="row">
<!-- 여기에 카드가 추가될 것입니다 -->
</div>
</div>
</div>
<!-- end of .container-->
</section>
<!-- 중간 생략 -->
<script>
fetch("${root}/trip?action=getBestTrip")
.then((response) => response.json())
.then((data) => parsing(data));
function parsing(data) {
const cardContainer = document.getElementById("cardContainer");
// Iterate over each trip data and create a card for each
data.forEach((trip) => {
// Create card elements
const cardColumn = document.createElement("div");
cardColumn.className = "col-md-4 mb-4";
const card = document.createElement("div");
card.className = "card overflow-hidden shadow";
const cardImage = document.createElement("img");
cardImage.className = "card-img-top";
cardImage.src = trip.firstImage;
cardImage.alt = trip.title;
const cardBody = document.createElement("div");
cardBody.className = "card-body py-4 px-3";
const cardTitleWrapper = document.createElement("div");
cardTitleWrapper.className = "row mb-3";
const cardTitle = document.createElement("h4");
cardTitle.className = "col-md-12 text-secondary fs-1 fw-400";
const cardTitleLink = document.createElement("a");
cardTitleLink.className = "link-900 text-decoration-none stretched-link";
cardTitleLink.href = "#";
cardTitleLink.setAttribute("data-bs-whatever", trip.contentId); // 해당 데이터의 contentId로 설정
cardTitleLink.setAttribute("data-bs-toggle", "modal");
cardTitleLink.setAttribute("data-bs-target", "#detailModal");
cardTitleLink.textContent = trip.title;
const visitorCount = document.createElement("span");
visitorCount.className = "col-md-12 fs-1 fw-medium text-end";
visitorCount.textContent = trip.readCount + "명";
const tripDuration = document.createElement("div");
tripDuration.className = "d-flex align-items-center";
const tripDurationImage = document.createElement("img");
tripDurationImage.src = "assets/img/dest/navigation.svg";
tripDurationImage.style.marginRight = "14px";
tripDurationImage.width = "20";
tripDurationImage.alt = "navigation";
const tripDurationText = document.createElement("span");
tripDurationText.className = "fs-0 fw-medium";
tripDurationText.textContent = "Detail";
// Append elements
cardTitle.appendChild(cardTitleLink);
cardTitleWrapper.appendChild(cardTitle);
cardTitleWrapper.appendChild(visitorCount);
tripDuration.appendChild(tripDurationImage);
tripDuration.appendChild(tripDurationText);
cardBody.appendChild(cardTitleWrapper);
cardBody.appendChild(tripDuration);
card.appendChild(cardImage);
card.appendChild(cardBody);
cardColumn.appendChild(card);
cardContainer.appendChild(cardColumn);
});
}
</script>
TripDao
앞에 있던 TripDao와 동일한 클래스에 위치해 있다.
또한 서블릿도 동일하게 trip으로 받는다.
@Override
public List<DetailResponseDto> getBestTrip() throws SQLException {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
List<DetailResponseDto> result = new ArrayList<>();
try {
conn = dbUtil.getConnection();
StringBuilder sql = new StringBuilder();
sql.append("select title, addr1, addr2, first_image,readcount, i.content_id\n");
sql.append("from attraction_info i \n");
sql.append("join attraction_description d on i.content_id = d.content_id\n");
sql.append("where content_type_id = 12\n");
sql.append("and first_image != ''\n");
sql.append("and readcount > 1000\n");
sql.append("order by rand() limit 6\n");
pstmt = conn.prepareStatement(sql.toString());
rs = pstmt.executeQuery();
while (rs.next()) {
DetailResponseDto requestDto = new DetailResponseDto();
requestDto.setReadCount(rs.getInt("readcount"));
requestDto.setTitle(rs.getString("title"));
requestDto.setAddr1(rs.getString("addr1"));
requestDto.setAddr2(rs.getString("addr2"));
requestDto.setFirstImage(rs.getString("first_image"));
requestDto.setContentId(rs.getInt("content_id"));
result.add(requestDto);
}
} finally {
dbUtil.close(rs, pstmt, conn);
}
return result;
}
또한 실시간 검색어 순위 시스템을 만들었다.
현재 아쉬운 점은 누적 검색어가 높은 순위로 보이도록 하였는데, 이러면 실시간이라는 의미가 떨어지기에 나중에 스케줄링을 적용하는 방식을 적용하여 1시간 단위로 초기화 작업이 필요해 보인다.
해당 순위에 해당하는 이미지를 누르면 검색 결과페이지로 이동한다.
검색어의 경우 이전에 검색을 하는 과정에서 검색버튼을 누를 때 데이터베이스에 해당 데이터를 증가시키도록 코드를 추가해 두었다.
또한 인덱스페이지 아래에는 베스트 게시글과 랜덤 하게 댓글 3개가 동적으로 바뀌도록 만들었다.
베스트 게시글의 경우 조회수가 가장 많은 게시글이 보이도록 동적으로 설정해 두었다.
이것 또한 인덱스 페이지로 올 때마다 계속해서 데이터베이스에서 비동기적으로 호출하기에 하루단위로 캐싱하는 방식을 사용해야겠다.
게시글의 경우 본인글만 글수정과 삭제가 보이도록 설정해 주었다.
댓글도 마찬가지로 랜덤 하게 3개가 나오는데 이 또한 최적화 포인트가 보인다.
댓글을 누르면 자세히 보기가 나오도록 설정해 주었다.
랜덤 댓글 코드
fetch("${root}/trip?action=getBestComment")
.then(response => response.json())
.then(data => {
var count = 0;
var carousel = document.getElementById("carousel-inner");
data.forEach(function (item2) {
const starIcon = 'assets/img/icons/yellowstar.png';
let stars = document.createElement('span');
for (let i = 0; i < item2.score; i++) {
let img = document.createElement('img');
img.src = starIcon;
img.style.width = '15px';
stars.appendChild(img);
}
let citem = document.createElement('div');
citem.classList.add('carousel-item', 'postion-relative');
if (count === 0) citem.classList.add('active');
let card = document.createElement('div');
card.classList.add('card', 'shadow');
card.style.borderRadius = '10px';
let row4 = document.createElement('div');
row4.classList.add('position-absolute', 'end-0', 'top-0');
row4.style.margin = "0px 15px 0px 0px";
let image = document.createElement('img');
image.src = item2.firstImage;
image.className = 'rounded-circle fit-cover';
image.width = 150;
image.height = 150;
card.appendChild(row4);
row4.appendChild(image);
let cardBody = document.createElement('div');
cardBody.classList.add('card-body', 'p-4');
let para1 = document.createElement('p');
para1.classList.add('fw-medium', 'mb-4');
para1.textContent = item2.comment;
let title = document.createElement('h5');
title.classList.add('text-secondary');
title.textContent = item2.memberName;
let para2 = document.createElement('p');
para2.classList.add('fw-medium', 'fs--1', 'mb-0');
para2.textContent = item2.score;
const cardTitleLink = document.createElement("a");
cardTitleLink.className = "link-900 text-decoration-none stretched-link";
cardTitleLink.href = "#";
cardTitleLink.setAttribute("data-bs-whatever", item2.contentId); // 해당 데이터의 contentId로 설정
cardTitleLink.setAttribute("data-bs-toggle", "modal");
cardTitleLink.setAttribute("data-bs-target", "#detailModal");
cardBody.appendChild(para1);
cardBody.appendChild(title);
cardBody.appendChild(stars);
card.appendChild(cardBody);
let shadowCard = document.createElement('div');
shadowCard.className = 'card shadow-sm position-absolute top-0 z-index--1 mb-3 w-100 h-100';
shadowCard.style.borderRadius = '10px';
shadowCard.style.transform = 'translate(25px, 25px)';
citem.appendChild(card);
citem.appendChild(shadowCard);
citem.appendChild(cardTitleLink);
carousel.appendChild(citem);
});
})
후기
이번에 서블릿과 JSP만을 사용하여 개발해 본 적이 처음인데 확실히 풀스택으로 개발을 하니 어렵긴 하였지만 그만큼 재미있었다.
프론트엔드를 이번에 처음 해보았는데, 이전에 몰랐던 지식을 많이 얻었다고 생각한다.
또한 카카오맵을 활용한 개발이 직관적이고 이전의 프로젝트보다는 더 재미있게 느껴졌다.
하지만 아쉬운 부분도 많기 때문에 프레임워크를 적용하여 더 성능적으로나 기능적으로나 개선할 예정이다.
더 개발한 이후에 또다시 포스팅으로 돌아와야겠다.
카카오 맵
https://apis.map.kakao.com/web/sample/