프로젝트 Git Hub 링크

개발 기간 : 2024-08-21 ~ 진행중


8월 21일 수

오늘은 ERD 수정을 시작으로

  1. 일정 CRU
  2. 댓글 CRUD
  3. 일정 전체 출력 페이징
  4. 일정 D
  5. 유저 CRUD 까지 진행했다.

ERD

ERD 작성부터 큰 고난이 있었다. 지금 진행되는 프로젝트는 2개 이상의 테이블이 서로 1:N 혹은 N:M 관계를 맺게 되어있다. 우선 처음 발생한 오류는 1:N 상황에서 1인 테이블에 외래키를 설정한것. -> N의 입장인 테이블에 설정해야한다. 지금 프로젝트를 예로 들면 1개의 Event에 N개의 댓글이 달린다. 그러면 댓글 table에 Event ID라는 FK를 만들어줘야 한다.

두번째 오류는 N:M을 바로 연관한것이다. 무조건 중간 테이블을 생성해서 1:N, 1:M 관계로 관리해야한다. 어차피 JPA도 중간 테이블을 임의로 만들어서 관리하기 때문에, 개발자가 직접 하는게 더 좋다.

무튼 ERD는 ReadMe에 있다.


일정 CRU


기본적으로 모든 구현은 3 Layer architecture로 구성된다. 우선 JPA를 사용하기 때문에 JDBC를 사용한 전 프로젝트와는 달리 build.gradle에 implementation ‘org.springframework.boot:spring-boot-starter-data-jpa’를 사용해주면 된다. 이후 Repository를 interface로 생성하고 expends JpaRepository<엔티티 타입, 키 타입>으로 만들고 사용하면 된다. CRUD중 Update를 제외한 모든 구현이 JpaRepository내부에 되어있기 때문에 CRD는 구현이 JDBC때랑 똑같다.

JPA덕분에 모든 비즈니스 로직은 Service에서 이뤄지게 되었고, save, delete, findById 등등은 내장 메소드를 사용하면 된다. 단, Update는 구현되어있지 않기 때문에 조금 다르게 접근해야한다. 우선 Update메소드를 Entity 내부 메소드로 생성하고, 이용하면 되는데, 이때 @Transactional을 사용해야한다. 이 어노테이션을 사용하면 해당 메소드가 하나의 트랜잭션으로 취급되어 메소드 종료시 데이터베이스에 변경으로 커밋된다. 즉, 변경 대상 Event의 id를 통해서 해당 인스턴스에 접근하여 update()하고 종료하면 자동으로 데이터베이스에 업로드된다.


댓글 CRUD


댓글로 일정과 동일하게 동작하지만 지금까지의 엔티티들과 다르게, 댓글은 일정의 id를 외래키로 소지하고 있다. 또한 본 프로젝트의 엔티티는 전부 양방향 관계 이므로 일정이 존재해야지만 댓글을 생성할 수 있고, 댓글이 삭제되면 일정에서도 삭제되어야 한다. 하지만 일정 테이블에 댓글 관련 column이 없다. 이 경우 Event 엔티티 내부에

@OneToMany(mappedBy = "event")
private List<Comment> commentList = new ArrayList<>();

를 통해서 일정 엔티티가 댓글 엔티티를 가지고 있다는걸 알려야한다. 이래야지 1개의 일정에 여러 댓글이 있다는걸 JVM이 알 수 있기 때문

또한 사실 댓글이 삭제되어도 일정에는 영향이 없기 때문에 댓글 CRUD도 하던대로 하면 된다.


일정 전체 출력 페이징


페이징부터 JPA의 진가가 발휘된다. 페이징은 Spring Data JPA가 제공하는 Pageable과 Page인터페이스를 사용하면 된다. 우선 페이징을 위해선 필수적으로 표시할 페이지와 페이지당 포함한 데이터 개수를 알려줘야 한다. 본 프로젝트에서는 이러한 정보를 쿼리 파라미터로 받기 때문에 다음과 같이 받는다.

@GetMapping()
public List<PageResponseDto> printEvents(
@RequestParam(value = "page") Integer page,
@RequestParam(value = "size", defaultValue = "10") Integer size) {
return eventSerivce.printEvents(page, size);
}

이를 통해 Service에서 페이지네이션을 진행한다.

public List<PageResponseDto> printEvents(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("modifiedAt").descending());
return eventRepository.findAll(pageable)
.map(event -> new PageResponseDto(
event.getTitle(),
event.getContent(),
event.getCommentList().size(),
event.getCreatedAt(),
event.getModifiedAt(),
event.getUsername()
))
.getContent();
}

