프로젝트

Spring - 일정관리 앱 만들기

haseung22 2024. 10. 4. 13:00

저번주 월요일에 일정관리 앱을 만드는 과제를 받았다. 과제에 집중하느라 블로그 작성이 소홀해졌다...ㅠ

이번 과제에서 구현해야할 기능들은 아래와 같다.

 

  1. 일정 생성 및 조회
  2. 일정 수정
  3. 일정 삭제
  4. 일정 테이블과 작성자 테이블의 연관관계 설정
  5.  페이지네이션
  6.  API 명세서 작성
  7.  ERD 작성

이렇게 크게 5가지이다.거의 처음하다시피하는 스프링으로 하다보니 생성 조회부터 막혔었다....그래도 과제 제출일인 오늘까지 꾸역꾸역 다 구현해서 다행인것같다. 

API 명세서와 erd는 시작하기 전에 작성해보고 개발을 했지만 API 명세서 같은 경우는 굉장히

많이 추가됐다.

 

개발 전 쓴 API 명세서

처음 작성하는것이다보니 그냥 예시에  나와있는 것과 굉장히 유사하게 작성했었다.. 

솔직히 이정도면 굴러가겠구나 싶기도했지만 크나큰 착각이였다

 

 

모두 마친 후 API 명세서

다 끝마친 후에는 포스트맨을 이용하여 API 명세서를 만들어봤다.페이지네이션을 하기 위해 전체 행을 뽑아오는 코드들이 추가되면서 api 명세서에도 추가해야할 것들이 많아졌다.

https://documenter.getpostman.com/view/21774594/2sAXxLCuf3

 

일정관리 과제

The Postman Documenter generates and maintains beautiful, live documentation for your collections. Never worry about maintaining API documentation again.

documenter.getpostman.com

 

이번 프로젝트의 전체적인 흐름은 클라이언트의 요청이 들어오면 ajax를 이용하여 Controller으로 요청을 보내고

Controller -> service -> repository -> serivce -> Controller -> 응답의 흐름으로 개발했다.

repository에서는 Db에서 값을 꺼내거나 저장시키는 역할을 담당하였고,

service 단에서 requestDTO 객체를 entity 객체로 변환해서 repository에 보내 값을 받아 온 후,

다시 responseDTO 객체로 변환하여 사용자에게 요청에 대한 응답을 보냈다.

 

이제 생성부터 시작해보자

일정 생성

클라이언트가 일정 등록버튼을 누르면 ajax를 통해 유효성 검사 후

받은 이메일로 User 테이블의 그 이메일이 존재하는지 확인 한 후 없다면, user테이블에 작성자를 생성해 준 뒤,

그 user_id를 가져와서 Controller로 일정 생성 요청을 보낼 때 같이 보낸다.

만약 같은 이메일이 user 테이블에 존재한다면 그 userd의 user_id를 가져와서

Controller로 일정 생성 요청을 보낼 때 같이 보낸다.

