본문 바로가기
프로젝트/아리(Ari)

[Spring Boot, Data JPA] 마이페이지 - 회원 정보 수정, 개인 이벤트 등록

by sepang 2022. 9. 28.

대략적인 기능

  이제 회원가입/로그인 과정까지는 구현이 되었으니 마이페이지를 구현해보자. 일반 회원들은 로그인을 해도 즐겨찾는 가게를 모아서 확인할 수 있는 정도의 기능만 있어서 마이페이지는 크게 필요없지만, 사장님 회원들은 자신의 가게 정보를 수정, 자체적인 이벤트를 추가/삭제, 협약 요청 확인 및 처리를 해야하기 때문에 해당 기능에 편하게 접근할 수 있는 마이페이지가 필수적이다.

fig 1. 마이페이지

  마이페이지에 들어가면 fig 1처럼 페이지가 뜬다. 여기에 있는 기능들을 구현하면 된다. 내가 담당한 파트는 내 가게 정보 수정, 개인 이벤트 등록, 찜 목록, 협약 요청 목록이다. 하나씩 살펴보자. 

 

내 가게 정보 수정

fig 2. 내 가게 정보 수정 페이지

  마이 페이지에서 '내 가게 정보 수정' 버튼을 클릭하면 fig 2와 같은 페이지로 넘어간다. 그리고 이때의 입력폼에는 빈 칸이 아니라 기존 정보들을 띄어줘야 한다. 그것을 지우고 새로운 정보를 채운 뒤에 '수정' 버튼을 클릭하면 새롭게 채운 내용이 DB에 반영되어야 한다.

기존 정보 불러오기

@GetMapping("/edit/store")
public ResponseEntity<?> existingInfo(Principal principal) throws IOException {
    return storeService.existingInfo(principal);
}
/**
 * 현재 자신의 가게들 정보를 반환함
 */
public ResponseEntity<?> existingInfo(Principal principal) throws IOException {
    /*1. 요청보낸 회원의 id를 통해 회원의 Store 리스트를 가져온다.*/
    Long ownerId = Long.valueOf(principal.getName());
    List<Store> stores = memberRepository.findById(ownerId).get().getStores();
    log.info("stores 정보: {}", stores.isEmpty());

    /*2. 현재 가게들의 정보가 저장될 dto의 리스트를 선언한다*/
    List<EditInfoDto> existingInfos = new ArrayList<>();

    /*3. 반복문을 통해 EditInfoDto를 만들고 이를 existingInfos에 추가한다.*/
    for (Store store : stores) {
        List<String> existingImages = fileService.loadImage(store);
        log.info("store 정보: {}", store.getName());
        EditInfoDto existingInfo = new EditInfoDto(store.getId(), store.getName(), store.getAddress().getRoadAddress(), store.getAddress().getDetailAddress(),
                store.getOwnerName(), store.getPhoneNumber(), existingImages, store.getSubText(), store.getOpenTime());

        existingInfos.add(existingInfo);
    }
    return response.success(existingInfos, "기존 가게정보", HttpStatus.OK);

}

  우선 기존 가게 정보들을 가져오는 코드부터 살펴보자. 요청에서 받은 jwt 토큰을 통해 요청 보낸 회원의 id를 가져온다. 그리고 memberRepository를 이용해 id에 해당하는 member의 Store 객체를 가져온다. 그 다음 수정 페이지 입력폼에 띄어줄 Dto의 리스트를 새롭게 만든다.

  그 뒤 반복문을 통해 store들을 하나씩 확인하면서 store 객체의 정보를 EditInfoDto에 담아준 뒤 이것을 결과들이 담길 existingInfos에 추가해준다. 그리고 이것을 반환하면 서버에서 할 일은 끝이난다.