위 코드를 보면 Pageable pageable = PageRequest.of(page, size, Sort.by(“modifiedAt”).descending()); 부분부터 보이는데, Pageable은 JPA에서 제공하는 인터페이스이고, page, size, 정렬 순서 등을 매개변수로 넣을 수 있다. 즉, 나는 지금 어느 페이지를 보여달라고 요청중인데, 한 페이지당 size만큼의 일정이 수록되고, 수정일 기준으로 내림차순 해달라는 것이다. 이후 pageable 조건에 맞춰서 eventRepository에서 일정을 찾는데, 이때 요구사항에 따라 .map()으로 해당 데이터들을 넣어서 .getContent()를 통해 List로 반환하고 있다.


일정 D


일정 삭제는 List를 통해서 댓글이 일정에 묶여있기 때문에 일정 삭제시 관련 댓글도 전부 삭제해야한다. 이때 사용하는것이 영속성 전이이다. 영속성 전이는 영속 상태의 Entity에 취해지는 작업이 관련 Entity 까지 전파되는 것이다. 영속성 전이로는 저장과 삭제가 수행될 수 있는데, 여기선 삭제만 구현한다.

@OneToMany(mappedBy = "event", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})//영속성 전이로 한번에 댓글 다 삭제
private List<Comment> commentList = new ArrayList<>();

@관계 어노테이션의 파라미터인 cascade로 지정이 가능한데, CascadeType.PERSIST가 영속 상태를 의미하고 CascadeType.REMOVE로 삭제시 영속성 전이된다는걸 알린다. 이렇게 설정하면 Service단에서는 그냥 repository.delete(event)로 해당 이벤트를 지우면 자동으로 댓글까지 다 삭제된다.


유저 CRUD


유저 테이블이 추가되는 부분이다. 이때부터 슬슬 관계들이 복잡해진다. ERD를 보면 유저는 일정과의 중간테이블과 댓글과 연관이 있다. 이때, 한명의 유저가 댓글을 여러개 작성하고, 한명의 유저가 여러개의 일정을 작성(애매함)하는걸 보면 D의 경우 영속성 전이를 사용해야한다는걸 유추할 수 있다. 무튼 이걸 신경써서 CRUD를 구현하면 눈물나는 중간 테이블을 이용해 일정과 매칭하기 가 있다.

한명의 유저가 여러개의 일정을 작성.. 에서 애매하다고 했던 이유는, 일정을 작성한 유저는 추가로 일정 담당 유저를 배치할 수 있다는 요구사항이 있기 때문이다. 이때문에 일정과 유저는 N:M관계가 되고, 이는 곧 중간 테이블 생성을 요구하게 된다. 나의 경우 유저가 일정을 포스트 한다고 생각해서 Post 테이블을 생성했고, Post 테이블은 PK인 PostId와 FK인 UserId, EventId를 갖는다.

이 테이블을 이용해서 연관관계를 맺는건 다음과 같다.

public void setUserToEvent(Long eventId, Long userId) {
Event setEvent = findEvent(eventId);
User setUser = userRepository.findById(userId).orElseThrow(() -> new IllegalArgumentException("해당 유저는 없습니다."));

    Post post = new Post();
    post.setEvent(setEvent);
    post.setUser(setUser);
    postRepository.save(post);

}

이렇게 설정할 Event와 User를 찾아서 Post에 넣으면 끝이다.


이렇게 정리하다 보니까 좀 아쉬운게 많이 보이는데, 프로젝트 마감이 다음주 목요일이기 때문에 우선 남은 추가 요구사항을 구현하고, 리팩토링을 진행할 예정이다. 우선 지금 아쉬운건 event 생성시 존재하지 않는 user id를 넣어도 생성이 된다는점. User - Event 관계를 수동으로 맺어야한다는 점. 등등 이 있다.


8월 22일 목

#일정 조회 개선 — 일정 단건 조회시 일정의 필드에 있는 데이터만 출력을 진행했는데, 본 단계를 통해 기존 데이터 + 담당 유저들의 고유 식별자, 유저명, 이메일이 추가된다. 이를 위해서 ResponseDto 객체를 추가로 생성했고, Service에 printEvent 메소드를 새로 생성했다.

public EventDetailResponseDto printEvent(Long eventId){
Event event = findEvent(eventId);
List<Post> postList = postRepository.findByEventId(eventId);

    List<UserResponseNoTimeDto> userDtos = postList.stream()
            .map(post -> {
                User user = post.getUser();
                return new UserResponseNoTimeDto(user);
            })
            .distinct()
            .collect(Collectors.toList());

    return new EventDetailResponseDto(event, userDtos);

}