function writeSchedule(){
     let username = $('#username').val();
     let user_email = $('#user_email').val();
     let password = $('#password').val();
     let title = $('#title').val();
     let content = $('#content').val();
     let date = $('#date').val();
     let start_date = date.substring(0,10);
     let end_date = date.substring(13);

     if(!inputCheck(username,password,title,content)) return;
     if(emailChecked(user_email) === 0){
         alert("이메일을 입력해주세요");
         return;
     }
      if(emailChecked(user_email) === 1){
          alert("이메일 형식에 맞게 입력해주세요");
          return;
      }

     let data = {
          'user_email':user_email
      };

      $.ajax({
      // user테이블의 작성자에게 입력받은 이메일이 존재하는지 확인한다.
          type : 'POST',
          url : "/user/checking",
          dataType : 'json',
          data : data,
          success : function (response){
               let user_id = response.user_id;
               // 넘겨받은 id가 0이 아니라는 것은
               // user 테이블에 같은 이메일이 존재한다는 것이므로
               // controller에 보낼 때 이 user_id도 같이 보낸다.
               if(user_id !== 0){

                   let data = {
                       'user_id' : user_id,
                       'username': username,
                       'password': password,
                       'title'   : title,
                       'content': content,
                       'start_date' : start_date,
                       'end_date' : end_date
                   };

                   $.ajax({
                       type : 'POST',
                       url : '/scheduler',
                       contentType : 'application/json',
                       data : JSON.stringify(data),
                       success : function(response){
                           alert('일정 등록 완료 ');
                           location.href = '/';
                       }
                   });
               }
               // 넘겨받은 id값이 0이라면 존재하지 않는다는 것이기 때문에
               // 먼저 user테이블에 email과 username을 저장시키고 그 저장한
               // user_id를 반환 받아서
               // controller에 같이 넘겨준다.
               else{

                   let data = {
                       'user_email': user_email,
                       'username': username
                   };

                   $.ajax({
                       type: 'POST',
                       url: '/user/create',
                       data: JSON.stringify(data),
                       contentType : 'application/json',
                       dataType: 'json',
                       success: function (response) {
                           let user_id = response.user_id;
                           let data = {
                               'user_id': user_id,
                               'username': username,
                               'password': password,
                               'title': title,
                               'content': content,
                               'start_date': start_date,
                               'end_date': end_date
                           };
                           $.ajax({
                               type: 'POST',
                               url: '/scheduler',
                               contentType: 'application/json',
                               data: JSON.stringify(data),
                               success: function (response) {
                                   alert('일정 등록 완료 ');
                                   location.href = '/';
                               }
                           });
                       }
                   });
               }
           }
      });
  }
  
   function inputCheck(name, password, title, content) {
      if (name == '') {
          alert("이름을 입력해주세요");
          return false;
      }
      if (password == '') {
          alert("비밀번호를 입력해주세요");
          return false;
      }
      if (title == '') {
          alert("제목을 입력해주세요");
          return false;
      }
      if (content == '') {
          alert("할일을 입력해주세요");
          return false;
      }
      return true;
  }

 

일정 조회

일정 조회는 전체 조회, 작성자 및 수정일로 검색 조회,

사용자의 이메일을 입력받아 사용자가 쓴 게시글만 보여주는 조회 

크게 이렇게 3개로 나뉘지만,  너무 길어질것같아서 전체조회만 쓰려고한다.

이 3개의 조회 모두 페이징 처리를 위해 조회하기전에 먼저 행의 수를 구하는 동작부터 수행한다.

 

먼저 페이지네이션을 위해 만든 페이지 클래스부터 보자.

페이지네이션은 다른 분들의 블로그를 참고하여 만들었다.

package com.sparta.scheduler.page;


import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;


@Getter
@Setter
@NoArgsConstructor
public class Page {
    /* 현재 페이지 */
    private int pageNum;

	// 전체 행의 수
    private int total;

	// 전체 페이지 수
    private int totalPages;

	// 시작 페이지 번호
    private int startPage;

	// 마지막 페이지 번호
    private int endPage;

