지난 시간에는 가벼운 정보만 담겨있는 샘플 CSV파일을 파싱해보는 과정을 포스팅했다.
(지난 게시글 보러가기)
이번 포스팅은 샘플자료가 아닌 실제 사용할 데이터가 담긴 CSV을 파싱하여 DB에 저장하는 과정과, 구현하지 않았던
findByAddr(), findByLocation(), findAll() 함수를 구현하는 과정이다. 바로 시작해보자.
'Save 함수' 수정
우선 저번 시간에 만들었던 save 함수를 수정해야한다.
저번에 만든 로직은 csv 파일의 데이터를 전부 저장하도록 설계했다. 하지만 그대로 실제 데이터가 담긴 CSV 파일을
사용했을 때 데이터 저장과정에서 오류가 발생했다.. 확인해보니 주식별자(PRIMARY KEY) 중복 문제였다.
뭐지 싶어 엑셀파일 확인해보니, 주차장 코드가 같은 주차장이 있었다.
상식적으로 주차장을 구분하는 코드가 같을 수가 없다는 생각에 주차장 코드가 중복 되는 것들끼리 대조해봤다.
그 결과 최신화된 정보와 이전 정보가 공존하고 있어 중복이 발생했던 것이다.
그리고 중복되는 것들 중 findByLocation 함수로 주차장을 찾는 데 꼭 필요한 위도, 경도가 없는 것들이 있었다.
데이터 관리를 알아서 할 거라면 그냥 지우고 쓰겠지만 종종 변경되는 이 데이터를 데이터가 변동될때마다
중복 데이터를 하나씩 찾아서 변경해주는 것은 상당히 비효율적란 생각이 들었다. 그래서 로직을 전부 저장하는 방식
에서 다른 방식으로 변경했다.
1) 만약 위도,경도 컬럼이 null 값이거나 아예 존재자체를 하지 않는다면 저장하지 않고 넘기고,
2) 또 만약 주차장 코드가 중복이라면 저장하지 않고 넘긴다. (CheckDuplicate 함수에서 로직 수행)
이 두가지 모두 해당하지 않는다면 Park 객체로 만들어 parkList에 추가하고 DB에 저장한다.
(parkList는 save함수 로직 수행중 DB에 저장한 것들이 다 저장되어있는 리스트로써 중복 검사에 사용된다.)
JpaParkRepository.save()
@Component
public class JpaParkRepository implements ParkRepository {
private final EntityManager em;
boolean isDuplicate = false;
@Autowired
public JpaParkRepository(EntityManager em) {
this.em = em;
}
@Override
public void save() {
//PARK_DATA 초기화
Query q = em.createQuery("DELETE FROM Park");
q.executeUpdate();
//csv파일의 절대경로 구하기
String path = System.getProperty("user.dir"); //csv파일 path 저장
System.out.println("path = " + path);
//저장했던 Park 객체를 저장하는 리스트 ( 중복 검사에 사용 )
ArrayList<Park> parkList = new ArrayList<>();
FileReader in = null;
BufferedReader bufIn = null;
try {
in = new FileReader(path + "\\src\\main\\java\\jh\\ParkingService\\repository\\park\\data.csv");
bufIn = new BufferedReader(in);
bufIn.readLine(); // 컬럼명은 저장되지 않도록 한 줄 읽기
String data;
do {//파일에서 데이터를 읽어 파싱하고 Park 객체로 만들어 ArrayList에 넣는다.
data = bufIn.readLine(); //한 라인 읽기
if (data != null) {
String[] parkInfo = data.split(","); //콤마로 분리하기
if (parkInfo[28] == null || parkInfo[29] == null || parkInfo[28].isEmpty() || parkInfo[29].isEmpty()) { //읽어온 데이터의 위도, 경도 값이 없거나 null 이면 저장하지 않고 넘김
continue;
} else if (checkDuplicate(parkInfo[0], parkList)) { //주차장 코드가 중복(checkDuplicate 값이 true)이면 저장하지 않고 넘김
continue;
} else { //위의 두 조건에 해당사항이 없으면 데이터를 객체에 저장 후 임시 저장 ArrayList에 삽입
Park park = new Park(); //Park 객체 생성하기
park.setPrkplceNo(parkInfo[0].isEmpty() ? "" : parkInfo[0]); //객체에 값 저장하기
park.setPrkplceNm(parkInfo[1].isEmpty() ? "" : parkInfo[1]);
park.setPrkplceSe(parkInfo[2].isEmpty() ? "" : parkInfo[2]);
park.setPrkplceType(parkInfo[3].isEmpty() ? "" : parkInfo[3]);
park.setRdnmadr(parkInfo[4].isEmpty() ? "" : parkInfo[4]);
park.setLnmadr(parkInfo[5].isEmpty() ? "" : parkInfo[5]);
park.setPrkcmprt(parkInfo[6].isEmpty() ? "" : parkInfo[6]);
park.setFeedingSe(parkInfo[7].isEmpty() ? "" : parkInfo[7]);
park.setEnforceSe(parkInfo[8].isEmpty() ? "" : parkInfo[8]);
park.setOperDay(parkInfo[9].isEmpty() ? "" : parkInfo[9]);
park.setWeekdayOperOpenHhmm(parkInfo[10].isEmpty() ? "" : parkInfo[10]);
park.setWeekdayOperCloseHhmm(parkInfo[11].isEmpty() ? "" : parkInfo[11]);
park.setSatOperOperOpenHhmm(parkInfo[12].isEmpty() ? "" : parkInfo[12]);
park.setSatOperCloseHhmm(parkInfo[13].isEmpty() ? "" : parkInfo[13]);
park.setHolidayOperOpenHhmm(parkInfo[14].isEmpty() ? "" : parkInfo[14]);
park.setHolidayCloseOpenHhmm(parkInfo[15].isEmpty() ? "" : parkInfo[15]);
park.setParkingchrgeInfo(parkInfo[16].isEmpty() ? "" : parkInfo[16]);
park.setBasicTime(parkInfo[17].isEmpty() ? "" : parkInfo[17]);
park.setBasicCharge(parkInfo[18].isEmpty() ? "" : parkInfo[18]);
park.setAddUnitTime(parkInfo[19].isEmpty() ? "" : parkInfo[19]);
park.setAddUnitCharge(parkInfo[20].isEmpty() ? "" : parkInfo[20]);
park.setDayCmmtktAdjTime(parkInfo[21].isEmpty() ? "" : parkInfo[21]);
park.setDayCmmtkt(parkInfo[22].isEmpty() ? "" : parkInfo[22]);
park.setMonthCmmtkt(parkInfo[23].isEmpty() ? "" : parkInfo[23]);
park.setMetpay(parkInfo[24].isEmpty() ? "" : parkInfo[24]);
park.setSpcmnt(parkInfo[25].isEmpty() ? "" : parkInfo[25]);
park.setInstitutionNm(parkInfo[26].isEmpty() ? "" : parkInfo[26]);
park.setPhoneNumber(parkInfo[27].isEmpty() ? "" : parkInfo[27]);
park.setLatitude(parkInfo[28].isEmpty() ? "" : parkInfo[28]);
park.setLongitude(parkInfo[29].isEmpty() ? "" : parkInfo[29]);
park.setReferenceDate(parkInfo[30].isEmpty() ? "" : parkInfo[30]);
park.setInsttCode(parkInfo[31].isEmpty() ? "" : parkInfo[31]);
park.setInsttNm(parkInfo[32].isEmpty() ? "" : parkInfo[32]);
parkList.add(park); //리스트에 Park 객체 저장하기
em.persist(park); //park 객체를 DB에 INSERT
}
}
} while (data != null);
} catch (IOException e) {
System.out.println(e.getMessage());
} catch (EntityExistsException e) {
e.printStackTrace();
} finally {
try {
in.close();
} catch (Exception e) {
}
try {
bufIn.close();
} catch (Exception e) {
}
}
}
private boolean checkDuplicate(String prkplceNo, List<Park> list) {
isDuplicate = false; //중복 여부
list.forEach(data -> { //list에 저장된 객체중 주차장 코드(prkplceNo) 중복이 있는지 검사 (중복이면 true 없으면 false 리턴)
isDuplicate = (data.getPrkplceNo().equals(prkplceNo));
if (isDuplicate) { //중복 발견시 반복문 종료
return;
}
});
System.out.print("prkplceNo = " + prkplceNo);
System.out.println(" isDuplicate = " + isDuplicate);
return isDuplicate; //중복 여부 리턴
}
객체의 컬럼이 33개여서 객체에 데이터 저장을 수행하는 로직이 많이 길어 코드가 더럽다..
클린코드를 열심히 공부해야하는 이유인 것 같다. 프로젝트 중간에도 틈틈히 계속 공부해서 코드를 수정해나갈 것이다.
다음은 findByAddr() 구현이다.
'findByAddr 함수' 구현
findByAddr는 소재지도로명주소, 소재지지번주소, 주차장이름 컬럼 중 입력받은 문자열(addr)을 포함하는 값이 있으면
위도,경도 기준 가까운 거리 순으로 오름차순 정렬하여 리스트 형태로 리턴해주는 형태로 구현했다.
JpaParkRepository.findByAddr()
public List<Park> findByAddr(String addr, String lat, String lng) {
/* 소재지도로명주소, 소재지지번주소, 주차장이름 컬럼 중 입력받은 문자열(addr)을 포함하는 값이 있으면
위도,경도 기준 가까운 거리 순으로 오름차순 정렬하여 리스트 형태로 리턴 */
List<Park> parkList = em.createQuery("SELECT p FROM Park p WHERE p.rdnmadr LIKE ?1 OR p.lnmadr LIKE ?1 OR p.prkplceNm LIKE ?1 ORDER BY abs(p.latitude - ?2) + abs(p.longitude - ?3)")
.setParameter(1, '%'+addr+'%')
.setParameter(2, lat)
.setParameter(3, lng)
.getResultList();
return parkList;
}
JPQL 의 'LIKE 구문'으로 DB에서 포함값을 끌어왔고 'ORDER BY절' 을 통해 가까운 거리 순으로 정렬 시키는데 성공했다.
'?1', '?2', '?3' 은 파라미터이다. 아래의 .setParameter로 각각 숫자에 어떤 값을 대입할 지 입력할 수 있다.
또 JPQL 에서 LIKE 문을 사용하려면 '%' 또는 '_' 를 쿼리문 내부에 사용하는 것이 아니라
.setParameter(1, '%'+addr+'%') 이런 식으로 대입할 값에 적어주어야 정상 작동한다.
JPA를 접한 지 얼마 안 된 사람이라면 참고하면 좋을 것 같다.
'findByLocation 함수' 구현
findByLocation 함수 는 +-n 위도/경도 범위에 있는 주차장들을 찾아서 리스트 형태로 리턴해주도록 구현했다.
JpaParkRepository.findByLocation()
public List<Park> findByLocation(String lat, String lng) {
Double n = 0.02;
// +-n 위도/경도에 있는 주차장 찾아서 리스트 형태 저장
List<Park> parkList = em.createQuery("SELECT p FROM Park p WHERE p.latitude BETWEEN ?1 - ?3 AND ?1 + ?3 AND p.longitude BETWEEN ?2 - ?3 AND ?2 + ?3")
.setParameter(1, Double.parseDouble(lat))
.setParameter(2, Double.parseDouble(lng))
.setParameter(3, n)
.getResultList();
return parkList; //받은 주차장 리스트 리턴
}
JPQL 의 BETWEEN 'A' AND 'B' 구문을 사용해 +-n 범위의 값을 수월하게 구했다.
SQLD 자격증 취득을 위해 SQL을 열심히 공부했었는데 꽤 도움이 되는 것 같다.
.setParameter 부분은 위에서 설명했으니 넘어가도록 하겠다.
'findAll 함수' 구현
마지막으로 구현할 findAll 함수 는 간단한 JPQL 구문으로 쉽게 구현할 수 있었다.
JpaParkRepository.findAll()
public List<Park> findAll() {
//테이블의 모든 주차장 정보 리스트형태로 리턴
return em.createQuery("SELECT p FROM Park p").getResultList();
}
짠! 이게 끝이다. 정말 간단하지 않은가?
테이블에 저장된 모든 주차장 정보를 가져와서 리스트 형태로 리턴하도록 구현했다. JPA 만 있으면 DB 사용이 정말
수월해지는 것 같다. 확실히 공부해볼 가치가 있으니 공부를 고민 중이라면 꼭 해보길 바란다.
이제 이 기능들을 받아서 수행해줄 Service 클래스를 구현해보도록 하자!
'Service 클래스' 구현
Service 클래스는 Repository에서 다 만들어 놓은 것들을 바로 사용할 수 있도록만 해놓으면 되므로 개발이 쉽다.
다른 형태로 수정할 일이 없으니 인터페이스 없이 바로 구현체만 만들 것이다.
ParkServiceImpl.java
@Component
@Transactional
public class ParkServiceImpl {
private ParkRepository parkingRepository;
@Autowired
public ParkServiceImpl(ParkRepository parkingRepository) {
this.parkingRepository = parkingRepository;
}
public void saveData(){
parkingRepository.save();
}
public List<Park> searchAddr(String addr, String lat, String lng){ return parkingRepository.findByAddr(addr, lat, lng); }
public List<Park> searchLotLoc(String lat, String lng){ return parkingRepository.findByLocation(lat, lng); }
public List<Park> searchLots() { return parkingRepository.findAll(); }
}
우선 @Component 어노테이션으로 스프링 컨테이너에 "이건 스프링 빈으로 등록해서 사용할 거야~" 라고 알려주었고
DB에 저장 수정 등의 작업이 일어날 클래스 이므로 @Transactional 어노테이션을 작성했다.
그리고 ParkRepository의 함수들을 사용해야하기 때문에 생성자에 부분에 @Autowired 로 스프링 컨테이너에서 레퍼지토리 구현체를 가져왔다.
저번 시간에 말했다시피 생성자가 하나뿐이라면 @Autowired는 생략 가능하지만 가시성을 위해 적은 점은 참고하기 바란다.
다음으로 Service 클래스의 함수들이다. Repository의 기능을 기반으로 작성되었고 아래는 함수 별 기능이다.
saveData() : ParkRepository.save() 를 실행하는 함수
searchAddr() : ParkRepository.findByAddr() 을 실행하는 함수
searchLotLoc() : ParkRepository.findByLocation() 을 실행하는 함수
searchLots() : ParkRepository.findAll() 을 실행하는 함수
이제 컨트롤러만 만들어주면 완성이다. searchLotLoc() 을 사용할 컨트롤러와 searchAddr() 을 사용할 컨트롤러를
Servlet 으로 구현해보겠다.
'Controller' 구현
먼저 개발한 Controller는 searchLotLoc() 함수를 사용하여 로직을 수행할 컨트롤러이다.
searchLotLoc() 으로 받아온 Park 객체 List를 Jackson 라이브러리의 ObjectMapper를 사용해 Json형태의 String으로
변환하여 클라이언트에게 리턴해주도록 코드를 짜봤다.
ParkDataLocationSearchServlet.java
@WebServlet(name = "parkDataLocationSearchServlet", urlPatterns = "/lots/location")
public class ParkDataLocationSearchServlet extends HttpServlet {
ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private ParkServiceImpl parkingService;
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String latitude = request.getParameter("latitude");
String longitude = request.getParameter("longitude");
System.out.println("longitude = " + longitude);
System.out.println("latitude = " + latitude);
List<Park> parkList = parkingService.searchLotLoc(latitude, longitude);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
PrintWriter out = response.getWriter();
List<String> parkJsonList = new ArrayList<>();
//objectMapper 로 'park(자바객체형태)' -> 'Json 형태'-> 'Json 구문의 String 형태' 순으로 변환하여 String 변수에 저장
//그리고 String List 인 parkJsonList 에 값 저장
for (Park park : parkList) {
String parkJson = objectMapper.writeValueAsString(park);
parkJsonList.add(parkJson);
}
//저장한 parkJsonList 를 클라이언트에게 전달
out.print(parkJsonList);
out.flush();
}
}
위에 말한대로 구현하면서 중간에 'response.setContentType()' 을 사용해 json형태로 콘텐츠 타입을 지정하고
'response.setCharacterEncoding()' 을 통해 UTF-8 로 인코딩했다.
Url패턴은 "/lots/location" 으로 뒤에 ?Parameter=Value 값을 입력해주면 'request.getParameter("Parameter")' 로
Value 값을 받아와 사용할 수 있다.
여기서 파라미터는 latitude(위도) 와 longitude(경도) 를 받는다.
다음으로 searchAddr() 을 사용해 로직을 수행할 컨트롤러를 구현해보자.
ParkDataAddrSearchServlet.java
@WebServlet(name = "parkDataAddrSearchServlet", urlPatterns = "/lots/address")
public class ParkDataAddrSearchServlet extends HttpServlet {
ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private ParkServiceImpl parkingService;
@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
String addr = request.getParameter("addr");
String latitude = request.getParameter("latitude");
String longitude = request.getParameter("longitude");
System.out.println("addr = " + addr);
System.out.println("longitude = " + longitude);
System.out.println("latitude = " + latitude);
List<Park> parkList = parkingService.searchAddr(addr, latitude, longitude);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
PrintWriter out = response.getWriter();
List<String> parkJsonList = new ArrayList<>();
//objectMapper 로 'park(자바객체형태)' -> 'Json 형태'-> 'Json 구문의 String 형태' 순으로 변환하여 String 변수에 저장
//그리고 String List 인 parkJsonList 에 값 저장
for (Park park : parkList) {
String parkJson = objectMapper.writeValueAsString(park);
parkJsonList.add(parkJson);
}
//저장한 parkJsonList 를 클라이언트에게 전달
out.print(parkJsonList);
out.flush();
}
}
Url패턴은 "/lots/address" 이며 파라미터는 addr(검색창 입력값) 과 latitude(위도) 와 longitude(경도) 를 받는다.
위에 구현한 ParkDataLocationSearchServlet.java 와 차이점은 파라미터가 하나 늘어난 것과 url패턴 말고는 없어 중복되는 설명은 자제하겠다.
이제 개발한 프로그램이 정상적으로 작동하는지 확인해보는 일만 남았다. 프론트와의 통신을 통해 결과를 확인해보자.
작동 테스트
우선 ParkDataLocationSearchServlet 을 테스트 해보았다. 파라미터에 latitude 와 longitude 값을 기입해야 하기 때문에 임의로 설정하여 요청을 보내보았다.
다행히 생각했던 대로 성공적으로 출력되었다. 이제 ParkDataAddrSearchSevlet 테스트를 해보고 이번 포스팅을 마치겠다.
ParkDataLocationSearchServlet 은 파라미터가 addr, latitude, longitude 이렇게 3개가 들어간다.
결과 확인을 위해 임의로 정한 값을 파라미터에 넣어 요청을 보내보았고 결과는 성공적이었다.
이번에도 의도한 결과대로 결과가 출력되었다. 하지만 하나 더 확인해 볼 것이 있다.
과연 내가 입력한 위도와 경도를 기준으로 가까운 순서대로 출력이 잘 되었을까?
이 로직에 사용 된 JPQL 과 동일한 SQL 구문을 통해 직접 MySQL DB 에서 실험해보았다.
SELECT *, abs(latitude-37.6)+abs(longitude-126.8) as d FROM park_data p WHERE p.latitude BETWEEN 37.6 -0.001 AND 37.621036529541 +0.001 AND p.longitude BETWEEN 126.8 -0.001 AND 126.83155822753906 +0.001 ORDER BY d;
코드는 이러하다. 임의의 값으로 아무렇게나 주차장이 여러개 나오도록 설정한 SQL문이다.
어찌됐던 latitude와 longitude 를 사용해 가까운 거리에 있는 주차장 순서로 정렬하면 되지 않는가.
d는 "latitude에서 파라미터로 입력받은 latitude를 뺀 절대값 + longitude에서 파라미터로 입력받은 longitude를 뺀 절대값" 이다. "즉 d는 입력받은 위도 경도로부터 주차장까지의 거리" 라고 생각하면 된다.
나는 d 를 기준으로 오름차순 정렬을 시켰고 결과는 성공적이었다.
이렇게 이번 기능 구현을 완료했다. 뜻밖의 오류와 예상치 못한 문제들로 같은 작업을 여러번 반복하기도 했었지만
그만큼 배운 점이 많았다. 실패는 성공의 어머니라는 말을 몸으로 느꼈다.
인생은 실전이듯이 코딩도 실전이다. 프로젝트라는 실전을 통해 많은 경험을 쌓을 수 있어 이번 첫 프로젝트 진행은
정말 의미있는 기회라고 생각하고 더욱 더 열심히 임하는 내가 되어야겠다고 생각했다.