프로젝트/일정관리

[일정관리앱] 프로젝트 lv2까지의 구현 과정 및 구현 방향

yoney 2025. 1. 27. 10:34

지옥같던  스프링 강의 듣기를 이틀만에 끝내고 드디어 프로젝트 구현에 들어갔다.

jdbc를 안 쓴 지도 오래됐고 jdbc template이란 건 한 번도 안 써봐서 구현 초반에 강의 실습 코드를 많이(아주 많이) 참고하게 되었다 ㅋ..

이래서야 뭐 실력 발전이 될 것 같지가 않아서 코드를 다시 살펴봐야겠다.

 

‼️ 이 프로젝트는 JDBC 기반!!


 

   [목차]

     


    1. 구현 순서


    내가 lv2까지 구현하면서 어떤 순서로 어떻게 구현하는게 편한지 주관적으로 정리하자면

    • 기본 세팅: 한 번 작성해놓으면 해당 주제에 대한 crud 기능 구현 가능
      1. 의존성 설정, db 연결
      2. entity 작성
      3. dto 작성(requestDto, responseDto)
    • 3 Layered architecture 구현 순서(강의를 보면서 굳이 interface를 써야하나 싶었는데 확실히 정리가 되고 가독성이 좋다)
      1. controller(컨트롤러부터 작성하면 메소드들이 구현이 안되어서 오류가 나지만 빨간 줄을 두려워하면 안된다!!)
      2. service - interface-> interface를 implement한 service 클래스
      3. repository - interface-> interface를 implement한 repository 클래스

    2. 구현 과정


    1. entity

    데이터베이스 테이블 구조와 일치하는 클래스이다. 데이터베이스에서 조회한 결과를 매핑하기 위해 사용한다.
    JDBC의 RowMapper를 통해 데이터베이스와 직접적인 상호작용이 이루어지므로 다른 계층에 영향을 주지 않도록 분리하여 사용한다.

     

    schedule.java

    package com.example.schedulemanager.entity;
    
    import lombok.AllArgsConstructor;
    import lombok.Getter;
    
    import java.time.LocalDateTime;
    
    @Getter
    @AllArgsConstructor
    public class Schedule {
        private Long id;
        private String username;
        private String password;
        private String contents;
        private LocalDateTime createdDate;
        private LocalDateTime updatedDate;
    
        public Schedule(String username, String password, String contents) {
            this.username = username;
            this.password = password;
            this.contents = contents;
            this.createdDate = LocalDateTime.now();
            this.updatedDate = LocalDateTime.now();
        }
    }

    이 클래스에는 기능 구현을 위한 모든 필드들을 작성한다.


    2. dto

    requestDto

    클라이언트에서 서버로 데이터 전달시 사용한다. 즉 컨트롤러에서 사용자를 통해 받아와서 처리할 수 있는 데이터.

    body데이터를 가져오는데 사용한다.=>컨트롤러에서 @RequestBody로 사용자가 작성한 requestDto를 가져온다.

     

    responseDto

    서버에서 클라이언트로 데이터 전달 시 사용한다. 데이터를 처리한 결과를 responseDto에 담아 클라이언트에게 반환한다.

     

    dto를 사용함으로써 entity를 직접적으로 노출하지 않을 수 있다. 그리고 비밀번호 같은 민감 데이터를 requestDto로 받으면 db에 저장 하고 responseDto에는 담지 않고 클라이언트에게 반환할 수 있다. 즉 보안 강화

     

    구분 Entity DTO
    역할 데이터베이스의 테이블과 매핑. 계층 간 데이터 전달 및 응답.
    특징 모든 테이블 필드를 포함. 필요한 데이터만 포함.
    사용 위치 데이터베이스 작업 (Repository, DAO)에서 주로 사용. Controller, Service 계층 간 또는 API 응답에서 사용.
    가공 여부 데이터 가공 없이 데이터베이스 그대로 표현. 가공된 데이터 전달.
    의존성 데이터베이스와 직접 연결. 데이터베이스와 독립적.
    민감 데이터 포함 가능 (예: password). 민감 데이터 제거 또는 가공 (예: password 제외).

     

     

    ScheduleRequestDto.java

    package com.example.schedulemanager.dto;
    
    import lombok.Getter;
    
    @Getter
    public class ScheduleRequestDto {
        private String username;
        private String contents;
        private String password;
    }

     

     

    ScheduleResponseDto.java

    package com.example.schedulemanager.dto;
    
    import com.example.schedulemanager.entity.Schedule;
    import lombok.AllArgsConstructor;
    import lombok.Getter;
    
    import java.time.LocalDateTime;
    
    @Getter
    @AllArgsConstructor
    public class ScheduleResponseDto {
        private Long id;
        private String username;
        private String contents;
        private LocalDateTime createdDate;
        private LocalDateTime updatedDate;
    
        public ScheduleResponseDto(Schedule schedule) {
            this.id = schedule.getId();
            this.username = schedule.getUsername();
            this.contents = schedule.getContents();
            this.createdDate = schedule.getCreatedDate();
            this.updatedDate = schedule.getUpdatedDate();
        }
    }

    민감 데이터인 비밀번호를 제외하고 responseDto 생성

     


    3. controller

    아직 기본 구현만 해놓고 리팩토링을 안해서 todo도 남아있고 좀 긴 메서드들도 있다.


    🔍 일정 생성

    @PostMapping
    public ResponseEntity<ScheduleResponseDto> createSchedule(@RequestBody ScheduleRequestDto scheduleRequestDto) {
        return new ResponseEntity<>(scheduleService.saveSchedule(scheduleRequestDto), HttpStatus.CREATED);
    }
    • @PostMapping은 HTTP POST 요청을 처리한다. 기본 경로 /schedules에서 POST 요청을 받는다.
    • @RequestBody는 클라이언트에서 요청 본문에 전달된 JSON 데이터를 ScheduleRequestDto 객체로 변환한다.
    • scheduleService.saveSchedule(scheduleRequestDto)는 새 일정을 저장하고 ScheduleResponseDto를 반환한다.
    • ResponseEntity를 사용해 상태 코드 201 CREATED와 함께 응답한다.

    🔍 일정 목록 조회

    @GetMapping("/find")
    public List<ScheduleResponseDto> findAllSchedule(
            @RequestParam(value = "updatedDate", required = false) String updatedStringDate,
            @RequestParam(value = "username", required = false) String username
    ) {
        LocalDateTime updatedDate = null;
        if (updatedStringDate != null && !updatedStringDate.isEmpty()) {
            updatedDate = LocalDateTime.parse(updatedStringDate + "T00:00:00");
        }
        return scheduleService.findAllSchedule(updatedDate, username);
    }
    • @GetMapping("/find")는 /schedules/find 경로에서 GET 요청을 처리한다.
    • @RequestParam은 URL 쿼리 매개변수 updatedDate와 username을 메서드 매개변수로 매핑한다. 매개변수가 없으면 null로 설정된다. 메서드 매개변수로 받으면 /schedules/find?updatedDate= ~~&username= ~~~ 경로에서 GET 요청을 처리한다.
    • updatedDate는 LocalDateTime 형식으로 변환되며, 쿼리 매개변수가 전달되지 않을 경우 null로 처리된다.
    • scheduleService.findAllSchedule(updatedDate, username)을 호출하여 조건에 맞는 일정 목록을 조회한 후 반환한다.

    그 외 코드는 거의 비슷한 형식 반복이라 생략한다.


    📌 controller에서 기억할 점

    • Service 클래스를 생성자 주입 방식으로 컨트롤러에 연결해서 비즈니스 로직을 처리한다.
    • @RequestMapping("/경로"): 클래스 명 위에 작성해줌으로써 모든 메서드의 기본 경로 설정
    • @RequestBody: body에 작성된 정보를 가져온다. (ex- requestDto)
    • @RequestParam: url 쿼리 정보를 가져온다. 즉 경로에서 "?" 뒤에 있는 파라미터 정보를 가져온다.(ex- /schedules/find?username=홍길동)
    • @PathVariable: "/"뒤의 경로에서 변수를 가져온다. (ex- /schedules/id)
    • 거의 웬만하면 ResponseEntity<responseDto>를 반환하고 return new ResponseEntity<>(responseDto,HttpStatus.~) 형식

    4. service

    마찬가지로 리팩토링 해야 할 부분이 보인다..

    인터페이스인 ScheduleService와 그걸 implement한 ScheduleServiceImpl 클래스로 구분해서 구현했다.


    🔍 추상메서드 선언

    ScheduleResponseDto saveSchedule(ScheduleRequestDto scheduleRequestDto);
    List<ScheduleResponseDto> findAllSchedule(LocalDateTime updatedDate,String username);
    ScheduleResponseDto findScheduleById(Long id);
    ScheduleResponseDto updateSchedule(Long id, ScheduleRequestDto scheduleRequestDto);
    void deleteSchedule(Long id, String password);
    • 인터페이스에서 먼저 추상메서드를 선언한다. 매개변수로 뭘 받아야 할지 정하는 것도 쉽지 않지만 return 타입 정하는 게 더 고민된다🤯

     

    🔍 ID를 통해 단건 일정 조회

       @Override
        public ScheduleResponseDto findScheduleById(Long id) {
            Optional<Schedule> optionalSchedule = getOptionalSchedule(id);
            return new ScheduleResponseDto(optionalSchedule.get());
        }
        
        private Optional<Schedule> getOptionalSchedule(Long id) {
            Optional<Schedule> optionalSchedule = scheduleRepository.findScheduleById(id);
    
            if(optionalSchedule.isEmpty()) {
                throw new ResponseStatusException(HttpStatus.NOT_FOUND,"Does not exist id = " + id);
            }
            return optionalSchedule;
        }
    • 레포지토리에서 아이디로 단건 정보를 가져오는 메소드를 작성하고 Optional로 받아온다.
    • schedule 객체를 생성자에 전달해서 ScheduleResponseDto가 엔티티 데이터 기반으로 리턴된다.

     

    ❓ Optional이란

    null 값을 명시적으로 처리하기 위해 사용한다. null을 직접 다루지 않고 값이 있는 경우와 없는 경우를 구분해서 처리할 수 있다.
    값이 비면 Optional.empty()를 생성한다.
    Optional.get() 메소드는 값을 직접 반환한다. 즉 위 코드에서 Optional<Schedule> optionalSchedule로 선언되어있는데 optionalSchedule.get()을 하면 Schedule 객체를 가져온다.

     

     

    🔍 일정 삭제

        @Override
        public void deleteSchedule(Long id, String password) {
            Schedule schedule = getOptionalSchedule(id).get();
            if(!schedule.getPassword().equals(password)) {
                throw new IllegalArgumentException("비밀번호 불일치");
            }
            if(scheduleRepository.deleteSchedule(id)==0) throw new ResponseStatusException(HttpStatus.NOT_FOUND,"삭제 불가");
        }

     

    • getOptionalSchedule(id).get(): 삭제하려는 일정을 데이터베이스에서 가져온다.
    • 비밀번호 검증: 전달받은 password와 기존 일정의 password가 다르면 예외를 발생시킨다.
    • scheduleRepository.deleteSchedule(id): db에서 일정을 삭제하고 레포지토리에서 삭제된 행의 개수를 반환한다.
    • 삭제된 행의 개수가 0이면 예외를 발생시킨다.

     

     

    나머지는 이걸 응용하거나 간단한 코드라서 생략한다.


    📌 service에서 기억할 점

    • 거의 웬만하면 responseDto를 반환한다.
    • 레포지토리 객체를 주입받아 사용한다.
    • Optional은 null 방지 클래스

    5. repository

    내 기준 제일 어려웠던 레포지토리 작성..

    인터페이스인 ScheduleRepository와 그걸 implement한 JdbcScheduleRepository 클래스로 구분해서 구현했다.


    🔍 jdbc 템플릿 사용과 일정 저장

    private final JdbcTemplate jdbcTemplate;
    
        public JdbcScheduleRepository(DataSource dataSource) {
            this.jdbcTemplate = new JdbcTemplate(dataSource);
        }
    
        @Override
        public ScheduleResponseDto saveSchedule(Schedule schedule) {
            SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
            jdbcInsert.withTableName("schedule").usingGeneratedKeyColumns("id");
    
            Map<String, Object> parameters = new HashMap<>();
            parameters.put("username",schedule.getUsername());
            parameters.put("password",schedule.getPassword());
            parameters.put("contents",schedule.getContents());
            parameters.put("createdDate", Timestamp.valueOf(schedule.getCreatedDate()));
            parameters.put("updatedDate", Timestamp.valueOf(schedule.getUpdatedDate()));
    
            Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
    
            return new ScheduleResponseDto(key.longValue(),schedule.getUsername(),schedule.getContents(),schedule.getCreatedDate(),schedule.getUpdatedDate());
    
        }
    • JdbcTemplate 객체를 선언해서 레포지토리에 객체로 초기화한다.
    • SimpleJdbcInsert: JdbcTemplate의 확장 기능으로, SQL 없이 데이터를 삽입할 수 있다.
    • withTableName: 데이터를 삽입할 테이블 이름 지정.
    • usingGeneratedKeyColumns: 자동으로 생성된 키를 사용하도록 설정.
    • 삽입할 데이터의 컬럼과 값을 매핑하기 위해 parameters 맵을 생성한다.
    • 삽입 작업을 수행하고, 자동 생성된 ID 값을 Number 타입 Key로 반환받는다.

     

    🔍 조건을 통해 일정 조회하기

    @Override
        public List<ScheduleResponseDto> findAllSchedule(LocalDateTime updatedDate,String username) {
            String sql ="select * from schedule where 1=1";
            List<Object> parameters = new ArrayList<>();
    
            // 수정일, 작성자명 기준 조회 조건문
            if(updatedDate!=null) {
                sql += " AND DATE(updated_date) = ?";
                parameters.add(updatedDate);
            }
            if(username != null && !username.isEmpty()) {
                sql += " AND username = ?";
                parameters.add(username);
            }
    
            sql += " order by updated_date desc";
    
            return jdbcTemplate.query(sql, parameters.toArray(),scheduleRowMapper());
    
        }
    • SQL 초기화: 기본적으로 모든 데이터를 조회하는 sql 문을 디폴트로 준다.
    • parameters: 쿼리에 동적으로 조건을 추가하기 위한 리스트.
    • 수정일(updatedDate) 또는 사용자명(username)으로 필터링 조건 추가.
    • 조회 결과를 scheduleRowMapper를 통해 매핑한다.

     

    🔍 RowMapper

    private RowMapper<ScheduleResponseDto> scheduleRowMapper() {
            return new RowMapper<ScheduleResponseDto>() {
                @Override
                public ScheduleResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {
                    return new ScheduleResponseDto(
                            rs.getLong("id"),
                            rs.getString("username"),
                            rs.getString("contents"),
                            rs.getTimestamp("created_date").toLocalDateTime(),
                            rs.getTimestamp("updated_date").toLocalDateTime()
                    );
                }
            };
        }
    • resultSet의 데이터를  ScheduleDto로 매핑한다.

    ❓ RowMapper란

    스프링 JDBC에서 제공하는 인터페이스로, 데이터베이스 쿼리 결과(ResultSet)를 특정 객체로 매핑하는 데 사용한다.

    db 테이블의 행을 자바 객체로 변환해준다.

    @FunctionalInterface
    public interface RowMapper<T> {
        T mapRow(ResultSet rs, int rowNum) throws SQLException;
    }

     

    • T: 변환할 객체의 타입 (예: User, ScheduleResponseDto 등).
    • mapRow:
      • 매개변수:
        • ResultSet rs: 쿼리 결과를 담고 있는 객체.
        • int rowNum: 현재 행의 인덱스 (0부터 시작)
      • 예외: SQLException.
    • RowMapper 동작 과정
      1. 쿼리 실행:
        • SQL 쿼리가 실행되고, 결과가 ResultSet으로 반환된다.
      2. RowMapper 호출:
        • JdbcTemplate은 ResultSet의 각 행에 대해 RowMapper의 mapRow 메서드를 호출한다.
      3. 객체 반환:
        • mapRow 메서드에서 해당 행의 데이터를 추출해 객체로 변환한 후 반환한다.
        • 변환된 객체는 리스트 또는 단일 객체로 정리된다.

     

    ❓ JdbcTemplate이란

    db 연결, statement 생성, resultset처리 등의 작업을 자동으로 처리하고 sql 쿼리를 직접 작성하고 실행하는 sql 중심 접근 방식의 클래스.

    👇🏻 주요 메서드

    더보기

    1. queryForObject

    • 단일 행(single row) 또는 단일 값을 조회할 때 사용된다.
    • 예: 특정 ID의 사용자 조회.
    String sql = "SELECT name FROM users WHERE id = ?";
    String name = jdbcTemplate.queryForObject(sql, String.class, 1);
    • 매개변수:
      • SQL 쿼리
      • 반환할 데이터 타입
      • 쿼리 매개변수(예: ID)
    • 결과: 쿼리 결과에서 하나의 값을 반환.

     

    2. query

    • 여러 행(multiple rows)을 조회하고 RowMapper를 사용해 결과를 객체 리스트로 반환한다.
    String sql = "SELECT id, name, email FROM users";
    List<User> users = jdbcTemplate.query(sql, (rs, rowNum) -> new User(
        rs.getLong("id"),
        rs.getString("name"),
        rs.getString("email")
    ));
    •  매개변수:
      • SQL 쿼리
      • RowMapper 또는 람다 표현식.
    • 결과: 쿼리 결과를 객체 리스트로 반환.

     

    3. update

    • INSERT, UPDATE, DELETE 쿼리를 실행한다.
    • 영향을 받은 행(row)의 수를 반환한다.
    String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
    int rowsAffected = jdbcTemplate.update(sql, "John Doe", "john.doe@example.com");
    • 매개변수:
      • SQL 쿼리
      • 쿼리에 전달할 매개변수(바인딩 값).

     

    4. batchUpdate

    • 여러 행을 일괄 처리(batch update)할 때 사용한다.
    String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
    List<Object[]> batchArgs = List.of(
        new Object[]{"Alice", "alice@example.com"},
        new Object[]{"Bob", "bob@example.com"}
    );
    
    int[] rowsAffected = jdbcTemplate.batchUpdate(sql, batchArgs);

     

    • 매개변수:
      • SQL 쿼리
      • 매개변수 배열 리스트.

     

    5. queryForList

    • 쿼리 결과를 List<Map<String, Object>> 형태로 반환한다.
    • 컬럼 이름을 키로, 값은 컬럼 데이터를 포함하는 맵을 생성한다.
    String sql = "SELECT * FROM users";
    List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql);
    
    for (Map<String, Object> row : rows) {
        System.out.println(row.get("name"));
    }

     

    JdbcTemplate vs JPA


         
    특성 JdbcTemplate JPA
    쿼리 작성 방식 SQL 쿼리 직접 작성 JPQL 또는 메서드 이름 기반 쿼리 생성
    초기 학습 곡선 쉬움 다소 어려움
    유연성 SQL 쿼리 작성으로 높은 유연성 제공 데이터베이스 독립성과 간단한 CRUD 제공
    자동화 수준 낮음 (수동으로 매핑 작업 필요) 엔티티와 테이블 매핑 자동화

     


    필수 요구사항을 모두 구현한 지금 가장 필요한 것은 리팩토링

    3 Layer Architecture 에 따라 각 Layer의 목적에 맞게 개발했는가?

     

    라는 질문에 당당하게 그렇다고 답할 수 있도록 리팩토링해야지

    아좌좟 ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️ ❗️