	// 페이징의 개수
    private int pagingCount;


	
	// size : 한 화면에 보여질 행의 수
    public Page(int total, int pageNum, int size, int pagingCount) {
        this.total = total;
        this.pageNum = pageNum;
        this.pagingCount = pagingCount;

		// select 결과가 없다면 모두 0으로 초기화
        if(total == 0){
            totalPages = 0;
            startPage = 0;
            endPage = 0;
        }
        // select 결과가 있다면
        // 전체 페이지 수 구하기
        // 정수와 정수의 나눗셈의 결과는 정수이므로
        else{
            totalPages = total / size;
            // 나머지가 0보다 클 경우 전체 페이지 수를 1증가
            if(total % size > 0){
                totalPages++;
            }

			// startPage : 'prev [1] [2] [3] [4] [5]... next' 일때 1을 의미
            // 			현재 페이지 / 페이징의 개수 * 페이징의 개수 + 1;
            startPage = pageNum / pagingCount * pagingCount + 1;
			// 			현재 페이지 % 10 == 0 일때
            if(pageNum % pagingCount == 0){
            	// startPage = startPage - 10;
                startPage -= pagingCount;
            }

			// endpage : 'prev [1] [2] [3] [4] [5]... next' 일때 5를 의미
            endPage = startPage + pagingCount - 1 ;
			
            // endPage > totalPages일때는 전체 페이지의 개수를 넘은것 이기에
            // endPage에 전체 페이지수를 대입
            if(endPage > totalPages){
                endPage = totalPages;
            }
        }
    }

}
 function getPaging(num,total){
 		// 맨 처음 호출 할때 num은 1, total은 0이 넘어가도록 설정해두었다.
        let pageNum = {
            'pageNum': num,
            'total' : total
        };
        $.ajax({
            type : 'GET',
            url : '/scheduler/page',
            dataType : 'json',
            data: pageNum,
            success : function (response){
            // 페이징 처리를 하기 위하여 만들어뒀던 Page 객체를 받아
            // 버튼들을 생성하고 난 뒤,
            // pageNum을 보내주며 게시글을 불러온다.
                $('#paginationUl').empty();
                console.log(response.total)
                let startPage = response.startPage;
                let pageNum = response.pageNum;
                let totalPages = response.totalPages;
                let endPage = response.endPage;
                addBtnHtml(startPage,pageNum,totalPages,endPage);
                getSchedules(pageNum);
            }
        });
    }
    
    // pageNum을 같이 보내주는 이유는 
    // pageNum의 값으로 repository단에서 limit을 이용하여 정보들을 뽑아올 것 이기
    // 때문이다.
    // 만약 pageNum이 2라는 것은 2페이지의 정보를 불러오라는 것이기에
    // service단에서 2 *= 10을하여 20을 만들고
    // 그 20의 값을 레퍼지토리에 넘기며 정보를 받아올 때 limit 20, 10으로 받아온다.
    // 여기서 뒤의 20, 10은 20행부터 10개만 가져오라는 뜻이다.
    function getSchedules(num) {
        $('#tbody-box').empty();
        let data = {'pageNum': num};
        $.ajax({
            type: 'GET',
            url: '/scheduler/paging',
            dataType: 'json',
            data: data,
            success: function (response) {
                for (let i = 0; i < response.length; i++) {
                    let schedules = response[i];
                    let schedule_id = schedules['schedule_id'];
                    let username = schedules['username'];
                    let title = schedules['title'];
                    let modification_date = schedules['modification_date'];
                    indexAndMyScheduleViewAddHTML(schedule_id, username, title, modification_date);
                }
            }
        })
    }

 

일정 수정

먼저 비밀번호를 입력받고 비밀번호와 게시글의 schedule_id을 가져가서 유무 확인 후 

비밀번호가 맞다면 수정 폼을 보여주고 아니라면 비밀번호가 틀리다는 alert창을 띄워준다.

이후 사용자가 수정확인 버튼을 클릭하면 수정을 해주고 취소 클릭한다면 게시글 뷰로 돌려보낸다.

function checking(){
        let password = $('#password').val();
        let param = `schedule_id=${schedule_id}&password=`+password;
        if(password === ''){
            alert("비밀번호를 입력해주세요.");
            return;
        }
        $.ajax({
           type : 'POST',
           url : '/passwordCheck',
           data : param,
           dataType : 'json',
           success : function(data){
           // 비밀번호 입력 인풋을 hidden style 부여된 hidden 클래스를 추가해준다.
               $('#input-content').attr('class' ,"hidden");
               // 감춰뒀던 input을 보여주기위해 removeAttr로 hidden 클래스 요소를 지워준다.
               $('#appendContainer').removeAttr('class');
           },
           error : function(){
               alert("비밀번호를 확인해주세요");
           }
        });
    }
    
     function modifySchedule(){
        let username = $('#username').val();
        let content = $('#content').val();
        let title = $('#title').val();
        let date = $('#date').val();
        let start_date = date.substring(0,10);
        let end_date = date.substring(13);

        let data = {
            'username':username,
            'title':title,
            'content':content ,
            'start_date':start_date,
            'end_date':end_date
        };
        if(username === ''){
            alert("이름을 제대로 입력해주세요");
            return;
        }
        if(content === ''){
            alert("일정을 제대로 입력해주세요");
            return;
        }
        $.ajax({
           type : 'PUT',
           url : `/scheduler/${schedule_id}`,
           contentType : 'application/json',
           data : JSON.stringify(data),
           success : function(){
               alert('수정 완료!');
               $('#form').submit();
           },
           error : function (){
               alert("업데이트 오류 발생");
           }
        });
    }

 

일정 삭제

일정 수정과 마찬가지로 비밀번호를 먼저 입력받고 비밀번호가 일치한지 확인 후 일치하다면 삭제해준다.