우선 단건 조회는 eventId를 이용해서 단일 Event를 찾고, post라는 중간 테이블을 통해서 연관를 맺은 User를 찾기위해 findByEventId라는 쿼리메소드를 postRepository에 생성해서 해당 DB를 통해 해당 이벤트에 연결된 모든 user를 찾는다. 이후 해당 Post 리스트에서 각 User를 추출하고, 중복 제거 후 UserResponseNoTimeDto 리스트로 변환한다. 이후 이벤트 정보와 사용자 정보를 포함하는 EventDetailResponseDto 즉, 출력 Dto에 넣어서 보낸다. 이러면 일정 단건 조회시 3개의 필드가 추가되서 출력된다.

다음은 일정 전체 조회 시 담당 유저 정보가 포함되지 않는다는데, 애초에 ResponseDt로 출력하는데 담당 유저 정보는 안되는게 맞지 않나..? 또한 일정과 담당 유저는 중간 테이블을 중간에 두고 있고 oneToMany라 default가 lazy라서 지연 로딩이라 일당 그대로 뒀다.


#회원가입 — 회원가입을 위해서 유저에 비밀번호 필드를 추가하고, 비밀번호는 PasswordEncoder를 통해 암호화한다. 원래 스트링 시큐리티를 이용해서 사용해도 되는데,

//build.gradle
implementation 'at.favre.lib:bcrypt:0.10.2'
//config.PasswordEncoder
import at.favre.lib.crypto.bcrypt.BCrypt;
import org.springframework.stereotype.Component;

@Component
public class PasswordEncoder {

    public String encode(String rawPassword) {
        return BCrypt.withDefaults().hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
    }

    public boolean matches(String rawPassword, String encodedPassword) {
        BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword);
        return result.verified;
    }

}

로 구현해도 된다.

무튼 저걸 받아오고 Jwt의 기능을 관리하는 JwtUtil을 구현해야한다. JwtUtil은 총 5개의 기능을 지원한다.

  1. JWT생성
  2. JWT Cokkie에 저장
  3. JWT에서 실제 토큰값 추출하기
  4. JWT 검증
  5. JWT에서 사용자 정보 가져오기 를 구현해야하는데, 우선 회원가입 단계에선 1,2기능만 사용한다.

지금까지 회원가입, 즉 Db에 등록할때는 RequestDto를 바로 Entity로 바꿔서 Save했는데, 이제부턴 검증 절차가 필요하다. 검증은 Id, Email처럼 중복이면 안되는 필드를 대상으로 진행하고, 비밀번호는 String password = passwordEncoder.encode(userRequestDto.getPassword()); 를 이용해서 암호화 해야 한다.

이후 User user = new User(username, password,email)를 이용해서 user 인스턴스를 만들고, 저장하면 된다.

근데, 요구사항에 따라서 토큰을 만들어서 쿠키에 넣어서 보내줘야한다.

String token = jwtUtil.createToken(createUser.getId());
jwtUtil.addJwtToCookie(token,res);

#로그인 — 로그인은 JWT를 이용해 구현된다. 로그인은 이메일과 비밀번호를 입력받고 검증을 하는데, userRepository에서 findByEmail를 이용해서 email을 저장한 user를 가져오고, 해당 user의 비밀번호는

if (!passwordEncoder.matches(password, user.getPassword())) {
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}

를 이용해서 비밀번호까지 같으면 이제 토큰을 발급하면 된다. 이후 토큰을 RequestHear에 추가해서 인증처리를 진행하면 되늗네 <- 이걸 안했다. 추후 해야할듯

회원가입과 로그인은 토큰을 얻는 과정이기 때문에 인증처리에서 제외한다. 이건 필터에서