가게 정보 수정 요청

  그 다음 가게 정보 수정 창에 입력한 수정 내용으로 DB, 파일을 갱신하는 코드이다. 기본적인 정보야 단순히 DB에 있는 내용을 업데이트하면 되지만 이미지 파일의 경우에는 어떤 식으로 처리할 지 생각을 많이 했다. 왜냐하면 파일은 그 내용 자체를 DB에 저장하는 것이 아니라 제목이나 경로 등을 DB에 기록해놓고 이 정보들을 이용해 파일 입출력 함수를 이용해 저장된 파일을 불러오기 때문이다.

  수정 요청 시 변경되지 않은 이미지 파일이 있다면 해당 파일은 건들지 않는 것이 최선이겠지만 이렇게 하면 코드가 너무 복잡해 질 것 같아 다음과 같은 방법으로 가게 정보 수정 요청 시 이미지 파일을 처리하려고 한다. 기존 가게 대표 이미지가 3개라고 가정하자.

  1. 가게 정보 수정 페이지에 들어올 때 기존 가게 정보를 각 입력 폼에 채우는데 이때 가게 이미지는 저장된 파일을 참고하는 것이 아니라 파일을 불러와서 base 64로 인코딩한 다음 이미지 내용 자체를 보낸다. 만약 기존에 설정한 이미지가 없다면 기본 이미지(default.png)를 인코딩하여 보낸다.
  2. 이후 기존 2개의 이미지를 지우고 1개의 이미지를 삭제 후 다른 사진으로 교체 후 정보 수정 요청을 보냈을 때, 먼저 기존의 해당 가게 이미지 파일, db에 저장된 해당 가게의 이미지 정보를 모두 삭제한다. 만약 3개의 사진이 바뀐게 없었어도 정보 수정 요청을 받으면 모두 삭제된다. 즉 변경되지 않은 사진도 새롭게 인코딩 되서 요청에 담긴다는 것이다.
  3. 새롭게 받은 이미지 파일들을 받아서 정보는 DB에 저장하고 파일은 별도의 공간에 저장한다.
@Data
public class EditInfoDto {

    private Long storeId;
    private String storeName;
    private String roadAddress;
    private String detailAddress;
    private String ownerName;
    private String phoneNumber;
    //private List<MultipartFile> newImages = new ArrayList<>();
    private List<String> existingImages;
    private String subText;
    private String openHour;

    public EditInfoDto(Long id, String storeName, String roadAddress, String detailAddress, String ownerName, String phoneNumber, List<String> existingImages, String subText, String openHour) {

        this.storeId = id;
        this.storeName = storeName;
        this.roadAddress = roadAddress;
        this.detailAddress = detailAddress;
        this.ownerName = ownerName;
        this.phoneNumber = phoneNumber;
        this.existingImages = existingImages;
        this.subText = subText;
        this.openHour = openHour;
    }

}
@PostMapping("/edit/store")
public ResponseEntity<?> editInfo(@ModelAttribute EditInfoDto editInfoDto,
                                  @RequestParam(value = "newImages", required = false) List<MultipartFile> images,
                                  Principal principal) throws IOException {
    return storeService.editInfo(editInfoDto, images, principal);
}
@Transactional
    public ResponseEntity<?> editInfo(EditInfoDto editInfoDto,
                                      List<MultipartFile> images,
                                      Principal principal) throws IOException {
        //1. 우선 해당 가게의 기존 이미지 파일을 모두 삭제
        //Long ownerId = Long.valueOf(principal.getName());
        Store store = storeRepository.findById(editInfoDto.getStoreId()).get();
        //1-1. 이미지 파일을 삭제. 파일 경로 정해야 함
        for (StoreImgFile imgFile : store.getStoreImgFiles()) {
            File file = new File(imgFile.getFileUrl() + imgFile.getFilename());
            if (file.exists()) {
                file.delete();
                storeImgFileRepository.delete(imgFile);
            }
        }
        store.getStoreImgFiles().clear();

        //2. 이미지를 제외하고 새 가게 정보로 변경
        store.updateInfo(editInfoDto.getStoreName(), new Address(editInfoDto.getRoadAddress(), editInfoDto.getDetailAddress()),
                editInfoDto.getOwnerName(), editInfoDto.getPhoneNumber(), editInfoDto.getSubText(), editInfoDto.getOpenHour());

        //3. 이미지 새로 저장
        fileService.saveStoreImage(store, images);

        storeRepository.save(store);

        return response.success();
    }

  우선 이미지를 받기 위해 클라리언트 쪽에서 multipart/form-data 형식으로 입력 폼의 데이터들을 담아보낸다. 그래서 계속 써오던 @RequestBody가 아닌 @ModelAttribute를 사용한다. 두 어노테이션 모두 요청 데이터를 인자에 할당하는건 동일한다. 다만 ModelAttribute로 설정한 EditInfoDto에 이미지 정보가 계속해서 잘 들어가지 않아 이미지 정보만 @RequestParam을 이용해 따로 받았다.

  이제 서비스 코드로 넘어가보자. 우선 수정 요청을 보낸 가게의 id를 이용해 해당 가게 객체를 가져온다. 그 다음은 반복문과 파일 입출력 함수, storeImgRepository의 delete()를 이용하여 기존에 저장된 파일과 이미지 정보를 삭제해준다.

  그 다음 이미지를 제외한 정보부터 기존 store 객체에 갱신해준 뒤, saveStoreImage()를 이용하여 MultipartFile 리스트 형태로 받아온 image를 저장한 뒤 이름이나 경로를 DB에 저장한다.

