[데이터베이스 작성과 환경설정]
먼저 데이터베이스를 작성한다. 이후 GitHub 레포지토리를 생성한다. 레포지토리를 만든 후, Spring Initializr를 이용하여 프로젝트를 생성하고 해당 프로젝트를 레포지토리 폴더에 클론한다. 그다음 IntelliJ를 실행한다.
디펜더시 외 설정을 해준다. 이미지를 위해 s3를 만들고 설정도 해주겠다.
메이븐 동기화 눌러주기 해서 환경설정을 먼저 해준다
1.회원가입
제이슨 설정을 위해 유저 리퀘스트를 만들고, 서비스에서 signUp이라는 함수를 생성한다. 이 함수에는 유저가 보낸 제이슨을 담는다. 현재 서비스가 없으므로, 서비스를 먼저 생성하도록 한다.
벨리데이션 라이브러리를 설치하여 DTO에서 데이터를 검증할 수 있다. 세 번째 사진과 같이 작성하면, 알아서 데이터를 처리해준다.
이 라이브러리는 이메일 형식, 비밀번호 형식, 닉네임 형식을 자동으로 체크해준다.
암호화한 값을 유저 리퀘스트의 패스워드에 저장한다. 이제 레포지토리가 필요하므로, 이를 생성한다.
레포지토리는 데이터를 클래스로 처리하는 것을 철학으로 한다. 데이터베이스의 테이블을 클래스로 다루어, 객체지향적으로 데이터를 관리하는 방식이다.
레포지토리는 필요에 따라 점진적으로 구성해 나가는 방식으로 작성한다.
클래스로 처리한다고 했으니, 이제 유저 엔티티를 생성한 후, 레포지토리를 마무리한다. 이후 다시 서비스 로직으로 돌아간다.
이 방식은 레포지토리에서 유저만 받아오는 방식이라 문제가 된다. 따라서 DTO를 엔티티로 변환하여 저장해야 한다.
데이터를 정확히 입력하는 것이 중요하다. 닉네임을 작성하지 않으면 데이터가 정상적으로 저장되지 않는다. 입력하고자 하는 값을 정확하게 작성해야 한다.(이것때문에 에러 났었다)
데이터를 저장(save)하려면 유저 객체를 직접 생성해야 한다. 만약 어떤 값을 넣어야 할지 모른다면, 첫 번째 사진의 () 위에 커서를 올리면 필요한 파라미터 정보를 확인할 수 있다. 이를 참고하여 판단할 수 있다.
데이터베이스의 이메일 유니크(UNIQUE) 설정으로 인해 오류가 발생할 수 있다. 이 문제를 방지하는 코드를 작성할 수도 있지만, 여기서는 생략한다.
작업을 마쳤지만, 필터를 무정차 통과할 수 있도록 설정해야 한다. 이를 위해 필터에 무정차 코드를 추가한다.
필터에서는 equals를 사용할 수 있지만, startsWith를 사용하면 뒤에 어떤 값이 오더라도 무정차 통과를 가능하게 해준다.
equals는 특정 문자열이 완전히 일치하는 경우만 허용하므로, **시큐리티 설정(Security Config)**에도 한 번 더 적용해야 한다.
시큐리티 설정에서는 패턴 기반으로 작성할 수 있다. 예를 들어, 경로 뒤에 /와 **를 사용하면, 해당 경로 하위의 모든 요청을 무정차 통과할 수 있다.
이와 같은 설정을 통해 회원가입과 로그인 요청을 무정차로 통과할 수 있도록 보장한다.
DTO뿐만 아니라 컨트롤러에도 @Valid 어노테이션을 추가해야 데이터를 정확히 검증할 수 있다.
@Valid는 데이터가 컨트롤러로 전달될 때, DTO의 유효성 검사 규칙을 적용하도록 도와준다. 이를 통해 잘못된 데이터가 전달되었을 경우, 요청이 컨트롤러에 도달하기 전에 오류를 감지하고 예외를 발생시킨다.
따라서, 데이터의 정확성을 보장하기 위해 DTO와 컨트롤러 모두에 @Valid를 사용해야 한다.
2.로그인
레포지토리에 작성한 내용은 DB 관리 도구인 DBeaver로 비교했을 때, 마지막 사진에 해당한다.
코드를 작성할 때, API 문서에 맞춰 정확하게 처리해야 한다.
만약 오류를 처리하고 싶다면, ResponseEntity를 사용하여 status에 상태 코드를 명시하고, API 명세서에 맞게 정확하게 작성해야 한다.
정상적인 반환 처리도 동일하게 작성해야 한다.
유저 서비스가 DTO를 반환하고, 로그인 요청에 대한 LoginResponse를 제공하므로, 이를 ResponseEntity의 body에 담아 반환하면 된다.
이때, ResponseEntity를 리턴해야 하므로, ResponseEntity.ok()와 함께 LoginResponse 객체를 반환해야 정상적으로 동작한다.
LoginResponse 객체는 public 접근 제어자를 설정하여 외부에서 접근 가능하게 구성해야 한다.
3. 여행코스 상세 조회
코스에서 처리해주는 것이니깐 새로운 코스컨트롤러 만들어주고 시작한다.
조인을 수행할 때는 클래스로 데이터를 받아온다.
**코스(course)**를 등록할 때, 한 번만 올리는 것은 비현실적이다. 예를 들어, 제주도, 전주 한옥마을 등 여러 코스를 방문할 수 있기 때문이다.
따라서 코스 테이블에서는 하나의 유저 ID가 여러 번 등장할 수 있다. 이를 매핑할 때, @ManyToOne 어노테이션을 사용해야 한다.
반대로, 유저 테이블의 userId는 **유니크(UNIQUE)**한 값으로, 한 명의 유저는 하나의 고유 ID만을 가진다. 그러나 코스 테이블에서는 같은 유저 ID가 여러 번 등장할 수 있다.
즉, 유저 ID는 코스 테이블에서 다수 존재할 수 있지만, 유저 테이블에서는 단 하나만 존재해야 한다.
이후 코스 레포지토리를 마무리한다. 데이터를 가져오려 했으나 레포지토리가 없었기 때문에 새로 생성했고, 이제 서비스로 다시 돌아간다.
서비스에서 코스 레포지토리를 사용할 수 있도록, @Autowired 어노테이션을 이용해 주입해준다.
이제 레포지토리를 통해 ID를 기준으로 데이터를 조회한다.
findById 메서드를 사용하여 데이터를 가져오는데, 코스 레포지토리이므로 코스를 반환하게 된다.
하지만, 데이터가 존재할 수도 있고, 존재하지 않을 수도 있으므로, **Optional**을 사용하여 처리한다.
Optional은 값이 없을 경우를 대비해 안전하게 데이터 접근을 가능하게 한다.
id, title, writer는 유저 정보와 관련된 데이터다.
course.getUser().getId()를 통해 유저 정보를 가져오며, 조인을 설정했으므로 해당 데이터를 쉽게 접근할 수 있다.
남은 데이터는 코스 엔티티 내부에 포함되어 있다. 상단의 데이터 처리는 이미 완료된 상태다.
이제 전체 데이터를 담을 DTO를 만들어야 한다. 현재 해당 DTO가 없으므로, 새로 생성해야 한다.
일단 모든 데이터를 가져왔으면, 이제 장소 리스트를 세팅해야 한다.
해당 리스트는 **코스(course)**에 연결된 데이터로, 코스에 맞는 장소 리스트를 설정해야 한다.
플레이스 레포지토리를 생성해야 한다.
이를 위해 새로운 레포지토리를 작성하여, 플레이스 데이터를 관리할 수 있도록 한다.
코스 ID가 반드시 하나만 존재해야 하는 것은 아니다.
예를 들어, 특정 코스에서 카페 방문, 식사, 다음날 다른 장소 방문 등 여러 장소가 등록될 수 있다.
하지만, 코스 ID는 코스 테이블을 참조하는 값이므로, 코스 테이블에서는 하나의 ID만 존재해야 한다.
따라서 @ManyToOne 어노테이션을 사용하여, 플레이스 엔티티에서 코스를 참조할 수 있도록 설정한다.
플레이스 레포지토리도 사용해야 하므로, @Autowired 어노테이션을 사용하여 서비스에 주입한다.
이제 특정 코스 ID에 해당하는 장소를 데이터베이스에서 가져올 수 있도록 플레이스 레포지토리를 활용한다.
플레이스 ID는 의미가 없으므로, 대신 코스 ID를 설정해야 한다.
이를 위해 플레이스 레포지토리에서 코스 ID를 참조할 수 있도록 설정해야 한다.
코스 ID는 데이터베이스에 이미 존재하므로, 이를 기반으로 데이터를 가져올 수 있다.
따라서 플레이스 레포지토리에서 코스 ID를 기준으로 장소 데이터를 조회할 수 있도록 구현한다.
제이슨 데이터를 확인해보니, ID, 이름(name) 등 필요한 정보를 모두 가져왔다.
하지만 사진(photos) 정보는 존재하지 않는다.
따라서 레포지토리에서 사진 데이터를 추가로 가져와야 하며, 이후 데이터를 합쳐서 설정해주면 된다.
우선, DTO에서 가져온 데이터를 먼저 세팅해야 한다.
하지만 현재 DTO가 없으므로, 먼저 DTO를 생성한 후 데이터를 세팅해야 한다.
플레이스가 많으니, 반복문을 사용해 플레이스 리스트에서 플레이스를 하나씩 꺼내온다.
for(Place place:placeList)
DTO를 하나 생성해야 한다.
PlaceResponse는 new PlaceResponse()를 사용하여 새 인스턴스를 생성한다.
이제 **포토(사진)**를 추가해야 한다.
하지만 현재 포토 데이터가 없으므로, 포토 레포지토리를 새로 생성해야 한다.
포토 레포지토리를 사용하기 위해, @Autowired 어노테이션을 사용하여 포토 레포지토리를 주입한다.
이를 통해 포토 데이터를 조회하고 사용할 수 있게 설정한다.
다시 코스 서비스간다 또 반복문해서 만들어야한다.
리스판스에서 생성자를 만들어줘서 필요없어진 서비스에서 만든 리스트 객체를 업애준후 계속한다
에러...
ID를 Id로 작성하지 않으면 에러가 발생한다.
또한, 데이터베이스와 필드 이름이 일치하지 않아서 에러가 발생한 것이다.
결국, 작은 오타 하나가 큰 에러를 발생시킬 수 있다. 😢
트래블 코스에서 코스 ID를 기준으로 1번 코스를 가져오면, 해당 유저 정보를 가져올 수 있다.
코스 ID를 통해 유저 ID를 확인하고, 이어서 관련 데이터를 계속 조회할 수 있다.
하지만, 장소 리스트도 필요하다면, 코스 ID로 장소를 가져온 후, 반복문을 사용하여 해당 코스에 속한 포토(사진) 데이터까지 조회해야 한다.
이 과정은 반복의 반복으로 복잡해질 수 있다.
따라서 @ManyToOne 어노테이션을 사용하면, 조인을 통해 데이터 접근을 단순화하고, 이러한 복잡한 과정을 줄일 수 있다.
JPA에서 OneToMany와 ManyToOne 관계는 엔터티 간의 관계를 매핑할 때 사용된다.
Place와 Photo라는 두 개의 엔터티가 있다. Place는 여러 개의 Photo를 가질 수 있으며, Photo는 하나의 Place에 속한다. 이를 JPA에서 OneToMany와 ManyToOne 관계로 설정할 수 있다.
Place 엔터티는 다음과 같이 OneToMany 관계를 설정한다:
@Entity
public class Place {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "place")
private List<Photo> photos = new ArrayList<>();
}
Photo 엔터티는 ManyToOne 관계를 설정한다:
@Entity
public class Photo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "place_id")
private Place place;
}
- mappedBy: Photo 엔터티의 place 필드를 참조하여 관계를 정의함.
- JoinColumn: Photo 테이블에서 place_id 컬럼을 외래키로 설정함.
또한 Course와 Place 간의 관계를 설정할 수 있다. Course는 여러 개의 Place를 가질 수 있다.
Course 엔터티:
@Entity
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "course")
private List<Place> places = new ArrayList<>();
}
Place 엔터티:
@Entity
public class Place {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "course_id")
private Course course;
}
- OneToMany와 ManyToOne 관계를 사용하면, 특정 Course를 조회할 때 해당 Place를 자동으로 가져올 수 있다.
- List를 사용하여 여러 개의 엔터티를 관리할 수 있다.
이러한 관계 설정을 통해 Course를 조회할 때 Place와 Photo 데이터도 함께 가져올 수 있다. DTO를 사용할 경우 반복문을 사용하여 데이터 매핑을 보다 깔끔하게 처리할 수 있다.
포토컨트롤러 사진관련된거 처리하느 애만들고 시작한다.
사진이름을 유니크하게 만들어주는 유틸을 만들어준다
메서드는 photo라는 MultipartFile 객체를 입력으로 받아 photo.isEmpty()를 사용하여 파일이 비어있는지 검사하고,
비어있다면 IllegalArgumentException 예외를 던지며, 이후 UniqueFileNameGenerator.generateUniqueFileName() 메서드를 사용하여 placeId와 원본 파일 이름을 기반으로 유니크한 파일 이름을 생성하고, S3에 업로드하기 위해 PutObjectRequest를 생성하여 bucketName(버킷 이름), name(파일 이름), 파일 크기, 파일의 MIME 타입, ACL(공개 읽기 권한)을 설정해준다.
try-with-resources를 사용해 InputStream을 열고 photo.getInputStream()으로 파일 데이터를 읽어와 s3Client.putObject() 메서드를 사용해 S3 버킷에 파일을 업로드하며, 업로드 중 오류가 발생할 경우 RuntimeException을 던지고, 업로드 완료 후 https://bucketName.s3.amazonaws.com/파일명 형식으로 업로드된 사진의 접근 URL을 생성하고, placeId를 사용하여 placeRepository.findById() 메서드로 Place 엔티티를 조회한 후, Optional이 비어있다면 해당 placeId를 찾을 수 없다는 예외를 던지며, 이후 새로운 Photos 엔티티를 생성하고, 조회한 Place 엔티티를 photosEntity에 연결한 뒤, photoUrl과 description을 각각 Photos 엔티티의 photoUrl과 description 필드에 설정하고, 마지막으로 photosRepository.save()를 호출하여 데이터베이스에 사진 정보를 저장한다.
Controller에서 마무리 해준다.
4. 여행 코스목록조회
먼저 카테고리 없는걸 만들겠다 그리고 나서 카테고리를 넣으면 조금더 편하게 개발 할 수 있다.
디렉션을 먼저 정해준다
디비에서 받아오는 제이슨이랑 일대일 메칭이니간 여기에 넣어준다.
아... 널 포인터 예외가 발생했다. 왜 객체가 메모리에 생성되지 않았었다..
이렇게 객체생성을 해주던지 서비스 가서 만들어주던지 해서 객체 생성을 해야한다
디티오 한정이긴하지만 이렇게 해두면 편하다
그리고 content 값이 안떠서 보니간 리스트값을 잘못넣어서 집어 넣어었던 것 이다 이걸 위와 같이 고쳐 주니 잘 들어갔다
PageRequest pageRequest1 = PageRequest.of(page - 1, size);를 사용함으로써 페이지 처리를 해주는데
PageRequest.of(page - 1, size)를 사용하면 페이지 번호를 0부터 시작하는 인덱스에 맞춰 조정하고, 지정한 크기만큼 데이터를 자동으로 조회하며, 정렬 기준도 함께 설정할 수 있어 페이지 처리를 직접 구현할 필요가 없다.
그리고 findByTitleContaining 메서드를 생성해야 한다.
이 메서드는 Spring Data JPA의 메서드로, 특정 제목을 포함하는 Courses 엔티티를 검색하기 위해 사용된다. Pageable 객체를 사용하여 페이지 처리와 정렬을 함께 적용할 수 있다.
코스 서비스 | 코스 레파지토리 | |
방법1 | Page<Course> coursePage = courseRepository.findAllContainsTitle( keyword, pageRequest); | @Query("SELECT tc\n" + "FROM Course tc\n" + "WHERE tc.title LIKE %:keyword%") Page<Course> findAllContainsTitle(String keyword, Pageable pageable); |
방법2 | Page<Course> coursePage = courseRepository.findByTitleContains( keyword, pageRequest); | Page<Course> findByTitleContains(String keyword, Pageable pageable); |
길어서 서비스를 따로 올리겠다
public CourseListResponse getAllCourses(int page, int size, String sort, String keyword) {
if (keyword == null){
//키워드가 없는 경우
//페이징처리를 위해서 pageable 만든다.
// sort.endsWith("desc") 이렇게 해도됨
String[] sortArray =sort.split(","); // 갯수가 정확히 하는거니깐 배열을 써주는 것이다
//sort[0] => "totalCost"
//sort[1] => "desc"
Sort.Direction direction = null;
if (sortArray[1].equals("desc")) {
direction = Sort.Direction.DESC;
}else {
direction = Sort.Direction.ASC;
}
PageRequest pageRequest = PageRequest.of(page-1, size, Sort.by(direction, sortArray[0]));
//DB에 쿼리한다
Page<Courses> coursesPage=courseRepository.findAll(pageRequest);
//결과를 DTO로 만든다
ArrayList<CoursesPlaceResponse> courseList = new ArrayList<>();
for( Courses course : coursesPage){
CoursesPlaceResponse coursePlaceResponse =
new CoursesPlaceResponse();
coursePlaceResponse.id = course.id;
coursePlaceResponse.title = course.title;
coursePlaceResponse.region = course.region;
coursePlaceResponse.durations = course.duration;
coursePlaceResponse.totalCost = course.totalCost;
coursePlaceResponse.writer = new UserResponse(course.user.id, course.user.nickname);
coursePlaceResponse.placeCount = course.placeList.size();
coursePlaceResponse.createdAt = course.createdAt.toString();
courseList.add(coursePlaceResponse);
}
CourseListResponse courseListResponse = new CourseListResponse();
courseListResponse.content = courseList;
courseListResponse.page= page;
courseListResponse.size = size;
courseListResponse.totalElements = coursesPage.getTotalElements();
courseListResponse.totalPages = coursesPage.getTotalPages();
return courseListResponse;
}else {PageRequest pageRequest1 = PageRequest.of(page-1, size);
Page<Courses> coursesPage = courseRepository.findByTitleContains(keyword, pageRequest1);
String[] sortArray =sort.split(","); // 갯수가 정확히 하는거니깐 배열을 써주는 것이다
//sort[0] => "totalCost"
//sort[1] => "desc"
Sort.Direction direction = null;
if (sortArray[1].equals("desc")) {
direction = Sort.Direction.DESC;
}else {
direction = Sort.Direction.ASC;
}
PageRequest pageRequest = PageRequest.of(page-1, size, Sort.by(direction, sortArray[0]));
Page<Courses> coursePage = courseRepository.findByTitleContains( keyword, pageRequest);
//결과를 DTO로 만든다
ArrayList<CoursesPlaceResponse> courseList = new ArrayList<>();
for( Courses course : coursesPage){
CoursesPlaceResponse coursePlaceResponse =
new CoursesPlaceResponse();
coursePlaceResponse.id = course.id;
coursePlaceResponse.title = course.title;
coursePlaceResponse.region = course.region;
coursePlaceResponse.durations = course.duration;
coursePlaceResponse.totalCost = course.totalCost;
coursePlaceResponse.writer = new UserResponse(course.user.id, course.user.nickname);
coursePlaceResponse.placeCount = course.placeList.size();
coursePlaceResponse.createdAt = course.createdAt.toString();
courseList.add(coursePlaceResponse);
}
CourseListResponse courseListResponse = new CourseListResponse();
courseListResponse.content = courseList;
courseListResponse.page= page;
courseListResponse.size = size;
courseListResponse.totalElements = coursesPage.getTotalElements();
courseListResponse.totalPages = coursesPage.getTotalPages();
return courseListResponse;
//키워드가 있는 경우
}
}
'Spring Boot JPA > 실습' 카테고리의 다른 글
박스오피스 OPEN API 실습과 배포 (0) | 2025.01.13 |
---|---|
YouTube검색 활용한 OPEN API 서버 개발 실습 (0) | 2025.01.10 |
JPA Join과 Config 설정으로 데이터베이스 관계 정리2 (0) | 2025.01.08 |
JPA Join과 Config 설정으로 데이터베이스 관계 정리 (0) | 2025.01.07 |
AI를 활용한 화면 만들기 (1) | 2025.01.07 |