function deleteSchedule(){
      let schedule_id = $('#schedule_id').val();
      let password = $('#password').val();
      let param = `schedule_id=${schedule_id}&password=`+password;
      if(password === ''){
        alert("비밀번호를 입력해주세요");
        return;
      }
      if(confirm("게시글을 정말 삭제하시겠습니까 ?")){
          $.ajax({
            type : 'POST',
            url : '/passwordCheck',
            dataType : 'json',
            contentType : 'application/x-www-form-urlencoded',
            data : param,
            success : function(data){
                $.ajax({
                  type : 'DELETE',
                  url : `/scheduler/${schedule_id}`,
                  success : function(){
                    alert("삭제 완료 !");
                    location.href = "/";
                  }
                });
            },
            error : function(data,error){
              alert('비밀번호를 확인해주세요');
            }
          });
       }
    }

 

SchedulerRepository

DB와 직접적인 소통을 하는 클래스이다.

@Repository
public class SchedulerRepository {
    private final JdbcTemplate jdbcTemplate;

    public SchedulerRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

	// 일정 추가
    public Scheduler insert(Scheduler scheduler) {
        KeyHolder keyHolder = new GeneratedKeyHolder();

        String sql = "INSERT INTO schedules(user_id,username,password,title,content,start_date,end_date) VALUES (?,?,?,?,?,?,?)";
        jdbcTemplate.update(con -> {
            PreparedStatement ps = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
            ps.setLong(1, scheduler.getUser_id());
            ps.setString(2, scheduler.getUsername());
            ps.setString(3, scheduler.getPassword());
            ps.setString(4, scheduler.getTitle());
            ps.setString(5, scheduler.getContent());
            ps.setString(6, scheduler.getStart_date());
            ps.setString(7, scheduler.getEnd_date());
            return ps;
        },keyHolder);

        scheduler.setSchedule_id(keyHolder.getKey().longValue());
        return scheduler;
    }
	