/**
     * 기존 가게의 이미지를 저장
     */
    @Transactional
    public void saveStoreImage(Store store, List<MultipartFile> images) throws IOException{
        /*1. 아무 이미지가 없으면 바로 반환*/
        if(images == null)
            return;

        /*2. 파일이 저장될 경로를 설정해준다.*/
        String fileUrl = "/Users/jsc/ari_files/";

        /*3. images에 있는 multipartFile 객체 image를 하나씩 확인하면서 저장 폴더에 저장하고 DB에 기록한다.*/
        for(MultipartFile image : images) {
            /*3-1. 클라이언트에서 보낼 당시의 파일 이름*/
            String originalFileName = image.getOriginalFilename();

            /*3-2. 실제로 저장될 파일이름을 uuid로 설정하여 중복을 방지*/
            String fileName = UUID.randomUUID().toString() +
                    originalFileName.substring(originalFileName.lastIndexOf("."));
            File destinationFile = new File(fileUrl + fileName);

            destinationFile.getParentFile().mkdirs();
            image.transferTo(destinationFile); // 파일을 설정한 경로에 저장

            /*3-3. storeImgFile 객체에 저장된 파일에 대한 정보 담음*/
            StoreImgFile storeImgFile = StoreImgFile.builder()
                            .store(store)
                            .originalFileName(originalFileName)
                            .filename(fileName)
                            .fileUrl(fileUrl).build();

            store.addImgFile(storeImgFile); // store의 ImgFile에 추가

            storeImgFileRepository.save(storeImgFile);
        }

        storeRepository.save(store);
    }

  saveStoreImage() 코드이다. images에 담긴 이미지가 확인한 뒤, 반복문을 통해 받은 파일들을 저장한다. 이때 요청 받을 때의 원래 파일 이름으로 저장하지 않고 uuid를 통해 중복되지 않는 이름으로 변경한 뒤에 저장한다. 그리고 가게 이미지 파일을 관리하는 엔티티 객체인 storeImgFile에 파일 정보를 넣어준 뒤 각 리포지토리를 갱신하면 된다.

개인 이벤트 등록

fig 3-1

  개인 이벤트 등록 페이지에서는 가게 자체적으로 진행할 이벤트를 제어할 수 있다. 사실상 매우 간단한 게시판이라고 생각하면 된다.

현재 이벤트 정보 불러오기

@GetMapping("/edit/self-event")
public ResponseEntity<?> existingEvent(Principal principal) {
    return storeService.existingEvent(principal);
}
public ResponseEntity<?> existingEvent(Principal principal) {
    /*1. 요청보낸 회원의 id를 통해 회원의 Store 리스트를 가져온다.*/
    Long ownerId = Long.valueOf(principal.getName());
    List<Store> stores = memberRepository.findById(ownerId).get().getStores();
    List<EventListDto> result = new ArrayList<>(); // 반환에 사용될 EventListDto 리스트 변수

    /*2. 바깥 반복문에서는 이벤트 내용이 담긴 eventListDto를 result에 추가해준다. */
    for (Store store : stores) {
        List<Event> eventList = store.getEventList();
        List<String> eventInfo = new ArrayList<>(); // 한 가게의 이벤트 내용들이 담길 변수

        /*3. 안쪽 반복문에서는 한 가게의 이벤트 정보들을 eventInfo에 담아준다.*/
        for (Event event : eventList) {
            eventInfo.add(event.getInfo());
        }

        EventListDto eventListDto = new EventListDto(store.getId(), store.getName(), eventInfo);
        result.add(eventListDto);
    }

    return response.success(result, "기존 이벤트 정보", HttpStatus.OK);
}
@AllArgsConstructor
@Getter
public class EventListDto {
    Long storeId;

    String storeName;

