토이 프로젝트 1 - 여행과 여정 정보를 기록하고 조회하는 Java 애플리케이션 개발
최종 구현 목표가 "여행 여정을 기록과 관리하는 SNS 서비스" 인 토이 프로젝트가 시작 되었다. 프로젝트를 되돌아 보고 내가 작성한 코드들을 스스로 다시 리뷰할 겸 작성하는 게시물이다.
[Toy 프로젝트 1 단계 달성 목표]
- Java 문법, 파일 입출력, 예외 처리, 클래스 설계
- 여행의 여정 정보를 기록하고 조회하는 Java 애플리케이션 개발
- 개별 여행은 복수의 여정 정보로 구성됨 (여행 : 여정 = 1 : n)
- 여정 정보는 이동 (출발지, 도착지, 출발 시각, 도착 시각)과 숙박(체크인, 체크 아웃) 등의 유형이 있을 수 있음
- 여행 정보와 특정 여행 정보의 여정 목록은 데이터 저장 경로에 JSON 파일 형태로 저장
JSON 파일의 예시는 다음과 같다.
{
"trips" : [ {
"trip_id" : "123e4567-e89b-12d3-a456-426614174001", // 여행 아이디
"trip_name" : "Family Vacation", // 여행 이름
"start_date" : "2023-07-15", // 시작 날짜
"end_date" : "2023-07-20", // 종료 날짜
"itineraries" : [ {
"itinerary_id" : "123e4567-e89b-12d3-a456-426614174002", // 여정 아이디
"departure_place" : "City A", // 출발 장소
"destination" : "City B", // 도착 장소
"departure_time" : "2023-07-15T08:00:00", // 출발 시간
"arrival_time" : "2023-07-15T10:00:00", // 도착 시간
"check_in" : "2023-07-15T12:00:00", // 체크인
"check_out" : "2023-07-30T10:00:00" // 체크아웃
}, {
"itinerary_id" : "123e4567-e89b-12d3-a456-426614174003",
"departure_place" : "City B",
"destination" : "City C",
"departure_time" : "2023-07-16T09:00:00",
"arrival_time" : "2023-07-16T11:00:00",
"check_in" : "2023-07-16T13:00:00",
"check_out" : "2023-07-20T10:00:00"
}, {
"itinerary_id" : "123e4567-e89b-12d3-a456-426614174004",
"departure_place" : "City C",
"destination" : "City D",
"departure_time" : "2023-07-20T12:00:00",
"arrival_time" : "2023-07-20T14:00:00",
"check_in" : "2023-07-20T16:00:00",
"check_out" : "2023-07-30T10:00:00"
} ]
}, {
"trip_id" : "70a1bfe8-fcce-4cb1-a133-8ef9babfb85a",
"trip_name" : "우정 여행",
"start_date" : "2023-12-12",
"end_date" : "2023-12-30",
"itineraries" : [ ]
} ]
}
[프로젝트 요구사항]
- 요구 사항
- 개별 여행은 여러 여정 정보로 구성된다. (여행: 여정 = 1: n)
- 여정 정보에는 이동 유형(출발지, 도착지, 출발 시간, 도착 시간) 및 숙박(체크인/체크 아웃)이 포함될 수 있다.
- 여행 정보 및 특정 여행 정보의 여정 목록은 데이터 저장 경로에 JSON파일 형태로 저장된다.
- 객체 지향 성격이 잘 들어날 수 있도록 클래스를 설계한다.
- 프로젝트 기능 구현을 위한 필요한 메서드를 정의하고 구현한다.
- 세부기능 구현
- 여행 및 여정 정보 기록 기능
- 여행 일정을 기록해야 한다.
- 하나의 여행에 여러 개의 여정 정보를 기록해야 한다.
- 여정 정보를 N개 입력 후 계속 기록 여부를 묻고(Y/N) 기록을 종료 할 수 있다.
- 여행 및 여정 조회하는 기능
- 여행 전체 리스트를 조회 할 수 있어야 한다.
- 여행 전체 리스트에서 확인된 여행 아이디를 입력하면 해당 여행 정보와 여정 정보를 조회 할 수 있어야 한다.
- 여행 정보와 여정 정보 조회는 JSON 파일에서 조회가 가능해야 한다.
- 예외 처리
- 여행 정보가 없으면 오류 메시지를 출력한다.
- 여행에 여정이 없으면 추가할 수 있어야 한다.
- JSON 형식에 맞지 않으면 오류 메세지를 출력 한다.
- 한글 문자가 깨지지 않도록 해야 한다.
- 여행 및 여정 정보 기록 기능
폴더의 구조는 아래와 같이 구성했다.
모델
domain 폴더 내의 itinerary와 trip 폴더에 있는 model, repository, service가 모델 부분에 해당한다. model은 데이터 구조를 정의하고, repository는 데이터베이스 접근 및 데이터 관리를, service는 비즈니스 로직을 처리합니다.
뷰
view 폴더 내의 page에 있는 ItineraryListPage, ItineraryUpdatePage, MenuPage, TripListPage, TripUpdatePage 등이 해당한다. 각각의 페이지가 Swing 컴포넌트를 사용하여 사용자 인터페이스를 구성하고 있다.
컨트롤러
Swing 기반의 프로젝트에서는 view 내의 페이지 파일들이 컨트롤러의 역할을 수행할 수 있다. ItineraryListPage, ItineraryUpdatePage 등의 클래스가 사용자의 액션에 반응하여 모델을 업데이트하고, 뷰를 새로고침하는 역할을 할 수 있다. 직접적인 컨트롤러 클래스는 없지만 페이지 클래스 내에서 이러한 역할을 수행한다.
필자는 여정(itinerary)를 조회하고 삭제하는 역할을 맡았다.
먼저 JSON 파일과의 데이터 상호작용을 위해 Entity 클래스를 설계했다.
@ToString
@Getter
@NoArgsConstructor
public class ItineraryEntity {
@JsonProperty("itinerary_id")
private String itineraryId;
@JsonProperty("departure_place")
private String departurePlace;
@JsonProperty("destination")
private String destination;
@JsonProperty("departure_time")
private String departureTime;
@JsonProperty("arrival_time")
private String arrivalTime;
@JsonProperty("check_in")
private String checkIn;
@JsonProperty("check_out")
private String checkOut;
}
@JsonProperty 어노테이션은 JSON 객체의 키와 해당 클래스 필드를 매핑하는 데 사용된다.
다음으로 계층 간에 필요한 데이터를 전달하기 위해 DTO 클래스를 작성했다. 현 프로그램에서는 Entity가 관리하는 데이터와 DTO의 데이터가 완벽히 일치한다.
public record ItineraryDTO(
String itineraryId,
String departurePlace,
String destination,
String departureTime,
String arrivalTime,
String checkIn,
String checkOut
) {}
record를 사용하면 생성자, Getter, equals(), hashCode(), toString() 등의 메서드가 자동으로 생성되므로, 이러한 메서드들을 수동으로 작성할 필요가 없어 간결성에 도움이 된다. 또한 필드를 final로 자동으로 선언하여 불변성을 보장하므로 버그 발생을 최소화하고 코드의 안정성을 향상시킨다. 그리고 record를 사용함으로써 해당 클래스가 데이터 전송 전용임을 명확히 나타낼 수 있다.
데이터 전용 클래스들을 작성했으므로 repository 관련 클래스를 작성했다. 먼저 여정 조회를 위한 ItineraryReadRepository이다.
public class ItineraryReadRepository {
private static ItineraryReadRepository instance;
// 싱글턴 패턴을 적용하기 위해 생성자를 private로 선언한다.
private ItineraryReadRepository() {}
public static ItineraryReadRepository getInstance() {
if (instance == null) {
instance = new ItineraryReadRepository();
}
return instance;
}
// 주어진 tripId에 해당하는 모든 ItineraryEntity를 읽어오는 메서드다.
public List<ItineraryEntity> readItinerariesForTrip(String tripId) {
String filePath = FilePath.RESOURCES_data.getPath();
ObjectMapper objectMapper = new ObjectMapper();
try {
// JSON 파일을 읽어 TripEntityContainer 클래스의 인스턴스로 역직렬한다.
TripEntityContainer trips = objectMapper.readValue(new File(filePath), TripEntityContainer.class);
// 역직렬화된 컨테이너에서 tripId에 해당하는 모든 일정을 찾아 반환한다.
return getAllItinerariesForTrip(trips, tripId);
} catch (IOException e) {
throw new CustomExceptionImpl(ItineraryListErrorCode.FILE_READ_ERROR);
}
}
// TripEntityContainer에서 주어진 tripId에 해당하는 TripEntity를 찾고,
// 그 TripEntity에 포함된 ItineraryEntity의 리스트를 반환한다.
private List<ItineraryEntity> getAllItinerariesForTrip(TripEntityContainer trips, String tripId) {
for (TripEntity trip : trips.getTrips()) {
if (trip.getTripId().equals(tripId)) {
return trip.getItineraries();
}
}
return Collections.emptyList();
}
}
JSON 파일을 데이터 저장소로 사용하고 있으며, Jackson 라이브러리를 사용하여 JSON 데이터를 객체로 매핑했다.
다음으로 여정 삭제를 위한 ItineraryDeleteRepository이다.
public class ItineraryDeleteRepository {
private static ItineraryDeleteRepository instance;
// 싱글턴 패턴을 적용하기 위해 생성자를 private로 선언한다.
private ItineraryDeleteRepository() {}
public static ItineraryDeleteRepository getInstance() {
if (instance == null) {
instance = new ItineraryDeleteRepository();
}
return instance;
}
// 삭제할 여정 아이디들을 받아 해당하는 여정들을 JSON 파일에서 삭제하는 메서드다.
public void deleteItineraries(List<String> itineraryIds) {
String filePath = FilePath.RESOURCES_data.getPath();
// JSON 데이터를 처리하기 위한 ObjectMapper 인스턴스를 생성하고,
// 출력 시에는 들여쓰기를 적용하기 위해 SerializationFeature를 활성화다.
ObjectMapper objectMapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
try {
File file = new File(filePath);
// JSON 파일의 내용을 TripEntityContainer 클래스의 인스턴스로 역직렬화한다.
TripEntityContainer tripEntityContainer = objectMapper.readValue(file, TripEntityContainer.class);
boolean isDeleted = false;
for (TripEntity trip : tripEntityContainer.getTrips()) {
List<ItineraryEntity> itineraries = trip.getItineraries();
// 일정 목록의 크기가 변경될 것이므로, 뒤에서부터 앞으로 순회한다.
for (int i = itineraries.size() - 1; i >= 0; i--) {
ItineraryEntity itinerary = itineraries.get(i);
if (itineraryIds.contains(itinerary.getItineraryId())) {
itineraries.remove(i);
isDeleted = true;
}
}
}
// 만약 일정이 하나 이상 삭제되었다면 변경 사항을 파일에 다시 쓴다.
if (isDeleted) {
objectMapper.writeValue(file, tripEntityContainer);
}
} catch (IOException e) {
throw new CustomExceptionImpl(ItineraryListErrorCode.FILE_READ_ERROR);
}
}
}
삭제할 여정의 ID를 받아와서 해당 ID의 여정 내부 정보를 모두 삭제하고, 삭제한 이력이 있으면 변경 사항을 JSON 파일에 다시 작성하는 클래스다.
다음으로 Repository 클래스와 상호작용하는 Service 클래스다. 먼저 ItineraryReadService이다.
public class ItineraryReadService {
private static ItineraryReadService instance;
private final ItineraryReadRepository itineraryReadRepository = ItineraryReadRepository.getInstance();
// 싱글턴 패턴을 적용하기 위해 생성자를 private로 선언한다.
private ItineraryReadService() {}
public static ItineraryReadService getInstance() {
if (instance == null) {
instance = new ItineraryReadService();
}
return instance;
}
// 특정 tripId에 대한 여행 일정 데이터를 2차원 Object 배열로 반환한다.
// 이 배열은 GUI 컴포넌트에서 데이터를 표시하는 데 사용된다.
public Object[][] getItineraryData(String tripId) throws CustomExceptionImpl {
List<ItineraryDTO> dtos = getItinerariesDTO(tripId);
if(dtos.isEmpty()) {
throw new CustomExceptionImpl(ItineraryListErrorCode.NO_ITINERARY_INFORMATION);
}
return dtos.stream()
.map(dto -> new Object[]{
dto.itineraryId(),
dto.departurePlace(),
dto.destination(),
dto.departureTime(),
dto.arrivalTime(),
dto.checkIn(),
dto.checkOut()
})
.toArray(Object[][]::new);
}
// tripId에 해당하는 ItineraryEntity 객체들을 ItineraryDTO 리스트로 변환하는 보조 메서드다.
private List<ItineraryDTO> getItinerariesDTO(String tripId) {
List<ItineraryEntity> entities = itineraryReadRepository.readItinerariesForTrip(tripId);
// ItineraryEntity 객체들을 ItineraryDTO 객체들로 변환한다.
return entities.stream()
.map(entity -> new ItineraryDTO(
entity.getItineraryId(),
entity.getDeparturePlace(),
entity.getDestination(),
entity.getDepartureTime(),
entity.getArrivalTime(),
entity.getCheckIn(),
entity.getCheckOut()
))
.toList();
}
}
특정 여행 ID에 대한 여정 정보를 DTO 로 변환 후 GUI 컴포넌트에서 사용할 수 있도록 2차원 배열 형태로 리턴한다.
다음으로 ItineraryDeleteService이다.
public class ItineraryDeleteService {
private static ItineraryDeleteService instance;
private ItineraryDeleteRepository deleteRepository = ItineraryDeleteRepository.getInstance();
// 싱글턴 패턴을 적용하기 위해 생성자를 private로 선언한다.
private ItineraryDeleteService() {}
public static ItineraryDeleteService getInstance() {
if (instance == null) {
instance = new ItineraryDeleteService();
}
return instance;
}
// 선택된 여행 일정의 ID 목록을 받아 해당 일정을 삭제하는 메서드다.
public void deleteSelectedItineraries(List<String> itineraryIds) {
// ItineraryDeleteRepository를 사용하여 실제 삭제 작업을 수행한다.
deleteRepository.deleteItineraries(itineraryIds);
}
}
지금은 비록 단순히 Id 리스트를 전달하는 역할만 해서 service 클래스가 필요없어 보일 수도 있지만 추후 확장성을 위해 service 클래스는 존재하는게 좋다.
마지막으로 여정 조회 및 삭제의 view와 controller를 담당하는 ItineraryListPage이다. 앞서 말했듯이 Swing 라이브러리를 사용해서 view와 controller가 하나의 파일로 구성되었다. 그리고 Delete 기능은 조회한 화면에서 실행되기 때문에 별도의 Page가 존재하지 않는다.
public class ItineraryListPage {
ItineraryReadService itineraryReadService = ItineraryReadService.getInstance();
ItineraryDeleteService itineraryDeleteService = ItineraryDeleteService.getInstance();
private DefaultTableModel model;
private JPanel mainPanel;
// 특정 여행 ID(tripId)에 대한 페이지를 생성하고 구성하는 메서드다.
public void getPage(String tripId) {
JFrameCustom jFrameCustom = JFrameCustom.getInstance();
JButton deleteButton = new JButton("여정 삭제");
deleteButton.addActionListener(e -> {
List<String> selectedIds = getSelectedItineraryIds(); // 선택된 여정 ID를 가져온다.
itineraryDeleteService.deleteSelectedItineraries(selectedIds); // 선택된 여정을 삭제한다.
getPage(tripId); // 페이지를 새로 고침한다.
});
JButton tripListButton = new TripListButton(); // 여행 목록으로 돌아가는 버튼을 생성한다.
jFrameCustom.addButton(tripListButton, deleteButton);
JPanel mainPanel = getMainPanel(tripId);
jFrameCustom.mainPanel(mainPanel);
jFrameCustom.start();
}
// 메인 패널을 생성하고 구성하는 메서드다. 여행 일정 데이터를 표시한다.
private JPanel getMainPanel(String tripId) {
mainPanel = new JPanel(new BorderLayout())
try {
Object[][] data = itineraryReadService.getItineraryData(tripId);
if (data == null || data.length == 0) {
throw new CustomExceptionImpl(ItineraryListErrorCode.NO_ITINERARY_INFORMATION);
} else {
JTable table = createTable(data);
// 여기서 더블 클릭 이벤트 처리 등 추가 기능 구현...
TableRowSorter<TableModel> sorter = sortSetting(table);
JPanel sortPanel = sortTime(sorter);
JPanel searchPanel = searchCity(sorter);
mainPanel = showMainPanel(mainPanel, table, sortPanel, searchPanel);
}
} catch (CustomExceptionImpl e) {
JLabel noItineraryLabel = new JLabel(ItineraryListErrorCode.NO_ITINERARY_INFORMATION.getErrorMsg(), SwingConstants.CENTER);
mainPanel.add(noItineraryLabel, BorderLayout.CENTER);
}
return mainPanel;
}
// 테이블에서 선택된 여정 ID를 리스트로 반환하는 메서드다.
private List<String> getSelectedItineraryIds() {
List<String> selectedIds = new ArrayList<>();
for (int i = 0; i < model.getRowCount(); i++) {
Boolean checked = (Boolean) model.getValueAt(i, 0); // 첫 번째 열(체크박스)의 값을 가져온다.
if (checked) { // 체크박스가 선택된 경우
selectedIds.add(model.getValueAt(i, 1).toString()); // 두 번째 열(여정 ID)의 값을 리스트에 추가한다.
}
}
return selectedIds;
}
// 데이터로부터 JTable을 생성하는 메서드다.
private JTable createTable(Object[][] data) {
String[] columnNames = new String[8];
int index = 0;
// 열 이름 설정 로직...
// 테이블 모델 설정 로직...
return new JTable(model)
}
// 테이블 정렬 설정을 수행하는 메서드다.
private TableRowSorter<TableModel> sortSetting(JTable table) {
// 정렬 설정 로직...
return sorter;
}
// 시간 기준 정렬 패널을 생성하는 메서드다.
private JPanel sortTime(TableRowSorter<TableModel> sorter) {
// 정렬 패널 구성 로직...
return comboPanel
}
// 도시 검색 패널을 생성하는 메서드다.
private JPanel searchCity(TableRowSorter<TableModel> sorter) {
// 검색 패널 구성 로직...
return searchPanel;
}
// 메인 패널에 필요한 구성요소를 추가하고 메인 패널을 반환하는 메서드다.
private JPanel showMainPanel(JPanel jPanel, JTable table, JPanel sortPanel, JPanel searchPanel) {
// 메인 패널 구성 로직...
return jPanel;
}
}
이 클래스는 UI 구성요소를 생성하고 관리는 뷰의 역할 수행하며, 사용자의 입력에 대한 이벤트 처리와 비즈니스 로직 호출을 통해 컨트롤러의 역할도 겸한다.
뷰(View)의 역할:
- UI 구성: JFrame, JButton, JPanel, JTable 등의 Swing 컴포넌트를 사용하여 사용자 인터페이스를 구성한다.
- 데이터 표시: ItineraryReadService에서 가져온 여행 일정 데이터를 JTable에 표시한다.
컨트롤러(Controller)의 역할:
- 이벤트 처리: 사용자의 액션(예: 버튼 클릭, 테이블 내 항목 더블 클릭)에 대한 이벤트 리스너를 등록하고, 해당 이벤트 발생 시 적절한 처리를 수행한다.
- 비즈니스 로직 호출: 사용자의 요청에 따라 ItineraryReadService 또는 ItineraryDeleteService를 호출하여 데이터를 조회하거나 수정, 삭제하는 등의 작업을 수행한다.
전체 코드는 아래의 링크에서 볼 수 있다.
https://github.com/tmdeh/KDT_BE8_Toy-Project-1/tree/deploy