    // 전체 일정 조회
    public List<Scheduler> selectAll() {
        String sql = "SELECT * FROM schedules order by schedule_id desc";

        return jdbcTemplate.query(sql, new RowMapper<Scheduler>() {
            public Scheduler mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Scheduler(rs);
            }
        });
    }

	// 일정 아이디로 한가지의 게시글만 조회
    public Scheduler findById(Long schedule_id) {
        String sql = "SELECT * FROM schedules WHERE schedule_id=?";
        return jdbcTemplate.query(sql, resultSet->{
            if(resultSet.next()) {
                return new Scheduler(resultSet);
            }
            else{
                return null;
            }
        }, schedule_id);
    }

	// 일정 삭제
    public Long delete(Long schedule_id) {
        String query = "DELETE FROM schedules WHERE schedule_id = ?";
        jdbcTemplate.update(query, schedule_id);
        return schedule_id;
    }

	// password 확인
    public Long checkPassword(Long schedule_id, String password){
        Connection connection;
        PreparedStatement ps;
        String query = "SELECT * FROM schedules WHERE schedule_id = ? AND password = ?";
        try {
            connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/scheduler","root","dksdkffiwna0~!");
            ps = connection.prepareStatement(query);
            ps.setLong(1, schedule_id);
            ps.setString(2, password);
            ResultSet rs = ps.executeQuery();
            if(rs.next()) {
                return rs.getLong("schedule_id");
            }else{
                return null;
            }
        } catch (SQLException e) {
            throw new RuntimeException("checkPassword - 런타임 에러");
        }
    }

	// 일정 수정
    public Long update(Long schedule_id, Scheduler scheduler) {
        String query = "update schedules set username = ?,title = ?, content  = ?, start_date = ?, end_date = ? where schedule_id = ?";
        jdbcTemplate.update(query, scheduler.getUsername(),scheduler.getTitle(), scheduler.getContent(), scheduler.getStart_date(), scheduler.getEnd_date(),schedule_id);
        return schedule_id;
    }

	// 수정일로만 검색했을 때
    public List<Scheduler> selectSearchModifyDate(String date, int pageNum) {
        String query = "select * from schedules where DATE_FORMAT(modification_date,'%Y-%m-%d') = ? order by modification_date desc limit ? , 10";
        return jdbcTemplate.query(query,new RowMapper<Scheduler>(){
            @Override
            public Scheduler mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Scheduler(rs);
            }
        },date,pageNum);
    }

	// 수정일로만 검색했을 때 나오는 결과값의 토탈 수를 구하기 위한 메서드
    public List<Scheduler> selectSearchModifyDateLength(String date) {
        String query = "select * from schedules where DATE_FORMAT(modification_date,'%Y-%m-%d') = ? order by modification_date desc";
        return jdbcTemplate.query(query,new RowMapper<Scheduler>(){
            @Override
            public Scheduler mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Scheduler(rs);
            }
        },date);
    }


	// 작성자명으로만 검색했을 때
    public List<Scheduler> selectSearchAuthor(String username, int pageNum) {
        String query = "select * from schedules where username = ?  order by modification_date desc limit ? , 10";
        return jdbcTemplate.query(query,new RowMapper<Scheduler>(){
            @Override
            public Scheduler mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Scheduler(rs);
            }
        },username,pageNum);
    }

	// 작성자명으로만 검색했을 때 결과값의 토탈 수를 구하기위한 메서드
    public List<Scheduler> selectSearchAuthorLength(String username) {
        String query = "select * from schedules where username = ?  order by modification_date desc";
        return jdbcTemplate.query(query,new RowMapper<Scheduler>(){
            @Override
            public Scheduler mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Scheduler(rs);
            }
        },username);
    }

	// 수정일, 작성자명 둘 다 검색
    public List<Scheduler> selectSearchAll(String username, String date, int pageNum) {
        String query = "select * from schedules where username = ? and DATE_FORMAT(modification_date,'%Y-%m-%d') = ? order by  modification_date desc limit ?,?";
        return jdbcTemplate.query(query,new RowMapper<Scheduler>(){
            @Override
            public Scheduler mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Scheduler(rs);
            }
        },username,date,pageNum,10);
    }
    
    // 수정일, 작성자명 둘 다 검색했을 때 결과값의 토탈 수를 구하는 메서드
    public List<Scheduler> selectSearchAllLength(String username, String date) {
        String query = "select * from schedules where username = ? and DATE_FORMAT(modification_date,'%Y-%m-%d') = ? order by  modification_date desc";
        return jdbcTemplate.query(query, new RowMapper<Scheduler>() {
            @Override
            public Scheduler mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Scheduler(rs);
            }
        }, username, date);
    }


	// 전체 글을 페이징처리하며 뽑아낸다.
    public List<Scheduler> selectAllByPaging(int pageNum) {
        String query = "select * from schedules order by modification_date desc limit ?,? ";
        return jdbcTemplate.query(query, new RowMapper<Scheduler>(){
            public Scheduler mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Scheduler(rs);
            }
        },pageNum,10);
    }

 	// 내가 작성한 글 리스트들을 반환
    public List<Scheduler> selectByMySchedule(Long user_id, int pageNum) {
        String query = "select * from schedules where user_id = ? order by modification_date desc limit ?,10";
        return jdbcTemplate.query(query, new RowMapper<Scheduler>(){
            @Override
            public Scheduler mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Scheduler(rs);
            }
        }, user_id,pageNum);
    }
	
    // 내가 작성한 글 리스트들의 토탈 수를 반환해주기 위한 메서드
    public List<Scheduler> selectByMyScheduleCnt(Long user_id) {
        String query = "select * from schedules where user_id = ? order by modification_date desc";
        return jdbcTemplate.query(query, new RowMapper<Scheduler>(){
            @Override
            public Scheduler mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Scheduler(rs);
            }
        }, user_id);
    }
}

 

게시판 메인 화면 이미지

Controller과 Service까지 넣기에는 너무 길어질 것 같아서 깃헙 주소를 남긴다.https://github.com/HaSeung2/schedules

 

GitHub - HaSeung2/schedules

Contribute to HaSeung2/schedules development by creating an account on GitHub.

github.com

 

회고

이번 과제를 진행하며 수많은 트러블이 있었지만 모두 구글링을 통하여 해결은 하였지만,

과제 기능을 다 구현해보기 위해 집중하느라 트러블을 바로바로 정리하지 못 한것이 가장 아쉽다.

트러블 슈팅이 습관화되면 나중에도 내가 뭘 몰랐는지, 어떻게 해결하였는지를 내가 쓴 트러블 슈팅을 보고

복기할 수 있었을텐데 이번엔 거의 처음 접해보는 것들을 많이 써보고 그것들을 이용하여 과제를 진행해야 했기에

트러블 슈팅을 제쳐두게되었는데, 다음부터는 글을 쓰진 못해도 뭘 몰랐는지, 어떤 문제가 발생하였는지 

메모라도 해봐야겠다. 그렇게 메모하는 습관이 들다보면 자연스레 트러블 슈팅이 습관화 되지않을까 기대해본다.