if (StringUtils.hasText(url) &&
(url.startsWith("/users") || url.startsWith("/login"))
) {
// 회원가입, 로그인 관련 API  인증 필요없이 요청 진행
chain.doFilter(request, response); // 다음 Filter  이동

이런식으로 그냥 넘겨버리면 된다.


#권한 확인 — 권한 확인을 원해선 각 유저가 권한을 지녀야하기 때문에 유저 테이블에 권한 필드를 추가한다. 이때, 권한은 사용자가 임의로 정하는 것이 아니고, 서버에서 정해둔 권한만을 소유해야하기 때문에 enum형으로 권한을 등록한다.

enum형으로 선언된 형태는 Entity에 다음과 같이 사용할 수 있다.

@Column(nullable = false)
@Enumerated(value = EnumType.STRING)
private UserRoleEnum role;

이렇게 하면 enum에서 등록한 타입으로 등록된다.

또한, 회원가입에서 권한을 입력받아야 하는데, 이는 권한 비밀 키를 이용해서 관리자 + 옳은 비밀 키를 통해서 관리자 권한을 부여할 수 있고, 또 일반 사용자 권한으로 그냥 가입도 가능하다.


8월 23일 금

#외부 API를 이용해서 날씨 조회하기 — RestTemplate를 사용해서 외부 API에 날짜를 보내서 날씨를 받아오는 기능을 추가해야한다. 이를 위해 Event Entity와 테이블에 날짜 관련 필드를 생성해주고, 날짜는 CreateAt을 사용한다.

우선 RestTemplate는 다른 빈들과는 다르게 생성자에서 restTemplateBuilder를 통해서 build한다.

날씨는 일정 생성시 필드에 저장해야하기 때문에 기존 createEvent 서비스에 기능을 추가했고, 날씨를 받는 메소드를 새로 제작했다.

public String getWeather(LocalDateTime date) {
// 날짜를 "MM-dd" 형식으로 포맷팅
String formattedDate = date.format(DateTimeFormatter.ofPattern("MM-dd"));

    URI uri = UriComponentsBuilder
            .fromUriString("https://f-api.github.io")
            .path("/f-api/weather.json")
            .queryParam("date", formattedDate)
            .encode()
            .build()
            .toUri();

    ResponseEntity<String> responseEntity = restTemplate.getForEntity(uri, String.class);
    String responseBody = responseEntity.getBody();

    // Jackson ObjectMapper 사용하여 JSON 문자열을 객체로 변환
    ObjectMapper objectMapper = new ObjectMapper();
    List<Map<String, String>> weatherData = null;
    try {
        weatherData = objectMapper.readValue(responseBody, new TypeReference<List<Map<String, String>>>() {});
    } catch (JsonProcessingException e) {
        e.printStackTrace();
        // 예외 처리: 로그를 남기거나 기본 값을 반환할  있습니다.
        return "Error processing weather data";
    }

    // 날짜에 맞는 날씨 정보를 찾기
    for (Map<String, String> entry : weatherData) {
        if (entry.get("date").equals(formattedDate)) {
            return entry.get("weather");
        }
    }

    // 날짜에 맞는 날씨 정보가 없는 경우
    return "No weather data available for the specified date";
}

일단 외부 API의 형식이 Date가 MM-dd형식이라 포맷을 해주고, uri는 UriComponentsBuilder를 이용해서 제작한다. 우선 이 부분을 수정을 해야하는데, 저런식으로 했더니 해당 JSON 데이터를 전부 받아버린다. 그래서 방법을 찾기 전까지 확인을 위해서 Jackson ObjectMapper를 이용해서 기능만 구현해뒀다.


#리펙토링 1 Dto 감소 — 우선 Dto가 많아도 너무 많아서 수를 좀 줄여보자. Comment : Response 1, Request 1 Event : Response 2, Request 1 User : Response 2, Request 1 Login : Request 1 Page : Response 1

우선 Page의 경우 부터 확인한다. title, content, commentCount, create_date, update_date, user_id로 구성되어 있다. <- 그냥 EventResponseDto 에 필드 추가하고 사용하면 될듯, 추가할 필드 : commentCount

LogIn의 경우 email, password 를 request로 받는데, UserRequestDto에 전부 있다.

UserResponse Dto는 CreatedAt, ModifiedAt이 있고 없고 차이다. 하나로 통일

EventReponse Dto도 private List users와 wather의 유무 차이다. 통일.


구현 단계별 사용 기술 정리

  • 일정 CRU
    1. 3 lay architecture
    2. JPA
    3. Timestamped (작성일, 수정일)

  • 댓글 CRUD
    1. 3 lay architecture
    2. JPA
    3. 연관관계(@ManyToOne, ~)

  • 일정 페이징 조회
    1. JPA의 Pageable, Page 인터페이스

  • 일정 삭제
    1. 영속성 전이 (연관관계의 cascade={CascadeType.PERSIST, CascadeType.REMOVE}) 옵션

  • 유저 CRUD
    1. 다대다 관계를 중간 테이블을 이용해서 ManyToOne 2개로 분할
    2. 영속성 전이

  • 일정 조회 개선
    1. DTO 수정
    2. 지연 로딩?? <- 내가 구현 안함.. 아직도 뭔지 모르겠음

  • 회원 가입
    1. PasswordEncoder 사용을 통한 비밀번호 암호화
    2. JwtUtil에서 JWT 발급 기능 및 쿠키에 삽입 기능

  • 로그인
    1. 필터를 이용해 Jwt를 검증하여 User 도출후 Controller에 보재구니
    2. PostMan에서 Hear에 토큰 실어 보내기

  • 권한 확인
    1. 권한 구현용 ENUM 클래스 구현
    2. Entity에 ENUM 클래스 적용 ( @Column(nullable = false), @Enumerated(value = EnumType.STRING))
    3. 회원가입시 권한 추가 (관리자 Secret Key)

  • 외부 API 조회
    1. RestTemplate
    2. Jackson ObjectMapper