    List<String> eventList = new ArrayList<>();
}

  우선 페이지에 처음 들어갔을 때 기존 이벤트 정보들을 가져와줘야 한다. jwt 토큰에 저장된 요청 보낸 회원의 id를 가져와서 이를 이용해 해당하는 멤버 객체를 가져온 후 Stores 필드를 가져온다. 해당 필드에는 사장님의 가게 정보들이 리스트 형식으로 들어있다. 그리고 dto를 통해 반환해야 하므로 반환 객체인 EventListDto 리스트 변수 results를 초기화해준다.

  이제 stores 내부의 store을 하나씩 꺼내며 반복문을 돌리며 EventListDto로 가공해준 뒤 results에 추가해준다. 내부 반복문에서는 해당 가게의 이벤트 목록을 하나씩 뽑아서 문자열 리스트 eventInfo에 넣어준다.

이벤트 추가

fig 3-2

@PostMapping("/add/self-event")
public ResponseEntity<?> addEvent(@RequestBody Map<String, String> param, Principal principal) {
    Long storeId = Long.valueOf(param.get("storeId"));
    String info = param.get("info");

    return storeService.addEvent(storeId, info, principal);
}

이벤트를 추가하는 서비스 코드이다. 가게 id와 이벤트 정보만 받으면 되기 때문에 따로 dto를 사용하지 않고 @RequestBody를 이용하여 Map형식으로 요청 데이터를 받았다. 

public ResponseEntity<?> addEvent(Long storeId, String info, Principal principal) {
    Store store = storeRepository.findById(storeId).get();

    Event newEvent = Event.builder().store(store).info(info).build();
    eventRepository.save(newEvent);

    log.info("이벤트 갯수 표시: {}", store.getEventList().size());
    if (store.getEventList().size() == 1) {
        store.changeEventStatus(true);
    }
    store.getEventList().add(newEvent);
    storeRepository.save(store);

    return response.success("", "성공적으로 이벤트가 추가되었습니다.", HttpStatus.OK);
}

  그 다음은 매개변수들을 사용하여 이벤트 객체를 생성한 뒤, store의 eventList와 DB에 추가해주면 된다. 만약 이벤트 개수가 0 -> 1로 바뀐다면 이벤트 진행 여부를 판단하는 privateEvent 변수를 true로 바꿔준다.

이벤트 수정

@PostMapping("/edit/self-event")
public ResponseEntity<?> editEvent(@RequestBody Map<String, String> param, Principal principal) {
    Long storeId = Long.valueOf(param.get("storeId"));
    Integer eventNum = Integer.valueOf(param.get("eventNum"));
    String newInfo = param.get("newInfo");

    return storeService.editEvent(storeId, eventNum, newInfo);
}

  몇 번째 이벤트를 수정해야 하는지 알아야 하므로 eventNum이라는 값을 받는다. 지금 생각해보면 DB에 저장되는 이벤트 id를 통해 이벤트 객체를 찾아야 했는데, 여기서는 페이지에서 몇번째로 표시되는 이벤트인지에 따라 이벤트를 구별하고 있다.

public ResponseEntity<?> editEvent(Long storeId, int eventNum, String newInfo) {
    Store store = storeRepository.findById(storeId).get();
    Event event = store.getEventList().get(eventNum);
    event.changeInfo(newInfo);

    eventRepository.save(event);

    return response.success();
}

  이벤트를 수정하는 과정은 매우 간단하다. 요청한 가게를 찾은 뒤에 해당 이벤트 내용을 수정해주고 이것을 다시 저장하면 된다.

이벤트 삭제

@PostMapping("/delete/self-event")
public ResponseEntity<?> deleteEvent(@RequestBody Map<String, String> param, Principal principal) {
    Long storeId = Long.valueOf(param.get("storeId"));
    Integer eventNum = Integer.valueOf(param.get("eventNum"));

    return storeService.deleteEvent(storeId, eventNum, principal);
}

  여기서도 이벤트를 구별하는 방법은 수정 파트와 동일하다.

public ResponseEntity<?> deleteEvent(Long storeId, int eventNum, Principal principal) {
    Long ownerId = Long.valueOf(principal.getName());
    Store store = storeRepository.findById(storeId).get();
    Event event = store.getEventList().get(eventNum);

    store.getEventList().remove(event);
    log.info("이벤트 갯수 표시: {}", store.getEventList().size());
    if (store.getEventList().size() == 0) {
        store.changeEventStatus(false);
    }
    storeRepository.save(store);

    eventRepository.delete(event);

    return response.success(null, "성공적으로 삭제되었습니다", HttpStatus.OK);
}

  가게와 이벤트를 특정한 뒤에 해당 이벤트를 가게의 eventList에서 삭제하고 DB에서도 삭제한다. 여기서는 해당 가게의 이벤트의 개수가 1 -> 0이 되었을 때, store의 privateEvent를 false로 바꿔준다.

댓글